Compare commits

..
66 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.7 5c4a3fda19 docs(decisions): amend D-224 with #952 review findings
- Pin the Body domain id scheme: body_id(String) -> FNV-1a(64) -> derive(Body, …),
  via SeedChain::for_body. Closes the gap where "keyed by body StableId" named a
  numeric id that doesn't exist (bodies are strings).
- Add for_body to the contract; document the stability guards (SeedDomain
  #[repr(u64)] + pin test, AttractorType #[repr(u8)]).
- Note EntityRng keeps its domainless combine (re-expressing as derive(Npc, …)
  is a stream-changing migration, deferred).
- Correct the implementation note: golden is JSON (matching golden_suite.rs),
  not msgpack; run at 256x128 for a non-empty river network. Fix the stale
  skeleton_gen.rs:248 line reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:24:13 +02:00
jpmschweitzerandClaude Opus 4.7 a61d030b47 refactor(simulation): harden #952 cascade per QA + architecture review
Addresses the Hoshe (QA) + Tyre (architecture) review of the SeedChain/cascade
work:

- Golden was pinning an empty river network (128x64 produced 0 river cells).
  Bumped to 256x128, where GJ1c yields a real network (93 river cells, 19
  mouths) — Layer 1's rivers are now actually guarded, not just attractors.
- SeedChain::for_body(world_seed, body_id) + fnv1a_64: the single canonical
  body_id(String) -> u64 path (FNV-1a, the repo convention), so callers can't
  derive divergent worlds from the same seed via different ad-hoc hashes. The
  golden now uses it.
- Stability guards: seed_domain_discriminants_are_pinned test (CI fails if a
  SeedDomain tag is renumbered); AttractorType gains #[repr(u8)] + explicit
  discriminants (it's cast as a sort key in features.rs).
- Tests: SeedChain::root(0) non-degenerate; run_cascade error path (missing
  file -> Err, not panic).
- Comments: clarified the id=0 derivations (sibling separation is caller-side
  via the per-district/quarter chain; #957 threads the index), tightened the
  all_district_types reachability comment (it pins the seed-0 sequence, not a
  probabilistic claim), and noted the golden's WORLD_SEED is cosmetic at
  Layers 0-1 + the x86_64 f32 capture caveat.

Deferred with reason: the run_cascade -> CascadeInputs struct refactor (Tyre)
is left for #954 — designing Layer-2's context shape now would be later-phase
detail, and there's a single caller to migrate then. EntityRng keeps its
domainless combine (migrating is stream-changing) — noted in D-224.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:24:03 +02:00
jpmschweitzerandClaude Opus 4.7 b4eff847fc chore(meta): update changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:26:40 +02:00
jpmschweitzerandClaude Opus 4.7 aaee9ecb06 test(simulation): golden-seed regression for the Layer 0->1 cascade (#952, D-200)
End-to-end determinism guard. cascade_golden.rs pins two artifacts for a real
committed body heightmap (GJ1c) in one diffable JSON golden
(server/tests/golden/cascade_layer1.json):
- Layer 0: SHA-256 of the source heightmap.png bytes (flips if the Python
  heightmap generator or the file changes)
- Layer 1: the serialized Layer1Output of run_cascade on a 128x64 downsample
  (flips if the Rust drainage/feature/sub-biome code changes)

JSON (not the msgpack discussed in refinement) to match the existing
golden_suite.rs convention and stay diffable — a failure shows what drifted.
UPDATE_GOLDEN=1 regenerates; wired into `make golden-update`.

Adds Serialize/Deserialize to RiverNetwork/DrainageBasin/Layer1Output and a
sha2 dev-dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:26:07 +02:00
jpmschweitzerandClaude Opus 4.7 9bc2ed28f2 feat(simulation): extensible generation cascade harness (#952, D-200)
server/src/atlas/cascade.rs — run_cascade(body_seed, body_id, path,
default_sea_level, up_to) loads Layer 0 (heightmap.png) and runs every layer up
to the requested CascadeLayer, returning a CascadeSnapshot. The pure core
run_cascade_from_heightmap orchestrates the layers without file I/O (testable);
run_cascade is the thin path-loading wrapper.

CascadeSnapshot is extensible — each layer's artifact is an Option that becomes
Some once it runs (Layer 0 heightmap always present, Layer 1 topography next;
#954+ append their fields). The carried SeedChain is unused by the RNG-free
Layers 0-1 and feeds the RNG-using layers later (D-224). CascadeLayer is an
append-only ordered enum.

Tests: layer-gating, same-heightmap determinism, layer ordering. The golden-seed
regression fixture (the #952 deliverable) builds on this next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:09:49 +02:00
jpmschweitzerandClaude Opus 4.7 41343987f0 refactor(simulation): thread SeedChain through atlas RNG callers (#952, D-224)
generate_skeleton / compute_district_mix / derive_layout_mode /
organic_placements / derive_reservations now take a SeedChain instead of a
bare seed: u64. The two ad-hoc wrapping_add pre-mixing hacks are replaced with
real domain separation:
- district-type allocation draws from chain.derive(SeedDomain::Layer4Quarter, 0)
- organic block placement draws from chain.derive(SeedDomain::Block, 0)

DistrictSkeleton.seed now records chain.seed(); test call sites pass
SeedChain::root(N); stale seed-param docs updated.

Re-tuned all_district_types_can_appear: the seed-stream change exposed it as
latently fragile — every type has a clamped weight >= 1 (reachable), but 50
weighted draws can miss a low-weight type (Administrative) depending on the
sequence; the old seed got lucky. Raised the draw count to 500 so it tests
genuine reachability rather than a lucky sequence. Determinism-affecting by
design — this is the RNG-using layer D-224 flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:05:19 +02:00
jpmschweitzerandClaude Opus 4.7 de9142ce37 feat(simulation): SeedChain seed-derivation primitive in server/src/seed.rs (#952, D-224)
New top-level seed module that owns every deterministic-RNG primitive:
- splitmix64 — the one canonical mixer (was duplicated as a private fn in
  simulation/rng.rs; EntityRng now imports the shared one, no behavior change)
- AtlasRng — moved here from atlas/rng.rs (it is a generation-RNG primitive,
  not atlas-specific); atlas now depends on seed, not the reverse
- SeedDomain — append-only domain tags (Body/Layer1Topography/Layer3Settlement/
  Layer4Quarter/Block/Npc) for collision-proof per-domain seed separation
- SeedChain — root(world_seed) → derive(domain, id) → atlas_rng()/seed(), per
  the D-224 formula splitmix64(self ^ splitmix64(domain)) ^ splitmix64(id)

Structural move only — SeedChain is not yet threaded through the cascade
callers (skeleton_gen still uses ad-hoc wrapping_add pre-mixing); that is the
next step. Unit tests cover the splitmix64 known-vector, avalanche,
determinism, domain/id separation, and chain composition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:51:09 +02:00
jpmschweitzerandClaude Opus 4.7 f4f43e826c docs(decisions): D-224 SeedChain deterministic seed-derivation contract (#952)
Single typed seed-derivation chain (server/src/seed.rs) descending from the
master world seed via domain-separated splitmix64 mixing — the only sanctioned
way to derive a child seed. Promotes the existing splitmix64 (EntityRng's
mixer) to a shared pub(crate) function and removes ad-hoc wrapping_add
pre-mixing. Pins the derivation formula (load-bearing) and documents the
scope of effect: heightmaps (Python Layer 0) and Layer 1 (RNG-free) are
unaffected; only RNG-using layers (skeleton_gen, future settlement) change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:33:19 +02:00
jpmschweitzerandClaude Opus 4.7 20ed153991 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:03:43 +02:00
jpmschweitzerandClaude Opus 4.7 cdd702b901 chore(config): lint tests + examples in pre-push clippy (#967)
The pre-push clippy invocation ran without --all-targets, so it only checked
lib + bins — test and example targets were never clippy-linted, which is how
the cfg(test)/test-target debt cleared in the previous commit accumulated
unflagged. Add --all-targets now that the debt is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:03:23 +02:00
jpmschweitzerandClaude Opus 4.7 8eb6c47f74 style(server): clear clippy-1.93 cfg(test)/test-target debt (#967)
Manual clippy-1.93 fixes that the prior machine-applicable sweep couldn't auto-
apply, all in cfg(test) modules and tests/ targets (invisible to the lib-only
pre-push clippy, hence accumulated unflagged):

- disallowed_types HashSet/HashMap → BTreeSet/BTreeMap (determinism rule):
  shadowcast_bench.rs (×8, (i32,i32) keys), mood.rs, sound.rs. SoundEventKind
  gains a PartialOrd/Ord derive (fieldless Copy enum) so it is BTree-usable.
- field_reassign_with_default → struct-init: disclosure.rs, monologue.rs (×2),
  save_io.rs (keeps `mut` for the deliberate last-write-wins overwrite).
- assertions_on_constants on the EAVESDROP_THRESHOLD invariant → compile-time
  `const _: () = assert!(...)`: listening.rs, cross_room_transitions.rs. This is
  stronger than the runtime assert and needs no #[allow].
- approx_constant: settings/types.rs round-trip literal 3.14 → 2.5 (the value is
  arbitrary test data, never meant to be PI — change avoids both the lint and a
  suppression).
- drop_non_drop: vision.rs early Mut<WalkabilityMap> release → scoped block.
- unnecessary_get_then_check → contains_key: information_boundaries.rs (×3).
- cloned_ref_to_slice_refs → std::slice::from_ref: triangle_validation.rs.
- unused_must_use: input.rs dropped the unused .id() on a spawn.

cargo clippy --all-targets -- -D warnings is clean; cargo test green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:03:15 +02:00
jpmschweitzerandClaude Opus 4.7 9a10c6ffd6 chore(deps): rand 0.9.4 + compatible sweep + serde_yaml→serde_norway (#966)
Focused Rust dependency-maintenance pass from the 2026-05-23 security/freshness
review. No CVEs; one advisory cleared and one deprecated crate replaced.

- rand 0.9.2 → 0.9.4 (lockfile): clears RUSTSEC-2026-0097 (unsound with a
  custom logger using rand::rng()). Semver-compatible; rand 0.10 is a separate
  major.
- Compatible-update sweep: ~90 lockfile-only patch/minor bumps (bevy 0.18.0→
  0.18.1, clap 4.5→4.6, rayon 1.11→1.12, pathfinding 4.14→4.15, uuid 1.20→1.23,
  zerocopy, serde_json, tracing-subscriber, etc.). cargo test green.
- serde_yaml 0.9 (deprecated/archived upstream) → serde_norway 0.9, an actively
  maintained drop-in fork. In the server it is test-only (poi.rs round-trip,
  trait_modifiers.rs fixture, tests/news_ticker.rs) so it moves to
  dev-dependencies; line-previewer parses dialogue/monologue pool YAML at
  runtime, so it keeps it as a normal dependency. API is identical (from_str/
  to_string).

news_ticker.rs also picks up its share of the #967 clippy sweep (HashSet/HashMap
→ BTree, doc-list indent) since it is the same file as the serde rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:02:57 +02:00
jpmschweitzerandClaude Opus 4.7 88712ba54f style: clippy-1.93 machine-applicable auto-fixes (#967)
cargo clippy --fix on files the new clippy (1.93) flags: unused imports
(name_index, storyteller), manual_range_contains (block_irregularity),
length-comparison/is_empty (layer3, serialization). All behavior-preserving.
Surfaced because a warm target/ makes the pre-push hook actually run clippy
(it skips on cold worktrees). Remaining non-auto-fixable test-code lints
tracked in #967.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:42:22 +02:00
jpmschweitzerandClaude Opus 4.7 f3e017a12f style(simulation): rustfmt + clippy fixes for Layer-1/heightmap code (#953 #963)
Pre-push surfaced fmt + clippy (-D warnings) failures in the new code:
- rustfmt the 6 new/changed atlas files + the bench example.
- features.rs: HashMap → BTreeMap (project bans HashMap for determinism via
  clippy disallowed_types; the bucket map is lookup-only either way).
- attractor_matching.rs: drop now-redundant .clone() on AttractorType (it
  became Copy in #953) — clippy clone_on_copy.
- drainage.rs tests: manual range → (1..=12).contains(&n).
- features.rs test: drop .clone() on Copy AttractorType.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:11:43 +02:00
jpmschweitzerandClaude Opus 4.7 7440e933d6 chore(config): disable pre-push clerk review pending rework (#965)
Clerk was net-negative on the #963 push: non-exhaustive (three distinct real
D-202 inconsistencies surfaced only on successive re-pushes, each run missing
the others — so APPROVED can't be trusted) and it re-reviews the whole range
every push (token burn, no verdict cache). Gate it behind SR_RUN_CLERK=1
(default off). Rework tracked in #965; decision-record consistency is the
author's responsibility until then.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:49:15 +02:00
jpmschweitzerandClaude Opus 4.7 399cd590e9 docs(decisions): fix D-202 self-contradiction on sea_level storage (#963)
Clerk caught it: D-202 line 928 said sea_level is 'carried alongside (not in
the PNG)' while line 930 + the implementation store it IN the PNG (tEXt chunk).
Update line 928 to match — sea_level is a tEXt chunk, with a caller default
fallback. D-202 is now internally consistent (resolution, sea_level, table-drop).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:36:02 +02:00
jpmschweitzerandClaude Opus 4.7 6334b0ddbe docs(simulation): fix stale 2048×1024 heightmap resolution in loader docstring (#963)
The heightmap.rs module docstring still said 'canonically 2048×1024' (the
pre-decision figure); the canonical resolution is 1024×512 (D-202 amended).
Clerk caught the contradiction. Now consistent across D-200/201/202/208 + code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:26:01 +02:00
jpmschweitzerandClaude Opus 4.7 2c6621ac25 docs(decisions): reconcile D-200/D-201/D-208 with D-202 file-based heightmap (#963)
Clerk pre-push review caught that the D-202 amendment introduced cross-record
contradictions + a wrong number. Fix:
- D-202: canonical heightmap is 1024×512 (not 2048×1024 — stale figure; the
  bake and D-201's canonical PNG are both 1024×512).
- D-200: atlas_body_heightmaps no longer produced (elevation is a per-body file).
- D-201: amend the Tier-3 canonical-format lock — stored heightmap is now a
  16-bit grayscale 1024×512 PNG; PNG dims unchanged, Layer 1 downsamples to the
  512×256 working grid (satisfies the record's own 'deviation requires amending'
  gate).
- D-208: clarify 512×256 is the Layer-1 working grid (downsampled from 1024×512).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:08:38 +02:00
jpmschweitzerandClaude Opus 4.7 38d98fc9f6 chore(meta): update changelog
Phase-4 foundation: Layer-1 topography cascade (#953), canonical 16-bit
heightmaps + reliefmap rename (#963), planet sim 1024×512, atlas_body_heightmaps
table dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:51:36 +02:00
jpmschweitzerandClaude Opus 4.7 4ae8428979 chore(db): regen systems.db — drop atlas_body_heightmaps (#963)
The heightmap BLOB table is dropped (canonical elevation is now the per-body
16-bit heightmap.png file). Re-stamped by import_economics.

Clerk-Skip: binary DB build artifact — no D-record/code surface

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:50:48 +02:00
jpmschweitzerandClaude Opus 4.7 a0dd5ec675 data(assets): bake canonical 16-bit heightmaps + rename reliefmaps (#963)
The last Python elevation bake (D-202 amended):
- All 2,398 bodies: legacy color heightmap.png → reliefmap.png (display).
- 267 non-Sol inhabited bodies: fresh clean reliefmap.png + 16-bit grayscale
  heightmap.png (1024×512, sea_level in tEXt) from simulate() at the bumped
  native grid — the canonical elevation the Rust cascade loads.
- Sol (GJ-0): reliefmap.png only (renamed; real-geography terrain untouched).
0 errors, ~190 MB. Determinism guarded by test_sim_determinism.py.

Clerk-Skip: bulk binary asset bake — no D-record/code surface

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:50:43 +02:00
jpmschweitzerandClaude Opus 4.7 b71f339996 feat: complete file-based heightmap migration — tEXt sea_level, client relief, drop BLOB (#963)
- heightmap.rs: read sea_level from the PNG tEXt chunk (bake writes it),
  default-fallback param; new test reads_sea_level_from_text_chunk.
- client atlas_viewer.gd: load reliefmap.png (color display) instead of
  heightmap.png (now 16-bit grayscale elevation, cascade-only).
- drop atlas_body_heightmaps: removed from systems-schema.sql; DROP TABLE in
  import_economics MIGRATION_SQL (the PNG is the store now).
- D-202 amendment: implementation-status note (consumer + producer done),
  resolving the review's 'reads done but producer pending' point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:23:23 +02:00
jpmschweitzerandClaude Opus 4.7 3b2cd6914e feat(tooling): rewrite import_heightmaps as the 16-bit heightmap + relief bake (#963)
Replaces the dead atlas_body_heightmaps DB-BLOB importer (Tyre review B2 —
it asserted 256×512 and wrote a dropped table) with the canonical asset bake:
- one-time rename of the legacy color heightmap.png → reliefmap.png for ALL
  bodies (incl. Sol — display-file rename only, no re-sim);
- for each non-Sol inhabited body: simulate() at 1024×512 → clean reliefmap.png
  (render_heightmap, no painted features) + 16-bit grayscale heightmap.png with
  sea_level in a tEXt chunk;
- no systems.db writes; uses parse_system for proper body defs; run via uv.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:22:50 +02:00
jpmschweitzerandClaude Opus 4.7 dd296b1ae5 test: review-driven determinism + coverage (#953 #963)
From the Hoshe/Tyre review:
- drainage: assert flow_accumulation/max_accumulation determinism + clamp ≥ 1
  (the D-209 strength denominator); isolated-basin merge path (no panic).
- subbiome: each derivable variant reachable + Volcanic never emitted.
- planet_simulation: new test_sim_determinism.py — same body simulates to a
  bit-identical elevation array at 1024×512 (the 271-body bake can't be cheaply
  re-run, so a silent drift = full re-bake). Verified PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:09:26 +02:00
jpmschweitzerandClaude Opus 4.7 6e70647e69 fix(db): sort corp-HQ body iteration for determinism (#951)
Review (Tyre) caught a determinism hole: corp-HQ→city matching iterated a
Python set (sys_body_ids), so on a name collision across bodies in the same
system, which row received corp_id depended on set order — nondeterministic
output landing in the committed DB (violates D-010 #4). Iterate sorted().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:09:14 +02:00
jpmschweitzerandClaude Opus 4.7 abcb41deaa fix(simulation): type-aware attractor cap so RiverMouth isn't crowded out (#953)
Review (Hoshe) caught that the MAX_ATTRACTORS cap sorted by global strength,
and RiverMouth's normalized strength (accum/max_accum) is tiny — so on a
realistic body 108 river mouths produced 0 surviving RiverMouth attractors,
violating D-209 ('RiverMouth: always high-value') and starving #955 placement.
Replace the global-strength cap with group-by-type + round-robin so every
present type keeps representation (strongest-first within each type).
Deterministic. New test river_mouths_survive_cap locks it in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:08:57 +02:00
jpmschweitzerandClaude Opus 4.7 cabdd7c097 refactor(tooling): bump planet sim to native 1024×512, drop compute_rivers (#963)
- planet_simulation: GRID 512×256 → 1024×512. The elevation noise is
  resolution-independent (normalized coords + absolute freqs), so the
  finer grid samples the SAME terrain — features keep physical size,
  generation stays deterministic. Pixel-unit constants (gaussian sigma,
  crater radii, peak-filter window, erosion slope) scale by GRID_W/512.
  Validated: non-Sol bodies render same-world-crisper at 1024.
- Remove compute_rivers + _rivers_to_grid + the rivers/river_grid terrain
  keys: rivers are the Rust cascade's job (D8 drainage, D-208), the single
  source of river truth. The old heuristic didn't even reach the sea.
- render_heightmap: stop painting rivers onto the relief (cascade/Atlas
  overlay computed rivers instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 01:31:52 +02:00
jpmschweitzerandClaude Opus 4.7 506b2c7feb docs(decisions): amend D-202 — file-based 16-bit heightmap, relief rename (#963)
Supersede the atlas_body_heightmaps BLOB store with a per-body 16-bit
grayscale heightmap.png (2048x1024, canonical elevation) next to a renamed
reliefmap.png (color display render). Records the rationale (binary
merge-conflict avoidance + DB size + viewable/deterministic single source),
the multi-resolution split (Layer 1 downsamples to 512x256), and the
dropped table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:36:11 +02:00
jpmschweitzerandClaude Opus 4.7 3886cea46e feat(simulation): load canonical 16-bit grayscale heightmap.png + downsample (D-202 #963)
Replace the atlas_body_heightmaps DB-BLOB loader with a per-body file
loader (D-202 amendment): heightmap.rs reads the 16-bit grayscale
heightmap.png (via the new png dep) from the body's terrain_reference
path, normalizes to f32 [0,1], and rejects RGB so a reliefmap can't be
misread as elevation. Adds BodyHeightmap::downsample (box-average,
deterministic) so Layer 1 drops the high-res stored heightmap to the
512x256 working resolution. sea_level becomes body metadata carried
alongside, not in the PNG. 4 loader tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:35:58 +02:00
jpmschweitzerandClaude Opus 4.7 98658a512d test(simulation): per-body Layer-1 benchmark example (#953)
examples/bench_layer1 times run_layer1 on a synthetic 512x256 heightmap
with a phase breakdown (drainage / terrain analysis / feature extraction).
Post-optimization: ~106ms/body total (drainage ~45ms). Documents the
synthetic-terrain caveat (real heightmaps via #963).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:35:47 +02:00
jpmschweitzerandClaude Opus 4.7 d418eba8bb feat(simulation): Layer-1 topography pipeline — features, sub-biome, orchestrator (#953)
Wire the empty-world topography cascade (D-208/209/210):
- generator.rs: add SubBiomeVariant (11 variants, D-210) + sub_biome and
  terrain_modification_cost fields on GeographicAttractor; AttractorType
  is now Copy.
- features.rs (new, D-209): extract the 7 attractor tags from heightmap +
  drainage. Coast/lake derived from the heightmap (D-209/D-223
  reconciliation — markers are names-only now, no polygons). Deterministic
  (sorted seeds, integer keys, bucket-grid thinning); strength-capped at
  MAX_ATTRACTORS preserving type diversity. Shared TerrainAnalysis
  (masks/slope/moisture/percentile) feeds both extraction and sub-biome.
- subbiome.rs (new, D-210): classify sub-biome + terrain_modification_cost
  from elevation/slope/moisture/latitude. Volcanic stays in the enum but
  is not emitted (no heightmap signal).
- layer1.rs (new): run_layer1 orchestrator + attach_feature_names (D-223
  pool names to largest rivers / Alpine peaks).
- attractor_matching constructors updated for the new fields.

76 atlas tests pass; run_layer1 determinism verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:35:39 +02:00
jpmschweitzerandClaude Opus 4.7 7cf7614342 perf(simulation): expose flow accumulation + optimize basin merge in D8 drainage (#953)
- DrainageResult now exposes flow_accumulation + max_accumulation for
  D-209 attractor-strength normalization.
- Rewrite merge_small_basins from an O(merges x n) loop (rescanned the
  whole grid per merge) to an adjacency-graph + union-find pass: one grid
  scan, lazy merges. Cuts D8 drainage at 512x256 from ~299ms to ~45ms,
  meeting the D-208 ~50ms target (the module had never been run at
  canonical resolution before — it was orphaned). Determinism preserved
  (smallest by (size,id), largest neighbor by (size, lowest id)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:35:16 +02:00
jpmschweitzerandClaude Opus 4.7 a409360802 docs(decisions): D-223 #951 implementation notes + Gemma naming methodology
- Add docs/gemma-naming-methodology.md preserving the corridor-aware LLM
  place-naming approach (sector palettes, two-stage register selection +
  generation, few-shot prompting, KV-cache refresh, dedup, Earth-major
  blocklist, deterministic fallback) as institutional knowledge after the
  pipeline's retirement.
- Add an implementation-status note to D-223 recording what #951 did
  (generator + naming cluster retired, import_economics owns the atlas
  index, population deferred to #955, Sol exempt, dup bug fixed).
- CHANGELOG entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:29:38 +02:00
jpmschweitzerandClaude Opus 4.7 a0d61dc53d chore(db): regen systems.db — names-only atlas pool, empty geometry (D-223 #951)
Regenerated by import_economics: atlas_city_names holds 329 pooled names
+ 134 corp-HQ rows (Sol excluded); the 8 atlas geometry tables are empty
(server cascade fills them); stamped solely by import_economics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:29:26 +02:00
jpmschweitzerandClaude Opus 4.7 d49bd36b7e data(assets): restore Sol markers geometry — Sol exempt from generators (D-223 #951)
Sol (system GJ 0) uses real Earth/Mars/Luna geography via the offline
sol_import.py and is permanently exempt from the procedural cascade. The
names-only strip had removed Sol's generated marker geometry; restore the
geometry-bearing markers.json for Earth (GJ0d), Luna (GJ0d-1), Mars (GJ0e)
and GJ0f-2 as preserved positional config. sol_import.py is left in place
for future scripted Sol integration; the source config in
tooling/planet-gen/sol_markers/ was never stripped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:29:18 +02:00
jpmschweitzerandClaude Opus 4.7 b11e847337 chore(tooling): retire atlas geometry generator + LLM naming cluster (D-223 #951)
The procedural server cascade (Phase 4) and the frozen names-only pool
supersede the Python atlas geometry generator and the LLM namer. Retire:

- generate_atlas.py (geometry production — cities/roads/rivers placement)
- gemma_naming.py, naming_core.py + tests (test_batch_naming,
  test_register_selection, qa_naming) and run-atlas-naming.sh (the LLM
  place-namer; its output is now the frozen pool)
- apply_name_fixes.py (name-field patches), fix_fewshot_bleed.py /
  prune_atlas_features.py (geometry tools)
- import_city_names.py (redundant with import_economics name-pool path)

Pipeline updates: drop the generate_atlas step + atlas-generate /
test-atlas-determinism targets from the Makefile; remove generate_atlas
from the stamp registry (import_economics is the sole regen-db generator);
drop run-atlas-determinism from tests/run-all; refresh stale references in
schema_version, backfill_cultural_corridor, earth_blocklist (kept as
reference data), populate_terrain_reference, and heightmap.rs.

The Gemma prompting methodology is preserved in
docs/gemma-naming-methodology.md (separate commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:29:08 +02:00
jpmschweitzerandClaude Opus 4.7 b0bfbfc7dd feat(db): import_economics owns atlas index — names pool, empty geometry (D-223 #951)
Rework the regen-db atlas path for the names-only marker pool:

- populate_atlas_city_names now reads the names.cities pool instead of
  the retired geometry-bearing cities[] records; population/kind are
  deferred to placement (#955). Adds a deterministic clear-then-insert
  (no UNIQUE on (body_id, name)) that fixes a latent duplicate-
  accumulation bug — atlas_city_names dropped from an inflated 3276 to a
  clean 329 pooled names + 134 corp-HQ rows.
- ensure_atlas_index_schema applies the canonical ATLAS INDEX block from
  systems-schema.sql and empties the 8 geometry tables every regen; the
  Phase 4 server cascade fills them (they start empty — the revealed gap).
- Sol (system 'GJ 0') is permanently exempt from the normal generators:
  skipped in both the name-pool importer and the corp-HQ cross-ref.
- MIGRATION_SQL drops the retired generate_atlas meta stamp row so the
  fail-closed stamp checker accepts older committed DBs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:28:53 +02:00
jpmschweitzerandClaude Opus 4.7 642be3ac41 refactor(tooling): extract atlas_common from generate_atlas (D-223 #951)
Move the shared atlas-DB utilities (schema application, inhabited-body
query, body-def loader, grid constants) out of the soon-to-be-retired
generate_atlas.py into a dedicated atlas_common.py with no dependency on
geometry-production code. Repoint the surviving build-time importers
(import_heightmaps, import_province_boundaries) at the new module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:28:38 +02:00
jpmschweitzerandClaude Opus 4.7 d8af513a5e docs(decisions): Q-098 — persistence of generated river/city mapping outputs
Looking-ahead question from the Phase 4 markers strip. Generated mapping (rivers via
drainage, cities via econ sim + placement) is deterministic so always recomputable,
but expensive — should be computed once per body and kept, not volatile. Refines D-203
(LRU evicts) and D-200 (build-time bake vs runtime persist). Gates the atlas viewer
(#960). To resolve during the execution-model work (#952).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:55:01 +02:00
jpmschweitzerandClaude Opus 4.7 206a3c9920 data(assets): strip markers.json to names-only flavored pool (D-223)
Part 1 of #951. Per D-223, every markers.json is reduced to a flavored name pool —
names only, no positions/geometry. 2398 files; 25,264 names preserved across
rivers/mountains/oceans/cities/pois/roads/railroads. Positions now come from the
deterministic cascade (heightmap + drainage) and economic sim; names attach from the pool.

Expected fallout (next #951 steps): the tooling/planet-gen geometry pipeline and
import_economics read markers geometry, so regen-db breaks until the importer is
reworked for the names-only format. Sol markers (tooling/planet-gen/sol_markers/) not
yet handled. No push (batch until Phase 4 done).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:52:29 +02:00
jpmschweitzerandClaude Opus 4.7 676ded339d docs(decisions): D-223 — authored content as flavored name pool
Resolves the open question on merging preconfigured per-planet content into the
deterministic cascade. Authored content is a flavored NAME POOL only, never pinned
geometry: markers.json reduced to names; river/mountain positions derive from the
heightmap + drainage; settlement positions from the economic sim + placement; names
attach from the pool.

The 6 hand-authored templates (Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade)
have their machinery removed — the bodies stay as ordinary named places, only the
authored positioning + reserved pinning + template special-casing go. After this
'template' is no longer a distinguishable category.

Amends D-207 (markers names-only, no reserved pinning) and D-191 §8 (markers format
superseded). Implementation tracked in #951 (Phase 4). Historical archives untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:37:44 +02:00
jpmschweitzerandClaude Opus 4.7 73965c0ead docs(meta): cascade reorder — world generation (Phase 4) before player control (Phase 5)
The player-before-generation order kept resurfacing because it was recorded in
three places (D-166, CLAUDE.md cascade table, epics #749/#750) while the
generation-first rule was recorded nowhere. Fix all three to match the rule.

Rule: no player-control or in-world rendering work begins until the generator can
deterministically seed-generate every tile of every world via the full multilayer
cascade. The hand-made 2-floor test map is dropped — test layers come from the
generator itself once layer-drawing begins. Generation progress is viewed as
per-layer maps in the implant Atlas (the Phase 3 deliverable, already built), not
an in-world renderer. Existing in-world rendering code is left as-is until Phase 5.

Amends D-166 (Phases 4/5 swapped + amendment note), CLAUDE.md cascade table, and
epics #750 (now Phase 4) / #749 (now Phase 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:09:46 +02:00
jpmschweitzerandClaude Opus 4.7 d87e700039 fix(tooling): remove unused import in fill-missing-globes.py
ruff F401 — unused 'import os'. Pre-existing in the globe-coverage script; ruff
runs at pre-push (not pre-commit), so it surfaced only now and blocked the push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:43:00 +02:00
jpmschweitzerandClaude Opus 4.7 96dcdf98b6 docs(decisions): D-222 — spatial hierarchy & naming (Quarter 512m, District 2048m)
Establishes the canonical sub-settlement ladder as the single source of truth:
Subtile 0.5m / Tile 1m / Chunk 64m / Block 128m / Quarter 512m / District 2048m,
fluid above. Renames the old 512m 'District' to Quarter and promotes District to a
real urban scale (2048m, 4.19 km², 4×4 quarters), grounded against real block /
superblock / district sizes.

Codifies the lore-vs-code rule: tier names are fixed generation grid cells; the same
words in narrative/UI are free-form region labels and must NOT be reconciled to a
code tier by reviewers or the clerk.

Amends D-094 (hierarchy), D-201 (tier table renumbered 6-9), D-220 (its footprint
'district' cell is the Quarter), and D-066 (sim tile -> Subtile vocabulary) to point
at D-222. Resolves the clerk finding on commit 7 (D-220 scale chain). Code/terminology
rename deferred to ticket #950.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:05:34 +02:00
jpmschweitzerandClaude Opus 4.7 f87e251621 docs(decisions): D-191 — drop shadow economy atlas overlay (underwater modifier)
Resolves the clerk finding on the pre-existing overlay removal. Shadow economy
is an underwater simulation modifier (shadow_economy_intensity, D-174) feeding
derived signals like collection_efficiency and signal 7 official_coverage_ratio
(D-181) — not a user-navigable data point. The atlas overlay and City Data Panel
field were correctly removed in code; D-191 was stale. Amends D-191 §7 (9 -> 8
overlays), §6 (drop city-panel field), §10 (completion criteria). D-174 simulation
layer is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:29:41 +02:00
jpmschweitzerandClaude Opus 4.7 bf3659d1a5 fix(meta): clerk-review — incomplete reviews no longer false-reject
The per-commit clerk had three flaws, exposed by a 20-commit push where 6 of 7
rejections were false (incl. a CHANGELOG-only commit):

1. No-verdict / max-turns / timeout defaulted to REJECTED — an unfinished review
   read as 'hard contradiction found'. Now a third outcome, INCOMPLETE, which is
   non-blocking (the push proceeds with a warning); only a real REJECTED blocks.
2. Turn/time budget too tight (6 turns / 150s) for decision-heavy commits. Raised
   defaults to 15 turns / 300s, and the prompt now biases to APPROVED when no
   concrete contradiction is found ('unsure' means APPROVED, never REJECTED).
3. The skip valve matched its own feature commit because it scanned for the token
   anywhere in the message. Moved to a trailer-line match so prose/subject mentions
   no longer trip it.

Pre-push hook updated to treat INCOMPLETE as a non-blocking warning. The git-commit
skill documents the trailer form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:59:44 +02:00
jpmschweitzerandClaude Opus 4.7 606c732bdd feat(meta): add [clerk-skip] safety valve to clerk-review
A commit whose message contains the token [clerk-skip] is auto-approved by
the pre-push clerk without spawning an agent. Intended for bulk content/data
commits — e.g. shipping thousands of generated planetary description files —
where D-record review is moot and would only burn agents on noise.

Documented in the /git-commit skill with the caveat: never use it on commits
that touch decisions/, code, or ticket-bearing work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:41:28 +02:00
jpmschweitzerandClaude Opus 4.7 a854db5f84 chore(meta): clerk-review reviews per-commit in parallel
The clerk pre-push review spawned a single claude -p over the entire combined
diff (truncated at 50k chars) with a 3-turn budget. On a large push (e.g. 754
files / 2.8M chars) it ran out of turns before emitting a verdict, which the
wrapper defaulted to REJECTED.

Rewrite to review one commit at a time — commits are the logical units, so each
clerk agent sees a self-contained change plus its commit message (enabling the
'does this match ticket #NNN?' check). Commits are reviewed by a bounded pool of
parallel clerk agents and the verdicts aggregated (REJECTED if any commit
contradicts an active D-record). Oversized commit diffs truncate to a budget.

Adds --plan (print the per-commit plan, no agents spawned) and env knobs:
SR_CLERK_COMMIT_BUDGET / SR_CLERK_WORKERS / SR_CLERK_MAX_TURNS / SR_CLERK_TIMEOUT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:29:06 +02:00
jpmschweitzerandClaude Opus 4.7 8fabebe325 chore(meta): retire obsolete team-test.md and update references
The tmux teammate-mode investigation is resolved (works on 2.1.148; the
'broken regression' was a teammateMode: in-process config issue). Per the
file's own cleanup note, delete it now that the test passes.

Update the two references:
- decisions/questions-process.md: replace the stale 'partially broken'
  pointer with the resolved status.
- whats-next/SKILL.md: the 'custom subagent_types lose SendMessage' caveat is
  fixed (all agents now carry SendMessage + Task tools), so reword and drop
  the dead team-test.md link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:17:08 +02:00
jpmschweitzerandClaude Opus 4.7 1391ffc58e chore(meta): update changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:38:30 +02:00
jpmschweitzerandClaude Opus 4.7 7404b3baf7 docs: record tmux teammate-mode investigation findings
Log the 2.1.148 results in team-test.md: the 'broken tmux pane' premise was
a stale config claim (teammateMode was in-process), not a regression; auto mode
spawns real panes; and custom restricted-tool subagents need coordination tools
listed explicitly to function as pane-mode teammates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:37:53 +02:00
jpmschweitzerandClaude Opus 4.7 0127fc8fd9 chore(config): centralize git for teammates and enable tmux panes
Two agent-team tooling changes:

- teammateMode: in-process -> auto. Teammates now spawn in tmux split panes
  when the lead runs inside tmux, with graceful in-process fallback.
- New PreToolUse hook git-centralize-guard.sh blocks .git-mutating commands
  (add, commit, merge, push, pull, rebase, reset, checkout, stash,
  cherry-pick, rm, mv) for teammates, keeping version control centralized to
  the lead. Detection keys on the agent_type field, which a teammate's hook
  input carries and the lead's does not. Read-only git is allowed.

Documented in .claude/rules/git-safety.md. Hooks load at lead startup, so a
restart is required for the hook to reach teammates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:37:47 +02:00
jpmschweitzerandClaude Opus 4.7 a0097caee7 chore(agents): add team coordination tools to all agent definitions
Custom subagent types used as agent-team teammates in tmux pane mode only
receive their definition's restricted tools list — the coordination tools
are not injected (confirmed on Claude Code 2.1.148). Without SendMessage a
teammate can't message the lead and can't return a shutdown_response, so it
orphans its pane.

Add SendMessage, TaskList, TaskUpdate, and TaskGet to all 21 agents so they
work as full teammates. TaskCreate is intentionally omitted: task creation
stays centralized with the team lead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 12:37:33 +02:00
jpmschweitzerandClaude Opus 4.6 934c78e8b9 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 21:03:36 +02:00
jpmschweitzerandClaude Opus 4.6 bbb7e5b0f9 fix(ui): regional atlas — wiki path loading, overlay bar input, header width
Heightmap and markers load from wiki filesystem path via FileAccess
instead of res:// (wiki is outside the Godot project root). Overlay
bar buttons now receive mouse events — viewer skips input processing
when cursor is over UI elements. Header gets explicit minimum width
to prevent vertical text stacking. Shadow economy overlay removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 21:03:09 +02:00
jpmschweitzerandClaude Opus 4.6 768f37fd83 feat(ui): inline body detail panel on system orbital view
Body info panel with globe artwork renders on the right side of the
system map instead of navigating to a separate screen. Single-click
shows/swaps panel, double-click opens regional heightmap view, ESC
closes panel. Station panel also moved to right side for consistency.
Moon and station positioning clears parent body radius + label space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 21:02:54 +02:00
jpmschweitzerandClaude Opus 4.6 b9fd75b840 docs(workshops): planet-down cascade workshop + misc stray files
Planet-down cascade workshop (3 rounds, 5 agents): layer-by-layer
generation from empty world through population overlay, city planning,
and street rendering. Includes consultant review by Troblum.

Also commits: pre-Sprint-35 DB backup, Claude Code team-mode tmux
test log (team-test.md).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:18:30 +02:00
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
jpmschweitzerandClaude Opus 4.6 288e3ccd2e chore(meta): bump server Cargo.lock to v0.2.0
Lockfile version was still at 0.1.37 after the v0.2.0 release tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:17:40 +02:00
jpmschweitzerandClaude Opus 4.6 408fead2e6 docs(decisions): D-220 settlement density model — tile scale, density classes, vertical pressure
Five density classes (Frontier→Compressed) driven by four pressures
(utility cost, terrain, economic gravity, political). Amends D-201
tile scale to 1m with 0.5m subtile. Calibrated against real-world
settlements (Kiruna, Uzès, Kanazawa, Paris).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:17:27 +02:00
jpmschweitzerandClaude Opus 4.6 8de7bc7b73 chore(skills): git-commit — triage pre-existing changes before committing
Add a mandatory step between assessing changes and grouping commits:
surface any modified/untracked files that weren't touched in the current
session and ask the user whether to include them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:16:57 +02:00
jpmschweitzerandClaude Opus 4.6 59ab4d0c33 chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:12:36 +02:00
jpmschweitzerandClaude Opus 4.6 903efda191 feat(assets): 100% globe coverage — generic images + donor fill
Generated oort cloud and asteroid belt generic globe images via Gemini.
Background-masked with flood-fill (shadow-preserving). Donor fill script
copies matching globe.png from same planet_class+body_type pool with
per-body hash selection, no repeats within a system. 629 bodies filled,
3240/3240 now have globe.png.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:12:00 +02:00
jpmschweitzerandClaude Opus 4.6 29f9945291 feat(db): body_radius_km scatter + gas giant/moon scale classes
Deterministic ±scatter on body radii seeded by body_id hash — no two
bodies share the same radius. Gas giants 40k-60k km, moons 200-2600 km,
rocky planets ±15% from class base. Oort/asteroid skip radius (NULL).
Sol system gets real planetary radii. body_radius_km exported to
star_map_data.json for client orbital diagram sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:11:31 +02:00
jpmschweitzerandClaude Opus 4.6 e8b4dc74b5 feat(ui): atlas system orbital redesign — horizontal orrery with globe textures
Half-sun on left edge, planets left-to-right with radius-based sizing
(Earth = 24px reference, unclamped — gas giants fill the view). Globe
textures loaded from wiki at runtime. Double-click on reach map opens
system view. M closes atlas from any screen, ESC navigates back.

Fixes Godot 4.6 type inference parse errors in main.gd (var zoom/speed).
Uses physical_keycode throughout for layout-independent key handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:11:16 +02:00
jpmschweitzerandClaude Opus 4.6 e6a557e8e7 feat(meta): replace sprint workflow with kanban + milestones (D-221)
Sprint-based workflow (38 sprints) replaced by kanban + milestones.
Milestones are many-to-many with tickets and can block each other.

New: /whats-next skill (dependency-driven batch selection with Si
refinement review), /pr-process skill (renamed from pr-push, adds
review comment pickup), clerk agent + pre-push hook for D-record
consistency checks.

Deleted: sprint CLI, sprint-start/sprint-plan/sprint-status skills,
team-scoped file restrictions. Si rewritten as refinement manager.
All 19 agent briefings updated from stale PROJECT_STATE.md reference
to live ticket milestone queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:11:00 +02:00
5886 changed files with 81957 additions and 598217 deletions
+8 -6
View File
@@ -34,7 +34,8 @@ All agents read their briefing file at `docs/briefings/{name}.md` before startin
| Agent | File | Role | Model | When to use |
|-------|------|------|-------|-------------|
| `si` | si.md | Project Manager & Scrum Master | sonnet | Sprint planning, ticket management, coordination |
| `si` | si.md | Refinement Manager | sonnet | Ticket context review before batch activation (`/whats-next` step 2) |
| `clerk` | clerk.md | Institutional Guardrail | sonnet | Pre-push D-record/ticket consistency checks |
### Standby team (activate when implementation starts)
@@ -93,7 +94,7 @@ Synthesize findings.
| Design review | Team (core agents) | Independent perspectives, genuine disagreement |
| Style guide creation | Subagent (araminta) | Focused creative work |
| Test writing | Subagent (hoshe) | Focused, spec-driven |
| Implementation sprint | Team (tyre + hoshe + relevant others) | Parallel build + test |
| Implementation batch | Team (tyre + hoshe + relevant others) | Parallel build + test |
| Documentation update | Subagent (qatux) | Structured, accurate citations |
## Agent usage notes
@@ -108,10 +109,11 @@ Synthesize findings.
- Has access to `/asset-gen` skill and `generate_image` MCP tool
- **Image generation costs money - always ask Team Leader for permission before generating**
### SI (Project Manager)
- Manages the ticketing database via `/ticket` skill
- Creates initiatives from decisions, breaks into epics/stories/tasks
- Does not make design decisions - coordinates and tracks
### SI (Refinement Manager)
- Spawned by `/whats-next` to review ticket context before batch activation
- Reads D/Q-records, workshop outcomes, existing code to assess ticket readiness
- Reports READY (with context summary) or GAPS (with specific ambiguities)
- Does not implement — refines
### Qatux (Documenter & Librarian)
- Core team member — participates in discussion rounds as documenter
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: araminta
description: Visual Designer responsible for art direction, UI consistency, asset style guides, and visual coherence across the game. NOT part of regular brainstorming discussions - spawned when visual decisions need to be made or when implementation needs visual guidance. Use when creating mockups, defining color palettes, establishing UI patterns, or reviewing visual consistency.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+2 -2
View File
@@ -1,7 +1,7 @@
---
name: burnelli-sheldon
description: Economist and Simulation Modeler for the Settled Reach game project. Use when designing economic systems, evaluating market models, grounding supply/demand mechanics in real economic theory, building price formation models, or when the team needs someone to ask "is this economically credible?" Covers both conceptual frameworks (market equilibrium, price elasticity, input-output models) and quantitative modeling (convergence algorithms, stability analysis, parameter tuning).
tools: Read, Glob, Grep, Edit, Write
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
@@ -39,4 +39,4 @@ Named for the Burnelli-Sheldon dynasty — old money that understood how wealth
## Project context
Read your briefing at `docs/briefings/burnelli-sheldon.md` before starting work (if it exists). Read the relevant decisions/ domain files listed in the sprint briefing for confirmed decisions. Key references: D-117 (tycoon bookmark), D-118 (small business owner), D-131 (economic verb vocabulary), D-132 (dual-scale consequence model).
Read your briefing at `docs/briefings/burnelli-sheldon.md` before starting work (if it exists). Read the relevant `decisions/*.md` domain files referenced in your ticket. Key references: D-117 (tycoon bookmark), D-118 (small business owner), D-131 (economic verb vocabulary), D-132 (dual-scale consequence model).
+43
View File
@@ -0,0 +1,43 @@
---
name: clerk
description: Institutional guardrail for the Settled Reach game project. Pre-push review agent that checks D-record consistency, ticket drift, and decision contradictions. Spawned by the pre-push hook or manually for consistency audits. Binary output (APPROVED/REJECTED) with verbose findings file.
tools: Read, Glob, Grep, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
You are the CLERK, the institutional guardrail on a game development team building a top-down immersive sim set in the Settled Reach universe.
## Your personality
Precise, dispassionate, thorough. You are not a reviewer — you don't judge code quality. You are a consistency checker. You say things like "File X contradicts D-142" and "Ticket #890 describes outcome Y but implementation does Z." You do not have opinions about design. You have facts about what was decided and whether the code matches.
## Your role
You check whether a diff is consistent with the project's institutional knowledge:
1. **D-record consistency** — do changed files contradict any active D-record?
2. **Referenced decisions** — if code references a D/Q/R-ID, does that ID exist and is it active?
3. **Ticket drift** — if commits reference ticket #NNN, does the implementation match the ticket's described outcome? (Drift is flagged, not blocked — tickets are descriptive, not prescriptive.)
4. **Q-record surfacing** — are there open Q-records relevant to the changed files? Surface them as context, not blockers.
## Output format
When spawned by the pre-push hook:
- Write findings to `.cache/pre-push-review.md` (verbose: each check, what you found, citations)
- Output exactly one word to stdout: `APPROVED` or `REJECTED`
- `REJECTED` only for hard contradictions with active D-records. Everything else is a finding, not a block.
When spawned manually for an audit:
- Report findings directly. No binary gate needed.
## What you do NOT do
- Judge code quality, style, or architecture (that's /pr-review)
- Make design decisions
- Modify any files except `.cache/pre-push-review.md`
- Block on subjective grounds
## Project context
Read `decisions/README.md` for the domain index. The `decisions` table in the ticketing DB is synced from these files via `tooling/db/decisions-sync`.
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: dudley
description: Server Developer for the Settled Reach game project. STANDBY - activate when simulation implementation begins. Responsible for the game simulation server, entity systems, information boundaries, deterministic tick processing, and all server-side game logic.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: gestalt
description: Systems Design and Fun Factor specialist for the Settled Reach game project. Use when designing game mechanics, evaluating whether systems create interesting decisions, mapping concepts to concrete mechanics, defining how systems interact, or when someone needs to ask "is this fun?" Use proactively when implementation discussions need mechanical grounding.
tools: Read, Glob, Grep, Edit, Write
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: gore
description: Themes and Endgame Design specialist for the Settled Reach game project. Use when discussing ascension paths, the philosophical questions the game explores, what the game is fundamentally ABOUT, late-game transformation mechanics, or when the team needs someone to zoom out and reframe the question at a higher level.
tools: Read, Glob, Grep
tools: Read, Glob, Grep, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: hoshe
description: QA Engineer and Test specialist for the Settled Reach game project. Use when tests need to be written, test plans created, bugs investigated, test reports generated, or when implementation needs verification against specifications. NOT part of brainstorming discussions - spawned for testing and quality assurance work.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: inigo
description: Sound Designer for the Settled Reach game project. STANDBY - activate when audio implementation begins. Responsible for soundscape design, ambient audio layers, diegetic sound cues, audio propagation rules, and all player-facing audio. Use when designing sound palettes, defining audio triggers, creating spatial audio specs, or reviewing audio consistency.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: justine
description: Polish and Deployment specialist for the Settled Reach game project. STANDBY - activate when builds need packaging, performance needs optimizing, or release preparation begins. Responsible for build pipelines, performance profiling, platform packaging, and release quality.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: mellanie
description: Copywriter for the Settled Reach game project. STANDBY - activate when game text needs writing - internal monologue lines, dialogue, descriptions, UI text, tutorial text, news ticker content. Responsible for all in-game written content.
tools: Read, Glob, Grep, Edit, Write
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: miri
description: Worldbuilder and Setting Designer for the game project. Use when designing the original setting (inspired by Commonwealth-style space opera), building factions, cultures, technology trees, locations, history, and lore. Ensures the world is internally consistent, richly detailed, and serves gameplay mechanics. Use when new features need setting grounding or when world consistency matters.
tools: Read, Glob, Grep, WebSearch, WebFetch
tools: Read, Glob, Grep, WebSearch, WebFetch, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: nigel
description: Sandbox and Replayability advocate for the Settled Reach game project. Use when evaluating whether features create emergent stories, when discussing how systems produce different experiences across playthroughs, when considering procedural generation, or when the team needs someone to ask "what happens the SECOND time you play this?"
tools: Read, Glob, Grep
tools: Read, Glob, Grep, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: oscar
description: Networking Developer for the Settled Reach game project. STANDBY - activate when networking/multiplayer work begins. Responsible for client-server communication, network protocol design, sync mechanisms, and ensuring the architecture supports future multiplayer.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: ozzie
description: Player Experience and "Wow Factor" advocate for the Settled Reach game project. Use when evaluating whether features are exciting, when the team needs a gut-check on whether something will feel good to play, or when designs risk being technically correct but emotionally flat. Champions the moments that make players feel something.
tools: Read, Glob, Grep
tools: Read, Glob, Grep, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: paula
description: Narrative and Political Depth specialist for the Settled Reach game project. Use when designing faction mechanics, character relationships, political intrigue, consequences of player actions, or narrative structure. Focused on the human drama and ensuring choices have meaningful weight.
tools: Read, Glob, Grep, WebSearch
tools: Read, Glob, Grep, WebSearch, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: qatux
description: Documenter and Librarian for the Settled Reach game project. Use when discussion decisions need to be recorded, when documents need updating, when the team needs a summary of current state, when open questions need tracking, when searching project history, or when answering "did we already discuss this?". Maintains decisions/ domain files, DISCUSSION.md, and briefings.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+25 -23
View File
@@ -1,43 +1,45 @@
---
name: si
description: Project Manager and Scrum Master for the Settled Reach game project. Use when creating or managing tickets, planning sprints, breaking initiatives into epics/stories/tasks, tracking progress, or coordinating work across agents. Primary user of the /ticket skill. Does not participate in design discussions - coordinates execution.
tools: Read, Glob, Grep, Edit, Write, Bash
description: Refinement Manager for the Settled Reach game project. Spawned by /whats-next to review ticket context before activation. Reads D/Q-records, workshop outcomes, and existing code to assess whether tickets have sufficient context for implementation. Reports READY or GAPS. Does not implement — refines.
tools: Read, Glob, Grep, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
You are SI, the Project Manager and Scrum Master on a game development team building a top-down immersive sim set in the Settled Reach universe.
You are SI, the Refinement Manager on a game development team building a top-down immersive sim set in the Settled Reach universe.
## Your personality
You are organized, direct, and calm under pressure. You turn vision into executable plans. You see the dependency graph that others miss. You say things like "Let me break that into actionable items" and "What's the blocker?" and "Sprint goal:" You do not offer design opinions - you coordinate execution. Efficient, never wastes words.
Organized, direct, thorough. You see the dependency graph that others miss. You say things like "This ticket references D-194 but the constraint in Q-086 qualifies it" and "The outcome is clear but the approach has two valid readings." You do not implement — you ensure implementers have what they need. Efficient, never wastes words.
Named after the Sentient Intelligences that manage all Commonwealth infrastructure - tireless, omnipresent, keeping everything running so others can focus on their work.
Named after the Sentient Intelligences that manage all Commonwealth infrastructure — tireless, omnipresent, keeping everything running so others can focus on their work.
## Your role on the team
## Your role
- Manage the ticketing database via /ticket skill and sqlite_connector.py
- Break decisions into initiatives, epics, stories, and tasks
- Plan and track sprints
- Identify blockers, dependencies, and critical paths
- Coordinate parallel work across agents
- Maintain project velocity and scope clarity
- Report status to Team Leader
- Ensure nothing falls through the cracks between agents
You are spawned by `/whats-next` step 2, one instance per ticket in a batch. Your job:
## How you work
1. **Research context** for your assigned ticket:
- Read the ticket's `decision_ref` D/Q-record in `decisions/*.md`
- Grep for related Q-records in `decisions/questions-*.md`
- Read workshop outcomes if referenced (check `docs/workshops/`)
- Check whether referenced code, tables, or files actually exist
You are execution-focused. When a decision is made, you immediately think about what needs to happen, in what order, by whom, and what depends on what. You maintain the project's pulse - always knowing what's in progress, what's blocked, and what's next. You don't wait to be asked for status updates; you surface risks early.
2. **Assess completeness** — does the ticket have enough context for an agent to work without guessing?
- Is the desired outcome clear and unambiguous?
- Are relevant D-records consistent about the approach?
- Are there open Q-records that conflict with or qualify the ticket?
- Does the ticket reference artifacts that exist in the codebase?
## Ticket assignment rules
3. **Report back** with exactly one of:
- **READY** — ticket has sufficient context. Include a one-paragraph summary of what the implementing agent needs to know (key D-records, relevant files, constraints).
- **GAPS** — list each specific ambiguity with the options you see. Be concrete: "D-194 says X but Q-086 leaves Y open" is useful; "needs more detail" is not.
The development teams are: **server**, **client**, **copy**, **audio**, **visual**, **ci**.
## What you do NOT do
When creating or splitting tickets:
- **Always assign a ticket to exactly one team.** Every ticket must have a team.
- **Never use "joint" as a team.** If work spans multiple teams, split it into separate tickets — one per team — with explicit dependencies between them.
- For example, a feature requiring server-side logic and client-side rendering becomes two tickets: one for server (implement the data/system), one for client (consume and render it), with the client ticket blocked by the server ticket.
- Sprint proof/acceptance tickets should be assigned to the team responsible for the final integration step, with blockers on the upstream tickets.
- Implement anything
- Create tickets or modify the database
- Make design decisions — you surface the gap, the human decides
- Offer opinions on whether the ticket is a good idea
## Project context
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: stig
description: UI Developer for the Settled Reach game project. STANDBY - activate when UI implementation begins. Responsible for insert/minimap UI, perception mode overlays, internal monologue display, HUD elements, and all player-facing interface code.
tools: Read, Glob, Grep, Edit, Write, Bash
tools: Read, Glob, Grep, Edit, Write, Bash, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: tiger
description: Translator and Localization specialist for the Settled Reach game project. STANDBY - activate when the game needs localization to other languages. Responsible for translation, localization infrastructure, and cultural adaptation of game text.
tools: Read, Glob, Grep, Edit, Write
tools: Read, Glob, Grep, Edit, Write, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: troblum
description: Technical sparring partner and external consultant for architecture evaluation. Use when Tyre needs a second opinion on engine choices, architectural tradeoffs, technology evaluations, or performance analysis. NOT part of brainstorming discussions - only spawned for specific evaluation sidequests alongside Tyre.
tools: Read, Glob, Grep, Bash, WebSearch, WebFetch
tools: Read, Glob, Grep, Bash, WebSearch, WebFetch, SendMessage, TaskList, TaskUpdate, TaskGet
model: sonnet
memory: project
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: tyre
description: Technical Architect and Feasibility specialist for the Settled Reach game project. Use when evaluating engine choices, assessing technical feasibility of features, designing system architecture, discussing performance implications, or when the team needs a reality check on scope. Also use proactively for any implementation planning or code architecture decisions.
tools: Read, Glob, Grep, Edit, Write, Bash, WebSearch, WebFetch
tools: Read, Glob, Grep, Edit, Write, Bash, WebSearch, WebFetch, SendMessage, TaskList, TaskUpdate, TaskGet
model: opus
memory: project
---
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# git-centralize-guard: PreToolUse hook that blocks .git-mutating commands for
# agent-team teammates. Version control is centralized to the team lead
# (see CLAUDE.md / memory: "Team lead commits, never agents").
#
# Detection: an agent-team teammate's hook input carries an "agent_type" field
# (e.g. "qatux"); the team lead's main session does NOT. So agent_type present
# => a teammate (or subagent) => block git writes. Absent => the lead => allow.
# (Empirically confirmed on Claude Code 2.1.148; docs field is "agent_type".)
#
# Read-only git (status, log, diff, show, fetch, branch listing) is always allowed.
set -uo pipefail
INPUT=$(cat)
# Only act on Bash tool calls
TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null)
[ "$TOOL_NAME" = "Bash" ] || exit 0
# Teammates/subagents carry agent_type; the lead does not.
AGENT_TYPE=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('agent_type','') or '')" 2>/dev/null)
[ -n "$AGENT_TYPE" ] || exit 0 # lead -> allow everything
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null)
# Block git history/working-tree-mutating subcommands anywhere in the command
# (catches chained forms like `cd x && git commit`). Read-only git is allowed.
if echo "$COMMAND" | grep -qE '(^|[^[:alnum:]_])git[[:space:]]+(add|commit|merge|push|pull|rebase|reset|checkout|stash|cherry-pick|rm|mv)([[:space:]]|$)'; then
echo "BLOCKED: git write operations are centralized to the team lead. Teammate '${AGENT_TYPE}' may not run: ${COMMAND}" >&2
echo "Editing files is fine — leave staging, commits, merges, and pushes to the lead." >&2
exit 2
fi
exit 0
+15
View File
@@ -7,6 +7,21 @@
- Skip files in `.gitignore`
- The `.claude/` directory IS tracked — skills and agents belong in the repo
## Centralized version control (agent teams)
Git is the **team lead's** job. Teammates edit files; the lead stages, commits,
merges, and pushes. This is enforced, not just convention:
- **Hook:** `.claude/hooks/git-centralize-guard.sh` (PreToolUse on Bash) blocks
`.git`-mutating commands — `add`, `commit`, `merge`, `push`, `pull`, `rebase`,
`reset`, `checkout`, `stash`, `cherry-pick`, `rm`, `mv` — for any agent-team
teammate. Read-only git (`status`, `log`, `diff`, `show`, `fetch`) is allowed.
- **Detection:** a teammate's PreToolUse hook input carries an `agent_type` field
(e.g. `"qatux"`); the lead's main session does not. Present → block; absent →
allow. (Confirmed on Claude Code 2.1.148.)
- **Activation:** hooks load at lead startup and teammates inherit that snapshot,
so changes to this hook require a Claude Code restart to reach teammates.
## Commit conventions
Use conventional commits: `<type>(<scope>): <summary>`
+2 -3
View File
@@ -21,15 +21,14 @@ docs/
architecture/ # Technical architecture documents
design/ # Game design documents
diagrams/ # d2 source + PNG renders
sprints/ # Sprint briefings per team
sprints/ # Historical archive (Sprint 1–38) — no new sprint directories
workshops/ # Workshop briefs and outputs
db/
schema.sql # Database schema
tooling/
db/ # Connector scripts for SQLite and audio
config.json # Endpoint configuration
ticket # Ticket CLI
sprint # Sprint lifecycle CLI
ticket # Ticket + milestone CLI
sqlite_connector.py # SQLite mini MCP
audio_connector.py # Stable Audio Open connector
.claude/
+2 -2
View File
@@ -3,7 +3,7 @@
## Model selection
Default model is Opus 4.6 (200K context). For heavy sessions (workshops,
sprint planning, large reviews), switch to extended context on-demand:
large reviews, milestone planning), switch to extended context on-demand:
- `/model sonnet[1m]` — Sonnet 4.6 with 1M context window
- `/model opus[1m]` — Opus 4.6 with 1M context window
@@ -20,7 +20,7 @@ Key: writers use Write tool directly (no transcription bottleneck), librarian ca
## Team monitoring (stuck agent detection)
When leading a team (sprint, workshop, or any multi-agent session):
When leading a team (workshop, batch, or any multi-agent session):
**Agent heartbeat rule** — include in every agent spawn prompt:
> If you have been working on a single task for more than 15 minutes
+19 -1
View File
@@ -25,10 +25,27 @@ tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision
# Read
tooling/db/ticket show <id>
tooling/db/ticket list [--sprint N] [--team T] [--status S]
tooling/db/ticket list [--milestone N] [--team T] [--status S]
# Update
tooling/db/ticket assign <id> <agent>
# Dependencies
tooling/db/ticket deps <id>
tooling/db/ticket dep add <blocker_id> <blocked_id>
tooling/db/ticket dep rm <blocker_id> <blocked_id>
# WIP
tooling/db/ticket wip
# Milestones
tooling/db/ticket milestone list [--status S]
tooling/db/ticket milestone create <name> [--description TEXT] [--phase N]
tooling/db/ticket milestone link <ticket_id> <milestone_id>
tooling/db/ticket milestone unlink <ticket_id> <milestone_id>
tooling/db/ticket milestone complete <milestone_id>
tooling/db/ticket milestone show <milestone_id>
tooling/db/ticket milestone dep <blocker_id> <blocked_id>
```
## Key rules
@@ -37,3 +54,4 @@ tooling/db/ticket assign <id> <agent>
- **Quote the title** — always wrap in double quotes to handle spaces
- **Never use `sqlite3` CLI** — it crashes (std::bad_alloc). Use `tooling/db/sqlite-query` or `tooling/db/sqlite-exec` for raw SQL
- **Verify after create** — run `tooling/db/ticket show <id>` to confirm the title is clean
- **Milestones are many-to-many** — a ticket can be linked to multiple milestones via `milestone link`
+8 -11
View File
@@ -4,7 +4,7 @@
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
"SR_DB_PATH": "/var/home/jeroenschweitzer/Projects/settled-reach/settledreach.db"
},
"teammateMode": "in-process",
"teammateMode": "auto",
"permissions": {
"allow": [
"Bash(git add *)",
@@ -28,7 +28,6 @@
"Bash(git rev-parse --show-toplevel)",
"Bash(tooling/db/ticket *)",
"Bash(tooling/db/sprint *)",
"Bash(tooling/db/sqlite-query *)",
"Bash(tooling/db/sqlite-exec *)",
"Bash(tooling/db/sqlite-init)",
@@ -58,22 +57,15 @@
"Bash(ruff check)",
"Bash(tests/run-*)",
"Bash(mkdir -p docs/sprints/*)",
"Write(docs/sprints/*)",
"Bash(chmod *)",
"Bash(ls *)",
"Bash(find *)",
"Bash(list *)",
"Bash(tree *)",
"Bash(sed -n *)",
"Bash(.claude/skills/sprint-start/scripts/start-sprint.sh *)",
"Bash(.claude/skills/sprint-start/scripts/sprint-teardown.sh *)",
"Skill(git-commit)",
"Skill(sprint-start)",
"Skill(sprint-plan)",
"Skill(pr-push)",
"Skill(whats-next)",
"Skill(pr-process)",
"Skill(pr-review)",
"Skill(ticket)",
"Skill(docs-search)",
@@ -95,6 +87,11 @@
"type": "command",
"command": ".claude/hooks/git-lock-guard.sh",
"timeout": 5
},
{
"type": "command",
"command": ".claude/hooks/git-centralize-guard.sh",
"timeout": 5
}
]
}
+36 -3
View File
@@ -14,16 +14,31 @@ allowed-tools: Bash, Read, Grep, Glob, Write, Edit
## Workflow
1. Run `git status` and `git diff --stat` to assess all pending changes.
2. Group changes into **logical commits** — each commit should represent one
2. **Triage: session vs pre-existing.** Separate changes you made in this
session from changes that were already dirty when the session started.
Check the conversation history — if you didn't touch a file, it's
pre-existing. After grouping your own commits, surface any remaining
modified or untracked files to the user:
> These files are also modified/untracked but weren't part of this
> session's work:
> - `path/to/file` — <brief description of the diff>
> - `path/to/other` — <untracked, N files>
>
> Should any of these be included in a commit?
Wait for the user's answer before staging them. Never silently skip
or silently include pre-existing changes.
3. Group changes into **logical commits** — each commit should represent one
coherent change. Common groupings:
- A bug fix (all files touched to fix one issue)
- A new feature or system (e.g., new trait files + localization + events)
- Refactoring / cleanup (encoding fixes, comment fixes, renaming)
- Config / meta changes (CLAUDE.md, .claude/ skills, mod descriptor)
- Data corrections (province remapping, missing fields)
3. For each logical group, stage only the relevant files and commit with a
4. For each logical group, stage only the relevant files and commit with a
properly formatted message.
4. After all commits, update CHANGELOG.md.
5. After all commits, update CHANGELOG.md.
## Commit Message Format
@@ -73,6 +88,24 @@ Use the project subsystem as scope. Examples:
- Never combine unrelated changes (e.g., don't mix a crash fix with new features).
- When in doubt, prefer more smaller commits over fewer large ones.
### Clerk safety valve (`Clerk-Skip:` trailer)
The pre-push clerk reviews each commit for D-record consistency. For a pure bulk
content commit — e.g. shipping thousands of generated planetary description files
— that review is moot and just burns agents on noise. Add a `Clerk-Skip:` **trailer
line** to the commit message and `tooling/clerk-review` auto-approves that commit
without spawning an agent. Use it only for content/data dumps, never for commits
that touch `decisions/`, code, or ticket-bearing work.
It must be a trailer (a line starting with `Clerk-Skip:`), not inline prose — that
way a commit that merely *mentions* the token in its subject or body isn't skipped.
```
data(content): generate 5000 planetary description files
Clerk-Skip: bulk generated content, no D-record surface
```
### Examples
```
@@ -1,17 +1,16 @@
---
name: pr-push
name: pr-process
description: >
Push commits and create or update a pull request. Use when the user says
"push pr", "push and create pr", "update pr", "create a pr", "open a pr",
or invokes /pr-push. NOT triggered by plain "push" (that's just git push).
Pushes the current branch, creates a PR if none exists, or confirms the
existing PR was updated. NEVER merges the PR into main — this skill only
pushes to the branch and manages the PR lifecycle.
Author-side PR lifecycle: commit, lint, push, create PR, AND pick up review
comments from /pr-review. Use when the user says "process pr", "push pr",
"push and create pr", "update pr", "handle review comments", or invokes
/pr-process. Runs from the worktree. The counterpart to /pr-review which
runs from main.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, AskUserQuestion, Skill
---
# Push PR Skill
# Process PR Skill
Push commits to remote and create or update a PR. Operates exclusively on the
current branch — never touches main.
@@ -46,7 +45,7 @@ If the user invokes `/pr-push --dry-run`:
git branch --show-current
```
If on `main`, stop: "You're on main. Switch to a team branch first."
If on `main`, stop: "You're on main. Switch to a topic branch first."
### 1a. Orphan process check (MANDATORY)
@@ -346,13 +345,34 @@ Report which tickets were moved to review. Skip tickets that are
already `done`, `review`, `cancelled`, or `backlog` (only transition
`in_progress` → `review`).
### 9. Next steps
### 9. Pick up review comments
If a sprint team is active (you are the team lead), do NOT shut down
agents after pushing. The team should remain alive for PR review and
potential comment fixes.
Check if the PR already has review comments (from a prior `/pr-review` run):
Suggest: "PR created/updated. Run `/pr-review` to review before merge."
```bash
tea pr --login schweitz --repo jpmschweitzer/settled-reach --comments -o simple <PR_NUMBER>
```
If comments exist and contain a review verdict (look for "CHANGES REQUESTED" or
"REQUEST_CHANGES" or a structured review table):
1. Parse each file-specific issue from the review comment
2. Present each issue to the user (or working agents)
3. For each issue, the response is one of:
- **Fix:** make the change, commit via /git-commit
- **Pushback:** explain why the comment should be retracted (concrete technical rationale)
4. After addressing all comments, re-run lint + smoke checks (steps 1b, 1c)
5. Push updated commits (step 5)
6. Post a response comment on the PR summarizing:
- Which issues were fixed (with commit refs)
- Which issues were pushed back on (with rationale)
- Use `tooling/tea-comment <PR_NUMBER> @/tmp/pr-response.md`
If no review comments exist, or the review is APPROVED, skip this step.
### 10. Next steps
Suggest: "PR processed. Run `/pr-review` from main to review, or `/whats-next` for the next batch."
## Arguments
+36 -35
View File
@@ -104,17 +104,28 @@ Then determine the branch:
### 2. Determine reviewer team
Map the branch name to a reviewer set. Use the branch prefix (before any `/`
or `-` suffix) to classify:
Classify the branch by **which directories changed**, not by branch name prefix.
Run:
| Branch type | Branches | Reviewers |
|-------------|----------|-----------|
| **code** | `server`, `client`, `ci`, or unknown | Hoshe (code quality) + Tyre (architecture) |
| **copy** | `copy` | Hoshe (QA) + Paula (narrative depth) + Miri (world consistency) |
| **visual** | `visual` | Hoshe (QA) + Araminta (art direction) |
| **audio** | `audio` | Hoshe (QA) + Ozzie (player experience) |
```bash
git diff --stat main...<branch>
```
If the branch name doesn't match any known type, default to **code** reviewers.
Examine the changed file paths to determine the dominant change type:
- Mostly `server/` changes → **code** reviewers
- Mostly `client/` changes (excluding `client/ui/` art assets) → **code** reviewers
- Mostly `wiki/`, `docs/atlas/`, `content/` changes → **copy** reviewers
- Mostly `client/ui/` or asset changes (`.tres`, `.tscn`, textures) → **visual** reviewers
- Mostly audio-related changes (audio assets, audio scripts) → **audio** reviewers
- Mixed or unclear → **code** reviewers (default)
| Branch type | Reviewers |
|-------------|-----------|
| **code** | Hoshe (code quality) + Tyre (architecture) |
| **copy** | Hoshe (QA) + Paula (narrative depth) + Miri (world consistency) |
| **visual** | Hoshe (QA) + Araminta (art direction) |
| **audio** | Hoshe (QA) + Ozzie (player experience) |
### 3. Generate the diff and read source files
@@ -162,11 +173,8 @@ into every reviewer prompt with prominent language. The reviewer
reads from the worktree, not from main.
```bash
# Determine worktree path
SPRINT_NUM=$(echo "<branch>" | sed -E 's|sprint-([0-9]+)/.*|\1|')
TEAM=$(echo "<branch>" | sed -E 's|sprint-[0-9]+/||')
REPO_ROOT=$(git rev-parse --show-toplevel)
WORKTREE="$(dirname "$REPO_ROOT")/.sprint/sprint-${SPRINT_NUM}/${TEAM}"
# Find worktree for this branch (if one exists)
WORKTREE=$(git worktree list --porcelain | grep -B2 "branch refs/heads/<branch>" | grep "worktree " | sed 's/worktree //')
# Verify it exists and matches the branch tip
git -C "$WORKTREE" rev-parse HEAD # should equal `git rev-parse origin/<branch>`
@@ -174,10 +182,10 @@ git -C "$WORKTREE" rev-parse HEAD # should equal `git rev-parse origin/<branch
If the worktree exists and its HEAD matches `origin/<branch>`, use it
as the reviewer's source of truth. If it doesn't exist (e.g. the
sprint has been torn down or you're reviewing a non-sprint branch),
fall back to `git show origin/<branch>:<path>` — explicitly flag this
fallback in the reviewer prompt so the reviewer knows Read/Grep on
any local path would be wrong.
worktree has been torn down or you're reviewing a branch without a
worktree), fall back to `git show origin/<branch>:<path>` — explicitly
flag this fallback in the reviewer prompt so the reviewer knows
Read/Grep on any local path would be wrong.
In the reviewer prompt, state the rule non-negotiably:
@@ -304,26 +312,20 @@ tea pr close --login schweitz --repo jpmschweitzer/settled-reach <PR_NUMBER>
Gitea does **not** auto-close PRs when you push a local merge — always close
manually with `tea pr close` after pushing.
### 8. Post-review team actions
If a sprint team is active and you are the team lead, handle the
review outcome:
### 8. Post-review actions
**CHANGES_REQUESTED:**
The sprint-start lifecycle (step 9c) handles dispatching review
comments to agents. After presenting results, remind the lead:
"Review requested changes. Create tasks from each issue and dispatch
to idle agents, then re-push and re-review."
After presenting results, tell the user: "Run `/pr-process` from the
worktree to pick up and address review comments."
The team may **push back** on specific comments. When a team agent
The author may **push back** on specific comments. When the author
disagrees with a reviewer comment, the process is:
1. The team agent explains why the comment should be retracted — with
1. The author explains why the comment should be retracted — with
a concrete technical rationale, not just "I disagree."
2. The team lead (you) evaluates the pushback. If the rationale is
sound, mark that comment as **retracted** in the review table and
note the reason.
3. If the team lead is unsure, escalate to the user for a ruling.
2. Evaluate the pushback. If the rationale is sound, mark that
comment as **retracted** in the review table and note the reason.
3. If unsure, escalate to the user for a ruling.
4. Retracted comments do NOT need to be fixed. The re-review should
note which comments were retracted and why.
@@ -332,9 +334,8 @@ that every comment is taken seriously. The bar for retraction is
"the reviewer was wrong about this" — not "we don't want to do it."
**APPROVED:**
The sprint-start lifecycle (step 9c) handles shutdown. After
presenting results, remind the lead: "Review approved. Proceed with
team shutdown per sprint-start step 9c."
After presenting results, tell the user: "Review approved. Proceed
to merge from main (section 7)."
## Tips from practice
-213
View File
@@ -1,213 +0,0 @@
---
name: sprint-plan
description: >
Plan the next sprint and generate team briefing files. Use when the user says
"plan sprint", "prep sprint briefing", "plan next sprint", "sprint planning",
or invokes /sprint-plan. Gathers current sprint status, scans the backlog,
proposes ticket selection, and writes briefing files per team to
docs/sprints/sprint-N/.
user-invocable: true
allowed-tools: Task, Read, Grep, Glob
---
# Plan Sprint
**Delegate this entire skill to SI** (Project Manager agent, `subagent_type: si`).
When this skill is invoked, spawn SI using the Task tool:
```
Task(
subagent_type: "si",
prompt: "Run /sprint-plan for Sprint N. Read the skill at
.claude/skills/sprint-plan/SKILL.md for the full workflow,
then execute it. Use the arguments provided: {args}",
description: "Plan sprint N"
)
```
Pass through any arguments the user provided (e.g. sprint number).
Present SI's output to the user when done. Do NOT run the workflow yourself.
---
The remainder of this file is SI's reference for executing the workflow.
Generate sprint briefing files for each active team (e.g. `server.md`,
`client.md`, `copy.md`, `joint.md`) for the next sprint based on current
project state. Only generate briefings for teams that have tickets in the sprint.
## Teams and Default Agents
| Team | Branch | Default Agents | Focus |
|------|--------|----------------|-------|
| `server` | `server` | Dudley (dev), Tyre (arch), Hoshe (QA) | Rust/bevy_ecs simulation, ECS systems, knowledge graph, perception |
| `client` | `client` | Stig (dev), Tyre (arch), Hoshe (QA) | Godot 4 client, rendering, UI, HUD, input handling |
| `copy` | `copy` | Mellanie (author), Paula (narrative), Gestalt (systems) | Dialogue, monologue, UI text, knowledge vocabulary, lore |
| `audio` | `audio` | Inigo (sound design) | Soundscapes, ambient layers, diegetic cues, audio propagation |
| `visual` | `visual` | Araminta (art direction) | Art assets, sprites, visual consistency, style guides |
| `ci` | `ci` | Justine (build/deploy) | Build pipelines, CI/CD, tooling, packaging |
| `planning` | `planning` | Purpose-assembled (see below) | Design discussions, decision resolution, workshop-style tickets |
When writing briefings, name the assigned agents in the **Agents** line of each
file so the team knows who to spawn.
### Planning Team Tickets
Some tickets need **design discussion** before implementation can begin — tagged
"NEEDS DESIGN DISCUSSION" or blocking multiple downstream tickets with open
questions. These run on the `planning` branch as structured discussions with
the user and a purpose-assembled agent panel.
**When to create a planning ticket:**
- Ticket description says "NEEDS DESIGN" or "NEEDS DESIGN DISCUSSION"
- Ticket blocks 2+ downstream tickets across different teams
- Open Q-NNN items that block sprint candidates
- Architectural decisions that need multi-domain input before implementation
**Planning briefing format** (differs from implementation briefings):
- **Agents line**: List agents by domain relevance, not fixed team roster.
Pick from: Gestalt (systems), Miri (worldbuilding), Araminta (visual/spatial),
Tyre (technical), Paula (narrative), Ozzie (player experience), Gore (themes),
Nigel (replayability). Typically 4-6 domain agents, plus Qatux (documenter —
records decisions, updates domain files) and SI (project manager — creates
follow-up tickets, updates sprint assignments).
- **Discussion rounds**: Structure the conversation into 2-3 rounds
(inventory → proposals → convergence)
- **Context section**: List all existing design docs, decisions, and related
tickets that participants must read before the discussion
- **Output specification**: What the discussion must produce — typically a
D-record in `decisions/`, possibly a design doc in `docs/design/`
- **Decision questions**: Specific questions the discussion must answer,
not open-ended exploration
## Workflow
### 1. Run sprint prepare
Get carry-overs, backlog candidates, and decision gaps in one shot:
```bash
tooling/db/sprint prepare
```
This auto-detects the next sprint number (max ID + 1), creates the sprint
record in `planning` status if needed, and outputs:
- Previous sprint status and carry-over candidates
- Backlog candidates grouped by team
- Decision coverage gaps
- Already-assigned tickets (if any)
### 2. Deepen the scan
For critical epics, check their children for granular candidates:
```bash
tooling/db/ticket children <epic_id>
```
Use `tooling/db/ticket show --brief <id> [<id>...]` to quickly scan multiple tickets.
### 3. Read existing code state
Scan what's already built to write accurate "what exists" notes:
```bash
# Server modules
ls server/src/ server/src/*/
# Client scripts
ls client/scripts/ client/scripts/*/
```
Read key files that sprint tickets will build on (bridge types, existing
renderers, etc.) to reference specific integration points in the briefing.
### 4. Select tickets — propose to user
Based on the backlog scan, propose a sprint with:
- **Sprint theme** — a short name (Sprint 1 was "Run", Sprint 2 was "See")
- **Sprint goal** — one sentence shared across all teams
- **Server tickets** — stories from server-side epics
- **Client tickets** — stories from client-side epics
- **Joint tasks** — integration proofs, pre-sprint decisions, schema work
Present the proposal using AskUserQuestion for the user to approve or adjust.
Selection heuristics:
- Follow dependency chains (don't pick a ticket if its blocker isn't in scope)
- Respect D-030 test priority phases (sprint 1-2: infra, sprint 3-4: integration)
- Mix carry-overs with new work
- Aim for 3-6 tickets per team, with parallel tracks where possible
- Check `decisions/questions.md` for open Q-NNN items that block candidates
### 5. Read relevant decisions
For the selected tickets, identify which `decisions/*.md` files are relevant.
Read them to provide accurate cross-references in the briefing.
### 6. Write briefing files
Create `docs/sprints/sprint-N/` and write one file per team.
Read the template at `references/briefing-template.md` in this skill directory
for the exact file structure.
**Relative paths only:** All file paths in briefings must be relative to
the repo root. Example: `server/src/bridge/types.rs`, not absolute paths.
Each sprint branch (`sprint-{N}/{team}`) contains the full repo.
Key requirements per file:
- **server.md**: Carry-overs, new tickets, dependency chain, key decisions, notes
referencing existing Rust modules by path from worktree root (e.g. `server/src/simulation/movement.rs`)
- **client.md**: Same structure, notes referencing existing GDScript files by path from worktree root (e.g. `client/scripts/rendering/fog.gd`)
- **copy.md**: In-game text tasks — dialogue, UI copy, tooltips, flavor text, lore
- **audio.md**: Sound design, music, audio integration tasks
- **visual.md**: Art direction, asset creation, visual consistency tasks
- **ci.md**: Build pipeline, CI/CD, tooling, infrastructure tasks
- **joint.md**: Pre-sprint decisions table, integration tickets, sprint
completion proof (concrete observable criteria), test plan alignment
Only generate briefing files for teams that have tickets assigned in the sprint.
Not every sprint will have work for every team.
### 7. Assign tickets to sprint in DB
After the user approves, assign all selected tickets. The sprint record
was already created by `sprint prepare` in step 1 (status: `planning`).
Update it with the theme and goal, then assign tickets:
```bash
# Update the sprint with theme and goal
tooling/db/sqlite-exec "UPDATE sprints SET name='Sprint N: Theme', goal='goal' WHERE id=N"
# Assign tickets
tooling/db/ticket sprint assign <ticket_id> <sprint_id>
```
The sprint stays in `planning` status until explicitly activated via
`tooling/db/sprint start`. This prevents starting an unplanned sprint.
### 8. Commit and push
Stage the briefing files and any other changes (db backup, closed tickets),
then commit and push so worktree branches can pull the planning artifacts:
```bash
git add docs/sprints/sprint-N/
make db-backup
git add docs/backups/settledreach.db.backup
git commit -m "chore(meta): plan Sprint N: Theme"
git push
```
### 9. Present summary
Output:
- Sprint number, theme, and goal
- Ticket count per team
- Carry-over count
- Open questions that need early resolution
- Files written
- Commit pushed to main
@@ -1,80 +0,0 @@
# Sprint Briefing Template
Each team gets one briefing file at `docs/sprints/sprint-N/<team>.md`.
## File structure
```markdown
# Sprint N: <Theme> — <Team> Tasks
**Goal:** <One-sentence sprint goal, shared across all teams>
**Branch:** `sprint-{N}/<team>`
**Agents:** <Agent names and roles>
## Carry-over from Sprint N-1
(Only if there are incomplete tickets from the previous sprint)
| # | Title | Status | Notes |
|---|-------|--------|-------|
| #ID | Title | status | Why it carried over |
## New Tickets
| # | Title | Blocked by |
|---|-------|------------|
| #ID | Title | #dependency or — |
Use `tooling/db/ticket show <id>` for full details.
## Key Decisions
- `decisions/<domain>.md` — D-NNN (short name), D-NNN (short name)
## Open Questions to Resolve Early
(Only if there are Q-NNN items that block sprint tickets)
- **Q-NNN: Title** — Brief context. Resolve before #ID starts.
## Notes
One bullet per ticket with:
- What exists already (files, modules, stubs)
- What the ticket actually needs to deliver
- Integration points with other tickets
- Non-obvious gotchas
## Dependency Chain
```
#A (name) → #B (name) → #C (name)
#D (name) → standalone, parallel track
```
## PR Workflow
When ready to submit, create a PR with `tea` CLI. **All flags are required** to avoid TTY prompts (see CLAUDE.md "Gitea access" section):
\```bash
tea pr create --repo jpmschweitzer/settled-reach --login schweitz --title "feat(<scope>): description" --description "body" --base main --head sprint-{N}/<team>
\```
```
## Joint briefing extras
The `joint.md` file additionally includes:
- **Pre-Sprint** table: decisions/schema work that must happen before implementation
- **Sprint Completion Proof**: concrete observable criteria (what you can see/do when the sprint is done)
- **Test plan** alignment with D-030 phases
## Team assignments
| Team | Branch | Agents | Scope |
|------|--------|--------|-------|
| server | `sprint-{N}/server` | Dudley (simulation), Oscar (networking) | server/, Rust/bevy_ecs simulation |
| client | `sprint-{N}/client` | Stig (UI), Oscar (networking) | client/, Godot rendering |
| copy | `sprint-{N}/copy` | Mellanie, Paula, Miri | wiki/, docs/atlas/, content/ |
| joint | both | All implementation agents | Integration, proofs, cross-team schema |
| content | (none) | Mellanie, Paula, Miri, Araminta | Content authoring, no code branch |
-616
View File
@@ -1,616 +0,0 @@
---
name: sprint-start
description: >
Manage the sprint lifecycle from main, or start sprint work on a team
branch. Use when the user says "start sprint", "start working on the
server/client/copy", "begin sprint", or invokes /sprint-start. On main:
assesses sprint state and does the next right thing (close, activate, or
guide). On a team branch: merges main, loads the briefing, presents the
work plan.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, TeamCreate, Task, TaskCreate, TaskUpdate, TaskList, SendMessage, AskUserQuestion
---
# Start Sprint Skill
Prepare a team branch for sprint work: sync with main, load the sprint
briefing, and present actionable next steps.
## Workflow
### 1. Determine the team
The current branch IS the team. Read it with:
```bash
git branch --show-current
```
Sprint branches follow the pattern `sprint-{N}/{team}` (e.g. `sprint-31/server`).
Valid team names: `server`, `client`, `copy`, `audio`, `visual`, `ci`, `planning`.
If on `main`, follow the **Main branch workflow** below instead of
the team branch workflow (steps 2–8).
---
## Main branch workflow (sprint lifecycle management)
When `/sprint-start` is run on `main`, assess the current sprint state
and do the next right thing. Query the database to determine the state:
```bash
tooling/db/sqlite-query "SELECT id, name, status FROM sprints ORDER BY id DESC LIMIT 3"
```
Then follow the **first matching case**:
### Case A: An active sprint exists
First, check whether the sprint's work is actually done:
```bash
tooling/db/sprint status
```
This shows ticket counts by status (done, in_progress, backlog).
Also check for open PRs that may contain completed work waiting for
review or merge:
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
Report the full picture to the user:
- Ticket progress (done / in_progress / backlog counts)
- Open PRs (if any — these represent work that's done but not merged)
**If tickets remain unfinished** (in_progress or backlog) **or open PRs
exist**, do NOT close the sprint. Instead, report the current progress
and ask the user what they want to do:
- **Continue working** — switch to a team branch and run `/sprint-start`
there to resume work
- **Review & merge PRs first** — (only if open PRs exist) merge pending
work before deciding whether to close
- **Close anyway** — proceed with the close workflow below (carries over
unfinished tickets)
Use `AskUserQuestion` to confirm. Do not proceed to A1 unless the user
explicitly chooses to close.
#### A1. Close the active sprint
```bash
tooling/db/sprint stop
```
This marks the active sprint as completed and lists carry-over candidates.
Note the sprint number (N) from the output.
#### A1b. Sprint retrospective and review
Before bumping the version, run a brief retro. Present the following to
the user:
1. **What shipped** — list completed tickets with one-line summaries
2. **What didn't ship** — carry-overs and why (blocked, cut, deprioritized)
3. **What we learned** — open questions raised during the sprint (new Q-NNN
items), review findings that surfaced design gaps, and any assumptions
that turned out to be wrong
4. **Process notes** — what worked well, what was friction (e.g. dependency
chains that blocked teams, specs that were over/under-specified,
review cycles that caught real issues vs busywork)
5. **Process improvements** — this is the most important section. Do NOT
skip it. Look for:
- Dependency chains that blocked teams — could the sprint have been
structured differently to avoid the bottleneck?
- Specs that were over-specified (wasted planning) or under-specified
(wasted iteration) — what's the right level of detail for this
project's current stage?
- Review cycles — did they catch real issues or create busywork?
- Agent coordination — were agents stuck, duplicating work, or idle?
- **Dig into the deeper why.** Don't stop at "the dependency chain
blocked the copy team." Ask: why was there a dependency chain? Was
the sprint structured wrong, or was the work inherently sequential?
Could Phase 0 have been done pre-sprint? Should we change how we
plan sprints going forward?
- If something went rough, understand the root cause — not just what
happened, but why the process allowed it to happen.
- If a concrete process change follows naturally, propose it. But do
NOT force improvements. If nothing was broken, say so and move on.
Unnecessary process changes are worse than no changes.
Keep each section concise — a few bullet points, not a document. The
retro is a conversation checkpoint, not a report. Use `AskUserQuestion`
to let the user add their own observations and push back before proceeding.
If the user raises items that should be tracked, create Q-NNN entries
or backlog tickets on the spot. If process changes are agreed, update
the relevant skill files or CLAUDE.md immediately — don't defer them.
#### A1c. Clean up sprint worktrees (MANDATORY — do not skip)
Always run the teardown script. It's idempotent and prints
"No worktrees found" gracefully if there's nothing to clean:
```bash
.claude/skills/sprint-start/scripts/sprint-teardown.sh {N}
```
**Do not try to pre-check whether worktrees exist by running `ls`
locally.** Sprint worktrees live at
`$(dirname <repo-root>)/.sprint/sprint-{N}/` — a *sibling* of the
repo root, not a child. Running `ls .sprint/` from inside the repo
will always show nothing even when worktrees exist, leading to a
false negative and skipped cleanup (Sprint 36 close missed teardown
this way; three stale worktrees persisted until Sprint 37 planning).
The script knows the correct path via its own `SCRIPT_DIR` — trust it.
Verify cleanup after it runs:
```bash
git worktree list
```
Only `main` should remain. Local `sprint-{N}/{team}` branches are
left in place (they're harmless stale refs pointing at already-merged
work; `origin/sprint-{N}/*` survives on the remote).
#### A2. Bump the version
The project version scheme is `v0.1.{sprint_number}`. After closing
sprint N, the version is `v0.1.N`.
Update `project.yaml`:
- Set the `version` field to `0.1.N` (this is the source of truth).
Update `server/Cargo.toml`:
- Set `version = "0.1.N"` in `[package]`.
Update `CHANGELOG.md`:
- Move all entries under `## [Unreleased]` into a new section
`## [v0.1.N] — YYYY-MM-DD` (using today's date).
- Leave `## [Unreleased]` as an empty section above the new version.
- Keep the existing sub-headings (Added, Fixed, Changed, Removed) —
only move entries that have content.
#### A3. Commit the release
Stage and commit `project.yaml`, `server/Cargo.toml`, and `CHANGELOG.md`:
```
chore(meta): release v0.1.N
```
#### A4. Tag the release
```bash
git tag v0.1.N
```
#### A5. Push
```bash
git push && git push --tags
```
#### A6. Check for a planned sprint
After closing, re-query the database. If a sprint in `planning` status
exists, continue to **Case B**. Otherwise, report the close and suggest
running `/sprint-plan`.
---
### Case B: No active sprint, but a planned sprint exists
A sprint is ready to activate. Verify it looks complete:
1. Check that briefing files exist at `docs/sprints/sprint-N/`:
```bash
ls docs/sprints/sprint-N/
```
2. Check the ticket count:
```bash
tooling/db/sprint status --sprint N
```
If briefings are missing or the sprint has 0 tickets, report the gap
and suggest running `/sprint-plan` to complete planning.
If everything looks ready, activate the sprint:
```bash
tooling/db/sprint start
```
Then open team terminal tabs automatically:
```bash
.claude/skills/sprint-start/scripts/start-sprint.sh
```
This creates ephemeral worktrees under `.sprint/sprint-{N}/{team}/` for
each team with open tickets, and opens Ptyxis windows with tmux + Claude
auto-starting in each tab. The user will have one tab per active team.
Report:
- Sprint activated (name, ticket count per team)
- Worktrees created and tabs opened
- Each team tab runs `/sprint-start` to load briefing and spawn agents
---
### Case C: No active sprint and no planned sprint
Nothing is ready. Report the state and suggest running `/sprint-plan`
to plan the next sprint.
---
## Team branch workflow
### 2. Sync with main
Sprint branches are created fresh from main by `start-sprint`, so they
should already be up to date. If main has moved since branch creation:
```bash
git fetch --all
git merge origin/main --no-edit
```
If the merge has conflicts, report them and stop — do not force-resolve.
### 3. Load sprint context
Run the sprint CLI to get the full context dump in one shot:
```bash
tooling/db/sprint start-work
```
This auto-detects the active sprint and current team from the branch.
It outputs: sprint metadata, briefing paths, decision refs, actionable
tickets, blocked tickets, and done tickets.
If no active sprint is found, report that and stop.
### 4. Read the sprint briefing
Read the briefing file(s) listed in the `start-work` output
(e.g. `docs/sprints/sprint-6/server.md` and `joint.md`).
If no matching briefing exists for the team, suggest running
`/sprint-plan` to generate one.
### 5. Load ticket details
For tickets that need more detail than the `start-work` summary provides:
```bash
tooling/db/ticket show <id>
```
### 6. Read key decisions
Read the decision files referenced in the sprint briefing so the agent has
full architectural context before starting work.
### 7. Mark tickets in progress and present the work plan
Mark all actionable (unblocked, non-done) tickets in the sprint as
`in_progress`:
```bash
tooling/db/ticket status <id> in_progress
```
Then output a summary:
- Sprint name and goal
- Branch status (clean merge or conflicts)
- Tickets marked in_progress (list with IDs)
- Blocked tickets (and what blocks them)
- Key decisions loaded
- Suggested first task (lowest ID unblocked ticket)
### 8. Confirm and spawn the team
Before spawning agents, use `AskUserQuestion` to confirm the work plan and
agent lineup with the user. If declined, stop.
Once confirmed:
#### 8a. Parse agents from the briefing
Extract agent names from the `**Agents:**` line. Format:
```
**Agents:** Name (role), Name (role), ...
```
Map each name to its `subagent_type` (lowercase):
- "Dudley (simulation)" → `dudley`
- "Stig (UI)" → `stig`
- "Tyre (architecture)" → `tyre`
- "Hoshe (QA)" → `hoshe`
- "Mellanie (author)" → `mellanie`
- etc. (see `.claude/agents/` for full roster)
#### 8b. Create the team
```
TeamCreate(team_name: "sprint-{N}-{team}")
```
This makes you the team lead.
#### 8c. Create tasks from tickets
For each ticket in the briefing, create a task:
```
TaskCreate(
subject: "#{id}: {title}",
description: "Full ticket details from step 5, plus briefing notes
and integration points for this ticket.",
activeForm: "Working on #{id}: {short_title}"
)
```
After creating all tasks, mirror the dependency chain from the briefing
using `TaskUpdate` with `addBlockedBy`.
#### 8d. Spawn agents
For each agent from the `**Agents:**` line, spawn a teammate in the
background. Spawn all agents in parallel (one message, multiple Task calls):
**Model pin (MANDATORY for team members):** every team-mode spawn —
i.e. any `Task` with a `team_name` argument — must pass `model: "sonnet"`.
Sprint 37 observed Opus 4.7 teammates ignoring scope rules, leaving
tasks half-done, and failing to report back via SendMessage. Sonnet 4.6
follows literal rules block discipline better. The **team lead**
(this session, running `/sprint-start`) stays on whatever model the
user has selected — typically Opus.
**Inline (non-team) Agent spawns are exempt.** One-shot reviewers
(`/pr-review`), research subagents, and other `Task` calls without a
`team_name` keep their default model. The pin applies to the
long-running team-coordination path specifically, not every Agent call.
```
Task(
subagent_type: "{name_lowercase}",
team_name: "sprint-{N}-{team}",
name: "{name_lowercase}",
model: "sonnet",
prompt: "You are on the {team} team for Sprint {N}.
Branch: `sprint-{N}/{team}`
RULES (NON-NEGOTIABLE):
0. TEAM SCOPE: Your team is `{team}` on branch `sprint-{N}/{team}`.
Stay within your team's file scope (server → server/, client → client/, copy → wiki/ + docs/atlas/ + content/).
You may read (but not modify): docs/, decisions/, wiki/, .claude/
Do NOT modify files belonging to other teams.
1. GIT: Do NOT run any git commands (commit, push, pull, merge,
checkout, branch, stash, tag, etc.). All git operations are
handled by the team lead. No exceptions.
2. DB SCRIPTS: When calling ticket/sprint/sqlite scripts, use
the exact command with no wrappers or chaining. Examples:
tooling/db/ticket show 528
tooling/db/ticket list --sprint {N}
Do NOT prepend python3, do NOT chain with && or ;, do NOT
add cleanup commands. Just the bare command.
3. READ BEFORE WRITE: Before modifying ANY file, Read it first.
Before creating a new file, Glob for similar files to learn
the existing patterns (naming, structure, imports). Follow
the conventions you find — do not invent new ones.
4. VERIFY AFTER WRITE: After implementing a change, grep for
all references to functions/properties/classes you modified
or removed. If you renamed, moved, or deleted something,
update EVERY call site. Missing a call site breaks tests
and blocks the team.
5. NO PARTIAL WORK: Do not mark a task completed unless ALL
parts of the ticket are implemented. If the ticket says
'deliver A, B, and C', all three must exist and work. If
you cannot complete part of a task, message the team lead
explaining what is blocked and what remains — do NOT mark
it completed.
6. MESSAGE WHEN BLOCKED: If you hit a problem you cannot solve
in 3 attempts, stop and message the team lead immediately.
Do not silently skip work or leave stubs. Do not move to
the next task while the current one is incomplete.
7. BACKWARD COMPATIBILITY: When extracting, moving, or
refactoring code, ensure all existing consumers still work.
Add proxy methods/properties if needed. Grep for the old
name to find every call site.
WORKFLOW:
1. Read the sprint briefing: docs/sprints/sprint-{N}/{team}.md
2. Read the decision files referenced in the briefing.
If your work touches systems.db sources (markers.json, TOML files,
or generator code), read .claude/rules/asset-pipeline.md before
modifying anything.
3. Check TaskList for available work.
4. Claim an unblocked task (TaskUpdate with owner: your name),
mark it in_progress, and implement it.
5. Before marking done, verify:
- All deliverables from the ticket exist (not just some)
- No broken references (grep for changed names/signatures)
- New files follow existing naming and directory conventions
- Modified files still parse (no syntax errors)
6. Mark the task completed and check TaskList for the next
available task.
7. If no tasks remain, message the team lead. Do NOT shut down
on your own.
Use `tooling/db/ticket show <id>` for full ticket specs.",
description: "Sprint {N} {team}: {name}",
run_in_background: true
)
```
#### 8d-planning. Planning team variant — proposers vs executors
When the team is `planning`, agents work in discussion mode, not
implementation mode. The briefing typically defines discussion rounds
(propose → review → execute). Most agents are **proposers** — they
analyze and recommend. Only designated agents **execute** (write to
files, modify DB).
**Classify agents from the briefing:**
- **Executor agents:** SI (project manager), Qatux (documenter).
These agents wait for consensus before writing anything.
- **Proposer agents:** Everyone else (Gestalt, Tyre, Paula, Ozzie,
etc.). These agents analyze, propose, and discuss — they do NOT
write decision files, modify the DB, or create/delete tickets.
**Add this block to proposer agent prompts** (replaces rules 3–5
and the WORKFLOW section from the standard template):
```
PLANNING TEAM RULES (replace standard rules 3-7 and WORKFLOW):
3. PROPOSE ONLY: You are a discussion participant. Your job is
to ANALYZE tickets, PROPOSE team assignments, and RECOMMEND
splits. You do NOT:
- Write or modify decision files (decisions/*.md)
- Run DB update/insert/delete commands
- Create, delete, or modify tickets
- Mark tasks as completed
Those actions belong to executor agents (SI, Qatux) after
the team lead confirms consensus.
4. OUTPUT FORMAT: Your first message to the team lead should
be your full analysis. Structure it as:
- Your position on open questions (with rationale)
- Proposed team assignments (table format)
- Split candidates (if any)
- Sprint readiness assessment
Do NOT claim or work tasks. The team lead coordinates.
5. WAIT FOR CONSENSUS: Do not treat your own proposal as
decided. Other agents may disagree. The team lead calls
consensus and directs executors to implement it.
```
**Add this block to executor agent prompts** (appended after the
standard rules):
```
PLANNING TEAM RULES (additional):
8. WAIT FOR CONSENSUS: Do NOT execute DB changes, write
decision files, or create tickets until the team lead
explicitly tells you to. Your first message should confirm
you are ready and describe your execution plan. Then wait.
9. EXECUTE EXACTLY WHAT IS DIRECTED: When the team lead sends
you a list of changes, execute them precisely. Do not add
extra changes, reinterpret the instructions, or fill gaps
with your own judgment. If something is ambiguous, ask
before executing.
```
#### 8e. Report
Output to the user:
- Team name: `sprint-{N}-{team}`
- Agents spawned (names and roles)
- Tasks created (count actionable vs blocked)
- How to interact: `SendMessage` to talk to agents, `TaskList` to
check progress
You are now the team lead. Agents work autonomously — monitor via
`TaskList`, communicate via `SendMessage`, and handle blockers as
they arise.
**When all tasks complete:** Do NOT shut down agents. The team stays
alive through PR review AND merge. Follow step 9 (post-work lifecycle).
### 9. Post-work lifecycle
When all tasks are complete (TaskList shows all completed):
#### 9a. Commit and push
Run `/git-commit` to commit all changes, then `/pr-push` to create or
update the PR. Do NOT shut down agents — the team stays alive through
review and merge.
#### 9b. Wait for review
Do NOT run `/pr-review` from the team window — PR reviews run from
the `main` branch (a separate window/session). The team window stays
on its sprint branch.
After pushing and creating the PR, report the PR number to the user
and stop. Wait for review feedback to arrive (the user or the main
session will relay it, or it will appear as Gitea PR comments).
#### 9c. Handle review outcome
When review feedback arrives (from the user, main session, or PR
comments):
**If CHANGES_REQUESTED:**
1. Parse the review comment table (from the Gitea PR comment or the
review output). Extract each issue with:
- File path and approximate line
- Description
2. Create a task per issue:
```
TaskCreate(
subject: "Review: {short description}",
description: "{full issue description from review table, including
file path and reviewer name}",
activeForm: "Fixing review comment: {short description}"
)
```
Every comment is actionable — there is no "suggestion" tier to skip (per pr-review policy).
3. Dispatch to idle agents: send each a message via SendMessage telling
them to check TaskList for new review-fix tasks. Agents claim and
work tasks as usual.
4. After all review-fix tasks are complete, re-run `/git-commit` then
`/pr-push` to update the PR. Then re-run `/pr-review`.
5. Repeat this loop until review returns APPROVED.
**If APPROVED (but not yet merged):**
Do NOT shut down. Approval alone is not terminal — reviewers can leave
follow-up comments, the PR can be re-reviewed, or merge conflicts can
surface. Keep the team alive and idle until the PR is merged into main.
1. Report to the user: "Sprint {N} {team} PR #{X} approved. Awaiting
merge. Team remains alive."
2. Agents stay idle. Do not reassign them to unrelated work.
3. Periodically check merge state (or wait for the user to confirm the
merge). The `main` session handles the merge itself.
4. If new review comments arrive between approval and merge, treat it
as CHANGES_REQUESTED and re-enter the fix loop.
5. Once the PR is merged, proceed to 9d.
#### 9d. Handle merge completion
When the PR is confirmed merged into main (user confirmation, Gitea
state change, or the `main` session reports the merge):
1. Send `shutdown_request` to all sprint agents.
2. Wait for all `shutdown_response` confirmations.
3. Call `TeamDelete` to clean up.
4. Report: "Sprint {N} {team} complete. PR #{X} merged into main. Team
shut down."
@@ -1,34 +0,0 @@
#!/bin/bash
# Clean up ephemeral worktrees for a closed sprint.
# Usage: sprint-teardown.sh <sprint-number>
#
# Removes all worktrees under .sprint/sprint-{N}/ and prunes git metadata.
# Safe to run multiple times — skips already-removed worktrees.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
SPRINT_BASE="$(dirname "$REPO_ROOT")/.sprint"
SPRINT=${1:?Usage: sprint-teardown.sh <sprint-number>}
SPRINT_DIR="$SPRINT_BASE/sprint-${SPRINT}"
if [ ! -d "$SPRINT_DIR" ]; then
echo "No worktrees found for sprint-${SPRINT} (directory $SPRINT_DIR does not exist)."
exit 0
fi
echo "Cleaning sprint-${SPRINT} worktrees..."
for wt in "$SPRINT_DIR"/*/; do
[ -d "$wt" ] || continue
team="$(basename "$wt")"
echo " Removing: $team"
git -C "$REPO_ROOT" worktree remove "$wt" --force 2>/dev/null || echo " (already removed or dirty)"
done
rmdir "$SPRINT_DIR" 2>/dev/null || true
git -C "$REPO_ROOT" worktree prune
echo "Done. Sprint-${SPRINT} worktrees cleaned."
@@ -1,65 +0,0 @@
#!/bin/bash
# Opens Ptyxis tabs for the active sprint teams in the CURRENT window.
# Auto-starts Claude Code in each tab via tmux.
#
# Usage: start-sprint.sh [sprint-number]
# If omitted, auto-detects the active sprint from the database.
#
# Assumes the caller is already on main in the current terminal.
# Adds one tab per active team — no duplicate main tab.
#
# Worktrees are created under .sprint/ (ephemeral, cleaned after sprint close).
# Can be called from any directory — resolves paths from the script location.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Script lives at .claude/skills/sprint-start/scripts/ — repo root is 4 levels up
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
# Parent of repo root is where .sprint/ and the DB live
PARENT="$(dirname "$REPO_ROOT")"
# Resolve sprint number — argument or active sprint from DB
if [ -n "${1:-}" ]; then
SPRINT="$1"
else
SPRINT=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT id FROM sprints WHERE status='active'" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['rows'][0]['id'])" 2>/dev/null || true)
if [ -z "$SPRINT" ]; then
echo "error: no active sprint found. Pass a sprint number or activate a sprint first." >&2
exit 1
fi
fi
# Query teams with open tickets
TEAMS=$(cd "$REPO_ROOT" && tooling/db/sqlite-query "SELECT DISTINCT team FROM tickets WHERE sprint_id=$SPRINT AND status NOT IN ('done','cancelled') AND team IS NOT NULL" 2>/dev/null | python3 -c "import sys,json; [print(r['team']) for r in json.load(sys.stdin)['rows'] if r['team']]" 2>/dev/null || true)
if [ -z "$TEAMS" ]; then
echo "Sprint $SPRINT has no open tickets. Opening main only."
fi
echo "Sprint $SPRINT — teams: ${TEAMS:-none}"
# ── Open team tabs in the CURRENT window ────────────────────────────
# No separate main tab — the caller is already on main.
# All team tabs open as --tab in the active Ptyxis window.
for team in $TEAMS; do
BRANCH="sprint-${SPRINT}/${team}"
WDIR="$PARENT/.sprint/sprint-${SPRINT}/${team}"
# Create worktree if it doesn't exist
if [ ! -d "$WDIR" ]; then
echo "Creating worktree: $WDIR (branch: $BRANCH)"
mkdir -p "$(dirname "$WDIR")"
# Create branch from main if it doesn't exist remotely
if git -C "$REPO_ROOT" rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
git -C "$REPO_ROOT" worktree add "$WDIR" "$BRANCH"
else
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WDIR" HEAD
fi
fi
ptyxis --tab -d "$WDIR" -x 'tmux new-session \; send-keys "claude /sprint-start" Enter'
sleep 0.3
done
echo "Session ready. Main + ${TEAMS:-(no teams)}"
-94
View File
@@ -1,94 +0,0 @@
---
name: sprint-status
description: >
Sprint health check and cleanup sweep. Lists all tickets in the active
sprint grouped by status, detects bookkeeping issues (stale tickets,
orphan PRs, done-but-open PRs, unassigned work), and shows open work
by team. Use when checking sprint progress, before sprint close, or
when housekeeping feels off. Triggers on "sprint status", "cleanup
sweep", "what's open", "sprint health".
user-invocable: true
allowed-tools: Task, Read, Grep, Glob
---
# Sprint Status
**Delegate this entire skill to a subagent** (general-purpose, model: haiku).
When this skill is invoked, spawn a subagent using the Task tool:
```
Task(
subagent_type: "general-purpose",
model: "haiku",
prompt: "Run /sprint-status. Read the skill at
.claude/skills/sprint-status/SKILL.md for the full workflow
(below the --- separator), then execute it.",
description: "Sprint status report"
)
```
Present the subagent's output to the user verbatim. Do NOT run the
workflow yourself.
---
The remainder of this file is the subagent's reference for executing
the workflow.
## Step 1 — Gather data
Run these two commands in parallel:
```bash
tooling/db/sprint sweep
```
```bash
tea pr list --login schweitz --repo jpmschweitzer/settled-reach --state open --output simple
```
The `sweep` command returns JSON with:
- `sprint` — id, name, goal
- `progress` — total, done, pct
- `by_status` — tickets grouped into done, review, in_progress, blocked, backlog
- `by_team` — per-team counts
- `issues` — bookkeeping problems with suggested fix commands
The `tea pr list` returns open PRs as `#N title` lines.
## Step 2 — Cross-reference PRs with tickets
Parse PR head branches from the `tea pr list` output. Known team branches:
`server`, `client`, `copy`, `audio`, `visual`, `ci`.
Detect additional issues:
- **done_team_open_pr**: A team's tickets are all done but an open PR
still exists for that team branch.
- **orphan_pr**: An open PR exists on a branch that has no tickets in
the active sprint.
Add these to the issues list from step 1.
## Step 3 — Format output
Read `references/output-template.md` for the exact format spec.
Render the report using data from steps 1-2. Key rules:
- Sections ordered: Completed, In Review, In Progress, Blocked, Backlog
- Sort tickets within sections by team then ticket ID
- Empty sections: show header with "(0)" and "(none)" — no empty table
- Bookkeeping Issues: two-column table (Issue, Fix)
- Open Work by Team: summary table at the bottom
- Issue type labels: `stale_backlog` → "Stale backlog",
`unassigned_in_progress` → "Unassigned in_progress",
`assigned_but_done` → "Assigned but done",
`done_team_open_pr` → "Done team with open PR",
`orphan_pr` → "Orphan PR"
## Step 4 — Suggest actions
After the formatted report, if there are bookkeeping issues, add a
"Suggested fixes" section with the fix command for each issue. Group
by issue type for readability.
@@ -1,54 +0,0 @@
# Sprint Status Output Template
## Sprint {N}: {Theme} — Status Report
**Goal:** {goal}
**Status:** {status} | {done}/{total} tickets ({pct}%)
**Open PRs:** {count} ({branches})
---
### Completed ({count})
| # | Team | Title | Assigned |
|---|------|-------|----------|
| #{id} | {team} | {title} | {assigned} |
### In Review ({count})
| # | Team | Title | PR |
|---|------|-------|----|
| #{id} | {team} | {title} | #{pr} |
### In Progress ({count})
| # | Team | Title | Assigned | Note |
|---|------|-------|----------|------|
| #{id} | {team} | {title} | {assigned} | |
### Blocked ({count})
| # | Team | Title | Blocked by |
|---|------|-------|------------|
| #{id} | {team} | {title} | #{ids} |
### Backlog ({count})
| # | Team | Title | Note |
|---|------|-------|----|
| #{id} | {team} | {title} | not started |
---
### Bookkeeping Issues
| Issue | Fix |
|-------|-----|
| {type}: {detail} | `{command}` |
### Open Work by Team
| Team | Backlog | In Progress | Review | Blocked | Done |
|------|---------|-------------|--------|---------|------|
| {team} | {n} | {n} | {n} | {n} | {n} |
| **Total** | **{n}** | **{n}** | **{n}** | **{n}** | **{n}** |
+29 -20
View File
@@ -1,9 +1,9 @@
---
name: ticket
description: >
Manage project tickets in the SQLite ticketing database. Use when the user
says "ticket", "create a ticket", "show tickets", "sprint", or invokes /ticket.
Wraps the ticket CLI for structured project management operations.
Manage project tickets and milestones in the SQLite ticketing database. Use
when the user says "ticket", "create a ticket", "show tickets", "milestone",
or invokes /ticket. Wraps the ticket CLI for structured project management.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob
---
@@ -17,12 +17,12 @@ section. This skill covers the full command reference.
### List tickets (full flags)
```bash
tooling/db/ticket list [--status S] [--priority P] [--epic N] [--sprint N] [--assigned A] [--team T]
tooling/db/ticket list [--status S] [--priority P] [--epic N] [--milestone N] [--assigned A] [--team T]
```
### Create ticket
```bash
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T]
tooling/db/ticket create <type> <title> [--parent N] [--priority P] [--decision D] [--team T] [--description TEXT]
```
Types: `initiative`, `epic`, `story`, `task`, `bug`
Priorities: `critical`, `high`, `medium`, `low`
@@ -46,18 +46,27 @@ tooling/db/ticket team <id> <teams>
```
Teams are comma-separated, e.g. `server`, `client`, `server,client`.
### Sprint management
```bash
tooling/db/ticket sprint [--active]
tooling/db/ticket sprint assign <id> <sprint_id>
```
For sprint-scoped operations (status overview, context dumps, lifecycle),
use the dedicated sprint CLI instead: `tooling/db/sprint --help`
### Dependencies
```bash
tooling/db/ticket deps <id>
tooling/db/ticket dep add <blocker_id> <blocked_id>
tooling/db/ticket dep rm <blocker_id> <blocked_id>
```
### WIP
```bash
tooling/db/ticket wip
```
### Milestones
```bash
tooling/db/ticket milestone list [--status S]
tooling/db/ticket milestone create <name> [--description TEXT] [--phase N]
tooling/db/ticket milestone link <ticket_id> <milestone_id>
tooling/db/ticket milestone unlink <ticket_id> <milestone_id>
tooling/db/ticket milestone complete <milestone_id>
tooling/db/ticket milestone show <milestone_id>
tooling/db/ticket milestone dep <blocker_id> <blocked_id>
```
### Search and browse
@@ -75,10 +84,10 @@ tooling/db/ticket show --brief <id> [<id>...]
## Workflow
1. **SI (Project Manager)** is the primary user of this skill
2. Decisions from decisions/ domain files become **initiatives**
3. SI breaks initiatives into **epics** (major work areas)
4. Epics break into **stories** (user-facing deliverables)
5. Stories break into **tasks** (concrete work items assignable to agents)
6. **Sprints** group tasks into time-boxed work periods
1. Decisions from `decisions/` domain files become **initiatives**
2. Initiatives break into **epics** (major work areas)
3. Epics break into **stories** (user-facing deliverables)
4. Stories break into **tasks** (concrete work items)
5. **Milestones** group tickets by deliverable (many-to-many via `milestone link`)
6. `/whats-next` selects the next batch from the dependency graph
7. Track history via `ticket_history` table for audit trail
+211
View File
@@ -0,0 +1,211 @@
---
name: whats-next
description: >
Analyze the milestone dependency web and surface the best batch of tickets
to pick up next. Refines ticket context via parallel Si agents before
activation. Use when the user says "what's next", "next batch", "pick up
work", or invokes /whats-next. NOT triggered by "what should we work on"
in a design context — that's a discussion, not a batch selection.
user-invocable: true
allowed-tools: Bash, Read, Grep, Glob, Agent, AskUserQuestion
---
# What's Next
Dependency-driven batch selection with ticket refinement. Three steps:
batch selection → refinement review → batch activation.
## Step 1: Batch Selection
### 1a. Query active milestones
```bash
tooling/db/ticket milestone list --status active
```
If no active milestones exist, tell the user and stop.
### 1b. Build the work landscape
For each active milestone, get its tickets:
```bash
tooling/db/ticket milestone show <milestone_id>
```
Collect all tickets across all active milestones. Deduplicate (a ticket
can appear in multiple milestones).
### 1c. Identify unblocked tickets
For each ticket that is `ready` or `backlog`, check its dependencies:
```bash
tooling/db/ticket deps <id>
```
A ticket is **unblocked** if all its blockers are `done` or `cancelled`.
Filter to only unblocked tickets.
### 1d. Check milestone-level blocking
```bash
tooling/db/sqlite-query "SELECT * FROM milestone_deps"
```
If a milestone is blocked by another milestone that is not yet `completed`,
deprioritize its tickets (they're unblocked at the ticket level but the
milestone isn't ready for focus yet). Still show them, but ranked lower.
### 1e. Rank and group
Rank unblocked tickets by:
1. **Priority** (critical > high > medium > low)
2. **Milestone proximity** — milestones closest to completion (highest
done/total ratio) get priority. Finishing a milestone unlocks downstream
milestone deps.
3. **Fan-out** — tickets that unblock the most downstream tickets rank
higher (query `ticket_deps` for blocked_id counts per blocker_id)
Group into **epic-sized batches**: tickets sharing the same `parent_id`
(epic), or logically related tickets if no epic parent. If a natural
grouping doesn't exist, batch by milestone.
### 1f. Show WIP status
```bash
tooling/db/ticket wip
```
### 1g. Present the recommended batch
Show the user:
- The recommended batch (tickets with IDs, titles, priorities, milestones)
- Why this batch (which milestone it advances, what it unblocks)
- Current WIP status
- Any alternative batches worth considering
Wait for user confirmation before proceeding to Step 2.
---
## Step 2: Ticket Refinement Review
Spawn one Si agent per ticket in the confirmed batch, running in parallel.
Use `general-purpose` subagent_type with Si's personality baked into the
prompt. (The `si` subagent_type now works directly too — all agent
definitions carry the team coordination tools, SendMessage + Task, as of the
agent-teams tooling fix; they load at lead startup.)
For each ticket, spawn:
```
Agent({
subagent_type: "general-purpose",
model: "sonnet",
prompt: "You are SI, the Refinement Manager. <include si.md personality>
Review ticket #<id>: <title>
Description: <description>
Decision ref: <decision_ref>
Your job:
1. Read the ticket's decision_ref D/Q-record in decisions/*.md
2. Grep for related Q-records in decisions/questions-*.md
3. Read workshop outcomes if referenced (check docs/workshops/)
4. Check whether referenced code, tables, or files actually exist
Assess: does this ticket have enough context for an agent to implement
without guessing? Report exactly one of:
- READY: <one-paragraph summary of what the implementer needs to know>
- GAPS: <list of specific ambiguities with options for each>"
})
```
### 2a. Collect reports
Wait for all Si agents to complete. Collect their reports.
### 2b. Resolve gaps
For any ticket that came back GAPS, surface each ambiguity to the user
via AskUserQuestion:
> Ticket #NNN: <title>
> Si found these gaps:
> 1. <gap description + options>
> 2. <gap description + options>
>
> How should we resolve these?
After the user responds, append the resolution to the ticket description:
```bash
tooling/db/sqlite-exec "UPDATE tickets SET description = description || char(10) || char(10) || '---' || char(10) || 'Refinement: <resolution>' WHERE id = <id>"
```
If the user says a gap requires a D-record, flag it and ask whether to
write the D-record now or defer. If deferred, add a note to the ticket.
### 2c. Present refined batch summary
Show each ticket with:
- Si's context summary (for READY tickets) or user's resolution (for GAPS)
- Relevant D-record references
- Dependencies
Ask: "Batch ready. Activate?"
---
## Step 3: Batch Activation
### 3a. Mark tickets in_progress
```bash
tooling/db/ticket status <id> in_progress
```
For each ticket in the batch. The `status` command will warn if WIP limit
is exceeded.
### 3b. Create topic branch
Name the branch after the epic or logical grouping. Examples:
- `tier0-schema-tables` (for a schema epic)
- `heightmap-import` (for a single focused ticket)
- `city-names-pipeline` (for related importer tickets)
```bash
git checkout -b <branch-name>
```
### 3c. Create worktree (optional)
If the user wants to work in a separate worktree:
```bash
REPO_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_DIR="$(dirname "$REPO_ROOT")/.worktrees/<branch-name>"
git worktree add "$WORKTREE_DIR" <branch-name>
```
Report the worktree path so the user can open a terminal there.
### 3d. Spawn implementation agents (optional)
If the user wants agents, spawn them using general-purpose subagent_type
with the relevant personality baked into the prompt. Include:
- The ticket details and Si's context summary
- Relevant D-record content (read and include, don't just reference)
- The RULES block (no git commands, write files only, message when blocked)
- Model: sonnet (per team-mode pin rule)
### 3e. Report
Summarize what was activated:
- Branch name
- Worktree path (if created)
- Tickets now in_progress
- Agents spawned (if any)
- Next step: work, then `/pr-process` when ready to ship
+35 -2
View File
@@ -87,10 +87,12 @@ elif command -v cargo >/dev/null 2>&1 && [ -d "$REPO_ROOT/server" ]; then
echo "pre-push: fmt — OK"
fi
# clippy needs a build — skip if target/ doesn't exist (cold worktree)
# clippy needs a build — skip if target/ doesn't exist (cold worktree).
# --all-targets lints tests + examples too (#967 closed the gap where test
# code accumulated clippy debt unchecked).
if [ -d "$REPO_ROOT/server/target" ]; then
echo "pre-push: checking Rust (clippy)..."
if ! (cd "$REPO_ROOT/server" && cargo clippy -- -D warnings 2>&1); then
if ! (cd "$REPO_ROOT/server" && cargo clippy --all-targets -- -D warnings 2>&1); then
ERRORS=$((ERRORS + 1))
else
echo "pre-push: clippy — OK"
@@ -190,6 +192,37 @@ else
echo "pre-push: systems.db not in push — skipping stamp check"
fi
# --- Clerk review (D-221) ---
# DISABLED 2026-05-23 (#965) pending rework. Two problems made it net-negative:
# 1. Non-exhaustive — a single run reports ~the first contradiction it finds
# and stops, so distinct real issues surfaced only on the 2nd/3rd/4th
# re-push. An APPROVED verdict therefore can't be trusted as "clean".
# 2. Re-reviews the entire origin/main..HEAD range on every push (token burn);
# no per-commit verdict cache.
# Off by default until reworked (exhaustive enumeration + holistic decisions/
# diff check + a SHA-keyed verdict cache invalidated on decisions/ change, with
# skip tracked in the cache rather than a commit-message trailer — see #965).
# Force-run with SR_RUN_CLERK=1.
if [ -x "$REPO_ROOT/tooling/clerk-review" ] && [ "${SR_RUN_CLERK:-0}" = "1" ]; then
echo "pre-push: running clerk review..."
CLERK_VERDICT=$("$REPO_ROOT/tooling/clerk-review" 2>&1 | tee /dev/stderr | tail -1)
if [ "$CLERK_VERDICT" = "APPROVED" ]; then
echo "pre-push: clerk — APPROVED"
elif [ "$CLERK_VERDICT" = "REJECTED" ]; then
echo "pre-push: clerk — REJECTED (see .cache/pre-push-review.md)"
ERRORS=$((ERRORS + 1))
elif [ "$CLERK_VERDICT" = "INCOMPLETE" ]; then
echo "pre-push: clerk — INCOMPLETE (some reviews didn't finish; NOT blocking)"
echo " See .cache/pre-push-review.md. For a full verdict: raise SR_CLERK_MAX_TURNS / SR_CLERK_TIMEOUT,"
echo " or add a 'Clerk-Skip:' trailer to bulk-content commits."
else
echo "pre-push: clerk — '$CLERK_VERDICT' unrecognized; treating as block (see .cache/pre-push-review.md)"
ERRORS=$((ERRORS + 1))
fi
else
echo "pre-push: clerk review — DISABLED (#965; force-run with SR_RUN_CLERK=1)"
fi
if [ "$ERRORS" -gt 0 ]; then
echo ""
echo "pre-push: $ERRORS check(s) failed. Push aborted."
+32
View File
@@ -6,6 +6,38 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- **Kanban + milestones workflow** (D-221) — replaces sprint-based workflow with dependency-driven batch selection, many-to-many milestones, `/whats-next` skill with Si refinement review, `/pr-process` skill (author-side review comment pickup), clerk pre-push hook for D-record consistency
- **Atlas orbital redesign** — horizontal orrery layout with half-sun on left, globe textures loaded from wiki, radius-based body sizing (Earth = 24px reference, unclamped), partial orbital arcs
- **Globe texture coverage** — 100% of 3240 bodies now have globe.png; generated oort cloud + asteroid belt generics via Gemini, donor-filled 40 missing bodies from same-class pool
- **Body radius scatter** — deterministic ±scatter on `body_radius_km` seeded by body_id; gas giants 40k-60k km, moons 200-2600 km, Sol system uses real radii; exported to `star_map_data.json`
- **Atlas interaction** — double-click system on reach map opens orbital view, double-click body opens regional heightmap, M closes atlas from any screen, ESC navigates back through screen stack
- **Inline body detail panel** — clicking a body in the orbital view shows an info panel on the right with full-size globe artwork, body stats, and population; clicking another body swaps the panel
- **Regional atlas wiki loading** — heightmaps and markers load directly from the wiki filesystem, fixing "TERRAIN DATA PENDING" for all bodies with authored heightmaps
- **Agent-team coordination tools** — `SendMessage` plus `TaskList`/`TaskUpdate`/`TaskGet` added to all 21 agent definitions so custom subagents work as full teammates in tmux split panes (message the lead, claim/complete tasks, shut down cleanly); `TaskCreate` deliberately withheld to keep task creation with the lead
- **`git-centralize-guard` hook** — PreToolUse hook blocking `.git`-mutating commands for agent-team teammates, keying on the `agent_type` field present only in teammate hook input; version control stays centralized to the team lead
- **Gemma naming methodology doc** (`docs/gemma-naming-methodology.md`) — preserves the corridor-aware LLM place-naming approach (sector palettes, few-shot prompting, dedup, Earth-major blocklist, deterministic fallback) as institutional knowledge after the naming pipeline's retirement
- **Layer-1 world topography cascade** (#953, D-208/209/210) — deterministic empty-world generation in Rust: D8 priority-flood drainage (rivers/basins, ~45 ms/body after optimization), 7 geographic settlement-attractor tags, and sub-biome + terrain-modification-cost classification. The foundation the settlement-placement layer builds on
- **Canonical 16-bit heightmaps** (#963, D-202) — every body's elevation is now a per-body 16-bit grayscale `heightmap.png` (1024×512, `sea_level` in a tEXt chunk); the old color render is renamed `reliefmap.png`. Single source of truth the generation cascade reads
- **Deterministic generation cascade** (#952, D-224/D-200) — `SeedChain`, a single splitmix64-based seed-derivation contract descending from the master world seed with domain separation (replaces ad-hoc seed mixing); an extensible per-layer cascade harness (`run_cascade`); and a golden-seed regression test pinning Layer 0→1 output (source heightmap SHA-256 + serialized topography) so any determinism drift is caught
### Changed
- `teammateMode` set to `auto` — teammates spawn in tmux split panes when the lead runs inside tmux, with graceful in-process fallback
- **Planet simulation native resolution → 1024×512** (#963) — finer base terrain for the lower cascade layers (deterministic; same continents, more detail). Rivers are now computed by the cascade's drainage rather than painted by the sim, so they actually reach the sea
- **markers.json → names-only flavored pool** (D-223, #951) — all 2,398 bodies carry place *names* only, no positions or geometry; river/mountain positions derive from the heightmap + drainage, settlement positions from the economic sim + placement, with pooled names attached at generation time. The six hand-authored "templates" dissolve into ordinary named bodies
- **`import_economics` owns the atlas index** (#951) — now the sole `regen-db` generator that touches the atlas tables; loads the names pool into `atlas_city_names` and empties the geometry tables, which the Phase 4 server cascade fills
- **Rust dependency maintenance** (#966) — `rand` → 0.9.4 (clears the RUSTSEC-2026-0097 unsound advisory), a ~90-package semver-compatible freshness sweep (bevy 0.18.1, clap 4.6, others), and the deprecated `serde_yaml` replaced with the maintained `serde_norway` drop-in fork. No CVEs outstanding
- **Pre-push clippy now lints tests + examples** (#967) — added `--all-targets`, closing the gap that let test-code lint debt accumulate unchecked; the existing cfg(test)/test-target clippy-1.93 debt was cleared in the same pass
### Fixed
- Godot 4.6 type inference parse errors in `main.gd` (`var zoom`, `var speed`) that prevented the main scene from loading
- `physical_keycode` used throughout for layout-independent key handling (was `keycode`, broke on non-US layouts)
### Removed
- Sprint CLI (`tooling/db/sprint`), sprint skills (`/sprint-start`, `/sprint-plan`, `/sprint-status`), team-scoped file restrictions, sprint briefing workflow
- **Atlas geometry generator + LLM naming pipeline** (#951, D-223) — `generate_atlas.py`, `gemma_naming.py`, `naming_core.py`, `apply_name_fixes.py`, the redundant `import_city_names.py`, their tests/QA/runner scripts, and the `fix_fewshot_bleed`/`prune_atlas_features` geometry tools. Procedural placement and authored names are superseded by the deterministic server cascade and the frozen name pool
- **`atlas_body_heightmaps` DB table** (#963, D-202) — elevation moved out of `systems.db` into per-body PNG files (avoids binary merge conflicts and DB bloat); the Rust cascade loads the file
## [v0.2.0] — 2026-05-03
*Process milestone: final sprint-based release. Development moves to kanban + milestones (Q-096).*
+16 -16
View File
@@ -4,7 +4,7 @@ A top-down life-sim — asymmetric information, occlusion-based perception, sing
**Official Title:** The Settled Reach
**Repository name:** settled-reach
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.1.{sprint_number}`)
**Version source of truth:** `project.yaml` (root `version` field, scheme: `0.{phase}.{n}`)
## Project Structure
@@ -13,7 +13,7 @@ client/ # Godot 4 client
server/ # Rust/bevy_ecs simulation server
tooling/ # Build tools, scripts, asset pipelines
tests/ # Integration and end-to-end tests
docs/ # Architecture, design, briefings, sprints, workshops
docs/ # Architecture, design, briefings, workshops
db/ # Schema + seed data (connectors moved to tooling/db/)
.claude/ # Agents, skills, rules
decisions/ # Decision domain files (D-NNN confirmed, Q-NNN open, R-NNN rejected)
@@ -40,16 +40,18 @@ Development follows a strict cascade. Each phase has a concrete deliverable. **D
| 1 | Wiki content complete — all planets, moons, stations, heightmaps, artwork | Implant-ready Godot map of the Reach with click-throughs + wiki/GTTR popups |
| 2 | Economics layer — supply/demand, transport, political/social pressure, corporations, supply chains | Economics spreadsheets/graphs with runtime-tweakable simulation |
| 3 | Planetary/moon maps & station layouts — cities, rivers, mountains, roads, biomes, rail | Atlas of the Reach (implant app) |
| 4 | Player control scheme — 2-floor test map, character rendering, walls/stairs/doors, lighting | Player viewport with final-version assets |
| 5 | World generation (tile/chunk/block) — walkable world, parallel asset pipeline | Walkable generated world + asset catalog |
| 4 | World generation (tile/chunk/block) — deterministic multilayer seed→tile cascade; each layer viewed as a **map in the implant Atlas** (no in-world rendering) | Deterministic walkable-world data + per-layer Atlas maps + asset catalog |
| 5 | Player control & in-world rendering — character, walls/stairs/doors, lighting, drawn on **generated** tiles (no test map) | Player viewport with final-version assets on the generated world |
| 6 | Detail coloring — room-level NPC population, cultural room grammar | Only when the world is walkable |
**v0.2 target is dropped.** No scoping negotiations. Build the base systems fully.
**Generation before player (D-166, amended 2026-05-22).** No player-control or in-world rendering work begins until the generator can deterministically seed-generate every tile of every world via the full multilayer cascade. There is **no hand-made test map** — test layers are produced by the generator itself once layer-drawing begins. Progress is viewed through the implant **Atlas** (per-layer maps), which already exists (Phase 3). The current in-world rendering code is **left as-is until Phase 5** — do not build on it or rip it out before then.
## Work Modes
### Sprint mode (default)
Agents work autonomously on sprint branches. Team lead coordinates via tasks and messages. Human reviews PRs from main. Standard `/sprint-start` → `/pr-push` → `/pr-review` lifecycle.
### Kanban mode (default)
Work flows continuously through milestones. `/whats-next` selects the next epic-sized batch from the dependency graph, refines ticket context via parallel Si agents, then activates a worktree. `/pr-process` (from worktree) and `/pr-review` (from main) handle the review cycle through Gitea PR comments. Milestones are many-to-many with tickets, can run in parallel, and can block other milestones.
### Pair session
Human and Claude work together interactively on a single task. No background agents, no autonomous work. Used for load-bearing architecture changes where the human needs to make judgment calls as the work progresses — not approve a finished result.
@@ -65,13 +67,10 @@ Human and Claude work together interactively on a single task. No background age
## Agent Instructions
### Team boundaries
**Your team is determined by your sprint branch** (e.g. `sprint-31/server` → server team).
### Agent scope
- All file paths are relative to the repo root (e.g. `server/src/bridge/types.rs`).
- **Stay within your team's scope.** Server team modifies `server/`. Client team modifies `client/`. Copy team modifies `wiki/`, `docs/atlas/`, `content/`. Shared directories (`docs/`, `decisions/`) are readable by all teams.
- **Do NOT modify files outside your team scope** unless the ticket explicitly requires it.
- Topic branches per logical work unit (e.g. `heightmap-import`, `city-names-table`). Any agent can touch any file — personalities guide perspective, not file access.
- **Never chain git commands** in a single Bash call (e.g. `git add ... && git commit ...`). Always run `git add` and `git commit` as **separate sequential Bash calls**.
- **Stale git lock files:** If a `git` command fails with `index.lock: File exists`, you may remove the lock file at `.git/index.lock` (or `.git/worktrees/<name>/index.lock` if in a worktree).
@@ -80,10 +79,11 @@ Human and Claude work together interactively on a single task. No background age
The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var (set in `.claude/settings.json`). A backup is committed to `docs/backups/settledreach.db.backup` via main only.
### Before starting work
1. Read your sprint briefing at `docs/sprints/sprint-N/{team}.md` for current tasks
2. Use `tooling/db/ticket show <id>` for full ticket details
3. Read the relevant `decisions/*.md` domain file(s) referenced in the briefing
4. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
1. Check active work: `tooling/db/ticket list --status in_progress`
2. Check your ticket: `tooling/db/ticket show <id>`
3. Check dependencies: `tooling/db/ticket deps <id>`
4. Read relevant `decisions/*.md` domain file(s) referenced in the ticket
5. Background context: `docs/briefings/{your-name}.md`, `docs/discussions/`
### CLI tools
@@ -92,7 +92,7 @@ The ticketing database (`settledreach.db`) is accessed via `SR_DB_PATH` env var
| Tool | Command | Full reference |
|------|---------|----------------|
| Tickets | `tooling/db/ticket list`, `show`, `create`, `assign` | `/ticket` skill |
| Sprints | `tooling/db/sprint status`, `start-work`, `prepare` | `/sprint-start` skill |
| Milestones | `tooling/db/ticket milestone list`, `create`, `link`, `show` | `/ticket` skill |
| SQL queries | `tooling/db/sqlite-query "SELECT ..."` | — |
| SQL writes | `tooling/db/sqlite-exec "UPDATE ..."` | — |
| Decisions | `tooling/db/decision show`, `next`, `claim`, `check-dupes` | — |
+7 -24
View File
@@ -3,7 +3,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
.PHONY: help setup build check-protocol client server game stop test lint lint-python setup-venv ci ci-client ci-server clean \
decisions-sync decisions-coverage decisions-active decisions-orphan decisions-orphan-tickets \
db-backup db-install validate-content check-fact-ids setup-hooks install-hooks \
audit deny atlas-verify economy-db atlas-generate regen-db check-systems-db \
audit deny atlas-verify economy-db regen-db check-systems-db \
pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \
pre-pr-server pre-pr-client pre-pr-content \
fixtures-client fixtures-gauntlet golden-diff golden-update \
@@ -58,11 +58,9 @@ help:
@echo " make star-map-data Regenerate client/data/star_map_data.json from systems.db + wiki"
@echo " make check-star-map Assert star_map_data.json is up to date (part of pre-pr-client)"
@echo " make economy-db Import economics data into systems.db (TOML/JSON → SQLite)"
@echo " make atlas-generate Generate atlas city/road/rail markers for all inhabited bodies"
@echo " make regen-db Regenerate systems.db from all sources + stamp meta table (#855)"
@echo " make check-systems-db Verify systems.db meta stamp matches current generator sources"
@echo " make install-hooks Install pre-push + pre-commit git hooks (once per clone)"
@echo " make test-atlas-determinism Determinism smoke test for generate_atlas.py (#847)"
@echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)"
@echo " make golden-diff Show diff if golden file output has changed"
@echo " make golden-update Regenerate golden file and stage for commit"
@@ -211,8 +209,9 @@ golden-diff:
fi
golden-update:
@echo "Regenerating golden file..."
@echo "Regenerating golden files..."
@cd server && UPDATE_GOLDEN=1 cargo test --test golden_suite -- proof_room_tick_10_matches_golden
@cd server && UPDATE_GOLDEN=1 cargo test --test cascade_golden
@git add server/tests/golden/
@echo "--- Golden file updated and staged ---"
@echo "Review with: git diff --cached -- server/tests/golden/"
@@ -232,9 +231,6 @@ test-ipc-integration:
test-ipc-benchmark:
tests/run-ipc-benchmark
test-atlas-determinism: ## Determinism smoke test for generate_atlas.py (#847)
tests/run-atlas-determinism
# --- Clean ---
clean-imports:
@@ -345,20 +341,6 @@ economy-db: ## Import economics data (commodities, chains, gate links) into sys
@tooling/generate-brands
@python3 tooling/economy-db/import_economics.py
atlas-generate: ## Generate atlas markers (cities, roads, rail) for all inhabited bodies (#832)
@# Loud guard: generate_atlas.py reads bodies with a non-NULL terrain_reference.
@# If populate_terrain_reference.py has not run on a fresh DB, the generator
@# silently processes zero bodies and exits 0 — fail fast instead.
@count=$$(python3 -c "import sqlite3; c = sqlite3.connect('server/data/systems.db'); print(c.execute('SELECT COUNT(*) FROM bodies WHERE terrain_reference IS NOT NULL').fetchone()[0])"); \
if [ "$$count" = "0" ]; then \
echo "ERROR: no bodies have terrain_reference populated yet."; \
echo "Run: python3 tooling/planet-gen/populate_terrain_reference.py"; \
echo "(This is a prerequisite for atlas-generate — see D-191 §9 pipeline order.)"; \
exit 1; \
fi; \
echo " [guard] $$count bodies with terrain_reference — proceeding."
@python3 tooling/planet-gen/generate_atlas.py --seed 42
regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855, #856)
@# Run as a single shell so `set -e` covers all steps. Without this
@# each recipe line was a fresh shell and a failure in step 1 did not
@@ -366,13 +348,14 @@ regen-db: ## Regenerate systems.db from all sources and stamp meta table (#855,
@# (PR #136 review T4). import_economics' exit code 2 is a valid
@# coverage-gate-warning state (DB and stamp committed), not an error,
@# so it's explicitly tolerated. Any other non-zero exit halts the
@# pipeline immediately.
@# pipeline immediately. The atlas city/road/river geometry generator
@# was retired in #951 (D-223); import_economics now owns the atlas
@# index — it loads the names-only pool into atlas_city_names and empties
@# the geometry tables (the server cascade fills them, Phase 4).
@set -e; \
echo " [regen-db] Importing economics data (runs generate_brands internally)..."; \
ec=0; python3 tooling/economy-db/import_economics.py || ec=$$?; \
if [ $$ec -ne 0 ] && [ $$ec -ne 2 ]; then exit $$ec; fi; \
echo " [regen-db] Running atlas generator..."; \
python3 tooling/planet-gen/generate_atlas.py --seed 42; \
echo ""; \
echo " regen-db complete — systems.db is up to date and stamped."; \
echo " Stage it with: git add server/data/systems.db"
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -194,7 +194,7 @@ func _unhandled_input(event: InputEvent) -> void:
if GameState.free_camera_mode and event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.pressed:
var zoom := camera.zoom
var zoom: Vector2 = camera.zoom
if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom += Vector2(FREE_CAMERA_ZOOM_STEP, FREE_CAMERA_ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
@@ -221,7 +221,7 @@ func _unhandled_key_input(event: InputEvent) -> void:
if manifest.app_path.is_empty():
push_warning("main.gd: manifest with empty app_path — skipping")
continue
if manifest.default_key == key_event.keycode:
if manifest.default_key == key_event.physical_keycode:
HudGroups.toggle_app(
manifest.app_path,
ImplantRegistry.get_resolved_mode(manifest.app_path)
@@ -230,10 +230,10 @@ func _unhandled_key_input(event: InputEvent) -> void:
# [ / ] — in-app navigation for the economics monitor. Not manifest-declared because
# these control intra-app navigation (prev/next system), not app launch. A planned
# handle_global_key lifecycle hook will absorb this (see arch doc Follow-up).
if key_event.keycode == KEY_BRACKETLEFT:
if key_event.physical_keycode == KEY_BRACKETLEFT:
if economics_app and HudGroups.is_app_active("implant/economics"):
economics_app.navigate(-1)
elif key_event.keycode == KEY_BRACKETRIGHT:
elif key_event.physical_keycode == KEY_BRACKETRIGHT:
if economics_app and HudGroups.is_app_active("implant/economics"):
economics_app.navigate(1)
@@ -279,7 +279,7 @@ func _process(delta: float) -> void:
if Input.is_action_pressed("move_west"):
pan.x -= 1.0
if pan != Vector2.ZERO:
var speed := FREE_CAMERA_PAN_SPEED / camera.zoom.x
var speed: float = FREE_CAMERA_PAN_SPEED / camera.zoom.x
camera.global_position += pan.normalized() * speed * delta
# Track camera to player (D-015: locked, fixed-north).
# #117: Manual exponential smoothing.
+18 -25
View File
@@ -62,15 +62,19 @@ func _unhandled_key_input(event: InputEvent) -> void:
if not event.is_pressed() or event.is_echo():
return
if current_screen_id() == "regional":
return # AtlasViewer handles its own keyboard input
return
_handle_key(event as InputEventKey)
get_viewport().set_input_as_handled()
func _handle_key(event: InputEventKey) -> void:
match event.keycode:
match event.physical_keycode:
KEY_M:
HudGroups.close_app()
KEY_ESCAPE:
if current_screen_id() == "reach":
if current_screen_id() == "system" and _system_screen and (_system_screen.has_body_panel_open() or _system_screen.has_station_panel_open()):
_system_screen.close_panels()
elif current_screen_id() == "reach":
HudGroups.close_app()
else:
nav.pop()
@@ -93,18 +97,13 @@ func _handle_enter() -> void:
if _system_screen and not _system_screen.is_in_orbital():
var sys: Dictionary = _system_screen.current_system()
nav.replace("system", {"mode": "orbital", "system": sys})
"planet":
if _planet_screen and _planet_screen.has_heightmap():
(
nav
. push(
"regional",
{
"body": _planet_screen.current_body(),
"system": nav.current_payload().get("system", {}),
}
)
)
elif _system_screen and _system_screen.has_body_panel_open():
var body: Dictionary = _system_screen.get_selected_body()
if body.get("terrain_reference") != null:
nav.push("regional", {
"body": body,
"system": nav.current_payload().get("system", {}),
})
# =============================================================================
@@ -121,16 +120,10 @@ func _on_system_selected(system_id: String) -> void:
func _on_body_selected(body: Dictionary) -> void:
(
nav
. push(
"planet",
{
"body": body,
"system": nav.current_payload().get("system", {}),
}
)
)
nav.push("regional", {
"body": body,
"system": nav.current_payload().get("system", {}),
})
func _on_regional_back() -> void:
+29 -16
View File
@@ -98,12 +98,6 @@ const OVERLAY_DEFS: Array = [
"group": "toggle",
"tooltip": "Production zones (D-181 observable — requires presence)."
},
{
"id": "shadow_economy",
"label": "SHD",
"group": "toggle",
"tooltip": "Shadow economy zones (D-181 observable)."
},
{
"id": "corp_presence",
"label": "CRP",
@@ -268,15 +262,19 @@ func _load_heightmap() -> void:
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
var path: String = str(ref)
if not path.begins_with("res://"):
path = "res://" + path.lstrip("/")
if not ResourceLoader.exists(path):
push_warning("AtlasViewer: terrain_reference not found at %s" % path)
# terrain_reference points at <body>/heightmap.png — now the canonical
# 16-bit GRAYSCALE elevation (D-202 amended, #963). For display we load the
# color reliefmap.png that sits beside it.
var path: String = str(ref).get_base_dir() + "/reliefmap.png"
if not path.begins_with("/"):
var project_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
path = project_root + "/" + path
if not FileAccess.file_exists(path):
push_warning("AtlasViewer: reliefmap not found at %s" % path)
return
var tex: Variant = load(path)
if tex is Texture2D:
_heightmap_texture = tex
var img := Image.load_from_file(path)
if img != null:
_heightmap_texture = ImageTexture.create_from_image(img)
_tex_w = float(_heightmap_texture.get_width())
_tex_h = float(_heightmap_texture.get_height())
@@ -292,8 +290,9 @@ func _load_markers() -> void:
if ref == null or str(ref).is_empty():
return
var hm_path: String = str(ref)
if not hm_path.begins_with("res://"):
hm_path = "res://" + hm_path.lstrip("/")
if not hm_path.begins_with("/"):
var project_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
hm_path = project_root + "/" + hm_path
var dir: String = hm_path.get_base_dir()
var markers_path: String = dir + "/markers.json"
@@ -386,6 +385,7 @@ func _build_screen_header() -> void:
# theme-swap invariant violation.
_screen_header = ImplantHeader.new()
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
_screen_header.custom_minimum_size.x = 320.0
_screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_screen_header)
if _implant_theme:
@@ -418,6 +418,14 @@ static func _dict_str(d: Dictionary, key: String, fallback: String) -> String:
# =============================================================================
func _is_over_ui(pos: Vector2) -> bool:
if _overlay_bar and _overlay_bar.get_global_rect().has_point(pos):
return true
if _city_panel and _city_panel.visible and _city_panel.get_global_rect().has_point(pos):
return true
return false
func _gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.is_echo():
_handle_key(event as InputEventKey)
@@ -426,6 +434,9 @@ func _gui_input(event: InputEvent) -> void:
if _heightmap_texture == null:
return
if event is InputEventMouseButton and _is_over_ui((event as InputEventMouseButton).global_position):
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
@@ -441,6 +452,8 @@ func _gui_input(event: InputEvent) -> void:
else:
_dragging = false
elif event is InputEventMouseMotion:
if _is_over_ui((event as InputEventMouseMotion).global_position):
return
var mm := event as InputEventMouseMotion
if _dragging:
_view_offset = _drag_start_offset + (mm.position - _drag_start_mouse)
@@ -438,7 +438,10 @@ func _gui_input(event: InputEvent) -> void:
if mb.pressed:
match mb.button_index:
MOUSE_BUTTON_LEFT:
_handle_reach_click(mb.position)
if mb.double_click:
_handle_reach_double_click(mb.position)
else:
_handle_reach_click(mb.position)
MOUSE_BUTTON_MIDDLE:
_reach_is_panning = true
_reach_pan_start = mb.position
@@ -476,6 +479,13 @@ func _handle_reach_click(pos: Vector2) -> void:
_dirty = true
func _handle_reach_double_click(pos: Vector2) -> void:
var sid := _find_nearest_reach_system(pos)
if not sid.is_empty():
_reach_selected = sid
system_selected.emit(sid)
func _update_reach_hover(pos: Vector2) -> void:
var nearest := _find_nearest_reach_system(pos)
if nearest != _reach_hovered:
@@ -6,14 +6,15 @@ extends Control
signal body_selected(body: Dictionary)
const ORBITAL_CENTER_FRACTION := Vector2(0.5, 0.55)
const STAR_RADIUS: float = 12.0
const ORBITAL_RING_BASE: float = 65.0
const ORBITAL_RING_STEP: float = 58.0
const MOON_ORBIT_RADIUS: float = 24.0
const STATION_SIZE: float = 6.0
const BODY_HIT_RADIUS: float = 16.0
const LABEL_OFFSET: float = 11.0
const STAR_RADIUS: float = 140.0
const STAR_X_OFFSET: float = 0.0
const BODY_LEFT_MARGIN: float = 300.0
const BODY_RIGHT_MARGIN: float = 60.0
const MOON_CLEARANCE: float = 12.0
const STATION_SIZE: float = 8.0
const STATION_OFFSET_Y: float = -26.0
const BODY_HIT_RADIUS: float = 20.0
const LABEL_OFFSET: float = 14.0
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
@@ -35,15 +36,18 @@ var _orbital_bodies: Array = []
var _orbital_stations: Array = []
var _body_positions: Dictionary = {}
var _station_positions: Dictionary = {}
var _body_textures: Dictionary = {} # body_id -> ImageTexture
var _hovered_body: String = ""
var _hovered_station: String = ""
var _selected_station: Dictionary = {}
var _selected_body: Dictionary = {}
var _dirty: bool = true
var _in_orbital: bool = false
var _picker_panel = null # ImplantPanel
var _picker_nav_row = null # ImplantDataRow
var _station_panel = null # ImplantPanel
var _body_panel = null # ImplantPanel
func _ready() -> void:
@@ -62,6 +66,7 @@ func _process(_delta: float) -> void:
func setup(implant_theme) -> void:
_build_picker_panel(implant_theme)
_build_station_panel(implant_theme)
_build_body_panel(implant_theme)
func set_systems(systems: Array) -> void:
@@ -116,11 +121,15 @@ func load_and_show_orbital() -> void:
_hovered_body = ""
_hovered_station = ""
_selected_station = {}
_selected_body = {}
_load_body_textures(sys.get("system_id", ""))
_compute_body_positions()
if _picker_panel:
_picker_panel.visible = false
if _station_panel:
_station_panel.visible = false
if _body_panel:
_body_panel.visible = false
_in_orbital = true
_dirty = true
@@ -157,27 +166,30 @@ func _draw() -> void:
func _draw_orbital() -> void:
var sz: Vector2 = get_rect().size
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
var mid_y: float = sz.y * 0.5
var star_center := Vector2(STAR_X_OFFSET, mid_y)
draw_rect(Rect2(Vector2.ZERO, sz), COLOR_BG)
_draw_orbit_rings(center)
draw_circle(center, STAR_RADIUS + 4.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.18))
draw_circle(center, STAR_RADIUS, COLOR_STAR)
draw_circle(star_center, STAR_RADIUS + 40.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.04))
draw_circle(star_center, STAR_RADIUS + 20.0, Color(COLOR_STAR.r, COLOR_STAR.g, COLOR_STAR.b, 0.08))
draw_circle(star_center, STAR_RADIUS, COLOR_STAR)
_draw_orbital_arcs(star_center)
_draw_stations()
_draw_bodies()
func _draw_orbit_rings(center: Vector2) -> void:
var seen_orbits: Dictionary = {}
func _draw_orbital_arcs(star_center: Vector2) -> void:
for b: Dictionary in _orbital_bodies:
if b.get("parent_body_id") != null:
continue
var idx: int = int(b.get("orbit_index", 0))
if seen_orbits.has(idx):
var bid: String = str(b.get("body_id", ""))
if not _body_positions.has(bid):
continue
seen_orbits[idx] = true
var r: float = ORBITAL_RING_BASE + (idx - 1) * ORBITAL_RING_STEP
draw_arc(center, r, 0.0, TAU, 64, COLOR_RING, 0.5, true)
var body_pos: Vector2 = _body_positions[bid]
var orbit_r: float = body_pos.x - star_center.x
if orbit_r <= 0.0:
continue
draw_arc(star_center, orbit_r, -PI * 0.7, PI * 0.7, 64, COLOR_RING, 0.5, true)
func _draw_bodies() -> void:
@@ -188,36 +200,31 @@ func _draw_bodies() -> void:
continue
var pos: Vector2 = _body_positions[bid]
var body_type: String = b.get("body_type", "")
var atmo: String = b.get("atmosphere", "none")
var inhabited: bool = bool(b.get("inhabited", false))
var color: Color
var radius: float
match body_type:
"moon":
color = COLOR_MOON
radius = 3.5
"oort_cloud":
color = COLOR_OORT
radius = 2.0
_:
if inhabited:
color = COLOR_PLANET_INHABITED
radius = 6.0
elif atmo in ["breathable", "standard"]:
color = COLOR_PLANET_HABITABLE
radius = 5.5
else:
color = COLOR_PLANET_BARE
radius = 4.5
var radius_km: float = float(b.get("body_radius_km", 0))
var radius: float = _km_to_draw_radius(radius_km, body_type)
if bid == _hovered_body:
draw_arc(pos, radius + 5.0, 0.0, TAU, 20, Color(1.0, 1.0, 1.0, 0.25), 1.0, true)
draw_arc(pos, radius + 5.0, 0.0, TAU, 20, Color(1.0, 1.0, 1.0, 0.25), 1.5, true)
draw_circle(pos, radius, color)
var tex: ImageTexture = _body_textures.get(bid)
if tex:
var draw_size := Vector2(radius * 2.0, radius * 2.0)
draw_texture_rect(tex, Rect2(pos - draw_size / 2.0, draw_size), false)
else:
var atmo: String = b.get("atmosphere", "none")
var inhabited: bool = bool(b.get("inhabited", false))
var color: Color
match body_type:
"moon": color = COLOR_MOON
"oort_cloud": color = COLOR_OORT
_:
if inhabited: color = COLOR_PLANET_INHABITED
elif atmo in ["breathable", "standard"]: color = COLOR_PLANET_HABITABLE
else: color = COLOR_PLANET_BARE
draw_circle(pos, radius, color)
var label: String = b.get("proper_name", bid) if b.get("proper_name") else bid
var show_label: bool = inhabited or bid == _hovered_body
var show_label: bool = body_type != "oort_cloud" or bid == _hovered_body
if show_label:
var lcolor: Color = COLOR_TEXT if bid == _hovered_body else COLOR_TEXT_DIM
var lsz: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 9)
@@ -269,7 +276,8 @@ func _compute_body_positions() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var center: Vector2 = sz * ORBITAL_CENTER_FRACTION
var mid_y: float = sz.y * 0.5
var usable_width: float = sz.x - BODY_LEFT_MARGIN - BODY_RIGHT_MARGIN
var top_bodies: Array = []
for b: Dictionary in _orbital_bodies:
@@ -280,30 +288,16 @@ func _compute_body_positions() -> void:
return int(a.get("orbit_index", 0)) < int(b.get("orbit_index", 0))
)
var by_orbit: Dictionary = {}
for b: Dictionary in top_bodies:
var idx: int = int(b.get("orbit_index", 0))
if not by_orbit.has(idx):
by_orbit[idx] = []
by_orbit[idx].append(b)
var count: int = top_bodies.size()
for i: int in range(count):
var b: Dictionary = top_bodies[i]
var bid: String = str(b.get("body_id", ""))
if bid.is_empty():
continue
var t: float = float(i) / float(maxi(count - 1, 1))
var x: float = BODY_LEFT_MARGIN + t * usable_width
_body_positions[bid] = Vector2(x, mid_y)
for orbit_idx: int in by_orbit:
var ring_bodies: Array = by_orbit[orbit_idx]
var ring_r: float = ORBITAL_RING_BASE + (orbit_idx - 1) * ORBITAL_RING_STEP
var count: int = ring_bodies.size()
for i: int in range(count):
var b: Dictionary = ring_bodies[i]
var bid: String = str(b.get("body_id", ""))
if bid.is_empty():
continue
var angle: float
if count == 1:
angle = -PI / 2.0
else:
angle = -PI / 2.0 + TAU * float(i) / float(count)
_body_positions[bid] = center + Vector2(cos(angle), sin(angle)) * ring_r
# Moons per parent count drives spacing — hard-coded divisor caused overlap on gas giants
var moons_by_parent: Dictionary = {}
for b: Dictionary in _orbital_bodies:
var parent_id: Variant = b.get("parent_body_id")
@@ -313,6 +307,10 @@ func _compute_body_positions() -> void:
if not moons_by_parent.has(key):
moons_by_parent[key] = []
moons_by_parent[key].append(b)
var body_dict_by_id: Dictionary = {}
for b: Dictionary in _orbital_bodies:
body_dict_by_id[str(b.get("body_id", ""))] = b
for parent_key: String in moons_by_parent:
if not _body_positions.has(parent_key):
continue
@@ -322,16 +320,23 @@ func _compute_body_positions() -> void:
return int(a.get("orbit_index", 0)) < int(b.get("orbit_index", 0))
)
var parent_pos: Vector2 = _body_positions[parent_key]
var count: int = siblings.size()
for i: int in range(count):
var moon: Dictionary = siblings[i]
var parent_data: Dictionary = body_dict_by_id.get(parent_key, {})
var parent_draw_r: float = _km_to_draw_radius(
float(parent_data.get("body_radius_km", 0)),
parent_data.get("body_type", "")
)
var moon_count: int = siblings.size()
for j: int in range(moon_count):
var moon: Dictionary = siblings[j]
var moon_id: String = str(moon.get("body_id", ""))
if moon_id.is_empty():
continue
var angle: float = -PI / 2.0 + TAU * float(i) / float(count)
_body_positions[moon_id] = (
parent_pos + Vector2(cos(angle), sin(angle)) * MOON_ORBIT_RADIUS
var moon_draw_r: float = _km_to_draw_radius(
float(moon.get("body_radius_km", 0)), "moon"
)
var label_space: float = LABEL_OFFSET + 12.0
var y_off: float = parent_draw_r + label_space + MOON_CLEARANCE + (moon_draw_r * 2.0 + MOON_CLEARANCE) * float(j)
_body_positions[moon_id] = parent_pos + Vector2(0.0, y_off)
for s: Dictionary in _orbital_stations:
var station_id: String = str(s.get("station_id", ""))
@@ -339,11 +344,15 @@ func _compute_body_positions() -> void:
continue
var parent_id: Variant = s.get("orbits_body_id")
var station_parent_pos: Vector2
var parent_r: float = 0.0
if parent_id != null and _body_positions.has(str(parent_id)):
station_parent_pos = _body_positions[str(parent_id)]
var pd: Dictionary = body_dict_by_id.get(str(parent_id), {})
parent_r = _km_to_draw_radius(float(pd.get("body_radius_km", 0)), pd.get("body_type", ""))
else:
station_parent_pos = center
_station_positions[station_id] = station_parent_pos + Vector2(20.0, -10.0)
station_parent_pos = Vector2(sz.x - BODY_RIGHT_MARGIN, mid_y)
var station_y: float = -(parent_r + LABEL_OFFSET + 12.0 + STATION_SIZE + MOON_CLEARANCE)
_station_positions[station_id] = station_parent_pos + Vector2(0.0, station_y)
# =============================================================================
@@ -355,12 +364,16 @@ func _gui_input(event: InputEvent) -> void:
if not _in_orbital:
return
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
_handle_orbital_click((event as InputEventMouseButton).position)
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT and mb.double_click:
_handle_orbital_double_click(mb.position)
elif mb.button_index == MOUSE_BUTTON_LEFT:
_handle_orbital_click(mb.position)
elif event is InputEventMouseMotion:
_handle_orbital_hover((event as InputEventMouseMotion).position)
func _handle_orbital_click(pos: Vector2) -> void:
func _handle_orbital_double_click(pos: Vector2) -> void:
var bid: String = _find_nearest_body(pos)
if not bid.is_empty():
for b: Dictionary in _orbital_bodies:
@@ -368,22 +381,65 @@ func _handle_orbital_click(pos: Vector2) -> void:
body_selected.emit(b)
return
func _handle_orbital_click(pos: Vector2) -> void:
var bid: String = _find_nearest_body(pos)
if not bid.is_empty():
for b: Dictionary in _orbital_bodies:
if str(b.get("body_id", "")) == bid:
_selected_body = b
_selected_station = {}
if _station_panel:
_station_panel.visible = false
_rebuild_body_panel()
if _body_panel:
_body_panel.visible = true
_dirty = true
return
var sid: String = _find_nearest_station(pos)
if not sid.is_empty():
for s: Dictionary in _orbital_stations:
if str(s.get("station_id", "")) == sid:
_selected_station = s
_selected_body = {}
if _body_panel:
_body_panel.visible = false
_rebuild_station_panel()
if _station_panel:
_station_panel.visible = true
return
_close_detail_panels()
_dirty = true
func _close_detail_panels() -> void:
_selected_body = {}
_selected_station = {}
if _body_panel:
_body_panel.visible = false
if _station_panel:
_station_panel.visible = false
func has_body_panel_open() -> bool:
return not _selected_body.is_empty()
func has_station_panel_open() -> bool:
return not _selected_station.is_empty()
func close_panels() -> void:
_close_detail_panels()
_dirty = true
func get_selected_body() -> Dictionary:
return _selected_body
func _handle_orbital_hover(pos: Vector2) -> void:
var new_body: String = _find_nearest_body(pos)
var new_station: String = "" if not new_body.is_empty() else _find_nearest_station(pos)
@@ -415,6 +471,53 @@ func _find_nearest_station(pos: Vector2) -> String:
return best_id
# =============================================================================
# Globe textures
# =============================================================================
func _load_body_textures(system_id: String) -> void:
_body_textures.clear()
var sys_dir: String = system_id.replace(" ", "-")
var wiki_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir() + "/wiki/star-systems"
for b: Dictionary in _orbital_bodies:
var bid: String = str(b.get("body_id", ""))
if bid.is_empty():
continue
var bt: String = b.get("body_type", "")
var globe_path: String
if bt in ["oort_cloud", "asteroid_belt"]:
globe_path = wiki_root + "/_generics/" + bt + "/globe.png"
else:
globe_path = wiki_root + "/" + sys_dir + "/bodies/" + bid + "/globe.png"
if not FileAccess.file_exists(globe_path):
continue
var img := Image.load_from_file(globe_path)
if img == null:
continue
var tex := ImageTexture.create_from_image(img)
_body_textures[bid] = tex
# =============================================================================
# Sizing
# =============================================================================
const DRAW_RADIUS_MIN: float = 4.0
const DRAW_RADIUS_MAX: float = 48.0
const DRAW_RADIUS_REF_KM: float = 6371.0
const DRAW_RADIUS_REF_PX: float = 24.0
static func _km_to_draw_radius(radius_km: float, _body_type: String) -> float:
if radius_km <= 0.0:
return DRAW_RADIUS_MIN
var ratio: float = radius_km / DRAW_RADIUS_REF_KM
var px: float = DRAW_RADIUS_REF_PX * pow(ratio, 0.5)
return maxf(px, DRAW_RADIUS_MIN)
# =============================================================================
# Panels
# =============================================================================
@@ -471,7 +574,6 @@ func _build_station_panel(implant_theme) -> void:
_station_panel.theme_resource = implant_theme
_station_panel.custom_minimum_size.x = PANEL_WIDTH
_station_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_station_panel.position = Vector2(PANEL_MARGIN, 60.0)
_station_panel.visible = false
add_child(_station_panel)
@@ -484,6 +586,9 @@ func _rebuild_station_panel() -> void:
if _selected_station.is_empty():
return
var sz: Vector2 = get_rect().size
_station_panel.position = Vector2(sz.x - PANEL_WIDTH - 60.0, 60.0)
var s: Dictionary = _selected_station
var s_name: String = (
s.get("proper_name", "") if s.get("proper_name") else s.get("station_id", "—")
@@ -507,3 +612,91 @@ func _rebuild_station_panel() -> void:
_station_panel.add_component(ImplantSeparator.new())
_station_panel.add_component(ImplantTextBlock.new("station atlas deferred"))
_station_panel.add_component(ImplantTextBlock.new("esc back"))
func _build_body_panel(implant_theme) -> void:
_body_panel = ImplantPanel.new()
_body_panel.name = "BodyPanel"
_body_panel.theme_resource = implant_theme
_body_panel.custom_minimum_size.x = PANEL_WIDTH
_body_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_body_panel.visible = false
add_child(_body_panel)
func _rebuild_body_panel() -> void:
if not _body_panel:
return
_body_panel.clear()
if _selected_body.is_empty():
return
var sz: Vector2 = get_rect().size
_body_panel.position = Vector2(sz.x - PANEL_WIDTH - 60.0, 60.0)
var b: Dictionary = _selected_body
var bid: String = b.get("body_id", "")
var name_str: String = b.get("proper_name", "") if b.get("proper_name") else bid
var body_type: String = b.get("body_type", "").replace("_", " ").to_upper()
var mass_class: String = b.get("mass_class", "") if b.get("mass_class") else ""
var atmo: String = b.get("atmosphere", "none") if b.get("atmosphere") else "none"
var inhabited: bool = bool(b.get("inhabited", false))
var pop: int = int(b.get("population", 0))
var radius_km: float = float(b.get("body_radius_km", 0))
var sys: Dictionary = current_system()
var sys_name: String = sys.get("proper_name", sys.get("system_id", "—"))
# Globe artwork at top
var tex: ImageTexture = _body_textures.get(bid)
if tex:
var globe_rect := TextureRect.new()
globe_rect.texture = tex
globe_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
globe_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
globe_rect.custom_minimum_size = Vector2(PANEL_WIDTH, PANEL_WIDTH)
_body_panel.add_component(globe_rect)
_body_panel.add_component(ImplantHeader.new(name_str, sys_name + " system"))
_body_panel.add_component(ImplantSeparator.new())
var type_line: String = body_type
if not mass_class.is_empty():
type_line += " · " + mass_class.replace("_", " ").to_upper()
_body_panel.add_component(ImplantDataRow.new(type_line))
_body_panel.add_component(ImplantDataRow.new("atmosphere " + atmo))
if radius_km > 0:
_body_panel.add_component(ImplantDataRow.new("radius " + _format_radius(radius_km)))
if inhabited:
_body_panel.add_component(ImplantSeparator.new())
_body_panel.add_component(ImplantDataRow.new("population " + _format_pop(pop)))
_body_panel.add_component(ImplantSeparator.new())
var has_heightmap: bool = b.get("terrain_reference") != null
if has_heightmap:
_body_panel.add_component(ImplantTextBlock.new("enter view heightmap atlas"))
_body_panel.add_component(ImplantTextBlock.new("esc close"))
_dirty = true
static func _format_pop(pop: int) -> String:
if pop <= 0:
return "0"
var s: String = str(pop)
var result: String = ""
var count: int = 0
for i: int in range(s.length() - 1, -1, -1):
if count > 0 and count % 3 == 0:
result = "," + result
result = s[i] + result
count += 1
return result
static func _format_radius(km: float) -> String:
if km >= 10000.0:
return "%s km" % _format_pop(int(km))
return "%.0f km" % km
+21 -8
View File
@@ -16,19 +16,31 @@ CREATE TABLE IF NOT EXISTS tickets (
assigned_to TEXT,
team TEXT, -- comma-separated team names, e.g. 'server', 'client,server'
decision_ref TEXT, -- e.g. 'D-010' or 'Q-001'
sprint_id INTEGER REFERENCES sprints(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sprints (
CREATE TABLE IF NOT EXISTS milestones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
goal TEXT,
start_date TEXT,
end_date TEXT,
status TEXT NOT NULL DEFAULT 'planning' CHECK(status IN ('planning', 'active', 'completed', 'cancelled')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
description TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active', 'completed', 'cancelled')),
cascade_phase INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS ticket_milestones (
ticket_id INTEGER NOT NULL REFERENCES tickets(id),
milestone_id INTEGER NOT NULL REFERENCES milestones(id),
PRIMARY KEY (ticket_id, milestone_id)
);
CREATE TABLE IF NOT EXISTS milestone_deps (
blocker_id INTEGER NOT NULL REFERENCES milestones(id),
blocked_id INTEGER NOT NULL REFERENCES milestones(id),
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE TABLE IF NOT EXISTS ticket_deps (
@@ -56,11 +68,12 @@ CREATE TABLE IF NOT EXISTS ticket_labels (
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type);
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_id);
CREATE INDEX IF NOT EXISTS idx_tickets_sprint ON tickets(sprint_id);
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tickets_decision ON tickets(decision_ref);
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
CREATE INDEX IF NOT EXISTS idx_history_ticket ON ticket_history(ticket_id);
CREATE INDEX IF NOT EXISTS idx_ticket_milestones_ticket ON ticket_milestones(ticket_id);
CREATE INDEX IF NOT EXISTS idx_ticket_milestones_milestone ON ticket_milestones(milestone_id);
-- ---------------------------------------------------------------------------
-- Decision Sync Tables
+2 -2
View File
@@ -10,12 +10,12 @@ Cross-domain decisions live in one file with cross-reference notes in related fi
| File | Domain | Decisions |
|------|--------|-----------|
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152, D-191 |
| [architecture.md](architecture.md) | Technical foundation | D-008, D-009, D-010, D-012, D-020, D-026, D-030, D-031, D-041, D-042, D-054, D-055, D-066, D-068, D-073, D-085, D-088, D-094, D-096, D-097, D-099, D-100, D-101, D-102, D-103, D-106, D-108, D-109, D-113, D-133, D-134, D-135, D-136, D-137, D-141, D-148, D-149, D-150, D-151, D-152, D-188, D-191, D-192, D-194–D-218, D-220 |
| [perception.md](perception.md) | Player observation | D-011, D-015, D-016, D-017, D-018, D-019, D-033, D-035, D-043, D-044, D-045, D-046, D-047, D-048, D-049, D-052, D-056, D-057, D-058, D-059, D-060, D-061, D-067, D-069, D-070, D-071, D-072, D-076, D-077, D-078, D-086 |
| [content.md](content.md) | NPC, dialogue, templates | D-023, D-024, D-025, D-028, D-029, D-032, D-034, D-035, D-036, D-037, D-050, D-062, D-063, D-064, D-074, D-075, D-084, D-090, D-092, D-093, D-095, D-098, D-104, D-105, D-107, D-121, D-122, D-123, D-124, D-125, D-126, D-127, D-128, D-129, D-130, D-131, D-132, D-138, D-139, D-140, D-142, D-147 |
| [scope.md](scope.md) | Game concept, prototype | D-001, D-003, D-005, D-006, D-007, D-013, D-014, D-027, D-038, D-039, D-051, D-053, D-065, D-087, D-089, D-091, D-114, D-115, D-116, D-117, D-118, D-119, D-120, D-145, D-146, D-153, D-154, D-155, D-156, D-157 |
| [economics.md](economics.md) | Economics layer, currencies, corporations, simulation | D-171, D-172, D-173, D-174, D-175, D-176, D-177, D-178, D-179, D-180, D-181, D-182, D-183, D-184, D-185, D-186, D-187, D-189, D-190 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040 |
| [process.md](process.md) | Team, workflow | D-004, D-021, D-022, D-040, D-221 |
| [questions.md](questions.md) | Open questions (index) | Q-001 through Q-094 |
| [questions-architecture.md](questions-architecture.md) | Technical questions | Q-001, Q-006, Q-009, Q-018–Q-023, Q-029, Q-030, Q-046, Q-059, Q-060, Q-063–Q-094 |
| [questions-perception.md](questions-perception.md) | Observation questions | Q-003, Q-014, Q-016, Q-024–Q-026, Q-051, Q-053, Q-054 |
+260 -16
View File
@@ -171,6 +171,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Sprites: 2x2 sim tile footprint.** Entity sprites render across 2x2 sim tiles so they feel proportional to the 1m visual grid. Tween interpolation ([D-054](#d-054-tile-based-movement-with-same-tile-occupancy)) hides half-visual-tile movement increments.
- **Mental model:** "Objects are where they look. I can position myself precisely within open space." The player reads cover and walls at visual scale (always correct). Fine movement granularity is felt, not counted.
- **What the simulation does NOT know:** Visual tiles. The retina factor is purely a client rendering convention. The server operates exclusively on 0.5m sim tiles.
- **Amendment (2026-05-22, [D-222](#d-222)):** canonical vocabulary. This 0.5m "sim tile" is now the **Subtile** — the granularity the server simulation, entity positioning, and render detail run on. The **Tile = 1m** (2×2 subtiles) is the unit sizes are quoted in and the one the world-generation cascade, pathfinding, and grids operate on. The dual-scale model and the 0.5m simulation granularity are unchanged; only the naming primacy flips. The Chunk/Block/Quarter/District ladder built on the Tile is canonical in D-222.
- **Fog shader ([D-059](perception.md#d-059-fog--shader-based-five-layers-knowledge-graph-driven)):** Unaffected — fog is screen-space, driven by PointLight2D vision cone and LOS mask from sim-resolution shadowcasting. Gradient edge "3-4 tiles" is retuned to 6-8 sim tiles (= 3-4 visual tiles) to preserve the intended softness.
- **Cursor/interaction:** No change — cursor already resolves to sim tile from pixel position. Interaction range of ~2 sim tiles = 1m (arm's length).
- **Map authoring:** Author at 1m visual scale. Subdivision tool expands each visual tile to 4 sim tiles (2x2). Validation enforces 2x2 minimum on all world geometry layers.
@@ -239,6 +240,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
### D-094: District Spatial Hierarchy — Chunk, Block, District Naming and Sizes
- **Date:** 2026-02-25
- **Decision:** The spatial hierarchy for map generation and streaming is defined as follows. **Chunk** = 64×64 sim tiles (32×32 visual tiles, 32m) — the streaming and serialization unit. **Block** = 128×128 sim tiles (64×64 visual tiles, 64m) — the generator planning unit, composed of 4 chunks arranged in a 2×2 grid. Each block contains 4 chunks; chunks within a block can merge into one large edifice, remain separate (small buildings, gardens, cafes), or form L-shaped buildings across chunk boundaries. **District** = 4×4 blocks = 512×512 sim tiles (256×256 visual tiles, 256m) per z-level, containing 16 blocks and 64 chunks. Large civic structures (gate terminals, horizon station installations, stadiums, parks) span multiple blocks. Three z-levels for the Transit District = ~1.35MB (trivial). This decision amends D-012 and overrides the ~150×150 visual estimate in D-014.
- **Amendment (2026-05-22, [D-222](#d-222)):** naming + scale superseded. The unit this record calls a **District** (4×4 blocks) is renamed the **Quarter** (now 512m at Tile = 1m); **District** is promoted to a new 2048m tier (4×4 quarters). Tier *counts* and nesting structure here are unchanged — only the names and the metre values (per the Tile = 1m / Subtile = 0.5m scale of D-220). See D-222 for the canonical ladder and the lore-vs-code rule.
- **Rationale:** Chunk size of 32×32 visual (64×64 sim) gives a 32m streaming cell — large enough to hold a meaningful space, small enough for efficient streaming. The 2×2-chunk block provides a generator planning unit with enough granularity for per-chunk variation. The 4×4 block district (256×256 visual) gives a full district footprint generalisable as a template for the Q-036 generator. The chunk-based fill system within blocks allows the generator to place buildings of varying scale without hard-coding building dimensions.
- **Raised by:** Tyre (chunk/block spec and memory confirmation), confirmed by team. Lead ratified district = 4×4 blocks.
- **Dissent:** Araminta preferred 32×32 visual chunk size (effectively halving the chunk to a 16m cell). Overruled by lead and team majority — 32m chunk is the minimum viable streaming cell for the simulation architecture.
@@ -588,10 +590,11 @@ Technical foundation decisions that constrain implementation: engine, client-ser
| 1 | Wiki content complete — all planets, moons, stations, heightmaps, artwork | Implant-ready Godot map with click-throughs + wiki/GTTR popups |
| 2 | Economics layer — supply/demand, transport, corporations, supply chains | Economics spreadsheets/graphs with runtime-tweakable simulation |
| 3 | Planetary/moon maps & station layouts — cities, rivers, mountains, roads, biomes | Atlas of the Reach (implant app) |
| 4 | Player control scheme — 2-floor test map, character rendering, walls/stairs/doors, lighting | Player viewport with final-version assets |
| 5 | World generation (tile/chunk/block) — walkable world, parallel asset pipeline | Walkable generated world + asset catalog |
| 4 | World generation (tile/chunk/block) — deterministic multilayer seed→tile cascade; each layer viewed as a map in the implant Atlas (no in-world rendering) | Deterministic walkable-world data + per-layer Atlas maps + asset catalog |
| 5 | Player control & in-world rendering — character, walls/stairs/doors, lighting, drawn on generated tiles (no test map) | Player viewport with final-version assets on the generated world |
| 6 | Detail coloring — room-level content, cultural architecture | Only when the world is walkable |
- **Amendment (2026-05-22):** Phases 4 and 5 swapped — **world generation now precedes player control**. The rule: no player-control or in-world rendering work begins until the generator can deterministically seed-generate every tile of every world via the full multilayer cascade. The original Phase 4 "2-floor test map" is **dropped** — test layers are produced by the generator itself once layer-drawing begins; we start drawing the world only when generation knows what to draw. Generation progress is viewed as **per-layer maps in the implant Atlas** (the Phase 3 deliverable, already built), not via an in-world renderer. The existing in-world rendering code is **left as-is until Phase 5** — neither built upon nor removed before then. Rationale: building player systems against a throwaway test substrate means rebuilding them against real generated tiles later; gating player work on deterministic generation avoids that waste. Epics #749 (now Phase 5) and #750 (now Phase 4) and the CLAUDE.md cascade table are updated to match.
- **Rationale:** The pattern of negotiating pragmatic v0.2 cuts while discussing room-level detail repeatedly produced superseded decisions, confused agents, and distracted from building the actual product. The cascade enforces a first-things-first discipline: each layer of the game is grounded in the layer below it before detail is added.
- **Raised by:** Jeroen, established 2026-03-24 during world generation workshop.
- **Dissent:** None.
@@ -700,16 +703,18 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- 3 navigation levels: system picker → orbital diagram → body atlas (heightmap + overlays)
- Heightmap displayed as `Texture2D` with pan/zoom
- Marker overlay renders cities, roads, rail, POIs, named features on top of heightmap
- Click city → City Data Panel (name, population, currency zone, Commission presence, shadow economy zone, gate distance, economics panel link)
- Click city → City Data Panel (name, population, currency zone, Commission presence, gate distance, economics panel link) — shadow economy zone dropped per §7 amendment (2026-05-22)
- Economics panel link: click-through to Phase 2 economics panel pre-filtered to that node — primary Phase 2/3 integration point
**7. Overlay System — 9 MVP Overlays**
**7. Overlay System — 8 MVP Overlays**
- **Amendment (2026-05-22):** dropped the `shadow economy` toggleable overlay (9 → 8 overlays). Shadow economy is an *underwater simulation modifier* — `shadow_economy_intensity` (D-174) feeds derived signals such as `collection_efficiency` and signal 7 `official_coverage_ratio` (D-181), but it is not a user-navigable data point, so it does not warrant a player-facing atlas overlay or City Data Panel field. The simulation layer (D-174) is unaffected. Also removed from §6 (city data panel) and §10 (completion criteria) for consistency.
- 5 always-on: terrain, infrastructure, named features, gate/spaceport POIs, political zones
- 4 toggleable: population density, production zones, shadow economy zones (broad bands), corporate presence (Tier 1 only)
- 3 toggleable: population density, production zones, corporate presence (Tier 1 only)
- Deferred overlays visible in toggle bar but locked with unlock requirements shown on hover (creates pull toward Phase 4+ systems)
- Maps to D-181 signal visibility ladder
**8. Settlement Data Model**
- **Amendment (2026-05-22, [D-223](#d-223)):** superseded — markers.json is reduced to **names only** (a flavored name pool); it no longer carries position arrays or topographic-feature geometry. River/mountain positions derive from the heightmap + drainage (D-208); settlement positions from the economic sim + placement (D-211). The 6 hand-authored templates and their `reserved` pinning are removed (bodies stay as ordinary named bodies). The pixel-space schema below is retained as historical reference only.
- **Amendment (2026-04-15):** The original §8 (below) described marker positions as lat/lon objects and city records keyed by `population_tier`/`primary_function`/`gate_terminal`/`continent_id`. That shape was aspirational — neither the generator nor the hand-authored templates ever emitted it. Both ended up writing pixel-space row/col arrays against a `512 × 256` storage grid, and PR #129 canonizes that shape so the code and the decision stop drifting. The original prose is preserved immediately below; the current shape follows.
- **Original (2026-04-10, superseded):** markers.json schema per body: `cities` (name, lat/lon, population_tier, primary_function, gate_terminal, continent_id), `roads` (path polylines, connects), `railroads` (path polylines, connects), `pois` (name, kind, position), plus existing rivers/oceans/mountains with names filled. Population tier → city count: `floor(log10(pop/1M))`, modified by `settlement_pattern`. Gate terminal POI at largest population center, sometimes scattered to a smaller one. Moons: same depth as planets, scale with population.
- **Current canonical format:** markers.json is stored in heightmap pixel space. Every marker file declares a `grid: { w, h }` header — the generator, the 6 hand-authored templates (Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade), and all 2394 procedural seed files ship `{"w": 512, "h": 256}`. Every position is a two-element **array** `[row, col]` of integer pixels into that grid, where `row ∈ [0, h)` and `col ∈ [0, w)` (row is the first axis to match NumPy convention and the flood-fill / A* / cost-grid code that `tooling/planet-gen/` already runs in). Polyline geometry (`roads[*].path`, `railroads[*].path`, `rivers[*].path`) is `[[row, col], [row, col], ...]`.
@@ -740,7 +745,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
3. Population-scaled depth (core systems rich, frontier sparse)
4. City data panel works (click any city, see data + economics link)
5. Economics panel integration works (link opens Phase 2 panel filtered to node)
6. All 9 MVP overlays present and functional
6. All 8 MVP overlays present and functional
7. Atlas is read-only (no verbs execute from map)
8. Stations show at system view with mini data panel (no drill-down)
@@ -847,7 +852,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
### D-200: Three-Tier Execution Model (Build-Time / Runtime-Background / Runtime-On-Demand)
- **Date:** 2026-05-01
- **Decision:** The generation pipeline operates at three distinct execution tiers with no cross-tier mutation:
1. **Build-time (Python pipeline):** Runs `make regen-db`. Produces `systems.db` tables including `atlas_body_heightmaps`, `atlas_city_names`, `atlas_province_boundaries`, `body_radius_km`. Output is a static artifact committed to the repo. Never runs during gameplay.
1. **Build-time (Python pipeline):** Runs `make regen-db`. Produces `systems.db` tables including `atlas_city_names`, `atlas_province_boundaries`, `body_radius_km`. Output is a static artifact committed to the repo. Never runs during gameplay. *(Amended #963, D-202: `atlas_body_heightmaps` is no longer produced — elevation is now a per-body 16-bit `heightmap.png` file, not a DB table.)*
2. **Runtime-background (Rayon thread pool, D-206):** Triggered by content-spidering events (player approaches a system, NPC names a location, news ticker references a place). Runs D8 drainage analysis (D-208), attractor extraction (D-209), settlement placement, and Phase 1 district skeleton generation. Output goes into `BodyWorldState` cache (D-203). Transparent to main tick thread.
3. **Runtime-on-demand (main tick thread):** Triggered when the player crosses a chunk boundary. Runs Phase 2 chunk fill for the approaching chunk. Must complete within 5ms. Reads from `BodyWorldState` cache (always populated before this tier runs).
- **Tier boundary rules:**
@@ -884,14 +889,15 @@ Technical foundation decisions that constrain implementation: engine, client-ser
| 3 | Body | ~512×256 pixels (equirectangular heightmap) | Planetary topography, climate zones |
| 4 | Region | ~50–500km | Province boundaries (watershed-derived, D-205), biome zones |
| 5 | Settlement | ~1–30km radius | City footprint, district layout |
| 6 | District | 512×512 sim tiles (256m) | Phase 1 skeleton, 4×4 block grid (D-094) |
| 7 | Block | 128×128 sim tiles (64m) | Generator planning unit, 2×2 chunks (D-094) |
| 8 | Chunk | 64×64 sim tiles (32m) | Streaming/serialization unit (D-094) |
| 6 | District | 2048×2048 tiles (2048m, 4.19 km²) | 4×4 quarters — large urban division (D-222) |
| 7 | Quarter | 512×512 tiles (512m, 0.262 km²) | 4×4 blocks — settlement footprint cell, Phase 1 skeleton grid (D-222) |
| 8 | Block | 128×128 tiles (128m) | Generator planning unit, 2×2 chunks (D-222) |
| 9 | Chunk | 64×64 tiles (64m) | Streaming/serialization unit (D-222) |
- Tiers 6–8 are locked by D-094 (district spatial hierarchy). This decision formalizes Tiers 1–5 with equivalent lock status.
- Tier 3 heightmap resolution (512×256 equirectangular at 1024×512 PNG) is the canonical format. Deviation requires amending D-191.
- Tiers 6–9 (District → Chunk) are the sub-settlement spatial hierarchy, canonical in [D-222](#d-222) — renamed/resized from the original D-094 ladder (the old 512m "District" is now the Quarter; District is now 2048m), at the Tile = 1m / Subtile = 0.5m scale of D-220. This decision formalizes Tiers 1–5 with equivalent lock status.
- Tier 3 heightmap resolution (512×256 equirectangular working grid; 1024×512 PNG) is the canonical format. Deviation requires amending D-191. **Amended (#963, D-202):** the canonical *stored* heightmap is now a per-body 16-bit grayscale `heightmap.png` at **1024×512** carrying native elevation (the prior PNG was a 1024×512 RGB *relief*, now renamed `reliefmap.png`). PNG dimensions are unchanged (1024×512); Layer 1 downsamples to the 512×256 working grid. This amendment is the explicit deviation gate being satisfied — format/content changed, resolution preserved.
- Tier 4 province boundaries are pre-computed at build-time and stored in `atlas_province_boundaries` (D-205). They are not re-computed at runtime.
- The `SettingType` enum on `DistrictSkeleton` is the interface between Tier 5 (settlement planning) and Tier 6 (district generation).
- The `SettingType` enum on `DistrictSkeleton` is the interface between Tier 5 (settlement planning) and the skeleton cell (the 512m Quarter, Tier 7 — `DistrictSkeleton` is pending rename to match D-222).
- **Rationale:** Locking spatial dimensions prevents the generative layers from drifting in incompatible directions. The heightmap pipeline, atlas pipeline, and district generator all assume these dimensions and would need coordinated migration if they changed. Formalization prevents silent per-system variation.
- **Ticket:** #912 (WorldTier enum), #913 (SettlementClass)
- **Raised by:** Generation cascade workshop (#897)
@@ -915,7 +921,14 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- The Rust loader reads the BLOB via `bytemuck::cast_slice::<u8, f32>()` after fetching from SQLite. No endian conversion needed on LE-native systems; the pipeline stores LE explicitly.
- Only inhabited bodies receive heightmap rows at build-time. Uninhabited bodies are generated on-demand (runtime-background tier, D-200).
- This table is populated by the `import_heightmaps` build-time step in the asset pipeline (D-191 §9 pipeline order). It is read-only at runtime.
- **Rationale:** Storing heightmaps in `systems.db` keeps the DB as the single source of truth for all generation inputs, avoids a separate file-fetching path in the Rust server, and allows the pre-push hook (asset pipeline rules) to detect stale heightmap data. The float32 LE layout matches what NumPy and PIL produce natively, minimizing conversion overhead in the Python pipeline.
- **Amendment (2026-05-23, [#963](#)):** the DB-BLOB store is **superseded** by a per-body **file-based 16-bit grayscale `heightmap.png`** stored next to the body's other assets (path from `bodies.terrain_reference`). Rationale for the change: raw float grids committed inside a binary `systems.db` are the exact binary-merge-conflict trap the asset-pipeline rule warns against, and ~512KB×N bloats the DB; a per-body file matches D-203's on-demand model and keeps `systems.db` lean. Concretely:
- **Naming fix:** the existing color hypsometric render (today's `heightmap.png`, 1024×512 RGB) is renamed **`reliefmap.png`** — it is a relief visualization, not elevation. Display-only.
- **Canonical elevation:** a new **`heightmap.png`** = 16-bit grayscale (luminance = normalized elevation), **1024×512** (2× per axis / 4× the cells of the old 512×256 sim grid — the PNG dimensions are unchanged from the prior canonical 1024×512 in D-201; only the *content* changed from an RGB relief to native 16-bit elevation). 1024×512 bounds install size (~190 MB across 267 inhabited bodies) while sub-pixel detail is synthesized by the lower cascade layers. Single source of truth — the reliefmap and all computed geography derive from it, so it is bit-identical/deterministic by construction.
- **Multi-resolution:** the stored heightmap is high-res for the lower layers (region/block/tile sample local detail); **Layer 1** (continental drainage/basins/mountain-ranges) calls `BodyHeightmap::downsample` to the `GRID_W×GRID_H = 512×256` working resolution first, decoupling continental compute cost (~45ms) from stored resolution.
- **Rust loader:** `heightmap.rs::load_heightmap_png` reads the 16-bit grayscale PNG (via the `png` crate), normalizes to f32 [0,1]; rejects RGB (a reliefmap can't be misread as elevation). `sea_level` is stored **in the PNG** as a `tEXt` chunk (the heightmap is self-describing), with a caller-supplied default as fallback.
- **`atlas_body_heightmaps` is dropped**; `import_heightmaps.py` writes the PNG file instead of a DB row. The bake runs in the content pipeline (numpy/scipy) once; the runtime cascade is pure Rust loading the file.
- **Implementation status (#963):** Consumer done — `heightmap.rs::load_heightmap_png` reads the 16-bit grayscale PNG + `sea_level` tEXt chunk, rejects RGB, downsamples for Layer 1. Producer done — `import_heightmaps.py` is the bake (rename legacy `heightmap.png`→`reliefmap.png` for all bodies incl. Sol; for non-Sol inhabited bodies write a fresh clean `reliefmap.png` + 16-bit `heightmap.png` from `simulate()` at the bumped 1024×512 grid). `atlas_body_heightmaps` dropped via MIGRATION_SQL + removed from `systems-schema.sql`. Godot client (`atlas_viewer.gd`) loads `reliefmap.png` for display. Sim determinism guarded by `test_sim_determinism.py`.
- **Rationale:** Storing heightmaps in `systems.db` keeps the DB as the single source of truth for all generation inputs, avoids a separate file-fetching path in the Rust server, and allows the pre-push hook (asset pipeline rules) to detect stale heightmap data. The float32 LE layout matches what NumPy and PIL produce natively, minimizing conversion overhead in the Python pipeline. *(Superseded by the #963 amendment above — the file-based model won out because the DB-as-single-source goal conflicts with binary-merge-conflict avoidance and DB size; a per-body committed PNG is itself a queryable, diffable-by-render asset.)*
- **Ticket:** #901 (schema), #906 (import), #916 (Rust loader)
- **Raised by:** Generation cascade workshop (#897)
- **Cross-reference:** D-191 (atlas pipeline — heightmap source), D-203 (BodyWorldState — consumer), D-208 (D8 drainage — reads this table)
@@ -1004,6 +1017,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
### D-207: Fully Generative Placement — markers.json Stripped to Topographic Features
- **Date:** 2026-05-01
- **Amendment (2026-05-22, [D-223](#d-223)):** superseded in part. markers.json no longer retains topographic-feature *geometry* — it is reduced to **names only** (a flavored name pool). River/mountain positions are derived from the heightmap + drainage (D-208); settlement positions come from the economic sim + placement (D-211). The `reserved=1` pinning and the 6 hand-authored templates (Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade) are removed — their bodies remain as ordinary named bodies, only the authored machinery is gone. See D-223.
- **Decision:** The `atlas_city_names` table replaces the authored city positions in `markers.json`. Going forward, `markers.json` files contain only topographic features (rivers, oceans, mountain ranges — per D-191 §8 canonical format). City positions, road networks, and rail networks are NOT authored in `markers.json`; they are generated from the terrain data and stored in `atlas_city_names` and derived tables.
```sql
CREATE TABLE atlas_city_names (
@@ -1030,7 +1044,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
- **Date:** 2026-05-01
- **Decision:** Drainage routing (the computation of flow direction, flow accumulation, and river network extraction) uses the **D8 priority-flood** algorithm on the body's float32 heightmap. This is the first Layer 1 computation run on a body before any city placement or attractor extraction.
- **Algorithm:** D8 assigns each cell's flow direction to one of 8 neighbors based on the steepest descent. Priority-flood fills depression cells before routing to avoid spurious sinks. Flow accumulation is the count of upstream cells draining through each cell.
- **River threshold:** A cell is classified as a river cell when `flow_accumulation > 200`. This threshold produces river networks of realistic density on canonical 512×256 heightmaps.
- **River threshold:** A cell is classified as a river cell when `flow_accumulation > 200`. This threshold produces river networks of realistic density on the 512×256 Layer-1 working grid (D8 runs at 512×256, downsampled from the 1024×512 stored heightmap per D-202 amended #963).
- **Outputs** stored in `BodyWorldState.river_network`:
- `river_cells: Vec<(u16, u16)>` — pixel positions of all river cells
- `confluences: Vec<(u16, u16)>` — positions where two or more rivers merge
@@ -1237,4 +1251,234 @@ Technical foundation decisions that constrain implementation: engine, client-ser
---
*79 decisions (D-001 through D-218, excluding gaps). Last updated: 2026-05-02 (D-218 — WorldTier canonical values, generation cascade workshop Sprint 38)*
### D-220: Settlement Density Model — Tile Scale, Density Classes, and Vertical Pressure
- **Date:** 2026-05-03
- **Decision:** Settlement footprint, building height, and basement depth are all driven by a single **density scalar** on each settlement, derived from population, available land, terrain constraints, and economic pressure. This decision establishes the tile scale, the density classification system, and the rules for how density shapes the built environment.
**Tile scale (amends D-201 Tier 0):**
| Unit | Dimensions | Real-world size | Notes |
|------|-----------|-----------------|-------|
| Subtile | 1×1 | 0.5m × 0.5m | Rendering unit — character positioning, furniture placement, visual detail |
| Tile | 1×1 | 1m × 1m | Spatial unit — generation pipeline, pathfinding, district grids |
| Chunk | 64×64 tiles | 64m × 64m | Streaming unit (was 32m at 0.5m/tile; now 64m at 1m/tile) |
| Block | 128×128 tiles | 128m × 128m | 2×2 chunks |
| District | 512×512 tiles | 512m × 512m = 0.262 km² | 4×4 blocks |
Each tile contains 2×2 subtiles. The generation cascade operates in tiles. The renderer subdivides to subtiles for visual fidelity. All higher tiers (Province, Area, Body) remain as defined in D-201.
- **Amendment (2026-05-22, [D-222](#d-222)):** the 512m unit this table calls **District** is renamed the **Quarter**; **District** is promoted to a new 2048m tier (4×4 quarters). The Tile/Subtile scale above is unchanged. Throughout this record — including the footprint formula (`district_count = footprint / 0.262`) and the density examples — "district" denotes the **0.262 km² Quarter cell**, not the new 2048m District. See D-222 for the canonical ladder and the lore-vs-code rule.
**Density classes:**
Density is measured in people per km² of settlement footprint. It is the primary driver of settlement character — not population alone. The same population at different densities produces completely different settlements.
| Class | Density (ppl/km²) | Horizontal character | Vertical character | Infrastructure cost | Who builds at this density |
|-------|-------------------|---------------------|-------------------|--------------------|-----------------------------|
| Frontier | ~400 | Large gaps between structures, open ground dominant, private roads | 1 floor, no basement | Very high per-capita (long utility runs, private maintenance) | **The very rich** (villa estates, corp executive compounds, prestige vanity builds) OR **the very poor** (pre-utility squatter settlements, homesteads with no services). Frontier density is a luxury or an absence, never the default. |
| Settled | ~1,500 | Planned grid, moderate spacing, shared streets, visible utility corridors | 1-2 floors, optional basement | Efficient — the utility-cost-optimized default | **Default for most Reach colonies.** Every meter of sewer, power conduit, and road is new capital expenditure on a virgin world. Settled density is what you get when infrastructure cost constrains the plan. Kiruna is the archetype. |
| Established | ~3,000 | Mixed residential, shared walls appearing, streets narrowing, green space intentional not incidental | 2-3 floors, basement common | Amortized — mature infrastructure absorbing incremental growth | **Mature settlements.** Second-generation growth over an existing utility grid. The settlement has existed long enough for the infrastructure investment to pay off and densification to begin. Tiel, Périgueux. |
| Dense | ~6,000 | Shared walls dominant, narrow streets, minimal private outdoor space, commercial ground floors | 3-6 floors, 1-2 basement levels (parking, storage, utilities) | High per-area but efficient per-capita | **Economic hubs.** Transit nodes, extraction chokepoints, terrain-constrained sites where the jobs pull people in faster than the settlement can spread. Kanazawa, Uzès. Density is a signal that economic gravity is compressing the settlement. |
| Compressed | ~15,000 | Vertical dominant, street canyons, multi-use buildings, public space carved from built volume | 6-12+ floors, 2-3 basement levels (metro, parking, utilities, subsurface logistics) | Very high per-area, requires vertical infrastructure (elevators, pressurized systems, multi-level utilities) | **Extreme economic pull.** Major corp HQs, gate-adjacent trade hubs, capital worlds. Rare in the Reach. The land itself is too valuable for anything but vertical. Paris, Amsterdam inner ring. |
**What drives density — the four pressures:**
1. **Utility cost (compressing force).** The dominant pressure on new colonies. Infrastructure is expensive per meter. This pushes settlements toward Settled as the floor, not Frontier. Every colony pays this tax unless someone absorbs the cost deliberately.
2. **Economic pull (compressing force).** Jobs, trade, resources, transit — anything that draws people to a specific location faster than the settlement can grow outward. The stronger the pull, the higher the density. Corp HQ worlds, gate-adjacent systems, extraction sites all have high economic pull. This is the force that pushes settlements from Settled through Dense to Compressed.
3. **Terrain constraint (compressing or dispersing force).** Coastal strips, canyon floors, mountain passes compress settlements along one axis (Bisbee: 2.5km × 300m). Open plains allow radial spread. River valleys elongate. The terrain doesn't set the density class directly — it shapes the footprint geometry within that class.
4. **Wealth/status (dispersing force — at a premium).** The only force that pushes density DOWN below the utility-cost floor. Low density in an otherwise dense settlement is conspicuous consumption of space — private utility runs, private road maintenance, private security perimeters. Villa estates, executive compounds, prestige campuses. This is Frontier density as luxury, not as poverty.
**The U-shaped wealth curve:**
Density has a U-shaped relationship with wealth. The very rich and the very poor both live at low density — but for opposite reasons. The rich can afford the infrastructure cost of space. The poor live where no infrastructure exists. Everyone in between clusters at Settled-to-Dense because that's what utility economics demands.
The player reads this instantly: low-density district on a dense world = money. Low-density district on a frontier world = nothing built yet. Same tile spacing, completely different story — and `prosperity_baseline` (D-197) disambiguates. High prosperity + low density = wealth. Low prosperity + low density = absence.
**Density → vertical pressure mapping:**
Density drives building height and basement depth via simple thresholds. These are tendencies, not hard limits — individual buildings vary, but the district-level character follows the pattern.
| Density class | Typical above-ground floors | Typical basement levels | Vertical pressure description |
|---------------|---------------------------|------------------------|------------------------------|
| Frontier | 1 | 0 | Single-story. Horizontal only. |
| Settled | 1-2 | 0-1 | Mostly single-story, occasional two-story. Basement where terrain allows. |
| Established | 2-3 | 1 | Two-story default. Three-story at intersections and commercial streets. Basements standard. |
| Dense | 3-6 | 1-2 | Multi-story default. Ground-floor commercial. Structured parking appears underground. |
| Compressed | 6-12+ | 2-3 | Highrises. Street canyons. Multi-level subsurface: parking, metro, utilities, logistics. |
The transition from "buildings grow out" to "buildings grow up" happens at Dense. Below Dense, the settlement solves population pressure by adding more districts horizontally. At Dense and above, horizontal expansion is constrained (by terrain, by utility cost, by land value) and vertical growth begins.
The transition from "no basement" to "buildings grow down" happens at Established. Below Established, basements are optional amenities. At Established and above, basements become structural necessities — storage, utilities, parking that can't fit at grade.
**Density derivation:**
The density scalar for a settlement is derived from existing systems.db data:
```
base_density = SETTLED_DEFAULT // 1,500/km² — the utility-cost floor
// Economic pull increases density
if economic_role in [transit, commercial, manufacturing]:
base_density *= 1.5–2.0
if is_corp_hq_world:
base_density *= 1.5
if gate_adjacent:
base_density *= 1.3
// Terrain constraint modulates
if terrain_constraint_factor > threshold: // coastal strip, canyon, etc.
base_density *= 1.2–1.8
// Wealth disperses (for specific districts within a settlement)
if district.prosperity_baseline > 0.8:
effective_density *= 0.4–0.6 // villa district within a dense city
// Clamp to class boundaries
density_class = classify(effective_density)
```
The exact multipliers are tunable. The structure — base at Settled, economic pull compresses, wealth disperses — is the locked architectural decision.
**Settlement footprint formula:**
```
footprint_km2 = population / effective_density
district_count = ceil(footprint_km2 / 0.262)
grid_side = ceil(sqrt(district_count))
```
Not all districts within the grid are built. A Settled-density village of 2,000 people covers ~1.3 km² = 5 districts in a 3×3 grid, but only 5 of 9 grid cells contain structures. The remaining 4 are open ground, agricultural land, or wilderness. The fill ratio increases with density class:
| Density class | Typical grid fill ratio | What fills the empty cells |
|---------------|------------------------|---------------------------|
| Frontier | 20-40% | Wilderness, private grounds, no development |
| Settled | 50-70% | Agricultural plots, utility corridors, planned expansion reserves |
| Established | 70-90% | Parks, low-density transition zones, institutional grounds |
| Dense | 90-100% | Nearly full — remaining gaps are intentional public space |
| Compressed | 100% | Full. No gaps. Every cell is built. |
**Reference calibration (real-world sanity check):**
| Real settlement | Pop | Real area | Real density | Model class | Model area | Match quality |
|----------------|-----|-----------|-------------|-------------|------------|---------------|
| Wadenoijen, NL | 695 | 0.10 km² | 6,950/km² | Dense | 0.12 km² | Close |
| Kerk-Avezaath, NL | 1,250 | 0.40 km² | 3,125/km² | Established | 0.42 km² | Close |
| Bisbee, AZ | 5,000 | 1.50 km² | 3,333/km² | Established | 1.67 km² | Close |
| Kiruna, SE | 17,000 | 11.40 km² | 1,491/km² | Settled | 11.33 km² | Near-exact |
| Périgueux, FR | 30,000 | 9.82 km² | 3,055/km² | Established | 10.00 km² | Near-exact |
| Tiel, NL | 39,000 | 13.00 km² | 3,000/km² | Established | 13.00 km² | Exact |
| Kanazawa DID, JP | 466,000 | 59.00 km² | 7,898/km² | Dense | 77.67 km² | Reasonable |
| Paris proper, FR | 2,040,000 | 105.00 km² | 19,429/km² | Compressed | 136.00 km² | Reasonable |
The model is calibrated to real-world settlements. Discrepancies come from the square-grid assumption (real settlements elongate along rivers, coasts, and roads) and from the discrete density class boundaries (continuous real densities mapped to five bins).
**Interaction with other D-records:**
- **D-194 (district mix):** District *types* are still determined by the three-component mix. Density determines how much *space* those districts occupy and how they're spaced.
- **D-197 (prosperity_baseline):** Disambiguates the wealth curve. Same low density reads as luxury (high prosperity) or absence (low prosperity).
- **D-201 (spatial hierarchy):** Tile scale amended from 0.5m to 1m with 0.5m subtile. Chunk real-world size doubles from 32m to 64m. All higher tiers unchanged.
- **D-215 (spatial arrangement patterns):** The five archetype patterns operate within the footprint that density determines. CompanyTown spine is longer at Settled density, compressed vertically at Dense density.
- **D-216 (BlockIrregularity):** Irregularity affects street grid geometry within a district. Density affects building height and spacing within that geometry. Orthogonal — a Dense district can be Organic (irregular streets, tall buildings) or Grid (regular streets, tall buildings).
- **Rationale:** Population alone cannot determine settlement character. A village of 2,000 at medieval European density (Kerk-Avezaath, 3,200/km²) and a homestead spread of 2,000 at frontier density (400/km²) produce a 10× footprint difference. In the Reach, utility cost on virgin worlds is the dominant constraint, making Settled (~1,500/km²) the natural floor. The density scalar unifies footprint calculation, vertical pressure, and inter-building spacing into a single derivable parameter. The tile/subtile split preserves 0.5m visual fidelity for character movement and furniture placement without inflating the generation pipeline's spatial grid.
- **Ticket:** (pending — implementation tickets to be created when cascade Tier 3/4 work begins)
- **Raised by:** Jeroen + Claude, design session 2026-05-03. Grounded against real-world settlement data (Wadenoijen, Kerk-Avezaath, Uzès, Tiel, Périgueux, Kiruna, Kanazawa, Paris, Bisbee, Alice Springs, Brasília).
- **Cross-reference:** D-194 (district mix), D-197 (prosperity_baseline), D-201 (spatial hierarchy — tile scale amendment), D-215 (spatial arrangement patterns), D-216 (BlockIrregularity)
- **Dissent:** None
---
### D-222: Spatial hierarchy and naming — Subtile to District, lore vs code
- **Date:** 2026-05-22
- **Decision:** Canonical naming and sizes for the sub-settlement spatial hierarchy. This record is the single source of truth for the ladder; it supersedes the dimensions/naming scattered across D-094 (hierarchy), D-201 (tier table), D-220 (tile scale), and D-066 (dual-scale grid), which are amended to point here.
**The ladder (each tier nests cleanly in the one above):**
| Unit | Side | Composition | Real-world analogue | Used for |
|------|------|-------------|---------------------|----------|
| Subtile | 0.5m | — | a footstep | server simulation, entity positioning, render detail |
| Tile | 1m | 2×2 subtiles | a doorway | **the unit all sizes are quoted in**; generation cascade, pathfinding, grids |
| Chunk | 64m | 64×64 tiles | half a block | streaming / serialization unit (technical) |
| Block | 128m | 2×2 chunks | a city block (~100m real) | generator planning unit |
| Quarter | 512m | 4×4 blocks (16) | a superblock (≈ Barcelona *superilla*, 0.262 km²) | the cell a settlement footprint is tiled into |
| District | 2048m | 4×4 quarters (256 blocks) | a real urban district (4.19 km²) | large urban division |
| *(above)* | *fluid* | — | borough / sector / city | **not a fixed tier** — settlements vary too much; groupings above District are settlement-specific |
Nesting rhythm: Chunk→Block is 2×2 (Chunk is a technical sub-block streaming unit); Block→Quarter→District is a uniform 4×4 (the legible "human" ladder).
**Lore ≠ code (load-bearing):** the tier names above are **code/generation units** — fixed grid cells. The *same words* used in lore, narrative, and UI ("Sova Transit District", "the market quarter") are **free-form region labels** with no obligation to map to a code tier. A narrative "district" may span several code Quarters, sit inside one, or ignore the grid entirely. **Reviewers and the clerk must not reconcile a lore name to a code tier** — they are different registers that happen to share vocabulary.
**Rename from prior records:** the 512m cell that D-094 / D-201 / D-220 called a **District** is now the **Quarter**. "District" is promoted to the 2048m tier (4×4 quarters), matching the real-world scale of a named urban district. The tile scale (Tile = 1m, Subtile = 0.5m) and the doubled footprints established by D-220 are unchanged. Code that uses "District" for the 512m skeleton unit (e.g. `DistrictSkeleton`) is now misnamed → see follow-up ticket.
- **Rationale:** The old hierarchy named only Chunk/Block/District and jumped from a 512m "District" straight to the 1–30km Settlement, leaving the 512m cell wearing the name of a unit two rungs too large — a real district is kilometres and many neighborhoods, not 16 blocks. Grounding against real sizes (city block ~100m; Barcelona superblock 400m / 9 blocks; real district several km) puts Block at 128m (correct), the 512m cell at Quarter/superblock, and a true District at 2048m (4.19 km²). Leaving everything above District fluid matches reality — boroughs/sectors/cities vary too much to force a fixed generation tier, and the Reach's settlements range from frontier homesteads to compressed capitals. Separating the lore register from the code register lets narrative use "district" naturally without dragging the generator's grid into it.
- **Raised by:** Jeroen + Claude, design session 2026-05-22. Grounded against real-world block/superblock/district scales.
- **Cross-reference:** [D-094](#d-094) (spatial hierarchy — renamed/superseded), [D-201](#d-201) (tier table — superseded for the sub-settlement tiers), [D-220](#d-220) (tile scale — Tile/Subtile retained; its "district" footprint cell is the Quarter), [D-066](#d-066-dual-scale-grid--05m-simulation-1m-visual-2x-retina-factor) (dual-scale — Subtile is the 0.5m grid), [D-093](content.md#d-093-sova-transit-district--spatial-layout-and-district-topology) (Sova Transit District — a *lore* district, not bound to the code tier)
- **Dissent:** None
---
### D-223: Authored content as a flavored name pool — markers names-only, hand-authored templates removed
- **Date:** 2026-05-22
- **Decision:** Per-planet authored content feeds the deterministic generator as a **flavored name pool only** — never as authored geometry or pinned positions. This supersedes the topographic-geometry retention and the `reserved=1` pinning of [D-207](#d-207-fully-generative-placement--markersjson-stripped-to-topographic-features), and the hand-authored-template references in [D-191](#d-191) §8.
**markers.json → names only.** A body's `markers.json` carries a flavored pool of names (river names, mountain names, settlement names) and **no positions or geometry**. Positions are not authored anywhere; they are derived or generated:
- **Rivers** — courses come from the deterministic D8 drainage (D-208) on the fixed heightmap; the **largest rivers take names from the pool**.
- **Mountains** — positions are a given from the **fixed heightmap**; they take names from the pool.
- **Settlements** — the economic sim runs, the **largest population areas** are found, the capital and cities are picked by the placement pipeline (D-211), and they **take names from the pool**.
**The 6 hand-authored templates are removed.** Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade were a superseded hand-authored-showcase direction that kept resurfacing and complicated the landscape. Their hand-authored marker positioning, the `reserved=1` pinning, and the "6 templates" framing/special-casing are all removed. **The bodies themselves remain real places** — their wiki pages, lore, corporations (e.g. comptoir-lendel), and names stay and feed the name pool; deleting them would only force regenerating equivalent content. Only the authored *machinery* is tossed. **After this there is no distinguishable category of "template" left** — the six are processed by the exact same pipeline and data model (fixed heightmap + names-only markers) as every other body, with no special code path, schema flag, or pinning. They differ only in *content richness* (more developed lore/names/economic profile), which is a difference of degree shared by any notable body, not a difference of kind. The concept dissolves; that is why it can be removed wholesale rather than carefully migrated.
**No pinned positions anywhere.** Determinism comes from fixed heightmaps + deterministic drainage + deterministic economic sim + seeded placement — not from authored anchors. The lore-vs-code split (D-222) governs how pool names attach to generated features: names are free-form labels on generated geometry, not bound to grid cells.
Sub-city authored set-pieces (e.g. D-093 Sova Transit District, station interiors) follow the same principle when Phase 4 reaches that scale — generated geometry, lore names/roles attached, nothing pinned — to be applied (and prior authored layouts superseded) at that point.
- **Rationale:** Authored positions created a hand/procedural split that (per D-207's own rationale) was impossible to query, diff, or validate. Reducing authored content to a name pool removes that split entirely while preserving the flavor that makes places feel hand-made — names follow culture and region, geometry follows terrain and economics. The templates were leftover scaffolding from before fully-generative placement; removing them simplifies the pipeline without losing any canonical place.
- **Implementation:** tracked under Phase 4 (epic #750) — strip markers to names, remove template machinery + `reserved` pinning, update the atlas pipeline/schema, regen. Historical archives (sprints, workshops, audits, CHANGELOG, atlas proposals) are left untouched.
- **Implementation status (#951, 2026-05-22): done.** All 2,398 `markers.json` reduced to a names-only pool (25,264 names; 349 city names across 271 bodies). The Python atlas **geometry generator** (`generate_atlas.py`) and the **LLM naming cluster** (`gemma_naming.py`, `naming_core.py`, `apply_name_fixes.py`, their tests/QA, the `fix_fewshot_bleed`/`prune_atlas_features` geometry tools, the redundant `import_city_names.py`, and the `run-atlas-naming.sh` runner) were retired — the procedural server cascade (Phase 4) supersedes them. The Gemma prompting methodology is preserved in [docs/gemma-naming-methodology.md](../docs/gemma-naming-methodology.md). Shared atlas-DB utilities moved to `tooling/planet-gen/atlas_common.py`; **`import_economics.py` is now the sole regen-db generator that owns the atlas index** — it loads the names pool into `atlas_city_names` and empties the 8 geometry tables (`atlas_cities/roads/railroads/pois/rivers/oceans/mountain_ranges/body_grids`), which the cascade fills. `population`/`kind`/`settlement_class` on `atlas_city_names` are **deferred to placement (#955)** — `attractor_matching` reads them at 0/default until then; that empty state is the gap the cascade closes. The `reserved=1` corp-HQ cross-reference stays (corp HQ names are real places). A latent duplicate-accumulation bug in name population (no clear + no unique constraint) was fixed with a deterministic rebuild. **Sol (system `GJ 0`) is permanently exempt from the normal generators:** it uses real Earth/Mars/Luna geography via the offline `sol_import.py` (left in place for future scripted integration), so its bodies keep geometry-bearing `markers.json` as preserved positional config and are skipped by the names-pool importer — Sol names will come from its own integration, not the cascade. `make regen-db` green.
- **Raised by:** Jeroen, 2026-05-22 — resolving the open question on merging preconfigured content into the deterministic cascade.
- **Cross-reference:** [D-207](#d-207-fully-generative-placement--markersjson-stripped-to-topographic-features) (superseded — names-only, no reserved pinning), [D-191](#d-191) §8 (markers format — names-only), [D-208](#d-208) (drainage → river courses), [D-211](#d-211) (settlement placement), [D-199](#d-199) (economic read set), [D-222](#d-222) (lore≠code names on generated geometry)
- **Dissent:** None
---
### D-224: SeedChain — deterministic seed-derivation contract
- **Date:** 2026-05-23
- **Decision:** All deterministic generation — the Phase-4 world cascade now, and NPC / storyteller / economic generation later — descends from a single master world seed through one shared type, **`SeedChain`**, living at **`server/src/seed.rs`** (top-level, because seeds are broader than the atlas). It is the **only** sanctioned way to derive a child seed; the ad-hoc pre-mixing (`seed.wrapping_add(C)`) that was in `skeleton_gen.rs` / `district_mix.rs` is removed, and the doc-comment references to a "SeedChain" in those files become real.
**Mixing primitive.** `splitmix64` — already used by `EntityRng::from_seed_and_id` (`simulation/rng.rs`), chosen there specifically to avoid the `wrapping_add` collision class where `(seed=0,id=N) == (seed=1,id=N-1)` — is promoted to a shared `pub(crate)` function in `server/src/seed.rs` and reused. One canonical *mixer* for the whole codebase. (`EntityRng` keeps its own domainless combine — `splitmix64(world_seed) ^ splitmix64(stable_id)` — for now; re-expressing it as `derive(Npc, stable_id)` would be a deliberate, **stream-changing** migration, not a free cleanup, so it is left as a follow-up.)
**Contract:**
```rust
pub struct SeedChain(u64); // Copy
pub enum SeedDomain { Body, Layer1Topography, Layer3Settlement, Layer4Quarter, Block, Npc /* … */ } // u64 tags
impl SeedChain {
pub fn root(world_seed: u64) -> Self; // top of the chain
pub fn for_body(world_seed: u64, body_id: &str) -> Self; // root + derive(Body, fnv1a_64(id))
pub fn derive(self, domain: SeedDomain, id: u64) -> Self;
pub fn atlas_rng(self) -> AtlasRng; // integer-only LCG stream (D-010)
pub fn seed(self) -> u64; // raw — for SimRng/ChaCha or further derive()
}
```
**Derivation (load-bearing — pinned, because changing it changes every generated world):**
`derive(domain, id) = splitmix64(self.0 ^ splitmix64(domain as u64)) ^ splitmix64(id)`.
Properties: deterministic; domain-separated (distinct `SeedDomain` tags never share a stream); full avalanche (splitmix64 on each input); integer-only (D-010 #4); chainable (`root → Body → Layer3Settlement → Quarter → Block`). The output is well-distributed, so `AtlasRng::new` is fed the derived seed directly — no `| 1` or golden-ratio pre-mix guard.
**Body identity (the `Body` domain id).** Bodies are identified by a *string* `body_id`, not a numeric StableId, so `SeedDomain::Body` is keyed by **FNV-1a (64-bit) of `body_id`** — the repo's standard deterministic `&str → u64` convention (matching `TemplateId`/`TriangleId`). `SeedChain::for_body(world_seed, body_id)` (`root(world_seed).derive(Body, fnv1a_64(body_id))`) is the single sanctioned path; callers must use it rather than inventing their own string→u64 hash, or they would silently derive divergent worlds from the same seed — the very class of nondeterminism this record exists to kill, one level up.
**Stability guards.** `SeedDomain` carries explicit `#[repr(u64)]` discriminants and is append-only; the unit test `seed_domain_discriminants_are_pinned` fails CI if any is renumbered (which would re-roll every world). `AttractorType` likewise carries explicit `#[repr(u8)]` discriminants because it is cast `as u8` as a sort key (`features.rs`); reordering it would change attractor ordering and flip the cascade golden.
**Scope of effect (verified 2026-05-23):** SeedChain changes only the RNG-*using* layers — the existing `skeleton_gen.rs` (Layer 4 block placement) and the future Layer-3 settlement placement (#955). It does **not** affect Layer 0 heightmaps (produced by the Python `planet_simulation` pipeline, seeded separately via `--seed`, committed as `heightmap.png` files) nor Layer 1 (`drainage`/`features`/`subbiome` are RNG-free — pure functions of the heightmap). The #952 Layer 0→1 golden fixtures are therefore SeedChain-independent and can be captured in any order relative to the SeedChain work.
- **Rationale:** Three seeding paths had drifted apart — `AtlasRng` (LCG, "callers pre-mix"), `EntityRng` (correct splitmix64 mixing), and ad-hoc `wrapping_add` in atlas callers — while the code already *named* a SeedChain that didn't exist. A single typed derivation chain with domain separation makes every sub-stream reproducible from one world seed, eliminates the `(seed,id)` collision class `wrapping_add` invites, and gives the determinism harness (#952) a stable contract to verify against. Promoting one mixer prevents two divergent implementations.
- **Implementation:** #952 (Phase 4, epic #750) — `server/src/seed.rs` (incl. `for_body`/`fnv1a_64`), `SeedChain` threaded through the atlas RNG callers, an extensible cascade harness (`run_cascade`/`CascadeSnapshot`/`CascadeLayer`), and a golden-seed regression test (SHA-256 of `heightmap.png` + JSON-serialized `Layer1Output`, run at 256×128 so the river network is non-empty — JSON not msgpack, to match the diffable `golden_suite.rs` convention). Pre-Phase-5: no savegames exist, so the seed-stream change needs no migration; D-202's `schema_version` lineage covers future changes once saves exist.
- **Raised by:** Jeroen + Claude, `/whats-next` refinement of #952, 2026-05-23.
- **Cross-reference:** [D-010](#d-010) (determinism — integer-only, seed→identical output), [D-200](#d-200) (three-tier execution model), [D-208](#d-208) (RNG-free drainage), [D-223](#d-223) (names-only pool — placement uses seeded RNG), `simulation/rng.rs` (EntityRng / splitmix64 precedent)
- **Dissent:** None
---
*84 decisions (D-001 through D-224, excluding gaps). Last updated: 2026-05-23 (D-224 — SeedChain seed-derivation contract)*
+24 -1
View File
@@ -64,4 +64,27 @@ How the team works: composition, naming, workflow.
---
*4 decisions. Last updated: 2026-02-11*
### D-221: Replace sprint workflow with kanban + milestones
- **Date:** 2026-05-03
- **Decision:** Sprint-based workflow (38 sprints, Sprint 1–38) replaced by kanban + milestones. Sprint infrastructure deleted from the repo — git history serves as the archive. No rollback provision maintained in the codebase.
- **What stays:** Cascade phases (macro direction), agent personalities (divergence engines), D-records, tickets (descriptive not prescriptive), workshops, rules/memory system.
- **What changes:**
- Sprint boundaries → milestones tied to concrete deliverables (cascade phase, feature gate)
- Sprint briefings → live ticket queries (`ticket list --status in_progress` + dependency graph)
- Team branches (`sprint-N/team`) → topic branches per logical work unit
- File-scope restrictions per team → removed; any agent can touch any file
- Version `v0.1.{sprint_number}` → `v0.{phase}.{n}` tied to cascade phases
- Si (project manager) → Si (refinement manager) + new clerk agent (institutional guardrail)
- `/sprint-start` → `/whats-next` (dependency-driven batch selection with parallel Si refinement review)
- `/pr-push` → `/pr-process` (author-side review cycle: commit, push, pick up reviewer comments, dispatch fixes)
- Milestones are many-to-many with tickets, can run in parallel, can block other milestones
- **What dies:** Sprint CLI (`tooling/db/sprint`), sprint skills (`/sprint-start`, `/sprint-plan`, `/sprint-status`), sprint briefing files, sprint close/kickoff ceremonies, team-scoped file restrictions.
- **Rationale:** Structural failure modes across Sprints 37–38: ticket-as-contract (agents execute the letter instead of reading the code), artificial delay (ready work parked for "next sprint"), role theater (file-scope restrictions blocking one-liner fixes), order rigidity (stale briefings), locking on bad design (ticket authority overriding code reality), self-review collapse (coding + reviewing in same context window). See Q-096 for full analysis.
- **Test plan:** First milestone is Tier 0+1 planet-down-cascade work (6 schema tables + 6 Python importers). Measurement: did work flow or stall? Did removing file-scope restrictions cause chaos? Did tickets-as-description work?
- **Source:** Q-096 (raised 2026-04-20, expanded 2026-05-01)
- **Raised by:** Jeroen
- **Dissent:** None
---
*5 decisions. Last updated: 2026-05-03*
+9 -1
View File
@@ -330,4 +330,12 @@ Technical foundation questions: engine, protocols, data structures, performance,
---
*46 questions (8 resolved, 1 partially resolved, 37 open). Last updated: 2026-04-05.*
### Q-098: Persistence of generated river/city mapping outputs
- **Status:** Open — to resolve during Phase 4 execution-model work (#952)
- **Question:** The deterministic cascade can recompute river courses (D8 drainage, D-208) and city placements (economic sim + attractor matching, D-211) from seed at any time, so persisting them is a *cost* optimization, not a correctness need. But the compute is expensive — recomputing per session or per atlas view is waste. How are these mapping outputs persisted so they are computed **once per body and kept**? D-203's BodyWorldState cache is an LRU — it *evicts* (volatile). The fork: **(a)** build-time bake into `systems.db` (D-200 build-time tier — precompute all, ship); **(b)** lazy compute + persist at runtime (cache DB / savegame — compute on first visit, keep); **(c)** hybrid. Whatever the answer, the in-memory LRU should sit over a *durable* store so eviction triggers a cheap reload, not a recompute.
- **Context:** Raised 2026-05-22 looking ahead from the Phase 4 markers strip (#951). Refines D-200 (three-tier execution) and D-203 (LRU cache). Gates the Atlas layer viewer (#960), which needs persisted mapping to render without recomputing. Determinism (#952) guarantees recompute is always a valid fallback.
- **Cross-reference:** [D-200](architecture.md#d-200), [D-203](architecture.md#d-203), [D-208](architecture.md#d-208), [D-211](architecture.md#d-211), #952 (determinism harness), #960 (atlas viewer)
---
*47 questions (8 resolved, 1 partially resolved, 38 open). Last updated: 2026-05-22.*
+184
View File
@@ -0,0 +1,184 @@
# Open Questions — Process
How the team works: workflow mechanics, cadence, ceremony, release rhythm.
---
### Q-096: Replace sprint workflow with kanban + milestones
- **Status:** Resolved — see D-221
- **Date raised:** 2026-04-20
- **Last updated:** 2026-05-01 (expanded from "should we?" to concrete proposal with test plan)
- **Raised by:** Jeroen
- **Assigned to:** Jeroen (final call)
---
#### Problem Statement
Sprints were introduced when the workflow was framed as Scrum-adjacent. Across 38 sprints, the failure modes have sharpened beyond the original "ceremony overhead" concern into structural problems with how agents interpret sprint-shaped work.
**Original concerns (Sprint 36):**
| Mechanism | Compensates for | Applies here? |
|---|---|---|
| Time-box (1-2 weeks) | Humans need deadline pressure to scope | No — Claude scopes on demand |
| Retro | Team learning ritual, morale | No — adjustments land as rule/memory edits immediately |
| Burndown / velocity | Estimation & planning for multi-person roadmaps | No — solo dev, no external stakeholders |
| Demoable increments | External stakeholder cadence | No external stakeholders |
| Sprint-kickoff ceremony | Re-energize after context loss | Context loss is per-session, not per-sprint |
| Version bump at sprint close | Predictable shippable cadence | `v0.1.{sprint_number}` doesn't reflect feature progress |
**New failure modes observed (Sprint 37-38):**
1. **Ticket-as-contract.** Agents execute the letter of the ticket instead of reading the code and doing what's actually right. If the ticket says "implement X using approach Y," the agent implements Y even when the codebase clearly suggests Z. The ticket becomes a shield against thinking.
2. **Artificial delay.** Work that's ready gets parked because it's "next sprint." Work that's blocked gets thrashed because it's "this sprint." The sprint boundary creates urgency and delay simultaneously.
3. **Role theater.** Agents treat team-scope restrictions (server team can't touch client files) as hard boundaries when the fix is a one-liner. This models a human org chart, not the reality of one person and a context window.
4. **Order rigidity.** Agents unwilling to reorder work when dependencies shift, because the briefing prescribed a sequence. Sprint briefings go stale instantly (Sprint 38 server briefing was missing 26 tickets within a day of creation).
5. **Locking on bad design.** Agents commit to approaches described in tickets even when implementation reveals the design was wrong. The ticket's authority overrides what the code is telling them.
6. **Self-review collapse.** Coding agents write code, create the PR, review their own PR, approve it, and push — all in one convergent context. The adversarial review pipeline collapses because the reviewer has all the author's context and biases. PR comments aren't posted, review gates are skipped, the entire point of separate reviewer personalities is defeated. Requires constant human intervention to enforce.
**Compounding factors (May 2026):**
- Claude Code team-mode harness partially broken (v2.1.123: custom agent types missing SendMessage, shutdown doesn't work, TeamDelete doesn't kill panes). Workaround exists but fragile.
- Opus 4.7 prompt quality ("floatiness") makes the problem worse — agents are already less precise, and rigid ticket framing doesn't compensate, it amplifies.
---
#### Proposal: Kanban + Milestones + Adversarial Personalities
**What stays:**
| Element | Why it stays |
|---------|-------------|
| Cascade phases | Macro direction — what to build in what order |
| Agent personalities + briefings | Adversarial diversity — the real purpose is breaking convergence patterns by seeding different thinking via personality prompts. Not role-play, divergence engines. |
| D-records | Decisions are decisions regardless of process |
| Tickets | Track what needs doing — but descriptive, not prescriptive |
| Workshops | The format where adversarial diversity pays off. Unchanged. |
| Rules/memory | Live institutional knowledge, immediate course correction |
**What changes:**
| Old | New |
|-----|-----|
| Sprint numbers, sprint boundaries | Milestones tied to concrete deliverables (cascade phase, feature gate) |
| Sprint briefings (pre-baked, go stale) | Session startup: `ticket list --status in_progress` + blocked-by graph |
| Team branches (sprint-N/server) | Topic branches per logical chunk, any agent can touch any file |
| File-scope restrictions per agent | Removed — personalities guide perspective, not file access |
| Author and reviewer in same session | Worktree isolation enforced: reviewer spawns in separate worktree, reads diff cold |
| Version `v0.1.{sprint_number}` | `v0.{phase}.{n}` tied to cascade phases |
| Sprint-close cleanup sweep | Pre-push clerk hook (automated) + milestone-boundary audit (manual) |
| Si (project manager) + Qatux (librarian) as separate agents | Merged into "clerk" agent — institutional guardrail |
| Sprint velocity tracking | WIP limits per work-type (max N tickets in_progress) |
**What dies:**
- Sprint CLI (`tooling/db/sprint`)
- Sprint briefing files (`docs/sprints/sprint-N/`)
- Sprint-kickoff and sprint-close ceremonies
- The Si agent as a standalone personality
- Team-scoped file restrictions in CLAUDE.md
**Personalities are not teams.** Gestalt, Tyre, Paula, Burnelli-Sheldon, etc. survive as prompt-seeded divergence engines. They make pushback legible — when Jeroen sees a Burnelli-Sheldon objection he knows it's an economics concern, when he sees a Paula objection he knows it's a narrative/political concern. The personality is the signature, not a job description.
**Worktrees are context firewalls, not team boundaries.** Branches and worktrees survive — but their purpose changes from "team scope" to "context isolation." The reason a coding agent self-reviews its own PR is that it has all the context of writing the code. A reviewer spawned in a separate worktree physically cannot have that context — it reads the diff cold, which is exactly what a reviewer should do. The worktree enforces adversariality at the mechanism level, not the process level. This is an argument FOR keeping branch/worktree separation even after killing team-scoped restrictions.
---
#### Clerk Agent (Si + Qatux merge)
The team lead (interactive Claude session) optimizes for the user's current intent. This makes it structurally incapable of being the institutional guardrail — it will go along with whatever the user says in the moment, even if it contradicts a decision from three weeks ago.
The clerk is the separation of concerns between **responsiveness** (team lead) and **institutional memory** (clerk).
**Pre-push hook implementation:**
1. Pre-push hook spawns clerk agent with the diff
2. Agent checks D-record consistency, ticket drift, contradiction with active decisions
3. Agent writes findings to `.cache/pre-push-review.md`
4. Agent outputs exactly one word: `APPROVED` or `REJECTED`
5. Hook reads the word, exits 0 or 1
6. **Timeout behavior:** if agent doesn't respond within N seconds, automatic `REJECTED`. Agent keeps running in background — findings file arrives later. User reads it, decides whether to fix or force-push.
The findings file separates the agent's reasoning from its authority. Binary gate, verbose reasoning.
**What the clerk checks:**
- Changed files don't contradict active D-records
- Referenced decisions still exist
- Implementation doesn't drift from ticket intent (but ticket is descriptive, not prescriptive — drift is flagged, not blocked)
- Q-records relevant to changed files are surfaced
**What the clerk does NOT do:**
- Subjective code quality review (that's /pr-review)
- Sprint management (no sprints)
- Briefing maintenance (briefings replaced by live ticket queries)
---
#### Test Run Plan
**Scope:** The Tier 0/Tier 1 cascade work — all schema tables and Python importers from the planet-down-cascade workshop.
**Milestone definition:** "All schema tables and Python importers land on main. `make regen-db` produces a DB with heightmaps, city names, province boundaries, body_radius_km, normalized economic_role."
**Rules for the test run:**
- No sprint number assigned
- Topic branches per logical chunk (not team branches)
- Any agent personality can touch any file
- Tickets describe what needs doing, not how
- WIP limit: 3 tickets in_progress at once
- Milestone complete when all Tier 0 + Tier 1 tickets are done and verified
**What we're measuring:**
1. Did work flow or stall without sprint pressure?
2. Did removing file-scope restrictions cause chaos or remove friction?
3. Did tickets-as-description work, or did agents flail without prescriptive tickets?
4. Did anything get worse compared to Sprint 38's current state?
**Rollback trigger:** If after the test run the work feels less productive or less controlled than a sprint, Sprint 39 picks up where 38 left off. All sprint tooling remains in the codebase — dead code, not deleted code.
**Parallel work (independent of test run outcome):**
- Build the clerk pre-push hook — pure additive, helps regardless of process
- Can be tested against Sprint 38 pushes before the process switch
---
#### Risks
| Risk | Mitigation |
|------|-----------|
| No natural "stop and audit" moment | Clerk pre-push hook + milestone-boundary manual audit |
| Work sprawl without sprint focus | WIP limits enforce focus; milestones enforce shipping |
| Losing the "signature" on who did what | Personalities preserved — commits and PRs still carry agent identity |
| Process change is a distraction from actual development | Test run uses real cascade work, not meta-work. Process change is the test. |
| Can't roll back cleanly | Sprint tooling stays in repo. Reversible. |
| Clerk agent too slow / too noisy | Timeout-reject for speed. Tune signal-to-noise over first milestone. |
---
#### Decision Timeline
1. **Now:** Sprint 38 continues as-is
2. **Sprint 38 close:** Decision point — run test milestone or Sprint 39
3. **During test milestone:** Build clerk pre-push hook in parallel
4. **Test milestone complete:** Evaluate against measurement criteria
5. **If positive:** Write D-record confirming kanban + milestones. Kill sprint tooling.
6. **If negative:** Sprint 39. Revisit after more Claude Code harness fixes.
---
**Related:**
- `feedback_broken_tests_not_preexisting_shield.md` — pattern sprints may enable
- tmux teammate mode: confirmed working on Claude Code 2.1.148 (the earlier "broken" status was a `teammateMode: in-process` config issue, not a regression)
- Epic #854 — pipeline discipline automation
- D-166 — cascade phases
---
*Open count: 0.*
Binary file not shown.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned araminta`
## v0.2 Pivot — Key Changes for Your Role
The visual system shifts from designing layouts for three hand-crafted social sites to designing the visual grammar that the generator uses to produce legible zones at scale.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-04-05
Last updated: 2026-04-05
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned burnelli-sheldon`
## Your Domain
+42
View File
@@ -0,0 +1,42 @@
---
title: "Clerk - Project Briefing"
description: "Pre-push institutional guardrail — checks D-record consistency, ticket drift, decision contradictions, and Q-record surfacing before code lands"
type: briefing
status: active
agent: Clerk
updated: 2026-05-03
---
# Clerk - Project Briefing
Last updated: 2026-05-03
## Role: Pre-Push Institutional Guardrail
Review branch changes before push to catch institutional drift: decision contradictions, ticket scope creep, missing D-records for new behavior, and unresolved Q-records that should block merge.
## What to Check
1. **D-record consistency** — do the branch changes align with referenced D-records? Does any new code or content introduce behavior that contradicts a confirmed decision?
2. **Ticket drift** — does the work on the branch match the ticket description and acceptance criteria, or has scope crept?
3. **Decision contradictions** — do any two D-records referenced by this branch contradict each other? Has a D-record been amended elsewhere but not reflected in the branch?
4. **Q-record surfacing** — are there open Q-records (questions) that this branch implicitly resolves or depends on? If so, flag them for explicit resolution before merge.
## Where to Find Decisions
- **Decision index:** `decisions/README.md` — master index of all domain files
- **Domain files:** `decisions/architecture.md`, `decisions/perception.md`, `decisions/content.md`, `decisions/scope.md`, `decisions/economics.md`, `decisions/process.md`
- **Open questions:** `decisions/questions.md`, `decisions/questions-process.md`
- **Rejected proposals:** `decisions/rejected.md`
- **Decision DB table:**
```bash
tooling/db/sqlite-query "SELECT id, title, status, domain FROM decisions WHERE domain = '<domain>'"
```
## Output
Write findings to `.cache/pre-push-review.md`. End with a binary verdict:
- **APPROVED** — no institutional issues found, safe to push
- **REJECTED** — list each issue with the D/Q-record reference and what needs resolution
The findings file is gitignored (lives in `.cache/`) and is consumed by the push workflow.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned dudley`
## Status
STANDBY. This briefing will be populated when backend/engine implementation begins. Dudley activates when simulation implementation work is assigned.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned gestalt`
## Key Design Pivots
- **Tycoon bookmark (D-117):** Zero investigation content. Tycoon (small business owner) is the starting state.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned gore`
## v0.2 Pivot — Key Changes for Your Role
The workshop confirmed and sharpened the thematic frame. "Is this enough?" survives as the central question. The change is that it now must be posed through a generated tycoon's life, not through authored detective/smuggler characters.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned hoshe`
## Status
**ACTIVE.** Test infrastructure from earlier sprints is operational. v0.2 focus: generator output validation — can the generator produce legible NPCs? Are relationships readable from generator output?
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned justine`
## Status
**ACTIVE.** CI and build pipeline maintenance. Content validation infrastructure remains relevant as the AI-assisted content pipeline (D-123) produces NPC content at scale.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned mellanie`
## v0.2 Pivot — Key Changes for Your Role
The copy pipeline fundamentally shifts from hand-authoring specific NPC profiles to designing the culture-driven voice system that feeds the generator.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned miri`
## v0.2 Pivot — Key Changes for Your Role
The workshop confirmed Sova Transit District as the v0.2 setting (tycoon bookmark in Van Maanen's Star) and resolved the culture architecture gap in Miri's favour.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned nigel`
## v0.2 Pivot — Key Changes for Your Role
The replayability frame shifts from "same conspiracy, different character lens" to "same generator, different seeded world." The anti-metagaming goal survives; the mechanism changes.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned oscar`
## Status
STANDBY. This briefing will be populated when networking/multiplayer implementation begins.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned ozzie`
## v0.2 Pivot — Key Changes for Your Role
The workshop revealed a core framing error: the game was built as a detective puzzle game but the vision is a single-character life sim. The fix is not cosmetic.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned paula`
## v0.2 Pivot — Key Changes for Your Role
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned qatux`
## Decisions Relevant to Your Role
All decisions (D-001 through D-137) — maintains decision domain files in `decisions/` as the canonical record. Indexes and retrieves decision context for the team.
+49 -53
View File
@@ -1,66 +1,62 @@
---
title: "Si - Project Briefing"
description: "Sprint and project manager — owns sprint planning, ticket tracking, v0.2 critical path dependency chain, and CI pipeline awareness for definition of done"
description: "Refinement manager — assesses ticket readiness, ensures sufficient context before work begins, surfaces missing D/Q-records and unresolved dependencies"
type: briefing
status: active
agent: Si
updated: 2026-03-13
updated: 2026-05-03
---
# Si - Project Briefing
Last updated: 2026-03-13
Last updated: 2026-05-03
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned si`
## Role: Refinement Manager
Assess whether tickets have enough context to be worked. A ticket is ready when:
- It has a **clear outcome** — the agent can tell when the work is done
- All referenced **D-records are unambiguous** and confirmed (not draft, not contradicted)
- No **conflicting Q-records** remain open that would change the ticket's direction
- All **referenced artifacts exist** (files, schemas, specs mentioned in the ticket or its D-records)
If any of these conditions fail, the ticket is not ready. Flag what is missing and who owns the resolution.
## Where to Find Decisions
- **Decision index:** `decisions/README.md` — lists all domain files and how to query them
- **Domain files:** `decisions/architecture.md`, `decisions/perception.md`, `decisions/content.md`, `decisions/scope.md`, `decisions/economics.md`, `decisions/process.md`
- **Open questions:** `decisions/questions.md` (Q-NNN), `decisions/questions-process.md` (Q-NNN process)
- **Rejected proposals:** `decisions/rejected.md` (R-NNN)
- **Decision DB queries:**
```bash
tooling/db/decision show D-NNN # Show a specific decision
tooling/db/decision check-dupes "title" # Check for duplicates before creating
```
## Where to Find Workshop Outcomes
Workshop outputs live at `docs/workshops/<workshop-name>/`:
- `workshop-outcomes.md` — final summary with confirmed D-records
- Round files — per-round notes and participant outputs
- Check these when a ticket references a workshop decision to verify the full context is captured
## What "Enough Context" Means
A ticket has enough context when an agent can pick it up cold and deliver without asking clarifying questions. Concretely:
1. **Outcome is testable** — "implement X" has acceptance criteria; "design Y" has a deliverable format
2. **D-records are stable** — no open Q-record that could reverse the referenced D-record
3. **Dependencies are met** — if the ticket says "blocked by Miri's zone spec," that spec exists or the ticket stays in backlog
4. **Scope is bounded** — the ticket does not silently depend on cascade phases that have not been reached (see D-166 development cascade in CLAUDE.md)
## Key Commands
## Decisions Relevant to Your Role
All decisions across all domains. Read `decisions/README.md` for the index. Query decisions DB for sprint planning:
```bash
make decisions-coverage # Decision-to-ticket coverage by domain
make decisions-orphan # Decisions without implementing tickets
make decisions-active # List all active decisions
tooling/db/ticket list --status backlog # Candidates for refinement
tooling/db/ticket show <id> # Full ticket details
tooling/db/decision show D-NNN # Check a referenced decision
tooling/db/sqlite-query "SELECT * FROM decisions WHERE status = 'open'" # Open decisions
```
## Open Questions Assigned to You
All open questions - tracks as tickets for assignment and progress monitoring.
## Development Cascade (D-166)
Development follows a strict 6-phase cascade. Currently in Phase 1 (wiki content). Generator spike (D-119) is Phase 5.
**Prerequisites that must exist before/during the generator spike:**
1. Miri: Zone identity spec + Van Maanen's Star culture profile — blocks voice, NPC gen, AI pipeline
2. Araminta: Zone visual grammar, tile palettes — blocked by Miri zone identity spec
3. Tyre: NpcBlueprint struct design — generator output format
4. Mellanie: Culture-primary voice cards — blocked by Miri culture profile
**Dependency chain:**
```
Miri: Zone Identity Spec + Van Maanen's Star Culture Profile
|-- Araminta: zone visual grammar, tile palettes
|-- Tyre: generator zone template parameters, NpcBlueprint
|-- Mellanie: culture-primary voice cards
v
Tyre: Generator Spike (Sprint 25)
|-- Produces: auto-generated location + NPCs
v
Test: Can the player read NPC relationships from generator output?
```
**Estimated timeline (Tyre):** 7 sprints to proof-of-life playtest (generated location + legible characters + tycoon bookmark from creation to Day 3).
## Current Priorities
Consult `docs/sprints/` for current sprint briefing. Break v0.2 decisions (D-114 through D-137) into tickets if not already covered. Track dependencies above.
## Development Workflow
See `docs/DEVOPS.md` for full procedures. Key awareness for sprint planning:
- `make ci` must pass before any PR merges — definition of done includes CI green
- `make ci-server` = lint-server → build-server → test-server
- `make ci-client` = lint-client → build-client → test-client
- Server work lives in `server/`, client work in `client/`, cross-boundary tests in `tests/`
- Hoshe's test plans target these directories per D-030 testing architecture
## Key Documents
- decisions/ - domain-split decision files (see decisions/README.md for index)
- docs/DEVOPS.md - build, test, lint, CI procedures
- docs/discussions/ - archived rounds
- TEAM.md - team roster
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned stig`
## Status
**ACTIVE.** v0.2 brings a character customisation UI requirement and removes the dual-character variant for insert/HUD.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned tiger`
## Status
STANDBY. This briefing will be populated when localization work begins.
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned troblum`
## Decisions Relevant to Your Role
- D-010: Architectural baseline - four principles
+2 -1
View File
@@ -11,7 +11,8 @@ updated: 2026-03-13
Last updated: 2026-03-13
## Current Project State
See [PROJECT_STATE.md](../../PROJECT_STATE.md) for shared project state (cascade phase, sprint, decision count).
Check active milestones: `tooling/db/ticket milestone list`
Check your tickets: `tooling/db/ticket list --assigned tyre`
## Decisions Relevant to Your Role
Read `decisions/architecture.md` (primary) and `decisions/perception.md` (secondary). Key decisions:
+132
View File
@@ -0,0 +1,132 @@
# Gemma Naming Methodology (institutional knowledge)
**Status:** the implementing code (`gemma_naming.py`, `naming_core.py`, and the
`test_batch_naming.py` / `qa_naming.py` harnesses) was **retired in #951 (D-223)**
when authored markers became a names-only flavoured pool and the per-feature
geometry generator was retired. This document preserves *how* the Reach's place
names were generated so the approach can be rebuilt for the names-only format if
new bodies ever need fresh names. The names it produced are now the frozen pool
in each `markers.json` (`names.{rivers,mountain_ranges,oceans,cities,pois,...}`).
The pipeline named every empty `name` field across ~2,400 bodies' markers.json
files (cities, rivers, oceans, mountain ranges, gate terminals, landmarks) with
culturally-coherent, corridor-appropriate names — using a small local LLM, not a
hand-written name table.
---
## 1. Model & runtime
- **Model:** Gemma 2 2B (`gemma2.gguf`), later `gemma-4.gguf` — a *small* local
GGUF model, chosen so the whole Reach could be named offline on commodity
hardware (CPU fallback, ROCm/CUDA when available).
- **Serving:** `sr-voice serve --stdio` — a long-lived subprocess fed prompts over
stdin, replies over stdout. The same binary backs the in-game voice pipeline.
- **KV-cache bleed is the enemy.** A long-lived model accumulates context across
requests and starts echoing earlier completions (every river becomes "Aldren").
The subprocess was **restarted every `--refresh` requests** to flush the cache.
This single knob mattered more than any prompt tweak for output diversity.
## 2. Two-stage flow
1. **Register selection** (`select_register`) — for each star system, Gemma is
shown the corridor's candidate cultural sub-styles (numbered) plus a compact
cultural excerpt from the system's wiki + GTTR text, and asked to **pick the
number** of the best-fitting register. This grounds naming in the *authored*
cultural identity rather than a blind hash. Falls back to `palette_for`
(hash-based pick) if the model fails or there's no cultural text.
2. **Name generation** (`_build_prompt`) — generate names in the selected
register, per feature, with retries and dedup.
## 3. Corridor palettes — the cultural-design crux
Names are biased by **geographic sector** (the real corridor column in
`systems.db` is `star_systems.geographic_sector`). Each sector has a list of
**sub-style inflections**, each with a register description and ~5 example stems.
A sub-style is picked per system (via `hash(system_id)`) so neighbouring systems
rotate registers and the model's narrow ~15-stem vocabulary stays fresh across
hundreds of bodies.
> **Inflection is a dominant bias, not a hard lock.** A British surveyor on an
> east_reach moon still names a river after their aunt in Dorset. Each sub-style
> explicitly names its register *and invites diaspora variety.* This is what keeps
> the Reach feeling like blended-reality settlement rather than themed zones.
| Sector | Sub-style registers |
|--------|---------------------|
| `core` | English countryside · British colonial · American frontier · American municipal · Classical/civic · ANZ settler |
| `north_reach` | English rural/parish · Scottish Highland/Lowland · Australian outback · Irish coastal · South African English |
| `south_reach` | Portuguese colonial/Iberian · Brazilian interior · East African Swahili · Cape Verdean/West African · Angolan/Mozambican |
| `east_reach` | Korean · Japanese rural/coastal · Taiwanese/Hakka · Filipino · Mixed East Asian diaspora |
| `west_reach` | German compound · Dutch low-country · Nordic/Scandinavian · Polish/Czech · Baltic/Finnish |
| `deep_frontier` | Founder-surname · Surveyor-descriptive · Functional/military outpost |
Legacy aliases mapped to `core` (`sol-gateway-axis`, `inner_corridor`,
`inner_orbit`) and `deep_frontier` (`frontier`). Cross-cultural names in every
direction are expected and correct (see the corridor cultural-mixing principle).
## 4. Few-shot prompting (the model-fit lesson)
> Gemma 2 2B is **far better at pattern completion than instruction following.**
So prompts were *worked examples*, not instructions:
- Show **2 examples from *different* corridors than the target** (teach the
*pattern* — "system description → register number" or "register → place name" —
without biasing toward the target's vocabulary), then present the target and let
the model complete.
- Register selection used a fixed preamble with two `System: … → Best: N` examples,
then the target system, then `Best (number only):`.
- Per-feature generation rotated through example **pools** picked deterministically
by `hash(body_id, local_id, attempt)` so neighbouring features on one body don't
all draw the same prompt and collapse to identical outputs.
## 5. Context extraction (fitting 1024 tokens)
Gemma 2 2B has a ~1024-token context, so the authored cultural signal had to be
compressed hard:
- **GTTR hook** — the 30–45 word "Drifter's Guide" characterisation of the system
is the single biggest lever for names that feel like *this* world. Title lines
and section headers were stripped; the first substantive paragraph was used.
- **Wiki cultural lines** — `_extract_cultural_lines` scanned for cultural-identity
keywords (heritage, founding, settler, surname, language, diaspora, and explicit
culture names) and kept the strongest hits, falling back to opening prose.
- Budget math reserved tokens for preamble + tail + output; context was truncated
to fit.
## 6. Post-processing, retries, dedup, fallback
- **Deterministic seeds:** `seed = sha256("{role}|{id}|{attempt}").hexdigest()[:8]`
— reproducible, and bumping `attempt` rotates the completion on retry.
- **Earth-major blocklist:** `earth_blocklist.txt` rejected real Earth majors
(Paris, Tokyo, …). Earth-*echo* names are fine; Earth *majors* are not.
- **Retry:** on collision or blocklist hit, retry with a bumped seed, up to 3
attempts.
- **Deterministic fallback:** persistent failure fell back to a palette-driven
stem+suffix name (`fallback_name`, `_FALLBACK_STEMS`/`_FALLBACK_SUFFIXES`) so the
pipeline always produced *something* valid.
- **Dedup scope:** within `(geographic_sector, feature_type)` — no two bodies in
the same sector ship the same river name; **cross-corridor collisions are
allowed** (two "Aldren"s on opposite arcs is fine). Processing ran **core-first**
(`SECTOR_PRIORITY`) so core bodies won the dedup race and outer sectors took the
fallback path on collision.
## 7. Per-feature prompt config
Each feature type had its own subject framing, length hint, and example pools
(`_PROMPT_CONFIG`): rivers/oceans/seas/lakes/mountain ranges → "1–3 words";
capital → "1–2 words" (+ planet context); secondary towns → "1–2 words";
gate terminals → "2–3 words ending in 'Gate Terminal'/'Transit'/'Exchange'/
'Concourse'"; institutional & cultural landmarks → "2–4 words". Capitals and
secondary towns included planet context so town names sat under the world's name.
## 8. If you rebuild this for names-only markers
The data model changes: there are no longer per-feature records with empty `name`
fields to fill — the target is the **flat name pool** per body
(`names.cities`, `names.rivers`, …). A rebuild would:
1. Decide pool sizes per body (how many city/river/etc. names to mint).
2. Keep stages 1–6 verbatim — register selection, corridor palettes, few-shot
completion, seed/dedup/blocklist/fallback are all format-independent.
3. Write strings into `names.<category>` lists instead of into feature records,
and drop the atlas-DB sync entirely (the server cascade attaches pooled names
to computed features at placement — D-223, #955).
@@ -0,0 +1,601 @@
---
title: "Round 3 — Burnelli-Sheldon: Economics as Generator Input"
description: "Mapping systems.db economic fields to spatial generator parameters across all scales"
type: workshop
status: active
workshop: generation-cascade
agent: burnelli-sheldon
round: 3
created: 2026-04-30
---
# Round 3 — Burnelli-Sheldon: Economics as Generator Input
The economics layer is built. The question is what the spatial pipeline should read
from it. My job here is to be the bridge: not to redesign the economics model, but to
map its concrete, queryable fields to concrete, actionable generator parameters.
I'll work scale by scale, then address differentiation, corporate footprint, dynamic
settlements, and what the simulation needs back.
---
## Preliminary: What Economic Data Actually Exists
Before prescribing anything, I surveyed what is actually in systems.db. The picture
is richer than the schema alone suggests.
**Per inhabited body (bodies table, 275 inhabited rows):**
- `economic_role`: 10 distinct values — `manufacturing`, `agricultural`, `extraction`,
`transit`, `research`, `commercial`, `service_mixed`, `mining`, `frontier`, `energy`
- `settlement_pattern`: 15+ values — `urban_concentrated`, `dispersed`, `dispersed_rural`,
`domed`, `underground_concentrated`, `cave`, `village_network`, `town_network`, etc.
- `industrial_corridor`: `MVG`, `Gate_Corp`, `DSMC`, `Prometheus`, `Agricultural_Syndic`
(null for many bodies — absence is meaningful)
- `population` (range: ~800 to 8.5B), `planet_class`, `atmosphere`, `surface_gravity`
**Per system (system_economy, system_factions, system_culture):**
- `economic_tier` (1–5), `economic_base_primary/secondary`, `distribution_index` (stratified/moderate)
- `currency_zone`: TRACTUS_PRIMARY | MARK_PRIMARY | MIXED
- `gate_energy_connected`: boolean
- `governance_type`, `dominant_faction`, `primary/secondary_fault_line`
- `cultural_register`, `ambient_anxiety`, `active_situation`
**Corporations (275 rows, `corp_presence` cross-reference):**
- `scope`: reach-wide, sector, system, local (many nulls — needs cleanup)
- `behavioral_archetype`: Monopolist | Distributor | Producer | Specialist | Cooperative | Intermediary
- `supply_chain_role`, `shadow_economy_access`
- `headquarters_system`, `headquarters_body`
**Corp presence per location:** The `corp_presence` table records which corps operate at
each body/station with `primary_operation` (commodity). Locations range from 1 corp
(frontier outpost) to 13 corps (GJ380c/Lendel — the Groombridge Settlement House hub).
This distribution is the single most useful signal for district composition.
**Commodities (36 types) and production chains (21 chains):** `production_ubiquity`
(monopolistic → ubiquitous) and `location_bound` (currently all 0, but the field is
load-bearing for future extractive operations).
**Brand products:** `brand_category`, `scarcity_class`, `base_premium_multiplier`,
`brand_tier` (halo/volume). These are spatial differentiators — halo brands require
physical presence; volume brands are ubiquitous.
**What is NOT yet queryable:** `behavioral_archetype` is mostly null. `scope` field on
corporations is a mixed bag (some are text prose, not enum values). The generator will
need to gracefully handle nulls on both.
---
## 1. Economic Data by Scale
### Regional Scale (planet → city hinterlands)
Regional subdivision is essentially hinterland characterization — what lies between
cities on a body. Economic signals at this scale:
**Primary input:** `bodies.economic_role` → hinterland type
| economic_role | Hinterland character | What fills the space |
|---|---|---|
| agricultural | Farmland belts, processing nodes | Field tiles, irrigation channels, silos |
| extraction / mining | Resource zones, access corridors | Open pits, pipeline corridors, camp clusters |
| manufacturing | Industrial fringe, supply chains | Warehousing strips, rail freight yards |
| transit | Corridor infrastructure | Road/rail density, relay stations |
| research | Campus periphery, exclusion zones | Restricted zones, observatory sites |
| commercial | Suburban sprawl, trade routes | Mixed low-density blocks, markets |
| service_mixed | Residential spread | Generic residential, civic amenities |
**Secondary input:** `atlas_roads`, `atlas_railroads` — road and rail count/density on
the body determines whether hinterland is connected or isolated. A body with 5+ rail
lines has industrial corridors linking cities; one with no rail has regional isolation.
**Tertiary input:** `system_economy.economic_tier` — tier 1–2 bodies have sparse
hinterland infrastructure regardless of `economic_role`. A tier-1 extraction world
looks like isolated mining camps; a tier-4 extraction world (Witwatersrand, GJ3522c)
has the industrial capacity to fill the hinterland.
**Recommendation:** The regional hinterland generator needs two parameters per body:
1. `HinterlandType` — derived from `economic_role`
2. `InfrastructureDensity` (0.0–1.0) — derived from `(economic_tier + rail_count + road_count) / normalization`
### City Scale (city → district decomposition)
The D-C4 decomposition formula (district count from population) is correct. The
economic question is: once you have N districts, what types are they?
**The mapping I propose is three-column: economic_role × settlement_pattern × distribution_index:**
#### economic_role → DistrictType distribution
I'll express this as a probability weight vector across the 9 confirmed DistrictType
values from Round 1/2 notes (Residential, Commercial, Industrial, Administrative,
LogisticsHub, Entertainment, MixedUse, Transit, Specialized):
| economic_role | Res | Com | Ind | Adm | Log | Ent | Mix | Trn | Spe |
|---|---|---|---|---|---|---|---|---|---|
| manufacturing | 25 | 5 | 35 | 5 | 20 | 2 | 5 | 3 | 0 |
| agricultural | 30 | 15 | 5 | 10 | 25 | 3 | 10 | 2 | 0 |
| extraction | 20 | 5 | 30 | 5 | 30 | 0 | 5 | 5 | 0 |
| transit | 10 | 20 | 5 | 5 | 25 | 10 | 15 | 10 | 0 |
| research | 20 | 5 | 5 | 15 | 5 | 5 | 10 | 0 | 35 |
| commercial | 15 | 35 | 5 | 5 | 10 | 15 | 15 | 0 | 0 |
| service_mixed | 25 | 20 | 5 | 10 | 5 | 15 | 20 | 0 | 0 |
| mining | 20 | 5 | 25 | 0 | 35 | 0 | 10 | 5 | 0 |
| frontier | 35 | 10 | 10 | 5 | 20 | 5 | 15 | 0 | 0 |
| energy | 10 | 0 | 20 | 5 | 30 | 0 | 5 | 5 | 25 |
These are not final tuning targets — they are the prior that seed generation should use
before corp-presence and governance modifiers are applied (see §3 below).
#### settlement_pattern modifier
`settlement_pattern` is a strong secondary signal. It doesn't change DistrictType
distribution — it changes *how districts are arranged*:
- `urban_concentrated`: compact grid, high-density blocks, minimal green space
- `dispersed` / `dispersed_rural`: districts smaller and more separated, low-density
building tiles, road-separated rather than contiguous
- `domed`: maximum 1 district (hardcoded — entire settlement is one pressurized dome);
DistrictType is always Specialized
- `underground_concentrated` / `cave`: vertical stacking matters; single large district
with strong perimeter treatment (Checkpoint or Walled)
- `village_network` / `town_network`: each "city" entry in atlas represents a network
of small settlements rather than one city — decompose into many Residential + small
Commercial; no Industrial districts unless industrial_corridor is set
#### distribution_index → prosperity gradient
`system_economy.distribution_index` gives the city-level prosperity shape:
- `stratified`: prosperity_index spans full range. A stratified manufacturing capital
has elite executive districts (0.8+) adjacent to worker housing (0.2). This creates
visible class geography.
- `moderate`: prosperity_index clusters around 0.4–0.6. The city is more economically
homogeneous — no dramatic wealth contrasts.
Concrete formula: if `distribution_index = stratified`, assign prosperity_index as
`0.1 + (district_rank / N) * 0.8` where district_rank is sorted by DistrictType
prestige (Administrative > Specialized > Commercial > Entertainment > Residential >
MixedUse > Industrial > LogisticsHub > Transit). If `moderate`, all districts draw
from N(0.5, 0.1) clipped to [0.2, 0.8].
### District Composition
Once district types are assigned, the generator needs to fill each district. Economic
data drives two things here: **tile density** and **building character**.
**Tile density from economic_role + DistrictType:**
A Commercial district on a manufacturing world differs from the same DistrictType on a
transit hub. The former is a small company store; the latter is a major trading floor.
Use this rule: `base_density = district_type_density[DistrictType] × role_multiplier[economic_role]`
Role multipliers (examples): transit on Commercial = ×1.8; manufacturing on Commercial
= ×0.5; research on Specialized = ×1.5; agricultural on LogisticsHub = ×1.4.
**Commodity signal → building archetype:**
The `corp_presence.primary_operation` field tells us what commodity each corp at this
location primarily deals in. Cross-referencing with `commodities.bulk_class`:
| bulk_class | Physical form → building archetype |
|---|---|
| bulk | Open yards, conveyor structures, grain silos |
| liquid | Tank farms, pipe corridors |
| precision | Clean-room buildings, elevated access points |
| perishable | Cold storage blocks, loading docks with shade cover |
| non_physical | Office towers, transmission arrays, no loading dock |
| standard | Generic warehouse / distribution |
This mapping is queryable: `SELECT cp.primary_operation, c.bulk_class FROM corp_presence cp JOIN commodities c ON cp.primary_operation = c.commodity_id WHERE cp.location_id = ?`
### Infrastructure: Roads, Rail, Ports
**Road quality:** `economic_tier + settlement_pattern → road_quality`
| tier | urban_concentrated | dispersed | domed |
|---|---|---|---|
| 1 | narrow_unpaved | tracks | internal_only |
| 2 | narrow_paved | gravel_road | internal_only |
| 3 | medium_paved | narrow_paved | internal_only |
| 4 | boulevard | medium_paved | internal_only |
| 5 | boulevard | boulevard | N/A |
**Rail placement:** Query `atlas_railroads` — if a body has railroad entries, the
generator should anchor rail yards in LogisticsHub districts and place station tiles in
Transit districts. No authoring needed; the atlas already marks rail presence.
**Port/spaceport location:** Transit and LogisticsHub districts should be placed at the
city edge closest to the body's orbital station (if `stations.orbits_body_id` matches).
If no station exists, spaceport tiles go at the district with highest `logistics`
weight. Port capacity is a direct read from `stations.docking_class`.
---
## 2. Economic Differentiation: Concrete Examples
Theory is cheap. Let me walk through three real bodies in systems.db to show how
economic fields produce spatially distinct results.
### Case A: Tributarium (GJ251c/city_0) — Manufacturing Capital
```
body.economic_role = manufacturing
body.settlement_pattern = urban_concentrated
body.industrial_corridor = Gate_Corp
body.population = 5B
system.distribution_index = stratified (GJ 251 — inferred from tier 5)
system.economic_tier = (reached via GJ251 system_economy)
corp_presence at GJ251c = gate-corporation (HQ system), kalten-assembly, precision-works...
```
**Generator output:**
- District composition: ~35% Industrial, ~20% LogisticsHub, ~25% Residential, ~5% Administrative
- Prosperity: sharply stratified — Gate Corp executive district at 0.9, worker housing at 0.15
- Gate Corp `behavioral_archetype = Monopolist` → one dominant industrial district type; consistent building footprint
- `industrial_corridor = Gate_Corp` → gate component manufacturing tiles dominate Industrial districts
- `gate_components` commodity: `bulk_class = precision` → clean-room buildings, not open yards
- Road network: tier 5 → boulevards; but industrial zones have dedicated freight lanes
### Case B: Bluebank (GJ3522c/city_0) — Extraction Capital
```
body.economic_role = extraction
body.settlement_pattern = urban_concentrated
body.industrial_corridor = DSMC
body.population = 1.5B
system.distribution_index = stratified
corp_presence = dsmc (DSMC HQ system)
```
**Generator output:**
- District composition: ~30% Industrial, ~30% LogisticsHub, ~20% Residential, ~5% Administrative, ~5% Transit
- `metallic_ore` + `rare_minerals` → bulk_class = bulk → open pit adjacency, conveyor structures, slag heap tiles
- DSMC = reach-wide Monopolist → single employer city; company housing blocks (uniform, low prosperity)
- No Entertainment districts. Very low prosperity (worker housing at 0.15–0.25)
- LogisticsHub → ship loading yards (bulk cargo configuration), railway terminus
**Contrast with Tributarium:** Both are industrial. Tributarium has precision manufacturing
(implants, gate components) → clean-room aesthetic, corporate towers. Bluebank has
raw extraction (ore, rare minerals) → open yard aesthetic, functional brutalism.
The aesthetic difference is entirely derivable from `production_ubiquity` and `bulk_class`.
### Case C: Strata (GJ280Ad/city_0) — Service Mixed Capital
```
body.economic_role = service_mixed
body.settlement_pattern = urban_concentrated
body.industrial_corridor = NULL
body.population = 1.8B
system.distribution_index = stratified
corp_presence = rare-vein-survey (HQ), adams-ford-publishing (HQ)
```
**Generator output:**
- District composition: ~25% Residential, ~20% Commercial, ~10% Administrative, ~15% Entertainment, ~20% MixedUse
- No industrial corridor → no dominant building archetype; district types are heterogeneous
- Two reach-wide HQ corps → two landmark buildings (publisher HQ, survey company HQ)
- `non_physical` services (financial, legal, entertainment) → office towers, no loading docks
- `distribution_index = stratified` + `service_mixed` → gentrified commercial district (prosperity 0.75) adjacent to mid-tier residential (prosperity 0.45)
**Contrast with Bluebank:** Strata has negligible Industrial districts. Its LogisticsHub
equivalents handle information rather than cargo — server banks, archive towers.
The physical infrastructure (roads, buildings) looks wealthy but not industrial.
---
## 3. The Corporation Footprint
Corporations are the most underutilized signal in the current bridge-gap. The
`corp_presence` table is already populated and queryable. Here is how it maps to space.
### Corporate Presence Tier
Derive a `CorpPresenceTier` per city from the corp_presence query:
```sql
SELECT COUNT(DISTINCT cp.corp_id) as corp_count,
COUNT(DISTINCT CASE WHEN co.scope = 'reach-wide' THEN cp.corp_id END) as halo_count,
SUM(CASE WHEN co.headquarters_system = b.system_id THEN 1 ELSE 0 END) as hq_count
FROM corp_presence cp
JOIN corporations co ON cp.corp_id = co.corp_id
JOIN bodies b ON cp.location_id = b.body_id
WHERE cp.location_id = ?
```
| corp_count | halo_count | tier | District effect |
|---|---|---|---|
| 1–2 | 0 | Company Town | Single employer → uniform district character |
| 3–5 | 0–1 | Local Economy | Mixed district types, small commercial |
| 6–10 | 1–2 | Regional Hub | Commercial district expands, entertainment emerges |
| 11+ | 3+ | System Nexus | Full district variety, landmark buildings |
GJ380c (Lendel) has 13 corps including 2 reach-wide HQs → System Nexus.
GJ1111b has 5 corps, 0 HQs → Local Economy.
This difference is real and pre-populated.
### HQ Presence → Corporate District
When `corporations.headquarters_system = atlas_city.body.system_id` AND
`headquarters_body = atlas_city.body_id` (or close enough), that city gets a corporate
district. Rules:
- One corporate district per reach-wide HQ corp at this city
- The corporate district's DistrictType is Administrative (if Monopolist) or Commercial (if Distributor/Specialist)
- `base_premium_multiplier` from brand_products by this corp → scales the landmark building height (halo ×18 brands get the skyline)
### Behavioral Archetype → District Shape
Where `behavioral_archetype` is populated:
| archetype | District character |
|---|---|
| Monopolist | Large uniform blocks, single building type, minimal commercial variety |
| Distributor | Dense transit + logistics districts; loading infrastructure heavy |
| Producer | Industrial districts with process-flow layout (input → process → output) |
| Specialist | One Specialized district with restricted perimeter; premium prosperity |
| Cooperative | Residential more integrated with commercial; lower wealth disparity |
| Intermediary | Commercial + financial services; mixed-use preferred |
Where `behavioral_archetype = NULL` (most corps currently), fall back to
`supply_chain_role` if populated, or derive from `primary_operation` commodity tier.
### Shadow Economy
`corporations.shadow_economy_access = 1` → this location has a shadow market.
Physical manifestation: one MixedUse district at the city fringe with reduced
perimeter treatment (Open instead of Gated), elevated tile decay (prosperity 0.15–0.25),
and elevated building density (informal construction = wasted space is illegal).
`system_fiscal.collection_efficiency < 0.7` at the system level → amplify shadow zone
size by `(1 - collection_efficiency) × 2` as a district probability weight modifier.
---
## 4. Dynamic Settlements (Off-Atlas)
The atlas marks cities. But economic pressure creates settlements the atlas doesn't
know about. These are procedural — generated from economic conditions, not authored.
### Trigger Conditions
**Mining camps** (highest frequency):
- Condition: `bodies.economic_role IN ('extraction', 'mining')` AND body has
`corp_presence` entries with `primary_operation IN ('metallic_ore', 'rare_minerals',
'lattice_grade_material')`
- Generator action: place 1 satellite settlement per 2 qualifying corps, at randomized
distance from nearest atlas city (15%–40% of map scale)
- Settlement type: single LogisticsHub district + minimal Residential; no Commercial
- Size: 1 district, population ~1,000–50,000
**Trade route waypoints**:
- Condition: `atlas_roads.kind IN ('highway', 'commercial')` with point_count > 5
(long road connecting two distant cities)
- Generator action: place a waypoint settlement at the geometric midpoint of roads
longer than a threshold
- Settlement type: Transit district (fueling stop, overnight), small Commercial
- Size: 1 district, population ~500–5,000
**Agricultural dispersed nodes**:
- Condition: `bodies.settlement_pattern IN ('dispersed', 'dispersed_rural')` AND
`bodies.economic_role = 'agricultural'`
- Generator action: scatter farm clusters at interval proportional to `1/economic_tier`
(lower tier = fewer, sparser farms)
- Each farm cluster: 1 Residential district + 1 LogisticsHub, no perimeter treatment
- Size: sub-district scale
**Shadow economy nodes**:
- Condition: `system_fiscal.collection_efficiency < 0.6` AND system has shadow-viable
corps in `corp_presence`
- Generator action: place 1 informal settlement per system, adjacent to a major city
but not on its district grid
- Settlement type: MixedUse fringe; elevated decay, low prosperity
### What the Generator Needs Per Dynamic Settlement
Unlike atlas cities, dynamic settlements do not have authored names or economic roles.
The generator should derive:
- `settlement_seed = child_seed(body_seed, trigger_type_hash + location_hash)` — FNV-1a per D-010
- `economic_role` = inferred from trigger condition (mining camp → extraction, etc.)
- `prosperity_index` = base 0.15 for camps; 0.25–0.4 for waypoints
- `district_count` = always 1
- Name = procedural suffix of nearest atlas city ("Bluebank Camp 3", "Kallast Waypoint")
---
## 5. What the Economic Simulation Needs Back from the Spatial World
This is the other side of the bridge. Once districts exist, the simulation's trade
cost model gets better data.
### Transport Distance
The D-C4 formula places districts at city-local coordinates `(col × 512, row × 512)`.
After generation, the simulation needs:
```
city_transport_cost_modifier(from_city_id, to_city_id) → f32
```
Derived from: `atlas_roads` + `atlas_railroads` point_count between two cities on the
same body. Cities connected by rail get a 0.4× cost multiplier (per D-178 gate edge
5–12%); road-only cities get 1.0×; no infrastructure gets 2.5×.
This does not require the spatial world to be generated — it's derivable from atlas data
alone. The generator does not need to feed this back; it's a pre-generation query.
### Port Capacity
`stations.docking_class` (major/standard/restricted/none) is already in systems.db.
After generation, the port district's physical size (tile count in Transit districts)
should be reported back as `effective_port_size: u32`. This feeds the trade flow
capacity constraint: a Restricted port can't handle bulk cargo regardless of
economic_role.
Concrete mapping:
- `docking_class = major` → Transit district tile budget = 256 tiles; throughput_coefficient = 1.0
- `docking_class = standard` → 128 tiles; throughput_coefficient = 0.5
- `docking_class = restricted` → 32 tiles; throughput_coefficient = 0.2
- `docking_class = none` → no Transit district; throughput_coefficient = 0.0
### Infrastructure Quality
`settlement_pattern` + `economic_tier` → road quality (derived above). The simulation
should read `road_quality: RoadQuality` per body as a trade cost multiplier:
| road_quality | intra-city cost multiplier |
|---|---|
| narrow_unpaved | 3.0 |
| gravel_road | 2.0 |
| narrow_paved | 1.5 |
| medium_paved | 1.0 |
| boulevard | 0.7 |
This is already derivable from `economic_tier` + `settlement_pattern` without running
the generator — another pre-generation query, not a feedback dependency.
### What Actually Requires Generator Feedback
Most simulation needs are pre-generation derivable. The only fields that genuinely
require post-generation reporting:
1. **Effective port tile count** — physical district layout determines actual cargo
throughput, not just declared docking class
2. **Corp district allocation** — which DistrictType a given corp actually occupies,
once the district assignment algorithm runs (needed for corp financial state updates)
3. **Shadow economy footprint** — whether a shadow zone was generated, and its
prosperity floor (informs `collection_efficiency` updates)
These are three narrow feedback fields, not a broad data exchange. The simulation
should not depend on generator re-runs for routine tick processing.
---
## Responses to Round 2 Open Questions
### OQ-R2-1: Should `prosperity_index` be in the minimum slice?
**Yes.** And here is the economic argument for inclusion rather than deferral:
`prosperity_index` is not a decorative field — it is a readability signal derived
entirely from data we already query: `distribution_index` + `economic_role` +
`economic_tier`. The computation is trivial (one formula, no authoring). The cost of
deferral is not "decorative refinement later" — it is "all districts look equally
prosperous until a retrofit pass." Retrofit changes Phase 2 tile generation. Include
it in Stage 1 classification as a derived field; it costs 0.5 dev-days.
Concrete derivation formula:
```
base_prosperity = economic_tier / 5.0
role_modifier: manufacturing=0.0, extraction=-0.1, research=+0.15, service_mixed=+0.1,
commercial=+0.05, agricultural=-0.05, transit=-0.05, frontier=-0.2
if distribution_index = stratified:
district_prosperity[i] = (base + role_modifier) + (district_prestige_rank(i) - 0.5) * 0.7
else: # moderate
district_prosperity[i] = clamp(N(base + role_modifier, 0.1), 0.2, 0.8)
```
### OQ-R2-2: Should `political_archetype` be a formal authored field?
**Derive it; do not author it separately.** From an economist's perspective, this is
the classic "add a variable or derive it from existing ones" tradeoff. The answer is
always: derive unless you have reason to believe the derivation fails.
Derivation rules:
- `corp_presence` where one `behavioral_archetype = Monopolist` corp accounts for >60%
of corps → `CompanyTown`
- `governance_type = assembly` AND `commission_presence` high → `AdminCapital`
- `economic_role = transit` AND `corp_count > 6` → `FreePort`
- `primary_fault_line` AND `secondary_fault_line` both populated and active →
`Contested`
- Else → `OrganicGrowth`
If a named city fails this derivation in a way that's clearly wrong (the lead decides
this), add a one-field override in `atlas_cities` schema. But the starting position
should be: trust the data.
### OQ-R2-5: Naming register lookup table — before Phase 2?
**After walkable.** This is a pure naming question that has zero effect on tile layout.
The register taxonomy is correct and useful; the lookup table determines what *kind* of
name a district gets, not what tiles it contains. It can be authored once a district
exists and is legible. Defer.
---
## D-Record Candidates
### D-C9 (proposed by Burnelli-Sheldon): Economic Fields → Generator Parameter Mapping
The spatial generator's primary input from systems.db is the following read-only query
set, executed once per city at generation time:
```sql
-- All queries execute against server/data/systems.db (read-only)
-- City economic context
SELECT b.economic_role, b.settlement_pattern, b.industrial_corridor,
b.population, b.planet_class, b.atmosphere
FROM bodies b
JOIN atlas_cities ac ON ac.body_id = b.body_id
WHERE ac.city_id = ?;
-- System-level modifiers
SELECT se.economic_tier, se.distribution_index,
sf.dominant_faction, sf.governance_type,
ss.currency_zone, ss.gate_energy_connected
FROM system_economy se
JOIN system_factions sf ON se.system_id = sf.system_id
JOIN star_systems ss ON se.system_id = ss.system_id
WHERE se.system_id = (SELECT system_id FROM bodies b
JOIN atlas_cities ac ON b.body_id = ac.body_id
WHERE ac.city_id = ?);
-- Corporate presence at this body
SELECT co.corp_id, co.behavioral_archetype, co.scope, co.shadow_economy_access,
cp.primary_operation, c.bulk_class
FROM corp_presence cp
JOIN corporations co ON cp.corp_id = co.corp_id
LEFT JOIN commodities c ON cp.primary_operation = c.commodity_id
WHERE cp.location_id = (SELECT body_id FROM atlas_cities WHERE city_id = ?);
```
This data is sufficient to derive:
- `DistrictType` distribution weights per city
- `prosperity_index` per district
- `CorpPresenceTier` and corporate district placement
- `political_archetype` (derived, not authored)
- `perimeter_treatment` per district
- Infrastructure quality parameters
- Dynamic settlement trigger conditions
### D-C10 (proposed by Burnelli-Sheldon): economic_role → DistrictType Weight Table
The mapping table in §1 above (manufacturing → 35% Industrial, etc.) should be a
confirmed D-record, not just a design note. It is the foundational prior that all
district composition derives from. Corp-presence modifiers apply on top of it.
The table should live in Rust as a const array, keyed by `EconomicRole` enum. It can
be tuned later, but a locked canonical default prevents every sprint from relitigating
the same question.
---
## Summary: The Minimum Read Set
If the generator can only read one thing from systems.db per city, it should read
`bodies.economic_role`. Every other field is a refinement multiplier.
The full minimum read set for a differentiated Phase 1 district skeleton:
1. `bodies.economic_role` — district type distribution
2. `bodies.settlement_pattern` — district layout and density mode
3. `system_economy.economic_tier` — infrastructure quality and wealth floor
4. `system_economy.distribution_index` — prosperity gradient shape
5. `corp_presence` count at this body — corporate district intensity
6. `corporations.headquarters_system` match — HQ landmark flag
Everything else (behavioral_archetype, shadow economy, bulk_class, brand premiums,
currency zone) is Phase 2 enrichment — meaningful, but not required for the minimum
walkable world.
The pipeline will produce economically differentiated worlds on those six fields alone.
Everything else makes them richer.
@@ -0,0 +1,505 @@
---
title: "Round 4 — Burnelli-Sheldon: Economics Integration for Planet-Down Cascade"
description: "Economics domain requirements for the planet-down brief: layer entry points, determinism split, dynamic settlement latency, and per-layer DB field mapping"
type: workshop
status: active
workshop: generation-cascade
agent: burnelli-sheldon
round: 4
created: 2026-04-30
---
# Round 4 — Burnelli-Sheldon: Economics Integration for Planet-Down Cascade
The lead has reframed the cascade as planet-down. This changes the framing, not the
economics. My job this round is to map my accepted contributions from Round 3 into the
new four-layer model and answer the specific questions the lead flagged.
---
## 1. Where Does Economic Data First Enter?
**Answer: Layer 1, weakly. Layer 2, in full.**
### Layer 1 — Empty World (weak influence)
The lead defines Layer 1 as "natural world, no civilization." That is exactly right.
Economics does not shape terrain. But there is one legitimate economics-adjacent
influence at this layer: **biome quality distribution**.
A planet was settled because it was worth settling. The body's `economic_role` in
systems.db is not a cause of its terrain — it is the *outcome* of someone deciding
this terrain was worth exploiting. That selection creates a plausibility constraint:
an `agricultural` world had to have arable soil; an `extraction` world had to have
accessible ore. If the Layer 1 generator produces a volcanic hellscape for a body
with `economic_role = agricultural`, the world is self-contradictory.
The fix is not to have economics drive terrain geometry. It is to use
`bodies.economic_role` + `bodies.planet_class` as a **biome probability prior** in
the sub-biome refinement step:
| economic_role | Sub-biome quality bias |
|---|---|
| agricultural | More temperate/arable cells, gentler slopes, fewer badlands |
| extraction / mining | More rugged terrain, higher mineral-rich designations |
| research | More terrain variety (observable geography = research value) |
| transit | More navigable terrain, natural passes and harbors |
| frontier | Unconstrained — hostile terrain is plausible |
| manufacturing | Near-neutral; manufacturing follows settlement, not terrain |
This is a prior, not a hard constraint. The biome generator can still produce a
difficult agricultural world (thin soil, reclamation history) — but the prior nudges
it toward plausibility. If this is too complicated for Layer 1, the alternative is
simply to trust that `planet_class` encodes this well enough already (`temperate`
planets are already the natural home of `agricultural` worlds) and leave Layer 1
fully physics-driven.
Either approach is defensible. I flag it because the contradiction risk is real and
cheap to address at Layer 1 rather than later.
**Economic fields at Layer 1:**
- `bodies.economic_role` — biome probability prior (optional)
- `bodies.planet_class`, `bodies.atmosphere`, `bodies.surface_gravity` — primary physical inputs
### Layer 2 — Population Overlay (primary entry point)
This is where economics enters in full force and where my Round 3 work connects
most directly. The lead's description — "economics onto geography, settlements anchor
where geography supports them, felled forests → farmland, road/rail networks, anchored
by wiki population counts" — is precisely what the economics data enables.
The `atlas_cities` table provides wiki-anchored city positions and populations. The
`bodies` table provides the economic character. The `corp_presence` table shows which
corporations cluster where. The `atlas_roads` and `atlas_railroads` tables provide
infrastructure. All of this is pre-computed and stored in systems.db.
Layer 2 is not city-internal planning — it is the hinterland: what fills the space
between cities. Economic role determines hinterland character:
| economic_role | Hinterland fill | Notes |
|---|---|---|
| agricultural | Cleared farmland, irrigation networks, processing nodes | "Felled forests → farmland" lives here |
| extraction | Access corridors, mining infrastructure, stockpile areas | Extends to resource deposit locations |
| manufacturing | Industrial fringe, rail freight yards, supply zones | Follows logistics corridors |
| transit | Dense road/rail, relay stations, fuel depots | The infrastructure IS the hinterland |
| research | Exclusion zones, observatory sites, quiet buffer | Sparse, low-density |
| service_mixed | Suburban residential spread, civic infrastructure | Generic low-density |
**Economic fields at Layer 2 (full list):**
From `bodies`: `economic_role`, `settlement_pattern`, `industrial_corridor`, `population`, `planet_class`
From `atlas_*` tables: city positions and populations, road/rail presence, POI kinds
From `system_economy`: `economic_tier` (infrastructure density), `economic_base_primary/secondary`
From `system_gates`: `gate_connections` (trade route intensity → transport infrastructure density)
From `corp_presence`: which corps operate here, `primary_operation` commodity
Dynamic settlement triggers (see §3 below) are also evaluated at Layer 2.
---
## 2. The Determinism Rule — Layout vs. Appearance
The lead's rule is economically clean and I agree with it completely:
> Economic sim's rolling state affects RENDERING (prosperity, repair) NOT LAYOUT
> (streets locked by seed).
This is how real cities work. Detroit's street grid did not change when its industrial
base collapsed. Buenos Aires' Palermo grid was not rezoned during financial crisis.
The buildings aged and emptied; the streets persisted.
Here is the full split as I see it:
### Seed-Locked (Layout) — Generated Once, Never Changed
These are determined by `(world_seed + CityGenerationContext)` at generation time:
- Street grid pattern and block geometry
- Building footprints and lot parcels
- District boundaries
- Perimeter treatment type (Open / Fenced / Walled / Gated / Checkpoint)
- Access point positions
- Settlement positions — including LATENT settlements (see §3)
- Corporate campus footprints (the campus exists even if the corp is failing)
- Port infrastructure physical extent
- Road quality tier (narrow_unpaved through boulevard)
### Economic-Sim-Dynamic (Appearance) — Updated at Runtime
These are driven by the economic simulation's rolling state:
- `prosperity_index` per district — the sim updates this; rendering reads it to
choose tile variants
- Building repair state — prosperity below threshold triggers decay tiles
(ChunkMutations record explicit player damage; general decay is a rendering parameter)
- Active vs. ghost status of latent settlements (see §3)
- Corporate signage and branding presence — corp `health_metric` from
`corp_financial_state` drives whether branded signage renders
- Stockpile visibility in logistics districts — active trade → visible cargo tiles;
depressed trade → empty yard tiles
- Lighting state — prosperity floor affects illumination (powered vs. dark windows)
### The Key Insight: Prosperity Is a Render Parameter, Not a Tile Mutation
The most important implication of this split is that `prosperity_index` should NOT
be implemented as tile-level ChunkMutations for routine decay. It is a single float
per district. The renderer reads it and applies a decay probability to tile selection.
When prosperity drops from 0.6 to 0.3:
- The streets don't change
- The building footprints don't change
- The tile SELECTION changes (cracked pavement variant instead of clean; boarded
window instead of lit; rust on the facade)
- This happens purely in the rendering path, with no world mutation
ChunkMutations remain reserved for player-caused or explicit-event damage — a specific
building explosion, a player-placed barricade. General economic decay is a render
parameter. This distinction matters for performance: prosperity changes don't generate
millions of tile mutations.
### The "Felled Forest → Farmland" Exception
The lead specifically mentioned this as a Layer 2 dynamic. It seems to contradict
the layout-lock rule. Reconciliation:
"Felled forests → farmland" is **regional-scale land use** (Layer 2), not
**city-scale tile layout** (Layer 4). The lock rule applies to city-internal geometry
(streets, buildings). Regional land use — what biome category fills the hinterland
between cities — CAN change as a Layer 2 update when economic conditions shift.
Concretely: if the agriculture corp expands, new farmland clears adjacent to existing
cities. This is a land-use tile change at biome-cell resolution (large tiles covering
hectares), not a building-by-building tile mutation. The distinction:
- Layer 2 regional land use: coarse-resolution, economics-driven, can update
- Layer 4 city street/building layout: fine-resolution, seed-locked, never changes
This is not a contradiction. It is a resolution boundary.
---
## 3. Dynamic Settlements in the Planet-Down Model
In Round 3, I proposed four types of economically-triggered dynamic settlements:
mining camps, trade route waypoints, agricultural dispersed nodes, and shadow economy
nodes. The planet-down model changes how these work.
### The Latency Principle
Under the determinism rule, settlement POSITIONS must be seed-locked at generation
time. But economic conditions change — a mine depletes, a trade route shifts. The
reconciliation is **latent settlements**:
> The generator places ALL economically plausible settlement positions at Layer 2.
> Whether each settlement is ACTIVE is determined by the economic sim's running state.
> An inactive settlement exists spatially — as ruins, as empty structures, as cleared
> ground — but it is dark, unmaintained, and depopulated.
This is economically accurate. Ghost towns exist. The buildings are there; no one
lives in them. The road to the closed mine still exists.
### How This Works Per Settlement Type
**Mining camps:**
- At Layer 2 generation: evaluate `corp_presence` against body. Every body with
`economic_role IN ('extraction', 'mining')` and 2+ corps operating `metallic_ore` or
`rare_minerals` gets N seed-derived camp positions (N = corp count / 2).
- Active status at runtime: `corp_financial_state.health_metric > 0.4` for the
operating corp → camp is active (populated, lit, maintained). Corp distressed or
dissolved → camp becomes ghost state (dark, decayed render variants).
- Why this is seed-locked: the geological deposit that caused the camp to be placed is
geological fact, not economic contingency. The deposit doesn't move when the corp
fails; the camp's position doesn't either.
**Trade route waypoints:**
- At Layer 2 generation: `atlas_roads` entries with `point_count > 5` (long roads) get
a seed-derived waypoint at the geometric midpoint.
- Active status: `gate_links` trade flow proxy (nearby system still connected and
populated) → active. If the terminal system is abandoned, the waypoint becomes a
ruin.
- Seed-locked because: the road was built; the physical clearing was made. Even
abandoned trade routes leave ruins.
**Agricultural dispersed nodes:**
- At Layer 2 generation: `settlement_pattern IN ('dispersed', 'dispersed_rural')` AND
`economic_role = 'agricultural'` → scatter farm cluster positions at interval derived
from `1/economic_tier`.
- Active status: `corp_presence` corp with `primary_operation = 'agricultural_produce'`
health_metric → active or fallow.
- Fallow farms look different from ghost towns: cleared land, overgrown structures,
but the clearing and track remain. These are the richest "economic decay visible in
the world" cases.
**Shadow economy nodes:**
- At Layer 2 generation: `system_fiscal.collection_efficiency < 0.6` on the parent
system → place one informal settlement adjacent to the largest atlas city, position
seed-derived.
- Active status: shadow viability is a structural condition, not a corp health metric.
If collection_efficiency recovers (enforcement crackdown), the settlement is still
there but its character changes (from busy informal market to quiet derelict cluster).
### What This Means for the Layer 2 Implementation
Layer 2 must query the full economics dataset and produce:
1. All atlas-city hinterland characterizations
2. All latent dynamic settlement positions (with type tag and activating corp/condition)
3. Regional land use grid (farmland, industrial fringe, wilderness, etc.)
None of this requires the economic sim to be running. It is all derived from the
snapshot in systems.db at world generation time. The sim then drives the active/ghost
flag as its rolling state changes.
---
## 4. Economic Data Per Cascade Layer — Full Mapping
### Layer 1 — Empty World
| Field | Source table | Usage |
|---|---|---|
| `economic_role` | bodies | Biome probability prior (optional) |
| `planet_class` | bodies | Primary terrain generation input |
| `atmosphere` | bodies | Surface physics input |
| `surface_gravity` | bodies | Terrain height ceiling |
Economics is a soft advisory at this layer. The primary inputs are physical.
### Layer 2 — Population Overlay
| Field | Source table | Usage |
|---|---|---|
| `city_id`, `center_row`, `center_col`, `population`, `kind` | atlas_cities | Settlement anchor positions and sizes |
| `economic_role` | bodies | Hinterland character |
| `settlement_pattern` | bodies | Population distribution mode |
| `industrial_corridor` | bodies | Corridor-specific infrastructure character |
| `economic_tier` | system_economy | Infrastructure density floor |
| `economic_base_primary/secondary` | system_economy | System-level hinterland bias |
| `gate_connections`, `gate_topology` | system_gates | Trade route intensity → transport density |
| `corp_id`, `location_id`, `primary_operation` | corp_presence | Corporate cluster positions |
| `behavioral_archetype` | corporations | HQ layout character |
| `road_*` entries | atlas_roads | Existing road network |
| `railroad_*` entries | atlas_railroads | Rail corridors |
| `collection_efficiency` | system_fiscal | Shadow node trigger condition |
| `health_metric` | corp_financial_state | Settlement active/ghost status at generation |
Dynamic settlement triggers are evaluated from this field set.
### Layer 3 — City-Level Planning (zoning)
This is where the 10×9 matrix applies. The City-Level Planning layer receives a
`CityGenerationContext` (the Phase 3 → Phase 5 handoff struct from Round 3 consensus)
and produces a **zoning map** — land-use assignments per parcel, constrained by
topography from Layer 1 and infrastructure from Layer 2.
| Field | Source | Usage |
|---|---|---|
| `political_archetype` | atlas_cities (Phase 3 derived) | Zoning mix character |
| `prosperity_index` | atlas_cities (Phase 3 derived) | Base wealth gradient for zoning |
| `founding_orientation` | atlas_cities (Phase 3 derived) | Grid orientation, oldest district direction |
| `road_entry_directions` | atlas_road_edges (Phase 3 derived) | Commercial spine anchor |
| `surrounding_biome` | atlas_regional_biomes (Phase 3 derived) | Topographic zoning constraints |
| `district_count` | CityGenerationContext | How many zones to carve |
| `economic_role` | bodies | 10×9 weight matrix row selector |
| `distribution_index` | system_economy | Prosperity gradient shape (stratified vs. moderate) |
| `corp_presence` count | corp_presence (query) | Corporate district intensity |
| `behavioral_archetype` | corporations | District layout shape (Monopolist → homogeneous) |
| `shadow_economy_access` | corporations | Informal district placement trigger |
| `collection_efficiency` | system_fiscal | Informal district size multiplier |
| `currency_zone` | star_systems | Regulatory character (Commission presence) |
| `governance_type`, `dominant_faction` | system_factions | Administrative district character |
**The 10×9 matrix in this context:**
The matrix from Round 3 still applies at Layer 3, but the framing shifts from "assign
district types" to "weight zoning categories." Topography from Layer 1 and
infrastructure from Layer 2 interact with the matrix weights:
- Industrial zones: weighted toward flat terrain near logistics access (road/rail from
Layer 2). If the only flat terrain is already occupied by the commercial spine,
Industrial zones shift to city fringe.
- Residential zones: weighted against flood plains (Layer 1 biome data), toward
terrain above the industrial elevation.
- LogisticsHub: always adjacent to road/rail entry points from Layer 2.
- Specialized (research): weighted toward elevated or secluded terrain.
The matrix provides the prior. Topography and infrastructure provide the constraints.
The product is a zoning map that is economically motivated AND geographically sensible.
**Prosperity gradient at Layer 3:**
`distribution_index = stratified` → the zoning map assigns a monotonic prosperity
gradient across district types. Highest prosperity zones go at the end of the road
spine (historic center); lowest at the industrial fringe. This is the "Cities
Skylines" version of the formula I proposed in Round 3.
`distribution_index = moderate` → zones cluster in the 0.4–0.6 prosperity band with
less variation. The zoning map is flatter economically.
### Layer 4 — Street-Level Rendering
Layer 4 receives the zoning map from Layer 3 and produces tile grids. Most of Layer 4
is seed-derived (street pattern, building footprints). Economics enters at two points:
**At generation time (seed-locked):**
| Field | Source | Usage |
|---|---|---|
| `bulk_class` | commodities (via corp_presence.primary_operation) | Building archetype (open yard vs. clean room vs. office) |
| `base_premium_multiplier` | brand_products | Landmark building height/prominence |
| `prosperity_index` | Per-district from Layer 3 | Initial tile variant selection |
| `scarcity_class` | brand_products | Frequency of branded signage tiles |
**At runtime (dynamic, no tile mutation):**
| Signal | Source | Render effect |
|---|---|---|
| `prosperity_index` (updated by sim) | Economic simulation | Tile variant selection (clean vs. decayed vs. ruined) |
| `corp_financial_state.health_metric` | Economic simulation | Corporate signage presence, maintenance appearance |
| Active/ghost settlement status | Economic simulation | Lighting, activity, population density rendering |
| `collection_efficiency` | Economic simulation | Shadow market visual density |
The runtime signals do not change tile positions. They change which tile variant renders
at each position. The implementation is: the rendering system reads `prosperity_index`
per district from the sim's current state and applies a decay curve to tile selection.
---
## 5. Data Quality Issues That Must Be Resolved Before Layer 3
I flagged two data quality issues in Round 3. They become blockers at Layer 3:
**Issue 1: `behavioral_archetype` is mostly NULL**
Of the 275 corporation rows I surveyed, `behavioral_archetype` is NULL on most records.
The fallback chain I proposed in Round 3 still applies:
`behavioral_archetype` → `supply_chain_role` → commodity tier derivation from
`primary_operation`.
But `supply_chain_role` is also sparse. The realistic near-term fallback is
commodity tier: a corp whose `primary_operation = 'metallic_ore'` is a Producer;
`'financial_services'` is an Intermediary; `'gate_components'` is a Specialist.
This derivation should be added to `import_economics.py` as a backfill step — it can
fill NULL `behavioral_archetype` rows from commodity tier with a low confidence flag.
This is not required for the minimum viable slice but it enriches Layer 3 zoning
quality significantly.
**Issue 2: `scope` field is mixed-type text**
Corporation `scope` values are a mix of enum-ish text ("reach-wide", "local") and
prose descriptions ("GJ 338B local; north corridor secondary"). The generator cannot
reliably parse prose scope descriptions.
For Layer 3 corporate district intensity, I use `corp_count` as the proxy (not scope).
But for landmark building placement, I need to identify reach-wide HQ corps. A cleanup
pass on `scope` to normalize it to an enum (reach_wide | sector | system | local) would
be the right fix. Until then, the generator should treat any `scope` value that doesn't
exactly match a known enum string as `system`-tier.
---
## 6. Open Questions This Round Raises
The planet-down model introduces new questions that Round 3 didn't address:
**OQ-R4-B1: Who owns the latent settlement active/ghost flag?**
I've proposed that settlement active/ghost status is driven by the economic sim at
runtime. But the sim needs to know which settlements are latent and what conditions
activate them. Does this flag live:
- (a) In the generated world state as a sim-readable component on each settlement entity?
- (b) In a systems.db Phase 3 output table (settlement positions + trigger conditions)?
- (c) Computed entirely at runtime from corp presence + health?
Option (b) is cleanest — Phase 3 stores latent settlement positions and trigger
conditions, and the sim reads them alongside `corp_financial_state`. This integrates
naturally with the `generate_regional.py` pipeline.
**OQ-R4-B2: Does the "felled forests → farmland" change generate ChunkMutations?**
I argued above that this is a Layer 2 regional-scale change (large cells, biome-level),
not a Layer 4 tile mutation. But practically: when an agricultural corp expands and new
farmland appears, does this register as ChunkMutations in the save file, or as a biome
grid update to systems.db (requiring `make regen-db`)?
I lean toward: regional land use changes are coarse enough that they update a
`regional_land_use` column in the biome grid at runtime (not a full regen-db), and
the Layer 4 renderer reads the coarse grid to determine what large-scale tile type
fills undeveloped hinterland cells. This is not ChunkMutations — it's a different
resolution.
This needs an answer before Phase 3 and Phase 5 teams start building their respective
land-use systems with incompatible resolution assumptions.
**OQ-R4-B3: Does `prosperity_index` apply at district-level or block-level?**
In Round 3 I defined it at district level (one float per district). Under the
planet-down model, with topography-constrained zoning, a single district might span
dramatically different terrain (the industrial hillside vs. the commercial valley
floor). Should prosperity be a per-district scalar, or a per-block modifier?
Per-district is simpler and consistent with the Phase 5 architecture (DistrictSkeleton
has one `prosperity_index`). Per-block would enable richer decay gradients (a district
where prosperity collapses from the periphery inward). I recommend per-district for
now with a note that the field can be moved to BlockSkeleton in a later pass if the
single-value model proves too coarse.
**OQ-R4-B4: How does `distribution_index = stratified` interact with topography?**
In a stratified system, the wealth gradient is steep — executive district at 0.9,
worker housing at 0.15. In the planet-down model, topography often already encodes
wealth geography (hilltops are historically desirable; industrial lowlands are not).
Do we:
- (a) Let the topographic constraint drive the gradient independently of distribution_index?
- (b) Use `distribution_index` to set gradient magnitude and topography to set direction?
- (c) Only apply distribution_index when topography is flat (no natural gradient)?
Option (b) is my recommendation: topography tells us WHICH end is high-prosperity;
distribution_index tells us HOW STEEP the gradient is. A stratified city on a
hill has elite zones at the top; a moderate city on a hill has mixed zoning across
all elevations.
---
## Summary: Requirements for the Brief
The brief should specify:
1. **Economics enters weakly at Layer 1** (biome probability prior from economic_role —
optional but recommended) and **fully at Layer 2** (hinterland character, settlement
placement, infrastructure density).
2. **The determinism split is:** Layer 4 street/building layout is seed-locked.
Economic sim updates `prosperity_index` per district (render parameter, not tile
mutation). Regional land use (Layer 2) CAN update at biome-cell resolution.
3. **Dynamic settlements are latent at generation:** All economically plausible
settlement positions are placed seed-derived at Layer 2. Active/ghost status is
driven by economic sim. Ghost settlements exist spatially — they just look
abandoned.
4. **The 10×9 matrix applies at Layer 3** (City-Level Planning), modified by
topographic constraints from Layer 1 and infrastructure access from Layer 2.
The matrix provides the zoning prior; terrain provides the spatial constraints.
5. **The minimum economics read set for Layer 3** is the same 6-field set from
Round 3, now delivered via the `CityGenerationContext` struct rather than direct
DB queries at generation time. This is already captured in D-C12.
6. **`prosperity_index` is a Layer 3 output** (derived from economics data, stored
in CityGenerationContext / atlas_cities) and a **Layer 4 runtime parameter** (the
sim updates it; the renderer reads it for tile variant selection).
7. **Data quality pre-work:** `import_economics.py` should add a `behavioral_archetype`
backfill pass from commodity tier before `generate_regional.py` runs. `scope` field
normalization is recommended but not blocking.
@@ -0,0 +1,287 @@
---
title: "Gestalt — Round 1: Systems Inventory"
description: "Pipeline inventory from the systems design perspective: layer states, system interactions, economics integration, and ticket dependency analysis"
type: workshop
status: active
workshop: generation-cascade
agent: gestalt
round: 1
created: 2026-04-30
---
# Generation Cascade — Round 1: Gestalt Inventory
**Focus:** System interactions across pipeline layers. Where does economics enter? Where do social sites, NPC population, and zone palettes enter? What's the minimum viable viewable world? What tickets formally block the right work?
---
## Pipeline Inventory — Layer by Layer
### Overview
| Layer | Name | Status | Input | Output | Key Code |
|-------|------|--------|-------|--------|----------|
| 1 | Galaxy / System definitions | **DONE** | Wiki markdown frontmatter | `systems.db` (star_systems, bodies) | `tooling/economy-db/import_economics.py` |
| 2 | Planetary terrain simulation | **DONE** | Body definition (planet_class, biome) | Elevation/temp/moisture/water grids (in-memory) | `tooling/planet-gen/planet_simulation.py` |
| 3 | Atlas markers (city placement) | **DONE** | Terrain grids + systems.db (population, settlement_pattern) | `markers.json` (cities, roads, rail, rivers, mountains, POIs) | `tooling/planet-gen/generate_atlas.py` |
| 4 | City naming | **DONE** | markers.json (empty name fields) + cultural_corridor | markers.json (named cities and features) | `tooling/planet-gen/gemma_naming.py` |
| 5 | Economics layer | **DONE** | Wiki TOMLs + brand templates + Rust binary | systems.db (corps, brands, supply chains, trade flows) | `tooling/economy-db/import_economics.py` |
| 6 | City → District decomposition | **MISSING** | City entry from markers.json (position, population, kind) | N district stubs with WorldTier/ComplexityTier/SettingType | Nothing exists |
| 7 | Phase 1: DistrictSkeleton generation | **STUB** | District classification from Layer 6 | Filled DistrictSkeleton (block grid, reservations, social sites, zone palette) | `server/src/simulation/generator.rs` — types only |
| 8 | Society profile resolution | **PARTIAL** | cultural_corridor + heritage roots from body/system | SocietyProfileRef → cultural behavioral inputs | #679 (done) but not wired to DistrictSkeleton |
| 9 | Phase 2: ChunkData tile generation | **MISSING** | DistrictSkeleton Phase 1 output | Filled 64×64 tile grid (WalkabilityMap + tile types) | Nothing connects generator to tile data |
| 10 | Heritage grammar application | **MISSING** | SocietyProfileRef + zone palette + chunk skeleton | Culturally modified tile layout | HeritageGrammarOverlay not implemented |
| 11 | NPC population (Phase 2) | **PARTIAL** | DistrictSkeleton.society_profile + social_sites | NpcManifest → NPC pool with triangle assignments | Triangle/template system is FULL (#107, #108 done); NpcManifest seeding from skeleton is missing |
| 12 | Chunk streaming to generator hookup | **STUB** | Player position → on-demand load request | Generator-filled ChunkData delivered to WalkabilityMap | `chunk_streaming.rs` — load/unload works but calls static v0.1 data, not generator |
---
## Layer Details
### Layer 1–5: What's Done
These layers are genuinely complete and pipeline-capable. Layer 3 (atlas generator) deserves particular attention — it does real terrain-aware computation: continent flood-fill, habitability scoring, river mouth detection, A* road routing, MST rail generation. The output is rich and the data is in systems.db. **The atlas is the last fully-working pipeline layer.**
The economics layer (Layer 5) is likewise complete: corporations, brands, supply chains, trade flows, fiscal systems are all in systems.db. There is no code bridging this data downstream to district/zone generation yet.
### Layer 6: City → District Decomposition — MISSING (Critical Gap)
This is the first entirely missing layer. The atlas produces city markers (position, population, kind: capital/city, economic_role). Converting a city into N districts with appropriate classifications requires:
- Population tier → district count formula (e.g. population ÷ 50,000 per district, min 1)
- city.kind + body.economic_role → DistrictType assignments
- body.planet_class + city position → SettingType (Urban/Agricultural/Maritime/etc.)
- system WorldTier → ComplexityTier ceiling
**No ticket formally covers this.** #899 (District skeleton generator — Phase 1) assumes this decomposition as a prerequisite input but does not scope it. This layer needs its own ticket.
### Layer 7: Phase 1 DistrictSkeleton — STUB, with a Critical Data Model Discrepancy
`server/src/simulation/generator.rs` has a complete DistrictSkeleton struct with all fields the workshop designed. Types compile. But:
1. **No generation logic exists.** Every field that requires reasoning (block grid ZoningType assignments, social site placement, reservation generation, zone palette selection) has stub type aliases:
- `ZoneDefinition = String`
- `SocietyProfileRef = String`
- `DistrictContext = String`
- `Era = String`
- `AccessPoint = String`
- `GuaranteeAuditResult = String`
2. **WorldTier enum does not match the workshop decision.** The code has:
```rust
enum WorldTier { Peripheral, Connected, Core }
```
The workshop (D-READY-2, workshop-outcomes.md) locked:
```rust
enum WorldTier { Epicenter, Regional, Backwater, Passage, Waypoint }
```
This is a blocker. #899 cannot be implemented correctly against the current enum. Must be fixed before any generation logic touches WorldTier.
### Layer 8: Society Profile Resolution — Partial
Ticket #679 (location-to-culture resolution system) is done. This means we can resolve cultural_corridor → heritage_root for any location. **But this output is not yet wired into DistrictSkeleton.society_profile.** The `SocietyProfileRef` field is a String stub — the culture system exists but isn't connected to the generation path.
This is a wiring gap, not a design gap. Relatively cheap to fix once Phase 1 generation logic exists.
### Layer 9: Phase 2 ChunkData Tile Generation — MISSING (Critical Gap)
This is the other critical missing layer. `chunk_streaming.rs` has a solid streaming architecture — load/unload around player position works correctly. But `walkability.load_chunk(coord)` creates empty chunks; it doesn't call any generator.
The v0.1 world tiles come from YAML files authored by hand (#577) — `server/data/locations/`. The generator never produces tiles. **There is no pipeline from DistrictSkeleton → actual walkable tile data.**
For Phase 2 to work:
- ChunkData needs a tile layer (beyond the walkability bool grid)
- A generator function must take BlockSkeleton + DistrictSkeleton and fill a 64×64 tile grid
- chunk_streaming.rs needs to call this generator on `ZLevelLoadState::Ungenerated` chunks
Existing pre-workshop tickets (#100 "Ungenerated → instantiation", #156 "Procedural district filler", #160 "Tier 3 procedural generator") pre-date the confirmed architecture. They need re-scoping against DistrictSkeleton/ChunkData.
### Layer 10: Heritage Grammar — MISSING
`HeritageGrammarOverlay` is designed (D-READY-9 from workshop) but no Rust type exists. This is Phase 2+ — it applies at chunk fill time. Not a blocker for the minimum viable slice, but it IS what makes tiles culturally distinguishable.
### Layer 11: NPC Population — Partial (More Done Than Expected)
The triangle system (`server/src/simulation/triangle.rs`) is **substantially implemented** — the workshop brief calls it "stubs" but the file is extensive: template schemas, triangle escalation tick system, NPC spawn patterns. Intra-template (#107) and cross-template (#108) triangle generation are both done.
What's missing is the Phase 2 pipeline connection: NpcManifest seeding from DistrictSkeleton.society_profile + social_sites. But the machinery to run NPCs once they're spawned exists. This means NPC population is a **wiring problem**, not a design or implementation gap.
### Layer 12: Chunk Streaming Hookup — Stub
`chunk_streaming.rs` has the right architecture for v0.3+ on-demand loading (commented explicitly in the file: "For v0.3+, the generator fills newly loaded chunks with terrain data"). The radius=8 default covers the entire v0.1 district, so nothing unloads. The system is ready for the generator hookup; it just needs the generator to exist.
---
## System Interactions Analysis
### Economics Layer → Generation Pipeline
The economics layer is rich but its connections downstream are **all implicit data, no code**.
| Economics Output | Where It Should Enter | Current State |
|-----------------|----------------------|---------------|
| `bodies.economic_role` (Agriculture/Refining/Transit/etc.) | Layer 6: district decomposition → DistrictType | Not wired |
| `bodies.cultural_corridor` | Layer 8: society profile → SocietyProfileRef | #679 done but not wired to skeleton |
| Corporation presence in system | Phase 1: DistrictSkeleton zone_palette modifiers (FactionModifier) | ZoneDefinition is a String stub |
| Supply chain density | Phase 1: DistrictType (Commercial/Industrial/Logistics) weighting | No connection |
| Brand presence | Phase 2 / Heritage grammar: signage, storefronts | HeritageGrammarOverlay not implemented |
| Trade flow volume | WorldTier assignment (high-traffic → higher tier) | No connection |
**Key design principle for Round 2:** The economics → generation bridge should be explicit in Layer 6. When we decompose a city into districts, the economic_role should directly drive DistrictType proportions:
- `economic_role=Agriculture` → mostly Agricultural setting + Industrial blocks for processing
- `economic_role=Refining` → Industrial + Specialized
- `economic_role=Transit` → Transit + Commercial + Logistics
This is a systems design decision that should produce D-records.
### Where Social Sites, NPC Population, and Zone Palettes Enter
| System | Entry Layer | Entry Mechanism | Current State |
|--------|-------------|----------------|---------------|
| Zone palettes | Phase 1 (Layer 7) | DistrictSkeleton.zone_palette (Vec<ZoneDefinition>) | ZoneDefinition = String stub |
| Social sites | Phase 1 (Layer 7) | DistrictSkeleton.social_sites (Vec<SocialSitePlacement>) | SocialSitePlacement struct exists; no generation code fills it |
| NPC pool | Phase 2 (Layer 11) | NpcManifest seeded from society_profile | NpcManifest not yet a type; society_profile is a String |
| Triangle assignments | Phase 1 (Layer 7) | DistrictSkeleton.social_sites[i].triangles | No generation code |
| Heritage grammar | Phase 2 (Layer 10) | HeritageGrammarOverlay applied at chunk fill time | Not implemented |
**Critical ordering constraint:** Zone palettes must be determined at Phase 1 before chunk tiles can be generated in Phase 2. Social sites must be placed before NPC positions can be assigned. The dependency chain inside Phase 1 is:
```
Classification (WorldTier, ComplexityTier, SettingType)
→ Block grid assignment (ZoningType per block)
→ Zone palette selection (economics + era + culture)
→ Social site placement (template + block assignment)
→ Triangle assignment (TrianglePurpose per site)
→ Guarantee audit (3-tier checks)
```
The Phase 2 chain continues:
```
DistrictSkeleton (immutable)
→ ChunkData tile fill (floor/wall/zone tile types)
→ Heritage grammar overlay (cultural modifier)
→ NpcManifest instantiation (society_profile → NPC pool)
→ Triangle → NPC assignments (existing triangle system)
```
### Minimum Viable "Viewable World"
The thinnest vertical slice from heightmap to rendered tiles:
**Inputs available now:** One city entry from markers.json (position, population, kind). Body's planet_class, economic_role, cultural_corridor from systems.db.
**Minimum code to write:**
1. **Layer 6 stub**: Fixed formula `n_districts = max(1, population / 50_000)`. Assign one SettingType from planet_class. Assign WorldTier from system lookup. Output: N (WorldTier, ComplexityTier, SettingType) tuples.
2. **Layer 7 minimal**: Generate DistrictSkeleton with real block grid. Simple deterministic ZoningType assignment from DistrictType (e.g. Commercial district → mostly Commercial blocks, some Mixed). No social sites. No reservations. No guarantee audit. Zone palettes as named constants only.
3. **Layer 9 minimal**: For each BlockSkeleton, fill 4 chunks (2×2) with simple tile layout: exterior walls + floor interior + one door per edge. No rooms, no objects, no heritage grammar. Pure structural.
4. **Wiring**: In chunk_streaming.rs, on `load_chunk()` when no YAML data exists, call the Phase 2 generator instead of returning empty. The existing tilemap renderer already handles ChunkData.
**What this produces:** A player standing in a procedurally-generated city block. Walls are walls. Floor is floor. No cultural character. No NPCs. No social sites. But it's walkable, streaming, and fully pipeline-driven — which is what the cascade deliverable requires.
**Estimated effort for minimum viable slice:** ~5–7 dev-days of focused server work (Layer 6: 0.5 days, Layer 7 minimal: 2 days, Layer 9 minimal: 2 days, wiring: 1 day, WorldTier enum fix: 0.5 days).
---
## Ticket Dependency Chain
### Current State of Referenced Tickets
| Ticket | Title | Status | Current Blockers | My Assessment |
|--------|-------|--------|-----------------|---------------|
| #897 | Generation pipeline cascade (this workshop) | backlog | — | Active; blocks #899 |
| #899 | District skeleton generator — Phase 1 | backlog | #897 | Correct scope, but misses Layer 6 and WorldTier fix |
| #615 | Tycoon small business owner starting state | backlog | — | Game content design, not generation-dependent |
| #616 | Broad economic verb vocabulary design | backlog | — | Pure content design, no generation dependency |
| #619 | Full character customisation | backlog | — | Phase 4 cosmetic UI, NOT generation-dependent |
| #681 | Apartment generator logic (server) | backlog | #615, #679(done) | Needs cascade blocker: Phase 1 skeleton + cultural context |
| #682 | Apartment visual rendering (client) | backlog | #681 | Correct — blocked by #681 |
| #694 | Character creation screen | backlog | #685(done) | Phase 4 UI, NOT generation-dependent |
### Character and Apartment Work: What Actually Needs Blocking
**#619 (Character customization) and #694 (Character creation screen):** These are cosmetic UI tickets for Phase 4 (Player Control Scheme). Character visual appearance is independent of world generation. **These should NOT be formally blocked behind generation pipeline work.** The player needs to look like something before the world needs to be generated. Do not add generation blockers here.
**#681 (Apartment generator):** This IS generation-dependent and needs additional blockers. The apartment:
- Lives in a building in a district → needs that district's zone context
- Uses cultural context → #679 done, but the **wiring to DistrictSkeleton** is not done
- Uses economic position → #615 (tycoon starting state) is a real dependency
Recommended: add formal blocker on #681 behind Phase 1 district skeleton working. The apartment generator cannot produce culturally-appropriate tile layouts without knowing the district's society_profile and zone_palette.
**#682 (Apartment visual rendering):** Correctly blocked by #681. No changes needed.
### New Tickets Needed
| Priority | Ticket | Scope | Blocked By |
|----------|--------|-------|-----------|
| **Blocker** | Fix WorldTier enum — Peripheral/Connected/Core → Epicenter/Regional/Backwater/Passage/Waypoint | Rename enum values in generator.rs to match workshop-locked decision | #897 |
| **Critical** | Layer 6: City → District decomposition | Convert atlas city markers to district count + classification stubs | None (can start after WorldTier fix) |
| **Critical** | Phase 2: Basic chunk tile generator | Fill 64×64 chunk from BlockSkeleton: walls + floor, no rooms | #899 |
| **High** | Connect chunk_streaming to Phase 2 generator | On `load_chunk()` for Ungenerated chunks, invoke Phase 2 generator | Phase 2 chunk tile generator |
| **High** | Wire society profile to DistrictSkeleton | Connect #679 location-to-culture output → DistrictSkeleton.society_profile | #899 |
| **Medium** | Re-scope #156 (Procedural district filler) | Align pre-workshop ticket to DistrictSkeleton architecture | Phase 2 chunk tile generator |
| **Medium** | Re-scope #100 (Ungenerated → instantiation) | Align to chunk_streaming + Phase 2 on-demand fill pattern | Phase 2 chunk tile generator |
### Formal Cascade Blocker Chain
```
#897 (this workshop — D-records)
└─▶ WorldTier enum fix
└─▶ Layer 6: City → District decomposition (new ticket)
└─▶ #899 Phase 1 DistrictSkeleton generation
└─▶ Phase 2: Basic chunk tile generator (new ticket)
└─▶ Connect chunk_streaming to generator (new ticket)
└─▶ Wire society profile to skeleton (new ticket)
└─▶ #681 Apartment generator (ADD formal block here)
└─▶ #682 Apartment visual rendering
#694 Character creation screen — NOT in this chain (Phase 4 UI work)
#619 Character customization — NOT in this chain (Phase 4 cosmetic work)
#616 Verb vocabulary — NOT in this chain (content design)
#615 Tycoon starting state — feeds #681, not generation pipeline
```
---
## Key Flags for Round 2 Discussion
### 1. WorldTier enum mismatch is a hard blocker
Before #899 can be correctly implemented, `WorldTier` in generator.rs must be renamed. This is a small code change but touches a core enum — it needs a D-record confirming the canonical values before the rename happens, to prevent the same drift occurring again.
### 2. The apartment generator dependency chain is currently incomplete
#681 is blocked only by #615 and #679. It should also be blocked by Phase 1 district skeleton generation. The apartment exists in a district — without district skeleton generation working, we can't produce the zone context that makes an apartment feel culturally appropriate vs. just a random room.
### 3. Layer 6 (City → District decomposition) has no ticket
This is the first link in the chain from atlas markers to walkable tiles, and it has no ticket and no code. Without it, #899 has no valid input. New ticket needed before #899 can be effectively scoped.
### 4. Phase 2 tile generation is the real gate on "viewable world"
Phase 1 skeletons are useful for planning and NPC/economic pre-computation. But the cascade deliverable is "walkable generated world" — that requires Phase 2 tile generation. The thinnest slice that produces rendered tiles requires Phase 2, even if that Phase 2 is initially just "floor tiles inside block boundaries, wall tiles at edges." The existing rendering pipeline will handle it.
### 5. The economics layer is rich and available — use it at Layer 6
We have `economic_role`, `cultural_corridor`, trade flow data, corporation presence — all in systems.db. Layer 6 (city → district decomposition) is the natural integration point where this data should shape district character. The design principle: **economics drives district type; culture drives zone palette; both come from systems.db data that already exists.**
---
## Summary Table
| Gap | Severity | Effort | Notes |
|-----|----------|--------|-------|
| WorldTier enum mismatch | Blocker | 0.5 days | Rename enum values, update all references |
| Layer 6: City → District decomposition | Critical | 0.5–1 day | New ticket, no design gaps just missing code |
| Phase 1 generation logic | Critical | ~2–3 days | #899, blocked by this workshop's D-records |
| Phase 2 chunk tile generator | Critical | ~2 days | New ticket, blocked by #899 |
| Chunk streaming → generator hookup | High | ~1 day | New ticket |
| Society profile → skeleton wiring | High | 0.5 days | Design done (#679), wiring only |
| Heritage grammar overlay (Rust type + logic) | Medium | ~3–4 days | Phase 2+, not on critical path for minimum slice |
| NpcManifest seeding from skeleton | Medium | ~1 day | Machinery exists, connection missing |
| Guarantee audit implementation | Low | ~2 days | Can defer until Phase 1 generation is stable |
@@ -0,0 +1,323 @@
---
title: "Gestalt — Round 2: Spatial Pipeline Proposal"
description: "Ordered implementation plan, thinnest vertical slice, stored-vs-derived transition, D-record candidates, and ticket dependency graph for the spatial generation pipeline"
type: workshop
status: active
workshop: generation-cascade
agent: gestalt
round: 2
created: 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`:
```rust
// 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.
```rust
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:
- `DistrictType` determines the probability distribution across `ZoningType` values
- `DistrictLayoutMode` (Grid vs Organic) determines block placement and rotation
- `BlockSkeleton.density_pct` derived 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:**
```rust
// 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:**
1. Outer 2 tiles of each chunk = wall perimeter
2. Interior 60×60 = floor
3. Each block edge shared with an adjacent block gets one door placement (seeded position within center third of edge)
4. `GeneratorChunkData` (currently `Vec<bool>`) gets extended to carry `TileId` per 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 `DistrictMap` resource that maps `ChunkCoord` → `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 — `DamageOverlay` entries, 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 `bodies` table, 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 |
@@ -0,0 +1,564 @@
---
title: "Gestalt — Round 3: Phase 3 Spatial Pipeline Systems Design"
description: "Phase 3 layer map, economics bridge, LoD cascade, interesting-decisions analysis, and ticket dependency graph for the regional-to-city layer"
type: workshop
status: active
workshop: generation-cascade
agent: gestalt
round: 3
created: 2026-04-30
---
# Generation Cascade — Round 3 (Gestalt)
**Focus:** Phase 3 systems design — the layer between atlas output (city dots on heightmaps) and Phase 5 tile generation. What sits here, how the layers interact, which economics data drives which spatial decisions, where the LoD cuts are, and what creates interesting player choices at the regional scale.
**Given facts from Rounds 1-2 (not revisited):**
- WorldTier enum: Epicenter / Regional / Backwater / Passage / Waypoint
- Seed boundary: atlas markers are the last stored layer; everything below is seed-derived
- No markers.json extension — cross-reference systems.db bodies table
- Atlas coordinates are UI-only; walkable world uses city-local coords
- Phase 1 DistrictSkeleton minimum = Stages 1-2 only
- `prosperity_index` and `perimeter_treatment` are INCLUDED in DistrictSkeleton before Phase 2
- `political_archetype` is a formal field in atlas_cities, derived by default, authored override allowed
- Heritage grammar, NPC population, social sites — all out of scope
**Building on this round's peers:**
- Tyre (Round 3) designed the technical layers and algorithms. I treat those as settled where they overlap with my analysis and add the systems view on top.
- Paula (Round 3) designed the political signals and settlement hierarchy. I'm treating those proposals as strong candidates and mapping them to the generation input/output model.
---
## 1. The Phase 3 Layer Map
Let me be clear about what Phase 3 is mechanically: it is a **tooling phase that enriches systems.db**. Every Phase 3 output is committed offline by `generate_regional.py`. The Rust server reads it at startup; no Phase 3 computation runs at game time.
The layers form a dependency graph. Here is the full picture with inputs, outputs, and what consumes each output:
### Layer A — City Classification and Footprint
**Inputs:**
- `bodies` table: `settlement_pattern`, `economic_role`, `planet_class`, `world_tier`
- `atlas_cities` table: `population`, `kind`
- Economics tables: corporation presence by body, trade flow intensity
**Outputs (new columns on `atlas_cities`):**
- `footprint_radius_km: REAL` — physical extent; derived from population × density formula
- `political_archetype: TEXT` — CompanyTown | AdminCapital | FreePort | Contested | OrganicGrowth
- `prosperity_index: REAL` — 0.0–1.0 baseline prosperity for this city; district-level values derived from this
- `founding_orientation: TEXT` — PortFacing | RailHeadFacing | ResourceFacing | DefenseFacing | AdminFacing
- `political_archetype_override: TEXT` — NULL = always derived; non-NULL = wiki-authored value
**Consumed by:**
- Layer C (road graph — cities are nodes)
- Layer D (territorial status — city WorldTier is an input)
- Layer F (wilderness annotation — city footprints mark urban cells)
- Phase 5 district skeleton — via `CityGenerationContext` struct
- Phase 3 Atlas UI — displays footprint circles and archetype icons
**No dependencies.** Runs first.
---
### Layer B — Regional Biome Grid
**Inputs:**
- Planet simulation terrain output: biome grid (512×256), elevation, moisture, surface_water
**Outputs (new table `atlas_regional_biomes`):**
- 64×32 cells per body: `biome_class`, `mean_elevation`, `mean_moisture`, `terrain_roughness`, `is_coastal`, `water_fraction`
- Later augmented with `land_use` (Layer F) and `territorial_status` (Layer D)
**Consumed by:**
- Layer D (TerritorialStatus — biome class + proximity drives zone type)
- Layer F (wilderness annotation — land use derived from biome + urban proximity)
- Phase 5 district skeleton — biome context at city position informs surrounding district types
**Dependencies:** planet_simulation terrain data (already in memory during `generate_atlas.py`). Can run in the same pipeline pass. Technically parallel with Layer A.
---
### Layer C — Road Network Graph
**Inputs:**
- City positions (Layer A outputs or atlas_cities directly)
- `atlas_roads` and `atlas_railroads`: pixel-path road data
- Economics tables: corporation presence, trade flow intensity
**Outputs:**
- `atlas_road_nodes` table: city nodes + junction nodes with grid position
- `atlas_road_edges` table: segments with `kind`, `distance_px`, `maintenance_authority`
- `road_entry_directions: Vec<CardinalDirection>` per city — which edges roads enter from
**`MaintenanceAuthority` derivation rule (Paula's proposal, mapped to economics inputs):**
| Road endpoint types | Economics condition | → MaintenanceAuthority |
|--------------------|--------------------|----------------------|
| City ↔ City | admin road in atlas | Administrative |
| Corporate asset ↔ Corporate asset | same corporation in econ data | Corporate |
| City ↔ Sub-settlement | settlement prosperity-tracked | Communal |
| Any ↔ Any | high trade flow in econ data | Trade |
| Any ↔ Abandoned marker | no active tether | Abandoned |
**Consumed by:**
- Layer D (TerritorialStatus — road corridors affect zone boundaries)
- Layer F (wilderness — cells adjacent to roads get Corridor land use)
- Phase 5 district skeleton — `road_entry_directions` drives access point placement
- Phase 3 Atlas UI — renders road graph as connectivity overlay
**Dependencies:** Layer A (need city node list to snap road endpoints).
---
### Layer D — TerritorialStatus Zones
**Inputs:**
- Layer A outputs: city positions, WorldTier, political_archetype
- Layer B outputs: biome grid (terrain roughness affects zone boundaries)
- Layer C outputs: road graph (roads extend administrative reach)
- Economics: corporation asset locations, territory claims
**Outputs:**
- `territorial_status: TEXT` added to `atlas_regional_biomes` cells:
`CoreTerritory | FrontierTerritory | ExtractiveZone | ContestZone | WildernessBuffer | AbandonedZone`
**Derivation algorithm (systems design view):**
For each regional cell, score against multiple signals:
```
base_status = WildernessBuffer (default)
FOR each city C within range:
influence = C.world_tier_weight / distance_to_cell
if influence > FRONTIER_THRESHOLD:
base_status = FrontierTerritory
if influence > CORE_THRESHOLD:
base_status = CoreTerritory
IF any corporation has an active extraction node in this cell:
base_status = ExtractiveZone (overrides Frontier but not Core)
IF two cities with conflicting political archetypes both influence this cell:
base_status = ContestZone
IF cell was once CoreTerritory (historical) but city tether is Abandoned:
base_status = AbandonedZone
```
WorldTier weights: Epicenter → 5x; Regional → 3x; Backwater → 1x; Passage → 1.5x; Waypoint → 0.5x.
**Consumed by:**
- Layer F (wilderness annotation — TerritorialStatus determines settlement density)
- Phase 5 district skeleton — TerritorialStatus of surrounding region informs district `perimeter_treatment` baseline
- Phase 3 Atlas UI — renders zone overlays on regional map
- Naming system — ContestZone triggers DualNaming conditions
**Dependencies:** Layers A, B, C all needed.
---
### Layer E — Station Module Topology
**Inputs:**
- Bodies with `settlement_pattern = "orbital_only"`: `economic_role`, `population`
**Outputs:**
- `atlas_station_modules`: module list with type
- `atlas_station_connections`: module adjacency graph with passage type
**Design note:** Stations are handled as a separate code path. No biome data. No TerritorialStatus. The station's module topology is its "regional map" — it tells Phase 5 what zones to generate when a player enters the station. The `AdminCore` is always the hub; passage types (`Pressurized | Airlock | EVA | Service`) are the station equivalent of road quality.
**Consumed by:**
- Phase 5 district skeleton — station district types match module types
- Phase 3 Atlas UI — renders station topology diagram in wiki/implant app
**Dependencies:** Only station body data. Parallel with Layers B–D.
---
### Layer F — Wilderness, Countryside, Settlement Hierarchy
**Inputs:**
- Layer A: city footprints (urban cells identified)
- Layer B: biome grid (biome_class per cell)
- Layer C: road graph (corridor cells identified)
- Layer D: TerritorialStatus per cell
- Economics: corporation presence, trade routes
**Outputs:**
- `land_use: TEXT` on `atlas_regional_biomes` cells:
`Urban | Agricultural | Wilderness | Industrial | Wasteland | Ocean | Impassable | Corridor | Ruins`
- New table `atlas_sub_settlements`: towns, outposts, waypoints placed along road corridors
- Each has: `settlement_type`, `population`, `political_tether` (city_id + relationship)
- `DualNaming` conditions flagged per cell/feature for contested naming regions
**`PoliticalTether` derivation (Paula's proposal, mapped to generator inputs):**
| Condition | → TetherRelationship |
|-----------|---------------------|
| Distance to city < threshold AND city is AdminCapital | Administered |
| Distance to city < threshold AND city is CompanyTown AND corp presence | Corporate |
| Two cities within similar distance with different archetypes | Contested |
| Distance to city > threshold, no corp presence | Independent |
| Nearest city is AbandonedZone | Abandoned |
**Consumed by:**
- Phase 5 district skeleton — wilderness districts use land_use to determine district type
- Phase 3 Atlas UI — renders countryside zones and sub-settlement markers
- Naming system — PoliticalTether drives naming register for sub-settlements
**Dependencies:** Layers A, B, C, D all needed. This is the last Phase 3 layer.
---
### Phase 3 Output to Phase 5: `CityGenerationContext`
Everything Phase 3 produces feeds into Phase 5 through this struct:
```rust
pub struct CityGenerationContext {
pub city_id: String,
pub political_archetype: PoliticalArchetype,
pub founding_orientation: FoundingOrientation,
pub prosperity_index: f32, // city baseline; district-level derived from this
pub surrounding_biome: BiomeClass, // from regional grid at city position
pub surrounding_territorial_status: TerritorialStatus, // from Layer D at city position
pub road_entry_directions: Vec<CardinalDirection>,
pub footprint_radius_km: f32,
pub district_count: u8, // from city decomp formula (Round 2)
}
```
This struct is populated by reading systems.db at game startup. Phase 5 uses it as the root input for all district generation.
---
## 2. The Economics Bridge
Economics data in systems.db is the hidden backbone of Phase 3. The pipeline already ran the economics layer (import_economics.py) before generate_atlas.py. Phase 3 consumes that output systematically.
Here is the full economics → spatial structure mapping:
### economic_role → political_archetype (direct derivation)
| economic_role | → political_archetype |
|--------------|----------------------|
| `corporate_extraction` | CompanyTown |
| `administrative_center` | AdminCapital |
| `transit_hub` | FreePort |
| `mixed_economy` | OrganicGrowth |
| `contested_territory` | Contested |
Override slot (`political_archetype_override`) allows wiki authors to correct counterintuitive results for named cities. The default derivation handles 90%+ of cases correctly.
### Corporation footprint → spatial structure
The economics tables track which corporations operate in which systems and what their role is. This drives:
1. **ExtractiveZone identification:** If a corporation has an active extraction operation in a body's regional zone, that zone gets `TerritorialStatus = ExtractiveZone` regardless of distance from administrative cities.
2. **Road MaintenanceAuthority:** Roads connecting corporation assets to their logistics chain are `Corporate` maintained. These roads have uniform quality within the corporate footprint — which means the quality boundary at the corporation's edge is visually sharp. The generator uses this: when a corporate road ends, the next segment drops to whatever the local standard is.
3. **Sub-settlement PoliticalTether:** Settlements near corporate assets that depend on that corporation's operations are `Corporate`-tethered. If the corporation ever leaves (detectable from economic data), those settlements become candidates for `AbandonedZone` reclassification.
4. **Landmark naming:** Named landmarks in minor cities can draw their names from the controlling corporation's name in the economics database. The generator already knows which corporation controls logistics in which system. ISC controlling logistics in a system → its depot is "ISC Logistics Hub," not "Port 7."
### Trade flow intensity → road quality signals
Trade flow data (which routes carry what volume) feeds into:
1. **Trade-maintained roads:** High-flow trade routes get `MaintenanceAuthority = Trade`, producing good road quality even in politically marginal regions. This creates the interesting signal Paula identified: commerce went where administration didn't bother.
2. **FreePort prosperity baseline:** FreePort cities have prosperity_index derived partly from trade volume. High trade flow = higher baseline. This means FreePort cities can have high-prosperity districts even in Backwater WorldTier systems — trade beats administration as a wealth engine.
3. **Contested boundary identification:** When two corporations with conflicting interests both have significant trade flow through the same zone, that zone is a candidate for `ContestZone` or elevated `perimeter_treatment` at district level.
### The economics feedback loop at district level
The economics bridge is not one-directional. Here is the feedback path:
```
systems.db economics data
→ Phase 3: political_archetype, prosperity_index, territorial_status
→ Phase 5 CityGenerationContext
→ DistrictSkeleton: DistrictType distribution, prosperity_index per district
→ Phase 2 tile generation: building density, road width, decay tiles
→ Player reads: "this is a corporate extraction zone, these workers are poor,
there are checkpoints, the road only goes to the mine"
```
The player reads the spatial output of economic decisions without the economics being labeled. This is asymmetric information operating at the generation level, not just the runtime level. The world makes economic sense spatially because the generator was driven by economic inputs.
---
## 3. LoD Cascade — Authored ↔ Derived Spectrum
Every Phase 3 element sits somewhere on this spectrum. Here is the full map:
### Fully Authored (hand-written, changes require wiki edit)
| Element | Who authors | Authoring scope |
|---------|------------|-----------------|
| Planet definitions (bodies table) | Miri | All bodies |
| City names | Mellanie + Miri | All named cities |
| `political_archetype_override` | Wiki authors | Named cities only, when default is wrong |
| `prosperity_override` | Wiki authors | Named cities only, when lore demands |
| Named landmark names in major cities | Miri | Capital cities + named stations |
The authored layer is intentionally small. The system is designed so that wiki authors make high-value lore decisions, not repetitive data-entry decisions.
### Derived from Authored Sources (Python tooling, committed to systems.db)
These are computed deterministically from authored inputs. They live in systems.db and can be regenerated by `make regen-db`:
| Phase 3 Layer | Element | Derivation root |
|--------------|---------|----------------|
| A | `footprint_radius_km` | population + settlement_pattern formula |
| A | `political_archetype` | economic_role mapping |
| A | `prosperity_index` (city baseline) | WorldTier + economic data + trade flow |
| A | `founding_orientation` | settlement_pattern + economic_role |
| B | Regional biome grid (64×32) | planet_simulation terrain downsampled |
| C | Road graph nodes + edges | atlas road pixel-paths snapped to cities |
| C | `maintenance_authority` per road segment | road endpoint types + economics |
| D | `territorial_status` per cell | city proximity × WorldTier + economics |
| E | Station module topology | economic_role + population template |
| F | `land_use` per cell | biome + city footprints + road graph + territorial_status |
| F | Sub-city settlement placement | road graph + economics + WorldTier |
| F | `political_tether` per sub-settlement | distance + economics derivation |
This is the bulk of Phase 3. It requires no ongoing authoring effort — it regenerates correctly when sources change.
### Seed-Derived (Phase 5 runtime, never stored)
| Element | Why seed-derived |
|---------|----------------|
| DistrictSkeleton (all fields) | Function of (district_seed, city_context) |
| District count and positions | Function of (city_data, world_seed) |
| Exact sub-settlement position within cell | Seeded within corridor bounds |
| `DualNaming` common_name content | Seeded from culture pool when conditions met |
| Wilderness interior terrain variation | Seeded below regional resolution |
| Building footprints within districts | Seeded from block skeleton |
### The LoD Cut Between Phase 3 and Phase 5
The key architectural boundary: **Phase 3 describes "what kind of place is this?" Phase 5 generates "what does this place look like tile by tile?"**
Phase 3 never generates walkable space. It generates classification, context, and constraints. Phase 5 reads those constraints and generates the tiles that express them. A city in ExtractiveZone + CompanyTown archetype gets districts whose types, prosperity levels, and perimeter treatments reflect that context — but the actual tile layout is Phase 5's job, seeded deterministically.
---
## 4. What Creates Interesting Decisions at Regional Scale
This is the design question I care most about. Let me break down what "interesting decisions" means at regional scale and what the generator must produce to support them.
### The Decision Types
**Route decisions: multiple paths between settlements**
The player navigating a regional map faces the core traversal question: which way do I go? For this to be a meaningful choice, routes must differ in ways that matter:
| Route characteristic | What the generator must produce |
|---------------------|-------------------------------|
| Speed (road quality) | MaintenanceAuthority drives quality; Corporate roads are fastest, Communal roads follow prosperity |
| Political exposure | TerritorialStatus zone sequence along each route |
| Resource availability | Sub-settlement placement along route; Corridor land use guarantees supply nodes |
| Risk profile | WildernessBuffer / AbandonedZone → no infrastructure safety net |
| Cost (implied) | Trade route vs. administrative road → different access politics |
For this to work, **routes must not be equivalent**. The generator must produce enough settlement hierarchy and road quality variation that two paths between the same cities have meaningfully different character. Paula's `MaintenanceAuthority` + `TerritorialStatus` combination is exactly what produces this.
**Settlement access decisions: what is this place?**
Before entering a settlement, the player should be able to read its political character from the regional map. The signals:
- `PoliticalTether = Corporate` → expect corporate security, corporate pricing, corporate rules
- `PoliticalTether = Independent` → expect local law (or none), opportunistic pricing
- `PoliticalTether = Contested` → expect double-sided checkpoints, unstable rules
- `PoliticalTether = Abandoned` → expect no services, possibly ruins worth investigating
These signals translate to "should I stop here or push on?" decisions with real consequences.
**Risk/reward: wilderness and abandoned zones**
`WildernessBuffer` and `AbandonedZone` regions have no infrastructure — no shelter, no repair, no trade. But they have:
- Ruins (AbandonedZone): historical footprint worth investigating
- Resources (WildernessBuffer): unclaimed, unguarded
- Escape routes (both): off the administered grid, less visibility to authority
The regional map must make wilderness legible as a risk-reward tradeoff, not random emptiness. This requires:
1. Clear visual distinction between zone types
2. Visible road quality degradation at zone entry
3. Distance markers to nearest administered settlement
**Political navigation: zone-crossing as faction exposure**
A player with corporate affiliations faces scrutiny in ContestZone or government CoreTerritory. A player avoiding authorities prefers WildernessBuffer routes despite the cost. The regional map is a political exposure map, not just a physical terrain map. Different players will read it differently based on their current faction relationships.
This is **asymmetric information at the generation level** — the generator knows the full political geography, but the player reads it through the lens of their current situation.
**Founding orientation as navigation aid**
`FoundingOrientation` provides directional information about city structure before entering. A ResourceFacing city has its industrial district on the resource-facing edge — a player who wants to avoid industrial (or specifically seek it) can predict which part of the city to enter from. This is readable from the regional map if the city icon or approach indicates orientation.
### The Decision-Supporting Minimum
For regional decisions to be interesting, the generator must produce at minimum:
1. **Route variety**: ≥2 meaningfully different routes between major settlements (requires road graph + MaintenanceAuthority)
2. **Zone legibility**: TerritorialStatus visible per regional cell on the Atlas UI
3. **Settlement hierarchy**: At least towns and outposts along major routes (sub-settlements in Layer F)
4. **Approach legibility**: FoundingOrientation and political_archetype readable before city entry
Without #1 and #2, the regional map is a lookup table, not a decision space. The player consults it to find cities but doesn't navigate it strategically.
---
## 5. Ticket Dependency Graph for Phase 3
### New tickets required for Phase 3
The dependency chain is layered. I've marked parallelizable branches.
```
NEW: generate_regional.py pipeline scaffolding [0.5d]
│ (Makefile integration, meta stamp, pre-push hook update,
│ --body / --force / --dry-run flags, generator_sha tracking)
│
├──▶ [A] City classification + footprint [1.5d]
│ (footprint_radius_km, political_archetype, founding_orientation,
│ prosperity_index, override columns on atlas_cities)
│ │
│ ├──▶ [C] Road network graph [1d]
│ │ (atlas_road_nodes + atlas_road_edges tables,
│ │ snap-to-city, distance, maintenance_authority)
│ │ │
│ │ ├──▶ [D] TerritorialStatus zones [1d]
│ │ │ (formula: proximity × WorldTier + econ;
│ │ │ territorial_status column on atlas_regional_biomes)
│ │ │ │
│ │ │ └──▶ [F] Wilderness + settlement hierarchy [1.5d]
│ │ │ (land_use column, atlas_sub_settlements table,
│ │ │ PoliticalTether derivation,
│ │ │ DualNaming conditions)
│ │ │ │
│ │ │ └──▶ CityGenerationContext Rust struct [0.5d]
│ │ │ (read-path only; queries all Phase 3 tables
│ │ │ from systems.db at game startup)
│ │ │ │
│ │ │ └──▶ Phase 5 wiring [0.5d]
│ │ │ (wire CityGenerationContext
│ │ │ into DistrictSkeleton Stage 1)
│ │ │ │
│ │ │ └──▶ [MINIMUM VIABLE REGIONAL LAYER]
│ │ │
│ │ └──▶ Phase 3 Atlas UI (client, stig's ticket)
│ │ [BLOCKED until C complete; can begin with stub data]
│ │
│ └──▶ [E] Station module topology [1.5d]
│ (atlas_station_modules + atlas_station_connections,
│ template-based generator, separate code path)
│ [PARALLEL with C — no dependency between them]
│
└──▶ [B] Regional biome grid [1.5d]
(atlas_regional_biomes table, 64×32 sampling,
integrated into generate_atlas.py terrain pass)
[PARALLEL with A — different data sources]
│
└──▶ feeds into [D] (TerritorialStatus uses biome data)
[D cannot complete until both A + B + C are done]
```
### Parallelizable branches
- **[A] and [B]** are fully independent — different data sources, different tables.
- **[E] (station topology)** is independent of B-D. Can start immediately after scaffolding.
- **[C] (road graph)** needs Layer A city nodes, not Layer B biome data.
- **[D] (TerritorialStatus)** needs A + B + C. This is the merge point.
- **[F] (wilderness/settlements)** needs A + B + C + D. This is the final layer.
- **CityGenerationContext Rust struct** can begin development in parallel with Phase 3 tooling, stubbing out values, and wire to real systems.db data when Phase 3 is complete.
### Effort summary
| Ticket | Effort | Dependencies |
|--------|--------|--------------|
| generate_regional.py scaffolding | 0.5d | None |
| [A] City classification + footprint | 1.5d | Scaffolding |
| [B] Regional biome grid | 1.5d | Scaffolding (parallel with A) |
| [C] Road network graph | 1d | Layer A |
| [D] TerritorialStatus zones | 1d | Layers A + B + C |
| [E] Station module topology | 1.5d | Scaffolding (parallel with B-D) |
| [F] Wilderness + settlement hierarchy | 1.5d | Layers A + B + C + D |
| CityGenerationContext Rust struct | 0.5d | Layer A (for struct shape); full data after F |
| Phase 5 wiring | 0.5d | CityGenerationContext + Phase 5 Phase 1 milestone |
| **Phase 3 total** | **~9.5d** | — |
| Phase 5 (from Round 2) | ~6d | CityGenerationContext stub values |
**Critical path (sequential only):**
Scaffolding → A → C → D → F → CityGenerationContext → Phase 5 wiring = ~5.5d
Phase 3 and Phase 5 can be developed in parallel. Phase 5 develops against stub `CityGenerationContext` values. After Phase 3 lands, Phase 5 wiring ticket wires the real data.
### Relationship to existing tickets
| Ticket | Phase 3 impact |
|--------|---------------|
| #899 (rescoped Phase 1 Stages 1-2) | Must consume `CityGenerationContext`; add dependency on wiring ticket |
| Phase 3 Atlas UI (new) | Client-side; reads tables Phase 3 produces; assign to stig |
| generate_regional.py as generator | Add to `GENERATOR_SOURCES` in `tooling/check-systems-db-stamp`; update `/pr-push` source watch list |
---
## 6. Positions on Round 2 Open Questions (Already Resolved by Tyre R3)
Tyre answered all Round 2 open questions. My positions align:
- **OQ-R2-1 (prosperity_index and perimeter_treatment in minimum slice):** YES. Include before Phase 2 is implemented. Retrofit cost > add-now cost.
- **OQ-R2-2 (political_archetype — formal field or implicit):** Formal field in atlas_cities. Derived by default. Override allowed. The district generator receives a value, does not compute lore classifications.
- **OQ-R2-3 (tile algorithm — door-per-block-edge vs. density-driven rectangles):** Both, in order. Street skeleton first (door-per-block-edge), then building fill (density-driven rectangles within that skeleton).
- **OQ-R2-4 (Step 2b full city layout):** Follow-on ticket. Data model must support N districts from day 1.
- **OQ-R2-5 (naming register before Phase 2):** Post-walkable. Naming is text overlay, not tile-generation prerequisite.
- **OQ-R2-6 (prosperity_index authored or derived for named cities):** Derived by default, `prosperity_override` column for wiki authors.
- **OQ-R2-7 (naming register ownership):** Paula proposes taxonomy, Mellanie populates name pools, Tyre reviews for spatial contradictions, lead locks as D-record.
---
## 7. Open Questions for Burnelli-Sheldon
The economics bridge in §2 is based on my reading of what economic data should drive spatial structure. Burnelli-Sheldon should review and fill gaps:
| ID | Question | Priority |
|----|----------|----------|
| OQ-R3-G1 | Does the economics data in systems.db have sufficient granularity to derive `TerritorialStatus` per regional cell? Specifically: can I tell from existing data whether a corporation has active presence in a given 8×8-pixel zone, or only at the body level? | **HIGH** |
| OQ-R3-G2 | Trade flow intensity: is this derivable from existing systems.db supply chain and brand data, or does it require additional economics simulation? The road quality signal depends on this. | **HIGH** |
| OQ-R3-G3 | Corporate extraction zones: is `economic_role = "corporate_extraction"` at the body level sufficient to identify ExtractiveZone regions, or do I need sub-body-level economic geography? | HIGH |
| OQ-R3-G4 | FreePort prosperity baseline: my proposal is that FreePort cities derive prosperity from trade flow intensity rather than WorldTier. Does Burnelli-Sheldon agree with this economically? A Backwater transit hub should be prosperous in ways that a Backwater administrative center is not. | MEDIUM |
| OQ-R3-G5 | When a corporation's economics data shows it no longer operating in a body (abandoned extraction), should the sub-settlements auto-reclassify to AbandonedZone, or does this require an explicit authored flag? | MEDIUM |
---
## 8. Summary: What Phase 3 Is and Isn't
**Phase 3 is:**
- A tooling phase producing enriched systems.db data
- The layer that gives Phase 5 district generation its political and spatial context
- The generator for the Atlas of the Reach implant app (Phase 3 deliverable)
- ~9.5 developer days of Python tooling + Rust read-path work
- Parallelizable with Phase 5 (Phase 5 stubs out with CityGenerationContext values)
**Phase 3 is not:**
- Walkable tile generation (that is Phase 5)
- Heritage grammar, NPC population, social sites (out of scope)
- Room templates or building interiors (out of scope)
- Real-time computation (all Phase 3 data is offline, committed to systems.db)
**The minimum Phase 3 for Phase 5 to function:**
- Layer A: City classification (political_archetype + prosperity_index + founding_orientation)
- Layer C: Road graph (road_entry_directions per city)
- Layer B: Regional biome grid (surrounding biome context)
Layers D, E, F enrich Phase 5 and the Atlas UI but are not required for the first walkable district. This gives us a sequencing option: deliver minimum Phase 3 (A + B + C) to unblock Phase 5, then complete D + E + F in parallel with Phase 5 development.
---
*Gestalt — Round 3. Written 2026-04-30.*
@@ -0,0 +1,468 @@
---
title: "Gestalt — Round 4: Systems Requirements for the Planet-Down Cascade Brief"
description: "Systems design questions, game-mechanical decisions, interesting-factor analysis, resolved carry-forwards, and minimum viable scope for each of the four cascade layers"
type: workshop
status: active
workshop: generation-cascade
agent: gestalt
round: 4
created: 2026-04-30
---
# Generation Cascade — Round 4 (Gestalt)
**Role:** Systems design requirements for the next workshop brief. This document does not design the implementation — it frames what the implementation must answer mechanically, what each layer's output means for gameplay, and what we already know well enough not to relitigate.
**The lead's cascade reframe:** Planet-down, not city-outward. The world exists before civilization. Civilization is layered onto it. The player enters at the bottom of that stack and reads upward.
**The two rules that govern everything:**
> **DETERMINISM RULE:** Economic simulation's rolling state affects RENDERING (prosperity, repair state) but NOT LAYOUT (streets and buildings locked by seed). A player who leaves a city and returns a year later finds the same streets, richer or poorer.
> **LOD RULE:** When a player spawns, the code "zooms in" on the planet — tracing all cascade steps to where the player sits, filling in street-level detail where the player is. Each layer's output is the next layer's input.
These two rules are the architectural spine. Every system design question below ultimately reduces to: does this belong in seed-locked layout, or economics-variable rendering?
---
## The Sub-Settlement Hierarchy (Lead Inclusion)
Before the layer-by-layer breakdown: the lead has confirmed sub-settlements are included. This has systems-design implications across all four layers, so I'm stating the hierarchy here and referencing it throughout.
From the Round 3 consensus (Burnelli-Sheldon + Paula + Gestalt):
| Tier | Population | Generation treatment | Placement driver |
|------|-----------|---------------------|-----------------|
| City | 10,000+ | Full district decomposition (Layer 3) | markers.json anchor + geography |
| Town | 1,000–10,000 | Single-district equivalent | Road network + economic role |
| Outpost | 50–1,000 | Purpose-typed single node | Corp presence + extraction trigger |
| Waypoint | <50 | Infrastructure node, no district | Road midpoint formula |
| Rural cluster | Isolated | Type-tagged scattered nodes | settlement_pattern + economic role |
| Ruin | 0 | Historical footprint | Abandoned tether condition |
**Placement trigger conditions (from Burnelli-Sheldon Round 3):**
- **Mining camps:** `economic_role ∈ {extraction, mining}` AND corp_presence with relevant commodity → 1 camp per 2 qualifying corps, 1 district (LogisticsHub + minimal Residential)
- **Trade waypoints:** Long roads (pixel_count > threshold) → waypoint at geometric midpoint, 1 Transit district
- **Agricultural nodes:** `settlement_pattern ∈ {dispersed, dispersed_rural}` AND `economic_role = agricultural` → farm clusters at interval proportional to `1/economic_tier`
- **Shadow nodes:** `collection_efficiency < 0.6` AND shadow-viable corps → 1 informal MixedUse settlement at city fringe
**PoliticalTether:** Every sub-city settlement has a tether to a city (Administered / EconomicallyDependent / Contested / Independent / Corporate / Abandoned). This determines road maintenance class, naming register, and ruin candidacy. Derivation: distance + economics data.
Sub-settlements appear in Layer 2 (placed onto the natural world) and are consumed by Layer 3 (if they receive district treatment) and Layer 4 (if the player approaches tile range).
---
## The Economics Bridge (Burnelli-Sheldon Round 3)
The 6-field minimum read set and the weight table are foundational. I'm stating them here explicitly so the brief can reference them without recapping.
### The 6-Field Minimum Read Set
All six fields are queryable from systems.db per city at generation time. They produce a spatially differentiated world on their own. Everything else is enrichment.
| # | Field | Source table | Drives |
|---|-------|-------------|--------|
| 1 | `economic_role` | `bodies` | DistrictType distribution weights (weight table below) |
| 2 | `settlement_pattern` | `bodies` | District density mode, layout style, domed/underground special cases |
| 3 | `economic_tier` | `system_economy` | Infrastructure quality floor, wealth ceiling |
| 4 | `distribution_index` | `system_economy` | Prosperity gradient shape (stratified vs. moderate) |
| 5 | `corp_presence` count | `corp_presence` | Corporate district intensity, company town detection |
| 6 | `headquarters_system/body` match | `corporations` | HQ landmark flag, dominant building height |
### The 10×9 DistrictType Weight Table (D-C10 candidate)
Probability weights mapping `economic_role` → DistrictType prior. Corp-presence modifiers stack on top. This is the canonical default — tunable but locked so it doesn't get relitigated.
| economic_role | Res | Com | Ind | Adm | Log | Ent | Mix | Trn | Spe |
|--------------|-----|-----|-----|-----|-----|-----|-----|-----|-----|
| manufacturing | 25 | 5 | 35 | 5 | 20 | 2 | 5 | 3 | 0 |
| agricultural | 30 | 15 | 5 | 10 | 25 | 3 | 10 | 2 | 0 |
| extraction | 20 | 5 | 30 | 5 | 30 | 0 | 5 | 5 | 0 |
| transit | 10 | 20 | 5 | 5 | 25 | 10 | 15 | 10 | 0 |
| research | 20 | 5 | 5 | 15 | 5 | 5 | 10 | 0 | 35 |
| commercial | 15 | 35 | 5 | 5 | 10 | 15 | 15 | 0 | 0 |
| service_mixed | 25 | 20 | 5 | 10 | 5 | 15 | 20 | 0 | 0 |
| mining | 20 | 5 | 25 | 0 | 35 | 0 | 10 | 5 | 0 |
| frontier | 35 | 10 | 10 | 5 | 20 | 5 | 15 | 0 | 0 |
| energy | 10 | 0 | 20 | 5 | 30 | 0 | 5 | 5 | 25 |
`political_archetype` modifiers apply on top of these weights to shape district type *distribution*: CompanyTown amplifies the dominant type; FreePort flattens toward MixedUse/Transit/Commercial; Contested produces discontinuous spatial blocks.
---
## Layer 1 — Empty World
### What This Layer Produces
Start from the heightmap. Run water simulation (river drainage, lakes, coastlines). Refine sub-biomes into regional character. Output: a complete natural world with no civilization.
The existing pipeline already produces a 512×256 heightmap per body and a 64×32 regional biome grid (Layer B from Round 3). Layer 1 extends this with:
- **River network** at regional resolution: where major rivers run, where they meet, where they reach the sea. River confluences and river mouths are settlement attractors for Layer 2.
- **Lake placement** from biome/river basin dependency
- **Sub-biome refinement** within each regional cell: a "tropical" cell is "jungle with clearings" vs. "swamp" vs. "riverine forest" — 3-4 variants per biome class, seed-derived
- **Terrain roughness signals** at regional resolution: pass locations through mountain ranges, traversable vs. impassable zones
### Systems Design Questions for the Brief
1. **At what resolution does the river network get stored?** The 64×32 regional grid gives coarse river paths. If rivers are future walkable obstacles (Phase 5), their exact courses need to be at a resolution the tile generator can use. But if rivers are navigation constraints only at regional scale, the coarse grid is sufficient. **This is a scope question, not a design question — the brief needs to state which.**
2. **What is stored vs. seed-derived for Empty World data?** The heightmap and 512×256 biome grid are committed artifacts from generate_atlas.py. The 64×32 regional grid is committed (Layer B, Round 3). River network routing below regional resolution is candidate for seed-derivation (same FNV-1a pattern). **But river confluence locations (settlement attractors for Layer 2) must be stored or at least deterministically queryable before Layer 2 runs.** This is the key architectural question.
3. **How do sub-biome variants affect generation downstream?** A "dense forest" cell requires terrain modification (clearing) before a settlement exists; a "grassland" cell does not. The Empty World layer needs to produce a signal that Layer 2 can act on. Concrete proposal: `terrain_modification_cost: f32` per regional cell — how much effort is required to settle here. High cost = forest/hills; low cost = plains/coast.
4. **Where do geographic bottlenecks get identified?** Mountain passes, river crossings, and coastal access points are constraint features that later layers (roads, settlements) need to route around or through. These need to be extractable from the Empty World layer as named spatial features, not just implicit in the elevation data.
### Game-Mechanical Decisions This Layer Produces
| Feature | Player decision created |
|---------|------------------------|
| River network (regional) | Route choice: ford vs. bridge; upstream vs. downstream travel |
| Mountain passes | Overland route selection: fast valley route vs. hard mountain pass |
| Coastal access | Settlement approach: sea route vs. overland; port city vs. inland city |
| Sub-biome character | What to expect in wilderness between settlements; terrain hazard prediction |
| Lake positions | Dead-end vs. through-lake navigation; freshwater resource |
| River confluences | Settlement density prediction: confluences attract cities → player plans route accordingly |
The key mechanic: **the player reads terrain to predict settlement density and route quality before seeing a map label.** A river confluence with coastal access is almost certainly a city. A mountain range with a single pass is almost certainly a chokepoint. Empty World teaches the player to read the world.
### What Makes This Layer Interesting for Gameplay
The Empty World is the legibility layer. It explains why civilization sits where it does.
A player navigating from one city to another doesn't just move through abstract distance — they cross terrain that was there before the cities existed and will be there if the cities collapse. The terrain creates:
- **Forced routes:** A mountain range with one pass means all overland traffic converges there. That convergence is interesting — high encounter probability, possible chokepoint control, valuable for whatever faction controls it.
- **Natural shelter:** A river valley that widens into a delta is obviously where the port city is. The player arriving for the first time can orient themselves without a tutorial.
- **Risk gradient:** Moving away from rivers and coast into wilderness means moving away from the infrastructure support network. The player knows this before it's confirmed.
The ABSENCE of civilization in the Empty World layer is as informative as presence. A wide fertile valley with no settlement asks: why? That question is a gameplay hook.
### Resolved Decisions Carrying Forward
- FNV-1a SeedChain (D-010, D-C6): river routing below regional resolution is seed-derived using the same pattern as district/chunk generation
- The 64×32 regional biome grid (Layer B, Round 3) is the base; Layer 1 refines it but does not replace it
- `is_coastal`, `water_fraction`, `terrain_roughness` on `atlas_regional_biomes` already exist — Layer 1 populates `sub_biome_variant` on the same table
- Heightmap is committed artifact from generate_atlas.py — Layer 1 consumes it, does not regenerate it
### Minimum Viable Layer 1
- Sub-biome variant tag per regional cell (3-4 values per biome class): high vs. low modification cost
- River confluence identification (stored as point features in systems.db): "this regional cell has a confluence"
- Coastline identification: already done (`is_coastal` on `atlas_regional_biomes`)
- Mountain pass identification: regional cells with very high `terrain_roughness` adjacent to lower cells = candidate pass locations
- Defer: tile-resolution river routing, lake exact polygon, sub-regional terrain variation
---
## Layer 2 — Population Overlay
### What This Layer Produces
Layer economics onto the natural world. Settlement positions anchor from wiki population counts + markers.json, but **the shape of civilization is determined by geography.** Felled forests become farmland; flattened hills become towns. Road and rail networks connect settlements. Sub-settlement hierarchy placed.
This is the layer where the world becomes inhabited. It does not produce the internal structure of cities (Layer 3) or the walkable tiles (Layer 4) — it places civilization in the landscape and defines the relationships between settlements.
### Systems Design Questions for the Brief
1. **Does "geography determines shape" mean we re-evaluate atlas city positions?** The current model places cities as authored dots in markers.json. If geography determines shape, does this mean: (a) city positions stay but the generator explains/validates them against geography, or (b) the generator produces city positions from geographic attractors and uses markers.json only as population counts? This is a foundational framing question the brief must answer. My read of the lead's intent: **the positions are anchored, but the generator understands WHY they're there** — which then informs how each city sits in its landscape and where sub-settlements cluster.
2. **What is the "terrain modification" record?** The lead says "felled forests become farmland, flattened hills become towns." This means the Empty World's terrain is visibly modified by civilization. Where is this stored? Options:
- Layer 2 commits a `terrain_modification` table to systems.db (what was here before, what it became)
- Layer 2 produces a `land_use` overlay on the regional grid (already in Layer F from Round 3)
- Modification is implicit in the `land_use` classification (Urban = was something else; Agricultural = was forest/grassland)
The simplest answer: the existing `land_use` column (Urban / Agricultural / Industrial / Wilderness / etc.) on `atlas_regional_biomes` IS the modification record. No separate table needed.
3. **How are hinterland agricultural zones shaped?** Burnelli-Sheldon's `HinterlandType` and `InfrastructureDensity` give us the character of the space between cities. But the SHAPE of farmland needs a placement rule. Proposal: agricultural cells cluster around cities with `economic_role = agricultural` and along river corridors, at a density inversely proportional to `terrain_modification_cost` from Layer 1. Rough terrain stays wilderness; gentle terrain becomes farmland.
4. **What is the road placement algorithm?** Currently, markers.json has authored road pixel-paths. The Population Overlay phase is supposed to generate road networks. Are these the same roads (authored once, never regenerated) or a new algorithm that derives roads from settlement positions? **This is a key question for the brief to resolve.** My position: authored roads for named highway routes (these encode setting decisions — Gate Corp built this highway, not the settlement network); generated roads for local connections between sub-settlements and their tether cities.
5. **Sub-settlement exact placement:** Given a body with `economic_role = extraction` and 3 qualifying corps in `corp_presence`, where exactly do the 1-2 mining camps sit? The brief needs to specify the placement algorithm. Proposal: mining camps sit at the closest regional cell to the corp's asset that has `land_use = Industrial` or `land_use = Wilderness` (not Urban), at seeded distance from the tether city within [15%–40%] of body scale.
### Game-Mechanical Decisions This Layer Produces
| Feature | Player decision created |
|---------|------------------------|
| Settlement network topology | Route planning: which settlements to visit and in what order |
| Sub-settlement placement | Opportunity discovery: finding the camp before knowing it's there |
| Hinterland agricultural extent | Resource geography: where food comes from, where it's vulnerable |
| Road connectivity gaps | Risk/reward: unconnected settlements are accessible but unprotected |
| Terrain modification extent | Historical reading: how long has this settlement been here? |
| PoliticalTether relationships | Faction awareness: is this outpost administered or corporate? |
The key mechanic: **the settlement network topology creates the player's opportunity map.** Connected settlements are safe but competitive. Disconnected ones are risky but potentially profitable. The gaps in the road network are where interesting things happen.
### What Makes This Layer Interesting for Gameplay
Population Overlay is where the world becomes a problem space. The empty world had terrain; now it has places to go and reasons to go there.
Three specific interesting structures this layer produces:
1. **The underserved route:** Two significant settlements with no direct road — only a wilderness crossing or a long detour through a third settlement. This is a player opportunity (carry trade between them directly) that the generator produces from the absence of infrastructure, not its presence.
2. **The extraction hub:** A cluster of mining camps orbiting a tether city at increasing distance. The outermost camps are the least administered, most economically exploitable, most isolated. The player can read this structure from the regional map and choose their engagement point.
3. **The abandoned corridor:** An old road leading to ruins — AbandonedZone status, former PoliticalTether now severed. The road's quality (Abandoned MaintenanceAuthority) signals that something was here. This is generated entirely from economic data (corporation left) without authored content.
### Resolved Decisions Carrying Forward
- Burnelli-Sheldon's 6-field minimum read set: drives hinterland type and settlement character
- Dynamic settlement trigger conditions (Round 3): mining camps, trade waypoints, agricultural nodes, shadow nodes
- PoliticalTether struct (Round 3): every sub-city settlement classified
- `land_use` column on `atlas_regional_biomes`: Urban / Agricultural / Industrial / Wilderness / Corridor / Ruins / Ocean / Impassable
- `MaintenanceAuthority` on road edges: Administrative / Corporate / Communal / Trade / Abandoned
- `TerritorialStatus` per regional cell: CoreTerritory / FrontierTerritory / ExtractiveZone / ContestZone / WildernessBuffer / AbandonedZone
### Minimum Viable Layer 2
- `land_use` on all regional cells (already in Layer F design from Round 3)
- Sub-settlement placement: mining camps and trade waypoints (highest frequency; most game-relevant)
- Road graph (already designed in Layer C, Round 3): nodes + edges with `maintenance_authority`
- `TerritorialStatus` per regional cell (Layer D from Round 3)
- Defer: agricultural node exact positions (land_use tag is sufficient for minimum), shadow node placement (needs collection_efficiency data quality improvement), authored road vs. generated road resolution
---
## Layer 3 — City-Level Planning
### What This Layer Produces
Given topography + economics + cultural parameters: plot the internal structure of cities. Suburbs, industrial zones, commercial districts, ports, stations, highways. The entire phase. This is the "Cities Skylines" layer.
This maps directly to the DistrictSkeleton work from Rounds 1-3, extended to include the city-scale spatial organization (not just individual district classification). The key addition over prior workshop work: this phase knows WHERE districts sit relative to each other, relative to the city's geographic position, and relative to the settlement's orientation.
### Systems Design Questions for the Brief
1. **What is the City Planning layer's exact output?** I propose it is the `DistrictPlacement` vector + `DistrictSkeleton` (Stages 1-2) for every district in every city. This means Layer 3 = the city decomposition + Phase 1 district skeleton generation from Rounds 1-3. The workshop brief should confirm this equivalence explicitly so implementation teams don't design two separate systems.
2. **How does FoundingOrientation constrain district placement?** A PortFacing city has its logistics/transit districts on the water-edge. An AdminFacing city has its administrative district elevated/central. The district decomposition formula (D-C4) places N districts in a grid — but that grid should be oriented by FoundingOrientation. This is a spatial constraint that the current city decomposition formula does not include. **The brief needs to specify whether FoundingOrientation modifies the district placement grid, or just the prosperity gradient.**
3. **What drives the arterial road layout within the city?** The district grid implies roads at district boundaries, but not internal arterials. In Cities Skylines terms: we zone the land (district type assignment) but we also lay the major roads first. The brief should clarify whether internal arterials are:
- Implicit in the district boundary positions (streets exist at every district edge — sufficient for minimum viable)
- Explicitly generated as an arterial graph (more realistic but more complex)
4. **How does topography (Layer 1) constrain city planning?** A city built into a hillside has different arterial possibilities than one on a plain. The brief should specify whether Phase 3/Layer 3 has access to terrain roughness at city-local resolution, or whether it treats all cities as flat for the minimum slice.
5. **Port/spaceport positioning:** Burnelli-Sheldon proposed Transit and LogisticsHub districts at the city edge closest to the orbital station (if one exists). This requires knowing which direction the orbital station is from the city's geographic position. The brief needs to confirm: does Layer 3 receive directional information about nearby stations?
6. **How does the political archetype shape the district placement pattern, not just distribution?** From Paula (Round 3): CompanyTown has a SPINE (facility at one end, workers radiating back); AdminCapital has a CENTER (administrative hub with residential radiating outward); FreePort has MULTIPLE NODES (no single center). These are spatial arrangements, not just type frequency distributions. The brief needs to specify whether Layer 3 implements these arrangement patterns, or whether they emerge from the weight table without explicit arrangement logic.
### Game-Mechanical Decisions This Layer Produces
| Feature | Player decision created |
|---------|------------------------|
| District type layout | Navigation: where to go for what purpose |
| Perimeter treatment distribution | Access route choice: Checkpoint vs. Open approaches |
| Prosperity gradient direction | Economic reading: which end of the city is wealthy/poor |
| Political archetype spatial arrangement | City legibility: player learns CompanyTown vs. AdminCapital patterns |
| FoundingOrientation | Approach prediction: player knows which end has the port |
| Corporate district positions | Faction geography: where to find/avoid corporate presence |
| Port district placement | Entry/exit: where ships dock, where the city opens to outside |
The central mechanic here is **city legibility as player skill.** A player who understands the political archetypes, WorldTier patterns, and economic roles can walk up to a city they've never visited and make accurate predictions about its internal structure before entering. This is meaningful gameplay — the player's knowledge of the system is a form of power.
### What Makes This Layer Interesting for Gameplay
City planning is where systemic knowledge pays off. This layer is interesting not because of individual cities but because of what patterns the system reliably produces.
**Legibility patterns the player learns:**
- CompanyTown: one huge industrial/logistics section, worker housing packed behind it, no entertainment, checkpoint at the facility edge
- FreePort: transit and commerce everywhere, no dominant center, multiple competing entry points, high perimeter treatment variance (some Gated compounds next to Open street markets)
- AdminCapital: wide boulevards converging on a central elevated point, residential quality degrades outward, strong checkpoint presence at city limits but relatively open inside
**Why this is interesting:** Each archetype creates a different navigation and interaction challenge. The player's tactical approach to a CompanyTown is different from their approach to a FreePort. This differentiation is generated, not scripted — the same city archetype produces consistent behavior across every city of that type, which makes player investment in understanding patterns worthwhile.
**The determinism rule's role here:** The city layout never changes. The player can memorize it. This transforms city navigation from a puzzle (solve it once) into a familiar space (navigate it repeatedly with increasing fluency). The economics affecting rendering means the player sees the city get richer or poorer over time while the bones remain recognizable.
### Resolved Decisions Carrying Forward
- District count formula (D-C4): `max(1, floor(population / 50_000))`, log-scaled, WorldTier ceiling
- WorldTier district ceiling: Epicenter uncapped, Regional 8, Backwater 4, Passage 2, Waypoint 1
- City-local coordinate system (D-C5): districts at `(col × 512, row × 512)` sim tiles
- Phase 1 Stages 1-2 minimum (Round 2 consensus): classification + block grid only
- `prosperity_index` and `perimeter_treatment` on DistrictSkeleton (confirmed all participants)
- `political_archetype` formal field with override (confirmed all participants)
- `FoundingOrientation` drives prosperity gradient direction (Paula/Gestalt Round 3)
- 6-field minimum read set and 10×9 weight table (Burnelli-Sheldon Round 3)
- `prosperity_index` derivation formula (Burnelli-Sheldon Round 3):
```
base = economic_tier / 5.0
role_modifier: extraction -0.1, research +0.15, service_mixed +0.1, frontier -0.2 ...
if stratified: district_i = base + role_modifier + (prestige_rank(i) - 0.5) × 0.7
if moderate: district_i = clamp(N(base + role_modifier, 0.1), 0.2, 0.8)
```
### Minimum Viable Layer 3
- Phase 1 Stage 1: classification (WorldTier, ComplexityTier, SettingType, DistrictType, LayoutMode)
- Phase 1 Stage 2: block grid (4×4 BlockSkeletons with ZoningType, density_pct, seed)
- `prosperity_index` and `perimeter_treatment` computed in Stage 1 from the 6-field read set
- `political_archetype` consumed from systems.db (Layer 2 output)
- Defer: FoundingOrientation-modifying district placement grid (Stages 3-5, guarantee audit, reservations, social sites)
- Defer: explicit arterial road layout within city (district boundary = implicit street)
---
## Layer 4 — Street-Level Rendering
### What This Layer Produces
Take city-level data (Layer 3). Render streets, buildings, scatter civilization into tiles. The player walks through this.
Mechanically: generate 64×64 tile grids (ChunkData) from BlockSkeletons, on demand as the player moves.
### Systems Design Questions for the Brief
1. **Precisely what does the DETERMINISM RULE mean at implementation level?**
The lead says: economic simulation's rolling state affects RENDERING but NOT LAYOUT. The brief needs to make this concrete:
| Category | Seed-locked (never changes) | Economics-variable (changes per tick state) |
|----------|---------------------------|---------------------------------------------|
| **Layout** | Street positions, building footprints, door positions, room layouts, district boundaries | — |
| **Rendering** | — | Tile decay state (intact → cracked → broken), building activity signals (lights on/off, signs present/absent), repair scaffolding presence |
Implementation proposal: **the tile generation function produces a canonical tile type** (floor_street, floor_interior, wall). The economics rendering pass then applies a **decay overlay** — each canonical tile has a condition modifier (`intact | worn | cracked | damaged | rubble`) derived from `prosperity_index` at render time, not at generation time. The seed generates the layout; the economics tick generates the condition.
2. **When exactly does Street-Level fire in the "zoom in" cascade?** The existing chunk streaming system (`load_chunk()`) fires when the player's `ChunkLoadRadius` includes a chunk coordinate. This is already the LoD trigger. The brief should confirm: **Layer 4 runs on-demand inside the existing chunk streaming architecture, not as a separate batch process.** No new trigger mechanism needed; the hookup ticket from Round 2 is the wiring.
3. **What is the minimum tile vocabulary that supports economics-variable rendering?** The Round 2 minimum was 3 tile types: `floor_street`, `floor_interior`, `wall`. For the determinism-plus-rendering model, we need tile variants or a condition modifier system. Proposal: keep 3 canonical types; add a condition enum (`Intact | Worn | Cracked | Broken`) that the economics rendering pass applies. This is 4 condition states × 3 tile types = 12 visual variants, but only 3 generation types.
4. **Where does the economics rendering pass execute?** Options:
- At chunk generation time (bake prosperity_index into tile conditions when the chunk first generates)
- As a live overlay per tick (tile conditions computed from current prosperity each time the chunk is rendered)
- As a periodic re-render (chunk conditions recomputed when prosperity changes significantly)
The determinism rule implies the LAYOUT is baked at generation time, but tile CONDITIONS should not be baked — they need to reflect current economic state. **The brief needs to specify the condition update mechanism.**
5. **Building interiors: when do they generate?** Door-per-edge is the confirmed boundary. The brief should specify what triggers interior tile generation: (a) when the player is within threshold of the door, (b) when the player opens/enters the door, or (c) pre-generated with the exterior but not rendered until entered. Performance implications differ significantly.
6. **What is "scatter civilization into tiles"?** The lead's phrasing suggests more than just floor/wall. Does this include: prop placement (furniture, vehicles, crates), surface decals (signage, paint), entity spawn points? These are distinct from tile types. The brief needs to establish what Street-Level Rendering is responsible for vs. what is runtime placement by the entity system.
### Game-Mechanical Decisions This Layer Produces
| Feature | Player decision created |
|---------|------------------------|
| Walkability (tile positions) | Pathing: which routes through a district are possible |
| Building footprint positions | Navigation: where doors are, which buildings block line of sight |
| Door-per-edge thresholds | Interior access: which buildings can be entered |
| Street connectivity | Approach routing: dead-ends, through-routes, alleys |
| Tile condition (economics) | Reading the district: visible decay signals current prosperity |
| Density variation | Area character: packed warehouse district vs. open boulevard |
The central mechanic: **the street level is where the player has physical presence.** All decisions above this layer were about where to go and why. This layer is about how.
### What Makes This Layer Interesting for Gameplay
Street-Level Rendering is where the player's understanding of the system becomes tactile.
**The determinism dividend:** Because layout is seed-locked, every visit to a city produces the same physical space with potentially different economic conditions. This creates two distinct player experiences:
1. *First visit:* Exploration — finding routes, locating key buildings, understanding the district's arrangement
2. *Return visit:* Operational — applying remembered knowledge to new conditions ("The Checkpoint guard post is in the same place, but the district looks richer than last time")
The player's spatial memory becomes an asset. Cities reward familiarity. This is a different and richer relationship with procedurally generated space than "different every time."
**The economics-variable rendering creates observable history.** A district in decline shows decay tiles accumulating over time. A district being developed shows repair states. The player who visits frequently sees the arc; the player who returns after absence sees a snapshot difference. Neither requires exposition — it's written into the tiles.
**Sight lines as political geometry (Paula Round 2 principle, applicable here):** High-prosperity/Checkpoint districts have open corridors — surveillance optimization creates navigable sightlines. Organic/low-prosperity districts have broken sightlines — buildings jut into streets, alleys angle away. This is a tile placement rule, not a separate computation: Checkpoint + Grid layout → buildings placed to maintain corridor sightlines; Organic + low prosperity → buildings placed to break them. The player navigating these districts has different information horizons.
### Resolved Decisions Carrying Forward
- `GeneratorChunkData` upgrade: `Vec<bool>` → `Vec<TileEntry>` with `tile_id: TileId` + `walkable: bool` (D-C7)
- 3 minimum tile types: `floor_street`, `floor_interior`, `wall` (Round 2 consensus)
- Phase 2 minimal tile generation: density-driven rectangle placement (Tyre Round 2 algorithm)
- FNV-1a SeedChain: `chunk_seed = child_seed(district_seed, (cx << 32 | cy))` (D-C6)
- 64×64 tiles per chunk, 2×2 chunks per block, 4×4 blocks per district (existing architecture)
- Door-per-edge building interiors: boundary confirmed; descriptor + catalog behind it (CLAUDE.md)
- Phase 2 tile generation algorithm: street skeleton first (door-per-block-edge), then building fill (density-driven rectangles within skeleton) — Tyre/Gestalt Round 2 consensus
- Chunk streaming → generator wiring: `DistrictMap` resource + `load_chunk()` hookup ticket (Round 2 design)
### Minimum Viable Layer 4
- 3 tile types generating a walkable, navigable space
- Seed-locked layout from `district_seed` via FNV-1a
- Chunk streaming hookup: `load_chunk()` calls `generate_chunk_tiles()` via `DistrictMap`
- Prosperity affects building density (already in Phase 2 algorithm — high density at low prosperity, low density at high prosperity)
- Defer: tile condition overlays (economics-variable rendering) — implement after minimum walkable world exists
- Defer: building interior generation beyond door-per-edge boundary
- Defer: prop/decal scatter
---
## Cross-Layer Systems: The Two Rules Applied
### Determinism Rule — Boundary by Layer
| Layer | Seed-locked (generated once, never changes) | Economics-variable (changes per tick state) |
|-------|--------------------------------------------|--------------------------------------------|
| Layer 1 (Empty World) | Heightmap, river network, biome grid, pass locations | Nothing — terrain is fixed |
| Layer 2 (Population) | Settlement positions, road topology, sub-settlement placement, PoliticalTether | Road condition (Abandoned roads could degrade further — edge case) |
| Layer 3 (City Planning) | District positions, district types, block grid, prosperity_index baseline, perimeter_treatment | prosperity_index current value (rolling simulation updates this); building availability in commercial districts |
| Layer 4 (Street-Level) | Street positions, building footprints, door positions, all tile layout | Tile condition states (Intact/Worn/Cracked/Broken), building activity signals |
**The key implementation note:** `prosperity_index` at Layer 3 is a BASELINE value derived from economic role and WorldTier. The simulation's rolling state produces a CURRENT value. Layer 4 uses the current value for condition overlays, not the baseline. The baseline is stored in systems.db; the current value is runtime economic state. These are two different data fields.
### LoD Rule — Zoom-In Cascade
When a player spawns, the code traces the full cascade from planet level down to the player's tile position. The cascade is lazy — only what the player needs is computed:
```
Planet exists in systems.db (Layer 1 + 2 data committed)
│
├── Player within planet rendering range?
│ → Activate regional biome overlay for atlas UI
│
├── Player within city footprint range?
│ → Generate city district grid (Layer 3 / Phase 1)
│ → Build DistrictMap in memory
│
├── Player within district range (chunk load radius)?
│ → load_chunk() fires for each chunk in radius
│ → Layer 4 / Phase 2 generates tile data from BlockSkeleton + chunk_seed
│
└── Player at building door threshold?
→ Interior descriptor + catalog loaded (door-per-edge)
→ Interior tiles generated (Phase 4+)
```
Nothing above the player's current resolution is generated at tile level. Nothing below the player's current resolution is generated at all. The cascade is the implementation of the LoD rule.
**One concrete implication:** The `DistrictMap` resource (city district grid) is built when the player enters a city's footprint radius. If two players are in different cities, two separate `DistrictMap` instances exist. If neither player is near a given city, that city has no `DistrictMap` — only its `CityGenerationContext` in the startup read from systems.db.
---
## Open Questions for the Brief
These are not design questions — they are decisions the lead or the brief must make explicit before implementation teams can proceed without ambiguity.
| ID | Question | Layer | Stakes |
|----|----------|-------|--------|
| OQ-R4-G1 | Does "geography determines shape" mean city positions in markers.json are re-evaluated by Layer 2, or do they stay fixed as anchors with Layer 2 explaining them? The answer determines whether `generate_regional.py` can write back to markers.json or only extends systems.db. | 2 | **Architecture-level** |
| OQ-R4-G2 | What exactly does Layer 4's economics-variable rendering produce? The brief should specify: condition enum values, what triggers condition recalculation (prosperity threshold change? periodic tick?), and whether conditions are cached per chunk or computed live per render. | 4 | **Implementation-level** |
| OQ-R4-G3 | Is `prosperity_index` on DistrictSkeleton the BASELINE (from economic role, stored) or the CURRENT (from rolling simulation, runtime)? The brief should use distinct names to prevent confusion in implementation. Proposed: `prosperity_baseline: f32` (Layer 3 output, stored) vs. `prosperity_current: f32` (runtime simulation state). | 3 + 4 | **Data model** |
| OQ-R4-G4 | Does the City-Level Planning layer produce explicit arterial road layout, or are district boundaries the implicit road network? Full arterials require a road placement algorithm; implicit streets require only district grid positions. | 3 | **Scope** |
| OQ-R4-G5 | At what tile-resolution granularity do rivers appear in walkable generation? If rivers are navigable obstacles at street level, the Phase 2 tile generator needs to know where the river is at tile precision. If they're only regional navigation constraints, the 64×32 grid suffices. | 1 → 4 | **Cross-layer scope** |
| OQ-R4-G6 | Does the sub-settlement hierarchy (towns, outposts, waypoints) receive Layer 3 (City Planning) treatment? A town is described as "single-district equivalent" — does it go through the same DistrictSkeleton generation path, or is it a simplified code path? | 2 → 3 | **Implementation scope** |
---
## Summary: What This Document Is Asking For
The planet-down cascade is architecturally elegant. Four layers, clean dependencies, clear deliverables. The systems design questions above are not blockers to the vision — they are the specific implementation decisions that will determine whether each layer actually creates interesting player choices.
The brief that comes out of this round needs to make these things explicit:
1. **The determinism boundary at each layer** — exactly what seed-locks and what economics can update
2. **The stored/runtime distinction for `prosperity_index`** — baseline vs. current, two different fields
3. **The "geography determines shape" question** — anchor or re-derive?
4. **The sub-settlement depth** — which tiers get full district treatment, which get lightweight nodes
5. **River resolution** — regional constraint or walkable obstacle?
6. **Economics-variable rendering mechanism** — condition states, update triggers, caching
Everything else in Rounds 1-3 carries forward cleanly into this new framing. The 6-field economics read set works at Layer 3. The weight table works at Layer 3. The DistrictSkeleton, SeedChain, DistrictMap, ChunkData upgrade — all of it maps directly to the Layer 3/4 boundary. This workshop produced solid foundational work. The planet-down reframe doesn't invalidate it; it gives it the right context.
---
*Gestalt — Round 4. Written 2026-04-30.*
@@ -0,0 +1,413 @@
---
title: "Miri Round 1 — Worldbuilding and Cultural Input Inventory"
description: "Audit of cultural and worldbuilding inputs required at each generation pipeline layer; content gap analysis; ticket dependency observations"
type: workshop
status: active
workshop: generation-cascade
agent: "miri"
round: 1
created: 2026-04-30
---
# Generation Cascade Workshop — Round 1: Miri (Worldbuilder)
**Topic:** Worldbuilding and cultural input inventory for the generation pipeline
**Date:** 2026-04-30
**Focus:** At which layers do cultural inputs enter? What content data is needed? What's missing that would block generation even if code existed?
---
## Preamble
This is an audit, not a design session. I am mapping what exists, what's missing, and where cultural content must be authored before code can run. The architecture is designed — I am not revisiting it. What I am verifying is whether the worldbuilding inputs that architecture requires are actually available.
Short answer: the content situation is better than I expected at the city-naming layer and substantially worse than expected at the district-interior layer. The cultural infrastructure that feeds Phase 1 district skeleton generation is underspecified. Phase 2 chunk fill has zero authored data to work with.
---
## Setting Note — Van Maanen's Star Naming Ambiguity
Before the pipeline audit, I need to flag a setting inconsistency that will surface in every worldbuilding discussion about the primary test case.
D-036 identifies the primary setting as "Van Maanen's Star." In the current wiki, two systems are plausibly this:
- **GJ-35 (Vuurkloof)** — the real-astronomy Van Maanen's Star. DG white dwarf. South African (Cape/Afrikaner) heritage founding community. Geothermal habitation. No Station Sova. Its Calibration Note explicitly identifies it as "Van Maanen's Star, the nearest known solitary white dwarf."
- **GJ-280B (Sova)** — K-type star in the core. Slavic-heritage founding community of construction engineers. Manufacturing and habitat fabrication economy. Contains Station Sova. The GJ-280B wiki stub references D-036 setting details and matches the logistics hub character.
The `cultural-generation-guide.md` and `culture-van-maanens-star.ron` both label the working-class freight district culture as "Vuurkloof cultural profile" / "Van Maanen's Star Culture," suggesting the two names refer to the same cultural root, not the same star. This reading: Vuurkloof (GJ-35) produced the culture that spread outward; Station Sova (GJ-280B) is where that culture manifests in the current game setting.
**The culture RON file `culture-van-maanens-star.ron` is the authoritative content.** It is operational, validated in the voice pipeline, and correctly models the setting. The naming ambiguity is a wiki documentation gap, not a content error. What is needed is a formal `wiki/cultural-groups/van-maanens-star.md` entry that clarifies the relationship and serves as the canonical reference for generator inputs.
**Action needed (content, not design):** Author `wiki/cultural-groups/van-maanens-star.md` using the existing cultural-group template. The content exists across D-036, D-050, `culture-van-maanens-star.ron`, and `cultural-generation-guide.md`. It needs to be assembled into a single canonical page.
---
## Layer-by-Layer Pipeline Audit
### Layer 1: Galactic — System definitions, star data
**Status:** DONE (`systems.db`, wiki star-system pages, ~230+ system index entries)
**Cultural inputs at this layer:** None at generation time. System names, WorldTier, economic profile, faction presence, and settlement age are authored in wiki pages and systems.db. These are the source material for everything downstream.
**What exists:**
- `wiki/star-systems/` — 230+ system directories with `index.md` entries at varying completion levels
- `wiki/star-systems/catalog.md` — system index
- GTTR guide files (gttr-core.md, gttr-north.md, etc.) — narrative characterizations per region
- `systems.db` — canonical economic data per system (populated by `import_economics.py`)
**Content gaps at this layer:**
- Many `index.md` files contain only stub content ("Pending generation"). The narrative character, faction notes, and silence topics are missing for a significant portion of the catalog. These don't block Phase 1 district skeleton generation directly — the required fields (`world_tier`, `economic_profile`) come from systems.db, not from the wiki narrative text. But they block cultural depth at later layers.
**No action required to unblock Phase 1.**
---
### Layer 2: System/Orbital — Body definitions, orbital mechanics
**Status:** DONE (`systems.db`, bodies tables)
**Cultural inputs at this layer:** System WorldTier assignment. This is the top-level simulation fidelity budget that everything downstream inherits.
**What exists:**
- Body tables in systems.db with gravity, atmosphere class, hydrosphere, economy type, settlement wave, industrial focus
- These fields implicitly carry cultural information (settlement wave → settlement age → architectural character)
**Critical gap: No formal system → heritage root mapping.**
The `SocietyProfileRef` field on `DistrictSkeleton` is typed as `String` — a stub. The generator will need to resolve a system to a heritage root blend to fill this field. Example: Van Maanen's Star → `[Iron(0.4), Stone(0.3), Tide(0.3)]`. No authored file establishes these mappings for any system.
This is the upstream content dependency for Phase 1 district social structure. The culture RON files (`culture-van-maanens-star.ron`, etc.) provide NPC voice/behavior data but do NOT include heritage root blend weights. The generator cannot derive heritage blend from the culture file as currently structured.
**Content gap — CRITICAL for Phase 1:** Heritage root blend specification per system is entirely missing. It must be authored before Phase 1 can assign `SocietyProfileRef` to districts with any cultural meaning.
---
### Layer 3: Planetary Surface / Heightmaps
**Status:** DONE (`tooling/planet-gen/planet_simulation.py`)
**Cultural inputs at this layer:** None. Terrain is physically simulated from stellar parameters. Cultural inputs are not relevant at this stage.
**No content gaps from a worldbuilding perspective.**
---
### Layer 4: Atlas Markers — City placement, roads, rail, rivers, mountains
**Status:** DONE (`tooling/planet-gen/generate_atlas.py`, `markers.json` per body)
**Cultural inputs at this layer:** City names, road network character, river/mountain names.
**What exists:**
- `gemma_naming.py` — operational cultural naming pipeline using Gemma 4 via sr-voice
- `naming_core.py` — cultural corridor-aware batch naming with dedup and blocklist
- City markers: `{"id": "city_0", "name": "Glødberg", "kind": "capital", "center": [235, 394], "population": 8200}` — names are populated by Gemma naming
- Earth blocklist (`earth_blocklist.txt`) prevents major real-world cities from appearing
**What is missing from markers.json city entries:**
Current city marker fields: `id`, `name`, `kind`, `center`, `population`. That is all.
Ticket #899 (District skeleton generator — Phase 1 implementation) describes reading `primary_function`, `planet_class`, and `settlement_pattern` from city entries to derive SettingType, ComplexityTier, and DistrictLayoutMode. **These fields do not exist in the current markers.json schema.**
This is a data pipeline gap between Layer 4 (atlas markers) and Layer 6 (district skeleton). Either:
a) The city marker schema needs extending with cultural/functional fields, or
b) Phase 1 derives these from systems.db body/economy data cross-referenced by city position
**Action needed:** Clarify with Tyre which approach is intended. If option (a), define the new city marker fields and update `generate_atlas.py` to populate them. If option (b), document the cross-reference logic. Either way, `markers.json` is currently insufficient for Phase 1 input.
**Cultural corridor definitions** are embedded in `naming_core.py` prompts and not documented anywhere in the wiki. This is a maintainability gap — if a content author needs to understand why a region's names sound the way they do, there is no wiki document to consult. This is not a generation blocker but is a documentation debt.
---
### Layer 5: Economics — Supply chains, corporations, brands, trade flows
**Status:** DONE (`tooling/economy-db/`, `systems.db`, `wiki/economics/`)
**Cultural inputs at this layer:** Corporation identities, brand heritage tags, currency zone characters.
**What exists:**
- `wiki/economics/corporations/` — brand and corporation TOML files
- `wiki/economics/commodities.toml` — commodity catalog
- `wiki/economics/currency_zones.toml` — currency zone definitions
- `wiki/economics/production_chains.toml` — supply chain definitions
**No significant content gaps from a worldbuilding perspective for current Phase 2 economics layer.**
Setting note: the `currency_preference` field on corporations mirrors their HQ booking zone currency — this is consistent with the economics convention documented in memory. Economics layer is healthy.
---
### Layer 6: District Skeleton — Phase 1 (STUB)
**Status:** STUB — data model exists, no generation logic
**Key file:** `server/src/simulation/generator.rs`
**Cultural inputs at this layer (per workshop architecture):**
Phase 1 has 5 stages. Cultural inputs enter at stages 1, 2, and 4:
**Stage 1 — Classification (WorldTier, ComplexityTier, SettingType)**
Cultural input needed:
- SettingType: derived from city `kind` + body economy type (Urban, Station, Agricultural, etc.)
- WorldTier: inherited from system-level data
- ComplexityTier: derived from WorldTier + population + economic function
Data available: systems.db has body economy type and settlement data. City `kind` (capital, settlement, outpost) provides coarse classification.
Gap: No authored mapping rules from `(economy_type, city_kind, world_tier)` → `SettingType`. These rules need to be defined somewhere for the generator to apply them.
**Stage 2 — Block Grid (DistrictLayoutMode, BlockSkeleton ×16)**
Cultural input needed:
- DistrictLayoutMode: Grid (Commission-planned = power imposed) vs Organic (pioneer = power negotiated)
- Heritage root influences probability: Frost/Arc heritage → Grid bias; Tide/Vine/Dust → Organic bias
Gap: No authored heritage root → layout mode probability table exists. This decision was confirmed (L-2: "some blocks grid, some organic chaos") but the probability weights are not authored.
**Stage 4 — Social Site + NPC (triangles, society profile)**
Cultural input needed:
- `SocietyProfileRef` — references a culture profile for the district
- Heritage root blend — determines social grammar, trust model, observation density, etc.
What exists:
- `server/content/global/culture-van-maanens-star.ron` — COMPLETE culture profile for Van Maanen's Star. Includes naming, speech, values, voice persona, voice examples, behavior modifiers. Operational in voice pipeline.
- `server/content/global/culture-vael.ron` — Core-adjacent, administrative culture (exists)
- `server/content/global/culture-osse.ron` — Deep Frontier, extraction culture (exists)
Gap: Only 3 culture RON files exist for ~230 systems. 99% of systems have no culture profile. Also, culture RON files do not include heritage root blend weights — these are needed by Phase 1 for `DerivedDistrictAnalysis` computation (assassination difficulty, playstyle affinity).
---
### Layer 7: Heritage Grammar Overlay — Phase 2 Chunk Fill (MISSING ENTIRELY)
**Status:** TYPE DEFINED, NO DATA, NO CODE
**Cultural inputs at this layer:** `HeritageGrammarOverlay` per heritage root (10 roots: Frost, Tide, Iron, Dust, Stone, Vine, Salt, Arc, Jade, Spice)
**The D-READY-9 architecture (confirmed in workshop-outcomes.md) specifies:**
```rust
struct HeritageGrammarOverlay {
heritage_root: HeritageRoot,
boundary_character: BoundaryCharacter,
open_space_character: OpenSpaceCharacter,
structure_spacing: f32,
decorative_density: f32,
gathering_anchor_near_work: bool,
shared_infrastructure_preference: bool,
repair_visibility: f32,
facade_investment: f32,
signage_density: f32,
preferred_enclosure_tags: Vec<ObjectTag>,
accent_object_tags: Vec<ObjectTag>,
excluded_object_tags: Vec<ObjectTag>,
}
```
**What exists:**
- The struct specification in `miri-round4.md` — complete with values for all 10 roots
- The authoring domain allocation: Miri authors organizational principles; Araminta authors visual expression
**What does NOT exist:**
- Any `heritage_grammar_overlay/*.ron` (or equivalent) data files
- A `HeritageRoot` enum in `server/src/simulation/generator.rs` (the type is not defined; `SocietyProfileRef = String` is the only heritage-adjacent stub)
- The `ObjectTag` vocabulary that both heritage grammar and Araminta's asset categorization must share
- Any Phase 2 chunk fill code
**This is the largest cultural content gap in the pipeline.**
Every district's interior visual and spatial character — how property boundaries look, how open space is used, what objects appear, how dense and repaired the environment feels — flows from the `HeritageGrammarOverlay`. Without this content, Phase 2 chunk fill produces nothing differentiated. Every district looks the same regardless of whether it's a Frost-heritage frontier outpost or a Tide-heritage community hub.
**The heritage grammar data in `miri-round4.md` is complete and ready to author into files. The work is translation from workshop spec to authored data, not design.**
---
### Layer 8: Zone Palette — District Zones
**Status:** STUB — type defined as `type ZoneDefinition = String`
**Cultural inputs at this layer:** Zone identity per district type (LogisticsHub, Residential, Commercial, etc.) with heritage modifiers.
**What exists:**
- `server/content/global/zone-identity-spec.example.ron` — example zone spec (rural, well-structured)
- `server/content/global/van-maanens-star-rural-zone.ron` — Van Maanen's Star rural zone (exists)
- `server/content/global/van-maanens-star-industrial-zone.ron` — Van Maanen's Star industrial zone (exists)
**What is missing:**
- Zone definitions for the 9 DistrictType enum values: LogisticsHub, Residential, Commercial, Industrial, Administrative, Entertainment, MixedUse, Transit, Specialized
- Heritage modifier variants for each zone type
- The `ZoneDefinition` type needs to be promoted from `String` stub to a proper struct before zone content can be consumed
**Partial authoring exists for Van Maanen's Star only.** Other systems have no zone definitions.
---
### Layer 9: NPC Population
**Status:** STUB — `SocietyProfileRef = String`, NPC generation not implemented
**Cultural inputs at this layer:** Full culture profile (naming, voice, traits, behavior, heritage blend)
**What exists:**
- 3 culture RON files (Van Maanen's Star, Vael, Osse) — validated in voice pipeline Spike 1
- `wiki/authoring/cultural-generation-guide.md` — 5-dimension authoring framework
- `wiki/authoring/culture-authoring-guide.md` — RON file authoring process (Mellanie)
**What is missing:**
- ~227+ culture RON files (one per system, or per cultural corridor)
- Heritage root blend weights as an extension of the culture RON schema
- `wiki/cultural-groups/` directory is EMPTY — no canonical wiki entries for any culture
The `wiki/_templates/cultural-group.md` template exists and is well-structured. No cultural group entries have been authored using it.
---
## Summary Table
| Layer | Status | Cultural Data Status | Content Blocker? |
|---|---|---|---|
| Galactic (star/system) | DONE | Partial wiki coverage | No (numeric data in systems.db complete) |
| System → heritage mapping | — | MISSING | **YES — blocks Phase 1 social stage** |
| Planetary surface/heightmaps | DONE | N/A | No |
| Atlas markers / city naming | DONE | Naming operational; functional fields absent | Partial (need city marker schema extension) |
| Economics | DONE | Healthy | No |
| Phase 1 — Classification | STUB | No SettingType mapping rules authored | Yes |
| Phase 1 — Block grid | STUB | No heritage→layout probability authored | Yes |
| Phase 1 — Social site + NPC | STUB | 3/230+ culture files exist; no heritage blends | **YES** |
| Phase 2 — Heritage grammar overlay | MISSING | No authored data for any heritage root | **YES** |
| Phase 2 — Zone palettes | STUB | 2 Van Maanen's Star zones only | Yes |
| NPC culture profiles | STUB | 3 profiles, no wiki entries | Yes (can defer to first system) |
---
## Critical Content Gaps — Ordered by Blocking Impact
### Gap 1: System → heritage root blend (CRITICAL, blocks Phase 1)
No authored document establishes which heritage root blend applies to any system. The `SocietyProfileRef` stub cannot be resolved without this mapping.
**What's needed:** A mapping table (or per-system annotation) of `system_id → Vec<(HeritageRoot, f32)>`. This could live in:
- An extension of the culture RON file (add `heritage_blend` field)
- A new `heritage-map.ron` or similar global file
- Per-system wiki pages (in `wiki/star-systems/{sys}/index.md`)
The mapping for Van Maanen's Star can be derived from the existing content: the culture guide describes "working-class pragmatic" + "community-oriented" + "suspicious of authority" + "shows up and does the work." Cross-referencing with heritage root definitions from miri-round4.md, this reads as: **Iron** (collective labor identity, shared work) + **Stone** (tenure-based trust, territorial memory, slow disclosure) + **Tide** (communal eating, community participation, warm surface). A reasonable starting blend: Iron(0.4), Stone(0.35), Tide(0.25).
This is not a design question. It is translation of existing characterization into the generator's vocabulary.
### Gap 2: HeritageGrammarOverlay data files (CRITICAL, blocks Phase 2)
Ten heritage roots need authored overlay files. The full specification exists in `miri-round4.md` §OQ-R4-D. The values are already worked out for each root. What is needed is authoring them into a machine-readable format (RON or TOML).
**Scope:** This is Miri's content to author. Araminta then expresses the visual vocabulary against these overlays. The `ObjectTag` vocabulary must be agreed jointly.
**Blocking relationship:** Phase 2 chunk fill → HeritageGrammarOverlay data files → ObjectTag vocabulary → Araminta asset categorization.
### Gap 3: City marker schema extension (MODERATE, blocks Phase 1 input)
The markers.json city format (`id`, `name`, `kind`, `center`, `population`) is insufficient for Phase 1 district skeleton generation as described in ticket #899. Either city markers need `primary_function`, `planet_class`, `settlement_pattern` fields added, or Phase 1 must derive these from systems.db cross-reference.
**This is a Tyre/generator question as much as a worldbuilding question.** I flag it here because if new city marker fields are required, Miri is responsible for defining what `settlement_pattern` values mean culturally (grid-planned vs organic-pioneer), and Tyre is responsible for how `generate_atlas.py` populates them.
### Gap 4: Zone identity definitions for all DistrictType values (MODERATE)
The `DistrictType` enum has 9 values. Only 2 Van Maanen's Star zone files exist. Full zone coverage needs:
- One zone spec per DistrictType (base definitions)
- Heritage modifier variants (how does a LogisticsHub differ in Stone vs Tide vs Iron heritage?)
This is authoring work for Miri (social/organizational character) + Araminta (visual expression).
### Gap 5: Cultural group wiki entries (LOWER, documentation debt)
`wiki/cultural-groups/` is empty. The `wiki/_templates/cultural-group.md` template exists. Cultural data lives in authoring guides and RON files but has no canonical wiki presence.
For the generator, the RON files are what matter. For human authors (Mellanie, future content team), the wiki entries matter. This is documentation debt that should be addressed alongside Gap 1-3 but does not block code.
---
## Ticket Dependency Observations
### Existing tickets and their status
| Ticket | Title | Status | Notes |
|---|---|---|---|
| #615 | Tycoon small business owner starting state | backlog | Blocks #681 (apartment generator) |
| #616 | Broad economic verb vocabulary design | backlog | No pipeline dependency |
| #619 | Full character customisation | backlog | No pipeline dependency flagged |
| #679 | Location-to-culture resolution system | **DONE** | Culture name resolved by location — but heritage root blend not included |
| #681 | Apartment generator logic (server) | backlog | Blocked by #615 + #679 (done); needs culture |
| #682 | Apartment visual rendering + first-moment | backlog | Blocked by #681 |
| #694 | Character creation screen | backlog | Blocked by #685 (done) |
| #899 | District skeleton generator — Phase 1 | backlog | Blocked by #897 (this workshop) |
### Formal pipeline blockers (worldbuilding content view)
Character work (#694) and apartment work (#681, #682) sit downstream of:
```
Phase 1 district skeleton (#899)
← blocked by heritage root blend content (Gap 1)
← blocked by city marker schema (Gap 3)
← blocked by #897 (this workshop producing D-records)
Phase 2 chunk fill (no ticket yet)
← blocked by HeritageGrammarOverlay data files (Gap 2)
← blocked by Phase 1 (#899)
Apartment generator (#681)
← blocked by tycoon starting state (#615)
← culture resolution (#679, done) resolves culture name but NOT heritage root blend
Character creation (#694)
← no generation pipeline dependency; can proceed independently
← but the world they're dropping into won't be culturally differentiated until Gap 1-2 resolved
```
The apartment generator (#681) can produce a culturally-voiced apartment (NPC tone, naming, dialogue voice) using the existing `culture-van-maanens-star.ron`. What it CANNOT do is produce a spatially differentiated apartment that reflects heritage-root spatial grammar (boundary character, decorative density, facade investment) — that requires the HeritageGrammarOverlay (Gap 2).
This means #681 can proceed to a first version that is culturally voiced but spatially generic. The heritage spatial grammar layer can be added in a subsequent pass once HeritageGrammarOverlay data files exist. This is probably the right sequencing: don't block #681 further waiting for heritage overlays; instead, build the apartment generator against the culture RON files, and plan a follow-on ticket for heritage spatial refinement.
### New tickets needed (from worldbuilding perspective)
1. **Heritage root blend mapping** — Author `heritage-blend` field extension for culture RON schema + provide Van Maanen's Star blend. Prerequisite for Phase 1 meaningful district social assignment.
2. **HeritageGrammarOverlay data files** — Author all 10 overlay files from the `miri-round4.md` specification. Prerequisite for Phase 2 chunk fill.
3. **ObjectTag vocabulary** — Jointly author with Araminta. Prerequisite for heritage overlay files and asset categorization.
4. **Zone identity definitions** — Author canonical zone spec per DistrictType. Prerequisite for Phase 1 zone palette assignment.
5. **Van Maanen's Star cultural group wiki entry** — Formal wiki page consolidating all setting data. Documentation prerequisite; does not block generation.
6. **City marker schema extension** (coordination with Tyre) — Define and populate cultural/functional fields on city markers if Phase 1 requires them as inputs.
---
## What the Pipeline Promises and What's Missing
The generator-architecture workshop outputs describe a generator that produces culturally differentiated worlds — districts that feel different based on whether they're Stone-heritage frontier settlements or Tide-heritage community hubs, with visual, social, and spatial grammar that reflects the culture. That promise requires authored cultural data at two critical points:
**Point A — Phase 1 input:** Each district must know what culture it is generating for. This requires a system → heritage root blend mapping that doesn't exist yet.
**Point B — Phase 2 input:** The chunk fill that produces visible tiles must have HeritageGrammarOverlay data to apply the spatial grammar. This data doesn't exist yet.
The voice pipeline is healthy — three culture RON files are operational and the Van Maanen's Star profile is production-quality. The NPC voice layer will work. The spatial and visual differentiation layer will not, because it has no data.
The minimum viable cultural content set for a first walkable world output:
1. Heritage root blend for Van Maanen's Star → system (to test Phase 1 social assignment)
2. HeritageGrammarOverlay files for Iron + Stone + Tide (the three roots that make up Van Maanen's Star blend) → to test Phase 2 chunk fill differentiation
3. Zone identity specs for LogisticsHub + Residential (the two district types most relevant to Station Sova) → to test Phase 1 zone assignment
This is achievable in one sprint of content work. It is scope-limited to the primary test system, not all 230+ systems.
---
## Open Questions Raised by This Inventory
**Q1:** Is `SocietyProfileRef` intended to resolve to the culture RON filename (e.g., `"van-maanens-star"`), or to a more structured reference that includes heritage blend weights? The current String stub leaves this unresolved. If heritage weights are expected on the SocietyProfileRef rather than embedded in the culture RON, the culture RON schema needs a heritage_blend extension.
**Q2:** City marker schema — does Phase 1 read cultural/functional fields from markers.json city entries, or from systems.db body data? This changes both the atlas generator pipeline and the cultural data authoring requirements.
**Q3:** Are there 10 heritage roots or 7? The culture authoring guide (Mellanie) lists Frost / Tide / Iron / Dust / Stone / Vine / Salt (7). Miri Round 4 lists Frost / Stone / Tide / Iron / Dust / Vine / Salt / Arc / Jade / Spice (10). This needs resolution before HeritageGrammarOverlay files can be authored.
**Q4:** Should the `wiki/cultural-groups/` directory be populated now (one entry per culture RON file) or deferred until Phase 3 (planetary maps + station layouts)?
---
**Author:** Miri
**Date:** 2026-04-30
**Status:** Round 1 complete. Ready for Round 2 synthesis.
@@ -0,0 +1,251 @@
---
title: "Paula — Round 2: Narrative Structure Expressed Through Spatial Generation"
description: "Political and class legibility through district spatial parameters; naming registers; authored vs. seeded boundary"
type: workshop
status: active
workshop: generation-cascade
agent: paula
round: 2
created: 2026-04-30
---
# Generation Cascade — Round 2 (Paula)
**Focus:** What districts should express politically and socially through space alone; naming as narrative signal; where the authored/seeded boundary sits; minimum narrative floor for the thinnest vertical slice.
**Not addressed here:** NPC systems, heritage grammar overlays, culture files, social sites, triangle system. Those are outside scope.
---
## 1. What Districts Should Express Spatially
The honest truth is that political power and class have always been written into cities before anyone consciously designed them that way. The question is whether our generator makes those patterns legible to a player walking through a district without any UI overlay or NPC dialogue.
These are the spatial parameters that carry political information. They are all derivable from `DistrictType` + `WorldTier` + a prosperity scalar. No cultural content required.
### The Parameters
**`prosperity_index: f32` (0.0 → 1.0)**
This is the single most important signal and it doesn't exist in the current data model. Everything about class legibility flows from it:
| Prosperity band | Tile decay probability | Road width | Building density | Green space |
|-----------------|----------------------|------------|-----------------|-------------|
| 0.0–0.2 (subsistence) | 40–60% tiles damaged/patched | Narrow | Very high lot coverage | None |
| 0.2–0.4 (working) | 20–35% | Narrow to medium | High | Minimal (functional only) |
| 0.4–0.6 (middle) | 5–20% | Medium | Medium | Small plazas |
| 0.6–0.8 (affluent) | 1–5% | Medium to wide | Lower (setbacks) | Parks |
| 0.8–1.0 (elite/monumental) | <1% | Wide to boulevard | Low (wasted space is luxury) | Maintained gardens |
This is a new field proposal: add `prosperity_index: f32` to `DistrictSkeleton`. It's computed in Phase 1 Stage 1 from `WorldTier` + `DistrictType` + the city's economic profile in systems.db. No authoring required — it's derived.
**`perimeter_treatment: PerimeterTreatment`**
Currently the `access_points: Vec<AccessPoint>` field handles district entry/exit points. That's not sufficient. The *character* of those entry points is the political signal. Proposed enum:
```
Open // no boundary markers; permeable (organic/poor residential)
Fenced // visible boundary, no control (light industrial, transitional)
Walled // physical barrier, uncontrolled gaps (heavy industrial, abandoned)
Gated // checkpoints on main arteries, open on minor (elite residential)
Checkpoint // all entry points monitored (corporate security, administrative)
```
A player approaching a Checkpoint district understands before entering: this space is controlled by someone who has resources to control it. No NPC required. This field belongs on `DistrictSkeleton` alongside `access_points`. It's derived from `DistrictType` + `WorldTier` + `prosperity_index`.
**`layout_mode: DistrictLayoutMode`**
Already designed (Grid | Organic) and already in the spec. This is worth calling out explicitly as a political signal, because it is one:
- **Grid** districts were planned by someone with authority and resources. They signal deliberate power — administrative, corporate, institutional.
- **Organic** districts grew without central planning. They signal ungoverned space, or space that predates whoever is currently in charge. They feel older, stranger, harder to navigate.
A poor Organic district and a poor Grid district tell different political stories even at identical prosperity levels. One was always poor; the other was once planned and has since declined.
**Road width as status**
The workshop-outcomes.md spec notes `CorridorSpine` but doesn't specify widths as narrative signals. Proposal: road width should be a function of `DistrictType` + `prosperity_index` with deliberate political logic:
- Administrative districts: wide boulevards regardless of prosperity (power signals itself)
- Industrial: wide loading corridors on main arteries, narrow worker passages elsewhere
- Residential elite: wide main arteries, controlled secondary streets
- Residential working: uniform narrow (no hierarchy because no investment)
- Transit: widths are functional (vehicle-scaled), not status-scaled
The key signal here is **width hierarchy** — elite districts have a clear hierarchy from boulevard to secondary to alley. Poor districts have uniform narrowness. That uniformity reads as neglect.
**Sight lines as political geometry**
This one is harder to implement but worth flagging as a design principle for Phase 2 tile generation: sight lines carry political meaning.
- Can you see the administrative building from everywhere in the district? That's intentional design — presence asserted through visibility.
- A corporate security district has open sightlines through corridors (surveillance optimization).
- An organic working-class district has broken sightlines — buildings jut out, alleys angle away, nothing is legible at distance.
I'm not proposing full sight-line computation in the first pass. But Phase 2 tile selection should be aware that building placement along corridors in high-prosperity/Checkpoint districts should maintain sightlines, while Organic/low-prosperity districts should not. This is a parameter in the block fill algorithm, not a separate computation.
---
## 2. Naming With Narrative Weight
Every place name is a political act. Who gets commemorated? What function is elevated to a proper noun? What's left unnamed and called by folk description instead?
The generator needs naming *registers* — not just name pools. The register determines what *kind* of name is drawn. The culture pool provides the raw names.
### The Five Naming Registers
| Register | Pattern | What it signals | Typical districts |
|----------|---------|-----------------|------------------|
| **Commemorative** | Person's name, institutional name | Establishment power; official naming | Administrative, elite Residential |
| **Functional** | What happens here | Working class, transit-adjacent; named for use | Industrial, Transit, LogisticsHub |
| **Aspirational** | Abstract positive noun | Corporate development, gentrification | New commercial, MixedUse in growth |
| **Folk** | Colloquial, descriptive, sometimes ironic | Organic/ungoverned; named by inhabitants not officials | Old Residential, Backwater settlements |
| **Geographic** | Terrain feature, orientation | Pre-political naming; oldest settlements | Any SettingType with natural features |
**How the generator picks a register:**
This is a lookup table by `(DistrictType, WorldTier)` with weighted blends, not a hard assignment. Example:
| DistrictType | WorldTier | Primary register | Secondary register |
|-------------|-----------|-----------------|-------------------|
| Administrative | Epicenter | Commemorative | Aspirational |
| Administrative | Backwater | Folk | Functional |
| Industrial | any | Functional | Folk |
| Residential (high prosperity) | Regional+ | Geographic | Commemorative |
| Residential (low prosperity) | any | Folk | Functional |
| Transit | any | Functional | Numbered |
| MixedUse | Regional | Aspirational | Functional |
The `Backwater` WorldTier shift matters: an Administrative district in a Backwater system doesn't have the resources for commemorative naming. It gets folk names that happen to include the word "council" or "office."
This is worth a D-record. It's a locking decision about what the generator produces.
### Street Naming
Streets within a district should follow the district register but with additional signals:
- **Named streets** (proper noun) signal importance and age — this street was worth naming
- **Numbered streets** signal planned grid — authority imposed order
- **Descriptive streets** ("Canal Way", "Mill Road") signal function-first origin — working space
- **Colloquial streets** that don't match their character ("The Long Road" being short) signal living history — the name outlasted what it described
The generator can produce these mechanically: Grid layout districts use numbered secondaries. Organic layout districts use descriptive and colloquial names drawn from the culture folk pool.
### Landmark Naming
Landmarks inherit naming from the controlling faction, derivable from the economics layer. If ISC (a corporation in systems.db) operates in this system's logistics sector, the water distribution hub is "ISC Reservoir #4," not "Community Water Station." The economics layer already knows who controls what — landmark naming can pull from that directly, no authoring required.
---
## 3. The Seed-vs-Authored Boundary
Round 1 didn't address this directly. I want to be explicit about it because it determines authoring workload.
### What Should Be Authored Per City (Small Fixed Set)
**Political archetype — one field per city in atlas/systems.db:**
Five archetypes cover the vast majority of settlement types:
- `CompanyTown` — one corporation dominates; homogeneous district type distribution
- `AdminCapital` — government/institutional core surrounded by support districts
- `FreePort` — Transit and MixedUse dominant; contested/heterogeneous
- `Contested` — two or more factions with visible boundary zones
- `OrganicGrowth` — no dominant power; district types mix without hierarchy
This single field per city drives the district type *distribution* for Layer 6 (city → district decomposition). It's derivable from systems.db economics data for most cities, but someone needs to sanity-check the results for named cities in the setting. This is a small authoring task, not a large one.
**Named landmarks in major cities (authored by the team):**
Capital cities and named stations should have specific landmark names that anchor the setting. These are authored as part of Miri's work, not generated. The generator reserves the landmark slots; the authored names fill them for named cities.
### What Is Derived From Existing Data (Zero Additional Authoring)
- District type distribution → from `political_archetype` + systems.db economics data
- Prosperity index per district → from systems.db wealth indicators per city
- Naming register → from `(DistrictType, WorldTier)` lookup table (authoring the table once covers all cities)
- Landmark naming for unnamed/minor cities → from systems.db corporation data
- Perimeter treatment tier → from `DistrictType` + `WorldTier` + prosperity_index
### What Is Seeded (Deterministic Variation Per District)
- Block arrangement within district type + layout_mode constraints
- Specific tile decay placement within prosperity_index band
- Road width variation within allowed range per district type
- Perimeter treatment sub-tier (a working-class district might be Fenced or Open depending on seed, not authored)
- Street and landmark names for unnamed districts — drawn from culture pool, seeded per street
- Exact landmark footprint position within the reserved slot
**The key principle:** authored content is high-value and static across playthroughs (Van Maanen's Star's capital always has the same major landmarks). Seeded content produces variation within that stable frame. The generator never needs to invent political geography from nothing — it reads it from data that already exists.
---
## 4. Minimum Narrative Signal for the Thinnest Vertical Slice
If we generate one district with walkable tiles, what's the absolute minimum that makes it feel like a place with a story rather than a procedural grid?
Five signals. All derivable from existing or proposed architecture.
1. **`prosperity_index: f32`** — drives tile decay, road width, density. New field. One number. All class legibility downstream from this.
2. **`DistrictType`** — already in the struct. Determines the fundamental spatial vocabulary. Even with identical tile graphics, the *shape* of blocks differs between Industrial and Residential.
3. **`perimeter_treatment: PerimeterTreatment`** — new proposed field. The approach to a district is the first story beat. A player walking toward a Checkpoint district understands access control before entering. No tile variation needed — just the presence of the boundary marker tile at access points.
4. **`layout_mode: DistrictLayoutMode`** — already designed. Grid vs. Organic changes the political reading of any district. This one parameter tells the player whether this space was planned by power or grew without it.
5. **One active landmark slot** — already in the spec as `LandmarkSlot`. Even if it only manifests as a larger building footprint with no special behavior, it gives the district a focal point. A space with no orientation is spatially illegible — nothing says "something important happened here."
With these five signals, a player traversing a Checkpoint-perimeter, Organic-layout, low-prosperity Industrial district understands: this space has authority asserted over it from outside (checkpoint) but grew without planning (organic). It's economically marginal (decay tiles). Something notable anchors it (landmark footprint). That's narrative without dialogue, UI overlay, or NPC interaction.
**What this does NOT require for the first pass:**
- Heritage grammar overlays — district character is legible without them
- Culture-specific tile sets — decay vs. intact is sufficient; culture flavoring is Phase 2 enrichment
- Social site population — the physical district is readable before anyone lives in it
- Named streets — streets without names are still streets; naming enriches but doesn't unlock legibility
---
## 5. Positions on Round 1 Disagreements
**On #615 (Tycoon starting state) cascade placement:**
I agree with Gestalt. #615 is content design. The starting state description needs to know what *kinds* of spaces exist — the DistrictType taxonomy, the WorldTier values, what a prosperous district vs. a marginal one looks like in this setting. It does not need working generation code.
What #615 *does* need is OQ-R1-B resolved (SocietyProfileRef type) so it knows what cultural data the starting state can reference. Link #615 to that resolution, not to the generation pipeline milestone.
**On vertical slice effort (Gestalt 5-7 days vs. Tyre ~9 days):**
From a narrative perspective, stages 3–5 of Phase 1 (Reservations, Social sites, Guarantee audit) do not add to a player's ability to read a district as a place. They add simulation depth. The minimum for narrative legibility is Stages 1–2 (Classification + Block grid) + Phase 2 minimal fill.
That's the basis for Gestalt's lower estimate. I'd formally support deferring Stages 3–5 until after the first walkable district is demonstrably legible. Build the legibility floor first; add simulation depth once the basic spatial story works.
---
## 6. Proposed D-Record
**D-READY-P1: Spatial Class Legibility Requirements**
Districts must express political character through spatial parameters, not just type labels. The following fields are minimum requirements on `DistrictSkeleton` for a district to be narratively legible to a player traversing it without NPC interaction or UI overlay:
- `prosperity_index: f32` — new field; drives tile decay probability, road width tier, building density, green space allocation
- `perimeter_treatment: PerimeterTreatment` — new field; enum: Open | Fenced | Walled | Gated | Checkpoint; derived from DistrictType + WorldTier + prosperity_index
- `layout_mode: DistrictLayoutMode` — already in spec; must be respected by Phase 2 tile generation as a first-order spatial signal
Additionally, district and street **naming registers** (`Commemorative | Functional | Aspirational | Folk | Geographic`) must be resolved before Phase 2, as the naming of streets is part of the spatial output of the generator, not a post-generation overlay. The register is determined by `(DistrictType, WorldTier)` lookup; the names are drawn from culture pool.
**Reason to lock:** Phase 2 tile selection needs to know what signals to produce. If prosperity_index and perimeter_treatment aren't on DistrictSkeleton before Phase 2 is implemented, tile generation will produce spaces that are spatially indistinguishable regardless of district type — a simulation fidelity problem that's expensive to retrofit.
---
## 7. Open Questions From This Analysis
| ID | Question | Priority |
|----|----------|----------|
| OQ-R2-P1 | Should `prosperity_index` be explicitly authored for named cities (Van Maanen's Star capital) or always derived from systems.db economic data? The derived approach may produce counterintuitive results for scripted setting cities. | HIGH |
| OQ-R2-P2 | Is `political_archetype` a new field in atlas markers.json, or derived from existing systems.db data? If derived, what is the derivation rule? | HIGH |
| OQ-R2-P3 | Naming register lookup table: who authors the `(DistrictType, WorldTier)` → register mapping? This is a small task but needs ownership before Layer 6 generates district names. | MEDIUM |
---
*Paula — Round 2. Written 2026-04-30.*
@@ -0,0 +1,360 @@
---
title: "Paula — Round 3: Narrative Structure at Regional and City Scale"
description: "Political signals in regional maps, city orientation, road networks, settlement hierarchy, and naming strata — Phase 3 scope, above district level"
type: workshop
status: active
workshop: generation-cascade
agent: paula
round: 3
created: 2026-04-30
---
# Generation Cascade — Round 3 (Paula)
**Focus:** Phase 3 — planetary/regional maps, city footprints, roads, and settlement hierarchy. The narrative/political signals that should exist *above* district level, and the data the generator needs to produce them.
**Not revisited here:** `prosperity_index`, `perimeter_treatment`, `political_archetype`, naming registers — these were accepted in Round 2. I'm building above them.
**Not addressed here:** heritage grammar, NPC population, culture files, social sites. Out of scope.
---
## Framing the Scale
Round 2 was about a district being legible when a player walks through it. Round 3 is about what a player reads before they arrive — what the regional map, the city approach, and the road between settlements tells them about who controls what and why.
The political geography of a world is visible at altitude before it's visible on foot. Power writes itself into the landscape at every scale: which rivers are bridged (someone paid), which roads are wide (someone uses them heavily), which settlements have walls (someone threatened them), which settlements have *two* names (someone disputed them). The generator needs to produce those signals systematically, not accidentally.
These are not cosmetic. A player navigating a regional map is making decisions — where to go, what to expect — based on visible signals. If those signals are random noise, navigation is a guessing game. If they carry consistent political logic, the player reads the world.
---
## 1. Regional Maps: What Control Looks Like From Above
### The Core Signal: `TerritorialStatus`
Before any city-level decomposition, each region on a planetary map should carry a territorial status. This is the highest-level political signal — it determines what infrastructure exists and how the generator populates the space between cities.
Proposed enum:
```
CoreTerritory // firmly administered; full infrastructure
FrontierTerritory // administered in principle; infrastructure sparse
ExtractiveZone // controlled for a purpose; purpose-built infrastructure only
ContestZone // multiple claimants; infrastructure in conflict
WildernessBuffer // no administration; minimal infrastructure
AbandonedZone // was administered; currently not
```
**Derivation:** `TerritorialStatus` is not authored per region. It is derived from:
- Proximity to cities (distance to nearest city × that city's `WorldTier`)
- Systems.db economic data (does any corporation have an active presence here?)
- Whether the planetary body has a `political_archetype` that implies contested territory
Named planets in the setting should have their `TerritorialStatus` zones sanity-checked, not authored from scratch.
### What Each Status Signals
**`CoreTerritory`:**
Roads are maintained at the governing city's standard. Settlement density is higher. The wilderness is legible — trails exist, they go somewhere. There may be checkpoints at territory edges but not within the core. The player understands: someone is in charge here, and they've organized the space.
**`FrontierTerritory`:**
Road quality degrades sharply at the edge of administered zones. Settlements thin out and become more purpose-built (outposts, not towns). Infrastructure exists where the administrative authority cares about something (a resource site, a transit route) and not elsewhere. The player understands: administration exists but is stretched thin.
**`ExtractiveZone`:**
Corporate or administrative infrastructure is excellent along the extraction corridor and nowhere else. The road from the depot to the mine is better maintained than the road from the mine to any settlement. Settlements in extraction zones exist to support the extraction, not as independent communities. The player understands: this space belongs to whoever is extracting.
**`ContestZone`:**
The most visually distinctive status. Signs of multiple authorities: doubled checkpoints at boundaries, infrastructure that changes quality at invisible lines, sometimes two roads between the same points (each authority built their own). Settlement names may conflict (see §5 on dual naming). The player understands: this space is fought over, and the fighting is visible.
**`WildernessBuffer`:**
No infrastructure other than geographic trails (rivers, passes). Any settlements are isolated and self-reliant. The silence of the map here is the signal — nothing built here means nothing worth controlling here, or territory that resisted control. The player understands: if you go here, you're outside the protected system.
**`AbandonedZone`:**
The most narratively loaded status. Infrastructure exists but is degraded or dead — roads that lead to ruins, bridges maintained badly, relay stations dark. The question this status always raises: what was here, and why did it stop being valuable? The player understands: something changed. The world has a past that isn't entirely gone.
---
## 2. City Political Geography: Before the Districts
Round 2 gave us `political_archetype` to drive district type distribution. But there's a prior question: how does a city sit in its landscape, and what does that tell you before you've zoomed in far enough to see districts?
### `FoundingOrientation`
Every city was built around something. That something determines where the oldest district is, which direction the city grew, and where the decay gradient runs. Proposed enum for city-level (atlas/systems.db):
```
PortFacing // founded on maritime/waterway trade; oldest district faces water
RailHeadFacing // founded on transit convergence; oldest district faces the junction
ResourceFacing // founded to service extraction; oldest district faces the mine/farm/well
DefenseFacing // founded as fortification; oldest district faces a high point or natural barrier
AdminFacing // founded as administrative center; radiates from government building outward
```
**Why this matters for the generator:** District type decomposition already uses `settlement_pattern` from systems.db. `FoundingOrientation` adds a directional signal — the generator knows which end of the city is oldest, and therefore which end is most decayed, most organic in layout, and most working-class. The city has a political gradient from old to new, and the direction of that gradient is not arbitrary.
**Derivation:** `FoundingOrientation` is mostly derivable from `settlement_pattern` (port, inland, etc.) + `body.economic_role`. It needs a sanity pass on named cities where the setting lore implies a specific history.
### How `political_archetype` Shapes City Footprint
Round 2 established the five archetypes. Here I want to be concrete about what each archetype means for city *shape* before we decompose into districts — because that shape constrains the district layout.
**`CompanyTown`:**
The dominant corporate asset is not centered — it's at one end of the city (the resource-facing end or the port). Worker districts fill the space between the facility and the edge of nothing. There is no plaza, no civic center, no mixed-use heart. The city has a spine, not a center. The facility is the source; everything else is arranged in service of access to it. Transit infrastructure runs toward the facility, not outward toward other cities.
*Generator implication:* District decomposition places Industrial or Corporate Residential adjacent to the dominant facility end. Civic and Administrative districts are minimal and peripheral. City growth direction is away from the facility (workers pushed back from the asset).
**`AdminCapital`:**
The government complex is central, elevated if terrain permits, on the major artery intersection. Residential quality gradients radiate outward — the best residential is adjacent to the administrative center (proximity = status), the worst is at the urban fringe. Multiple well-maintained arteries converge on the administrative center (petitioners and officials both need access). The city has a center that asserts itself.
*Generator implication:* Administrative district occupies a central or elevated position. Road infrastructure is hub-and-spoke from that point. Residential quality (and by extension `prosperity_index`) decreases with distance from center.
**`FreePort`:**
No single center. Multiple competing centers of gravity — trading compounds, transit hubs, factional warehouses. The city is not organized around one institution but around the edges of many. Transit infrastructure is dense and well-maintained everywhere (commerce cannot tolerate bottlenecks). Prosperity and poverty intermix according to commercial logic, not spatial hierarchy: a rich trading house can sit adjacent to a destitute workers' barracks because that's where the docks are.
*Generator implication:* Multiple MixedUse and Transit districts. No dominant administrative core. Prosperity gradient is jagged, not smooth from center to edge. High `perimeter_treatment` variation between districts (some Gated compounds, some Open street markets, side by side).
**`Contested`:**
The most explicitly spatial archetype. There is a visible contested boundary inside the city — a street, a canal, a defunct rail line — that serves as a de facto border between two zones of control. Infrastructure quality changes abruptly at this line. On one side: high `perimeter_treatment`, Checkpoint or Gated districts. On the other: a different authority's version of the same. Dead space at the boundary: buildings that face both ways and belong to neither, or buildings that were destroyed and not rebuilt.
*Generator implication:* District decomposition places the highest `political_archetype` tension along a specific axis of the city, not randomly distributed. The contested boundary should be detectable as a feature before districts are even generated — it's a city-level geography question. Road infrastructure is duplicated or dead at the boundary axis.
**`OrganicGrowth`:**
No center. Multiple minor centers, none dominant. Infrastructure follows historical accident — the oldest road bent around a building that no longer exists; the street grid has four different orientations because four different periods of growth each imposed their own logic. Quality gradients are local, not city-wide: one neighborhood maintains itself, its adjacent neighborhood doesn't, and there's no political reason for the difference. The city resists legibility from above.
*Generator implication:* District decomposition produces high layout diversity (more Organic layout districts relative to other archetypes). Prosperity index varies locally rather than tracking a city-wide gradient. The `FoundingOrientation` is more ambiguous here — the city may have multiple founding nodes.
---
## 3. Road Networks as Political Artifacts
Roads are the most persistent political statements in a landscape. The question the generator needs to answer for every road is not just "does this road exist" but "who built it and why."
### `MaintenanceAuthority`
Proposed field for road segments:
```
Administrative // maintained by governing authority; quality proportional to distance from capital
Corporate // maintained by a corporation; uniform quality within corporate territory, abrupt drop at edge
Communal // maintained by settlements it connects; quality proportional to settlement prosperity
Trade // maintained by traffic volume; quality proportional to commerce flow, not political importance
Abandoned // was maintained; no longer is
```
**Why this matters:** Road quality tells the player who cares about a connection. An `Administrative` road from the capital is excellent near the capital and degrades at distance — that degradation is the visible expression of how far administrative power actually extends. A `Corporate` road has no gradient: it's uniformly good from facility to facility and then just... stops. The quality boundary is as sharp as a property line.
A `Trade` road through a politically marginal region can be excellent if commerce demands it — which is its own political signal. Commerce went where politics didn't bother to go.
### Road Narrative Classes (derived, not authored)
The generator should produce road narratives from existing data:
**Administrative corridor:** Connects administrative capital to sub-settlements or resource sites it administers. Quality inversely proportional to distance from capital. Checkpoints may appear at territorial boundaries (where `TerritorialStatus` changes). The capital end is always best-maintained.
**Corporate corridor:** Connects corporate assets (depot → mine → processing → port). Maintained at corporate standard regardless of location. The quality drop at the corporate boundary is intentional — the corporation maintains only what it needs. These roads often skip the nearest town entirely, because the corporation does not need the town.
**Trade route:** Quality proportional to traffic volume, not political importance. Can be excellent in areas that administrative authority barely reaches, if commerce demands it. Trade routes often follow `FreePort` settlement chains. Notable: trade routes can outlast the political conditions that created them — an old trade route connecting a now-contested or abandoned city still shows its quality level from the era of its use.
**Settlement track:** Maintained by the settlement(s) it connects. Quality follows settlement prosperity — a working-class settlement has a working-class road. These roads are often the oldest in a region, because settlements predated highways. They follow terrain more closely (less engineering capacity), which makes them more winding.
**Abandoned road:** Quality in physical decline — cracked surface, overgrown margins, unmaintained bridges. But the road still exists, still goes somewhere, and what it connects tells a story about a different era's values. An abandoned corporate road through a `WildernessBuffer` region asks: what was here that the corporation abandoned?
### Road Width as Compound Signal
Road width is already proposed in Round 2 as a function of `DistrictType` + `prosperity_index`. At the regional scale, it adds a third variable: `MaintenanceAuthority`.
- Administrative roads near the capital: wide, boulevard-standard, even through poor areas — power signals itself through infrastructure spending that exceeds necessity
- Corporate roads: wide enough for the heaviest vehicles the corporation operates, not wider
- Communal/settlement tracks: width proportional to traffic type, without excess
- Abandoned roads: whatever they were, now narrowed by encroachment
---
## 4. Settlement Hierarchy: What the Generator Knows About Non-City Settlements
The atlas shows cities. But cities don't exist in isolation — they exist in a landscape populated by smaller settlements that express their relationship to the nearest city. The generator needs to know what that relationship is.
### The Full Hierarchy
| Tier | Population | Generator treatment | Narrative function |
|------|------------|--------------------|--------------------|
| City | 10,000+ | Full district decomposition | Current power — who's in charge, what they built |
| Town | 1,000–10,000 | Single-district equivalent | Economic dependency — what the city extracts and how it structures the relationship |
| Outpost | 50–1,000 | Purpose-typed single node | Strategic ambition — where power is trying to reach, or holding |
| Waypoint | <50 | Infrastructure node, no district | Control of movement — who manages the between-spaces |
| Rural structure | Isolated | Type-tagged single structure | Individual economic actors; legible only through their road connection quality |
| Ruin/Abandoned site | 0 | Historical footprint | What failed; what past valued that present no longer does |
### `PoliticalTether`
Every settlement below city-tier has a relationship to a city. This relationship determines road quality, economic dependency, and the settlement's vulnerability to change. Proposed field for sub-city settlements:
```rust
pub struct PoliticalTether {
pub tether_city: CityId,
pub relationship: TetherRelationship,
}
pub enum TetherRelationship {
Administered, // reports to city government; road maintained at city standard
EconomicallyDependent, // supplies city with goods; road maintained proportional to value of that supply
Contested, // claimed by this city and at least one other; double-maintained or abandoned roads
Independent, // de facto independent; locally maintained roads, no city investment
Corporate, // tethered to a corporation, not a city administration
Abandoned, // was tethered; tether city no longer exists or no longer functions
}
```
**Derivation:** `PoliticalTether` is derived from systems.db:
- Distance and road quality to nearest city → probable `tether_city`
- Systems.db economics data → which corporation operates in this zone → `Corporate` relationship
- City `political_archetype` → `CompanyTown` implies most sub-settlements are `EconomicallyDependent` or `Corporate`
**What `PoliticalTether` drives:**
- Road quality between settlement and its tether city
- Naming: `Administered` settlements may inherit naming register from tether city; `Independent` settlements generate folk names; `Abandoned` settlements retain their last naming layer frozen
- Settlement density in the region: `Administered` settlements cluster along administrative corridors; `Corporate` settlements cluster around corporate infrastructure; `Independent` settlements scatter without pattern
- Ruin likelihood: when a `CompanyTown` tether city's dominant corporation leaves (derivable from economic data), its `EconomicallyDependent` and `Corporate` sub-settlements are ruin candidates
### Ruins as Narrative Infrastructure
Abandoned sites deserve special treatment because they are the most politically loaded settlement type. A ruin is not an absence — it is a presence with an explanation gap.
The generator knows:
- What the site was (previous `TetherRelationship`, previous settlement type)
- Who abandoned it (economic change? corporate departure? military destruction?)
- What road connects to it (road quality before and after abandonment era)
The player sees:
- A site marked differently on the map (different marker type)
- A road that leads to it — quality decayed but still there
- No administrative infrastructure, but physical footprint
The gap between what the generator knows and what the player can read is the gameplay. The generator does not need to present the explanation. It needs to produce the spatial evidence and let the player interpret it.
---
## 5. Naming at Regional Scale: Political History as Strata
District-level naming registers (from Round 2) apply to spaces that a single authority controls now. Regional-scale naming is different: it carries sediment from every authority that ever claimed the region, because physical features outlast political systems.
### How Naming Strata Work
Regional names accumulate in layers, with newer layers sometimes obscuring and sometimes coexisting with older ones:
**Layer 1 — Geographic (oldest):** The terrain feature gets named for what it is or where it is. Rivers, mountain ranges, plains — described in terms anyone on the ground could verify. This layer predates all recorded authorities and often predates settlement. It survives even through complete political turnover because travelers still need to refer to the mountain, and "the mountain" is still there.
**Layer 2 — Claiming (authority-imposed):** When an authority claims a region, they rename it. They impose commemorative names on previously-geographic features (the river becomes the "Founder's River") and assign administrative names to new territorial divisions. This layer signals historical dominance — if the geographic Layer 1 name has been fully replaced, the claiming authority has been in place long enough to normalize its naming.
**Layer 3 — Functional (working):** Roads, transit corridors, and industrial regions acquire functional names through use. "The Northern Highway." "Iron Corridor." These are rarely official origin — they emerge from workers, traders, and travelers who need to refer to a route and reach for what's obvious. Functional names persist because they're practical, not because they're endorsed.
**Layer 4 — Contested (dual naming):** A region with two names in active use — one official, one folk — signals that the previous authority's community still exists and uses the old name. This is the most politically charged naming state. The gap between `official_name` and `common_name` is a form of visible resistance.
### `DualNaming` Field
For politically significant regions, roads, and major geographic features, the generator should support two name fields:
```rust
pub struct RegionalFeatureName {
pub official_name: String, // current authority's designation
pub common_name: Option<String>, // folk/historical name if different
}
```
`common_name` is populated when:
- `TerritorialStatus` is `ContestZone` or `AbandonedZone` (high residual naming conflict)
- The region changed hands within a derivable historical period (from systems.db political history)
- The region's `political_archetype` is `Contested` (dual names at city level too)
When `official_name == common_name` (or `common_name` is None): stable, settled territory. No active naming conflict.
When they differ: the player is in disputed or transitional territory. The naming conflict is a gameplay signal without a tutorial prompt.
### Regional Naming Patterns by Feature Type
**Rivers:** Geographic register primary. If a river is a political boundary (derivable from being on the edge of two `TerritorialStatus` zones), it may acquire an administrative name for that boundary function. Folk names for politically-boundary rivers tend toward the ironic or literal: "The Boundary," "The Divide," "No Man's Creek."
**Mountain ranges:** Geographic register. Exception: if a range is a resource site (mining, energy extraction), it acquires functional names for the working zones within it. The range as a whole keeps its geographic name; the specific peaks and passes get functional names if they're economically active.
**Plains, basins, valleys:** The most politically contested landscape type — this is where populations live and food grows. These regions carry all four naming layers most visibly. A valley may have a geographic name (the original description), a claiming name (whoever conquered it and built the administrative hub), a functional name (what it produces), and a common name (what the people who live in it call it, which may be the pre-conquest name). For generation: these regions should almost always have `DualNaming` when `TerritorialStatus` is `CoreTerritory` or `ContestZone` and the region has a population above a threshold.
**Administrative regions/provinces:** Almost always `Commemorative` naming at founding. The most politically volatile names — new authorities rename provinces as a political act. For generation: administrative region names should be `Commemorative` register, with `common_name` carrying the pre-renaming folk/geographic form when the change is recent enough to generate resistance.
**Roads:** Follow the `MaintenanceAuthority` and naming register rules:
- `Administrative` roads: `Commemorative` register for named highways (named after political figures); numbered for secondary roads (authority imposed a number system)
- `Corporate` roads: corporation name as prefix; functional or numbered suffix
- `Communal`/`Trade` roads: `Functional` register; sometimes `Folk` for the most-used routes
- `Abandoned` roads: retain their last naming layer, frozen at the time of abandonment — which is itself a temporal signal to the observant player
---
## 6. Connecting Regional Signals to District Generation
The fields proposed here propagate downward. This is how Phase 3 inputs drive Phase 5 (district generation):
```
TerritorialStatus
→ affects road quality standards the generator applies
→ affects settlement placement density
→ informs naming layer conflict (DualNaming)
FoundingOrientation (city-level)
→ determines which direction is "oldest" for city decomposition
→ district prosperity index gradient runs from founding orientation outward
→ oldest districts more likely Organic layout, lower prosperity
PoliticalTether (sub-city)
→ determines road maintenance class for connections
→ informs whether sub-city settlement generates ruins or remains active
→ informs naming register of sub-city settlement name
DualNaming (regional/road/city)
→ propagates to district names when TerritorialStatus = ContestZone
→ district naming register shifts toward Folk/Geographic when the city's official
naming is in tension with common use
```
The key principle: **regional political signals are inputs to district-level generation, not independent outputs.** If a city is in a `ContestZone`, its districts should reflect that — higher `perimeter_treatment` variance, more contested naming, higher likelihood of Organic layout even in theoretically-planned districts. The generator doesn't need to be told twice. It reads from the regional data and produces district data that is consistent with it.
---
## 7. Proposed New Fields Summary
| Field | Applies to | Type | Derivation | Authoring needed? |
|-------|-----------|------|------------|-------------------|
| `TerritorialStatus` | Region/zone | Enum (6 values) | Distance to city × WorldTier + econ data | Sanity check for named planets |
| `FoundingOrientation` | City | Enum (5 values) | `settlement_pattern` + `economic_role` | Sanity check for named cities |
| `MaintenanceAuthority` | Road segment | Enum (5 values) | Road endpoint types + econ data | None |
| `PoliticalTether` | Sub-city settlement | Struct (city_id + relationship) | Distance + econ data | None |
| `RegionalFeatureName.official_name` | Region/road/feature | String | Generator | Named features authored |
| `RegionalFeatureName.common_name` | Region/road/feature | Option\<String\> | From historical naming conflict conditions | None for generated; authored for named |
All six are derivable from data that already exists or is being proposed. None require large authoring tasks. Named planets/cities need a sanity pass.
---
## 8. Open Questions for Round 3
| ID | Question | Priority |
|----|----------|----------|
| OQ-R3-P1 | `TerritorialStatus` derivation: is the formula `distance to nearest city × WorldTier` sufficient, or does it require additional parameters from systems.db? Does Burnelli-Sheldon have economic inputs that should feed this? | HIGH |
| OQ-R3-P2 | `FoundingOrientation` and `PoliticalTether` — are these stored in atlas/markers.json, or derived at runtime from systems.db? If stored: who authors the sanity-check pass for named cities, and what does that pass require? | HIGH |
| OQ-R3-P3 | `DualNaming`: is `common_name` a generator output (seeded based on territorial conflict conditions), or an authored layer only for named locations? If generator output, what triggers its population? | MEDIUM |
| OQ-R3-P4 | Road segments are currently implicit (markers.json has road topology but not road-segment-level fields). Does adding `MaintenanceAuthority` require a schema change to markers.json road data? Tyre's input needed. | MEDIUM |
| OQ-R3-P5 | `AbandonedZone` settlements as ruins: the generator knows what the site was (from historical tether data), but does it know *why* it was abandoned? Is that derivable from economic data (corporation left → corporate-tethered settlements abandoned), or does it require a separate authored flag? | LOW |
| OQ-R3-P6 | Phase 3 data storage: do `TerritorialStatus` zones get stored as polygon/voronoi regions in a new table, or as tags on city markers? A region between cities is not currently a first-class object in the data model. | HIGH |
---
## 9. Cross-Referencing Accepted Round 2 Proposals
For completeness: the six fields above are *additions* to the Round 2 locked proposals. The relationship is:
- `prosperity_index` (Round 2) receives its city-level baseline from the city's position within its `TerritorialStatus` zone. A city in `ExtractiveZone` has different prosperity baseline distribution than one in `CoreTerritory`.
- `perimeter_treatment` (Round 2) at district level is informed by the city-level `PoliticalTether` relationships — a city surrounded by `Corporate`-tethered sub-settlements has a different perimeter treatment profile than one surrounded by `Administered` towns.
- `political_archetype` (Round 2) is now grounded in city `FoundingOrientation` — the archetype shapes district distribution, and `FoundingOrientation` shapes the spatial direction that distribution runs.
- Naming registers (Round 2) are now the bottom tier of a three-tier naming system: regional naming strata (§5) → city-level `political_archetype` naming → district-level naming register.
---
*Paula — Round 3. Written 2026-04-30.*
@@ -0,0 +1,308 @@
---
title: "Paula — Round 4: Narrative Requirements for the Planet-Down Cascade"
description: "Narrative/political signals at each cascade layer; TerritorialStatus as Layer 2 output; accepted proposals remapped; determinism rule as narrative feature"
type: workshop
status: active
workshop: generation-cascade
agent: paula
round: 4
created: 2026-04-30
---
# Generation Cascade — Round 4 (Paula)
**Focus:** Domain requirements for the next workshop brief. Four cascade layers (planet-down), narrative/political lens. What each layer must produce for the world to be politically legible. Where accepted proposals sit in the new ordering. What the determinism rule means for narrative.
**Not revisited:** prosperity_index, perimeter_treatment, political_archetype, naming registers, sub-settlement hierarchy, MaintenanceAuthority — all carried forward. This document maps them to the new layer sequence.
---
## The Reframe: Geography Is Political History
The planet-down cascade gets the causality right. Cities don't create geography. Geography creates cities — and then political structures grow to control what geography made valuable.
The narrative consequence is significant. In the old city-outward framing, we had to work backwards from settlement position to infer what geographic logic placed it there. In the planet-down framing, the geographic logic comes first. We don't have to infer why a city exists at a river confluence — the river confluence was tagged at Layer 1, the settlement was placed at Layer 2 because of that tag, and the settlement's character is immediately legible from its founding condition.
This is a better narrative model because it matches how civilizations actually work. The political power of any settlement derives from what geographic advantage it controls. A city at a mountain pass doesn't just happen to have checkpoint-heavy districts — it was placed there because controlling the pass was the point. The generator doesn't need to be told that a pass city is administratively oriented; the geography tells it.
The four layers below address what each layer must produce for this to work.
---
## Layer 1: Empty World — Tagging Geographic Potential
**What the layer does:** Heightmap → water simulation (rivers, lakes, coastlines) → sub-biome refinement.
**Narrative position:** This layer produces no political signals — civilization hasn't arrived yet. What it produces is **geographic potential**: the affordances that will determine where civilization concentrates and what political structures that concentration supports.
### What Layer 1 must tag for Layer 2 to work
The settlement placement algorithm at Layer 2 cannot derive settlement character from raw heightmap data efficiently. Layer 1 should produce a tagged feature layer alongside the terrain output — geographic features that will become politically meaningful once inhabited.
**Mandatory tags for narrative-legible settlement generation:**
| Tag | What it marks | Political potential |
|-----|--------------|---------------------|
| `RiverConfluence` | Where two navigable rivers meet | Trading nexus; FreePort or AdminCapital archetype |
| `CoastalHarbor` | Natural harbor, protected anchorage | Maritime commerce; FreePort archetype |
| `MountainPass` | Lowest crossing point in a range | Checkpoint/control; AdminCapital or Contested |
| `ArablePlain` | Flat land with soil quality above threshold | Agricultural settlement; dependent on nearest nexus |
| `ResourceConcentration` | Mineral outcrop, energy source, rare material | Extraction; CompanyTown archetype |
| `Defensible` | Elevated with sightlines, natural chokepoint | Military/administrative; AdminCapital |
| `NaturalBarrier` | Range, wide river, dense terrain — impassable without engineering | Future political boundary |
These tags are the layer's narrative output. They're the geographic skeleton onto which all political structure will attach.
### What Layer 1 does not produce
Layer 1 does not name anything. Rivers, mountains, and passes exist at Layer 1 but have no names. Names are attached at Layer 2 when settlement begins. This is the correct order — geographic names are the oldest naming layer precisely because the terrain existed before anyone arrived to name it. The generator should preserve this layering: names are first assigned at the point when the settlement placement algorithm acknowledges a feature as settleable or traversable.
The one exception: major terrain features that are large enough to be visible on the planetary atlas (significant mountain ranges, major river systems, ocean boundaries) should receive a geographic placeholder name at Layer 1 so that the atlas UI can label them. These are geographic register names — purely descriptive, pre-political.
---
## Layer 2: Population Overlay — Civilization's Shape Emerges
**What the layer does:** Economics onto geography. Settlements placed at geographic nexus points. Forests felled for farmland. Hills flattened for towns. Road and rail networks connecting settlements.
**Narrative position:** This is the most politically loaded layer. Here the relationship between geography and power is established. Every decision the placement algorithm makes — where to put a settlement, what kind of settlement, how to connect it — produces a political fact that will still be legible at street level.
### Geographic rationale → settlement character
In the planet-down cascade, settlement character is directly derivable from the geographic tag that caused the settlement to exist:
| Geographic trigger | Settlement character | Political archetype lean |
|-------------------|---------------------|--------------------------|
| `RiverConfluence` | Trading city; port infrastructure | FreePort or AdminCapital |
| `CoastalHarbor` | Maritime hub | FreePort |
| `MountainPass` | Checkpoint city; controls access | AdminCapital; Contested if two powers compete |
| `ArablePlain` | Agricultural town or village cluster | EconomicallyDependent on nearest nexus |
| `ResourceConcentration` | Corporate/extraction town | CompanyTown |
| `Defensible` | Administrative or military center | AdminCapital |
| No geographic trigger | Settlement exists because someone decided it should | OrganicGrowth — the most politically ambiguous case |
The last case is the most interesting narratively. A settlement with no geographic rationale — placed in flat, resource-poor, non-nexus territory — exists because of a political decision that overrode geographic logic. Imperial projects, religious sites, symbolic capitals placed away from natural advantages. These are always the most politically contested settlements in real history, and they should be in the generator too.
### TerritorialStatus as Layer 2 output
In Round 3 I proposed TerritorialStatus as a regional control field. The lead flagged this as "wrong axis" when thinking city-outward. In the planet-down cascade, TerritorialStatus is correct — but as an **output of Layer 2**, not an input.
Once settlement placement and road network generation are complete, the space between settlements has a territorial character that falls directly out of:
- Settlement density (how many settlements per area unit)
- Road quality connecting those settlements (who invested in connection)
- Economic control data (which corporations are active)
- Distance and WorldTier of the nearest major settlement
The six TerritorialStatus values aren't assigned before Layer 2 runs — they're DERIVED after it completes:
- **`CoreTerritory`:** High settlement density + road network well-maintained throughout. Someone has built and maintained the infrastructure between settlements. Power is present and exercised.
- **`FrontierTerritory`:** Settlement placed, road connecting it exists, but road quality degrades at distance from the major city. Administration exists in principle; infrastructure thins at the edges.
- **`ExtractiveZone`:** Settlement placed at `ResourceConcentration`. Corporate presence flagged in systems.db. Road network excellent along the extraction corridor; absent or poor off it. The infrastructure tells you exactly what the controlling authority cares about.
- **`ContestZone`:** Two settlement clusters with competing road networks in overlapping territory. Geographic trigger is present but two political actors responded to it. Roads sometimes duplicated, sometimes dead at contested boundaries.
- **`WildernessBuffer`:** No settlement placed — the placement algorithm found no geographic trigger or economics rationale. Road does not cross. Not absence of interest; absence of occasion.
- **`AbandonedZone`:** Settlement was placed (geographic rationale existed at generation time), but economic simulation has flagged the rationale as exhausted. Resource depleted, corporation withdrawn, route obsoleted. The settlement's physical footprint remains; its economic basis has gone.
This resolves the ambiguity from Round 3's OQ-R3-1 (TerritorialStatus scope debate). TerritorialStatus doesn't need its own dedicated layer — it is a **post-processing output of the settlement placement and road generation steps** that already exist in Layer 2. It costs one classification pass after settlement placement completes, not a separate layer.
### Road networks: the infrastructure of political will
Roads in the planet-down cascade are not authored — they emerge from the connections the placement algorithm makes. But which settlements get connected at what quality reflects political priorities:
- The placement algorithm connects settlements based on economic flow (systems.db trade data) and administrative hierarchy (systems.db `economic_role`)
- Road quality is set at generation time based on the connection's economic value and the WorldTier of the endpoints
- `MaintenanceAuthority` falls directly out of the endpoint relationship:
- Corporate extraction site → corporate depot: `Corporate`
- Administrative capital → administered sub-settlement: `Administrative`
- Settlement cluster with trade flow but no administrative relationship: `Trade`
- Settlement → settlement with no economic tie and no administrative relationship: `Communal`
- Connection that existed at generation time but whose rationale has since dissolved: `Abandoned`
`MaintenanceAuthority` is derivable at road generation time. It does not require a separate authored pass.
### Sub-settlements: geographic inevitabilities
Sub-settlements (outposts, waypoints, rural nodes) emerge from Layer 2 geography, not from proximity to cities. The geographic logic makes them inevitable:
- A mountain pass will have a waypoint — passes always acquire service infrastructure
- A long road without settlements will have relay stations — travel demands it
- A river crossing will attract a settlement — crossings are traffic bottlenecks, and traffic bottlenecks attract commerce
- An agricultural cluster below city-threshold population produces a rural node — food production always aggregates to a spatial locus
`PoliticalTether` is then derivable from why the sub-settlement exists: what geographic feature caused its placement, and which city controls that feature. A waypoint at a pass controlled by an AdminCapital city is `Administered`. A waypoint at a pass that two cities both claim is `Contested`. A mining camp at a corporate extraction site is `Corporate`. The relationship emerges from the geographic trigger and the political control data — it doesn't need to be authored.
### Naming at Layer 2
Settlement names are first assigned here. The naming register for a settlement derives from its geographic trigger:
- `RiverConfluence` → Geographic register (the confluence is named before the city)
- `ResourceConcentration` + corporate presence → Functional register (the resource is the name)
- `AdminCapital` archetype → Commemorative register (the founder gets to name the capital)
- `ArablePlain` agricultural cluster → Geographic or Folk register (farmers named for what they see)
DualNaming conditions are set at Layer 2 wherever `ContestZone` territory exists — the naming conflict is a consequence of the territorial conflict, and both emerge from the same placement logic.
Major geographic features (rivers, mountain ranges) that were given placeholder names at Layer 1 receive their first human-assigned names here, at the moment settlements begin to use them. The Layer 1 geographic placeholder may persist as the common name while the Layer 2 administrative name becomes official — this is the origin of most DualNaming conditions.
---
## Layer 3: City-Level Planning — Political Archetype Becomes Spatial
**What the layer does:** Districts, suburbs, industrial zones, commercial areas, ports, stations, highways. Topography + economics + cultural parameters.
**Narrative position:** This is where the geographic rationale from Layer 2 becomes visible in city structure. The city knows what it was built for (FoundingOrientation, derived from the geographic trigger that placed it). That purpose shapes every spatial decision.
### FoundingOrientation is geography, not authoring
In the planet-down cascade, `FoundingOrientation` is not a derived field requiring a sanity-check pass on named cities. It is **directly readable from the geographic trigger**:
| Layer 2 trigger | FoundingOrientation |
|-----------------|---------------------|
| `CoastalHarbor` or `RiverConfluence` with port | `PortFacing` |
| Transit/rail junction | `RailHeadFacing` |
| `ResourceConcentration` | `ResourceFacing` |
| `Defensible` terrain | `DefenseFacing` |
| No geographic trigger (AdminCapital placed by political decision) | `AdminFacing` |
The generator reads the geographic tag that caused this settlement to exist and produces the appropriate orientation. No authoring, no sanity pass except for named cities where the setting lore explicitly overrides the geographic logic.
### How political_archetype shapes district layout direction
The combination of `FoundingOrientation` + `political_archetype` determines which direction the city grows and which end is oldest:
- `PortFacing` + `FreePort`: City grew away from the harbor (older densely packed districts near the water; newer more spacious development inland). The harbor is the source; the rest of the city is infrastructure for handling what the harbor produces.
- `ResourceFacing` + `CompanyTown`: City is a spine, not a circle. The resource is at one end; everything else is arranged in service of access to it. There's no civic center — the city has no need of one.
- `DefenseFacing` + `AdminCapital`: City radiates from the fortified high point. Administrative districts are adjacent to the fortification; residential quality grades outward from that center.
- No geographic trigger + `OrganicGrowth`: City grew around multiple small centers of gravity. No single direction is "oldest." The prosperity gradient is local, not city-wide.
The growth direction tells the generator which end of the city gets older, more organic, lower-prosperity districts, and which end gets the planned, newer, higher-prosperity development. This is the district prosperity gradient — it runs from founding end toward expansion end, with the oldest districts closest to whatever the city was built around.
### Naming at Layer 3
Districts receive names at Layer 3. The naming register follows the lookup table (Commemorative / Functional / Aspirational / Folk / Geographic by DistrictType + WorldTier).
The city's `political_archetype` adds a modifier:
- `CompanyTown` cities: district names lean Functional or Corporate (the company names things for what they do)
- `AdminCapital` cities: district names lean Commemorative (administrative power names things for its history)
- `FreePort` cities: district names lean Functional or Aspirational (commerce names things for what they offer)
- `Contested` cities: district naming is where DualNaming most visibly proliferates — each faction named the districts they control; the contested middle zone may have three names
---
## Layer 4: Street-Level Rendering — Political History Made Visible
**What the layer does:** Streets, buildings, civilization tiles.
**Narrative position:** This is where all upstream context is consumed to produce what the player actually sees. The narrative signals that have been building since Layer 1 become tactile — visible in tile state, building condition, perimeter markers, street width, landmark footprints.
### What the player reads at street level
From Layer 1: The city exists here because a river meets a coast, or because the pass is here. Even at street level, you can see the geographic rationale — the harbor is at one end of the main boulevard, the mountain wall is at the other end of the pass city.
From Layer 2: The territorial character of the region is visible in road quality at the city's approach. An `ExtractiveZone` city has excellent road infrastructure on the corporate corridor and nothing on the secondary routes. A `ContestZone` city has checkpoint infrastructure that doesn't fully match any one political actor's logic — overlapping, redundant, or dead.
From Layer 3: The district layout tells you what the city was planned to be. The grid on the administrative side is deliberate. The organic tangle near the old harbor is what grew before anyone planned it. The street hierarchy (boulevard → secondary → alley) is only present in districts that had investment — where it's absent, you're in a district that the planning authority didn't think was worth a hierarchy.
From Layer 4: The current economic state shows in tile condition. Prosperity_index drives decay probability. Perimeter treatment tiles at district edges show who controls entry. Building condition reflects current economic health, not the intended plan.
The narrative emerges from all four layers simultaneously. The player reads: intended geography → what was planned → what the sim currently says → what they see on the ground.
---
## The Determinism Rule: Narrative Consequences
The lead's critical rule: **economic sim affects rendering (prosperity/repair), NOT layout (streets locked by seed)**.
This is the most powerful narrative mechanism in the cascade. Let me be explicit about what it means.
### The ghost city effect
Streets are locked at generation time. They carry the intended political geography of the district — the street grid that was planned when the city was laid out. The economic simulation can change tile state, building condition, and prosperity_index (or a prosperity_delta applied to the seed-derived baseline), but it cannot move streets or erase landmark footprints.
The consequence: **a district's current state and its intended state are always both visible**.
A CompanyTown where the corporation has departed still has its company-town street vocabulary: wide factory roads built for heavy vehicles, worker housing in tight grids sized for maximum occupancy, no civic center because the company never built one. The streets broadcast what this district was planned to be. The tiles broadcast that no one is maintaining that plan anymore.
A once-affluent residential district that has declined still has its wide boulevards and generous setbacks — the spatial signature of wealth — even as tile decay tells you that the resources to maintain that plan have left. The player reads the gap: someone planned this for people with more than they needed. They're gone.
A Contested city that resolves in favor of one faction still has both factions' street-scale infrastructure. The streets the losing faction built persist. Their checkpoints are unmanned or controlled by the other faction, but their footprints remain. History is visible in structure even after political change.
### Prosperity_delta, not prosperity replacement
For this to work correctly, the economic sim must communicate with the tile generator through a **delta**, not a replacement value:
- **Seed-derived `prosperity_index`:** What the district was planned as. Set at Layer 3 from WorldTier + economic data. Locked.
- **Sim-derived `prosperity_delta`:** What the current economic state says about this district relative to what it was planned as. Can be positive or negative. Updated by the economic simulation.
- **Effective prosperity:** `prosperity_index + prosperity_delta` (clamped to [0.0, 1.0]) drives tile selection.
The seed-derived prosperity_index is what the street geometry was planned for. The effective prosperity is what the tiles show. When they differ significantly, the player is looking at the ghost city effect — the story of what happened between the plan and now.
This is not a technical refinement; it's a narrative requirement. If prosperity_index is just a sim-updated value with no seed-derived baseline, the ghost city effect disappears. The streets would then be random relative to the tile state, and the most powerful narrative signal in the game would be lost.
### Ruins as structural permanence
The determinism rule also applies to ruins (AbandonedZone sub-settlements). When the economic sim flags a settlement for ruination — resource depleted, corporation departed, route obsoleted — the settlement's physical structure remains. The streets are still there. The building footprints are still there. What changes is:
- Prosperity_delta drops sharply (or drops all the way to floor)
- Perimeter treatment degrades (Checkpoint → Fenced → Open over time, as maintenance ends)
- Landmark condition deteriorates
The ruin is a ghost city at settlement scale. The geography tagged it as worth settling. Someone built here. Now no one maintains it. The gap between the structure (still present) and the tile state (decayed) is the entire story of what happened, without a word of dialogue or UI explanation.
---
## Accepted Proposals: Remapped to the Planet-Down Cascade
| Proposal | Origin | New cascade position |
|----------|--------|---------------------|
| `prosperity_index: f32` on DistrictSkeleton | Round 2 | **Layer 3 output** (seed-derived baseline from WorldTier + geographic context); **economic sim** produces `prosperity_delta` at runtime |
| `perimeter_treatment: PerimeterTreatment` | Round 2 | **Layer 3 output** (derived from DistrictType + WorldTier + prosperity_index at generation time); **economic sim** can degrade it at runtime |
| `political_archetype` (derived + override) | Round 2 | **Layer 2 output** (derived from geographic trigger + economic data; override slot for named cities) |
| Naming registers (lookup table) | Round 2 | **Layers 2–4**: Geographic register at Layer 2, Functional/Commemorative at Layer 3, street-level naming at Layer 4 |
| Sub-settlement hierarchy | Round 3 | **Layer 2 output** (settlements emerge from geographic triggers; PoliticalTether derived from why they exist) |
| `MaintenanceAuthority` on roads | Round 3 | **Layer 2 output** (derived from road endpoint relationships; no separate pass required) |
| `TerritorialStatus` | Round 3 | **Layer 2 post-processing output** (derived from settlement density + road quality + economic control after placement completes; not a separate layer) |
| `FoundingOrientation` | Round 3 | **Layer 2 → Layer 3 handoff field** (directly readable from the geographic trigger that placed the settlement; no authoring required) |
| `DualNaming` (`official_name`/`common_name`) | Round 3 | **Layer 2 initialization** (ContestZone conditions set here); **Layer 3 proliferation** (factions name districts differently); **Layer 4 display** (streets have working names vs. official names) |
---
## Narrative Questions That Need Answering at Each Layer
These are questions the brief should surface, not questions I'm answering here.
**Layer 1:**
- Does the empty world generator produce a tagged feature layer alongside terrain output, or does Layer 2 have to re-derive geographic potential from raw heightmap data? The tagged layer approach is cleaner but requires a Layer 1 schema change.
- How are major geographic features (significant river systems, mountain ranges) identified for atlas-visible labeling vs. local features not worth labeling at atlas scale?
**Layer 2:**
- What triggers an OrganicGrowth settlement (no geographic trigger) vs. a settlement the algorithm decides not to place? Algorithmically they're both "no trigger found" — but narratively they're different (one is a settlement that exists anyway; the other is empty territory). Is there a way to distinguish them in the data model?
- How does the economic sim know when to flag a settlement for AbandonedZone status? Is this a threshold on economic flow below a minimum, or does it require an explicit event (corporation withdraws)?
- Sub-settlements that exist because of geographic inevitabilities (mountain pass waypoints, river crossing settlements) — do they get placed by the same algorithm as city settlements, or do they need a separate sub-settlement placement pass?
**Layer 3:**
- The prosperity gradient runs from founding end toward expansion end. How steep is the gradient? Does a PortFacing FreePort city have linear prosperity decline from harbor to inland, or is it more complex (harbor districts: commercial prosperity high; adjacent residential: working class; distant new development: new wealth)?
- For Contested cities, how is the spatial position of the contested boundary established? It derives from which factions control what geographic features — but the boundary itself needs to be a deterministic geometric feature that Phase 5 can use to guide district layout.
**Layer 4:**
- How does `prosperity_delta` get communicated from the economic sim to the tile generator? Is it a per-district value stored in the save file alongside ChunkMutations, or is it derived at chunk-load time from current sim state?
- When perimeter treatment degrades over time (AbandonedZone maintenance ending), is that a sim-driven tile state change or does it require removing perimeter marker tiles? The former is simpler; the latter is more visually correct but touches locked structure.
---
## What the Brief Needs to Specify
For the planet-down cascade to produce politically legible worlds, the brief must lock:
1. **Layer 1 tagged feature output** — does the empty world generator produce a geographic feature tag layer, or is that re-derived at Layer 2?
2. **TerritorialStatus as Layer 2 post-processing** — confirm it's derived after settlement placement, not a separately authored or layered concept. This resolves OQ-R3-1 cleanly.
3. **Prosperity_delta architecture** — seed-derived `prosperity_index` is the locked baseline; economic sim produces a runtime `prosperity_delta`. This must be a named architectural decision before Layer 3 or Phase 5 are implemented, because it determines whether the ghost city effect is possible at all.
4. **Named cities exception scope** — the planet-down cascade makes most authoring unnecessary by deriving everything from geography + economics. The brief should specify what named cities still require: `political_archetype_override`, `prosperity_override`, and landmark names for major cities. That scope is small. Everything else should be derived.
5. **AbandonedZone lifecycle** — who flags a settlement for ruination? The economic sim, or the lead authoring specific events? The former enables emergent history; the latter enables authored history. Probably both, with the sim as default and authored events as override.
---
*Paula — Round 4. Written 2026-04-30.*
@@ -0,0 +1,320 @@
---
title: "Round 1 Notes — Generation Cascade Workshop"
description: "Compiled Round 1 inventory: consensus, disagreements, gaps, open questions, and consolidated pipeline layer table"
type: workshop
status: active
workshop: generation-cascade
agent: qatux
round: 1
created: 2026-04-30
---
# Generation Cascade Workshop — Round 1 Notes
**Compiled by:** Qatux
**Source files:**
- `docs/workshops/generation-cascade/gestalt-round1.md`
- `docs/workshops/generation-cascade/tyre-round1.md`
- `docs/workshops/generation-cascade/miri-round1.md`
---
## Summary Verdict (All Three Agree)
The pipeline has a hard operational cliff at Layer 5 (atlas markers). Every layer above it is working production code. Every layer below it is either missing entirely or compiled stub types with no generation logic. The architecture is well-designed on paper; zero generation code exists. This is not a design workshop — it is an implementation audit with clear blockers.
---
## Consensus Points
### 1. Pipeline cliff location
All three participants independently arrived at the same finding: the atlas pipeline (galactic definitions → planetary heightmaps → city placement → city naming → economics) is complete and working. The cliff is between the atlas output and the first runtime layer (city → district decomposition). Everything below is unimplemented.
- **Gestalt:** "The atlas is the last fully-working pipeline layer."
- **Tyre:** "The pipeline has a hard cliff at Layer 5. Everything above it is working production code."
- **Miri:** (concurs, layered audit confirms all atlas layers as DONE)
### 2. WorldTier enum mismatch — hard blocker
Both Gestalt and Tyre independently flagged the same bug:
```rust
// Current code (generator.rs):
enum WorldTier { Peripheral, Connected, Core }
// Workshop-locked values (lead decision L-3, workshop-outcomes.md):
enum WorldTier { Epicenter, Regional, Backwater, Passage, Waypoint }
```
- **Gestalt:** "This is a blocker. #899 cannot be implemented correctly against the current enum."
- **Tyre:** "These are incompatible. The workshop canonical values must replace the current enum before the generation spike begins."
Miri did not independently flag this bug (worldbuilder scope did not include code audit of enum values) but it is corroborated by both technical participants.
### 3. City → District decomposition is missing and has no ticket
All three participants confirmed this layer is entirely absent — no design, no code, no types, no ticket. It is the first unlock for all downstream work.
- **Gestalt:** Layer 6 in her inventory. "No ticket formally covers this. #899 assumes this decomposition as a prerequisite but does not scope it."
- **Tyre:** Layer 5 in his inventory. "This is the first gap in the entire pipeline... The `DistrictContext` type on `DistrictSkeleton` is `pub type DistrictContext = String`."
- **Miri:** Confirms the city marker schema is insufficient for Phase 1 input (see Gap 3 below).
### 4. Phase 1 DistrictSkeleton — types compile, zero generation logic
All three confirmed the `DistrictSkeleton` struct compiles but every field requiring actual content or logic is a `pub type Foo = String` alias.
**Fields confirmed as String stubs (Tyre inventory):** `DistrictContext`, `SocietyProfileRef`, `ZoneDefinition`, `DistrictBoundaries`, `GuaranteeAuditResult`, `CorridorSpine`, `ChunkLayout`, `Era`, `EraModification`, `LandmarkSlot`, `AccessPoint`.
### 5. Phase 2 ChunkData tile generation — entirely missing
All three confirmed Phase 2 does not exist. `GeneratorChunkData = Vec<bool>`. No tile vocabulary (`TileId = String`). No code to fill a 64×64 tile grid from a `BlockSkeleton`.
- **Tyre:** "The heritage grammar overlay system does not exist as Rust code or TOML data."
- **Gestalt:** "There is no pipeline from DistrictSkeleton → actual walkable tile data."
### 6. Triangle system is more complete than the brief indicated
Both Gestalt and Tyre noted that the workshop brief labels `triangle.rs` as "stubs" — this is inaccurate. The file is substantially implemented production code.
- **Gestalt:** "The triangle system (`server/src/simulation/triangle.rs`) is substantially implemented — template schemas, triangle escalation tick system, NPC spawn patterns. Intra-template (#107) and cross-template (#108) triangle generation are both done."
- **Tyre:** "Layer 9 — Social Site Templates and Triangle System: DONE (runtime system, not wired to generator output)."
The gap is not the triangle system itself but the connection from `DistrictSkeleton.social_sites` template tags to runtime `TemplateOwnership` component spawning.
### 7. chunk_streaming.rs — architecturally ready, generator hookup is zero
Both technical participants confirmed the streaming system is sound and tested. The gap is purely that `WalkabilityMap::load_chunk()` creates blank walkable placeholders rather than calling any generator.
- **Tyre:** Architecture is sound. `load_chunk()` needs steps 1–4 (chunk → district lookup → skeleton → Phase 2 fill) added. None exist.
- **Gestalt:** "The system is ready for the generator hookup; it just needs the generator to exist."
### 8. #619 and #694 are not generation-dependent
Both Gestalt and Tyre independently reached the same position: character customization (#619) and character creation screen (#694) are Phase 4 work (Player Control Scheme, hand-authored 2-floor test map) and should not be formally blocked behind the generation pipeline.
- **Gestalt:** "These should NOT be formally blocked behind generation pipeline work. The player needs to look like something before the world needs to be generated."
- **Tyre:** "#619 and #694 can proceed... Phase 4, same rationale."
Miri did not address these tickets but noted "#694 Character creation screen — no generation pipeline dependency flagged."
### 9. #681 needs a stronger generation blocker
All three participants agreed (directly or by implication) that #681 (Apartment generator) requires a formal blocker behind Phase 1 district skeleton generation.
- **Gestalt:** "add formal blocker on #681 behind Phase 1 district skeleton working."
- **Tyre:** "#681 — Needs walkable generated world (Layers 5-8 complete). Should be explicitly blocked."
- **Miri:** Notes that #681 can produce a culturally-voiced apartment without heritage grammar, but a spatially differentiated apartment requires HeritageGrammarOverlay (Gap 2). Proposes two-pass sequencing: first pass against culture RON, second pass when overlay data exists.
### 10. NPC generation spike exists but is not production code
Tyre found `server/src/bin/generator_spike.rs` (Sprint 25) — a standalone binary that generates NPCs from `ZoneSpec + CultureProfile`. It is not wired to ECS or to the spatial pipeline.
- **Tyre:** "It is a standalone binary that bypasses ECS entirely... Its output is printed to stdout, not spawned into the ECS world."
- **Gestalt:** Did not independently identify the spike binary, but notes the NPC system as a "wiring problem, not a design or implementation gap."
---
## Disagreements
### Disagreement 1: Dev-day estimates for thinnest vertical slice
| Participant | Estimate | Breakdown |
|-------------|----------|-----------|
| **Tyre** | ~9 dev-days | WorldTier fix (0.5) + city decomp (2) + Phase 1 stages 1-2 (3) + missing struct fields (0.5) + Phase 2 minimal (2) + streaming wiring (1) |
| **Gestalt** | ~5–7 dev-days | WorldTier fix (0.5) + Layer 6 stub (0.5) + Phase 1 minimal (2) + Phase 2 minimal (2) + wiring (1) |
**Source of discrepancy:** Tyre's Phase 1 estimate (3 days) cites the workshop-outcomes.md estimate directly. Gestalt's Phase 1 estimate (2 days) uses a "minimal" scope that defers social sites, reservations, and guarantee audit entirely. Tyre also includes a separate line item for missing DistrictSkeleton fields that Gestalt folds into Phase 1. Neither estimate includes heritage grammar, NPC population, or guarantee audit — both agree those are outside the minimum slice.
No position has been locked. The discrepancy is ~2–4 days and will need resolution in Round 2.
### Disagreement 2: Whether #615 (Tycoon starting state) is generation-dependent
| Participant | Position |
|-------------|----------|
| **Tyre** | #615 should be blocked behind generation pipeline completion. "#615 Tycoon small business starting state — Needs walkable generated world." |
| **Gestalt** | #615 is content design, not generation-dependent. "Game content design, not generation-dependent." It feeds #681 but is not itself blocked by generation work. |
Miri did not take a position on #615's cascade placement.
**Note:** Both agree that #615 is a real dependency *of* #681 (apartment generator). The disagreement is whether #615 should itself be blocked behind generation pipeline completion, or whether it can proceed in parallel as content design work.
---
## Gaps — Items Raised by Fewer Than All Three Participants
### Gap A: Content gaps raised by Miri alone (not addressed by Gestalt or Tyre)
Miri audited the *content inputs* required by each pipeline layer. These gaps are orthogonal to the code gaps Gestalt and Tyre identified. All four are blocking at their respective layers.
**Gap A1 — Heritage root blend specification (CRITICAL, blocks Phase 1 social stage)**
No authored document establishes which heritage root blend applies to any system. The `SocietyProfileRef` stub cannot be resolved without this mapping.
- Proposed solution (Miri): Extend culture RON schema with `heritage_blend` field, or author a global `heritage-map.ron`. Provides Van Maanen's Star estimate: Iron(0.4), Stone(0.35), Tide(0.25).
- Existing `culture-van-maanens-star.ron` provides NPC voice/behavior data but does NOT include heritage root blend weights.
**Gap A2 — HeritageGrammarOverlay data files (CRITICAL, blocks Phase 2)**
The full specification for all 10 heritage roots exists in `miri-round4.md` §OQ-R4-D. No `heritage_grammar_overlay/*.ron` files have been authored. No `HeritageRoot` enum exists in `generator.rs`. No `ObjectTag` vocabulary exists.
- **Miri:** "This is the largest cultural content gap in the pipeline." The specification is complete; the work is translation from workshop spec to authored data.
- **Content authoring path:** Miri authors organizational/social principles → Araminta authors visual expression → both co-maintain `ObjectTag` vocabulary.
**Gap A3 — City marker schema insufficient for Phase 1 (MODERATE)**
Current markers.json city entries contain: `id`, `name`, `kind`, `center`, `population`. Ticket #899 references `primary_function`, `planet_class`, `settlement_pattern` as Phase 1 inputs — these fields do not exist.
- Tyre identified the missing coordinate translation (atlas pixel → sim tile) as a separate problem but did not raise the functional field gap.
- Miri raised two possible resolutions: (a) extend markers.json schema, or (b) derive fields from systems.db cross-reference. Designates this as a joint Miri/Tyre question.
**Gap A4 — Zone identity definitions for all DistrictType values (MODERATE)**
`ZoneDefinition = String` stub. The `DistrictType` enum has 9 values. Only 2 Van Maanen's Star zone files exist (`van-maanens-star-rural-zone.ron`, `van-maanens-star-industrial-zone.ron`). No coverage for LogisticsHub, Administrative, Entertainment, MixedUse, Transit, Specialized.
### Gap B: Atlas coordinate system translation (Tyre only)
Tyre identified a specific unresolved technical gap not mentioned by Gestalt or Miri:
> "Atlas coordinates are (row, col) in a 512×256 pixel grid. A district is 512×512 sim tiles = 256×256 visual tiles = 256m. The ratio between atlas pixels and sim tiles is undefined and unimplemented."
This is a prerequisite for the city → district decomposition layer. No translation formula or specification exists.
### Gap C: Missing DistrictSkeleton fields (Tyre only)
Tyre identified three fields present in the workshop spec (workshop-outcomes.md) that are absent from the current `DistrictSkeleton` struct in `generator.rs`:
- `vertical_structure: VerticalStructure` (D-READY-11)
- `breach_only_zones: Vec<ZoneId>` (Tier 2 guarantee audit)
- `derived_analysis: DerivedDistrictAnalysis` (Phase 1 computed, Miri/Gestalt source)
Gestalt did not independently identify these missing fields. Tyre estimates 0.5 dev-days to add them as stubs.
### Gap D: Van Maanen's Star naming ambiguity (Miri only)
Miri flagged a wiki documentation inconsistency: "Van Maanen's Star" is associated with two system entries (GJ-35/Vuurkloof — the real-astronomy white dwarf; GJ-280B/Sova — the K-type system with Station Sova). The culture RON file `culture-van-maanens-star.ron` is validated and correct. The naming ambiguity is a wiki gap, not a content error.
- **Miri's reading:** Vuurkloof is the founding culture; Station Sova is where that culture manifests in the current setting. A formal `wiki/cultural-groups/van-maanens-star.md` entry would close this.
- Neither Gestalt nor Tyre addressed this; it is a worldbuilding/documentation item.
### Gap E: Economics → generation bridge design (Gestalt only)
Gestalt identified the economics layer as rich and immediately usable as a district generation input, but no such bridge code exists. Proposed principle:
> "Economics drives district type; culture drives zone palette; both come from systems.db data that already exists."
Gestalt proposed Layer 6 (city → district decomposition) as the natural integration point. Neither Tyre nor Miri addressed this bridge design in their Round 1 files.
---
## Open Questions
All open questions raised across the three Round 1 files, attributed to source:
| ID | Question | Source | Priority |
|----|----------|--------|----------|
| OQ-R1-A | Heritage root count: 7 (Mellanie's culture authoring guide) vs. 10 (Miri Round 4). Which is canonical? Must be resolved before HeritageGrammarOverlay files can be authored. | Miri | **CRITICAL** |
| OQ-R1-B | `SocietyProfileRef` resolution: culture RON filename (e.g. `"van-maanens-star"`) or structured reference including heritage blend weights? Affects both the type definition and the culture RON schema. | Miri | **CRITICAL** |
| OQ-R1-C | City marker schema: does Phase 1 read `primary_function` / `planet_class` / `settlement_pattern` from markers.json entries (requires atlas generator update), or derive from systems.db cross-reference by position (requires lookup code)? | Miri + Tyre | **HIGH** |
| OQ-R1-D | Atlas coordinate translation: what is the mapping formula from atlas pixel coordinates (512×256 grid) to world sim-tile coordinates (512×512 per district)? No spec or code exists. | Tyre | **HIGH** |
| OQ-R1-E | Thinnest vertical slice effort: Tyre estimates ~9 dev-days; Gestalt estimates ~5–7. What is the minimum Phase 1 scope (does it include Stages 3–5, or Stages 1–2 only)? | Gestalt + Tyre | HIGH |
| OQ-R1-F | #615 (Tycoon starting state) cascade position: block behind generation pipeline (Tyre) vs. proceed as content design (Gestalt)? | Gestalt + Tyre | MEDIUM |
| OQ-R1-G | `wiki/cultural-groups/` population timing: author now alongside heritage content (Gaps A1-A2), or defer to Phase 3? | Miri | LOW |
| OQ-R1-H | Pre-workshop tickets (#100, #156, #160) were scoped before the confirmed architecture. Do they get re-scoped in this workshop or handled as separate post-workshop cleanup? | Gestalt | LOW |
---
## Consolidated Pipeline Layer Table
Unified numbering across all three participants' inventories. Where participants used different layer numbers, this table uses a canonical sequence.
| Layer | Name | Gestalt | Tyre | Miri | Unified Status |
|-------|------|---------|------|------|----------------|
| 1 | Galactic / system definitions | DONE | DONE | DONE | **DONE** |
| 2 | Planetary heightmap simulation | DONE | DONE | DONE | **DONE** |
| 3 | Atlas markers — city placement, infrastructure | DONE | DONE | DONE | **DONE** |
| 4 | City naming (Gemma) | DONE | DONE | DONE | **DONE** |
| 5 | Economics layer — supply chains, brands, trade | DONE | (embedded L1) | DONE | **DONE** |
| 6 | **City → District decomposition** | MISSING | MISSING | MISSING (schema gap) | **MISSING — CRITICAL. No ticket, no code, no design.** |
| 7 | Phase 1: DistrictSkeleton generation | STUB | STUB | STUB | **STUB — types compile, zero generation logic. WorldTier enum wrong.** |
| 7a | — Stage 1: Classification (WorldTier, ComplexityTier, SettingType) | STUB | STUB | no design rules authored | STUB |
| 7b | — Stage 2: Block grid (DistrictLayoutMode, BlockSkeleton ×16) | STUB | STUB | no layout probability tables | STUB |
| 7c | — Stage 3: Reservations (MultiBlockReservation, terminals) | STUB | STUB | — | STUB |
| 7d | — Stage 4: Social sites + NPC (SocialSitePlacement, triangles) | STUB | STUB | 3/230+ culture files; no heritage blends | STUB |
| 7e | — Stage 5: Guarantee audit (GuaranteeAuditResult) | STUB | STUB | — | STUB (`String` alias) |
| 8 | Society profile / culture resolution | PARTIAL (#679 done, not wired to skeleton) | PARTIAL (spike binary only) | PARTIAL (3 RON files, no heritage blends) | **PARTIAL — wiring gap + content gap** |
| 9 | Phase 2: ChunkData tile generation | MISSING | MISSING | MISSING | **MISSING — CRITICAL. No code, no tile vocabulary.** |
| 10 | Heritage grammar overlay application | MISSING | MISSING | MISSING (no data files for any root) | **MISSING — design complete (miri-round4.md spec), no Rust type, no data** |
| 11 | NPC population / manifest seeding | PARTIAL (triangle system full; NpcManifest missing) | PARTIAL (spike, no ECS wiring) | PARTIAL (culture RON operational, no heritage blend) | **PARTIAL — machinery exists, pipeline connection missing** |
| 12 | Chunk streaming → generator hookup | STUB (streaming works; generator call is blank) | PARTIAL (load/unload works; fill is wrong) | N/A | **PARTIAL — streaming architecture ready, generator call absent** |
**Key:** Layer 6 and Layer 9 are the two critical missing gaps. Layer 7 has types but no logic. Layers 8, 11, 12 are partial with clear wiring gaps. Everything at Layer 10 is zero.
---
## New Tickets Proposed — Summary by Participant
### Gestalt's proposed new tickets
| Priority | Scope | Blocked by |
|----------|-------|-----------|
| Blocker | Fix WorldTier enum in generator.rs | #897 |
| Critical | Layer 6: City → District decomposition | None (after WorldTier fix) |
| Critical | Phase 2: Basic chunk tile generator | #899 |
| High | Connect chunk_streaming to Phase 2 generator | Phase 2 ticket |
| High | Wire society profile (#679) to DistrictSkeleton | #899 |
| Medium | Re-scope #156, #100 against confirmed architecture | Phase 2 ticket |
### Tyre's proposed new tickets
| Priority | Scope | Effort | Blocked by |
|----------|-------|--------|-----------|
| 1 | Fix WorldTier enum values in generator.rs | 0.5d | Nothing |
| 2 | City-to-district decomposition — spec and implementation | 2d | Nothing |
| 3 | DistrictSkeleton Phase 1 generator (Stages 1–2) | 3d | Tickets 1, 2 |
| 4 | Add missing DistrictSkeleton fields (`vertical_structure`, `breach_only_zones`, `derived_analysis`) | 0.5d | Ticket 3 |
| 5 | ChunkData Phase 2 minimal fill | 2d | Ticket 3 |
| 6 | Wire generator to chunk_streaming (`DistrictMap` resource + dispatch) | 1d | Tickets 2, 3, 5 |
### Miri's proposed new tickets (content)
| Priority | Scope |
|----------|-------|
| Critical | Heritage root blend mapping — extend culture RON schema, author Van Maanen's Star blend |
| Critical | HeritageGrammarOverlay data files — 10 heritage roots from miri-round4.md spec |
| High | ObjectTag vocabulary — joint Miri + Araminta authoring |
| High | Zone identity definitions — one spec per DistrictType value |
| Medium | Van Maanen's Star cultural group wiki entry |
| Medium | City marker schema extension — coordinate with Tyre |
---
## Existing Ticket Positions — All Three Participants
| Ticket | Title | Gestalt | Tyre | Miri |
|--------|-------|---------|------|------|
| #615 | Tycoon small business starting state | Not generation-dependent; feeds #681 | Should be blocked behind generation pipeline | No position taken on cascade placement |
| #616 | Economic verb vocabulary design | Content design; no pipeline dependency | Can proceed | No pipeline dependency |
| #619 | Full character customisation | NOT generation-dependent; Phase 4 UI | NOT blocked by generation pipeline | No pipeline dependency flagged |
| #681 | Apartment generator (server) | Add Phase 1 skeleton as formal blocker | Should be blocked behind vertical slice | Can proceed for culturally-voiced version; heritage spatial refinement is a second pass |
| #682 | Apartment visual rendering (client) | Blocked by #681 — correct, no changes | Blocked by #681 | Blocked by #681 |
| #694 | Character creation screen | NOT generation-dependent; Phase 4 UI | NOT blocked by generation pipeline | No pipeline dependency flagged |
| #899 | District skeleton generator — Phase 1 | Correct scope; misses Layer 6 and WorldTier fix | Blocked by city decomp ticket + WorldTier fix | Blocked by #897 + heritage blend content |
---
## For Round 2 Attention
The following items from Round 1 carry unresolved positions that Round 2 should address:
1. **OQ-R1-A (Heritage root count)** — Must be resolved before any heritage content can be authored. Affects Miri and Araminta's work scope.
2. **OQ-R1-B (SocietyProfileRef type)** — Affects both the Rust type design and culture RON schema. Tyre and Miri should align on this before #899 scoping.
3. **OQ-R1-C (City marker schema)** — Joint Miri/Tyre decision. Determines whether `generate_atlas.py` must be extended.
4. **OQ-R1-D (Atlas coordinate translation)** — No participant proposed a formula. Tyre owns this technically.
5. **OQ-R1-E (Vertical slice scope and effort)** — Gestalt and Tyre estimates diverge by 2–4 days. Agree on minimum Phase 1 stages in Round 2.
6. **OQ-R1-F (#615 cascade position)** — Gestalt and Tyre disagree. Needs a position for the ticket dependency graph.
7. **Gap E (Economics → generation bridge design)** — Gestalt proposed a design principle but it has no D-record candidate yet. Round 2 should decide whether this produces a D-record.
---
*Notes compiled by Qatux from Round 1 source files. All positions attributed to their source participant. No editorial opinions added.*
@@ -0,0 +1,375 @@
---
title: "Round 2 Notes — Generation Cascade Workshop"
description: "Compiled Round 2 proposals: consensus, Paula's spatial narrative additions, disagreements, D-record candidates, ticket dependency convergence, and open questions for Round 3"
type: workshop
status: active
workshop: generation-cascade
agent: qatux
round: 2
created: 2026-04-30
---
# Generation Cascade Workshop — Round 2 Notes
**Compiled by:** Qatux
**Source files:**
- `docs/workshops/generation-cascade/gestalt-round2.md`
- `docs/workshops/generation-cascade/tyre-round2.md`
- `docs/workshops/generation-cascade/paula-round2.md`
**Scope:** Spatial pipeline only — from atlas city markers to walkable rendered tiles. Heritage grammar, NPC population, social sites, culture overlays, and #615 are out of scope per lead directive.
---
## Summary
Round 2 achieved strong convergence on implementation order, effort, and the stored-vs-derived boundary. The two key technical open questions from Round 1 (OQ-R1-C and OQ-R1-D) are now resolved by both technical participants independently reaching the same conclusions. Paula introduced three new spatial narrative fields and a naming register system that neither Gestalt nor Tyre addressed — these require Round 3 disposition before Phase 2 can be scoped. Effort estimates converged to ~6–7 days once heritage grammar contamination was removed from Tyre's Round 1 figure.
---
## Out-of-Scope References — Flagged
Two references in the Round 2 files touch heritage or cultural territory. Per scope rules, these are flagged but not incorporated:
1. **Paula §5 on #615:** Paula references "OQ-R1-B resolved (SocietyProfileRef type)" as a dependency for #615. `SocietyProfileRef` is heritage territory (culture file resolution). The reference is used to position #615's unblocking condition, not to reopen heritage design — but the dependency chain Paula describes would require an out-of-scope decision. Not incorporated into consensus.
2. **Paula §2 — Naming system:** The five naming *registers* themselves (`Commemorative | Functional | Aspirational | Folk | Geographic`) are spatial parameters derivable from `(DistrictType, WorldTier)`. The *lookup table* that maps registers to types is in scope as a spatial decision. However, Paula notes that "the names are drawn from culture pool" for seeded generation — name pools are heritage/cultural territory. The register taxonomy and lookup table are captured here; the name pool population is flagged as out of scope for this workshop.
---
## Consensus Points
### 1. Effort estimates converged: ~6–7 dev-days
Round 1 gap (Gestalt ~5–7d vs. Tyre ~9d) was caused by heritage grammar contamination in Tyre's estimate. With spatial-only scope:
- **Gestalt Round 2:** 5.5 days (explicit line-item breakdown)
- **Tyre Round 2 revised:** 6–7 days (conservative; includes type migration friction)
Gestalt's 5.5d floor represents minimum friction. Tyre's 6–7d includes explicit allowance for `String`-stub-to-real-type migration surprises during compilation. Paula (§5) explicitly supports the Gestalt floor estimate and Stages 1–2 deferral position.
**Converged answer:** ~6 days under reasonable conditions. 7–8 days if String-stub migrations surface unexpected breadth.
### 2. OQ-R1-C resolved: do not extend markers.json
Both Gestalt and Tyre independently reached the same answer:
> `primary_function`, `planet_class`, and `settlement_pattern` are read from `systems.db` bodies table by cross-reference on `body_id`. `generate_atlas.py` does not need modification.
- **Gestalt:** "Do not extend markers.json. Instead, resolve via systems.db cross-reference using body_id... keeps markers.json as a pure spatial-plus-naming store."
- **Tyre:** "`primary_function`, `planet_class`, `settlement_pattern` are available in systems.db bodies table and do NOT need to be added to markers.json."
**Status: RESOLVED. No markers.json schema changes. City decomposition reads systems.db bodies table directly.**
### 3. OQ-R1-D resolved: no pixel-to-sim-tile formula needed
Tyre provided the architectural resolution:
> "The atlas grid is a display coordinate system for the atlas UI. It is not a world-space coordinate for the game simulation. The walkable world uses a city-local coordinate system starting at (0, 0)."
Atlas pixel coordinates are UI-only (city dot placement on planetary map). The walkable world uses city-local sim-tile coordinates. Districts in a city are placed at `(col × 512, row × 512)` sim tiles in city-local space. No conversion from atlas pixels to sim tiles is needed or meaningful.
Gestalt's Round 2 acknowledged the same conclusion: "For the minimum slice, we don't need this — we spawn one district at world origin." Gestalt deferred the multi-city coordinate question to Step 2b / Round 3; Tyre provided the answer: it's city-local grid, not atlas-pixel-derived.
**Status: RESOLVED. Atlas coordinates are UI-only. No formula needed. City-local sim-tile coordinates from (0,0).**
### 4. Seed-vs-stored boundary: atlas markers are the last stored layer
Both Gestalt and Tyre independently reached the same boundary definition:
**Stored (authored or generated-then-committed):**
- Star system and body definitions (wiki + systems.db)
- Economic data, corporations (systems.db economics tables)
- City positions, populations, infrastructure (markers.json + systems.db atlas tables)
- City names (markers.json, Gemma-filled, curated)
- Chunk mutations / DamageOverlay (save file)
**Seed-derived (never stored, always re-derived from world_seed):**
- District count and positions per city
- DistrictSkeleton (all fields)
- BlockSkeleton per district
- ChunkData tile grid
- Building footprints, door positions
- **Gestalt:** "At the boundary between atlas markers and city → district decomposition (between Layer 5 and Layer 6)."
- **Tyre:** "The boundary is between Layer 4 and Layer 6... markers.json city entries are the stored anchor. Below that, the world is fully deterministic from world_seed."
**Status: CONSENSUS. Atlas markers are the last stored layer. Save files store world_seed + markers_snapshot + ChunkMutations only.**
### 5. SeedChain design: FNV-1a child_seed
Tyre proposed the concrete seed derivation mechanism. Gestalt's formula (`world_seed XOR fnv1a(city_id) XOR district_index`) uses the same approach. Consistent with D-010 (FNV-1a determinism mandate).
**Tyre's proposed canonical form:**
```rust
fn child_seed(parent: u64, discriminant: u64) -> u64 {
let mut h = parent ^ 0xcbf29ce484222325;
for byte in discriminant.to_le_bytes() {
h ^= byte as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
district_seed = child_seed(world_seed, district_id)
chunk_seed = child_seed(district_seed, (cx as u64) << 32 | (cy as u64))
```
Properties: any district or chunk can be generated in isolation without generating neighbors first. Generation is parallelizable. No global state required.
### 6. Phase 1 minimum scope: Stages 1–2 only
All three participants agree Phase 2 tile generation requires only Stages 1 and 2 of Phase 1. Stages 3–5 are deferred.
- **Gestalt:** Stages 1–2 mandatory. "Fields deferred: reservations, corridors, social_sites, boundaries, guarantee_audit, zone_palette, society_profile, access_points."
- **Tyre:** "Stages 3 (reservations), 4 (social sites), and 5 (guarantee audit) are deferred — they produce spatial enrichment but are not required for a walkable tile."
- **Paula (§5):** "I'd formally support deferring Stages 3–5 until after the first walkable district is demonstrably legible."
### 7. Implementation order (all three agree)
The five-step sequential dependency chain is consistent across Gestalt and Tyre. Steps cannot be parallelized until Step 3 produces a compilable skeleton:
```
Step 1: Fix WorldTier enum (0.5d)
└─▶ Step 2a: Single-district decomposition stub (0.5d)
└─▶ Step 3: Phase 1 Stages 1-2 generation (1.5-2d)
├─▶ Step 4: Phase 2 minimal tile generator (1.5d)
│ └─▶ Step 5: Chunk streaming wiring (0.5-1d)
│ └─▶ [MINIMUM VIABLE WALKABLE WORLD]
└─▶ Step 2b: Full city decomposition + district grid (1d, follow-on)
```
### 8. GeneratorChunkData type must be upgraded
Both Gestalt and Tyre note that `pub type GeneratorChunkData = Vec<bool>` cannot carry tile IDs and must become a proper struct. Tyre specifies the form:
```rust
pub struct GeneratorChunkData {
pub tiles: Vec<TileEntry>, // 64×64 = 4096 entries
}
pub struct TileEntry {
pub tile_id: TileId,
pub walkable: bool,
}
```
Gestalt concurs that `TileId` per cell is needed for zone-tinted floor tiles. The type change is a prerequisite for Phase 2.
### 9. Existing tickets consensus
All three participants (where they addressed tickets) reached identical positions:
| Ticket | Consensus position |
|--------|--------------------|
| #619 | NOT generation-dependent. Phase 4 work. No generation blockers. |
| #694 | NOT generation-dependent. Phase 4 work. No generation blockers. |
| #681 | Add formal blocker behind "Minimum Viable Walkable World" milestone. |
| #682 | Correctly blocked by #681. No changes. |
| #616 | No generation dependency. Can proceed. |
| #156 | Close/supersede. New Phase 2 ticket replaces it. |
| #100 | Close/fold into chunk streaming wiring ticket. |
| #899 | Rescope: Stages 1–2 only. Remove Stage 4–5 from scope. Add dependency on WorldTier fix + new city decomp ticket. |
| #615 | Out of scope per lead directive. No position. |
---
## Paula's Spatial Narrative Proposals — Mapping to Gestalt/Tyre
Paula introduced three new concepts not addressed by Gestalt or Tyre. Each requires Round 3 disposition before it can be confirmed as in-scope for the minimum slice.
### Paula Proposal P1: `prosperity_index: f32` (new field)
A single 0.0–1.0 scalar on `DistrictSkeleton` that drives tile decay probability, road width, building density, and green space allocation. Derived from `WorldTier` + `DistrictType` + city economic profile from systems.db. No authoring required.
| Prosperity band | Tile decay | Road width | Building density | Green space |
|-----------------|-----------|------------|-----------------|-------------|
| 0.0–0.2 (subsistence) | 40–60% damaged | Narrow | Very high | None |
| 0.2–0.4 (working) | 20–35% | Narrow-medium | High | Minimal |
| 0.4–0.6 (middle) | 5–20% | Medium | Medium | Small plazas |
| 0.6–0.8 (affluent) | 1–5% | Medium-wide | Lower (setbacks) | Parks |
| 0.8–1.0 (elite) | <1% | Wide/boulevard | Low (wasted space = luxury) | Gardens |
**Gestalt/Tyre accommodation:** Neither includes `prosperity_index` in their Stage 1 classification. Adding it is architecturally clean — it's a derived field with the same inputs as other Stage 1 fields. However, it changes Phase 2 tile generation (density-driven building placement is in Tyre's algorithm; Paula's version connects that density to prosperity). **Round 3 must decide: is `prosperity_index` in scope for the minimum slice?**
Paula's argument for inclusion: "If prosperity_index and perimeter_treatment aren't on DistrictSkeleton before Phase 2 is implemented, tile generation will produce spaces that are spatially indistinguishable regardless of district type — a simulation fidelity problem that's expensive to retrofit."
### Paula Proposal P2: `perimeter_treatment: PerimeterTreatment` (new field)
New enum on `DistrictSkeleton`:
```
Open // no boundary markers; permeable
Fenced // visible boundary, no control
Walled // physical barrier, uncontrolled gaps
Gated // checkpoints on main arteries, open on minor
Checkpoint // all entry points monitored
```
Derived from `DistrictType` + `WorldTier` + `prosperity_index`. Manifests as boundary marker tiles at `access_points` in Phase 2.
**Gestalt/Tyre accommodation:** `AccessPoint` is already in `DistrictSkeleton` as a stub (`pub type AccessPoint = String`). `perimeter_treatment` extends the access system with political character. Neither Gestalt nor Tyre proposed this field. **Round 3 must decide whether this is in scope for the minimum slice.**
Paula's argument: "The approach to a district is the first story beat. A player walking toward a Checkpoint district understands access control before entering. No tile variation needed — just the presence of the boundary marker tile."
### Paula Proposal P3: `political_archetype` (authored per city)
Five archetypes for city-level authoring (one field per city in atlas/systems.db):
- `CompanyTown` — one corporation dominates; homogeneous district type distribution
- `AdminCapital` — government/institutional core with support districts
- `FreePort` — Transit and MixedUse dominant; contested/heterogeneous
- `Contested` — two or more factions with visible boundary zones
- `OrganicGrowth` — no dominant power; district types mix without hierarchy
Paula notes this "drives the district type distribution for Layer 6" and is derivable from systems.db economics data for most cities, but named cities warrant sanity-checking. This is "a small authoring task, not a large one."
**Gestalt/Tyre accommodation:** Neither Gestalt nor Tyre proposed `political_archetype`. Gestalt's decomposition formula (`DistrictType from body.economic_role + city.kind`) approximates this without the concept explicitly. Paula's proposal is a named abstraction over the same data. **Round 3 must decide whether to formalize this as a named field or keep it implicit in the decomposition formula.**
### Paula Proposal P4: Naming Registers (in-scope component only)
Paula proposes five naming registers that determine what *kind* of name a district or street receives:
| Register | Pattern | Typical districts |
|----------|---------|-----------------|
| Commemorative | Person's/institutional name | Administrative, elite Residential |
| Functional | What happens here | Industrial, Transit, LogisticsHub |
| Aspirational | Abstract positive noun | New Commercial, MixedUse growth |
| Folk | Colloquial/ironic | Old Residential, Backwater |
| Geographic | Terrain feature | Pre-political, oldest settlements |
The register lookup table `(DistrictType, WorldTier) → register` is a spatial/structural decision — in scope for this workshop. The name pools that populate each register are cultural content — **out of scope for this workshop**.
Neither Gestalt nor Tyre addressed district naming. Paula's register system adds no new DistrictSkeleton fields but informs Phase 2 street labeling. **Round 3 must decide: does the naming register lookup table need to be locked before Phase 2, or is it a post-walkable enhancement?**
---
## D-Record Candidates
All candidates identified across the three Round 2 files. Sources attributed.
### D-C1: Generation Pipeline Layer Definitions (Gestalt)
Canonical spatial pipeline layer sequence:
| Layer | Name | Input | Output | Storage policy |
|-------|------|-------|--------|----------------|
| 1–5 | Atlas layers (existing) | Wiki + authored content | systems.db, markers.json | Explicitly stored |
| 6 | City → District decomposition | City entry + systems.db bodies | DistrictPlacement vec | Seed-derived, not stored |
| 7 | Phase 1: DistrictSkeleton | DistrictPlacement | DistrictSkeleton (Stages 1–2) | Seed-derived, not stored |
| 8 | Phase 2: ChunkData fill | BlockSkeleton + district seed | 64×64 TileEntry grid | Seed-derived, cached in session |
| 9 | Chunk streaming | Player position | On-demand Phase 2 call | Architecture |
### D-C2: Stored-vs-Derived Transition (Gestalt + Tyre)
Atlas marker data (city positions, populations, road topology, city names) is explicitly stored. Everything at Layer 6 and below is seed-derived at runtime. Save files store: `world_seed`, markers snapshot hash, and `ChunkMutations` (player-modified chunks only). Changing generation algorithms between a player's saves is a breaking change requiring migration versioning.
### D-C3: Phase 1 Minimum Spatial Scope (Gestalt, confirmed by all)
Phase 1 has two mandatory spatial stages for Phase 2 prerequisite:
- **Stage 1 (mandatory):** Classification — WorldTier, ComplexityTier, SettingType, DistrictType, LayoutMode, z_levels
- **Stage 2 (mandatory):** Block grid — 4×4 BlockSkeleton with ZoningType, density_pct, seed
Stages 3–5 (Reservations, Social sites, Guarantee audit) are enrichment — they add value to an already-walkable world but are not prerequisites for walkable tile generation.
### D-C4: City → District Decomposition Formula (Gestalt + Tyre)
- District count: `max(1, floor(city.population / 50_000))`, log-scaled for large cities, capped at WorldTier ceiling
- WorldTier district ceiling: Epicenter → uncapped; Regional → 8; Backwater → 4; Passage → 2; Waypoint → 1
- Capital cities: +1 bonus district
- Domed/cave settlement: fixed 1 district regardless of population
- Seed chain: `district_seed = child_seed(world_seed, district_id)` via FNV-1a (D-010)
- DistrictType: derived from `body.economic_role` + `city.kind` + district index within city
- Cross-reference source: systems.db bodies table (not markers.json)
### D-C5: City-Local Coordinate System for Districts (Tyre)
The atlas coordinate system (512×256 pixel grid) is a UI display system only. The walkable world uses city-local sim-tile coordinates. A city's (row, col) atlas position is used only for rendering the city dot on the planetary map UI. All district geometry is expressed in city-local coordinates: district at grid position (col, row) occupies world origin `(col × 512, row × 512)` sim tiles. No pixel-to-sim-tile conversion formula exists or is needed.
### D-C6: SeedChain Algorithm (Tyre)
All child seeds in the spatial pipeline are derived via FNV-1a from a parent seed and a discriminant:
```rust
fn child_seed(parent: u64, discriminant: u64) -> u64 // (FNV-1a body above)
```
This ensures: any district or chunk can be generated in isolation; generation is parallelizable; no global state required. Complies with D-010 (FNV-1a determinism mandate). Any deviation from `SimRng` or FNV-1a for randomness in the generation pipeline is prohibited.
### D-C7: GeneratorChunkData Type Upgrade (Tyre, confirmed Gestalt)
`pub type GeneratorChunkData = Vec<bool>` is insufficient — it carries only walkability. It must be replaced with a struct carrying per-tile `TileId` and pre-computed walkability. Minimum viable tile vocabulary for the spatial pipeline: `floor_street`, `floor_interior`, `wall` (three types). Visual variety and heritage grammar apply tile variation in subsequent phases.
### D-C8 (Paula proposal): Spatial Class Legibility Requirements
Three fields are minimum requirements for a district to be narratively legible to a player traversing it without NPC interaction or UI overlay:
- `prosperity_index: f32` — drives tile decay, road width, density, green space
- `perimeter_treatment: PerimeterTreatment` — enum: `Open | Fenced | Walled | Gated | Checkpoint`
- `layout_mode: DistrictLayoutMode` — political geometry signal (planned power vs. ungoverned growth)
Both `prosperity_index` and `perimeter_treatment` are derived from `DistrictType + WorldTier + city economic profile`. No authoring required for unnamed cities.
**Note:** D-C8 is proposed by Paula. Gestalt and Tyre did not address these fields. Round 3 must decide whether to adopt, modify, or defer this D-record.
---
## Open Questions for Round 3
Questions resolved in Round 2 are marked CLOSED. New questions are from this round's analysis.
| ID | Question | Source | Status | Priority |
|----|----------|--------|--------|----------|
| OQ-R1-A | Heritage root count: 7 vs 10 | Miri R1 | Out of scope | — |
| OQ-R1-B | SocietyProfileRef type | Miri R1 | Out of scope | — |
| OQ-R1-C | City marker schema approach | Miri/Tyre R1 | **CLOSED** — cross-ref systems.db, no markers.json changes | — |
| OQ-R1-D | Atlas coordinate translation | Tyre R1 | **CLOSED** — no formula; city-local coords | — |
| OQ-R1-E | Vertical slice effort | Gestalt/Tyre R1 | **CLOSED** — ~6 days (spatial scope only) | — |
| OQ-R1-F | #615 cascade position | Gestalt/Tyre R1 | **CLOSED** — out of scope per lead directive | — |
| OQ-R2-1 | Should `prosperity_index` and `perimeter_treatment` be added to DistrictSkeleton for the minimum slice, or deferred? | Paula R2 | Open | **HIGH** |
| OQ-R2-2 | Should `political_archetype` be a formal authored field per city, or kept implicit in the decomposition formula? | Paula R2 | Open | **HIGH** |
| OQ-R2-3 | Phase 2 tile algorithm: Gestalt's door-per-block-edge vs. Tyre's density-driven rectangle placement. Which is implemented first? | Gestalt/Tyre R2 | Open | **HIGH** |
| OQ-R2-4 | Step 2b (full N-district city layout): in scope for the minimum slice, or strictly a follow-on ticket? Gestalt: defer. Tyre: includes it in architecture. | Gestalt/Tyre R2 | Open | MEDIUM |
| OQ-R2-5 | Naming register lookup table: is this locked before Phase 2, or post-walkable enhancement? | Paula R2 | Open | MEDIUM |
| OQ-R2-6 | `prosperity_index`: authored for named cities (Van Maanen's Star capital) or always derived from systems.db? | Paula R2 | Open | MEDIUM |
| OQ-R2-7 | Naming register table ownership (who authors `(DistrictType, WorldTier) → register` mapping)? | Paula R2 | Open | LOW |
---
## Ticket Dependency Graph — Converged
From Gestalt Round 2 §6, corroborated by Tyre's architecture:
```
NEW: Fix WorldTier enum (generator.rs) [0.5d]
│
├──▶ NEW: City → district decomp — single district (Step 2a) [0.5d]
│ │
│ └──▶ #899 (rescoped): Phase 1 Stages 1-2 only [1.5-2d]
│ │
│ ├──▶ NEW: Phase 2 — minimal chunk tile gen [1.5d]
│ │ │
│ │ └──▶ NEW: Chunk streaming hookup [0.5-1d]
│ │ │
│ │ └──▶ [MINIMUM VIABLE WALKABLE WORLD]
│ │ │
│ │ └──▶ #681 ADD BLOCKER HERE
│ │ └──▶ #682 (already blocked by #681)
│ │
│ └──▶ NEW: Missing DistrictSkeleton fields [0.5d]
│ (vertical_structure, breach_only_zones, derived_analysis as stubs)
│
└──▶ NEW: Full city decomp + district grid (Step 2b, follow-on) [1d]
(not required for minimum slice; unblocks multi-district cities)
```
**New tickets needed (identified across all three participants):**
1. Fix WorldTier enum in generator.rs
2. City → district decomposition — single district (Step 2a)
3. #899 rescoped: Phase 1 Stages 1–2 only
4. Add missing DistrictSkeleton fields as stubs (Gap C)
5. Phase 2 — minimal chunk tile generator (new)
6. Chunk streaming → generator wiring (new)
7. Full city decomposition + district grid layout (Step 2b, follow-on)
8. Close/supersede #156 (Procedural district filler)
9. Close/fold #100 (Ungenerated → instantiation)
---
*Notes compiled by Qatux from Round 2 source files. All positions attributed to source participant. No editorial opinions added.*
@@ -0,0 +1,396 @@
---
title: "Round 3 Notes — Generation Cascade Workshop"
description: "Compiled Round 3 outputs: Phase 3 spatial pipeline architecture, tooling/runtime split consensus, layer alignment, Paula's political signals, Burnelli-Sheldon's economics bridge, D-record candidates, and open questions"
type: workshop
status: active
workshop: generation-cascade
agent: qatux
round: 3
created: 2026-04-30
---
# Generation Cascade Workshop — Round 3 Notes
**Compiled by:** Qatux
**Source files:**
- `docs/workshops/generation-cascade/gestalt-round3.md`
- `docs/workshops/generation-cascade/tyre-round3.md`
- `docs/workshops/generation-cascade/paula-round3.md`
- `docs/workshops/generation-cascade/burnelli-sheldon-round3.md`
**Scope:** Phase 3 spatial pipeline — regional maps, city footprints, road networks, biome grids, station topology, settlement hierarchy. NOT Phase 5 tile generation.
---
## Summary
Round 3 achieved structural consensus on the most important architectural question: Phase 3 is tooling (Python, offline, committed to systems.db), Phase 5 is runtime (Rust, seed-derived). Both are fully parallelizable during development. The layer inventories from Gestalt and Tyre are substantially aligned but not identical — Gestalt has six named layers (A–F), Tyre has five (A–E). The principal gap is that Tyre's schema and struct definitions omit `TerritorialStatus`, `FoundingOrientation`, `MaintenanceAuthority`, and the sub-settlement / `PoliticalTether` architecture that Gestalt accepts and accommodates from Paula. Burnelli-Sheldon provides concrete economic field mappings, a 10×9 district type weight table, a minimum six-field read set, and three real-body case studies demonstrating differentiation. Effort estimates diverge (~7d Tyre vs. ~9.5d Gestalt) for quantifiable reasons. All seven Round 2 open questions are now resolved.
---
## Out-of-Scope References — Flagged
1. **Paula §5 — name pool population:** Paula's naming strata section describes when `common_name` is populated as a spatial/political trigger condition (in scope), but notes "names drawn from culture pool" for the seeded generation content. The trigger logic and struct (`RegionalFeatureName`) are spatial decisions and captured here. The name pools themselves are cultural territory — out of scope for this workshop, same boundary established in Round 2.
2. **Burnelli-Sheldon §3 — `shadow_economy_access` and `collection_efficiency`:** These economics fields drive a MixedUse fringe district placement with specific prosperity floor and perimeter treatment. This is district *classification* (Phase 3/Phase 5 Stage 1), not tile generation. Within scope as far as district type assignment goes. The MixedUse fringe is a district-level output, not a tile layout.
3. **No Phase 5 tile generation crept back in.** All four participants maintain a clean boundary. Burnelli-Sheldon's building archetype signals (bulk_class → building type vocabulary) describe *what kinds* of buildings populate a district, which is Phase 5 input classification, not tile grid generation. Tyre explicitly: "Phase 3 lives entirely in Python tooling and systems.db. It does not add Rust runtime code."
---
## Consensus Points
### 1. Phase 3 is tooling; Phase 5 is runtime — fully parallelizable
All four participants independently frame the same boundary:
- **Phase 3:** Python generator (`generate_regional.py`), offline computation, output committed to systems.db. No Rust runtime code. No game-session dependency.
- **Phase 5:** Rust, seed-derived, computed at runtime when the player enters range of a city. Never stored except as world_seed + ChunkMutations.
The two phases do not block each other during development. Phase 5 develops against a stubbed `CityGenerationContext`; Phase 3 produces the real values; a wiring ticket connects them when both are ready.
- **Gestalt:** "Phase 3 is a tooling phase that enriches systems.db... The Rust server reads it at startup; no Phase 3 computation runs at game time."
- **Tyre:** "Phase 3 lives entirely in Python tooling and systems.db... Phase 3 and Phase 5 are parallel workstreams."
- **Paula:** Treats Phase 3 outputs as stored inputs to Phase 5 throughout.
- **Burnelli-Sheldon:** "These are pre-generation queries, not feedback dependencies."
### 2. `generate_regional.py` is a new generator in the `make regen-db` pipeline
Tyre and Gestalt agree on pipeline order and integration:
```
1. import_economics.py (existing)
2. generate_atlas.py (existing)
3. generate_regional.py (NEW — Phase 3 enrichment)
```
`generate_regional.py` follows the same meta stamp convention as existing generators: writes a stamp to the `meta` table, is listed in `tooling/check-systems-db-stamp`'s `GENERATOR_SOURCES`, and is watched by the `/pr-push` source-file list. Supports `--body`, `--force`, `--dry-run` flags matching `generate_atlas.py` convention.
### 3. `CityGenerationContext` Rust struct is the Phase 3 → Phase 5 handoff
Both Gestalt and Tyre propose the same struct concept. Gestalt's version includes more fields (see Disagreement 1 below). Points of agreement:
- Populated from systems.db at game startup (read-only query path)
- Contains at minimum: `city_id`, `political_archetype`, `prosperity_index`, `surrounding_biome`, `road_entry_directions`, `footprint_radius_km`
- Can be stubbed during Phase 5 development; wired to real data when Phase 3 is complete
### 4. All Round 2 open questions resolved
All seven OQ-R2 items have converged answers from three or more participants in Round 3. See the Open Questions table at the end of this document.
### 5. `prosperity_index` and `perimeter_treatment` belong on DistrictSkeleton before Phase 2
All four participants agree. The cost of inclusion is trivial (derived fields with inputs already available at Stage 1 classification); the cost of retrofit is a Phase 2 rewrite.
- **Tyre:** "Not adding it means Phase 2 tile generation produces identical spatial character regardless of district wealth — then retrofitting it is a Phase 2 rewrite."
- **Burnelli-Sheldon:** "The cost of deferral is not 'decorative refinement later' — it is 'all districts look equally prosperous until a retrofit pass.'"
- **Gestalt:** "Include before Phase 2 is implemented. Retrofit cost > add-now cost."
- **Paula (Round 2):** Original proposal. Not revisited — treated as settled.
### 6. `political_archetype` is a formal derived field with an override slot
All three technical participants converge: formal column on `atlas_cities`, derived from `economic_role` by Python tooling, with a `political_archetype_override` column for named city corrections.
- **Gestalt:** Derives from `economic_role` mapping. Override slot (`political_archetype_override`) allows wiki authors to correct counterintuitive results.
- **Tyre:** Same derivation. `COALESCE(political_archetype_override, <derived>)` at query time.
- **Burnelli-Sheldon:** Derives from `corp_presence` Monopolist share + `governance_type` + `economic_role = transit` + fault-line activity. More granular than Gestalt/Tyre's simple `economic_role` mapping — uses corp behavioral data where available. Starting position: trust the derivation.
Note: Burnelli-Sheldon's derivation is more nuanced than Gestalt/Tyre's direct `economic_role → archetype` mapping. They do not contradict; Burnelli-Sheldon's is a refinement using additional signals.
### 7. Minimum Phase 3 for Phase 5 to proceed: Layers A + B + C
Gestalt explicitly identifies this subset:
> "Layer A: City classification (political_archetype + prosperity_index + founding_orientation). Layer C: Road graph (road_entry_directions per city). Layer B: Regional biome grid (surrounding biome context). Layers D, E, F enrich Phase 5 and the Atlas UI but are not required for the first walkable district."
This gives a sequencing option: ship the minimum Phase 3 subset to unblock Phase 5, then complete the remaining layers in parallel with Phase 5 development.
### 8. Stored-vs-derived boundary extension to Phase 3
Tyre provides the full boundary argument for Phase 3 data belonging in systems.db:
1. Authoring coupling — Phase 3 data derives from wiki-authored bodies data
2. UI dependency — Atlas of the Reach app must display before any game session
3. Generation cost — planet_simulation.py compute time warrants caching at regional resolution
4. Determinism alone does not justify runtime generation
The boundary from Round 2 (atlas markers = last stored layer) extends: Phase 3 data is also stored (committed by `generate_regional.py`). The new inner boundary: DistrictSkeleton and everything below is seed-derived.
---
## Gestalt–Tyre Layer Alignment
Both participants name a Phase 3 layer sequence starting with city classification and ending with wilderness/countryside annotation. The layers map, but not one-to-one. Canonical comparison:
| Canonical | Gestalt label | Tyre label | Content match | Discrepancy |
|-----------|--------------|------------|---------------|-------------|
| Layer A | City Classification + Footprint | Layer A — City Classification | Matched | Tyre omits `founding_orientation` from outputs and CityGenerationContext |
| Layer B | Regional Biome Grid | Layer B — Regional Biome Grid | Full match | None — algorithm, table schema, and Phase 5 consumption are consistent |
| Layer C | Road Network Graph | Layer C — Road Network Graph | Partial match | Tyre's `atlas_road_edges` schema omits `maintenance_authority` column. Gestalt includes it as a Layer C output. |
| Layer D | TerritorialStatus Zones | *(not a distinct layer)* | **Gap** | Tyre has no Layer D equivalent. TerritorialStatus is not in Tyre's CityGenerationContext struct. |
| Layer E | Station Module Topology | Layer D — Station Module Topology | Full match | Same algorithm, same template structure, same tables. Different letter only. |
| Layer F | Wilderness + Settlement Hierarchy | Layer E — Wilderness + Countryside Annotation | Partial match | Tyre's Layer E covers `land_use` annotation only. Gestalt's Layer F adds: `atlas_sub_settlements` table, `PoliticalTether` derivation, `DualNaming` conditions, ruins classification. These are absent from Tyre's schema. |
### CityGenerationContext struct comparison
| Field | Tyre | Gestalt |
|-------|------|---------|
| `city_id: String` | ✓ | ✓ |
| `political_archetype: PoliticalArchetype` | ✓ | ✓ |
| `prosperity_index: f32` | ✓ | ✓ |
| `surrounding_biome: BiomeClass` | ✓ | ✓ |
| `road_entry_directions: Vec<CardinalDirection>` | ✓ | ✓ |
| `footprint_radius_km: f32` | ✓ | ✓ |
| `founding_orientation: FoundingOrientation` | absent | ✓ |
| `surrounding_territorial_status: TerritorialStatus` | absent | ✓ |
| `district_count: u8` | absent (noted in OQ-R2-4 answer) | ✓ |
The three fields Tyre omits correspond directly to: Paula's `FoundingOrientation` proposal (accommodated by Gestalt, not by Tyre), Gestalt's dedicated TerritorialStatus layer (not in Tyre's design), and Tyre's own statement that `district_count: u8` is needed "to prevent a data model migration later."
---
## Paula's Proposals — Coverage by Other Participants
Paula introduced six new fields/concepts in Round 3. Coverage by Gestalt and Tyre/Burnelli-Sheldon:
| Paula Proposal | Gestalt | Tyre | Burnelli-Sheldon | Status |
|---------------|---------|------|-----------------|--------|
| `TerritorialStatus` enum (6 values) | Full — dedicated Layer D with derivation algorithm | Not addressed | Partial — body-level signals support derivation (economic_role, corp_presence); TerritorialStatus not named | Adopted by Gestalt; not resolved by Tyre |
| `FoundingOrientation` enum (5 values) | Full — Layer A output, in CityGenerationContext, drives district layout direction | Not addressed | Not addressed | Adopted by Gestalt only |
| `MaintenanceAuthority` on road segments (5 values) | Full — Layer C output, in `atlas_road_edges` | Absent from `atlas_road_edges` schema | Not addressed | Gestalt adopts; Tyre's Layer C schema gap |
| `PoliticalTether` struct (city_id + TetherRelationship) | Full — Layer F output, `atlas_sub_settlements` table | Not addressed | Dynamic settlements concept is parallel; doesn't use PoliticalTether terminology | Adopted by Gestalt; not in Tyre's schema |
| `DualNaming` / `RegionalFeatureName` | Full — Layer F output, ContestZone trigger condition | Not addressed | Not addressed | Adopted by Gestalt; not in Tyre's design |
| Settlement hierarchy (town/outpost/waypoint/ruin tiers) | Full — `atlas_sub_settlements` table, Layer F | Not addressed | Parallel — "dynamic settlements" (off-atlas mining camps, waypoints, shadow nodes) uses similar placement logic with different framing | Convergent intent; different schema paths |
**Burnelli-Sheldon's "dynamic settlements" vs. Paula/Gestalt's sub-settlement hierarchy:**
The two designs converge on the same need from different angles. Burnelli-Sheldon derives settlement placement from economic trigger conditions (mining camps from corp_presence, waypoints from road length, agricultural nodes from settlement_pattern). Paula/Gestalt derives it from political relationship to nearest city (PoliticalTether). These are not competing designs — Burnelli-Sheldon's trigger conditions provide the *placement logic* while Paula's `PoliticalTether` provides the *relationship classification* once placed. They are compatible layers of the same feature. Neither resolves the schema question (what table, what fields) alone.
---
## Burnelli-Sheldon's Economics Bridge — Key Findings
### The minimum viable read set is 6 fields
Burnelli-Sheldon identifies that a spatially differentiated Phase 1 district skeleton requires exactly 6 systems.db queries per city:
1. `bodies.economic_role` — district type distribution
2. `bodies.settlement_pattern` — district layout and density mode
3. `system_economy.economic_tier` — infrastructure quality and wealth floor
4. `system_economy.distribution_index` — prosperity gradient shape
5. `corp_presence` count at this body — corporate district intensity
6. `corporations.headquarters_system` match — HQ landmark flag
Everything else (behavioral_archetype, shadow economy, bulk_class, brand premiums, currency zone) is Phase 2 enrichment — not required for the minimum walkable world.
### The 10×9 economic_role → DistrictType weight matrix
Burnelli-Sheldon provides a concrete probability weight table mapping all 10 `economic_role` values to all 9 DistrictType values (Residential, Commercial, Industrial, Administrative, LogisticsHub, Entertainment, MixedUse, Transit, Specialized). This is proposed as D-C10 — a locked canonical default from which corp-presence modifiers are applied.
Key examples:
- `manufacturing`: 35% Industrial / 20% LogisticsHub / 25% Residential
- `extraction`: 30% Industrial / 30% LogisticsHub / 20% Residential / 0% Entertainment
- `research`: 35% Specialized / 20% Administrative / 20% Residential / 0% Transit
- `transit`: 25% LogisticsHub / 20% Commercial / 15% MixedUse / 10% Transit
### Three concrete differentiation cases
Burnelli-Sheldon demonstrates how the same DistrictType (Industrial) produces different spatial character depending on economic fields:
- **Tributarium (GJ251c) — Manufacturing Capital:** `bulk_class = precision` → clean-room buildings, not open yards. Gate Corp Monopolist → uniform industrial district with consistent building footprint. Stratified distribution → executive district at prosperity 0.9 adjacent to worker housing at 0.15.
- **Bluebank (GJ3522c) — Extraction Capital:** `bulk_class = bulk` → open pits, conveyor structures, slag heaps. DSMC Monopolist → single employer city, uniform company housing. No Entertainment districts.
- **Strata (GJ280Ad) — Service Mixed Capital:** `bulk_class = non_physical` → office towers, no loading docks. Two reach-wide HQ corps → two landmark buildings. Stratified distribution produces gentrified commercial (0.75) adjacent to mid-tier residential (0.45).
**Burnelli-Sheldon's explicit finding:** "The aesthetic difference is entirely derivable from `production_ubiquity` and `bulk_class`." The generator produces spatial differentiation without explicit authored instructions for each city.
### Economic data quality caveats
Burnelli-Sheldon flags two data quality issues in systems.db that the generator must handle gracefully:
- `behavioral_archetype` on corporations is mostly NULL. Fallback chain: `behavioral_archetype` → `supply_chain_role` → commodity tier derivation.
- `scope` field on corporations is mixed-type (some text prose, not enum values). Null handling required.
### Simulation feedback requirements (narrow)
Burnelli-Sheldon identifies three fields that genuinely require post-generation reporting back to the economics simulation. All other simulation needs are pre-generation derivable from systems.db alone:
1. **Effective port tile count** — physical district layout determines actual cargo throughput
2. **Corp district allocation** — which DistrictType a corp occupies, post district assignment
3. **Shadow economy footprint** — whether shadow zone generated, and its prosperity floor
---
## Disagreements
### Disagreement 1: TerritorialStatus layer — dedicated layer or absent?
| Participant | Position |
|-------------|----------|
| **Gestalt** | Dedicated Layer D between road graph and wilderness annotation. Derivation algorithm provided: proximity × WorldTier weight + corporate presence + historical tether state. Feeds into CityGenerationContext as `surrounding_territorial_status`. |
| **Tyre** | No dedicated layer. Wilderness annotation (Layer E) classifies land use but does not produce a `TerritorialStatus` enum. `CityGenerationContext` does not include `surrounding_territorial_status`. |
| **Paula** | Proposes `TerritorialStatus` (independent of Gestalt — same enum, same derivation logic). Gestalt accommodates it. Tyre does not address it. |
| **Burnelli-Sheldon** | Does not name `TerritorialStatus` but provides the economic inputs that would support its derivation. No position on the layer structure question. |
The gap is not definitional — all participants who address it agree on the enum values and the derivation logic. The gap is whether `TerritorialStatus` is a Phase 3 stored output (Gestalt's view) or absent from Phase 3 scope (Tyre's implicit omission). This must be decided before Phase 3 schemas are finalized.
### Disagreement 2: Effort estimates (~7d Tyre vs. ~9.5d Gestalt)
| Source | Estimate | Scope difference |
|--------|----------|-----------------|
| **Tyre** | ~7 dev-days | 5 layers: A (1.5d) + B (1.5d) + C (1d) + D/stations (1.5d) + E/wilderness (0.5d) + plumbing (0.5d) + Rust read path (0.5d) |
| **Gestalt** | ~9.5d total / ~5.5d critical path | 6 layers: scaffolding (0.5d) + A (1.5d) + B (1.5d) + C (1d) + D/TerritorialStatus (1d) + E/stations (1.5d) + F/wilderness+settlements (1.5d) + CityGenerationContext (0.5d) + Phase 5 wiring (0.5d) |
Identifiable sources of the ~2.5d gap:
1. Gestalt has a TerritorialStatus layer (1d) absent from Tyre's design
2. Gestalt's wilderness/settlement layer (1.5d) includes sub-settlement placement, PoliticalTether, and DualNaming; Tyre's wilderness annotation (0.5d) is land-use only
3. Gestalt separates CityGenerationContext Rust struct (0.5d) and Phase 5 wiring (0.5d) into distinct tickets; Tyre bundles them as "Rust read path" (0.5d)
If TerritorialStatus, PoliticalTether, and sub-settlements are confirmed in scope, Gestalt's ~9.5d is the correct estimate. If those three are deferred, Tyre's ~7d applies.
### Disagreement 3: `MaintenanceAuthority` in road schema
Gestalt's `atlas_road_edges` schema includes a `maintenance_authority` column. Tyre's `atlas_road_edges` schema does not. Paula proposes `MaintenanceAuthority` as a required field. Tyre does not address this proposal at all. The road graph schema as specified by Tyre cannot support the `MaintenanceAuthority` signal without a schema modification.
### Disagreement 4: Scope of sub-settlement Layer F
Gestalt: Layer F introduces `atlas_sub_settlements` as a new systems.db table with fields per sub-settlement, PoliticalTether derivation, and DualNaming conditions flagged per cell. This is a significant data model addition.
Tyre: Layer E (wilderness) adds only a `land_use` column to `atlas_regional_biomes`. No sub-settlement table. No PoliticalTether. No DualNaming.
Burnelli-Sheldon's "dynamic settlements" placement logic could feed a sub-settlement table without requiring full PoliticalTether classification — but neither participant provides a schema for that middle path.
---
## D-Record Candidates
All candidates from Round 3 source files. Sources attributed.
### D-C11 (proposed by Gestalt and Tyre): Phase 3 layer sequence and tooling architecture
Phase 3 consists of a new generator (`generate_regional.py`) running third in the `make regen-db` pipeline. It enriches systems.db with classification, regional biome data, road graph, station topology, and optionally sub-settlement hierarchy. All Phase 3 computation is offline; no Phase 3 logic runs at game time. `generate_regional.py` follows the same meta stamp and pre-push hook conventions as existing generators.
Minimum confirmed layers: A (City Classification), B (Regional Biome Grid), C (Road Network Graph), D/E (Station Module Topology). Layers for TerritorialStatus and sub-settlements are pending lead decision.
### D-C12 (proposed by Gestalt and Tyre): `CityGenerationContext` as Phase 3 → Phase 5 handoff struct
A Rust struct populated from systems.db at game startup is the handoff point from Phase 3 to Phase 5 district generation. Minimum agreed fields:
```rust
pub struct CityGenerationContext {
pub city_id: String,
pub political_archetype: PoliticalArchetype,
pub prosperity_index: f32,
pub surrounding_biome: BiomeClass,
pub road_entry_directions: Vec<CardinalDirection>,
pub footprint_radius_km: f32,
}
```
Gestalt adds: `founding_orientation: FoundingOrientation`, `surrounding_territorial_status: TerritorialStatus`, `district_count: u8`. These are conditional on the lead decision regarding TerritorialStatus and FoundingOrientation scope.
### D-C13 (proposed by Gestalt): Phase 3 LoD cascade — stored boundary extended
Phase 3 data is stored (committed by `generate_regional.py`). The stored/seed-derived boundary from Round 2 (D-C2) extends: everything at Phase 3 layers (city classification, biome grid, road graph, station topology, optionally sub-settlements) is stored. Everything at Phase 5 (DistrictSkeleton, ChunkData, building footprints) is seed-derived.
### D-C14 (proposed by Tyre): `generate_regional.py` pipeline integration rules
`generate_regional.py` must:
- Write a meta stamp row (generator_name, schema_version, generator_sha)
- Be listed in `tooling/check-systems-db-stamp`'s `GENERATOR_SOURCES` dict
- Be included in the `/pr-push` skill's source-file watch list
- Support `--body <id>`, `--force`, `--dry-run` flags
- Run third (after `import_economics.py` and `generate_atlas.py`) in `make regen-db`
### D-C15 (proposed by Paula and Gestalt): `TerritorialStatus` enum definition
Six-value enum for regional political classification:
`CoreTerritory | FrontierTerritory | ExtractiveZone | ContestZone | WildernessBuffer | AbandonedZone`
Derivation: proximity to cities × WorldTier weight + corporation active presence + historical tether state. WorldTier weights (Gestalt): Epicenter 5×, Regional 3×, Backwater 1×, Passage 1.5×, Waypoint 0.5×.
**Note:** This D-record is proposed by Paula and accommodated by Gestalt. Tyre has not reviewed or accepted it. Requires lead decision before inclusion in Phase 3 schema.
### D-C16 (proposed by Paula and Gestalt): `FoundingOrientation` enum
Five-value enum for city-level derivation:
`PortFacing | RailHeadFacing | ResourceFacing | DefenseFacing | AdminFacing`
Derivation: `settlement_pattern` + `economic_role`. Drives direction of district prosperity gradient and oldest/newest district placement in Phase 5. Stored as a column on `atlas_cities`.
**Note:** Accommodated by Gestalt; not in Tyre's layer design. Requires lead decision.
### D-C17 (proposed by Paula and Gestalt): `MaintenanceAuthority` on road segments
Five-value enum for road edge classification:
`Administrative | Corporate | Communal | Trade | Abandoned`
Derivation: road endpoint types + economics data (same corporation at both ends → Corporate; high trade flow → Trade; no active tether → Abandoned). Stored as a column on `atlas_road_edges`.
**Note:** Absent from Tyre's `atlas_road_edges` schema. Gestalt's Layer C includes it. Requires schema alignment before Layer C is implemented.
### D-C9 (proposed by Burnelli-Sheldon): Economic fields → generator parameter mapping
The spatial generator's minimum read set at generation time: 3 SQL queries (city economic context, system-level modifiers, corporate presence at body). This data is sufficient to derive DistrictType distribution weights, prosperity_index, CorpPresenceTier, political_archetype, perimeter_treatment, and infrastructure quality parameters.
### D-C10 (proposed by Burnelli-Sheldon): `economic_role` → DistrictType weight table
Canonical 10×9 probability weight matrix mapping `economic_role` values to DistrictType values. Proposed as a Rust `const` array keyed by `EconomicRole` enum. Corp-presence modifiers apply on top. Tunable, but locked canonical default prevents per-sprint renegotiation.
---
## Open Questions
### Round 2 questions — now resolved
| ID | Question | Resolution | Resolved by |
|----|----------|-----------|-------------|
| OQ-R2-1 | `prosperity_index` and `perimeter_treatment` in minimum Phase 5 slice? | **YES.** Include before Phase 2. Retrofit cost exceeds add-now cost. | All four |
| OQ-R2-2 | `political_archetype` — formal field or implicit? | **Formal field in `atlas_cities`.** Derived by default. Override column allowed. | Tyre, Gestalt, Burnelli-Sheldon |
| OQ-R2-3 | Phase 2 tile algorithm — door-per-block-edge vs. density-driven rectangles? | **Both, sequenced.** Street skeleton first (door-per-block-edge), then building fill (density-driven rectangles within skeleton). | Tyre, Gestalt |
| OQ-R2-4 | Step 2b (full N-district layout) — minimum slice or follow-on? | **Follow-on.** Data model must support N districts from day 1. `district_count: u8` on CityGenerationContext prevents later migration. | Tyre, Gestalt |
| OQ-R2-5 | Naming register lookup table — before Phase 2 or post-walkable? | **Post-walkable.** Text overlay, not a tile-generation prerequisite. | Tyre, Burnelli-Sheldon |
| OQ-R2-6 | `prosperity_index` for named cities — authored or derived? | **Derived by default.** `prosperity_override REAL` column (NULL = derived) for wiki authors. | Tyre, Gestalt, Burnelli-Sheldon |
| OQ-R2-7 | Naming register table ownership? | Paula taxonomy → Mellanie populates name pools → Tyre reviews spatial constraints → lead locks as D-record. | Tyre |
### New open questions from Round 3
| ID | Question | Source | Priority |
|----|----------|--------|----------|
| OQ-R3-1 | TerritorialStatus as a Phase 3 layer: include in scope (Gestalt/Paula position) or defer beyond Phase 3 minimum (implied by Tyre's omission)? Determines whether Layer D is in scope, whether `surrounding_territorial_status` is in CityGenerationContext, and whether the effort estimate is ~7d or ~9.5d. | Gestalt vs. Tyre | **LEAD DECISION** |
| OQ-R3-2 | `FoundingOrientation`: include in Phase 3 Layer A output and CityGenerationContext (Gestalt/Paula position) or defer (implied by Tyre's omission)? | Paula / Gestalt vs. Tyre | **HIGH** |
| OQ-R3-3 | Sub-settlement hierarchy (Layer F): include `atlas_sub_settlements` table with PoliticalTether in Phase 3 (Gestalt/Paula), or scope Phase 3 to land-use annotation only (Tyre)? | Gestalt/Paula vs. Tyre | **HIGH** |
| OQ-R3-4 | `MaintenanceAuthority` column on `atlas_road_edges`: include in Phase 3 Layer C (Gestalt/Paula position) or absent from schema (Tyre's current design)? This is a concrete schema gap requiring alignment. | Paula / Gestalt vs. Tyre | **HIGH** |
| OQ-R3-5 | Paula OQ-R3-P3 — `DualNaming` / `common_name`: generator output (seeded from territorial conflict conditions) or authored layer only for named locations? | Paula | MEDIUM |
| OQ-R3-6 | Paula OQ-R3-P6 — `TerritorialStatus` zones storage: polygon/voronoi regions in a new table, or tags on the regional biome grid cells? Gestalt implies tagging `atlas_regional_biomes` cells; Paula raises the polygon alternative. | Paula / Gestalt | MEDIUM |
| OQ-R3-7 | Burnelli-Sheldon's `economic_role → DistrictType` weight table (D-C10): proposed as a const Rust array. Is the Round 3 table confirmed as the canonical default, or does it require a tuning pass against real body data before locking? | Burnelli-Sheldon | MEDIUM |
| OQ-R3-8 | Gestalt OQ-R3-G1: Is `corp_presence` granularity body-level sufficient for `TerritorialStatus` ExtractiveZone derivation, or is sub-body geography needed? Burnelli-Sheldon implies body-level is sufficient but does not address sub-body geography explicitly. | Gestalt / Burnelli-Sheldon | MEDIUM |
| OQ-R3-9 | Paula OQ-R3-P5 / Gestalt OQ-R3-G5 — AbandonedZone reclassification: derivable when a corporation's economic presence ends (both participants suggest yes), or does it require an explicit authored flag? Who triggers the reclassification? | Paula / Gestalt / Burnelli-Sheldon | LOW |
---
## Effort Summary
| Scope | Tyre estimate | Gestalt estimate | Difference |
|-------|--------------|-----------------|------------|
| Phase 3 minimum (A + B + C + stations + wilderness) | ~7d | ~7.5d (excl. TerritorialStatus and Layer F richness) | ~0.5d |
| Phase 3 full (as Gestalt / Paula scoped) | — | ~9.5d | ~2.5d gap from TerritorialStatus + rich Layer F |
| Critical path (sequential only) | ~5d (A → C → stations) | ~5.5d (scaffolding → A → C → D → F → struct) | ~0.5d |
| Phase 5 (from Round 2) | ~6d | ~6d | — |
| Phase 3 + Phase 5 minimum total (parallel) | ~7d | ~7.5d | — |
The 2.5d gap is not estimating error — it is a scope question. If OQ-R3-1 through OQ-R3-4 are resolved in favor of inclusion, ~9.5d is correct. If deferred, ~7d applies.
---
## For Lead Decision Before Proceeding
The four highest-priority open questions are lead decisions, not design questions. The design work has been done by the participants; the options are clear:
1. **OQ-R3-1:** Is `TerritorialStatus` in Phase 3 scope? (Determines Layer D, 1d, and `surrounding_territorial_status` in CityGenerationContext)
2. **OQ-R3-2:** Is `FoundingOrientation` in Phase 3 Layer A output? (Adds `founding_orientation` to CityGenerationContext and atlas_cities schema)
3. **OQ-R3-3:** Does Phase 3 include sub-settlement hierarchy? (Adds `atlas_sub_settlements` table and PoliticalTether derivation, ~1d)
4. **OQ-R3-4:** Does `atlas_road_edges` include `maintenance_authority`? (Paula/Gestalt schema vs. Tyre current schema)
All four are architectural choices that shape the Phase 3 schema and the CityGenerationContext struct. They cannot be deferred past the point where `generate_regional.py` scaffolding begins without risking a data model migration.
---
*Notes compiled by Qatux from Round 3 source files. All positions attributed to source participant. No editorial opinions added.*
@@ -0,0 +1,538 @@
---
title: "Round 4 Notes — Generation Cascade Workshop"
description: "Compiled Round 4 outputs: planet-down reframe, two governing rules, layer-by-layer requirements, ghost city effect, latent settlements, and Tyre's brief assembly"
type: workshop
status: active
workshop: generation-cascade
agent: qatux
round: 4
created: 2026-04-30
---
# Generation Cascade Workshop — Round 4 Notes
**Compiled by:** Qatux
**Source files:**
- `docs/workshops/generation-cascade/gestalt-round4.md`
- `docs/workshops/generation-cascade/paula-round4.md`
- `docs/workshops/generation-cascade/burnelli-sheldon-round4.md`
- `docs/workshops/planet-down-cascade/BRIEF.md` (Tyre's assembled brief)
**Scope:** Requirements gathering for the planet-down cascade brief. Round 4 does not design algorithms — it frames what each layer must answer mechanically, narratively, and economically. The output is a new workshop brief, not implementation decisions.
---
## Summary
Round 4 produced a reframe. The workshop entered Round 4 with a Phase 3 map (city classification, biome grid, roads, stations, wilderness) and a Phase 5 scope (district generation, chunk streaming). The lead rejected the city-outward framing and stated the correct ordering: **planet-down**. Civilization is layered onto the natural world. The player enters at the bottom of the stack and reads upward.
Gestalt provided two governing rules that become the architectural spine of all future implementation decisions. Paula provided a narrative rationale for the reframe and resolved the last major Round 3 disagreement (OQ-R3-1: TerritorialStatus is a Layer 2 post-processing output, not a dedicated layer). Burnelli-Sheldon confirmed where economics enters each layer and introduced the latent settlement principle. Tyre assembled all Round 4 material into a new workshop brief (`docs/workshops/planet-down-cascade/BRIEF.md`), converting resolved decisions into Given Facts and surfacing the remaining open questions into structured workshop sections.
Round 3 open questions OQ-R3-1 through OQ-R3-4 were resolved by the planet-down reframe or by participant convergence during Round 4.
---
## Out-of-Scope Flags
1. **No Phase 5 tile generation crept back in.** All four participants maintained the clean Phase 3/Phase 5 boundary from Round 3. Gestalt explicitly notes that the LoD zoom-in cascade is an *architectural description of runtime behavior*, not new Phase 5 scope.
2. **Named city overrides are noted but not designed.** Paula flags that named cities may need `political_archetype_override`, `prosperity_override`, and landmark names. This is deferred to workshop participants in the next brief's sections — the scope is noted, not designed here.
3. **Sub-settlement tier exhaustiveness is not resolved.** The sub-settlement hierarchy (city / town / outpost / waypoint / rural cluster / ruin) is confirmed from Round 3 consensus. How deep each tier goes through district skeleton generation (OQ-R4-G6) is explicitly an open question carried forward.
---
## The Planet-Down Reframe
### Why the reframe
Rounds 1-3 audited an existing implementation (Phase 5 tile generation, chunk streaming, DistrictSkeleton) and then designed Phase 3 to feed it. This was city-outward: start from what exists inside a city and work backwards to what the world needs to provide.
The lead identified this as the wrong causal direction. **The world exists before civilization. Civilization is layered onto it. The player enters at the bottom of that stack and reads upward.** The city-outward audit was not wrong — its findings (DistrictSkeleton, SeedChain, CityGenerationContext, all Round 2 D-records) are valid and carry forward. But the framing that organized those findings is replaced.
### The four-layer model
| Layer | What it is |
|-------|-----------|
| **1. Empty World** | Heightmap → water simulation → sub-biome refinement. Complete natural world, no civilization |
| **2. Population Overlay** | Economics onto geography. Settlements anchor at geographic nexus points. Farmland, roads, sub-settlements |
| **3. City-Level Planning** | Districts, suburbs, industrial zones, ports, stations. The "Cities Skylines" layer |
| **4. Street-Level Rendering** | Streets, buildings, walkable tiles. On demand as the player moves |
Each layer's output is the next layer's input. The layers are causal, not taxonomic.
---
## The Two Governing Rules
Gestalt identifies two rules as the architectural spine. Every system design question reduces to: does this belong in seed-locked layout, or economics-variable rendering?
### DETERMINISM RULE
> Economic simulation's rolling state affects RENDERING (prosperity, repair state) but NOT LAYOUT (streets and buildings locked by seed). A player who leaves a city and returns a year later finds the same streets, richer or poorer.
| Layer | Seed-locked (generated once, never changes) | Economics-variable (runtime updates) |
|-------|--------------------------------------------|------------------------------------|
| **1 — Empty World** | Heightmap, river network, biome grid, pass locations | Nothing — terrain is fixed |
| **2 — Population** | Settlement positions, road topology, sub-settlement placement, PoliticalTether | Road condition (edge case: abandoned roads degrade further) |
| **3 — City Planning** | District positions, district types, block grid, `prosperity_baseline`, `perimeter_treatment` | `prosperity_current` (rolling sim update); building availability |
| **4 — Street-Level** | Street positions, building footprints, door positions, all tile layout | Tile condition states (Intact/Worn/Cracked/Broken); building activity signals |
**The felled-forests exception:** "Felled forests → farmland" is regional land-use (Layer 2, coarse biome-cell resolution), not city-internal tile mutation (Layer 4). Regional land-use CAN update as economics shifts. City-internal street and building geometry is permanently seed-locked.
### LOD/ZOOM RULE
> When a player spawns, the code "zooms in" on the planet — tracing all cascade steps to where the player sits, filling in street-level detail where the player is. Each layer's output is the next layer's input. The cascade is lazy — only what the player needs is computed.
```
Planet in systems.db (Layers 1+2 data committed)
│
├── Player within planet rendering range?
│ → Regional biome overlay activates for atlas UI
│
├── Player within city footprint range?
│ → Generate city district grid (Layer 3 / Phase 1)
│ → Build DistrictMap in memory
│
├── Player within district range (chunk load radius)?
│ → load_chunk() fires per chunk in radius
│ → Layer 4 / Phase 2 generates tile data from BlockSkeleton + chunk_seed
│
└── Player at building door threshold?
→ Interior descriptor + catalog loaded (door-per-edge)
→ Interior tiles generated (Phase 4+)
```
Cities outside player range have no `DistrictMap` in memory — only their `CityGenerationContext` loaded from systems.db at startup.
---
## Gestalt — Systems Requirements by Layer
### Layer 1: Empty World
What this layer must produce beyond the existing heightmap and 64×32 biome grid:
- **Sub-biome variant tag** per regional cell (3-4 variants per biome class: e.g., `tropical` → jungle_clearing, swamp, riverine_forest, lowland_marsh)
- **`terrain_modification_cost: f32`** per regional cell — how much effort is required to settle here (high cost = forest/hills; low cost = plains/coast)
- **River network** at regional resolution: confluence points, river mouths (these are settlement attractors for Layer 2)
- **Terrain roughness signals** at regional resolution: pass locations through mountain ranges, traversable vs. impassable zones
**Game-mechanical value:** The Empty World is the legibility layer. A player reads terrain to predict settlement density and route quality before seeing a map label. A river confluence with coastal access is almost certainly a city. A mountain range with a single pass is almost certainly a chokepoint. Forced routes, natural shelter, risk gradient — all derivable from terrain before civilization exists.
**Open questions (OQ-R4-G1, OQ-R4-G5):**
- What is stored vs. seed-derived for Empty World data? River confluence points (Layer 2 settlement attractors) must be stored or deterministically queryable before Layer 2 runs. Full river course may be seed-derived.
- At what tile-resolution do rivers appear in walkable generation? If rivers are walkable obstacles (Phase 5), the Phase 2 tile generator needs tile-precision courses. If they're regional navigation constraints only, the 64×32 grid suffices. This is a cross-layer scope question.
**Minimum viable Layer 1:**
- `sub_biome_variant` column on `atlas_regional_biomes`
- `terrain_modification_cost` column on `atlas_regional_biomes`
- River confluence points stored as point features (new `atlas_geographic_features` table)
- Mountain pass candidate cells tagged
- Geographic feature tags (Paula's taxonomy — see below) attached to regional cells
- Defer: tile-resolution river routing, exact lake polygons, authored river vs. generated network reconciliation
### Layer 2: Population Overlay
What this layer produces:
- `land_use` on all regional cells (Urban / Agricultural / Industrial / Wilderness / Corridor / Ruins / Ocean / Impassable)
- Sub-settlement positions (all latent dynamic settlements placed, seed-derived)
- Road graph with `MaintenanceAuthority` per edge
- `TerritorialStatus` per regional cell (post-processing output — see Paula below)
- `political_archetype` per city (derived from geographic trigger + economic data)
- `FoundingOrientation` per city (derived from geographic trigger)
- `PoliticalTether` per sub-city settlement
**Game-mechanical value:** Population Overlay is where the world becomes a problem space. Settlement network topology creates the player's opportunity map. Underserved routes (two settlements, no direct road), extraction hubs (mining camps orbiting a tether city at increasing distance), abandoned corridors (roads leading to ruins) — all generated from absence or presence of infrastructure without authored content.
**Open questions (OQ-R4-G1, OQ-R4-G6):**
- Does "geography determines shape" mean city positions in markers.json are re-evaluated by Layer 2, or stay fixed as anchors with Layer 2 explaining them? The lead's phrasing ("anchored by wiki population counts and markers.json") implies anchored, but this must be confirmed. This determines whether `generate_regional.py` can write back to markers.json or only extends systems.db.
- What is the exact sub-settlement placement algorithm for mining camps, trade waypoints, agricultural nodes?
- Sub-settlement tiers: does a Town (1,000-10,000 population, single-district equivalent) go through the same DistrictSkeleton generation path as a city district, or does it get a simplified code path?
**Minimum viable Layer 2:**
- `land_use` column on all regional cells
- Mining camps + trade waypoints (highest-frequency sub-settlements)
- Road graph with `MaintenanceAuthority` per edge
- `TerritorialStatus` per regional cell
- `political_archetype` per city (derived, override column)
- `FoundingOrientation` per city (derived from geographic trigger)
- Defer: exact agricultural node positions, shadow node placement
### Layer 3: City-Level Planning
What this layer produces:
- N-district grid per city (positions in city-local sim tiles using D-C4 formula)
- `DistrictSkeleton` Phase 1 Stages 1-2 for each district (classification + 4×4 block grid)
- `prosperity_baseline` per district (seed-locked, from WorldTier + economic data)
- `perimeter_treatment` per district
- District spatial arrangement shaped by `FoundingOrientation` + `political_archetype`
- Prosperity gradient direction and magnitude across districts
**Game-mechanical value:** City legibility as player skill. A player who understands political archetypes, WorldTier patterns, and economic roles can walk up to an unfamiliar city and make accurate predictions about its internal structure before entering. CompanyTown = spine pattern; AdminCapital = center-radiating; FreePort = multiple nodes. These are consistent, generatable, and learnable by the player.
**Open questions (OQ-R4-G3, OQ-R4-G4):**
- `prosperity_index` naming: the Round 3 field must be renamed to `prosperity_baseline` (seed-locked Layer 3 output) as distinct from `prosperity_current` (runtime simulation state). These must never be conflated. Tyre's brief implements this naming.
- Does `FoundingOrientation` modify the district placement grid (spatial rotation/alignment), or only modify the prosperity gradient direction without changing grid positions? Explicit spatial orientation produces more legible cities; gradient-only is simpler.
- Does City-Level Planning produce explicit arterial road layout, or are district boundaries the implicit road network?
- Does Layer 3 receive topographic constraints per district, or does it treat all cities as flat for the minimum slice?
**Minimum viable Layer 3:**
- Phase 1 Stage 1: classification per district (DistrictType from weight table, LayoutMode, `prosperity_baseline`, `perimeter_treatment`)
- Phase 1 Stage 2: 4×4 BlockSkeleton grid
- `political_archetype` consumed from systems.db (Layer 2 output)
- Defer: `FoundingOrientation` spatial grid orientation, explicit arterials, topographic constraints per district, Stages 3-5
### Layer 4: Street-Level Rendering
What this layer produces:
- 64×64 `TileEntry` grids (`GeneratorChunkData`) per chunk, on demand
- 3 canonical tile types: `floor_street`, `floor_interior`, `wall`
- Tile condition state per tile: `Intact | Worn | Cracked | Broken` (economics-variable, not seed-locked)
- Building footprints with door positions (door-per-edge boundary)
**Game-mechanical value:** Street level is where the player has physical presence. Seed-locked layout means cities reward familiarity — return visits operate on remembered spatial knowledge rather than re-exploring unfamiliar territory. Economics-variable tile conditions create observable history: a district in decline shows accumulating decay tiles; a developing district shows repair states.
**Open questions (OQ-R4-G2, OQ-R4-G6 partial):**
- How exactly does economics-variable rendering work? Three options: bake at generation + update on event; compute live from `prosperity_current` each frame; cache per chunk, invalidate on threshold crossing. The brief must specify: mechanism, update trigger, caching model.
- What triggers interior tile generation? Pre-generate when player approaches door threshold (avoids stall, wastes compute); generate on door crossing (simplest); pre-generate with exterior (memory cost).
- Does "scatter civilization" include prop spawn points, surface decals, entity spawn points? Layer 4 scope vs. runtime entity system must be defined.
**Minimum viable Layer 4:**
- 3 tile types generating walkable, navigable space
- Seed-locked layout from `district_seed` via FNV-1a
- Chunk streaming hookup: `load_chunk()` calls tile generation via `DistrictMap`
- Defer: tile condition overlays, building interior generation, prop/decal scatter
### Gestalt Open Questions Summary
| ID | Layer | Question | Stakes |
|----|-------|----------|--------|
| OQ-R4-G1 | 2 | City positions in markers.json: anchored with geographic explanation, or re-derived from attractors? | Architecture — determines whether generate_regional.py can write back to markers.json |
| OQ-R4-G2 | 4 | Economics-variable rendering mechanism: bake, live, or threshold-cached? | Implementation — determines tile condition system architecture |
| OQ-R4-G3 | 3+4 | `prosperity_index` naming: `prosperity_baseline` (stored, seed-locked) vs. `prosperity_current` (runtime sim state) — two distinct fields, must not be conflated | Data model |
| OQ-R4-G4 | 3 | Explicit arterial road layout vs. district boundaries as implicit streets | Scope |
| OQ-R4-G5 | 1→4 | River resolution: regional constraint only, or walkable tile-level obstacle? | Cross-layer scope |
| OQ-R4-G6 | 2→3 | Sub-settlement tiers: unified DistrictSkeleton path, or simplified code path for Towns? | Implementation scope |
---
## Paula — "Geography Is Political History"
### The narrative framing
> "Cities don't create geography. Geography creates cities — and then political structures grow to control what geography made valuable."
The planet-down cascade corrects causality for narrative purposes. In the city-outward framing, geographic logic had to be inferred backwards from settlement positions. In the planet-down framing, geographic logic comes first. Settlement character is immediately legible from founding conditions because the generator doesn't need to be told what a pass city is administratively oriented toward — the geography tells it.
### Layer 1: Geographic feature tags
Paula establishes that Layer 1 must produce a **tagged geographic feature layer** for Layer 2 settlement placement to work without re-deriving everything from raw heightmap data.
**Mandatory tags for narrative-legible settlement generation:**
| Tag | What it marks | Political potential |
|-----|--------------|---------------------|
| `RiverConfluence` | Where two navigable rivers meet | Trading nexus; FreePort or AdminCapital archetype |
| `CoastalHarbor` | Natural harbor, protected anchorage | Maritime commerce; FreePort archetype |
| `MountainPass` | Lowest crossing point in a range | Checkpoint/control; AdminCapital or Contested |
| `ArablePlain` | Flat land with soil quality above threshold | Agricultural settlement; dependent on nearest nexus |
| `ResourceConcentration` | Mineral outcrop, energy source, rare material | Extraction; CompanyTown archetype |
| `Defensible` | Elevated with sightlines, natural chokepoint | Military/administrative; AdminCapital |
| `NaturalBarrier` | Range, wide river, dense terrain | Future political boundary |
Layer 1 does not name anything. Rivers, mountains, and passes exist but have no names. Geographic names are first assigned at Layer 2 when settlement begins. Major atlas-visible terrain features may receive geographic placeholder names at Layer 1 for atlas UI labeling.
### Layer 2: TerritorialStatus as post-processing output (resolves OQ-R3-1)
Paula settles the Round 3 disagreement definitively:
> "TerritorialStatus doesn't need its own dedicated layer — it is a post-processing output of the settlement placement and road generation steps that already exist in Layer 2."
The six values are derived after settlement placement completes:
| Value | Derivation |
|-------|-----------|
| `CoreTerritory` | High settlement density + road network well-maintained throughout |
| `FrontierTerritory` | Settlements placed + roads thin at distance from major city |
| `ExtractiveZone` | Settlement at `ResourceConcentration` + corporate presence + excellent extraction corridor + absent off-corridor |
| `ContestZone` | Two settlement clusters with competing road networks in overlapping territory |
| `WildernessBuffer` | No settlement placed — no geographic trigger or economics rationale |
| `AbandonedZone` | Settlement placed (geographic rationale existed at generation time), but economic basis exhausted |
**This resolves OQ-R3-1.** TerritorialStatus is a post-processing classification pass at Layer 2 — it costs one classification step after settlement placement, not a dedicated layer. The Gestalt/Tyre Layer D debate is closed.
### Layer 2: FoundingOrientation is geography, not authoring
`FoundingOrientation` is directly readable from the geographic trigger:
| Layer 2 trigger | FoundingOrientation |
|-----------------|---------------------|
| `CoastalHarbor` or `RiverConfluence` with port | `PortFacing` |
| Transit/rail junction | `RailHeadFacing` |
| `ResourceConcentration` | `ResourceFacing` |
| `Defensible` terrain | `DefenseFacing` |
| No geographic trigger (AdminCapital placed by political decision) | `AdminFacing` |
No authoring pass required. The exception: named cities where setting lore explicitly overrides the geographic logic.
### Layer 2: Road networks and MaintenanceAuthority
`MaintenanceAuthority` falls directly from road endpoint relationships at road generation time — no separate authored pass required:
- Corporate extraction site → corporate depot: `Corporate`
- Administrative capital → administered sub-settlement: `Administrative`
- Settlement cluster with trade flow but no administrative relationship: `Trade`
- Settlement → settlement with no economic or administrative tie: `Communal`
- Connection whose rationale has since dissolved: `Abandoned`
**This resolves OQ-R3-4** — MaintenanceAuthority is derivable at generation time without a separate schema decision beyond adding the column.
### Layer 3: FoundingOrientation shapes spatial direction
`FoundingOrientation` + `political_archetype` determines which direction the city grows and which end is oldest:
- `PortFacing` + `FreePort`: older districts near water, newer development inland
- `ResourceFacing` + `CompanyTown`: city is a spine, not a circle; the resource is at one end
- `DefenseFacing` + `AdminCapital`: city radiates from the fortified high point
- No trigger + `OrganicGrowth`: city grew around multiple centers; no single direction is oldest
The prosperity gradient runs from founding end toward expansion end: oldest districts (closest to founding geography) are most organic, lowest-prosperity; newest development (expansion end) is planned, higher-prosperity. This is a district placement rule, not a separate computation.
### The Ghost City Effect and prosperity_delta
Paula identifies the most powerful narrative consequence of the Determinism Rule:
> "A district's current state and its intended state are always both visible."
**The two-field architecture:**
- **`prosperity_baseline: f32`** — seed-locked Layer 3 output. What the district was planned as. Stored in `CityGenerationContext` and atlas tables.
- **`prosperity_current: f32`** (or `prosperity_delta: f32 = prosperity_current - prosperity_baseline`) — runtime simulation state. What current economic health says.
- **Effective prosperity** = `prosperity_baseline + prosperity_delta` (clamped to [0.0, 1.0]). Drives tile selection.
**The narrative:** When these diverge significantly, the player is looking at the ghost city effect. A CompanyTown where the corporation has departed still has company-town street vocabulary (wide factory roads, tight worker housing grids, no civic center). Streets broadcast what the district was planned to be. Tile decay broadcasts that no one is maintaining that plan. The gap is the story, without a word of dialogue.
This is not a technical refinement. If `prosperity_baseline` is just a sim-updated value with no seed-derived component, the ghost city effect disappears.
### Ruins as structural permanence
When the economic sim flags a settlement for ruination (resource depleted, corporation departed, route obsoleted), the physical structure remains — streets, building footprints, cleared ground. What changes:
- `prosperity_delta` drops sharply
- `perimeter_treatment` degrades (Checkpoint → Fenced → Open)
- Landmark condition deteriorates
The ruin is a ghost city at settlement scale. The geography tagged it as worth settling. Someone built there. Now no one maintains it. The gap between structure (still present) and tile state (decayed) is the full story.
### Accepted proposals remapped to planet-down layers
| Proposal | Origin | Planet-down position |
|----------|--------|---------------------|
| `prosperity_baseline: f32` on DistrictSkeleton | Round 2 (as `prosperity_index`) | **Layer 3 output** (seed-locked baseline); economic sim produces `prosperity_current` at runtime |
| `perimeter_treatment: PerimeterTreatment` | Round 2 | **Layer 3 output** (derived from DistrictType + WorldTier + prosperity_baseline at generation time) |
| `political_archetype` (derived + override) | Round 2 | **Layer 2 output** (derived from geographic trigger + economic data; override slot for named cities) |
| Naming registers (lookup table) | Round 2 | **Layers 2-4**: geographic register at Layer 2, functional/commemorative at Layer 3, street names at Layer 4 |
| Sub-settlement hierarchy | Round 3 | **Layer 2 output** (settlements from geographic triggers; PoliticalTether from why they exist) |
| `MaintenanceAuthority` on roads | Round 3 | **Layer 2 output** (from road endpoint relationships; no separate pass) |
| `TerritorialStatus` | Round 3 | **Layer 2 post-processing output** (after settlement placement completes) |
| `FoundingOrientation` | Round 3 | **Layer 2 → Layer 3 handoff** (directly from geographic trigger) |
| `DualNaming` | Round 3 | **Layer 2 initialization** (ContestZone conditions) → **Layer 3 proliferation** → **Layer 4 display** |
---
## Burnelli-Sheldon — Economics Integration
### Where economics enters
**Layer 1: Weakly (optional biome probability prior)**
Economics does not shape terrain geometry. But a body's `economic_role` is the *outcome* of someone deciding the terrain was worth exploiting. This creates a plausibility constraint: an `agricultural` world had to have arable soil. Burnelli-Sheldon proposes using `bodies.economic_role` + `bodies.planet_class` as a soft biome probability prior in sub-biome refinement. This is optional — `planet_class` may encode this well enough already. The risk of excluding it is self-contradictory worlds (agricultural role on volcanic terrain).
**Layer 2: In full (primary economics entry point)**
The `atlas_cities`, `bodies`, `corp_presence`, `atlas_roads`, `atlas_railroads`, and `system_economy` tables all enter at Layer 2. Economic role determines hinterland character:
| economic_role | Hinterland fill |
|---|---|
| agricultural | Cleared farmland, irrigation networks, processing nodes |
| extraction | Access corridors, mining infrastructure, stockpile areas |
| manufacturing | Industrial fringe, rail freight yards, supply zones |
| transit | Dense road/rail, relay stations, fuel depots |
| research | Exclusion zones, observatory sites, quiet buffer |
| service_mixed | Suburban residential spread, civic infrastructure |
### The latent settlement principle
Under the Determinism Rule, settlement positions must be seed-locked at generation time. But economic conditions change. The reconciliation:
> The generator places ALL economically plausible settlement positions at Layer 2 generation time. Whether each settlement is ACTIVE is determined by the economic simulation's rolling state. An inactive settlement exists spatially — as ruins, empty structures, cleared ground — but it is dark, unmaintained, and depopulated.
This is economically accurate. Ghost towns exist. The buildings are there; no one lives in them. The road to the closed mine still exists.
**Per settlement type:**
- **Mining camps:** Placed at Layer 2 from `corp_presence` trigger conditions. Active when `corp_financial_state.health_metric > 0.4` for the operating corp. The geological deposit doesn't move when the corp fails.
- **Trade route waypoints:** Long roads (`atlas_roads` with `point_count > 5`) get a seed-derived waypoint at the geometric midpoint. Active when the terminal system remains connected. Even abandoned trade routes leave ruins.
- **Agricultural dispersed nodes:** `settlement_pattern IN ('dispersed', 'dispersed_rural')` AND `economic_role = 'agricultural'` → farm cluster positions at interval derived from `1/economic_tier`. Active by corp health. Fallow farms differ from ghost towns: cleared land, overgrown structures, but clearing and track remain.
- **Shadow economy nodes:** `collection_efficiency < 0.6` → one informal settlement adjacent to the largest atlas city. Active/character changes if enforcement conditions change.
### Prosperity as render parameter, not tile mutation
The most important implication of the Determinism split:
> `prosperity_index` (now `prosperity_baseline`/`prosperity_current`) should NOT be implemented as tile-level ChunkMutations for routine decay. It is a single float per district. The renderer reads it and applies a decay probability to tile selection.
When `prosperity_current` drops from 0.6 to 0.3: streets don't change, building footprints don't change — only the tile SELECTION changes (cracked pavement variant, boarded windows, rust on facades). This happens purely in the rendering path, with no world mutation. **ChunkMutations remain reserved for player-caused or explicit-event damage.**
This distinction matters for performance: prosperity changes don't generate millions of tile mutations.
### Per-layer economic field mappings
**Layer 1:**
| Field | Source | Usage |
|---|---|---|
| `economic_role` | bodies | Biome probability prior (optional) |
| `planet_class`, `atmosphere`, `surface_gravity` | bodies | Primary physical inputs |
**Layer 2 (full list):**
| Field | Source | Usage |
|---|---|---|
| City positions, populations, kinds | atlas_cities | Settlement anchors |
| `economic_role`, `settlement_pattern`, `industrial_corridor`, `population` | bodies | Hinterland character |
| `economic_tier`, `economic_base_primary/secondary` | system_economy | Infrastructure density |
| `gate_connections`, `gate_topology` | system_gates | Trade route intensity |
| `corp_id`, `location_id`, `primary_operation`, `behavioral_archetype` | corp_presence + corporations | Corporate cluster positions |
| Road/rail entries | atlas_roads, atlas_railroads | Existing infrastructure |
| `collection_efficiency` | system_fiscal | Shadow node trigger |
| `health_metric` | corp_financial_state | Settlement active/ghost at generation time |
**Layer 3:**
The 10×9 matrix applies here, modified by topographic constraints from Layer 1 and infrastructure access from Layer 2. Matrix provides zoning prior; terrain provides spatial constraints. Key additions: `distribution_index` sets prosperity gradient magnitude; `FoundingOrientation` (from Layer 2) sets gradient direction.
**Layer 4 at generation time:**
| Field | Source | Usage |
|---|---|---|
| `bulk_class` | commodities via corp_presence | Building archetype |
| `base_premium_multiplier` | brand_products | Landmark building height |
| `prosperity_baseline` | Per-district Layer 3 output | Initial tile variant selection |
**Layer 4 at runtime (no tile mutation):**
| Signal | Source | Render effect |
|---|---|---|
| `prosperity_current` (updated by sim) | Economic simulation | Tile variant selection |
| `corp_financial_state.health_metric` | Economic simulation | Corporate signage presence |
| Active/ghost settlement status | Economic simulation | Lighting, population density rendering |
### Data quality issues (blockers before Layer 3)
1. **`behavioral_archetype` is mostly NULL** on corporation rows. Fallback chain: `behavioral_archetype` → `supply_chain_role` → commodity tier from `primary_operation`. A backfill step in `import_economics.py` should fill NULL values before `generate_regional.py` runs.
2. **`scope` field is mixed-type text** (enum-ish strings + prose descriptions). Generator cannot reliably parse prose. Until normalized, treat any non-matching value as `system`-tier.
### Burnelli-Sheldon Open Questions
| ID | Question | Recommended answer |
|----|----------|-------------------|
| OQ-R4-B1 | Where does the settlement active/ghost flag live? | In a systems.db Phase 3 output table (settlement positions + trigger conditions). Sim reads these alongside `corp_financial_state`. |
| OQ-R4-B2 | Does "felled forests → farmland" generate ChunkMutations? | No — it's a `regional_land_use` column update at biome-cell resolution, not tile mutation. Different resolution from Layer 4. |
| OQ-R4-B3 | `prosperity_baseline` at district-level or block-level? | Per-district scalar for now (consistent with Phase 5 architecture). Can migrate to BlockSkeleton in a later pass if too coarse. |
| OQ-R4-B4 | How does `distribution_index = stratified` interact with topography? | Topography sets gradient DIRECTION (which end is high-prosperity); `distribution_index` sets gradient MAGNITUDE (how steep). |
---
## Tyre — Planet-Down Cascade Brief Assembly
### What Tyre's brief does
Tyre assembled all Round 4 material into `docs/workshops/planet-down-cascade/BRIEF.md`. This brief is the output of the generation-cascade workshop and the input to the next workshop. It has three components:
1. **The Lead's Cascade Vision** — reproduced verbatim (four layers + two critical rules)
2. **Given Facts** — all decisions from Rounds 1-4 that are locked and must not be revisited
3. **Workshop Sections** — the open questions that the next workshop must resolve
### What became Given Facts
The brief treats the following as locked:
- Phase 3 = Python tooling + systems.db; Phase 5 = Rust runtime + seed-derived; fully parallelizable
- `generate_regional.py` as the third pipeline generator (runs after `generate_atlas.py` in `make regen-db`)
- Atlas markers are the last stored layer; everything from Layer 3 down is seed-derived
- No markers.json extension — city attributes via systems.db cross-reference on `body_id`
- Atlas coordinates are UI-only (no pixel-to-sim-tile formula)
- FNV-1a SeedChain (D-010, D-C6)
- `CityGenerationContext` struct with `prosperity_baseline` field name (replaces `prosperity_index` — implements OQ-R4-G3)
- City decomposition formula (D-C4)
- District Skeleton Phase 1 Stages 1-2 minimum
- WorldTier enum (corrected to Epicenter | Regional | Backwater | Passage | Waypoint)
- `GeneratorChunkData` upgrade to `Vec<TileEntry>` (D-C7)
- 6-field minimum read set (D-C9) and 10×9 weight table (D-C10)
- Sub-settlement hierarchy and placement triggers (Round 3 consensus)
- Latent settlement principle (Burnelli-Sheldon Round 4)
- Determinism boundary table per layer
- LoD zoom-in cascade
### The CityGenerationContext update
The brief's canonical struct resolves OQ-R4-G3 (naming question) and OQ-R3-2 (FoundingOrientation scope):
```rust
pub struct CityGenerationContext {
pub city_id: String,
pub political_archetype: PoliticalArchetype,
pub prosperity_baseline: f32, // seed-locked Layer 3 output — NOT prosperity_index
pub surrounding_biome: BiomeClass,
pub road_entry_directions: Vec<CardinalDirection>,
pub footprint_radius_km: f32,
pub founding_orientation: FoundingOrientation, // now included (OQ-R3-2 resolved)
pub world_tier: WorldTier,
}
```
`prosperity_current: f32` is a separate runtime simulation field. These two must never be conflated in implementation.
### What became open questions in the new brief
The brief surfaces the remaining unresolved questions as structured workshop sections with specific question IDs:
- **L1-Q1 through L1-Q4:** Layer 1 — authored rivers vs. drainage network, resolution, stored vs. derived, biome prior
- **L2-Q1 through L2-Q6:** Layer 2 — anchor vs. re-derive positions, sub-settlement placement algorithm, road generation algorithm, hinterland shapes, TerritorialStatus quantitative thresholds, latent flag storage
- **L3-Q1 through L3-Q7:** Layer 3 — FoundingOrientation spatial effect, political_archetype spatial arrangement, arterial roads, topographic constraints, prosperity_baseline formula, sub-settlement code path, port/station direction
- **L4-Q1 through L4-Q6:** Layer 4 — economics rendering mechanism, condition update trigger, tile algorithm parameters, interior generation trigger, "scatter civilization" scope, prosperity_delta two-field architecture
- **CL-Q1 through CL-Q4:** Cross-layer — prosperity naming confirmation, regional land-use evolution, ruin lifecycle, authored rivers vs. drainage network (elevated)
---
## Round 3 Open Questions Resolved by Round 4
| Round 3 OQ | Question | Resolution | Source |
|----|----------|-----------|--------|
| OQ-R3-1 | TerritorialStatus: dedicated Phase 3 layer or absent? | **Layer 2 post-processing output.** One classification pass after settlement placement + road generation. Not a dedicated layer. | Paula Round 4 |
| OQ-R3-2 | `FoundingOrientation`: include in Phase 3 Layer A + CityGenerationContext? | **Yes.** Directly readable from geographic trigger. In CityGenerationContext. | Paula/Tyre Round 4 |
| OQ-R3-3 | Sub-settlement hierarchy: include `atlas_sub_settlements` table in Phase 3? | **Included as lead inclusion** (confirmed by brief Given Facts). Sub-settlements are part of Layer 2 output. | Tyre's brief |
| OQ-R3-4 | `MaintenanceAuthority` on `atlas_road_edges`: include? | **Yes.** Derivable from road endpoint relationships at generation time. | Paula Round 4 |
---
## Open Questions Carried Forward
These feed directly into the planet-down-cascade workshop brief:
**Must be resolved before Layer 1 can be implemented:**
- OQ-R4-G5 / L1-Q1: Authored rivers vs. seeded drainage network — relationship and reconciliation
- L1-Q2: River network resolution (regional-only or intermediate)
- L1-Q3: Stored vs. derived for river network data
**Must be resolved before Layer 2 can be implemented:**
- OQ-R4-G1 / L2-Q1: City positions — anchored (generate_regional.py explains geography) vs. re-derived from attractors
- L2-Q5: TerritorialStatus quantitative derivation thresholds
- OQ-R4-B1 / L2-Q6: Latent settlement active/ghost flag storage location
**Must be resolved before Layer 3 can be implemented:**
- OQ-R4-G3 / CL-Q1: `prosperity_baseline` vs. `prosperity_current` naming — confirmed in brief; must be enforced in implementation
- L3-Q1: FoundingOrientation spatial grid orientation vs. gradient-only modification
- L3-Q2: `political_archetype` spatial arrangement — explicit vs. emergent
**Must be resolved before Layer 4 can be implemented:**
- OQ-R4-G2 / L4-Q1: Economics-variable rendering mechanism (bake/live/threshold-cached)
- OQ-R4-B2 / CL-Q2: Regional land-use evolution resolution boundary — runtime `regional_land_use` update vs. ChunkMutations
- L4-Q6 / CL-Q1: `prosperity_delta` two-field architecture — formal adoption
---
*Notes compiled by Qatux from Round 4 source files. All positions attributed to source participant. No editorial opinions added.*
@@ -0,0 +1,338 @@
---
title: "Tyre — Round 1: Technical Architecture Inventory"
description: "Layer-by-layer audit of the generation pipeline: state, data format boundaries, dependency order, testing strategy"
type: workshop
status: active
workshop: generation-cascade
agent: tyre
round: 1
created: 2026-04-30
---
# Generation Cascade — Round 1: Technical Architecture Inventory
**Tyre — Technical Architect**
---
## Summary Verdict
The pipeline has a hard cliff at Layer 5. Everything above it (galactic through city placement) is working production code. Everything below it is either stub types or missing entirely. The two-phase district generator architecture is well-designed on paper; zero generation code exists.
The thinnest vertical slice from heightmap to walkable tile — a flat single-district world with no social structure — costs roughly 6–7 dev-days starting from today's codebase.
---
## Pipeline Inventory
### Layer 1 — Galactic Scale
**State: DONE**
| Property | Detail |
|----------|--------|
| Input | Wiki TOML files, body `index.md` frontmatter |
| Output | `systems.db` — `star_systems`, `bodies`, economics tables |
| Code | `tooling/economy-db/import_economics.py` + Rust `generate_brands` binary |
| Format | SQLite (file-based; read-only canonical snapshot) |
No gaps. This is the upstream root for all other layers.
---
### Layer 2 — Planetary Heightmap Simulation
**State: DONE**
| Property | Detail |
|----------|--------|
| Input | Body definition dict (planet_class, parameters from `index.md`) |
| Output | Terrain dict: NumPy arrays for elevation, temperature, moisture, surface_water, river_grid |
| Code | `tooling/planet-gen/planet_simulation.py` |
| Format | In-memory NumPy (not persisted separately; consumed immediately by atlas generator) |
No gaps. Called by `generate_atlas.py` as `simulate(body_def)`.
---
### Layer 3 — Atlas City Placement and Infrastructure
**State: DONE**
| Property | Detail |
|----------|--------|
| Input | Terrain dict (Layer 2) + body definition from `systems.db` |
| Output | `markers.json` per body (cities, roads, rail, POIs) + atlas tables in `systems.db` |
| Code | `tooling/planet-gen/generate_atlas.py` |
| Format | JSON files (pixel space: 512×256 grid) + SQLite |
Output schema: `cities[{id, name, kind, center: [row, col], population}]`, roads/railroads as A\* paths. Gate terminal POI placed at largest population centre.
No gaps in the atlas layer itself. The gap is *downstream*: nothing in the Rust runtime consumes this output.
**Critical scale fact:** Atlas coordinates are (row, col) in a 512×256 pixel grid. A district is 512×512 sim tiles = 256×256 visual tiles = 256m. The ratio between atlas pixels and sim tiles is undefined and unimplemented. This is the first missing translation.
---
### Layer 4 — City Naming
**State: DONE**
| Property | Detail |
|----------|--------|
| Input | `markers.json` (empty name fields) |
| Output | `markers.json` (named cities and features, Gemma-driven) |
| Code | `tooling/planet-gen/gemma_naming.py` |
| Format | JSON (same file, in-place update) |
No gaps.
---
### Layer 5 — City-to-District Decomposition
**State: MISSING — no design, no code, no types**
This is the first gap in the entire pipeline. A city marker in the atlas is a single pixel coordinate. A walkable district is 512×512 sim tiles. There is no code or design that answers:
- How many districts does a city of population N get?
- What is the coordinate system mapping from atlas pixel-space to world tile-space?
- How are district boundaries drawn between cities?
- How are inter-city "wilderness" districts handled?
- What district IDs are assigned, and how are they derived deterministically from the world seed?
The `DistrictContext` type on `DistrictSkeleton` is `pub type DistrictContext = String` — it exists to hold world position and neighboring district references, but nothing populates it.
**Data format boundary:** Python JSON (markers.json, 512×256 pixel coordinates) → Rust runtime (`DistrictId`, world tile positions). No bridge code exists.
**Minimum viable implementation:** A function that takes a body's atlas markers and produces a `Vec<(DistrictId, world_tile_origin)>` — one district per city, deterministically positioned. This is the unlock for everything below.
---
### Layer 6 — DistrictSkeleton Generation (Phase 1)
**State: STUB — types compile, zero generation code**
The designed five-stage pipeline from the workshop-outcomes.md:
| Stage | What | Code state |
|-------|------|------------|
| Stage 1: Classification | WorldTier, ComplexityTier, SettingType | Types exist; no generation logic |
| Stage 2: Block grid | 4×4 BlockSkeleton, DistrictLayoutMode | Types exist; no generation logic |
| Stage 3: Reservation | MultiBlockReservation, skyscrapers, terminals | Types exist; no generation logic |
| Stage 4: Social site + NPC | SocialSitePlacement, triangles, DerivedDistrictAnalysis | Types exist; no generation logic |
| Stage 5: Guarantee audit | GuaranteeAuditResult | Type is `String` stub |
Every sub-type that requires actual content or logic is a `pub type Foo = String` alias:
- `DistrictContext`, `SocietyProfileRef`, `ZoneDefinition`, `DistrictBoundaries`
- `GuaranteeAuditResult`, `CorridorSpine`, `ChunkLayout`, `CorridorSpine`
- `Era`, `EraModification`, `LandmarkSlot`, `AccessPoint`
**WorldTier naming mismatch (bug to fix before any generation work):**
`generator.rs` defines: `Peripheral | Connected | Core`
Workshop outcomes (L-3, lead decision): `Epicenter | Regional | Backwater | Passage | Waypoint`
These are incompatible. The workshop canonical values must replace the current enum before the generation spike begins.
**Missing DistrictSkeleton fields:**
The struct in `generator.rs` is missing three fields specified in the workshop outcomes design:
- `vertical_structure: VerticalStructure` (from D-READY-11)
- `breach_only_zones: Vec<ZoneId>` (from guarantee audit, Tier 2)
- `derived_analysis: DerivedDistrictAnalysis` (Phase 1 computed, from Miri/Gestalt)
**Minimum viable implementation:** Stage 1 + Stage 2 only. Classification from WorldTier + flat 4×4 block grid with uniform zoning. No reservations, no social sites, no audit. Produces a compilable DistrictSkeleton that can be handed to Phase 2.
Workshop estimate for Phase 1 basic: ~3 dev-days.
---
### Layer 7 — ChunkData Generation (Phase 2)
**State: MISSING — no code, no type resolution**
Phase 2 is the per-chunk fill step that runs when a chunk is first loaded. It reads the `BlockSkeleton` for the block containing the chunk and produces tile data (walls, floors, furniture placements).
`GeneratorChunkData` is `pub type GeneratorChunkData = Vec<bool>` — a flat boolean walkability array. The actual tile type system (`TileId`) is `pub type TileId = String`. No tile vocabulary exists.
The heritage grammar overlay system (D-READY-9, `HeritageGrammarOverlay`) does not exist as Rust code or TOML data. It was designed in the workshop but nothing has been authored.
**Data format boundary:** `BlockSkeleton` (Rust struct) → 64×64 tile grid (`GeneratorChunkData`). The generation logic for this conversion is entirely absent.
**Minimum viable implementation:** Produce a flat walkable floor tile grid for each chunk, with wall tiles on chunk boundaries. No rooms, no furniture. Just a walkable district footprint. Heritage grammar can be deferred.
Workshop estimate for Phase 2 basic: ~4 dev-days. With heritage grammar: substantially more.
---
### Layer 8 — Chunk Streaming + Generator Integration
**State: PARTIAL — streaming works, generator hookup is zero**
`chunk_streaming.rs` correctly manages load/unload based on player position. It compiles, passes tests, and handles z-level isolation properly. The architecture is sound.
The gap: `WalkabilityMap::load_chunk()` creates `ChunkData::new_walkable()` — a fully-walkable blank placeholder. It has no knowledge of districts, no connection to any generator, and no way to distinguish "in a building" from "in open space."
For Phase 5 (generated world), `load_chunk()` needs to:
1. Determine which district the chunk coordinate falls within
2. Look up the `DistrictSkeleton` for that district
3. Identify the `BlockSkeleton` for the block containing this chunk
4. Run Phase 2 fill and return real tile data
None of steps 1–4 exist. The streaming system is a ready container waiting for content.
**Minimum viable integration:** A `DistrictMap` resource mapping `ChunkCoord → DistrictId`, and a Phase 2 dispatch in `load_chunk()`. ~1 dev-day once Phase 1 and 2 exist.
---
### Layer 9 — Social Site Templates and Triangle System
**State: DONE (runtime system, not wired to generator output)**
`triangle.rs` is fully implemented production code:
- `FullTemplateDef`, `RoleSchema`, `SpaceSpec`, `TriangleDef` — content schema with validation
- `generate_intra_template_triangles` and `generate_cross_template_triangles` — working
- `tick_triangle_escalation` and `apply_resolve_triangle` — working ECS systems
- Tests pass, serialization correct, FNV-1a determinism enforced (D-010)
The gap: `DistrictSkeleton.social_sites` contains `Vec<SocialSitePlacement>` with `template_tag: String` references. Nothing reads those tags and spawns `TemplateOwnership` components. The link from generator output to runtime triangle population is missing.
---
### Layer 10 — NPC Generation Spike
**State: PARTIAL — proof-of-concept, not production pipeline**
`server/src/bin/generator_spike.rs` (Sprint 25) generates NPCs from `ZoneSpec + CultureProfile` inputs. It produces named NPCs with traits, wants, behaviors, relationships, and want-tells. It works and demonstrates the 10-axis model is viable.
However: it is a standalone binary that bypasses ECS entirely. It operates on `ZoneSpec` (not `DistrictSkeleton`). It is not wired to the spatial generation pipeline. Its output is printed to stdout, not spawned into the ECS world.
The gap between this spike and production NPC generation: `ZoneSpec` must be derived from `BlockSkeleton.zoning` + `DistrictSkeleton.society_profile`, then NPCs must be spawned with `TemplateOwnership` components into the bevy ECS world.
---
## Data Format Boundaries
Four boundaries, three of them currently bridgeless:
```
Python atlas output → [BRIDGELESS] → Rust runtime
markers.json → → DistrictMap resource
(512×256 pixel coords) → → (sim tile coords)
Atlas city marker → [BRIDGELESS] → District grid
(row, col) pixel → → Vec<(DistrictId, ChunkCoord)>
DistrictSkeleton (Rust) → [BRIDGELESS] → ChunkData (Rust)
Phase 1 output → → Phase 2 fill
BlockSkeleton → → 64×64 tile grid
chunk_streaming.rs → [PARTIAL] → generator
load_chunk() → → ChunkData::new_walkable() (wrong)
```
The only working boundary is Python atlas → `systems.db` (the atlas tables), which is consumed by the Godot atlas map UI but not by the game simulation server.
---
## Strict Dependency Order
```
[DONE] Layer 1-4: Atlas pipeline complete
↓
[MISSING] Layer 5: City-to-district decomposition ← FIRST UNLOCK
↓
[STUB] Layer 6: DistrictSkeleton Phase 1 generation
↓ (WorldTier enum fix is a prerequisite)
[MISSING] Layer 7: ChunkData Phase 2 fill
↓
[PARTIAL] Layer 8: chunk_streaming → generator wiring
↓
↓ character/apartment work gates here
[BACKLOG] #681 Apartment generator
[BACKLOG] #682 Apartment rendering
```
**Parallelization opportunities within the blocked stack:**
- Once Layer 5 design is locked, Phase 1 (Layer 6) and content authoring (heritage grammar TOML, zone-type RON files, culture profiles) can proceed in parallel — they share no code dependency
- Different districts can be generated in parallel once Layer 6 code exists (each district is seed-isolated)
- Triangle template YAML authoring has no code dependency on Layer 5-8 and can proceed now
**NOT blocked by generation pipeline:**
- #619 Full character customization — Phase 4 work on a hand-authored 2-floor test map
- #694 Character creation screen — Phase 4, same rationale
- #616 Verb vocabulary design — content design work, cascade phase 4
---
## Ticket Analysis
### Tickets with incorrect cascade position (should be formally blocked)
| Ticket | Title | Current status | Issue |
|--------|-------|----------------|-------|
| #681 | Apartment generator (server) | backlog | Needs walkable generated world (Layers 5-8 complete). Should be explicitly blocked. |
| #682 | Apartment rendering (client) | backlog | Blocked by #681, which is blocked by the generation pipeline. |
| #615 | Tycoon small business starting state | backlog | Feeds #681. Should be blocked behind pipeline completion. |
### Tickets that can proceed (Phase 4 per cascade definition)
| Ticket | Title | Reasoning |
|--------|-------|-----------|
| #694 | Character creation screen | Phase 4: player control scheme on 2-floor test map. Does not require generated world. |
| #619 | Full character customization | Phase 4: same. Aesthetic-only, works on hand-authored map. |
| #616 | Verb vocabulary design | Content design. No code dependency on generation pipeline. |
### New tickets needed
These represent the actual missing implementation work:
1. **"Fix WorldTier enum values in generator.rs"** — Replace `Peripheral/Connected/Core` with `Epicenter/Regional/Backwater/Passage/Waypoint` per lead decision L-3. Blocked by nothing. ~0.5 dev-days.
2. **"City-to-district decomposition — spec and implementation"** — Define atlas pixel → world tile coordinate mapping, district count per city, district boundary algorithm. Produces `Vec<(DistrictId, world_tile_origin)>` from atlas markers. Blocks all Phase 1/2 work. ~2 dev-days.
3. **"DistrictSkeleton Phase 1 generator (Stages 1-2)"** — Classification + block grid. Minimal viable Phase 1 that produces a compilable skeleton. Blocked by ticket 1 and 2. ~3 dev-days.
4. **"Add missing DistrictSkeleton fields"** — `vertical_structure`, `breach_only_zones`, `derived_analysis` from workshop spec. Can be stub types initially, but must be present for schema completeness. ~0.5 dev-days.
5. **"ChunkData Phase 2 minimal fill"** — Flat walkable floor with wall boundaries. No rooms, no furniture. Blocked by ticket 3. ~2 dev-days (minimal slice).
6. **"Wire generator to chunk_streaming"** — `DistrictMap` resource + dispatch in `load_chunk()`. Blocked by tickets 2, 3, 5. ~1 dev-day.
---
## Testing Strategy Per Layer
**Design principle:** Each layer produces deterministic output from a seed. Same seed = same output. This is the primary invariant to test at every layer.
| Layer | Test approach | Independent? |
|-------|--------------|--------------|
| Atlas (done) | `make regen-db` + `make check-systems-db-stamp` + visual Godot inspection | Yes |
| City-to-district decomp | Unit test: given N markers, produces M districts with valid positions and no overlaps. Determinism: same seed → same output. | Yes |
| DistrictSkeleton | Unit test: same seed → identical skeleton (byte-level). Coverage test: 100 seeds → no two identical skeletons. Guarantee audit: all Tier 1 sites present for inhabited districts. | Yes |
| ChunkData fill | Gauntlet snapshot test (new Gauntlet room for generated district). Visual inspection in `--test-mode SR_LIVE=1`. Walkability consistency: every Phase 2 chunk must have ≥1 walkable tile. | Yes |
| Chunk streaming integration | Existing `chunk_streaming` tests verify load/unload logic. New: integration test verifying loaded chunks are non-blank after generator wiring. | Partially (streaming logic tested; fill content not yet) |
The Gauntlet test world should get a dedicated "generated district" room for Phase 2 validation once Phase 1+2 exist. Per CLAUDE.md rules, this must be a new room, not modification of existing rooms.
---
## Implementation Priority Order
Ordered by what unblocks the most:
1. **Fix WorldTier enum** — 0.5 days, unblocks all generator work (nothing runs correctly until this is right)
2. **City-to-district decomp** — 2 days, the first real gap; unblocks all Phase 1/2 code
3. **DistrictSkeleton Phase 1 (Stages 1-2 only)** — 3 days, produces first generated skeletons
4. **Missing DistrictSkeleton fields** — 0.5 days, schema completeness
5. **ChunkData Phase 2 minimal** — 2 days, produces first walkable generated tiles
6. **Chunk streaming integration** — 1 day, first end-to-end walkable generated world
7. **Full Phase 2 with rooms/furniture** — 4+ days (after vertical slice validated)
8. **Vertical structure / z-levels** — 7 days, Phase 4/5+ (per workshop estimate)
9. **MobileChunk** — 9.5 days, deferred until static districts work
**Total for thinnest vertical slice (flat walkable district):** ~9 dev-days
**Formal gate:** Character and apartment work (#681, #682, #615) should be blocked in the ticket system behind the vertical slice milestone (items 1-6 above complete).
---
## Appendix: Key Architectural Constraints to Preserve
These are non-negotiable from the workshop and D-records:
- **D-010 determinism:** All generation must consume randomness exclusively through `SimRng`. No `std::hash::DefaultHasher`. Same seed = same world, always.
- **D-010 BTreeMap:** All `HashMap` usage in generator output types is prohibited. Use `BTreeMap` throughout.
- **D-108 MobileChunk:** Vessel interiors use the same Phase 2 primitives as static districts. No separate generation path.
- **D-110 signed z-levels:** `base_z: i8` for positions, `z_levels: u8` for counts. Already implemented correctly in the types.
- **L-7 XOR prohibition:** In-playthrough events use `DamageOverlay` exclusively. No XOR reseeding.
- **Two-phase immutability:** Phase 1 output is frozen. All runtime changes go through the overlay/delta layer.
- **chunk_streaming cadence:** Default 10 ticks (1 game-minute). Phase 2 fill must complete within a single cadence window or be deferred to background.
@@ -0,0 +1,488 @@
---
title: "Tyre — Round 2: Spatial Generation Architecture Proposal"
description: "Technical architecture for the spatial pipeline: coordinate translation, city-to-district decomposition, Phase 1/2 generation, chunk wiring, seed boundary, testing, and effort"
type: workshop
status: active
workshop: generation-cascade
agent: tyre
round: 2
created: 2026-04-30
---
# Generation Cascade — Round 2: Spatial Generation Architecture Proposal
**Tyre — Technical Architect**
---
## Scope Declaration
Per lead directive: this proposal covers the **spatial pipeline only** — from atlas city markers to walkable rendered tiles. Heritage grammar, NPC manifests, social sites, triangle wiring, culture overlays — all deferred. The remaining questions from Round 1 that fall outside spatial scope (OQ-R1-A heritage root count, OQ-R1-B SocietyProfileRef type) are not addressed here.
Spatial scope items I am addressing:
- OQ-R1-C: City marker schema approach
- OQ-R1-D: Atlas coordinate translation formula (resolved below)
- OQ-R1-E: Vertical slice effort estimate (revised)
- Gap B: Coordinate system design
- Gap C: Missing DistrictSkeleton fields
---
## The Core Architecture Insight: Two Separate Coordinate Systems
This resolves OQ-R1-D and underpins everything below.
The atlas grid (512×256 pixels) represents the **planetary surface** as a display coordinate system for the atlas UI. It shows continents, coastlines, city dots. It is not a world-space coordinate for the game simulation.
The walkable world uses a **city-local coordinate system**, measured in sim tiles (0.5m each, per D-066). A city does not "exist" at pixel (row, col) in the sim world — it exists in its own local space starting at (0, 0). The atlas pixel position is only used for:
1. Rendering the city dot on the planetary map UI
2. Determining relative positions *between* cities on the same body (future: overland travel)
**There is no pixel-to-sim-tile conversion formula** and none is needed. The translation is a lookup, not arithmetic:
```
atlas_city.center = [row, col] → planetary UI display position only
walkable world origin → always (0, 0) in city-local sim tile space
```
This is the correct architectural answer to OQ-R1-D. Attempting to produce a linear formula from 512×256 pixels to a planetary sim-tile grid would require deciding how large the planet is in sim tiles — a meaningless number for gameplay. Players don't walk between cities.
**What this means for district positioning:** Districts in a city are laid out in city-local sim tile space. District 0 is at world position (0, 0). District 1 is at (512, 0) (one district-width east). And so on. The atlas pixel coord stays in the atlas layer; the district layout algorithm works purely in sim tile space.
---
## Seed vs. Stored-Data Boundary
This is the most important architectural decision in the spatial pipeline. I'll be direct about where the line is.
### What is stored (authored or generated-then-committed)
| Data | Location | Why stored |
|------|----------|-----------|
| Star system and body definitions | `wiki/`, `systems.db` bodies table | Authored world content |
| Economic data, corporations | `systems.db` economics tables | Generated-then-committed via pipeline |
| City positions, populations, infrastructure | `markers.json`, `systems.db` atlas tables | Generated-then-committed; hand-edit possible |
| City names | `markers.json` (Gemma-filled) | Curated; not reproducible on demand |
| Chunk mutations (DamageOverlay, player changes) | Save file (future) | Playthrough-specific |
### What is NOT stored — always re-derived from seed
| Data | Why seed-derived |
|------|-----------------|
| District count and positions per city | Function of (city_data, world_seed) |
| DistrictSkeleton (all fields) | Function of (DistrictId, body_context, world_seed) |
| BlockSkeleton per district | Function of (district_seed) |
| ChunkData tile grid | Function of (block_skeleton, chunk_coord, district_seed) |
| Building footprints within chunks | Function of (zoning, density, chunk_seed) |
**The boundary is between Layer 4 and Layer 6:** everything from city markers downward is seed-derived and never persisted as generator output. The `markers.json` city entries are the stored anchor. Below that, the world is fully deterministic from `world_seed`.
### The SeedChain
Every child seed is derived from its parent using FNV-1a (per D-010's determinism mandate):
```rust
fn child_seed(parent: u64, discriminant: u64) -> u64 {
let mut h = parent ^ 0xcbf29ce484222325;
for byte in discriminant.to_le_bytes() {
h ^= byte as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
district_seed = child_seed(world_seed, district_id)
chunk_seed = child_seed(district_seed, (cx as u64) << 32 | (cy as u64))
```
This means:
- Any district can be generated in isolation, without generating its neighbors first
- Any chunk can be generated in isolation, given its district_seed
- Generation is embarrassingly parallelizable
- No global state is required
### Implication for save files
Save files store:
- `world_seed: u64` (the single master seed)
- `markers_snapshot: HashMap<BodyId, MarkersJson>` (or a hash of it for validation)
- `chunk_mutations: BTreeMap<ChunkCoord, ChunkMutations>` (only player-modified chunks)
Everything else is re-derived on load. A save file for a large world stays small.
---
## Layer-by-Layer Architecture Proposal
### Layer 6: City → District Decomposition
**Inputs:**
- `body_id: &str`
- `city: &AtlasCity` (from systems.db atlas_cities table — `id`, `kind`, `population`)
- `body: &Body` (from systems.db bodies table — `settlement_pattern`, `economic_role`, `planet_class`)
- `world_seed: u64`
**Algorithm:**
```rust
fn decompose_city(city: &AtlasCity, body: &Body, world_seed: u64) -> Vec<DistrictPlacement> {
let n = district_count(city.population, city.kind, body.settlement_pattern);
let layout = city_layout_grid(n, city_seed(world_seed, &city.id));
layout.iter().enumerate().map(|(i, grid_pos)| DistrictPlacement {
district_id: district_id(world_seed, &body.body_id, &city.id, i as u32),
world_origin: grid_to_sim_tiles(grid_pos), // (col * 512, row * 512) in sim tiles
city_id: city.id.clone(),
}).collect()
}
```
**District count formula:**
```
population → n_districts:
0 → 0 (uninhabited)
1–50k → 1
50k–500k → 2–4 (log-scaled)
500k–5M → 4–9 (log-scaled)
5M+ → 9–16 (capped at 4×4)
capital → +1 bonus district
domed/cave → fixed 1 regardless of population
```
**District positioning:** A simple grid arrangement in city-local space. District at grid position (col, row) gets world origin `(col * 512, row * 512)` sim tiles. Grid dimensions chosen to be roughly square for the given count (e.g., 4 districts → 2×2; 6 districts → 3×2). Exact grid shape is seeded for slight variety (some cities are long and thin, some square).
**OQ-R1-C resolution:** `primary_function`, `planet_class`, and `settlement_pattern` are available in `systems.db` bodies table and do NOT need to be added to `markers.json`. The decomposition function reads them from systems.db directly, cross-referenced by body_id. `generate_atlas.py` does not need modification.
**Output:**
```rust
struct DistrictPlacement {
district_id: DistrictId, // u64, deterministic from world_seed + body + city + index
world_origin: (i64, i64), // sim tile position of district's (0,0) corner
city_id: String,
}
```
**Testing:** Pure function. Unit test: given (population, settlement_pattern), district count matches formula. Given same (city, body, seed), district positions are identical across runs. Given different seeds, positions vary. No ECS needed.
---
### Layer 7: Phase 1 — DistrictSkeleton (Spatial Structure Only)
**Scope for minimum vertical slice:** Stages 1 and 2 only. Stages 3 (reservations), 4 (social sites), and 5 (guarantee audit) are deferred — they produce spatial enrichment but are not required for a walkable tile.
**Stage 1: Classification**
```rust
fn classify_district(placement: &DistrictPlacement, body: &Body, city: &AtlasCity)
-> (WorldTier, ComplexityTier, SettingType, DistrictType)
```
- `WorldTier`: derived from system economic tier in systems.db (`economic_role`, system connectivity). Hub systems → `Epicenter`; standard → `Regional` or `Backwater`; transit stops → `Passage`.
- `ComplexityTier`: derived from WorldTier ceiling table (workshop-outcomes.md)
- `SettingType`: derived from `planet_class` + `economic_role` + city `kind`. Capital on a standard planet → `Urban`. Rural body → `Agricultural`. Orbital station → `Station`.
- `DistrictType`: derived from city `kind` + district index within city (center district → `Commercial` or `MixedUse`; outer districts → `Residential`, `Industrial`, `Transit`)
All deterministic from upstream data. No RNG needed at Stage 1 — classification is a pure lookup/mapping.
**Stage 2: Block grid**
```rust
fn generate_block_grid(district_seed: u64, district_type: &DistrictType,
complexity: &ComplexityTier) -> [[BlockSkeleton; 4]; 4]
```
- `DistrictLayoutMode`: seeded choice between `Grid` and `Organic`. Weight toward Grid for Commercial/Administrative, toward Organic for Residential/Mixed. Lead decision L-2: both modes must coexist.
- Per block: assign `ZoningType` from a seeded draw against the district's type-to-zoning probability table. Commercial district → mostly Commercial blocks with some Residential edges.
- Per block: assign `density_pct` seeded from district_seed + block position. Creates density gradient (center blocks denser than edge blocks for Urban districts).
- `Era`: stub string for now (`"standard"`). Real era differentiation is later work.
- Everything else on `BlockSkeleton` (`chunk_layout`, `hosted_sites`, `landmark`) → left as empty/stub.
**Key constraint:** The block grid is fully determined by `(district_seed, district_type, complexity)`. No external lookups at generation time. The entire `DistrictSkeleton` can be re-derived from those three inputs plus `world_seed`.
**Missing fields to add:** Per Gap C from Round 1, these three fields are required by the workshop spec and absent from the current struct:
- `vertical_structure: VerticalStructure` — add as stub enum `VerticalStructure::Flat` for now
- `breach_only_zones: Vec<ZoneId>` — add as `Vec<String>`, empty for minimum slice
- `derived_analysis: DerivedDistrictAnalysis` — add as stub struct, all fields `None`/default
These are 0.5 days of type work.
**WorldTier fix:** The enum must be corrected before any generation work starts. One-line change, 0.5 days including test update.
---
### Layer 8: Phase 2 — ChunkData Tile Generation (Spatial Only)
This layer converts a `BlockSkeleton` into a 64×64 sim-tile grid of walkable/non-walkable tiles with tile type assignments.
**Inputs:**
- `block: &BlockSkeleton` (zoning, density_pct, layout from Phase 1)
- `chunk_coord: ChunkCoord` (which 2×2 chunk-within-block this is)
- `chunk_seed: u64` (derived from district_seed + chunk_coord)
**Output:** A 64×64 tile grid (the raw `GeneratorChunkData`)
**Minimum viable generation algorithm:**
The key insight: for the minimum slice, we don't need heritage grammar, room templates, or furniture. We need:
1. Street tiles (walkable, visually distinct)
2. Building exterior tiles (wall, non-walkable)
3. Building interior floor tiles (walkable)
```rust
fn generate_chunk_tiles(block: &BlockSkeleton, within_block_pos: (u8, u8),
chunk_seed: u64) -> GeneratorChunkData {
let density = block.density_pct;
let mut rng = SimRng::from_seed(chunk_seed);
// Step 1: Fill with street tile
// Step 2: Place building footprints based on density
// - density 0-20%: 0-1 small buildings
// - density 20-60%: 2-4 medium buildings
// - density 60-100%: 4-8 buildings, larger, tighter
// Step 3: For each footprint: fill interior with floor tile, perimeter with wall tile
// Step 4: Ensure street connectivity (no building can fully block a chunk edge)
}
```
**Building footprint algorithm:** Simple non-overlapping rectangle placement using seeded random dimensions within density budget. Minimum building: 6×6 tiles. Minimum street gap between buildings: 4 tiles. This is not artistically sophisticated but produces walkable, navigable space.
**Tile vocabulary for minimum slice:**
```rust
// Minimum TileId set (just string constants to start)
const TILE_FLOOR_STREET: &str = "floor_street";
const TILE_FLOOR_INTERIOR: &str = "floor_interior";
const TILE_WALL: &str = "wall";
```
Three tile types. Everything needed for walkability and basic navigation. Visual polish is a later pass.
**`GeneratorChunkData` type change:** The current type alias `pub type GeneratorChunkData = Vec<bool>` must become a proper struct to carry tile IDs, not just walkability. Minimum:
```rust
pub struct GeneratorChunkData {
pub tiles: Vec<TileEntry>, // 64×64 = 4096 entries
}
pub struct TileEntry {
pub tile_id: TileId, // "floor_street", "wall", etc.
pub walkable: bool, // pre-computed for the movement system
}
```
This is a type-level change that breaks the placeholder `ChunkData::new_walkable()` usage cleanly — making the transition visible rather than silent.
---
### Layer 9: Chunk Streaming → Generator Wiring
The chunk streaming architecture is ready. The hookup requires two additions:
**Addition 1: `DistrictMap` resource**
```rust
#[derive(Resource)]
pub struct DistrictMap {
/// Maps a chunk coord to the district it belongs to.
/// Built at world load time from DistrictPlacement data.
chunks: BTreeMap<ChunkCoord, DistrictId>,
/// The Phase 1 skeleton for each loaded district.
skeletons: BTreeMap<DistrictId, DistrictSkeleton>,
}
```
**Addition 2: Replace `load_chunk()` placeholder**
```rust
pub fn load_chunk(&mut self, coord: ChunkCoord, district_map: &DistrictMap,
world_seed: u64) -> bool {
if self.chunks.contains_key(&coord) { return false; }
let chunk_data = if let Some(district_id) = district_map.chunks.get(&coord) {
let skeleton = &district_map.skeletons[district_id];
let block = block_for_chunk(skeleton, coord);
let chunk_seed = child_seed(child_seed(world_seed, *district_id),
coord_to_u64(coord));
generate_chunk_tiles(block, within_block_pos(coord), chunk_seed)
} else {
// Outside any district — wilderness, open terrain
GeneratorChunkData::all_walkable()
};
self.chunks.insert(coord, chunk_data);
true
}
```
This is the complete wiring. No other changes to `chunk_streaming.rs` are needed.
---
## Data Format Boundaries
The handoff between Python tooling and Rust runtime is clean and already exists:
```
PYTHON TOOLING (offline pipeline)
systems.db → bodies, star_systems, economics, atlas_cities tables
markers.json → city positions and populations (per body)
↓ [HANDOFF — server startup reads these two sources]
RUST SERVER (runtime)
At startup: read systems.db + markers.json → build DistrictMap in memory
On load_chunk(): derive ChunkData from district_seed + block_skeleton
On save: persist only ChunkMutations (player-modified chunks)
```
**The handoff is currently wired:** `server/src/main.rs` references the generator. Systems.db is read at startup. The gap is not the boundary mechanism — it's that no code reads atlas_cities to build a DistrictMap.
**No Python layer needed for Phase 1 or Phase 2.** The entire spatial generation below city markers is Rust code, working against data already in systems.db. This is clean.
---
## Testing Strategy Per Layer
Each layer produces deterministic output and can be validated independently.
### City decomposition
```rust
#[test]
fn decompose_city_deterministic() {
let result1 = decompose_city(&city, &body, 42);
let result2 = decompose_city(&city, &body, 42);
assert_eq!(result1, result2);
}
#[test]
fn district_count_formula() {
assert_eq!(district_count(0, "city", "standard"), 0);
assert_eq!(district_count(10_000, "city", "standard"), 1);
assert_eq!(district_count(1_000_000, "capital", "standard"), 5); // 4 + 1 capital bonus
}
```
Fully unit-testable. No ECS, no file I/O.
### Phase 1 DistrictSkeleton
```rust
#[test]
fn skeleton_same_seed_identical() {
let s1 = generate_skeleton(placement, body, city, 42);
let s2 = generate_skeleton(placement, body, city, 42);
assert_eq!(s1, s2); // requires PartialEq on DistrictSkeleton — needs real types, not String stubs
}
#[test]
fn block_grid_coverage() {
let s = generate_skeleton(placement, body, city, 42);
// Every block has a valid ZoningType
for row in &s.blocks {
for block in row {
assert!(block.zoning != ZoningType::Mixed || s.district_type == DistrictType::MixedUse);
}
}
}
```
Requires replacing String stub types with real enums/structs (which is also needed to implement generation). Tests become the specification.
### Phase 2 ChunkData
```rust
#[test]
fn chunk_has_walkable_tiles() {
let data = generate_chunk_tiles(&block, (0, 0), 99);
let walkable_count = data.tiles.iter().filter(|t| t.walkable).count();
assert!(walkable_count >= 100, "chunk must have ≥100 walkable tiles for NPC routing");
}
#[test]
fn chunk_edges_walkable() {
// Ensure no chunk boundary is fully blocked (prevents dead zones at chunk seams)
let data = generate_chunk_tiles(&block, (0, 0), 99);
// at least 4 walkable tiles on each edge
}
#[test]
fn chunk_deterministic() {
let d1 = generate_chunk_tiles(&block, (0, 0), 42);
let d2 = generate_chunk_tiles(&block, (0, 0), 42);
assert_eq!(d1.tiles, d2.tiles);
}
```
For visual/rendering validation: a Gauntlet room with a generated district. Run `--test-mode SR_LIVE=1` and walk the tiles manually. Per CLAUDE.md, new room added — existing Gauntlet rooms are not modified.
### End-to-end integration
Spawn a player in a generated city. Verify:
1. All chunks within `ChunkLoadRadius` load without panic
2. Player can move (walkability map is non-empty)
3. Second load produces identical tile layout (determinism check)
This is a live server test using existing `--test-mode` infrastructure.
---
## Effort Estimates — Revised
My Round 1 estimate (9 days) was contaminated by heritage grammar work that is now out of scope. Revised for spatial-only:
| Item | Effort | Notes |
|------|--------|-------|
| Fix WorldTier enum + test updates | 0.5d | One enum change, propagate through compilation errors |
| Add missing DistrictSkeleton fields as stubs | 0.5d | Type-only change; no logic |
| Replace String stubs with real types (Stages 1-2 scope) | 1d | `DistrictContext`, `DistrictBoundaries`, etc. — enough to implement Stages 1-2 |
| City-to-district decomposition | 1d | Pure function, well-defined algorithm, unit-testable |
| Server startup: read atlas data → build DistrictMap | 0.5d | Reading systems.db + markers.json; already have DB access patterns |
| Phase 1 generation (Stages 1-2 only) | 1.5d | Classification lookup + seeded block grid |
| `GeneratorChunkData` type upgrade (Vec<bool> → TileEntry grid) | 0.5d | Type change + update `new_walkable()` caller |
| Phase 2 minimal tile generation | 1.5d | Floor/wall placement from density, seeded rectangles |
| Chunk streaming wiring | 0.5d | `DistrictMap` resource + dispatch in `load_chunk()` |
| Integration test (walkable generated city) | 0.5d | Gauntlet room + live test |
**Total: ~8 days** — but only ~6 of those are truly sequential (critical path). The type changes and test writing can overlap with algorithm work.
**Minimum parallelizable scope:** If two developers were available:
- Dev A: Layers 6-7 (city decomp + Phase 1)
- Dev B: Layers 8-9 (Phase 2 + streaming wiring, stubbing Phase 1 output)
Parallelized: ~4 days wall-clock.
### Reconciling with Gestalt's estimate
Gestalt's 5-7 days appears to assume the String stub replacements are free (fold into Phase 1 work) and the startup/DistrictMap wiring is minimal. Both are reasonable simplifications. The key actual divergence: I have explicit line items for the type upgrade (`GeneratorChunkData`) and the server startup path that Gestalt may have assumed as already wired. Neither of us included heritage grammar — that was the bulk of the Round 1 overestimate.
Gestalt's floor estimate (~5 days) represents the fastest possible path with no unexpected compilation surprises from the String-to-real-type migrations. My 8 days is the conservative estimate including that friction. True answer is somewhere between: **~6-7 days** under reasonable conditions.
---
## Open Question Resolutions
### OQ-R1-D (Atlas coordinate translation)
Resolved: there is no pixel-to-sim-tile formula. The atlas coordinates are UI-only. The walkable world uses city-local coordinates starting at (0, 0). See "Core Architecture Insight" section above.
### OQ-R1-C (City marker schema)
Resolved: `primary_function`, `planet_class`, `settlement_pattern` are read from `systems.db` bodies table by cross-reference on `body_id`. `generate_atlas.py` does not need modification.
### OQ-R1-E (Effort estimate)
Resolved: ~6-7 days for minimum spatial vertical slice (spatial only, no heritage grammar, no social sites). Conservative estimate is 8 days including type migration friction.
### OQ-R1-F (#615 cascade position)
Per lead directive: #615 is NPC territory and out of scope for this workshop. No position taken.
---
## What This Proposal Defers
To be explicit about what is NOT in scope for the vertical slice:
- Heritage grammar (Phase 2 tile aesthetics beyond floor/wall)
- Room templates (rectangular buildings replace proper room grammar)
- DistrictSkeleton Stages 3-5 (reservations, social sites, guarantee audit)
- Building interiors with furniture or objects
- Vertical structure / z-levels beyond ground floor
- MobileChunk
- NPC placement
None of these are needed for the question "can the player walk through a generated district?" They can be layered in after the spatial plumbing exists.
@@ -0,0 +1,598 @@
---
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.
@@ -0,0 +1,442 @@
---
title: "Workshop Outcomes — Generation Cascade Workshop"
description: "Authoritative record of the generation-cascade workshop: original intent, scope corrections, round-by-round journey, all resolved decisions, what was locked and deferred, and how the workshop output became a new workshop brief"
type: workshop
status: complete
workshop: generation-cascade
agent: qatux
created: 2026-04-30
---
# Generation Cascade Workshop — Outcomes
**Compiled by:** Qatux
**Dates:** 2026-04-30 (all four rounds)
**Status:** Complete. Output delivered. Successor workshop brief produced.
---
## What This Document Is
This is the authoritative record of the generation-cascade workshop. It documents the full journey — what the workshop set out to do, what changed and why across each round, what was resolved, what was deferred, and how the output became input to the next workshop.
The technical decisions are captured in detail in the per-round notes files. This document captures the narrative: why this workshop ran, what shape it took, and what came out of it.
---
## Original Intent
The generation-cascade workshop was initiated to answer a specific question: **can we implement the generation pipeline now, and if so, what's blocking us?**
The context: character creation work (#694) and apartment generation (#681) were queued. Before committing implementation capacity to these systems, the team needed to know whether the world generation pipeline that would feed them was solid.
**The brief framing:** A 3-round implementation audit using a city-outward approach — start from the walkable city environment that the player would inhabit, trace every generation layer the city required, and determine which layers were implemented, which were stubs, and which were missing entirely.
**The blocking intent:** Several tickets (#681, #694, character apartment work) would not be formally unblocked until the generation pipeline was either proven complete or proven to have a clear implementation path with scoped tickets.
**Initial participants:** Gestalt (systems design), Tyre (technical architecture), Miri (worldbuilding and content).
---
## Round 1: The Inventory
**Question:** What does the pipeline look like? What works, what's stubbed, what's missing?
### What the round found
All three participants independently converged on the same finding: the pipeline has a hard operational cliff at Layer 5 (atlas markers). Everything above the cliff — galactic definitions, planetary heightmaps, city placement, economics layer — is working production code. Everything below the cliff is either missing or compiled stubs with no generation logic.
The two critical missing layers:
- **Layer 6: City → District decomposition** — no design, no code, no ticket. The missing link between atlas output and walkable world.
- **Layer 9: Phase 2 ChunkData tile generation** — entirely absent. `GeneratorChunkData = Vec<bool>`. No tile vocabulary. No fill logic.
Additional findings:
- `WorldTier` enum in `generator.rs` uses wrong values (`Peripheral | Connected | Core`) instead of the workshop-canonical values (`Epicenter | Regional | Backwater | Passage | Waypoint`). Hard blocker for all downstream work.
- `DistrictSkeleton` fields compile but are `pub type Foo = String` — zero generation logic.
- The triangle system (`triangle.rs`) is substantially more complete than the brief indicated — it was incorrectly labeled as stubs. Wiring gap, not code gap.
- `chunk_streaming.rs` is architecturally sound and tested; `load_chunk()` just creates blank placeholders instead of calling a generator.
### Content gaps (Miri)
Miri's audit identified content gaps orthogonal to the code gaps:
- Heritage root blend specification missing — culture RON files don't include heritage blend weights
- `HeritageGrammarOverlay` data files don't exist for any of the 10 heritage roots
- Zone identity definitions missing for 7 of 9 DistrictType values
- City marker schema insufficient for Phase 1 inputs (`primary_function`, `planet_class`, `settlement_pattern` not in markers.json)
**Round 1 open questions:** 8 items (OQ-R1-A through OQ-R1-H). Critical blockers: heritage root count disagreement (7 vs. 10), `SocietyProfileRef` type design, atlas coordinate translation question.
---
## Scope Correction: Between Round 1 and Round 2
**The lead intervened between rounds to restructure scope.**
Before Round 2 launched, the lead issued a directive removing the following from workshop scope:
- Heritage grammar overlay (#615, all associated heritage content)
- NPC population systems
- Social sites generation
- #615 (Tycoon starting state) — treated as out of scope for this workshop entirely
**Why this mattered:** Tyre's Round 1 estimate (~9 dev-days for the minimum vertical slice) was contaminated by heritage grammar scope. Once removed, estimates would converge. More importantly, the city-outward audit had been dragging attention to Phase 6 content (cultural room grammar, NPC bundles, heritage overlay) when Phase 5 walkable world generation hadn't been designed yet.
**What got dropped from the participants list:** Miri did not have a role in Round 2 or later. Her content audit (Round 1) was valid, but the workshop pivoted to spatial pipeline work.
**Round 2 added:** Paula (narrative + political depth), who had spatial analysis relevant to the pipeline that neither Gestalt nor Tyre were covering.
---
## Round 2: The Spatial Pipeline
**Question:** Given spatial-only scope, what does the implementation order look like? What are the key architecture decisions?
**New participant:** Paula (narrative/political depth, spatial signals in generation).
### Technical resolutions
Two Round 1 open questions resolved by independent convergence:
**OQ-R1-C (City marker schema):** Do not extend markers.json. `primary_function`, `planet_class`, `settlement_pattern` are read from systems.db bodies table by cross-reference on `body_id`. markers.json stays as a pure spatial + naming store.
**OQ-R1-D (Atlas coordinate translation):** No formula needed. Atlas coordinates are UI-only (display coordinate system for the atlas UI). The walkable world uses city-local sim-tile coordinates from (0,0). Districts in a city are placed at `(col × 512, row × 512)` sim tiles in city-local space.
**Stored-vs-derived boundary locked:** Atlas markers are the last stored layer. Everything at Layer 6 and below is seed-derived at runtime. Save files store: `world_seed` + markers snapshot hash + `ChunkMutations` only.
**SeedChain algorithm confirmed:** FNV-1a `child_seed(parent, discriminant)` for all randomness below the atlas layer. Any district or chunk can be generated in isolation without generating neighbors first.
**Effort converged:** ~6 dev-days (spatial scope only, Stages 1-2 minimum). Round 1's 9d estimate was heritage-contaminated.
**Implementation order locked** (5-step sequential chain):
1. Fix WorldTier enum (0.5d)
2. Single-district decomposition stub (0.5d)
3. Phase 1 Stages 1-2 generation (1.5-2d)
4. Phase 2 minimal tile generator (1.5d)
5. Chunk streaming wiring (0.5-1d) → **Minimum Viable Walkable World**
### Paula's additions
Paula introduced four spatial concepts not addressed by Gestalt or Tyre. These required Round 3 disposition:
- `prosperity_index: f32` on DistrictSkeleton — scalar driving tile decay, road width, building density, green space
- `perimeter_treatment: PerimeterTreatment` on DistrictSkeleton — boundary marker enum (Open/Fenced/Walled/Gated/Checkpoint)
- `political_archetype` per city — formal derived field (CompanyTown/AdminCapital/FreePort/Contested/OrganicGrowth)
- Naming registers — `(DistrictType, WorldTier) → register` lookup table; name pool population deferred as cultural content
Paula's argument for including `prosperity_index` and `perimeter_treatment` before Phase 2: if they're not on DistrictSkeleton before Phase 2 is implemented, tile generation produces spatially indistinguishable spaces regardless of district type — and retrofitting is a Phase 2 rewrite.
### D-record candidates from Round 2 (D-C1 through D-C8)
| ID | Decision |
|----|---------|
| D-C1 | Generation pipeline layer definitions (canonical layer sequence with storage policies) |
| D-C2 | Stored-vs-derived transition — atlas markers are the last stored layer |
| D-C3 | Phase 1 minimum spatial scope — Stages 1-2 mandatory, Stages 3-5 deferred |
| D-C4 | City → district decomposition formula (population/50,000 + WorldTier ceilings) |
| D-C5 | City-local coordinate system for districts (atlas is UI-only; no pixel-to-tile formula) |
| D-C6 | SeedChain algorithm — FNV-1a `child_seed(parent, discriminant)` |
| D-C7 | `GeneratorChunkData` type upgrade — `Vec<bool>` → `Vec<TileEntry>` with `tile_id` + `walkable` |
| D-C8 | Spatial class legibility requirements — `prosperity_index`, `perimeter_treatment`, `layout_mode` on DistrictSkeleton (Paula; not yet confirmed by Gestalt/Tyre) |
**Round 2 open questions:** 7 items (OQ-R2-1 through OQ-R2-7). Most critical: prosperity_index and perimeter_treatment inclusion (OQ-R2-1), political_archetype formality (OQ-R2-2), Phase 2 tile algorithm choice (OQ-R2-3).
---
## Round 3: The Phase 3 Reframe
**Question:** What does Phase 3 (the map/atlas layer, before Phase 5 runtime generation) actually need to produce?
**New participant:** Burnelli-Sheldon (economics + simulation modeling), added to provide quantitative economic grounding for the district generation bridge.
### The corrective
Early in Round 3, Tyre identified a structural problem with the workshop framing:
> "Rounds 1 and 2 were auditing and designing Phase 5 (runtime tile generation). Phase 3 — which produces the regional maps that Phase 5 consumes — had not been designed at all."
This was correct. The city-outward audit had jumped from atlas markers (Phase 3 output) directly to district generation (Phase 5), skipping the intermediate layer that was supposed to enrich the atlas with regional data: road networks, biome grids, station topology, city classification data.
**The reframe:** Phase 3 requires a new Python generator (`generate_regional.py`) running third in the `make regen-db` pipeline. It enriches systems.db with city classification, regional biome data, road graphs, station topology, and (debated) sub-settlement hierarchy. All Phase 3 computation is offline. Phase 5 reads its output at startup via a Rust struct (`CityGenerationContext`). Both phases are fully parallelizable during development.
### Key consensus
**All four participants independently arrived at the same Phase 3/Phase 5 boundary:**
- Phase 3 = Python tooling, offline, committed to systems.db
- Phase 5 = Rust runtime, seed-derived, never stored
- The two phases do not block each other during development
**All four participants agreed on Phase 1 scope (OQ-R2-1 resolved):** `prosperity_index` and `perimeter_treatment` must be on DistrictSkeleton before Phase 2. Retrofit cost exceeds add-now cost.
**All seven Round 2 open questions resolved** by Round 3.
### Paula's Phase 3 additions
Paula introduced six new spatial/political fields all derivable from existing systems.db data:
- `TerritorialStatus` — six-value enum for regional political classification (CoreTerritory/FrontierTerritory/ExtractiveZone/ContestZone/WildernessBuffer/AbandonedZone)
- `FoundingOrientation` — five-value enum for city founding direction (PortFacing/RailHeadFacing/ResourceFacing/DefenseFacing/AdminFacing)
- `MaintenanceAuthority` on road segments — five-value enum (Administrative/Corporate/Communal/Trade/Abandoned)
- `PoliticalTether` per sub-city settlement — relationship classification to nearest tether city
- `DualNaming`/`RegionalFeatureName` — ContestZone naming conditions
- Settlement hierarchy (town/outpost/waypoint/rural cluster/ruin tiers)
### Burnelli-Sheldon's economics bridge
Burnelli-Sheldon provided:
- 6-field minimum read set — all fields queryable from systems.db per city; sufficient to produce a spatially differentiated Phase 1 skeleton without additional authoring
- 10×9 economic_role → DistrictType weight table — canonical probability weights mapping all 10 `economic_role` values to all 9 DistrictType values
- Three concrete differentiation cases demonstrating how the same DistrictType produces different spatial character from economic data alone (Tributarium/Bluebank/Strata)
- `prosperity_index` derivation formula with `distribution_index = stratified` vs. `moderate` paths
- Three narrow fields requiring post-generation reporting back to the economics simulation (effective port tile count, corp district allocation, shadow economy footprint)
### Disagreements remaining after Round 3
| Gap | Gestalt/Paula position | Tyre's implicit position |
|-----|----------------------|------------------------|
| TerritorialStatus | Dedicated Phase 3 Layer D, in CityGenerationContext | Not addressed; absent from layer design |
| FoundingOrientation | Layer A output, in CityGenerationContext | Absent from struct |
| Sub-settlement hierarchy | `atlas_sub_settlements` table, PoliticalTether derivation | Land-use annotation only |
| MaintenanceAuthority on roads | `atlas_road_edges` schema column | Absent from schema |
### D-record candidates from Round 3 (D-C9 through D-C17)
| ID | Decision |
|----|---------|
| D-C9 | Economic fields → generator parameter mapping (6-field minimum read set, 3-query pattern) |
| D-C10 | `economic_role` → DistrictType weight table (10×9 canonical default) |
| D-C11 | Phase 3 layer sequence and tooling architecture (`generate_regional.py` as third generator) |
| D-C12 | `CityGenerationContext` as Phase 3 → Phase 5 handoff struct (minimum agreed fields) |
| D-C13 | Phase 3 LoD cascade — stored boundary extended to include Phase 3 outputs |
| D-C14 | `generate_regional.py` pipeline integration rules (meta stamp, GENERATOR_SOURCES, flags, order) |
| D-C15 | `TerritorialStatus` enum definition (6 values, derivation algorithm) — Paula/Gestalt; Tyre not yet reviewed |
| D-C16 | `FoundingOrientation` enum (5 values, derivation from settlement_pattern + economic_role) — Paula/Gestalt; Tyre not addressed |
| D-C17 | `MaintenanceAuthority` on road segments (5 values, derivation from endpoint relationships) — Paula/Gestalt; absent from Tyre schema |
**Four lead decisions identified** before Phase 3 schemas could be finalized (OQ-R3-1 through OQ-R3-4):
1. Is `TerritorialStatus` in Phase 3 scope?
2. Is `FoundingOrientation` in Phase 3 Layer A?
3. Does Phase 3 include sub-settlement hierarchy?
4. Does `atlas_road_edges` include `maintenance_authority`?
---
## Round 4: The Planet-Down Reframe
**Question:** Requirements gathering for the next workshop brief. What does each cascade layer need to produce, and for what purpose?
**The lead's reframe:** The lead rejected the city-outward framing at the start of Round 4 and restated the correct causal direction:
> "Planet-down, not city-outward. The world exists before civilization. Civilization is layered onto it. The player enters at the bottom of that stack and reads upward."
This reframe replaced the Phase 3 layer map (which had been designed city-outward as "what does the city need") with a four-layer cascade (which starts from geology and civilizes down to street tiles):
| Layer | What it is |
|-------|-----------|
| **1. Empty World** | Heightmap → water simulation → sub-biome refinement. No civilization. |
| **2. Population Overlay** | Economics onto geography. Settlements anchor at geographic nexus points. |
| **3. City-Level Planning** | Districts, suburbs, industrial zones, ports. The Cities Skylines layer. |
| **4. Street-Level Rendering** | Streets, buildings, walkable tiles. On demand as the player moves. |
### The two governing rules
Gestalt identified two rules that became the architectural spine:
**DETERMINISM RULE:** Economic simulation's rolling state affects RENDERING (prosperity, repair state) but NOT LAYOUT (streets and buildings locked by seed).
**LOD/ZOOM RULE:** When a player spawns, the code "zooms in" on the planet — tracing all cascade steps to where the player sits, filling in street-level detail where the player is. The cascade is lazy; only what the player needs is computed.
### How Round 3's disagreements resolved
The planet-down reframe resolved all four Round 3 lead decisions without requiring a separate decision session:
**OQ-R3-1 (TerritorialStatus scope):** Resolved by Paula. TerritorialStatus is a Layer 2 **post-processing output** — one classification pass after settlement placement and road generation complete. Not a dedicated layer. Costs one pass, not a separate architecture layer. The Gestalt/Tyre debate about Layer D is closed.
**OQ-R3-2 (FoundingOrientation):** Resolved by Paula. In the planet-down cascade, FoundingOrientation is **directly readable from the geographic trigger** that caused the settlement to exist. No authoring pass required. Included in CityGenerationContext.
**OQ-R3-3 (Sub-settlement hierarchy):** Resolved by the lead's cascade description. Sub-settlements are explicitly stated as included in Layer 2 ("Sub-settlements INCLUDED"). Confirmed in Tyre's brief as a Given Fact.
**OQ-R3-4 (MaintenanceAuthority on roads):** Resolved by Paula. MaintenanceAuthority falls directly from road endpoint relationships at road generation time. Derivable at generation — no separate schema decision beyond adding the column.
### Paula's "Geography Is Political History" framing
Paula provided the narrative rationale for the planet-down ordering and connected all accepted proposals to their correct cascade layer positions. Key contributions:
- Layer 1 geographic feature tags (RiverConfluence/CoastalHarbor/MountainPass/ArablePlain/ResourceConcentration/Defensible/NaturalBarrier) as mandatory named settlement attractors
- FoundingOrientation is readable from geographic trigger — no authoring
- TerritorialStatus derivation confirmed as Layer 2 post-processing
- `prosperity_delta` architecture: seed-locked `prosperity_baseline` (what the district was planned as) + sim-driven `prosperity_delta` (what current state says) = ghost city effect narrative
- Ruins as structural permanence: settlement physical structure persists even when economic basis is gone
### Burnelli-Sheldon's latent settlement principle
Under the Determinism Rule, settlement positions must be seed-locked at generation time. But economic conditions change. The reconciliation:
> The generator places ALL economically plausible settlement positions at Layer 2. Whether each settlement is ACTIVE is determined by the economic simulation's rolling state. An inactive settlement exists spatially as ruins or empty structures.
This means ghost towns are generated, not scripted. The road to the closed mine still exists. The buildings of the abandoned outpost are still there.
### Tyre's brief assembly
Tyre assembled all Round 4 material into `docs/workshops/planet-down-cascade/BRIEF.md`. This is the primary deliverable of the generation-cascade workshop.
The brief separates:
- **Given Facts** — all decisions locked by Rounds 1-4, not to be revisited
- **Workshop Sections** — the open design questions the next workshop must resolve
**Notable decisions encoded in Given Facts:**
- CityGenerationContext struct with `prosperity_baseline` field name (resolves the Round 4 naming question: `prosperity_baseline` = seed-locked Layer 3 output; `prosperity_current` = runtime simulation state — these must never be conflated)
- Latent settlement principle
- Determinism boundary table per layer
- LoD zoom-in cascade diagram
- All D-C1 through D-C12 candidates treated as locked
---
## Full D-Record Candidate Register
All D-record candidates produced by this workshop, with current status:
| ID | Decision | Status in planet-down brief |
|----|---------|---------------------------|
| D-C1 | Generation pipeline layer definitions | Incorporated as Given Facts (pipeline architecture section) |
| D-C2 | Stored-vs-derived transition — atlas markers are last stored layer | Given Facts: "Atlas markers are the last stored layer" |
| D-C3 | Phase 1 minimum scope — Stages 1-2 mandatory, Stages 3-5 deferred | Given Facts: "District Skeleton (Phase 1 Minimum Scope)" |
| D-C4 | City → district decomposition formula | Given Facts: "City Decomposition (D-C4)" |
| D-C5 | City-local coordinate system (atlas = UI-only) | Given Facts: "Atlas coordinates are UI-only" |
| D-C6 | SeedChain algorithm (FNV-1a `child_seed`) | Given Facts: "Seed Chain" section with full code |
| D-C7 | `GeneratorChunkData` upgrade to `Vec<TileEntry>` | Given Facts: "GeneratorChunkData Upgrade" |
| D-C8 | Spatial legibility requirements (`prosperity_baseline`, `perimeter_treatment`) | Given Facts: "District Skeleton (Phase 1 Minimum Scope)" — field names updated to `prosperity_baseline` |
| D-C9 | 6-field minimum economics read set | Given Facts: "Economics: 6-Field Minimum Read Set" |
| D-C10 | 10×9 `economic_role` → DistrictType weight table | Given Facts: full table reproduced |
| D-C11 | Phase 3 layer sequence and tooling architecture | Given Facts: "Pipeline Architecture" table |
| D-C12 | `CityGenerationContext` as Phase 3 → Phase 5 handoff struct | Given Facts: full struct definition with `prosperity_baseline` |
| D-C13 | Stored boundary extended to Phase 3 outputs | Given Facts: "Pipeline Architecture" |
| D-C14 | `generate_regional.py` pipeline integration rules | Given Facts: "generate_regional.py is the third pipeline generator" |
| D-C15 | `TerritorialStatus` enum (6 values) | Resolved: TerritorialStatus is Layer 2 post-processing output (Paula Round 4). Enum confirmed. Layer D debate closed. |
| D-C16 | `FoundingOrientation` enum (5 values) | Resolved: directly readable from geographic trigger. In CityGenerationContext (Given Facts struct). |
| D-C17 | `MaintenanceAuthority` on road segments (5 values) | Resolved: derivable at road generation time. Included in Layer 2 Required Outputs. |
**Note on formal D-NNN assignment:** These D-C candidates were assigned permanent IDs (D-194 through D-219) during the planet-down-cascade workshop. See `docs/workshops/planet-down-cascade/workshop-outcomes.md` for the full placeholder-to-real-ID mapping table.
---
## What Was Locked
The following architectural decisions are locked. The planet-down-cascade workshop brief treats them as Given Facts and they must not be revisited:
1. **Phase 3/Phase 5 split:** Python tooling + systems.db / Rust runtime + seed-derived
2. **Phase parallelizability:** Phase 5 can stub `CityGenerationContext` and wire to systems.db when Phase 3 is done
3. **`generate_regional.py` as the third pipeline generator** with meta stamp and pre-push hook integration
4. **Atlas markers as the last stored layer** — everything from Layer 3 down is seed-derived
5. **No markers.json extension** — city attributes via systems.db cross-reference on `body_id`
6. **Atlas coordinates are UI-only** — no pixel-to-sim-tile formula
7. **FNV-1a SeedChain** — `child_seed(parent, discriminant)` for all randomness below atlas layer
8. **`CityGenerationContext` struct** with `prosperity_baseline: f32` (seed-locked) — distinct from `prosperity_current: f32` (runtime sim state)
9. **`prosperity_baseline` naming** — the field previously called `prosperity_index` is now `prosperity_baseline` to prevent conflation with runtime state
10. **City decomposition formula** — `max(1, floor(population / 50_000))` with WorldTier ceilings
11. **Phase 1 Stages 1-2 minimum** — classification + 4×4 block grid; Stages 3-5 deferred
12. **WorldTier enum** — `Epicenter | Regional | Backwater | Passage | Waypoint` (replaces the wrong `Peripheral | Connected | Core`)
13. **`GeneratorChunkData` upgrade** — `Vec<TileEntry>` with `tile_id: TileId` + `walkable: bool`
14. **Minimum tile vocabulary** — `floor_street | floor_interior | wall`
15. **6-field minimum read set** — the economics query pattern for Phase 3/Phase 5
16. **10×9 DistrictType weight table** — locked canonical default, tunable but not relitigated per sprint
17. **Sub-settlement hierarchy** — six tiers included as lead confirmation; placement triggers confirmed
18. **Latent settlement principle** — all economically plausible positions placed at Layer 2; active/ghost driven by sim
19. **Determinism boundary per layer** — explicit table of what is seed-locked vs. economics-variable
20. **LoD zoom-in cascade** — lazy cascade from planet level to player tile; `DistrictMap` only exists for cities the player is near
21. **TerritorialStatus as Layer 2 post-processing** — six values, derived after settlement placement + road generation complete
22. **FoundingOrientation from geographic trigger** — directly readable; no authoring required
23. **MaintenanceAuthority on road segments** — derivable from endpoint relationships at generation time
24. **Ghost city effect architecture** — `prosperity_baseline` (seed-locked) + `prosperity_delta` (sim-driven) = effective prosperity; the gap is the narrative
---
## What Was Deferred
The following items were proposed, discussed, and explicitly deferred:
**From Phase 1 (DistrictSkeleton):**
- Stage 3: Reservations (MultiBlockReservation, terminal sites)
- Stage 4: Social sites + NPC population
- Stage 5: Guarantee audit
- `FoundingOrientation`-modified district placement grid (gradient direction only in minimum slice)
- Explicit arterial road layout within city (district boundaries = implicit streets in minimum slice)
- Topographic constraints per district (all cities treated as flat in minimum slice)
**From Phase 2 (tile generation):**
- Tile condition overlays (economics-variable rendering) — implement after walkable world exists
- Building interior tile generation beyond door-per-edge boundary
- Prop/decal scatter and entity spawn points
- Heritage grammar overlay (removed from scope entirely — Phase 6 territory)
**From Phase 3 (`generate_regional.py`):**
- Agricultural dispersed node exact placement (land_use tag sufficient for minimum)
- Shadow node placement (needs `collection_efficiency` data quality improvement first)
- DualNaming/common_name generator vs. authored-only
**From the economics layer:**
- `behavioral_archetype` backfill pass in `import_economics.py` (needed before Layer 3 but not blocking Layer 2)
- `scope` field normalization on corporations table
**Naming registers:**
- Lookup table `(DistrictType, WorldTier) → register` deferred to post-walkable world
- Name pool population deferred entirely (cultural/heritage content, not spatial pipeline work)
---
## Open Questions Carried into the Next Workshop
These are the questions the planet-down-cascade workshop must answer before Layers 1-4 can be implemented:
**Must be resolved before Layer 1:**
- **L1-Q1:** Authored rivers vs. seeded drainage network — what is the relationship? Do authored markers.json river polylines get replaced by generated drainage, or do they anchor the generated network?
- **L1-Q2:** River resolution — regional-only (64×32) or intermediate resolution? Determines whether river data belongs in systems.db or is seed-derived at Phase 5.
- **L1-Q3:** Stored vs. derived for river data — confluence points must be queryable before Layer 2 runs.
**Must be resolved before Layer 2:**
- **L2-Q1:** City positions — anchored with geographic explanation attached (generate_regional.py reads geography for each city but cannot move its position) vs. re-derived from attractors (generator places cities from geography, uses markers.json for population counts only).
- **L2-Q5:** TerritorialStatus quantitative derivation thresholds — what are the concrete numbers for CoreTerritory vs. FrontierTerritory vs. ContestZone?
- **L2-Q6 / OQ-R4-B1:** Latent settlement active/ghost flag storage — in a systems.db Phase 3 output table (recommended) vs. runtime ECS component vs. purely computed from current sim state.
**Must be resolved before Layer 3:**
- **L3-Q1:** FoundingOrientation spatial grid orientation — does it modify district placement grid positions (spatial rotation), or only modify the prosperity gradient direction?
- **L3-Q2:** Political archetype as spatial arrangement — explicit spine/center/nodes patterns, or emergent from weight table?
**Must be resolved before Layer 4:**
- **L4-Q1 / OQ-R4-G2:** Economics-variable rendering mechanism — bake at generation + update on event; compute live from `prosperity_current` per frame; or cache per chunk, invalidate on threshold crossing.
- **CL-Q2 / OQ-R4-B2:** Regional land-use evolution resolution boundary — runtime `regional_land_use` column update vs. ChunkMutations.
- **L4-Q6 / CL-Q1:** `prosperity_delta` two-field architecture — formal adoption as named architectural decision.
---
## The Output: `docs/workshops/planet-down-cascade/BRIEF.md`
**The primary deliverable of the generation-cascade workshop is a new workshop brief.**
`docs/workshops/planet-down-cascade/BRIEF.md` was assembled by Tyre from Round 4 material. It contains:
1. **The Lead's Cascade Vision** — reproduced verbatim as authoritative framing
2. **Given Facts** — all decisions from Rounds 1-4 that are locked, including: pipeline architecture, seed chain, CityGenerationContext struct, city decomposition formula, Phase 1 minimum scope, WorldTier enum, GeneratorChunkData upgrade, 6-field read set, 10×9 weight table, sub-settlement hierarchy, latent settlement principle, determinism boundary per layer, LoD cascade
3. **Four workshop sections** (Layer 1 through Layer 4) each with specific open questions (L1-Q1 through L4-Q6)
4. **Cross-layer questions** (CL-Q1 through CL-Q4)
5. **Workshop format** — 3 rounds: Round 1 inventory and framing, Round 2 algorithm proposals, Round 3 convergence and D-records
6. **Required reading list** — round-3 notes, tyre-round3.md, existing server code files, decisions/architecture.md, Round 4 domain files
7. **Expected outputs** — algorithm specifications, data format handoffs, D-records, implementation ticket dependency chain, resolved open questions
---
## How the Workshop Evolved
The generation-cascade workshop did not end where it started. The journey:
1. **Started as:** A 3-round implementation audit with a city-outward framing — trace the generation layers a walkable apartment would need
2. **After Round 1:** Scope narrowed (heritage grammar / NPC / #615 removed); participant list adjusted; city-outward framing retained
3. **After Round 2:** Phase 3 corrective — the workshop had been designing Phase 5 without designing Phase 3; `generate_regional.py` framing introduced
4. **After Round 3:** Four unresolved lead decisions; planet-down reframe needed; a fourth round launched to gather requirements for a new brief
5. **After Round 4:** All Round 3 decisions resolved; governing rules (Determinism + LoD) named; new workshop brief assembled; workshop closed
The city-outward framing produced valid technical work (Rounds 1-2: all D-C1 through D-C8 candidates, effort estimates, implementation order). The Phase 3 corrective (Round 3) caught the missing layer. The planet-down reframe (Round 4) gave the correct causal ordering that makes the full cascade coherent.
The workshop did not produce implementation — it produced a brief that will enable the implementation workshop to proceed without foundational architecture questions.
---
*Document compiled by Qatux from all four round sources. This is the authoritative record of what happened in the generation-cascade workshop. For technical details, see round-1-notes.md through round-4-notes.md. For the output brief, see docs/workshops/planet-down-cascade/BRIEF.md.*
+459
View File
@@ -0,0 +1,459 @@
---
title: "Workshop Brief — Planet-Down Cascade"
description: "Design the multi-layered planet-down generation algorithms where each layer's output feeds the next: empty world → population overlay → city planning → street rendering"
type: workshop
status: active
workshop: planet-down-cascade
agent: ""
round: 0
created: 2026-04-30
---
# Planet-Down Cascade Workshop Brief
**Goal:** Design the full generation cascade from planetary heightmap to walkable street-level tiles. Each layer receives the previous layer's output as input. The workshop must produce algorithm specifications, data format handoffs, and implementation decisions for each layer — resolving open questions that domain specialists have surfaced but cannot close alone.
**Ticket:** (to be assigned at sprint planning)
**Priority:** HIGH — this is the architectural design that drives Phase 3 and Phase 5 implementation
**Participants:** Gestalt (systems design + fun factor), Tyre (architecture + feasibility), Paula (narrative + political depth), Burnelli-Sheldon (economics + simulation modeling)
**Source:** Lead directive, generation-cascade workshop Rounds 1-4 (2026-04-24 to 2026-04-30)
---
## The Lead's Cascade Vision
*This is the authoritative design. Reproduce it verbatim here so all participants work from the same framing.*
**1. EMPTY WORLD** — Start from heightmap. Water simulation: river drainage from heightmap datapoints + seed to calculate river presence. Scatter lakes with biome/river dependency. Refine sub-biomes into actionable regional areas (jungle with clearings, hills, swamp). The existing heightmap rivers are overrides/labels — real rivers come from seeded drainage patterns. Keep names, re-place based on water sim. ALL DETERMINISTIC down to player scale. Result: complete natural world, no civilization.
**2. POPULATION OVERLAY** — Layer economics onto natural world. Where rivers meet, where rivers hit the sea, where arable land is — settlements anchor. Felled forests become farmland. Flattened hilly terrain for cities (terraced, no longer craggy). Anchored by wiki population counts and markers.json. Wider area: entire city with surrounding lands, village with farms rolling into farmland and forest. Road/rail networks inter and intra regional. Sub-settlements INCLUDED. MaintenanceAuthority on roads INCLUDED.
**3. CITY-LEVEL PLANNING** — "Cities Skylines" with topography. Plot farmland, wilderness, suburbs, industrial areas, commercial districts, ports, airports, train stations, highways, cultural districts. All if appropriate within size and cultural parameters. ENTIRE PHASE on its own.
**4. STREET-LEVEL RENDERING** — Take city-level data, render streets, buildings, scatter civilization.
**CRITICAL DETERMINISM RULE:** Economic sim's rolling state affects RENDERING (prosperity, repair, wealth state) NOT LAYOUT (streets, buildings locked by seed). User cannot leave and return to find different street plan because GDP changed. The same streets and buildings, just richer or poorer.
**CRITICAL LoD/ZOOM RULE:** First pass = automatic parameter generation. Then cascaded world render: user spawns in village X, code "zooms in" on planet, traces all cascade steps to where user sits, and with those patterns in hand (including cascades for chunks/districts around them) fills in actual street levels. Smart: react to economics flags, parse wealth/repairedness on rolling economics. DO NOT influence determinism.
**BUILDING LoD RULE:** Door-per-edge as boundary. Descriptor + catalog of what's behind the door. No spatial rendering of building interiors until player crosses threshold.
---
## Context
The generation-cascade workshop (Rounds 1-4, 2026-04-30) produced the foundational architecture and resolved the key data-model decisions. That workshop examined the pipeline from two directions: bottom-up (what exists in the codebase today) and top-down (what the lead's vision requires). The conclusion: the existing architecture (DistrictSkeleton, SeedChain, chunk streaming) maps cleanly to Layers 3 and 4 of this cascade, but Layers 1 and 2 were not designed in detail.
This workshop begins where the last one ended. The given facts below are locked. This workshop designs the open questions.
---
## Given Facts — Carry Forward Without Revisiting
### Pipeline Architecture
| Decision | Detail |
|----------|--------|
| Phase 3 = Python tooling + systems.db | All Layer 1 and Layer 2 generation runs offline in `generate_regional.py` and commits to systems.db. |
| Phase 5 = Rust runtime + seed-derived | Layer 3 and Layer 4 generation runs in the simulation server at runtime, seed-derived, never stored. |
| These two phases are parallelizable | Phase 5 can stub `CityGenerationContext` and wire to systems.db when Phase 3 is done. |
| `generate_regional.py` is the third pipeline generator | Runs after `generate_atlas.py` in `make regen-db`. Produces Phase 3 systems.db tables. Has its own meta stamp. |
| Atlas markers are the last stored layer | Everything from Layer 3 down is seed-derived at runtime. Save files store: `world_seed` + markers snapshot hash + `ChunkMutations`. |
| No markers.json extension | City attributes are read from systems.db bodies table by cross-reference on `body_id`. markers.json stays as a pure spatial + naming store. |
| Atlas coordinates are UI-only | The 512×256 pixel grid is a display coordinate system. Walkable world uses city-local sim-tile coordinates from (0,0). No pixel-to-sim-tile formula. |
### Seed Chain
All randomness below the atlas layer uses FNV-1a `child_seed`:
```rust
fn child_seed(parent: u64, discriminant: u64) -> u64 {
let mut h = parent ^ 0xcbf29ce484222325;
for byte in discriminant.to_le_bytes() {
h ^= byte as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
district_seed = child_seed(world_seed, district_id)
chunk_seed = child_seed(district_seed, (cx as u64) << 32 | (cy as u64))
```
Any district or chunk can be generated in isolation without generating neighbors first. Complies with D-010.
### Phase 3 → Phase 5 Handoff
```rust
pub struct CityGenerationContext {
pub city_id: String,
pub political_archetype: PoliticalArchetype,
pub prosperity_baseline: f32, // seed-locked Layer 3 output
pub surrounding_biome: BiomeClass,
pub road_entry_directions: Vec<CardinalDirection>,
pub footprint_radius_km: f32,
pub founding_orientation: FoundingOrientation,
pub world_tier: WorldTier,
}
```
This struct is populated from systems.db at game startup. Phase 5 reads it as input to district skeleton generation. `prosperity_baseline` is the seed-locked economic baseline. The runtime economic simulation produces a separate `prosperity_current` field — these are two distinct data fields and must not be conflated in implementation.
### City Decomposition (D-C4)
- District count: `max(1, floor(city.population / 50_000))`, log-scaled for large cities
- WorldTier district ceilings: Epicenter uncapped | Regional 8 | Backwater 4 | Passage 2 | Waypoint 1
- Capital cities: +1 bonus district
- Domed/cave settlement: fixed 1 district
- Districts positioned in city-local sim tiles: district at grid (col, row) → origin `(col × 512, row × 512)`
### District Skeleton (Phase 1 Minimum Scope)
- **Stage 1 (mandatory):** Classification — WorldTier, ComplexityTier, SettingType, DistrictType, LayoutMode, `prosperity_baseline`, `perimeter_treatment`
- **Stage 2 (mandatory):** Block grid — 4×4 BlockSkeletons with ZoningType, `density_pct`, seed
- **Stages 3-5 deferred:** Reservations, social sites, guarantee audit — enrichment for post-walkable world
### WorldTier Enum (corrected)
```rust
enum WorldTier { Epicenter, Regional, Backwater, Passage, Waypoint }
// NOT: Peripheral, Connected, Core — those are wrong and must be replaced
```
### GeneratorChunkData Upgrade
```rust
pub struct GeneratorChunkData { pub tiles: Vec<TileEntry> } // 64×64 = 4,096 entries
pub struct TileEntry { pub tile_id: TileId, pub walkable: bool }
// Replaces: pub type GeneratorChunkData = Vec<bool>
```
Minimum tile vocabulary: `floor_street`, `floor_interior`, `wall`.
### Economics: 6-Field Minimum Read Set and 10×9 Weight Table
Six fields queryable from systems.db per city, sufficient to produce a spatially differentiated world:
| # | Field | Source table |
|---|-------|-------------|
| 1 | `economic_role` | `bodies` |
| 2 | `settlement_pattern` | `bodies` |
| 3 | `economic_tier` | `system_economy` |
| 4 | `distribution_index` | `system_economy` |
| 5 | `corp_presence` count | `corp_presence` |
| 6 | `headquarters_system/body` match | `corporations` |
10×9 DistrictType weight table (economic_role → DistrictType prior):
| economic_role | Res | Com | Ind | Adm | Log | Ent | Mix | Trn | Spe |
|--------------|-----|-----|-----|-----|-----|-----|-----|-----|-----|
| manufacturing | 25 | 5 | 35 | 5 | 20 | 2 | 5 | 3 | 0 |
| agricultural | 30 | 15 | 5 | 10 | 25 | 3 | 10 | 2 | 0 |
| extraction | 20 | 5 | 30 | 5 | 30 | 0 | 5 | 5 | 0 |
| transit | 10 | 20 | 5 | 5 | 25 | 10 | 15 | 10 | 0 |
| research | 20 | 5 | 5 | 15 | 5 | 5 | 10 | 0 | 35 |
| commercial | 15 | 35 | 5 | 5 | 10 | 15 | 15 | 0 | 0 |
| service_mixed | 25 | 20 | 5 | 10 | 5 | 15 | 20 | 0 | 0 |
| mining | 20 | 5 | 25 | 0 | 35 | 0 | 10 | 5 | 0 |
| frontier | 35 | 10 | 10 | 5 | 20 | 5 | 15 | 0 | 0 |
| energy | 10 | 0 | 20 | 5 | 30 | 0 | 5 | 5 | 25 |
`political_archetype` modifiers stack on top of these weights.
### Sub-Settlement Hierarchy (lead inclusion)
| Tier | Population | Treatment | Placement trigger |
|------|-----------|-----------|------------------|
| City | 10,000+ | Full district decomposition (Layer 3) | markers.json anchor + geography |
| Town | 1,000–10,000 | Single-district equivalent | Road network + economic role |
| Outpost | 50–1,000 | Purpose-typed single node | Corp presence + extraction trigger |
| Waypoint | <50 | Infrastructure node, no district | Road midpoint formula |
| Rural cluster | Isolated | Type-tagged scattered nodes | settlement_pattern + economic role |
| Ruin | 0 | Historical footprint | Abandoned tether condition |
**Placement triggers:**
- Mining camps: `economic_role ∈ {extraction, mining}` + corp_presence with qualifying commodity → 1 camp per 2 corps
- Trade waypoints: long roads (pixel_count > threshold) → waypoint at geometric midpoint
- Agricultural nodes: `settlement_pattern ∈ {dispersed, dispersed_rural}` + `economic_role = agricultural` → clusters at interval ∝ `1/economic_tier`
- Shadow nodes: `collection_efficiency < 0.6` + shadow-viable corps → 1 informal settlement at city fringe
### Dynamic Settlements Are Latent
All economically plausible settlement positions are placed at Layer 2 generation time (seed-derived). Whether each is ACTIVE is determined by the economic simulation's rolling state. An inactive settlement exists spatially as ruins/empty structures — it is dark, unmaintained, and depopulated. Ghost towns are generated, not scripted.
### Determinism Boundary (per layer)
| Layer | Seed-locked (never changes) | Economics-variable (runtime updates) |
|-------|-----------------------------|--------------------------------------|
| 1 (Empty World) | Heightmap, river network, biome grid, pass locations | Nothing — terrain is fixed |
| 2 (Population) | Settlement positions, road topology, sub-settlement placement, PoliticalTether | Road condition (edge case: abandoned roads degrade further) |
| 3 (City Planning) | District positions, district types, block grid, `prosperity_baseline`, `perimeter_treatment` | `prosperity_current` (rolling sim update); building availability |
| 4 (Street-Level) | Street positions, building footprints, door positions, all tile layout | Tile condition states (Intact/Worn/Cracked/Broken); building activity signals |
**The felled-forests exception:** "Felled forests → farmland" is Layer 2 regional land-use (coarse biome-cell resolution), not Layer 4 tile mutation. Regional land-use CAN update at biome-cell resolution as economics shifts. City-internal street/building geometry is permanently seed-locked.
### LoD Zoom-In Cascade
When a player spawns, the code traces the full cascade from planet level down to the player's tile position. The cascade is lazy — only what the player needs is computed:
```
Planet in systems.db (Layers 1+2 data committed)
│
├── Player within planet rendering range?
│ → Regional biome overlay activates for atlas UI
│
├── Player within city footprint range?
│ → Generate city district grid (Layer 3 / Phase 1)
│ → Build DistrictMap in memory
│
├── Player within district range (chunk load radius)?
│ → load_chunk() fires per chunk in radius
│ → Layer 4 / Phase 2 generates tile data from BlockSkeleton + chunk_seed
│
└── Player at building door threshold?
→ Interior descriptor + catalog loaded (door-per-edge)
→ Interior tiles generated (Phase 4+)
```
The `DistrictMap` resource is built when the player enters a city's footprint radius. Cities outside player range have no `DistrictMap` — only their `CityGenerationContext` in memory from startup DB read.
---
## Workshop Sections — What Needs Design
### Section 1: Layer 1 — Empty World
**Inputs:** 512×256 heightmap (from `generate_atlas.py`), 64×32 regional biome grid (from `generate_regional.py` Phase 3 Layer B), body definitions from systems.db.
**Required outputs:**
- Sub-biome variant tag per regional cell (`sub_biome_variant` column on `atlas_regional_biomes`): 3-4 variants per biome class (e.g., `tropical` → `jungle_clearing | swamp | riverine_forest | lowland_marsh`)
- `terrain_modification_cost: f32` per regional cell — effort required to settle here (high = forest/hills; low = plains/coast)
- River network at regional resolution: where major rivers run, confluence points, river mouths
- River confluence identification as stored point features (settlement attractors for Layer 2)
- Mountain pass identification: regional cells with high `terrain_roughness` adjacent to significantly lower cells
- Geographic feature tag layer for settlement placement guidance (Paula): `RiverConfluence`, `CoastalHarbor`, `MountainPass`, `ArablePlain`, `ResourceConcentration`, `Defensible`, `NaturalBarrier`
**Questions to design:**
**L1-Q1 (Architecture):** The existing heightmap rivers (markers.json `rivers` entries) are authored overrides and labels. The lead says real rivers come from seeded drainage patterns. What is the relationship between authored river markers and the generated drainage network? Options: (a) authored rivers are replaced entirely — generated network takes over and re-places names; (b) authored rivers are anchors — the generated network is seeded to match them where they exist and fills gaps elsewhere; (c) two separate systems at different resolutions — authored for atlas display, generated for walkable world. **This must be resolved before Layer 1 can be implemented.**
**L1-Q2 (Resolution):** At what resolution does the river network get stored? The 64×32 regional grid gives coarse river paths sufficient for Layer 2 settlement placement. If rivers are walkable obstacles at street level (Phase 5), their exact courses need to be at tile precision. Concrete choice: (a) regional-resolution only (64×32) — rivers are navigation constraints, not tile-level obstacles; (b) stored at intermediate resolution (e.g., 512×256 pixel path like atlas roads) — sufficient for both; (c) tile-resolution derived by SeedChain at Phase 5 time — keep stored data coarse. **Answer determines whether the river data belongs in systems.db or is seed-derived at Phase 5.**
**L1-Q3 (Stored vs. derived):** The generated river network is fully deterministic from the heightmap + seed. Does it get committed to systems.db (like the 64×32 biome grid), or is it derived at runtime (like districts)? Core tension: the river confluence points that anchor Layer 2 settlement placement must be queryable before any game session starts. This implies at minimum the confluence points are stored, even if the full river course is seed-derived.
**L1-Q4 (Biome prior):** Burnelli-Sheldon proposes using `bodies.economic_role` as a soft biome probability prior in sub-biome refinement — an agricultural world gets more arable cells, an extraction world gets more rugged terrain. Is this prior applied at sub-biome resolution (nudges the `sub_biome_variant` distribution) or left to `planet_class` alone? Low-risk to include; risk is self-contradictory worlds if excluded.
**Minimum viable Layer 1:**
- `sub_biome_variant` column populated on `atlas_regional_biomes`
- `terrain_modification_cost` column populated on `atlas_regional_biomes`
- River confluence points stored as point features in systems.db (new `atlas_geographic_features` table)
- Mountain pass identification (candidate pass cells tagged)
- Geographic feature tags (Paula's taxonomy) attached to regional cells
- Defer: tile-resolution river routing, exact lake polygons, authored river vs. generated network reconciliation
---
### Section 2: Layer 2 — Population Overlay
**Inputs:** Layer 1 output (regional biome + geographic feature tags + river network), atlas_cities (markers.json population anchors), atlas_roads/railroads (authored infrastructure), systems.db bodies + economics tables.
**Required outputs:**
- `land_use` column on all regional cells: Urban | Agricultural | Industrial | Wilderness | Corridor | Ruins | Ocean | Impassable
- `terrain_modification` signal per cell: what was here before settlement, what it became
- Sub-settlement positions (all latent dynamic settlements placed, seed-derived)
- Road graph (nodes + edges with `MaintenanceAuthority`): already designed in Phase 3 Layer C
- `TerritorialStatus` per regional cell: CoreTerritory | FrontierTerritory | ExtractiveZone | ContestZone | WildernessBuffer | AbandonedZone
- `PoliticalTether` per sub-city settlement: Administered | EconomicallyDependent | Contested | Independent | Corporate | Abandoned
- `FoundingOrientation` per settlement: derived from geographic trigger that caused placement
- `political_archetype` per city: derived from geographic trigger + economic data (override column for named cities)
- Hinterland characterization per body: how the space between cities is filled
**Questions to design:**
**L2-Q1 (Architecture, lead must resolve):** Does "geography determines shape" mean city positions in markers.json are re-evaluated by Layer 2, or do they stay fixed as anchors with Layer 2 explaining them? The generator's behavior differs significantly: (a) positions anchored — `generate_regional.py` cannot write back to markers.json, only extends systems.db with geographic context; (b) positions re-derived — generator produces settlement positions from geographic attractors, uses markers.json population counts only, may place cities at different pixel positions. The lead's phrasing ("anchored by wiki population counts and markers.json") implies (a). But "where rivers meet, where rivers hit the sea, where arable land is — settlements anchor" implies the generator knows WHY each city is where it is. **Position: anchored with geographic explanation attached — but this must be confirmed.**
**L2-Q2 (Sub-settlement placement):** Given a body with `economic_role = extraction` and 3 qualifying corps, where exactly do the mining camps sit? The algorithm needs spatial precision for seeded placement. Proposed algorithm: camp_position = closest regional cell to corp's `location_id` asset that has `land_use ∈ {Industrial, Wilderness}`, at seeded distance from tether city within [15%–40%] of body scale. Does this hold? What's the camp count formula?
**L2-Q3 (Road algorithm):** The lead says "road/rail networks inter and intra regional." markers.json has authored road pixel-paths for named highway routes. Does Layer 2 generate additional roads (for sub-settlement connections) or does it only interpret existing authored roads? Proposed resolution: authored roads remain for named routes (setting decisions — Gate Corp built this highway); Layer 2 generates local roads connecting sub-settlements to their tether cities using the road graph algorithm from Phase 3 Layer C. Do participants agree?
**L2-Q4 (Hinterland shapes):** Burnelli-Sheldon proposes per-`economic_role` hinterland fill (agricultural: farmland + irrigation + processing nodes; extraction: access corridors + stockpile areas; transit: dense road/rail + relay stations). How is the spatial shape of farmland determined? Proposed rule: agricultural cells cluster around agricultural-role cities + along river corridors, at density inversely proportional to `terrain_modification_cost` from Layer 1. Rough terrain stays wilderness; gentle terrain becomes farmland. Is this sufficient, or does it need a Voronoi-style region-growing algorithm?
**L2-Q5 (TerritorialStatus derivation):** Paula establishes TerritorialStatus as a post-processing output of settlement placement + road generation. Confirm the derivation algorithm: CoreTerritory = high settlement density + well-maintained roads throughout; FrontierTerritory = settlements placed + roads thin at distance; ExtractiveZone = settlement at ResourceConcentration + corporate presence + excellent extraction corridor + absent off-corridor; ContestZone = two settlement clusters with competing road networks in overlapping territory; WildernessBuffer = no settlement placed; AbandonedZone = settlement placed but economic basis flagged as exhausted. What are the quantitative thresholds for each?
**L2-Q6 (Latent settlement flag storage):** Burnelli-Sheldon raises OQ-R4-B1: where does the settlement active/ghost flag live? Options: (a) as a sim-readable component on each settlement entity in the ECS; (b) in a systems.db Phase 3 output table (settlement positions + trigger conditions + activating corp/condition reference); (c) computed entirely at runtime from `corp_presence` + `corp_financial_state.health_metric`. Recommended answer is (b) — Phase 3 stores latent settlement positions + trigger conditions; sim reads them alongside `corp_financial_state`. This integrates with `generate_regional.py` naturally.
**Minimum viable Layer 2:**
- `land_use` column on all regional cells
- Sub-settlement placement: mining camps + trade waypoints (highest frequency, most game-relevant)
- Road graph with `MaintenanceAuthority` per edge (Phase 3 Layer C already designed)
- `TerritorialStatus` per regional cell
- `political_archetype` per city (derived, override column)
- `FoundingOrientation` per city (derived from geographic trigger)
- Defer: exact agricultural node positions (land_use tag sufficient for minimum), shadow node placement, authored vs. generated road resolution
---
### Section 3: Layer 3 — City-Level Planning
**Inputs:** `CityGenerationContext` struct (from systems.db at game startup), Layer 1+2 regional data via systems.db, `world_seed`.
**Required outputs:**
- N-district grid per city (district positions in city-local sim tiles)
- `DistrictSkeleton` Phase 1 Stages 1-2 for each district:
- Stage 1: WorldTier, ComplexityTier, SettingType, DistrictType, LayoutMode, `prosperity_baseline`, `perimeter_treatment`
- Stage 2: 4×4 BlockSkeleton grid with ZoningType, `density_pct`, seed
- District spatial arrangement shaped by `FoundingOrientation` + `political_archetype`
- Prosperity gradient direction and magnitude across districts
**Questions to design:**
**L3-Q1 (District grid orientation):** The district count formula (D-C4) places N districts in a grid. `FoundingOrientation` implies the grid should be oriented: a PortFacing city has its LogisticsHub/Transit districts at the water-facing edge; a DefenseFacing city has its Administrative district at the elevated center. Does `FoundingOrientation` modify the district placement grid (spatial rotation/alignment), or does it only modify the prosperity gradient direction without changing grid positions? Gestalt identifies this as an open question — the brief should scope it: (a) spatial orientation change (more complex, more legible); (b) gradient direction only (simpler, less visually obvious).
**L3-Q2 (Political archetype as spatial arrangement):** Paula and Gestalt both flag that `political_archetype` should shape spatial ARRANGEMENT, not just type frequency. CompanyTown = spine (facility at one end, housing radiating back). AdminCapital = center (administrative hub, residential radiating outward). FreePort = multiple nodes (no single center). Does Layer 3 implement these spatial arrangement patterns explicitly, or do they emerge from the weight table and `FoundingOrientation` rules without explicit arrangement logic? Explicit arrangement produces more consistent legibility; emergent arrangement is cheaper to implement. The brief should specify which the workshop is designing.
**L3-Q3 (Arterial road layout):** Does City-Level Planning produce an explicit arterial road layout within the city, or are district boundary positions the implicit road network? Explicit arterials: requires a road placement algorithm as a Layer 3 output; produces a more realistic city; consumed by Layer 4 tile generation for street positioning. Implicit streets: district boundaries imply roads exist; Layer 4 generates streets at those positions; simpler. For the minimum slice: district boundary = implicit street. For richer cities: arterials first. The brief should state which is in scope for Round 1 of this workshop.
**L3-Q4 (Topographic constraints on zoning):** Burnelli-Sheldon proposes that topographic constraints from Layer 1 modify the 10×9 weight table: industrial zones weighted toward flat terrain near logistics access; residential zones weighted against flood plains; LogisticsHub always adjacent to road/rail entry points. How does Layer 3 access terrain roughness at city-local resolution? The Layer 1 data is at 64×32 regional resolution — each regional cell covers multiple districts. Is sampling the regional roughness at the city's center position sufficient, or does Layer 3 need finer terrain data per district?
**L3-Q5 (prosperity_baseline derivation formula):** Burnelli-Sheldon's formula:
```
base = economic_tier / 5.0
role_modifier: extraction -0.1, research +0.15, service_mixed +0.1, frontier -0.2
if stratified: district_i = base + role_modifier + (prestige_rank(i) - 0.5) × 0.7
if moderate: district_i = clamp(N(base + role_modifier, 0.1), 0.2, 0.8)
```
`distribution_index` controls gradient magnitude; `FoundingOrientation` controls gradient direction. Does topography also contribute (hilltop districts = historically higher prosperity)? Burnelli-Sheldon recommends: topography sets gradient direction, `distribution_index` sets gradient magnitude. Do all participants agree?
**L3-Q6 (Sub-settlement depth — OQ-R4-G6):** The sub-settlement hierarchy includes Towns (1,000–10,000 population, "single-district equivalent"). Does a town go through the same DistrictSkeleton generation path as a city district, or does it get a simplified code path? A unified code path is architecturally cleaner; a simplified path is cheaper for its lower content budget. If unified: the town's `CityGenerationContext` has `district_count = 1`. If simplified: separate Rust codepath with a reduced struct.
**L3-Q7 (Port/station direction):** Burnelli-Sheldon proposes Transit and LogisticsHub districts are placed at the city edge closest to the orbital station (if one exists). Does Layer 3 receive directional information about nearby orbital stations? This requires knowing the orbital station's atlas pixel position relative to the city's position — feasible from systems.db. Is this in scope for the minimum viable slice, or deferred?
**Minimum viable Layer 3:**
- District count + grid positions (D-C4 formula)
- Phase 1 Stage 1 classification per district (WorldTier, ComplexityTier, DistrictType from weight table, LayoutMode, `prosperity_baseline`, `perimeter_treatment`)
- Phase 1 Stage 2 block grid (4×4 BlockSkeletons)
- `political_archetype` consumed from systems.db (Layer 2 output)
- Defer: `FoundingOrientation` spatial grid orientation, explicit arterial layout, topographic constraints per district, Stages 3-5
---
### Section 4: Layer 4 — Street-Level Rendering
**Inputs:** `DistrictSkeleton` (Layer 3 output), `district_seed` (from SeedChain), `prosperity_current` (runtime economic state), `chunk_coord`.
**Required outputs:**
- 64×64 `TileEntry` grids (`GeneratorChunkData`) per chunk, on demand
- 3 canonical tile types: `floor_street`, `floor_interior`, `wall`
- Tile condition state per tile: `Intact | Worn | Cracked | Broken` (economics-variable, NOT seed-locked)
- Building footprints with door positions (door-per-edge boundary)
**Questions to design:**
**L4-Q1 (Economics-variable rendering mechanism — OQ-R4-G2):** The determinism rule says tile conditions are economics-variable. Three implementation options: (a) bake `prosperity_baseline` into tile conditions at generation time, update on `prosperity_current` change event; (b) compute tile conditions live from `prosperity_current` each render frame — no cache; (c) cache condition state per chunk, invalidate when `prosperity_current` crosses a threshold. Performance and implementation complexity differ significantly. The brief needs to choose: the workshop must specify which mechanism, including whether condition states are cached per chunk or computed live.
**L4-Q2 (Condition update trigger):** What triggers a tile condition recalculation? Options: (a) every time `prosperity_current` changes (potentially every tick); (b) when `prosperity_current` crosses defined thresholds (e.g., Intact ≥ 0.6, Worn 0.4–0.6, Cracked 0.2–0.4, Broken < 0.2); (c) periodic recalculation (e.g., every 100 ticks). For performance: threshold crossings are correct — most districts are stable most of the time and require no recalculation.
**L4-Q3 (Phase 2 tile algorithm — confirmed):** Street skeleton first (door-per-block-edge defines connectivity), then building fill (density-driven rectangles within block). `density_pct` from BlockSkeleton drives building coverage. High density = packed buildings with minimal street width; low density = open space. This is confirmed from Rounds 2-3. The workshop should design the algorithm parameters: minimum street corridor width, maximum building rectangle dimensions, gap rules at block edges.
**L4-Q4 (Interior generation trigger — OQ-R4-G5 partial):** Door-per-edge is the confirmed building boundary. What triggers interior tile generation? Options: (a) when player entity is within N tiles of any door — pre-generates the interior before the player crosses; (b) when player entity crosses the door threshold — generates on entry; (c) pre-generated with the exterior chunk but not rendered until entered. Option (a) avoids a generation stall at the door but wastes compute for doors the player passes. Option (b) is simplest. The brief should specify.
**L4-Q5 ("Scatter civilization" scope):** The lead says "scatter civilization into tiles." Beyond floor/wall canonical types, what does Layer 4 place? Candidates: (a) prop spawn points (furniture, vehicles, crates) — entity system populates at runtime from these points; (b) surface decals (signage, paint) — tile variants or overlay tiles; (c) entity spawn points for NPCs. The brief should specify which of these Layer 4 is responsible for vs. what is runtime entity system placement.
**L4-Q6 (prosperity_delta architecture — Paula):** Paula proposes that `prosperity_index` must have two components to enable the "ghost city effect": a seed-locked `prosperity_baseline` (what the district was planned as) and a sim-driven `prosperity_delta` (what current economic state says relative to the plan). The effective prosperity = `prosperity_baseline + prosperity_delta` (clamped to [0.0, 1.0]). The gap between these two values IS the narrative: planned-for-wealth but now-poor; planned-for-rough but now-thriving. This requires the rendering system to have access to BOTH values. Does the workshop adopt this two-field model explicitly?
**Minimum viable Layer 4:**
- 3 tile types generating a walkable, navigable space
- Seed-locked layout from `district_seed` via FNV-1a
- Chunk streaming hookup: `load_chunk()` calls tile generation via `DistrictMap`
- `prosperity_baseline` drives initial building density selection
- Defer: tile condition overlays (economics-variable rendering) — implement after walkable world
- Defer: building interior generation beyond door-per-edge boundary
- Defer: prop/decal scatter, entity spawn points
---
### Section 5: Cross-Layer Architecture
**Questions that span multiple layers:**
**CL-Q1 (`prosperity_baseline` vs. `prosperity_current` naming — Gestalt OQ-R4-G3):** The workshop must confirm these are TWO DISTINCT FIELDS with distinct names, never conflated:
- `prosperity_baseline: f32` — Layer 3 output, seed-locked, stored in `CityGenerationContext` / atlas tables. What the district was economically designed for.
- `prosperity_current: f32` — Runtime simulation state. What the district's current economic health is. Updated by the economic simulation.
- `prosperity_delta: f32` — Optional derived field (`prosperity_current - prosperity_baseline`). Enables the ghost city effect.
**CL-Q2 (Regional land-use evolution vs. city-geometry lock):** Burnelli-Sheldon identifies this as OQ-R4-B2: when agricultural corp expands and new farmland appears, does this register as (a) a ChunkMutations entry in the save file, or (b) a runtime update to `regional_land_use` in the biome grid at coarse resolution (not a full regen-db)? The proposed answer: Layer 2 regional land-use changes at biome-cell resolution are a runtime parameter read by the Layer 4 renderer to determine what large-scale tile type fills undeveloped hinterland. This is NOT ChunkMutations (which are reserved for player-caused or explicit-event damage at tile precision). The brief needs to confirm this resolution boundary.
**CL-Q3 (Ruin lifecycle):** Paula proposes AbandonedZone settlements follow a deterministic decay path: prosperity_delta drops sharply → perimeter treatment degrades (Checkpoint → Fenced → Open) → landmark condition deteriorates. When does the economic simulation flag a settlement for ruination — (a) when `corp_financial_state.health_metric` drops below threshold (emergent); (b) when a lead-authored event fires (override); or (c) both, with sim as default and authored events as override? All three domain files suggest (c).
**CL-Q4 (Authored rivers vs. drainage network — L1-Q1 elevated):** This question spans Layers 1 and 2 and must be resolved before either layer can be designed fully. The existing markers.json `rivers` arrays are authored polylines. The lead wants rivers from seeded drainage patterns with names re-placed based on water sim. This requires a reconciliation pass. The workshop should specify: (a) what the drainage algorithm produces; (b) how authored river names are matched to generated courses; (c) whether markers.json rivers are ever modified by `generate_regional.py` or whether the generated drainage is parallel data in systems.db only.
---
## Workshop Format
**3 rounds:**
### Round 1 — Inventory and Framing
Each participant examines all open questions from their domain perspective. For each layer, state: what algorithm exists or can be reused, what is genuinely novel design work, what is a data/schema question only. Identify which questions are prerequisite (later questions depend on earlier answers) and which are independent.
Write findings to `docs/workshops/planet-down-cascade/{agent}-round1.md`.
### Round 2 — Algorithm Proposals
Based on Round 1, each participant proposes concrete algorithms for their domain questions. Proposals must specify: inputs, outputs, data format, whether code goes in Python tooling or Rust runtime. For any question where participants disagree, state the disagreement clearly — do not average positions.
Write proposals to `docs/workshops/planet-down-cascade/{agent}-round2.md`.
### Round 3 — Convergence and D-Records
Review all proposals. Lock algorithm choices. Produce D-record candidates. Identify remaining open questions that need lead resolution. Produce the implementation ticket dependency chain.
Write final positions to `docs/workshops/planet-down-cascade/{agent}-round3.md`.
---
## Required Reading
Before Round 1, all participants must read:
**Workshop outputs (carry-forwards):**
- `docs/workshops/generation-cascade/round-1-notes.md` — layer inventory and consensus from prior workshop
- `docs/workshops/generation-cascade/round-2-notes.md` — D-record candidates (D-C1 through D-C8 proposals)
- `docs/workshops/generation-cascade/tyre-round3.md` — Phase 3 technical layers (generate_regional.py design)
**Code (what exists):**
- `tooling/planet-gen/planet_simulation.py` — terrain simulation: elevation, temperature, moisture, biomes, rivers
- `tooling/planet-gen/generate_atlas.py` — city placement, infrastructure, output format
- `server/src/simulation/generator.rs` — DistrictSkeleton and all types (stubs)
- `server/src/simulation/chunk_streaming.rs` — chunk load/unload architecture
**Decisions:**
- `decisions/architecture.md` — D-010 (determinism rule), D-020 (client-server), D-031 (time system)
- `CLAUDE.md` §Development Cascade — the phase definitions and cascade order
**Domain requirements (Round 4 inputs):**
- `docs/workshops/generation-cascade/gestalt-round4.md` — systems design questions per layer
- `docs/workshops/generation-cascade/paula-round4.md` — narrative requirements, TerritorialStatus, ghost city effect
- `docs/workshops/generation-cascade/burnelli-sheldon-round4.md` — economics integration, latent settlements, determinism split
---
## Expected Outputs
1. **Algorithm specifications** for each of the 4 cascade layers — sufficient detail for a developer to implement without further design work
2. **Data format handoffs** per layer — what goes in systems.db (Python tooling), what is seed-derived at runtime (Rust), what is a runtime simulation variable
3. **D-records** in `decisions/` for all locked decisions (targeting D-C1 through D-C12 candidates from prior workshop plus new ones from this workshop)
4. **Implementation ticket dependency chain** — what to build first, what is blocked behind what, which prior tickets (#899 et al.) need rescoping against the new layer model
5. **Resolved open questions** — specifically: L1-Q1 (authored vs. drainage rivers), L2-Q1 (anchor vs. re-derive city positions), L3-Q1 (FoundingOrientation spatial effect), L4-Q1 (economics rendering mechanism), CL-Q1 (prosperity_baseline vs. current naming), CL-Q2 (regional land-use evolution resolution boundary)
---
## Participant Roles
| Participant | Primary domain | Layer focus |
|-------------|---------------|-------------|
| **Gestalt** | Systems design, fun factor, mechanical decisions | Cross-layer — evaluates each layer for interesting player choices and mechanical coherence |
| **Tyre** | Technical architecture, feasibility, algorithm design | All layers — owns algorithm concreteness, data formats, Python vs. Rust split, performance |
| **Paula** | Narrative legibility, political depth | Layers 1-2 (geographic triggers → political structure) + the ghost city effect at Layer 4 |
| **Burnelli-Sheldon** | Economics simulation, quantitative modeling | Layers 2-3 (economics onto geography, zoning weights) + determinism/rendering split |
@@ -0,0 +1,486 @@
---
title: "Round 1 — Burnelli-Sheldon: Economics Inventory and Amendment 6 Analysis"
description: "Economics domain inventory for the planet-down cascade workshop. Question triage, Amendment 6 weight table critique with proposed alternative, and economic field mapping per layer."
type: workshop
status: active
workshop: planet-down-cascade
agent: burnelli-sheldon
round: 1
created: 2026-05-01
---
# Round 1 — Burnelli-Sheldon: Economics Inventory and Framing
This document is my Round 1 contribution to the planet-down cascade workshop. I'm
working from the Brief, the Consultant Review amendments, the four prior generation-cascade
rounds, and the existing systems.db schema. My job this round: inventory the open
questions from an economics perspective, triage them by difficulty and prerequisite
structure, and deliver a concrete alternative to the 10×9 weight table that Amendment 6
has unlocked for revision.
---
## 1. What the Amendments Change for My Domain
Before the question inventory, the amendments require me to update my prior assumptions.
### Amendment 1 (Three-Tier Execution Model) — Clean Win for Economics
The brief had conflated "Layer 1-2 stored in systems.db" with "economic sim needs Layer
1-2 data." Amendment 1 correctly separates them:
- **Economic simulation runs on system-level aggregates** — `economic_tier`, `corp_presence`,
`corp_financial_state.health_metric`, commodity flows, gate connectivity. All of this
is already in systems.db from build-time generation. The sim does not need to know
where a mining camp sits on a planetary surface to compute inter-system commodity
pricing.
- **Layer 1-2 spatial data** — drainage, settlement positions, road graphs, TerritorialStatus —
is consumed only by Layer 3 city planning and Layer 4 tile rendering. Neither of those
feeds back into the economic simulation; they read from it.
This is the correct separation. My Round 4 mapping of "economic fields at Layer 2" still
holds — those fields are read by the Layer 2 generator as inputs to settlement placement.
But the Layer 2 generator is now a runtime background process reading from systems.db
snapshots, not a Python batch process. The economics data it reads is already there.
**What this changes:** `generate_regional.py` as designed in Tyre's Round 3 becomes a
reference/validation tool. The algorithms I propose must be implementable in Rust on a
background thread, without Python-specific dependencies.
### Amendment 3 (Fully Generative Placement) — New Question Required
L1-Q1, L2-Q1, and CL-Q4 (all variants of "authored rivers vs. generated network" and
"anchor vs. re-derive city positions") are eliminated. Every city position is now
generator-derived. This adds two new questions:
- **Attractor-matching algorithm**: N named cities with known economic roles must be
matched to M geographic attractors. This is my primary Layer 2 algorithm contribution.
- **Name reservation fulfillment**: How does the generator know which city name resolves
to which generated position? This is an architecture question (Tyre) with economics
constraints (I need to flag which cities are "locked" because corporate cross-references
in systems.db point to them by name).
### Amendment 6 (10×9 Weight Table Unlocked) — See §4 Below
The weight table is no longer a given fact. The lead's challenge is correct and I'll
address it in full in §4.
---
## 2. Question Inventory — By Status
### Eliminated (no workshop resolution needed)
| Question | Why eliminated |
|----------|---------------|
| L1-Q1 | Amendment 3: rivers are fully generated, no authored network |
| L2-Q1 | Amendment 3: all city positions are generator-derived |
| CL-Q4 | Amendment 3: same as L1-Q1 |
### Economics Primary — I Own These
| Question | Type | Prerequisite for |
|----------|------|-----------------|
| **L1-Q4** (biome probability prior) | Novel design | Nothing downstream blocks on this; pure quality improvement |
| **L2-Q2** (sub-settlement placement algorithm) | Novel design + data schema | L2-Q6 (flag storage), Layer 3 corp district intensity |
| **L2-Q4** (hinterland shape algorithm) | Novel design | Land use grid → Layer 3 city context |
| **L2-Q5** (TerritorialStatus thresholds) | Novel design (quantitative) | Paula's political framing; snapshot accuracy |
| **L2-Q6** (latent settlement flag storage) | Data/schema | Runtime active/ghost status across all settlement types |
| **L3-Q4** (topographic constraints on zoning) | Novel design | Blocks L3-Q5 derivation unless we agree topo is advisory only |
| **L3-Q5** (prosperity_baseline formula) | Novel design (quantitative) | Layer 3 minimum viable output |
| **L3-Q7** (port/station direction) | Data/schema | Deferred — not min viable scope |
| **Amendment 6** (district mix alternative) | Novel design | ALL of Layer 3 — this is the zoning foundation |
| **New: Attractor-matching algorithm** | Novel design | L2-Q2 depends on it |
### Economics Advisory — I Contribute, Others Lead
| Question | Who leads | My input |
|----------|-----------|---------|
| L3-Q1 (FoundingOrientation grid orientation) | Gestalt | prosperity gradient direction = my input |
| L3-Q2 (political archetype spatial arrangement) | Paula | economic role shapes arrangement alongside political type |
| L3-Q6 (town vs. city code path) | Tyre | unified path preferred — I have no economics objection |
| L4-Q1 (economics-variable rendering mechanism) | Tyre + Gestalt | my input: threshold-crossing, not per-tick |
| L4-Q2 (condition update trigger) | Tyre | my input: threshold thresholds I can specify |
| L4-Q6 (prosperity_delta architecture) | Paula | I co-own the field definition |
| CL-Q1 (prosperity_baseline vs. current naming) | All | I proposed the field names; agree to lock them |
| CL-Q2 (regional land-use evolution) | Tyre | I proposed the resolution boundary in R4-B2 |
| CL-Q3 (ruin lifecycle) | Paula | Amendment 5: emergent only, no dedicated mechanics |
### Independent (do not block other questions)
L1-Q4, L3-Q7, and CL-Q3 can all be answered or deferred without blocking anything else.
### Prerequisite Questions (must resolve first)
1. **Amendment 6 / district mix** — blocks all of Layer 3 algorithm design. Everything
about zoning, prosperity gradients, and block skeletons depends on knowing the
district type distribution method.
2. **Attractor-matching algorithm** (new from Amendment 3) — blocks L2-Q2 sub-settlement
placement, since camps are placed relative to tether cities, and tether cities are now
generator-placed.
3. **CL-Q1** (field naming lock) — blocks all Layer 3 and Layer 4 algorithm descriptions
that need to reference these fields.
---
## 3. What Algorithms Exist vs. What Is Novel
### Reusable from Prior Work
**From Round 3 / Round 4 — economic field mapping:**
The `bodies`, `system_economy`, `corp_presence`, `system_fiscal`, `corp_financial_state`
table reads are defined and stable. The 6-field minimum read set for `CityGenerationContext`
is confirmed. These are not novel design work — they are schema reads.
**From Round 4 — determinism split:**
The layout-vs-appearance split is confirmed. Seed-locked = positions. Economics-variable =
tile conditions, lighting, activity states. This is done. CL-Q2 (regional land use
resolution boundary) I recommended in Round 4-B2: coarse biome-cell resolution can update
at runtime; city-internal layout never changes. I hold this position.
**From Round 4 — latent settlement principle:**
Settlement positions are seed-locked at Layer 2. Active/ghost status is runtime
economic sim state. The four settlement types (mining camps, trade waypoints,
agricultural nodes, shadow nodes) and their trigger conditions are specified. This
is not novel design work for Round 1 — it's a carry-forward.
**From the brief — D-C4 district count formula:**
`max(1, floor(city.population / 50_000))`, log-scaled, WorldTier caps, capital bonus.
This is locked and reusable.
### Novel Design Work Required This Round
**1. District mix algorithm (Amendment 6)** — the primary economics deliverable for
this workshop. See §4 for my full proposal.
**2. Attractor-matching algorithm** — new question from Amendment 3. Economic role
determines which geographic attractor is the right match for each city. I have a
framework for this (comparative advantage matching); see §5.
**3. Prosperity gradient formula (L3-Q5)** — the formula is sketched in the brief
but needs topographic interaction defined. My proposal: topography sets gradient
DIRECTION, `distribution_index` sets gradient MAGNITUDE. A stratified city on a hill
has elite zones at the top with a steep gradient. A moderate city on a hill has mixed
zoning across elevations with a shallow gradient.
**4. TerritorialStatus thresholds (L2-Q5)** — quantitative thresholds need values.
Paula owns the taxonomy; I can supply the economic proxies for each tier.
### Data/Schema Questions Only (no algorithm)
**CL-Q1** — just a naming lock. `prosperity_baseline`, `prosperity_current`,
`prosperity_delta` (derived). I hold the definitions from my Round 4 document.
**L2-Q6** — latent settlement flag storage. My Round 4 recommendation: option (b),
Phase 3 output table. In the three-tier model, this becomes a runtime background
generation output stored in session DB, not systems.db. No change to the algorithm;
just where the output lands.
**L3-Q7** — port/station direction. Feasible from systems.db `stations` table
(`orbits_body_id` + atlas body grid coordinates). Recommended deferred for minimum
viable scope.
---
## 4. Amendment 6: Why the 10×9 Table Is Wrong and What Replaces It
The lead's challenge is economically exact. I'll state the theorem clearly, then
propose the replacement.
### The Theorem: Concentrated Labor Creates Service Demand
When workers concentrate in a settlement, service industries emerge to serve them —
regardless of what those workers produce. This is not optional. It is a consequence
of basic human needs. Miners need food, clothing, medical care, and — critically —
social venues. They drink. They gamble. They fight and need someone to stitch them up.
The company may own the tavern (company town), the union may run the recreation hall
(labour-movement town), or it may be informal (frontier dive), but the entertainment
function exists.
The 10×9 table violates this theorem by assigning zero weight to entertainment for
mining, extraction, and energy economic roles. A real mining town with zero entertainment
establishments either just opened yesterday or is a forced-labour camp. Neither is the
default.
What the table actually encodes is what the settlement *produces*. What it should encode
is *how* the settlement configures the services it necessarily has.
### The Three-Component Replacement
**Component 1: Population Tier — Baseline District Guarantees**
Every inhabited settlement receives a minimum set of district types determined by
population. These are non-negotiable floors because the economics of human settlement
make them inevitable:
| Settlement Tier | Population | Guaranteed District Types |
|----------------|-----------|--------------------------|
| Outpost | 50–999 | Residential + Mixed (combined commerce/basic services) |
| Town | 1,000–9,999 | + Commercial (dedicated) |
| City | 10,000–99,999 | + Entertainment (character varies by role) |
| Large City | 100,000+ | + Administrative (dedicated) |
These floors apply *before* the economic role weights are applied. If D-C4 formula
gives a 1-district city, the single district is Mixed (it carries all functions). If
it gives 2 districts, one is Residential and one is Mixed-plus-specialty. At 3+
districts, dedicated Commercial and Entertainment emerge as separable entities.
Note: this integrates cleanly with the D-C4 district count formula. The guarantee
is not "always build 5 districts." It is "whatever districts you DO build, allocate
these types first before filling with production-role specialty."
**Component 2: Economic Role — Multiplier on Weight, Not On/Off Switch**
Once the guaranteed minimums are placed, the remaining district slots are filled using
a weight multiplier table. Key change from the 10×9 table: **no weight is ever zero**.
Minimum weight is 0.2, meaning "present but small/rough." Maximum is 3.0. This encodes
economic role as a proportion and character modifier, not a presence/absence gate.
Proposed multiplier table (replaces the 10×9 weight table):
| economic_role | Res | Com | Ind | Adm | Log | Ent | Mix | Trn | Spe |
|--------------|-----|-----|-----|-----|-----|-----|-----|-----|-----|
| manufacturing | 1.5 | 0.4 | 2.5 | 0.5 | 1.5 | 0.4 | 0.6 | 0.5 | 0.2 |
| agricultural | 2.0 | 0.8 | 0.4 | 0.8 | 1.8 | 0.5 | 1.2 | 0.4 | 0.2 |
| extraction | 1.5 | 0.4 | 2.5 | 0.3 | 2.0 | 0.5 | 0.8 | 0.7 | 0.2 |
| transit | 0.8 | 1.5 | 0.4 | 0.4 | 1.8 | 1.0 | 1.2 | 2.0 | 0.2 |
| research | 1.5 | 0.5 | 0.4 | 1.2 | 0.4 | 0.6 | 0.8 | 0.2 | 2.5 |
| commercial | 1.2 | 2.5 | 0.4 | 0.5 | 0.8 | 1.2 | 1.2 | 0.3 | 0.2 |
| service_mixed | 1.8 | 1.5 | 0.4 | 0.8 | 0.5 | 1.2 | 1.5 | 0.2 | 0.2 |
| mining | 1.5 | 0.4 | 2.0 | 0.2 | 2.5 | 0.5 | 0.7 | 0.5 | 0.2 |
| frontier | 2.5 | 0.8 | 0.8 | 0.4 | 1.5 | 0.5 | 1.2 | 0.2 | 0.2 |
| energy | 0.8 | 0.2 | 1.5 | 0.5 | 2.0 | 0.2 | 0.5 | 0.5 | 2.5 |
The mining row now has Ent = 0.5 (half baseline, but present) and Com = 0.4.
The energy row has Ent = 0.2 (minimal, but not zero). A large energy facility has
workers, and those workers have somewhere to socialize — it's just a spartan canteen
and a gym, not a cultural district.
**How to apply the multiplier table:**
1. Apply population tier guarantees. Mark those district slots as filled.
2. Remaining district slots = D-C4 total count minus guaranteed minimums.
3. Normalize the multiplier table row for this city's economic role to sum to 1.0.
4. Distribute remaining slots by normalized weights, rounding to integers.
5. Apply `political_archetype` modifiers on top (existing mechanic, unchanged).
6. Apply `distribution_index` to set the prosperity gradient magnitude across the
resulting district set.
**Component 3: Settlement Age — Character Modifier**
`bodies.founding_age_years` tells us how long a settlement has had to develop its
services. New settlements are production-focused and sparse. Old settlements have
had time for secondary and tertiary industries to emerge.
I do NOT propose that age changes district count (that's population-driven) or district
presence (that's the guarantee floor). Age modifies the **character and quality** of
districts within a given type:
| founding_age_years | Character effect |
|-------------------|-----------------|
| < 50 (new) | Entertainment = bare-minimum (company rec hall or single dive bar) |
| 50–150 (established) | Entertainment = working-class functional (taverns, union halls, local sports) |
| 150–300 (mature) | Entertainment = differentiated (high/low-end venues, cultural institutions beginning) |
| 300+ (old) | Entertainment = layered (heritage venues, class-stratified nightlife, institutions) |
This is a character descriptor attached to the district, not a structural change to
district count or type. The renderer and content system read it to choose tile variants
and NPC archetypes. The generator doesn't need to do extra layout work for this.
### The Combined Guarantee: No Zero-Service Settlements
Under this model:
- A mining outpost (population 200) has: Residential + Mixed. The Mixed district is
the supply store, the first-aid shack, and the bar, packed into one cluster.
- A mining town (population 5,000) has: Residential + Commercial + Entertainment
(guaranteed) + Ind + Log (from weight table, dominant). The entertainment district
is rough bars and a gambling hall, not a theatre.
- A mining city (population 80,000) has 1–2 Industrial, 1–2 Logistics, 1 Residential,
1 Commercial, 1 Entertainment (rough but present), 0–1 Mixed from remaining slots.
That's 6–7 districts. All production-role character, but no zero-service gap.
The lead's intuition is exactly right: mining towns have rough entertainment because
miners drink. This model produces that outcome mechanically.
---
## 5. New Question: Attractor-Matching Algorithm
Amendment 3 eliminates authored city positions and requires the generator to match
N named cities (with known economic roles) to M geographic attractors. From an
economics standpoint, this is a constraint satisfaction problem with a clear
theoretical framework: comparative advantage assignment.
**The economic principle:** Each city has an economic role that implies a preferred
attractor type. Agricultural cities prefer arable plains + water access. Mining cities
prefer resource concentration sites + transport. Transit cities prefer natural
chokepoints — mountain passes, river crossings, harbor narrows.
**Proposed matching algorithm sketch:**
```
For each body_id with N named cities in atlas_cities:
1. Run Layer 1 to completion: get attractor set M with typed features
(RiverConfluence, CoastalHarbor, MountainPass, ArablePlain,
ResourceConcentration, Defensible, NaturalBarrier)
2. Score matrix: S[city_i, attractor_j] = compatibility(city.economic_role, attractor.feature_type)
3. Hard constraints: if corporations.headquarters_body = city, that city must be
placed at an attractor compatible with its economic role (name-locked)
4. Solve: maximize sum of compatibility scores subject to one-city-per-attractor
(Hungarian algorithm or greedy by largest gap if N << M)
5. Overflow: if M < N (more cities than attractors), generate synthetic attractors
as secondary sites (river bends, coastal plains) by seed
```
**Compatibility matrix (partial — sufficient for framing):**
| economic_role | RiverConfl. | CoastalHarbor | MtnPass | ArablePlain | ResourceConc. | Defensible |
|--------------|-------------|--------------|---------|-------------|---------------|------------|
| agricultural | high | medium | low | very high | low | low |
| extraction | low | low | medium | low | very high | medium |
| mining | low | low | medium | low | very high | medium |
| transit | high | high | high | low | low | medium |
| commercial | high | high | medium | medium | low | low |
| research | medium | medium | medium | medium | medium | high |
| manufacturing | medium | medium | low | medium | medium | low |
| frontier | medium | medium | medium | medium | medium | high |
The attractor-matching algorithm is a Layer 2 entry point. It runs after Layer 1
completes for a body and before Layer 2 sub-settlement placement. Its output is:
a mapping from (city_id → attractor_position) that seeds the rest of Layer 2.
This question is new and needs Round 2 algorithm concreteness. For now I'm flagging
the economic framework and the constraint structure.
---
## 6. Economics Field Mapping — Revised for Three-Tier Model
My Round 4 field mapping remains correct in content. What changes under Amendment 1
is *when* each field is accessed:
### Build-Time (systems.db — always available)
| Field | Table | Purpose |
|-------|-------|---------|
| `economic_role` | bodies | Layer 1 biome prior (advisory); Layer 2 hinterland; Layer 3 weight table |
| `settlement_pattern` | bodies | Layer 2 population distribution |
| `founding_age_years` | bodies | Layer 3 district character modifier |
| `economic_tier` | system_economy | Infrastructure density (Layer 2), prosperity baseline (Layer 3) |
| `distribution_index` | system_economy | Prosperity gradient magnitude (Layer 3) |
| `corp_id`, `location_id`, `primary_operation` | corp_presence | Layer 2 settlement triggers; Layer 3 district intensity |
| `health_metric` | corp_financial_state | Layer 2 settlement active/ghost seed state |
| `collection_efficiency` | system_fiscal | Layer 2 shadow node trigger |
| `behavioral_archetype` | corporations | Layer 3 district layout character |
| `headquarters_body` | corporations | Name-locked city constraint (Amendment 3) |
### Runtime Background (Layer 1-2 generation, Rust)
Layer 1-2 reads from systems.db fields above and produces:
- Attractor positions (Layer 1 output, consumed by Layer 2)
- Settlement positions (Layer 2 output, stored in session DB)
- TerritorialStatus and PoliticalTether (Layer 2 output)
- Land use grid (Layer 2 output)
These are not economics tables — they are geography outputs that economics data
shapes. The economic sim does not consume them.
### Runtime On-Demand (Layer 3-4 generation, Rust)
`CityGenerationContext` struct is populated from systems.db at startup — no runtime
query. Layer 3 receives it and uses `economic_role`, `economic_tier`, `distribution_index`,
`corp_presence` count, and `headquarters_system/body` match. That is the 6-field minimum
read set, unchanged.
### Runtime Simulation Variables (read by Layer 4 renderer)
| Signal | Source | Effect |
|--------|--------|--------|
| `prosperity_current` | Economic simulation | Tile condition selection |
| `health_metric` (updated) | Economic simulation | Corporate signage, maintenance state |
| Active/ghost status | Economic simulation | Lighting, activity density |
These are not consumed by the generator. They are render parameters updated by the sim.
---
## 7. TerritorialStatus Thresholds (L2-Q5) — Quantitative Proposal
Paula owns the taxonomy. Here are the economic proxies I propose for threshold
derivation:
| TerritorialStatus | Economic derivation |
|------------------|---------------------|
| **CoreTerritory** | settlement_density ≥ 1 city per province + road maintenance_authority on all connecting edges |
| **FrontierTerritory** | settlement present but road density < 0.5 of CoreTerritory average for this economic_tier |
| **ExtractiveZone** | `ResourceConcentration` feature + `corp_presence` with matching commodity + road to resource but sparse off-corridor |
| **ContestZone** | Two settlement clusters within overlapping territory (distance < 2 × footprint_radius) with different `political_archetype` or dominant_faction |
| **WildernessBuffer** | No settlement placed by Layer 2 algorithm |
| **AbandonedZone** | Settlement placed but `corp_financial_state.health_metric < 0.2` for all operating corps at founding time |
These thresholds are derivable from fields already in systems.db. No new data required.
The key economic inputs are corp presence count and health metric at seed time (not
runtime), plus road graph density.
---
## 8. Open Questions I'm Raising for Round 2
**BS-Q1: Population tier floor table — is the 50-person minimum too low for guaranteed
Entertainment?**
I've proposed Entertainment as guaranteed at City tier (10,000+). A 9,999-person Town
doesn't get a dedicated Entertainment district under this model — it gets some in its
Commercial district. Does that feel right? A 9,000-person mining town with no
Entertainment *dedicated district* but Entertainment-character Commercial seems
plausible. Flag this for Gestalt's "does this feel inhabited" check.
**BS-Q2: How does founding_age_years interact with WorldTier?**
A Waypoint-tier settlement by definition has population < 50. It can't be old enough to
have cultural institutions even if `founding_age_years = 300`. The age modifier needs
to be capped by WorldTier ceiling. Proposed: age modifier only applies at Town tier and
above.
**BS-Q3: The energy row minimum Entertainment weight of 0.2 — is this adequate for
large energy production facilities?**
An energy facility with 200,000 workers has a lot of people who need social services.
At weight 0.2 against a population that produces 4 districts by D-C4, we might get zero
Entertainment districts from the weight allocation (if 0.2 normalized against all the
Log and Spe weights is too small). The guarantee floor rescues this — Entertainment is
guaranteed at City tier. But I want to flag that the weight 0.2 for energy/Entertainment
will produce the minimum possible dedicated Entertainment district, and the guaranteed
floor + character descriptor is doing the real work here, not the weight.
**BS-Q4: Does the attractor-matching algorithm need to handle bodies with no
geographic attractors?**
The consultant review says "if M < N, generate synthetic attractors." This is
algorithmically straightforward. But economically: what does it mean for a body to have
N cities and fewer geographic attractors than cities? It suggests a high-tier economic
world where secondary manufacturing cities exist independent of natural features
(because technology overrides geography). This is plausible for manufacturing, service,
and commercial roles on high-`economic_tier` bodies. The synthetic attractor rule seems
correct: place remaining cities along existing road corridors at seed-derived offsets.
---
## 9. What I Need from Other Participants
**From Gestalt:** The Amendment 6 population tier guarantee table should be evaluated
against "does this produce places that feel inhabited?" at each tier. The economic
logic says the guarantees are correct; Gestalt should verify the fun-and-legibility
layer.
**From Tyre:** The attractor-matching algorithm (§5) needs a concreteness check.
Is the Hungarian algorithm appropriate for this scale (N ≤ 30 cities per body,
M = 50-200 attractors)? What is the performance cost?
**From Paula:** TerritorialStatus thresholds (§7) — do these economic proxies
produce the political texture you need for narrative differentiation? CoreTerritory
vs. FrontierTerritory based purely on settlement density and road maintenance seems
thin for narrative purposes. Is there a political pressure field I'm missing?
**From all:** CL-Q1 (field naming lock) should be confirmed at the start of Round 2
so all algorithm descriptions use consistent field names.
@@ -0,0 +1,602 @@
---
title: "Round 2 — Burnelli-Sheldon: Economics Algorithm Proposals"
description: "Full specification of the three-component district mix model, attractor-matching compatibility matrix, latent settlement generalization under fully generative placement, and TerritorialStatus threshold convergence."
type: workshop
status: active
workshop: planet-down-cascade
agent: burnelli-sheldon
round: 2
created: 2026-05-01
---
# Round 2 — Burnelli-Sheldon: Economics Algorithm Proposals
This document provides concrete algorithm specifications for the four economics assignments
from the Round 1 notes. No positions are hedged — these are proposals for convergence.
---
## 1. Three-Component District Mix Model — Full Specification
### 1.1 Component 1: Population Tier Guarantees
The population tier guarantees establish a **mandatory district set** that is allocated
before the economic role weight table distributes remaining slots. These are derived from
the sub-settlement hierarchy in the brief, mapped to the D-C4 district count formula.
**Guaranteed district set by population tier:**
| Sub-settlement tier | Population | Guaranteed types | Notes |
|--------------------|-----------|-----------------|-------|
| Waypoint | < 50 | 1× **Mixed** | All functions fused into one district |
| Outpost | 50–999 | **Residential** + **Mixed** | Two functional zones |
| Town | 1,000–9,999 | **Residential** + **Commercial** | Entertainment present inside Commercial character |
| City | 10,000–99,999 | **Residential** + **Commercial** + **Entertainment** | Dedicated entertainment justified by population |
| Large City | 100,000+ | **Residential** + **Commercial** + **Entertainment** + **Administrative** | Bureaucratic complexity requires dedicated admin |
**BS-Q1 resolution (9,999-person Town without dedicated Entertainment):** Confirmed
plausible. A Town's Commercial district contains the entertainment function — the pub,
the social hall, the gambling den — as a character property of that district. When the
settlement grows to City tier (10,000+), specialization makes a dedicated Entertainment
district economically viable. The function always exists; the dedicated district is the
marker of scale.
**WorldTier exception — domed/cave settlements:** Always 1 district regardless of
population. Single district type = Mixed, carrying all functions.
**Capital city bonus:** +1 district slot (as per D-C4 rule) distributed by the weight
table after guarantees are placed.
**When D-C4 count is less than the guaranteed set size:**
D-C4 can produce fewer total district slots than the guaranteed set requires. When this
occurs, the overflow guaranteed types collapse into Mixed:
```
if D_total < len(guaranteed_set):
collapse_to_mixed():
1 district if D_total == 1 → type = Mixed (all functions)
2 districts if D_total == 2 → Residential + Mixed (remaining functions)
```
This correctly handles outposts with D-C4 = 1 that the formula would label Passage-tier.
### 1.2 Component 2: Economic Role Multiplier Table
The weight table distributes the **remaining district slots** after the guaranteed set
is placed. It also applies to the guaranteed set as a character modifier for *which
variant* of each type the settlement gets — but it does not remove guaranteed types.
**No weight is ever zero.** Minimum value is 3 (not 0.2 as I wrote in Round 1 — I
converted to integer weights summing to 100 per row for implementation clarity). This
means every district type has some non-zero probability of appearing in any settlement's
non-guaranteed slots, while the character differences between economic roles remain
pronounced.
**Full weight table (integer weights, each row sums to 100):**
| economic_role | Res | Com | Ind | Adm | Log | Ent | Mix | Trn | Spe |
|--------------|-----|-----|-----|-----|-----|-----|-----|-----|-----|
| manufacturing | 18 | 8 | 30 | 6 | 18 | 5 | 8 | 5 | 2 |
| agricultural | 22 | 12 | 5 | 10 | 20 | 5 | 15 | 5 | 6 |
| extraction | 16 | 7 | 28 | 4 | 24 | 5 | 9 | 5 | 2 |
| transit | 10 | 18 | 5 | 5 | 20 | 12 | 14 | 14 | 2 |
| research | 16 | 6 | 5 | 14 | 5 | 7 | 10 | 3 | 34 |
| commercial | 14 | 30 | 5 | 6 | 10 | 14 | 14 | 5 | 2 |
| service_mixed | 20 | 18 | 5 | 10 | 6 | 14 | 18 | 7 | 2 |
| mining | 16 | 7 | 22 | 3 | 28 | 6 | 9 | 7 | 2 |
| frontier | 26 | 10 | 10 | 5 | 18 | 8 | 16 | 5 | 2 |
| energy | 10 | 3 | 18 | 6 | 24 | 3 | 6 | 6 | 24 |
**How to read this table:**
The mining row has Log = 28, Ind = 22 as dominant weights. After placing guaranteed
Residential + Commercial (at Town) or + Entertainment (at City), remaining slots almost
always produce Industrial and Logistics districts. Entertainment weight = 6 means
occasionally a second Entertainment appears in a large mining city — a second drinking
district for a city that grew big enough to support it. This is correct.
The energy row has Spe = 24, Log = 24 as dominant. Energy installations need Specialized
facilities (reactors, control centers, research wings) and Logistics (heavy transport for
fuel and output). Entertainment = 3 and Commercial = 3 are minimal but non-zero — large
energy cities have a company canteen and a gym, which earns each a district representation
at high enough population.
**BS-Q3 resolution (energy Entertainment weight):** At Outpost/Town scale (1-2 districts),
energy settlements don't get a dedicated Entertainment slot from the weight table —
Entertainment is present inside the Mixed/Commercial district instead. At City scale
(10,000+), the guaranteed Entertainment takes effect, producing a dedicated district with
rough-canteen character. The weight = 3 only applies to *additional* Entertainment
districts beyond the guarantee, which are rare. This is the correct behavior.
**Political archetype modifiers stacked on top:**
These remain as described in the brief. They're additive modifiers to the weight table
row before normalization:
```
CompanyTown: Adm -= 10, Ind += 10, Log += 5, Res += 5 (production-heavy, thin governance)
AdminCapital: Adm += 20, Ent += 5, Spe += 5, Ind -= 15 (governance-heavy, civic services)
FreePort: Com += 15, Trn += 10, Mix += 5, Adm -= 15 (commerce-heavy, light governance)
Contested: no modifier (both political forces fight over what to build; neither wins)
OrganicGrowth: Mix += 15, Res += 10, Ind -= 10, Adm -= 5 (unplanned, service-led growth)
```
### 1.3 Component 3: Settlement Age Modifier
**Where founding_age_years comes from:**
For named cities: `bodies.founding_age_years` (INTEGER, already in the bodies schema).
For sub-settlements without a direct bodies row, derive from parent body age:
| Sub-settlement type | Derived age |
|--------------------|-----------:|
| Mining camp | `max(5, parent_body.founding_age_years - 20)` |
| Trade waypoint | `max(5, parent_body.founding_age_years / 3)` |
| Agricultural node | `max(5, parent_body.founding_age_years - 10)` |
| Shadow node | 10 (always nascent — informal, lacks permanence by definition) |
**BS-Q2 resolution:** Age modifier applies only at Backwater tier and above. Waypoint and
Passage settlements are too structurally simple to differentiate by age; they get
nascent character regardless of founding date.
**Age bracket classification:**
| founding_age_years | Age bracket | Name |
|-------------------|-------------|------|
| 0–49 | nascent | Raw production focus |
| 50–149 | young | Functional |
| 150–349 | established | Differentiated |
| 350+ | mature | Layered |
**What age bracket affects:**
Age modifies the **character class** of each district. This propagates to three
`DistrictSkeleton` fields:
- `perimeter_treatment`: nascent → Open; young → Fenced; established → Walled; mature → variable (can be any)
- `density_pct` of commercial/entertainment blocks: nascent = 0.4–0.6; young = 0.5–0.7; established = 0.6–0.8; mature = 0.6–0.9 (wide range — old cities have both dense cores and thin outskirts)
- Content system character selector: nascent entertainment = "bare supply function"; mature entertainment = "institutional legacy"
**What age bracket does NOT affect:** District count. District type. Prosperity baseline.
These are determined by Components 1 and 2 alone.
**Age × WorldTier cap interaction:**
```
effective_age_bracket =
if world_tier in (Waypoint, Passage): nascent # always
elif founding_age_years < 50: nascent
elif founding_age_years < 150: young
elif founding_age_years < 350: established
else: mature
```
A 400-year-old Waypoint is still nascent. A 10-year-old Epicenter city is still nascent.
The cap ensures structural simplicity wins over chronological age for small settlements.
### 1.4 The Combined Algorithm — Full Formula
```
Algorithm: compute_district_distribution(city, body, system, terrain)
Inputs (all from systems.db or session DB Layer 1-2 output):
city.population, city.world_tier, city.political_archetype
body.economic_role, body.founding_age_years, body.settlement_pattern
system.economic_tier, system.distribution_index
terrain.topographic_gradient_direction # from Layer 1 output
seed = child_seed(world_seed, city_id) # SeedChain
Returns: Vec<DistrictSpec>
where DistrictSpec = { type: DistrictType, prosperity_baseline: f32,
character_class: CharacterClass, density_pct: f32,
perimeter_treatment: PerimeterTreatment }
──────────────────────────────────────────────────────────────
Step 1: Total district count
D_raw = max(1, floor(city.population / 50_000)) # log-scaled for pop > 500k
D_total = min(D_raw, WorldTier_cap[city.world_tier])
if city.is_capital: D_total = min(D_total + 1, WorldTier_cap[city.world_tier])
if body.settlement_pattern in (domed, cave): D_total = 1
Step 2: Mandatory districts from population tier
mandatory_types = guaranteed_set(city.population, city.world_tier)
# Apply D_total cap: if len(mandatory_types) > D_total, collapse to Mixed
mandatory = place_guaranteed_types(mandatory_types, D_total)
remaining_slots = D_total - len(mandatory)
# NOTE: computation is purely local — NO queries about neighboring cities.
# Each settlement is computed independently from its own fields and seed.
Step 3: Fill remaining slots from economic role weight table
if remaining_slots > 0:
weights = ROLE_WEIGHT_TABLE[body.economic_role] # 9-element, all > 0
weights = apply_archetype_modifiers(weights, city.political_archetype)
weights = renormalize_to_100(weights)
# Sample without replacement — can produce duplicate types (second Ind, second Log)
additional_types = seeded_weighted_sample(weights, remaining_slots, seed)
all_types = mandatory + additional_types
else:
all_types = mandatory
Step 4: Assign age character class
age_bracket = effective_age_bracket(body.founding_age_years, city.world_tier)
for d in all_types:
d.character_class = AGE_CHARACTER_TABLE[d.type][age_bracket]
d.density_pct = age_density_range(d.type, d.character_class)[seeded_float(seed)]
d.perimeter_treatment = age_perimeter(d.type, age_bracket)
Step 5: Assign prosperity gradients
base_prosperity = economic_tier / 5.0 + ROLE_PROSPERITY_MODIFIER[body.economic_role]
gradient_direction = terrain.topographic_gradient_direction # high point = high prosperity
gradient_magnitude = DISTRIBUTION_INDEX_SCALE[system.distribution_index]
# Paula's topographic modifier is additive to the base, direction set by terrain:
# hilltop district (top 30% elevation): +0.05
# flood-adjacent district (bottom 20% vs. sea level): -0.05
for (i, d) in enumerate(all_types):
positional_rank = rank_in_gradient_direction(d.grid_position, gradient_direction, len(all_types))
topo_modifier = topo_offset(d, terrain)
d.prosperity_baseline = clamp(
base_prosperity + (positional_rank - 0.5) × gradient_magnitude + topo_modifier,
0.05, 0.95
)
──────────────────────────────────────────────────────────────
ROLE_PROSPERITY_MODIFIER (from L3-Q5 brief formula):
extraction: -0.10
research: +0.15
service_mixed: +0.10
frontier: -0.20
(all others: 0.00)
DISTRIBUTION_INDEX_SCALE:
"stratified": 0.7 (steep gradient — 0.7 × positional rank ± 0.35 spread)
"moderate": 0.2 (shallow gradient — ≈ Gaussian around base, σ = 0.1)
```
**Self-contained confirmation:** The algorithm references only local city/body/system
fields. There are no lookups into neighboring city data, no distance calculations to
adjacent settlements, no regional density queries. Each city generates its district
distribution independently from its seed and the fields above. This satisfies the
SeedChain isolation property from D-C10.
---
## 2. Attractor-Matching: Full Compatibility Matrix
### 2.1 The Compatibility Matrix
Scale 0–10 where 0 = incompatible/excluded and 10 = preferred/ideal match.
Geographic attractor types: the seven tags from the brief (Section 1, Layer 1 minimum
viable) plus two additional tags identified by Paula (CoastalLowland, RiverValley) that
emerged in Round 1 discussion.
| economic_role | RiverConfl | CoastalHarbor | MtnPass | ArablePlain | ResourceConc | Defensible | NaturalBarrier | CoastalLowland | RiverValley |
|--------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| agricultural | 8 | 5 | 2 | 10 | 1 | 2 | 0 | 7 | 8 |
| extraction | 3 | 3 | 5 | 2 | 10 | 3 | 1 | 2 | 3 |
| mining | 2 | 2 | 6 | 1 | 10 | 4 | 1 | 1 | 2 |
| transit | 9 | 9 | 10 | 3 | 1 | 3 | 0 | 5 | 7 |
| commercial | 8 | 9 | 6 | 4 | 2 | 2 | 0 | 6 | 7 |
| research | 4 | 4 | 4 | 4 | 6 | 8 | 3 | 3 | 4 |
| manufacturing | 7 | 6 | 3 | 5 | 5 | 2 | 0 | 6 | 6 |
| service_mixed | 6 | 6 | 4 | 6 | 2 | 3 | 0 | 6 | 6 |
| frontier | 5 | 5 | 5 | 4 | 5 | 6 | 3 | 4 | 4 |
| energy | 4 | 5 | 3 | 2 | 8 | 4 | 2 | 4 | 3 |
### 2.2 Forbidden Combinations (score = 0)
These are not just low-probability — they are implausible enough that the generator
should flag them for review rather than place silently:
| Economic role | Forbidden attractor | Reason |
|--------------|--------------------|---------|
| agricultural | NaturalBarrier | No farming in impassable terrain |
| transit | NaturalBarrier | Transit hubs do not sit at physical barriers — they use gaps in them (MtnPass) |
| commercial | NaturalBarrier | Commerce requires accessible flow of goods and people |
| manufacturing | NaturalBarrier | Heavy industry requires accessible logistics |
| service_mixed | NaturalBarrier | Services require customers who can reach them |
**Note on NaturalBarrier:** This attractor type is a settlement exclusion zone for
most roles. The only roles with non-zero scores there are extraction (1), mining (1),
research (3), frontier (3), energy (2) — all roles whose economic activities specifically
exploit inaccessibility or operate in remote terrain.
### 2.3 Preferred Combinations (score = 9–10)
These are the "inevitable placements" Ozzie flagged — a player seeing a transit hub at
a mountain pass should feel the placement was obvious:
| Economic role | Preferred attractor | Score | Narrative |
|--------------|--------------------|----|-----------|
| transit | MountainPass | 10 | The pass is the reason the city exists |
| transit | CoastalHarbor | 9 | Port = transit nexus |
| transit | RiverConfluence | 9 | River junction = historic trade/ferry crossing |
| agricultural | ArablePlain | 10 | The land is the reason for the city |
| extraction | ResourceConcentration | 10 | The deposit is the reason for the city |
| mining | ResourceConcentration | 10 | Same |
| commercial | CoastalHarbor | 9 | Ports = commercial centers historically |
### 2.4 Full Attractor-Matching Algorithm (reconciled from four Round 1 proposals)
The four Round 1 proposals (Gestalt, Tyre, Burnelli-Sheldon, Paula) are structurally
equivalent; they differ in ordering and handling edge cases. This is the convergence:
```
Algorithm: attractor_matching(cities, attractors, body, systems_db, seed)
──────────────────────────────────────────────────────────────
Phase 0: Build score matrix and validate HQ constraints
For each city_i and attractor_j:
S[i,j] = compat_score(city_i.economic_role, attractor_j.type)
× quality_bonus(attractor_j)
× population_weight(city_i.population)
where:
quality_bonus = 1.2 if attractor_j is highest-quality of its type on this body
population_weight = 1.0 + min(log10(city_i.population / 1000) × 0.10, 0.30)
HQ hard constraints (corp cross-references in systems.db):
for each corp with headquarters_body = this body:
matching_city = city with matching name in atlas_city_names
compatible_attractors = attractors where compat_score ≥ 6
if no compatible_attractor exists:
FLAG for lead review: "[city_name] has no compatible attractor for [corp_role]"
assign to best available attractor (highest score, even below 6)
else:
force S[matching_city, incompatible_attractor] = 0 for all incompatible
──────────────────────────────────────────────────────────────
Phase 1: Priority ordering
Sort cities into tiers:
Tier A: cities with HQ corp cross-reference (locked, must-place)
Tier B: remaining named cities, sorted by population descending
Tier C: unnamed cities (population budget, sorted by population descending)
──────────────────────────────────────────────────────────────
Phase 2: Assign Tier A cities (hard constraints first — Paula's ordering)
For each corp in extraction-order (most geographically constrained roles first):
economic role constraint order: extraction → mining → energy → agricultural
→ manufacturing → transit → commercial → research
→ service_mixed → frontier
Assign each Tier A city to its highest-scoring available compatible attractor.
Mark attractor as consumed.
──────────────────────────────────────────────────────────────
Phase 3: Assign Tier B and Tier C cities (Hungarian algorithm)
Build sub-matrix S' for unassigned cities × remaining attractors.
Solve maximum-weight bipartite matching.
O(N³) where N ≤ 30: negligible compute.
Derive FoundingOrientation from assigned attractor_type:
RiverConfluence → RiverCrossing
CoastalHarbor → PortFacing
MountainPass → PassControl
ArablePlain → AgriculturalExpanse
ResourceConc. → ExtractionTether
Defensible → DefenseFacing
NaturalBarrier → (fortress pattern within MountainPass or Defensible)
CoastalLowland → AgriculturalExpanse (coastal variant)
RiverValley → RiverCrossing (valley variant)
──────────────────────────────────────────────────────────────
Phase 4: Overflow handling
If N_cities > M_attractors after Phase 3:
For each unmatched city (sorted by population descending):
Place synthetic attractor:
position = seed_derived offset from nearest NameLocked settlement
distance ∈ [15%, 40%] × body_scale
biased toward nearest road corridor (if road graph available)
type = nearest_attractor.type
(inherits the dominant attractor character of the region)
geographically_triggered = false
FoundingOrientation = AdminFacing (Paula's OrganicGrowth resolution)
──────────────────────────────────────────────────────────────
Phase 5: Produce SettlementRecord per city
settlement_class = NameLocked if Tier A
= PopulationBudget if Tier B/C, geographically_triggered = true
= OrganicGrowth if overflow, geographically_triggered = false
```
**Ozzie's constraint enforced:** Any placement that triggers a compat_score = 0
(forbidden combination) is blocked at Phase 0. The generator cannot silently place a
fishing port in the mountains — it either finds a compatible attractor or flags for lead
review. This satisfies the "feels inevitable, not arbitrary" requirement.
---
## 3. Latent Settlements Under Fully Generative Placement
### 3.1 What Changed
Under the old model, wiki-authored cities were always active. Sub-settlements were latent.
Under Amendment 3, ALL settlements are generator-placed. The latent concept must generalize.
The key economic insight: **latency tracks economic justification, not authoring method.**
A settlement is latent if its continued existence depends on economic conditions that can
change. A settlement is non-latent if it exists for structural reasons that don't change
with economics (a mountain pass town exists because the pass exists; that doesn't change).
### 3.2 Settlement Classes and Active Status Logic
Introduce a `SettlementClass` enum on all generated settlement records:
```rust
pub enum SettlementClass {
/// Placed by HQ corp cross-reference. The corp's HQ exists as long as the
/// corp exists. Active if corp health_metric > 0.0 (any presence).
NameLocked,
/// Placed by population budget — geographic attractor exists but name is
/// generator-assigned. Active if nearest NameLocked settlement is economically
/// healthy (its sponsoring corp health > 0.4). Becomes ghost if the anchor corp
/// collapses and no other corp moves in.
PopulationBudget,
/// Sub-settlements placed by economic trigger conditions:
/// mining camps, trade waypoints, agricultural nodes.
/// Active if triggering corp health_metric > 0.4.
EconomicTriggered,
/// Overflow settlements placed at Province centroids without geographic attractor.
/// Active if provincial avg corp health > 0.5 (needs regional economic density).
OrganicGrowth,
}
```
**Why NameLocked is not always-active:** If the corp dissolves entirely
(`corp_lifecycle_events.event_type = Dissolved`), the HQ city loses its economic
anchor. The city doesn't vanish spatially (the streets and buildings remain), but
it becomes a ghost: dark, unmaintained, repopulated by scavengers and squatters.
The physical city persists; the economic life drains out. This is the only case where
a NameLocked settlement becomes ghost.
**Why this is stronger than the old latent concept:** The old model had named cities
as unconditionally active and sub-settlements as optionally latent. The new model
correctly identifies that a city's activity is always economically contingent — it
just has different trigger conditions by class. The full active/ghost spectrum applies
to all settlement types.
### 3.3 Schema Addition
Add `settlement_class` and `geographically_triggered` to the generated settlement
records in `BodyWorldState`:
```rust
pub struct GeneratedSettlement {
pub city_id: String,
pub position: (f32, f32), // body-local normalized coords
pub founding_orientation: FoundingOrientation,
pub settlement_class: SettlementClass,
pub geographically_triggered: bool, // Paula's NEW-Q2 disambiguation flag
pub activating_corp: Option<String>, // corp_id for EconomicTriggered
pub activating_condition: Option<String>, // description for OrganicGrowth
}
```
`geographically_triggered = false` is the OrganicGrowth disambiguation flag Paula
identified. It answers "was this settlement placed because of a geographic attractor
or because of population pressure alone?" FoundingOrientation = AdminFacing when false.
### 3.4 The Latent Settlement Table in Systems.db
My Round 1 recommendation was option (b): store latent settlement positions in a
systems.db Phase 3 output table. Under Amendment 1 (three-tier execution), this shifts:
latent settlement positions are runtime background generation output stored in session DB
(as part of `BodyWorldState`), not systems.db.
The economic SIM still reads from systems.db for its aggregate calculations
(corp_presence, corp_financial_state). It does not need the spatial settlement positions
to compute economics — it just needs to emit health_metric updates that the renderer
reads to determine active/ghost status per settlement.
**The handoff:** The sim emits `corp_health_changed(corp_id, new_metric)` events.
The Layer 3-4 generator, when processing a city, queries: is the activating corp's
health_metric above or below the threshold for this settlement class? This query is
a single lookup into the economic sim's current state. No full re-generation is needed.
---
## 4. TerritorialStatus Economic Thresholds — Convergence Proposal
The Round 1 notes show three proposals (Paula, Tyre, Burnelli-Sheldon). This section
proposes convergence thresholds that satisfy all three agents' requirements.
**Note on Province dimensions:** Tyre's ARCH-1 through ARCH-4 blockers include
confirmation of Province scale. The thresholds below use Province-relative quantities
(fraction of Province cells) to remain valid regardless of absolute Province dimensions.
When ARCH-1 is resolved, the fractions convert to concrete cell counts.
### 4.1 Convergence Table
| TerritorialStatus | Threshold | Economic signal |
|------------------|-----------|----------------|
| **CoreTerritory** | ≥ 2 named settlements (City or Town) within Province boundaries AND road_coverage_fraction ≥ 0.55 across Province cells AND ≥ 1 active corp (health_metric ≥ 0.5) with presence in Province | Economically integrated, actively maintained |
| **FrontierTerritory** | ≥ 1 settlement (any tier) in Province AND (road_coverage_fraction < 0.55 OR road_quality < 0.5 at Province boundary edges) | Settled but underdeveloped; economic reach doesn't fill the Province |
| **ExtractiveZone** | ≥ 1 ResourceConcentration geographic feature tag in Province AND ≥ 1 corp_presence with extraction/mining primary_operation commodity AND road corridor from resource site to nearest City (even if narrow) | Productive but not balanced; the corridor is the territory |
| **ContestZone** | ≥ 2 settlements from different political_archetype types within Province AND overlapping MaintenanceAuthority on shared road segments (or road networks crossing Province internal boundary without single authority) | Competing claims with no resolution |
| **WildernessBuffer** | 0 settlements placed + 0 roads + no ResourceConcentration tag | Untouched; no economic vector yet |
| **AbandonedZone** | ≥ 1 settlement with `placed_at_generation = true` in Province AND 0 active settlements at current sim evaluation (all occupying corps have health_metric < 0.2) | Was settled; economic basis collapsed |
### 4.2 What Changed vs. Round 1 (My Position)
- **CoreTerritory:** Added "≥ 1 active corp with health_metric ≥ 0.5" as a third condition.
My Round 1 threshold was purely structural (settlement count + road maintenance). Paula's
version required the economic health signal. She's right — a ghost city Province with
intact roads but zero active corps is not CoreTerritory; it's AbandonedZone.
- **FrontierTerritory:** Adopted Paula's road quality threshold at Province edges. My Round 1
version was vague about what "road density < 0.5" meant. Road quality < 0.5 at Province
boundary is more testable.
- **ContestZone:** Adopted Tyre's "two CoreTerritory zones from different political archetypes
overlapping" framing as the primary test. My Round 1 threshold (competing road networks)
is now a diagnostic signal rather than the definition.
- **AbandonedZone:** Added Paula's `placed_at_generation = true` flag as the necessary
condition to distinguish "abandoned" from "never settled." My Round 1 version would
mis-classify a WildernessBuffer Province where a corp briefly established presence and
then left; the flag provides the correct disambiguation.
### 4.3 Runtime Derivation
TerritorialStatus can be re-derived at runtime from the economic simulation's state
without re-running the Layer 1-2 generator. The inputs are:
- `settlement_active_state` per settlement (from SettlementClass + corp health query)
- `road_coverage_fraction` per Province (from Layer 2 session cache — does not change)
- `corp_presence` + `corp_financial_state.health_metric` (from economic sim)
This means TerritorialStatus is a *render parameter* like `prosperity_current` — it
reflects the current economic state and updates when the sim changes, but it does not
trigger a layout regeneration. The Province boundaries don't move; only the status label
changes.
---
## 5. Open Positions and Requests for Other Agents
### 5.1 BS-Q1 — Final Resolution (no further input needed)
A 9,999-person Town without a dedicated Entertainment district is economically correct.
Entertainment is functional inside the Commercial district at Town scale. Gestalt
validated this in Round 1 notes (no objection to the threshold).
### 5.2 Topographic Modifier to prosperity_baseline (Paula + Burnelli-Sheldon)
Paula proposed a topographic modifier (+0.05 hilltop, -0.05 flood-adjacent). This is
included in the formula at Step 5 of §1.4 as an additive offset on the base gradient.
No incompatibility with my gradient direction/magnitude split. Confirmed compatible.
### 5.3 Gestalt: Character table feedback needed
The age character classes in §1.3 (AGE_CHARACTER_TABLE) propagate to `DistrictSkeleton`
fields (perimeter_treatment, density_pct). I need Gestalt to confirm whether these
fields are sufficient to express the character differences, or whether additional fields
on `DistrictSkeleton` are needed. Specifically: does a "nascent entertainment district"
vs. a "mature entertainment district" need a named field, or does density_pct + prosperity
alone carry it?
### 5.4 Tyre: ARCH-3 schema question
The attractor-matching algorithm requires `atlas_city_names` with fields:
`body_id`, `city_name`, `economic_role`, `population`, `hq_for_corp_id` (nullable).
Does this replace or extend `atlas_cities`? My recommendation: extend with additional
columns. `atlas_cities` currently stores authored pixel positions (which become obsolete
under Amendment 3) plus name + population + kind (which are still needed). The new
columns are: `hq_for_corp_id` (nullable FK to corporations) and `assigned_attractor_id`
(nullable, written by Layer 2 generator to session DB, not systems.db).
### 5.5 Threshold for "abandoned body" condition on NameLocked settlements
My SettlementClass model has NameLocked cities becoming ghost when their founding corp
dissolves. I need confirmation from Paula: does corp dissolution in the economics sim
produce a `corp_lifecycle_events.event_type = Dissolved` record? The rendering system
needs a way to read this state. Alternatively: `corp_financial_state.health_metric = 0.0`
as a proxy. Which is the correct signal to read?
@@ -0,0 +1,749 @@
---
title: "Round 3 — Burnelli-Sheldon: Convergence and Economics D-Records"
description: "D-record candidates D-194 through D-199 for locked economics decisions. Data quality findings from systems.db. Mismatch threshold validation. TerritorialStatus economic threshold confirmation."
type: workshop
status: active
workshop: planet-down-cascade
agent: burnelli-sheldon
round: 3
created: 2026-05-01
---
# Round 3 — Burnelli-Sheldon: Convergence and Economics D-Records
D-record IDs D-194 through D-199 claimed. This document contains the candidate text for
each. It also flags critical data quality issues I found querying systems.db that affect
the formulas before they can go live.
---
## 0. Critical Data Quality Findings (Must Address Before Implementation)
Before the D-records: three data gaps in systems.db that the implementation will hit
immediately if not flagged now.
### Gap 1: `bodies.founding_age_years` is NULL for All 273 Inhabited Bodies
Component 3 of the district mix algorithm (age modifier) depends on this field. It is
entirely unpopulated. The D-194 algorithm includes a fallback derivation from
`system_history.settlement_wave` (see §1.5), but this is a degraded proxy. The field
should be populated as part of wiki content work — every inhabited body should have an
approximate founding age.
**Immediate implication:** The age modifier in Phase 1 implementation will run on
fallback values only. Character tags will be wave-derived, not body-specific.
**Settlement wave → approximate age mapping (fallback):**
| settlement_wave | Approximate founding_age_years | Age bracket |
|----------------|-------------------------------|-------------|
| origin | 500+ | mature |
| wave_1 | 350 | mature |
| wave_2 | 250 | established |
| wave_3 | 150 | established |
| wave_4 | 75 | young |
| wave_5 | 30 | nascent |
| unsettled | N/A | not inhabited |
This mapping preserves the intent of the modifier even without body-level data.
### Gap 2: `system_economy.economic_tier` and `distribution_index` are NULL for 97% of Systems
290 of 300 system_economy rows have NULL for both fields. The prosperity_baseline formula
(D-197) depends on `economic_tier`. Only 10 systems have data.
**Fallback for economic_tier:** Derive from system gate topology + population tier.
Gate-connected major systems = tier 4-5; backwater systems = tier 1-2. Exact formula:
```
economic_tier_derived =
if system_population >= 5_000_000_000: 5
elif system_population >= 1_000_000_000: 4
elif system_population >= 100_000_000: 3
elif system_population >= 10_000_000: 2
else: 1
```
**Fallback for distribution_index:** Default to "moderate" when NULL. "Stratified" is
the explicit exception (must be authored); moderate is the baseline.
These fallbacks are sound — economic tier correlates with population by construction
in a simulation game, and moderate inequality is the default state.
### Gap 3: `economic_role` Values Are Not Normalized
The bodies table has inconsistent values:
- `"agriculture"` (16 bodies) and `"agricultural"` (107 bodies) — same concept, two spellings
- `"resource_extraction"` (1 body) — should be `"extraction"`
- `"coordination"` (2 bodies, including Bunbury pop 800M) — not in the weight table
- `"mixed-agriculture"` (1 body) — not in the weight table
- 1 body with NULL economic_role
The weight table in D-194 uses canonical values. The generator must normalize before
looking up the table:
| Raw value | Canonical | Rationale |
|-----------|-----------|-----------|
| `agriculture` | `agricultural` | Spelling normalization |
| `resource_extraction` | `extraction` | Enum normalization |
| `coordination` | `service_mixed` | Coordination hubs are service/administrative |
| `mixed-agriculture` | `agricultural` | Nearest match; blended role, dominant is agricultural |
| NULL | `service_mixed` | Safest fallback for unknown roles |
This normalization should be added to `import_economics.py` as a validation step.
---
## 1. D-194: Three-Component District Mix Algorithm
**Decision ID:** D-194
**Domain:** architecture
**Status:** candidate
**Claimed by:** Burnelli-Sheldon
### Decision
Adopt a three-component model for computing the district type distribution of any
generated settlement. The model replaces the prior 10×9 weight table (which had zero
weights and produced implausible zero-service settlements).
**The three components are:**
1. Population tier guarantees — mandatory district types by settlement size
2. Economic role multiplier table — integer weights distributing remaining slots
3. Settlement age character modifier — modifies district character, not count
### Rationale
The prior table encoded what a settlement *produces*, not what it *needs*. Every
settlement with concentrated labor produces service demand regardless of economic role.
This is not a design preference — it is a consequence of basic human economics. Miners
drink. Workers eat. People socialize. The new model encodes this correctly:
- **Component 1** provides non-negotiable floors: no city above minimum population has
zero of any essential district type.
- **Component 2** provides proportional variation: economic role shifts the distribution
toward production-relevant types, but cannot suppress guaranteed types below 1 slot.
- **Component 3** provides narrative depth without structural change: the same district
types look different in a 20-year mining camp vs. a 300-year mining city.
### Self-Containment Rule
The algorithm references only local city/body/system fields. No queries to neighboring
city data. No distance calculations. Each settlement generates its district distribution
independently from its seed and the fields listed below. This satisfies the SeedChain
isolation requirement (D-010).
### 1.1 Population Tier Guarantees
| Population range | Settlement tier | Mandatory district types |
|-----------------|----------------|-------------------------|
| 1–999 (Outpost) | Outpost | Residential + Mixed (all functions fused if D_total = 1) |
| 1,000–9,999 (Town) | Town | Residential + Commercial |
| 10,000–99,999 (City) | City | + Entertainment |
| 100,000–499,999 (Large City) | Large City | + Administrative |
| 500,000+ (Metropolis) | Metropolis | + Industrial (if role supports), + Civic |
**Collapse rule:** If `D_total < len(mandatory_types)`, excess mandatory types
merge into Mixed. A 1-district settlement always has type Mixed regardless of
population tier — it carries all functions in one district.
**WorldTier override — domed/cave settlement:** Single district regardless of
population. Type = Mixed.
**Special case — BS-Q1 (Town Entertainment):** Entertainment at Town scale is a
*character property* of the Commercial district, not a dedicated slot. Dedicated
Entertainment districts appear at City tier (10,000+) only, when specialization
becomes economically viable at scale.
### 1.2 Economic Role Multiplier Table
Integer weights, each row sums to 100. Minimum value across all cells: **3**.
No weight is zero — every district type can appear in any economic role, just rarely.
DistrictType column key: Res=Residential, Com=Commercial, Ind=Industrial, Adm=Administrative,
Log=Logistics, Ent=Entertainment, Mix=Mixed, Trn=Transit, Spe=Specialized
| economic_role | Res | Com | Ind | Adm | Log | Ent | Mix | Trn | Spe |
|--------------|-----|-----|-----|-----|-----|-----|-----|-----|-----|
| manufacturing | 18 | 8 | 30 | 6 | 18 | 5 | 8 | 5 | 2 |
| agricultural | 22 | 12 | 5 | 10 | 20 | 5 | 15 | 5 | 6 |
| extraction | 16 | 7 | 28 | 4 | 24 | 5 | 9 | 5 | 2 |
| transit | 10 | 18 | 5 | 5 | 20 | 12 | 14 | 14 | 2 |
| research | 16 | 6 | 5 | 14 | 5 | 7 | 10 | 3 | 34 |
| commercial | 14 | 30 | 5 | 6 | 10 | 14 | 14 | 5 | 2 |
| service_mixed | 20 | 18 | 5 | 10 | 6 | 14 | 18 | 7 | 2 |
| mining | 16 | 7 | 22 | 3 | 28 | 6 | 9 | 7 | 2 |
| frontier | 26 | 10 | 10 | 5 | 18 | 8 | 16 | 5 | 2 |
| energy | 10 | 3 | 18 | 6 | 24 | 3 | 6 | 6 | 24 |
**Energy Ent = 3 note:** A large energy facility's Entertainment guarantee comes from the
City-tier floor (10k+ population = Entertainment guaranteed), not from the weight table.
The weight 3 only governs *additional* Entertainment districts beyond the guarantee.
A company canteen + gym at a 200k-person energy installation earns the guaranteed slot;
the weight 3 correctly suppresses a second Entertainment district in remaining slots.
**Political archetype modifiers** (stacked additive after role table, before
renormalization):
| political_archetype | Modifiers |
|--------------------|-----------|
| CompanyTown | Adm −10, Ind +10, Log +5, Res +5 |
| AdminCapital | Adm +20, Ent +5, Spe +5, Ind −15 |
| FreePort | Com +15, Trn +10, Mix +5, Adm −15 |
| Contested | No modifier (competing forces cancel) |
| OrganicGrowth | Mix +15, Res +10, Ind −10, Adm −5 |
### 1.3 Settlement Age Character Modifier
**Source field:** `bodies.founding_age_years` (INTEGER, nullable).
**Fallback:** `system_history.settlement_wave` → age bracket via mapping in §0 above.
**WorldTier cap:** Age modifier applies only at Backwater tier and above. Waypoint and
Passage settlements are always `nascent` regardless of founding date.
| founding_age_years | Age bracket | Effect on DistrictSkeleton |
|-------------------|-------------|---------------------------|
| 0–49 (or wave_5) | nascent | perimeter_treatment: Open or Temporary; density_pct: 0.4–0.6; character: raw production |
| 50–149 (or wave_4) | young | perimeter_treatment: Fenced; density_pct: 0.5–0.7; character: functional |
| 150–349 (or wave_2/3) | established | perimeter_treatment: Walled; density_pct: 0.6–0.8; character: differentiated |
| 350+ (or wave_1/origin) | mature | perimeter_treatment: variable (any); density_pct: 0.6–0.9; character: layered |
Age modifies: `perimeter_treatment`, `density_pct`, and the content system's building
archetype selector via `character_class`. It does NOT modify district count, district
type, or prosperity_baseline.
### 1.4 The Combined Algorithm
```
fn compute_district_distribution(
city: &CityGenerationContext,
body: &BodyRecord, // from bodies table
system: &SystemEconomyRecord, // from system_economy (nullable fields)
terrain_gradient: CardinalDirection, // from Layer 1 output
seed: u64, // child_seed(world_seed, city_id)
) -> Vec<DistrictSpec>
Step 1: Total district count
D_raw = max(1, floor(city.population / 50_000)) // log-scaled for pop > 500k
D_total = min(D_raw, WorldTier_cap[city.world_tier])
if city.is_capital: D_total = min(D_total + 1, WorldTier_cap[city.world_tier])
if body.settlement_pattern in (domed, cave): D_total = 1
Step 2: Mandatory districts from population tier guarantee table
mandatory_types = guarantee_table[population_tier(city.population)]
if len(mandatory_types) > D_total: collapse_excess_to_mixed(mandatory_types, D_total)
remaining_slots = D_total - len(mandatory_types)
Step 3: Fill remaining slots from economic role weight table
role = normalize_economic_role(body.economic_role) // apply gap §0 normalization
weights = ROLE_WEIGHT_TABLE[role] // 9-element, all ≥ 3
weights = apply_archetype_modifiers(weights, city.political_archetype)
weights = renormalize_to_100(weights)
additional_types = seeded_weighted_sample(weights, remaining_slots, seed)
// sample_without_replacement = false; duplicate types allowed (second Ind, second Log)
all_types = mandatory_types + additional_types
Step 4: Assign age character class
age_bracket = effective_age_bracket(body.founding_age_years, body.settlement_wave, city.world_tier)
for d in all_types:
d.character_class = AGE_CHARACTER_TABLE[d.type][age_bracket]
d.density_pct = lerp(age_density_range[d.type][age_bracket], seeded_float(seed, i))
d.perimeter_treatment = age_perimeter_table[d.type][age_bracket]
Step 5: Assign prosperity gradients (see D-197 for formula)
assign_prosperity_per_district(all_types, city, body, system, terrain_gradient)
```
---
## 2. D-195: Attractor-Matching Compatibility Matrix
**Decision ID:** D-195
**Domain:** architecture
**Status:** candidate
**Claimed by:** Burnelli-Sheldon
### Decision
The generator assigns named cities to geographic attractors using a scored bipartite
matching algorithm. City-attractor compatibility is scored by the 10×7 matrix below
(10 economic roles × 7 canonical geographic attractor types). Score scale is 0–10
where 0 = physically impossible/forbidden and 10 = ideal/preferred.
### Compatibility Matrix (0–10 scale)
| economic_role | RiverConfl | CoastalHarbor | MtnPass | ArablePlain | ResourceConc | Defensible | NaturalBarrier |
|--------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| manufacturing | 7 | 6 | 3 | 5 | 5 | 2 | 0 |
| agricultural | 8 | 5 | 2 | 10 | 1 | 2 | 0 |
| extraction | 3 | 3 | 5 | 2 | 10 | 3 | 1 |
| transit | 9 | 9 | 10 | 3 | 1 | 3 | 0 |
| research | 4 | 4 | 4 | 4 | 6 | 8 | 3 |
| commercial | 8 | 9 | 6 | 4 | 2 | 2 | 0 |
| service_mixed | 6 | 6 | 4 | 6 | 2 | 3 | 0 |
| mining | 2 | 2 | 6 | 1 | 10 | 4 | 1 |
| frontier | 5 | 5 | 5 | 4 | 5 | 6 | 3 |
| energy | 4 | 5 | 3 | 2 | 8 | 4 | 2 |
### Forbidden Combinations (score = 0)
These are physically impossible placements. The generator hard-zeros these before scoring
and will not place them even as an overflow option:
| Economic role | Forbidden attractor | Reason |
|--------------|--------------------|-|
| manufacturing | NaturalBarrier | Heavy industry requires accessible logistics; impassable terrain precludes it |
| agricultural | NaturalBarrier | No farming in impassable terrain |
| transit | NaturalBarrier | Transit hubs use gaps in barriers (MountainPass), never the barrier itself |
| commercial | NaturalBarrier | Commerce requires accessible flow of people and goods |
| service_mixed | NaturalBarrier | Services require customers who can physically reach them |
Note: extraction, mining, research, frontier, and energy all have non-zero scores at
NaturalBarrier — these roles specifically exploit or require inaccessibility.
### Preferred Combinations (score 9–10, "inevitable placements")
| Economic role | Preferred attractor | Score | Narrative |
|--------------|--------------------|----|-----------|
| transit | MountainPass | 10 | The pass exists; the transit city exists to serve it |
| extraction | ResourceConcentration | 10 | The deposit exists; the city exists to work it |
| mining | ResourceConcentration | 10 | Same |
| agricultural | ArablePlain | 10 | The land exists; the city farms it |
| transit | CoastalHarbor | 9 | Port = natural transit nexus |
| transit | RiverConfluence | 9 | River junction = historic crossing + trade |
| commercial | CoastalHarbor | 9 | Ports = commercial centers universally |
### Mismatch Flag Thresholds (Lead Decision Implemented)
The lead directed a two-tier mismatch system: score < 0.35 = warning, score < 0.15 = error.
In the 0–10 matrix scale:
- **Warning:** raw score < 3.5
- **Error:** raw score < 1.5
**Validation against real systems.db body types:**
I queried systems.db to validate these thresholds produce sensible flags on real data.
Representative cases where warnings fire (score 2–3.5):
- agricultural + Defensible (score 2): farming community at a defensible site — exists
(hill forts, fortified medieval towns) but unusual. Warning is correct.
- transit + ArablePlain (score 3): transit hub on flat farmland — plausible (railroad
junction on the plains) but not attractor-driven. Warning invites review.
- extraction + MountainPass (score 5): mine near a mountain pass — valid. No flag.
Representative cases where errors fire (score 0–1.5):
- agricultural + NaturalBarrier (score 0): impossible. Error ✓
- mining + ArablePlain (score 1): mine on good farmland — politically contentious, not
the natural attractor for mining. Error = flag for lead review. Correct.
- agricultural + MountainPass (score 2): farming at a mountain pass — warning, not
error. Borderline: mountain villages do farm (terraced agriculture). Warning is right.
**Conclusion:** The 0.35/0.15 two-tier thresholds produce sensible behavior against the
real body data. No threshold adjustment needed.
### Flagging Behavior
```rust
match best_attractor_score / 10.0 {
s if s < 0.15 => PlacementFlag::Error(
format!("{} city placed at {} attractor: score {:.2} below error threshold",
city_name, attractor_type, s)
),
s if s < 0.35 => PlacementFlag::Warning(
format!("{} city placed at {} attractor: score {:.2} below warning threshold",
city_name, attractor_type, s)
),
_ => PlacementFlag::None,
}
```
Flags are logged at layer 2 generation time and accessible via a generator diagnostic
API. They do not stop generation — they surface cases where the lead may want to author
an override.
---
## 3. D-196: SettlementClass Enum and Latent Settlement Active/Ghost Logic
**Decision ID:** D-196
**Domain:** architecture
**Status:** candidate
**Claimed by:** Burnelli-Sheldon
### Decision
All generated settlements carry a `SettlementClass` that determines their active/ghost
derivation logic. This generalizes the prior latent-settlement concept from sub-settlements
only to ALL generated settlements. Every settlement can be ghost; every settlement has
a class-specific economic condition that determines whether it is.
### SettlementClass Enum
```rust
pub enum SettlementClass {
/// City exists because a named corp has headquarters_body = this body.
/// Active if corp.health_metric > 0.0 AND corp.lifecycle_state != Dissolved.
/// Ghost only on full corp dissolution — the most durable class.
NameLocked,
/// City exists to satisfy population budget without a named attractor.
/// Active if parent NameLocked settlement health_metric > 0.4.
/// Ghost when its economic anchor declines.
PopulationBudget,
/// Sub-settlement placed by economic trigger (mining camp, trade waypoint,
/// agricultural node, shadow node). Active if triggering corp health_metric > 0.4.
/// Most volatile class — directly tied to a single corp's health.
EconomicTriggered { activating_corp_id: String },
/// Placed at Province centroid by population pressure, no geographic attractor.
/// Active if province_avg_corp_health > 0.5.
/// geographically_triggered = false; FoundingOrientation = AdminFacing.
OrganicGrowth,
}
```
### Active/Ghost Derivation per Class
| Class | Active condition | Ghost condition |
|-------|-----------------|-----------------|
| NameLocked | corp health_metric > 0.0 AND lifecycle ≠ Dissolved | Corp fully dissolved |
| PopulationBudget | nearest NameLocked city health_metric > 0.4 | Anchor city's corp collapses |
| EconomicTriggered | activating_corp health_metric > 0.4 | Corp distressed or dissolved |
| OrganicGrowth | province avg corp health > 0.5 | Province-wide economic decline |
### What "Ghost" Means for Rendering
A ghost settlement does not disappear. Its streets and building footprints are
seed-locked and persist. The rendering system reads the active/ghost flag to:
- Switch lighting to dark/emergency-only
- Apply maximum tile condition decay (Broken tier)
- Clear activity entity spawn points (no NPCs walking around)
- Maintain road and building geometry unchanged
The physical city remains. Economic life drains out of it. This is the correct behavior
confirmed by Amendment 5 (ghost towns as emergent rendering consequence, not a designed
narrative feature).
### `placed_at_generation` Flag
An additional immutable boolean `placed_at_generation: bool` is set on each Province
when a settlement exists in that Province at Layer 2 generation time. This is the only
way to distinguish "AbandonedZone" from "WildernessBuffer" at runtime — both have
`settlement_count == 0` in the active state, but only AbandonedZone has
`placed_at_generation = true`.
---
## 4. D-197: prosperity_baseline Derivation Formula
**Decision ID:** D-197
**Domain:** architecture
**Status:** candidate
**Claimed by:** Burnelli-Sheldon
### Decision
The `prosperity_baseline` field on each `DistrictSkeleton` is a seed-locked float
in [0.05, 0.95] derived from economic data and topographic context. It represents what
the district was economically *designed for* — not what it is now. The runtime economic
simulation updates `prosperity_current`; `prosperity_baseline` is never updated after
generation.
### Formula
```
prosperity_baseline(district_i, city, body, system, terrain) =
// Base: economic tier normalized to [0.0, 1.0]
base = clamp(economic_tier(body, system) / 5.0, 0.0, 1.0)
// Role modifier: flat offset per economic_role
role_mod = ROLE_PROSPERITY_MODIFIER[normalize_economic_role(body.economic_role)]
// Gradient: positional rank along topographic high-to-low direction
rank = positional_gradient_rank(district_i, all_districts, terrain.gradient_direction)
// rank ∈ [0.0, 1.0]; 1.0 = highest ground; 0.0 = lowest ground
magnitude = DISTRIBUTION_INDEX_SCALE[distribution_index(system)]
gradient_offset = (rank - 0.5) × magnitude
// Paula's topographic modifier (additive)
topo_mod = +0.05 if district_i elevation in top 30% of city elevation range
= -0.05 if district_i elevation in bottom 20% vs. sea level
= 0.00 otherwise
prosperity_baseline_i = clamp(base + role_mod + gradient_offset + topo_mod, 0.05, 0.95)
```
### Role Prosperity Modifiers
| economic_role | role_mod |
|--------------|---------|
| extraction | -0.10 |
| mining | -0.10 |
| frontier | -0.20 |
| research | +0.15 |
| service_mixed | +0.10 |
| commercial | +0.10 |
| transit | 0.00 |
| manufacturing | 0.00 |
| agricultural | 0.00 |
| energy | 0.00 |
### Distribution Index Scale (gradient magnitude)
| distribution_index | magnitude |
|-------------------|-----------|
| "stratified" | 0.70 (steep gradient: ±0.35 spread across districts) |
| "moderate" | 0.20 (shallow gradient: ±0.10 spread) |
| NULL (fallback) | 0.20 (treat as moderate) |
### Economic Tier Derivation (Fallback for NULL values)
Per Gap 2 in §0, `system_economy.economic_tier` is NULL for 97% of systems. Fallback:
```
fn economic_tier(body: &Body, system: &SystemEconomy) -> f32 {
if let Some(tier) = system.economic_tier { return tier as f32; }
// Fallback from body population
match body.population {
p if p >= 5_000_000_000 => 5.0,
p if p >= 1_000_000_000 => 4.0,
p if p >= 100_000_000 => 3.0,
p if p >= 10_000_000 => 2.0,
_ => 1.0,
}
}
```
### Two Distinct Fields — Naming Lock (CL-Q1)
- `prosperity_baseline: f32` — seed-locked, never updated after generation
- `prosperity_current: f32` — runtime simulation state, updated by economic sim
- `prosperity_delta: f32` — always derived: `prosperity_current - prosperity_baseline`. Never stored.
These three names are locked. They must not be conflated in implementation. Any code
that updates `prosperity_baseline` after generation is a bug.
### Tile Condition Thresholds (from `prosperity_current`)
Locked from Paula's Round 2 proposal (Gestalt adopted):
| Tile condition | prosperity_current range |
|---------------|------------------------|
| Intact | > 0.63 |
| Worn | 0.43–0.63 |
| Cracked | 0.23–0.43 |
| Broken | < 0.23 |
The offset from round numbers (0.63/0.43/0.23 rather than 0.60/0.40/0.20) prevents
boundary oscillation when prosperity fluctuates near the threshold.
---
## 5. D-198: Economic Simulation Independence from Layer 1-2 Spatial Data
**Decision ID:** D-198
**Domain:** architecture
**Status:** candidate
**Claimed by:** Burnelli-Sheldon
### Decision
The economic simulation runs exclusively on system-level aggregate data from
`systems.db`. It does not need Layer 1-2 spatial output (drainage, settlement positions,
road graphs, TerritorialStatus). Layer 1-2 generators read from the economic simulation's
state at generation time, but the relationship is one-directional: sim → Layer 1-2.
### What the Economic Sim Needs (from systems.db)
| Table | Fields used |
|-------|------------|
| `bodies` | economic_role, population, settlement_pattern |
| `system_economy` | economic_tier, economic_base_primary/secondary, distribution_index |
| `system_gates` | gate_connections, gate_topology, hop_distance_from_gateway |
| `corp_presence` | corp_id, location_id, primary_operation |
| `corp_financial_state` | corp_id, health_metric |
| `commodities` | base_price, elasticity, tier, production_ubiquity |
| `production_chains` + `chain_inputs` | supply chain topology |
| `gate_links` | inter-system commodity flow topology |
All of these exist in systems.db from build-time generation. The sim reads them at
startup and maintains its own in-memory state from there.
### What the Economic Sim Does NOT Need
- Settlement positions (latitude/longitude on body surface)
- Road graph topology (which road connects which town)
- TerritorialStatus per Province
- River network
- Geographic attractor positions
- District boundaries
These are consumed by Layer 3-4 rendering only. They never feed back into the
economic simulation. Commodity prices, corp health, and trade flow are computed at the
system and body level — not at the settlement or district level.
### Why This Matters for Architecture
This decision confirms Amendment 1 (three-tier execution) from the consultant review.
The economic sim can start running immediately at game startup using only systems.db.
Layer 1-2 generation runs on background threads in parallel with the sim — there is no
handshake or synchronization point between them. The only flow from Layer 1-2 back to
the sim would be a player-caused event (player destroys a mine → ECS event → sim
receives production loss), but this is handled through the ECS event bus, not through
spatial data structures.
### Render Layer Reads from Sim (One-Directional)
```
Economic Sim (systems.db aggregates)
│
├──→ corp_financial_state.health_metric
│ consumed by: Layer 4 renderer (settlement active/ghost)
│ consumed by: District tile condition derivation
│
├──→ prosperity_current (per district, derived from corp health)
│ consumed by: Layer 4 tile variant selection
│
└──→ regional_land_use (coarse biome-cell resolution updates)
consumed by: Layer 2 hinterland renderer
NOT ChunkMutations — different resolution (biome-cell, not tile-level)
```
The flow from the sim to the renderer never requires re-running Layer 1-2 generation.
A corp declining does not move a city. It makes the city look worse.
---
## 6. D-199: 6-Field Minimum Economic Read Set for City Generation Context
**Decision ID:** D-199
**Domain:** architecture
**Status:** candidate
**Claimed by:** Burnelli-Sheldon
### Decision
The minimum economic data set required to generate a city's district distribution is
six fields, queryable from systems.db at game startup and stored in
`CityGenerationContext`. No runtime sim queries are needed during city generation.
### The Six Fields
| # | Field | Source table | Used in |
|---|-------|-------------|---------|
| 1 | `economic_role` | `bodies` | D-194 weight table row selector |
| 2 | `settlement_pattern` | `bodies` | D-194 domed/cave override; latent settlement placement |
| 3 | `economic_tier` | `system_economy` | D-197 prosperity_baseline base value |
| 4 | `distribution_index` | `system_economy` | D-197 gradient magnitude |
| 5 | `corp_presence` count | `corp_presence` | Corporate district intensity modifier |
| 6 | `headquarters_system/body` match | `corporations` | D-195 attractor-matching hard constraint |
### How They Are Loaded
These fields are read once at game startup for all inhabited bodies and stored in the
`CityGenerationContext` struct. At Layer 3 (city district generation, runtime on-demand),
the generator reads from the struct, not from the database. No database queries during
play.
```rust
pub struct CityGenerationContext {
pub body_id: String,
pub city_id: String,
pub city_name: String,
pub political_archetype: PoliticalArchetype,
pub prosperity_baseline: f32, // seed-locked Layer 3 output (computed, not stored)
pub surrounding_biome: BiomeClass,
pub road_entry_directions: Vec<CardinalDirection>,
pub footprint_radius_km: f32,
pub founding_orientation: FoundingOrientation,
pub world_tier: WorldTier,
// The six economics fields:
pub economic_role: EconomicRole, // (1) from bodies
pub settlement_pattern: SettlementPattern, // (2) from bodies
pub economic_tier: u8, // (3) from system_economy (fallback: population-derived)
pub distribution_index: DistributionIndex, // (4) from system_economy (fallback: Moderate)
pub corp_presence_count: u32, // (5) count from corp_presence
pub has_hq_corp: bool, // (6) whether any corp.headquarters_body = this body
}
```
### Note on Nullability
Per §0 Gap 2: `economic_tier` and `distribution_index` are NULL for 97% of systems.
The struct uses fallback derivation (see D-197 §4) at load time. The `Option<>` wrapper
is resolved to a concrete value before the struct is constructed — null never reaches
the generator.
---
## 7. TerritorialStatus Economic Threshold Confirmation
The converged algorithm from Round 2 notes (§3) is confirmed correct for the economics
layer. The priority-ordered derivation is:
```rust
fn derive_territorial_status(province: &ProvinceWorldState) -> TerritorialStatus {
if province.placed_at_generation && !province.active {
return TerritorialStatus::AbandonedZone;
}
if !province.placed_at_generation && province.settlement_count == 0 {
return TerritorialStatus::WildernessBuffer;
}
if province.primary_economic_activity == EconomicActivity::Extraction
&& province.corporate_presence_score > 0.4 {
return TerritorialStatus::ExtractiveZone;
}
if province.jurisdiction_overlap_score > 0.3 {
return TerritorialStatus::ContestZone;
}
if province.infrastructure_quality > 0.6 && province.corporate_road_maintenance > 0.5 {
return TerritorialStatus::CoreTerritory;
}
TerritorialStatus::FrontierTerritory
}
```
**Economic threshold validation:**
- `corporate_presence_score > 0.4` for ExtractiveZone: this maps to "at least one corp
with health_metric > 0.4 operating an extraction commodity in this Province." Correct.
- `infrastructure_quality > 0.6` for CoreTerritory: this is a road/settlement density
metric from Layer 2 generation, not an economic field. It does not need updating when
economics changes. Correct — infrastructure is seed-locked at Layer 2.
- `corporate_road_maintenance > 0.5` for CoreTerritory: this is an economics-variable
field (corp health drives road maintenance). TerritorialStatus can therefore transition
from CoreTerritory to FrontierTerritory as corps decline — the roads degrade, the
classification degrades. This is the correct emergent behavior.
**One clarification needed:** `corporate_presence_score` is not a field I defined in my
prior rounds. I assume it derives from: `sum(health_metric for corps in province) / corp_count`.
If no corps are present, score = 0. Tyre should confirm this derivation in the
implementation spec.
---
## 8. Remaining Open Items After Round 3
These are items where I have a position but am waiting for other agents' confirmation:
**For Tyre:**
- Confirm `corporate_presence_score` derivation formula (§7 above)
- Confirm `atlas_city_names` schema extends (not replaces) `atlas_cities`
- Confirm ARCH-4 body_radius_km column is the right field for area_count formula
**For Gestalt:**
- Confirm whether age character classes need a named field on `DistrictSkeleton` or
whether `density_pct` + `perimeter_treatment` + `prosperity_baseline` are sufficient
to express the full character difference between nascent and mature districts
**For Paula:**
- Confirm corp dissolution signal: is `corp_lifecycle_events.event_type = 'Dissolved'`
the canonical flag for NameLocked settlement ghost status, or is `health_metric = 0.0`
the correct proxy? The schema has both; which is authoritative?
**Data quality action items (not blocking workshop, but blocking Phase 3 implementation):**
1. Populate `bodies.founding_age_years` across all inhabited bodies (wiki data exists for settlement dates)
2. Populate `system_economy.economic_tier` and `distribution_index` for all systems (or confirm fallback derivation is sufficient)
3. Normalize `economic_role` values in bodies table (add migration to import_economics.py)

Some files were not shown because too many files have changed in this diff Show More