diff --git a/.claude/skills/pr-push/SKILL.md b/.claude/skills/pr-push/SKILL.md index be1b5ea6f..662a423ef 100644 --- a/.claude/skills/pr-push/SKILL.md +++ b/.claude/skills/pr-push/SKILL.md @@ -229,6 +229,11 @@ exactly the silent-stale-DB class of bug this skill exists to prevent. git diff --name-only origin/main...HEAD -- \ tooling/economy-db/import_economics.py \ tooling/planet-gen/generate_atlas.py \ + tooling/planet-gen/gemma_naming.py \ + tooling/planet-gen/naming_core.py \ + tooling/planet-gen/import_city_names.py \ + tooling/planet-gen/import_heightmaps.py \ + tooling/planet-gen/import_province_boundaries.py \ server/src/bin/generate_brands/main.rs \ server/src/bin/generate_brands/names.rs \ tooling/generate-brands \ diff --git a/CHANGELOG.md b/CHANGELOG.md index e8dd80e2c..056e1ff20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,21 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] ### Added +- **Generation cascade D-records** (D-194–D-218) — 25 decisions formalizing the full pipeline from planetary heightmap to walkable tile: WorldTier taxonomy, settlement classification, city generation context, drainage routing, attractor matching, district mix, block irregularity, tile conditions +- **Atlas data pipeline** (#901–#911) — new `atlas_body_heightmaps`, `atlas_city_names`, `atlas_feature_names`, `atlas_province_boundaries` tables; `body_radius_km` column; three new importers (heightmaps, city names, province boundaries via D8 watershed); `economic_role` normalized to 7 canonical values +- **Phase 1 generation pipeline** (#916–#924) — 10-module `server/src/atlas/` package: heightmap BLOB loader, BodyWorldState LRU cache, D8 drainage routing, background generation queue with Rayon pool, five-phase attractor matching, three-component district mix, block irregularity, tile condition thresholds +- **District skeleton generator** (#899) — `generate_skeleton()` wires the full atlas pipeline to produce filled `DistrictSkeleton` instances from city markers + planet data. Phase 1 scope: SettingType/ComplexityTier derivation, layout mode assignment, 4×4 block grid with zoning, multi-block reservations +- **SystemNameIndex** (#926) — Aho-Corasick text scanner over body/station/system names for background pre-generation queue integration (D-206) +- **Stamp expansion** (#892) — `gemma_naming.py` and `naming_core.py` added to `check-systems-db-stamp` source tracking and `/pr-push` watch list - **`make decisions-orphan-tickets`** (#887) — new CLI subcommand (`tooling/db/decision orphan-tickets`) that scans tickets with a `decision_ref` not matching any decision in the DB, surfacing silently orphaned tickets from typo'd or renumbered D-IDs ### Changed - **`meta.schema_version` switched to monotonic semver** (#888) — replaces SHA-1 hash with an orderable semver string (`"1.0.0"`); old SHA preserved in new `schema_sha` column for tamper detection; `check-systems-db-stamp` now rejects legacy SHA-hex values +### Fixed +- **Bevy baseline test panics** (#885) — `SnapshotBuffer` Option-wrapped in economy.rs, `TickPhase::configure` added to SimulationPlugin, stale golden file regenerated. All 6 previously-failing tests pass +- **Suffix monotony auto-fix** (#886) — `gemma_naming.py` re-queries affected bodies when >40% suffix clustering detected; cultural-history context threaded into naming prompts + ## [v0.1.37] — 2026-04-22 ### Added diff --git a/decisions/architecture.md b/decisions/architecture.md index ffb340960..0916c5c23 100644 --- a/decisions/architecture.md +++ b/decisions/architecture.md @@ -761,4 +761,480 @@ Technical foundation decisions that constrain implementation: engine, client-ser --- -*54 decisions. Last updated: 2026-04-21 (D-192 — drop PROTOCOL_VERSION lockstep handshake, sprint 36 client triage)* +### D-194: Three-Component District Mix Algorithm for City District Type Distribution +- **Date:** 2026-05-01 +- **Decision:** District type distribution for a generated city is computed from three components combined at generation time: + 1. **Population tier guarantees** — minimum district counts enforced by city size. Population tier is `floor(log10(pop / 1_000_000))`, capped at 5. Larger populations guarantee minimum counts of Transit, Commercial, and Residential districts. + 2. **10×9 economic multiplier table** — rows are 10 `economic_role` values (manufacturing, financial, agricultural, extraction, service_mixed, institutional, transit_hub, research, military, residential); columns are 9 `DistrictType` variants. Each cell is a weight multiplier (0.0–3.0) applied to that district type's base probability for cities of that economic role. + 3. **Political archetype modifiers** — `PoliticalArchetype` shifts weights for Institutional, Restricted-access, and Civic district types. Corporate archetype boosts Commercial + Restricted. Commission archetype boosts Institutional + Administrative. Pioneer archetype boosts Mixed-use + Organic residential. + - **Founding age character** is applied as a post-mix adjustment to `BlockIrregularity` (see D-216), not to the district type distribution itself. + - The mix is self-contained per city: two cities with the same economic role, population tier, and political archetype produce the same district type distribution (modulo seed-driven noise). No city-to-city state dependency. + - Integer weights throughout — no f32 for D-010 determinism. +- **Rationale:** Economic role should visibly shape a city's physical form. A financial hub looks different from a mining hub. Population tier prevents cities from being too small to sustain their economic function. Political archetype encodes power structure in spatial form — Corporate settlements are commercially dense, Commission settlements are institutionally heavy. The three-component model is the minimum set to produce legible variety; adding more inputs risks over-constraining the generator. +- **Ticket:** #920 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-191 (atlas pipeline), D-213 (DistrictType enum), D-214 (PoliticalArchetype), D-216 (BlockIrregularity) + +### D-195: Attractor-Matching Compatibility Matrix for Generative City Placement +- **Date:** 2026-05-01 +- **Decision:** City placement on a planetary surface uses an attractor-matching model. A `GeographicAttractor` is a terrain feature that increases city placement score at nearby positions. Seven `AttractorType` variants: `RiverMouth`, `CoastalAccess`, `RiverCrossing`, `ValleyFloor`, `PassEntrance`, `LakeShore`, `PlainCenter`. A `CompatibilityMatrix` is a 10×7 scoring table (10 `economic_role` values × 7 attractor types) whose cells are float weights (0.0–3.0) representing how strongly that economic role favors that terrain feature. Examples: manufacturing → RiverMouth 2.8, ValleyFloor 2.1; financial → CoastalAccess 2.5, PlainCenter 1.8; agricultural → ValleyFloor 3.0, PlainCenter 2.5. The matrix is authored data (not computed at runtime). Attractor extraction from heightmaps is defined in D-209. Matching algorithm is defined in D-211. +- **Rationale:** Terrain-naive city placement produces spatially incoherent worlds. The compatibility matrix gives different city types different terrain affinities, so financial hubs appear on coasts and agricultural cities appear in fertile valleys — without hard-coding placement rules per city type. The float weight matrix gives graduated preference, not binary requirement. +- **Ticket:** #919, #925 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-191 (atlas pipeline), D-208 (D8 drainage — attractor extraction), D-209 (feature tag extraction), D-211 (attractor-matching pipeline) + +### D-196: SettlementClass Enum and Latent Settlement Active/Ghost Logic +- **Date:** 2026-05-01 +- **Decision:** Every settlement (city marker in `atlas_city_names`) has a `SettlementClass` that determines how it enters and exits active simulation: + ```rust + enum SettlementClass { + NameLocked, // Named in wiki; always active regardless of population + PopulationBudget, // Active if pop > threshold; ghost if below + EconomicTriggered, // Active only while economic role condition is met + OrganicGrowth, // Emergent; generated by simulation, no prior wiki record + } + ``` + - **Active threshold** (applies to `PopulationBudget`): population ≥ 50,000 for a city to receive full Phase 1 district skeleton generation. Below threshold: 1-district stub with Minimal ComplexityTier. + - **Ghost threshold** (applies to `PopulationBudget`): population < 5,000. Settlement is present in atlas data but receives no NPC population; structures are generated as abandoned (Worn/Derelict condition baseline). + - `NameLocked` settlements bypass both thresholds — they are always simulated regardless of population (handles narrative-significant small towns). + - `EconomicTriggered` settlements collapse to ghost state when their triggering economic condition lapses (e.g., a mining outpost depopulates when the mine is exhausted). + - `OrganicGrowth` settlements are not in `atlas_city_names` at generation time; they are written to the table during simulation when a settlement emerges organically. +- **Rationale:** Not every named location needs full generation, and not every simulated location is named. The classification separates authorial intent (NameLocked) from economic reality (PopulationBudget, EconomicTriggered) and simulation emergence (OrganicGrowth). Ghost settlements are important for world texture — abandoned mining towns and depopulated frontier outposts are as legible as thriving hubs. +- **Ticket:** #913 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-191 (atlas pipeline), D-200 (CityGenerationContext), D-203 (BodyWorldState), D-207 (atlas_city_names) + +### D-197: prosperity_baseline Derivation Formula with Topographic Gradient +- **Date:** 2026-05-01 +- **Decision:** Each city's `prosperity_baseline` (f32, 0.0–1.0, used as economic pressure state seed) is derived at generation time from four components: + 1. **Economic role base** — lookup per `economic_role` value: manufacturing=0.55, financial=0.70, agricultural=0.50, extraction=0.45, service_mixed=0.60, institutional=0.65, transit_hub=0.60, research=0.65, military=0.55, residential=0.50. + 2. **Population log-scale bonus** — `0.04 × floor(log10(pop / 1_000_000 + 1))`, capped at +0.12. Larger cities are generally more prosperous. + 3. **Topographic gradient bonus** — terrain features that historically correlate with prosperity add to the baseline: river mouth +0.08, coastal access +0.06, valley floor +0.04, pass entrance +0.03. At most one terrain bonus applies (the highest-scoring attractor at the city's position). + 4. **Seed noise** — ±0.05 uniform noise applied last (integer-seeded per city, D-010 determinism). + - Formula: `base + pop_bonus + terrain_bonus + noise`, clamped to [0.1, 0.95]. + - `prosperity_baseline` is not the current prosperity level — it is the simulation's starting point and decay/growth target. The live pressure simulation (D-026) drifts from this value based on trade flows, events, and faction pressure. +- **Rationale:** A flat random baseline produces economically incoherent worlds. Terrain-informed prosperity encodes real-world patterns: port cities are wealthy, river-mouth cities are strategic. The log-scale population bonus prevents megacities from dominating without eliminating small-city character. Clamping to [0.1, 0.95] prevents degenerate all-thriving or all-collapsing starting states. +- **Ticket:** #920 (consumer of prosperity_baseline) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-194 (district mix — consumes prosperity_baseline), D-195 (attractor types that produce terrain bonus) + +### D-198: Economic Simulation Independence from Layer 1–2 Spatial Data +- **Date:** 2026-05-01 +- **Decision:** The economics simulation (Phase 2, D-026 background tier) runs independently of Layer 1 (galaxy graph) and Layer 2 (location profiles / planetary topography). Economic state is seeded at game start from `systems.db` data (economic roles, trade flows, corporate presence) and then drifts via the pressure simulation. The generator (Layer 7 district skeleton) reads economic pressure state as an input but does not feed back into the simulation model. The two layers communicate one-way: simulation → generator (pressure state used to set district condition and density), never generator → simulation. + - **Prohibited:** Generator code must not modify `PressureState`. Generator code must not query live simulation state during async background generation tasks (race condition risk). Generator reads a snapshot of pressure state taken at generation dispatch time. + - **Allowed:** The generator reads `economic_health`, `prosperity_baseline`, `industries`, and `faction_influence` from the snapshot. These are read-only inputs to Phase 1 skeleton classification and Phase 2 condition application. +- **Rationale:** Bidirectional coupling between generator and simulation creates initialization order dependencies and potential circular references. The one-way data flow (simulation → generator snapshot → generator) keeps both systems independently testable and avoids race conditions in the Rayon thread pool (D-206). The generator is a consumer of economic state, not a participant in economic evolution. +- **Ticket:** #915 (CityGenerationContext reads economic snapshot) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-026 (simulation tiers), D-200 (CityGenerationContext), D-206 (background generation queue) + +### D-199: 6-Field Minimum Economic Read Set for City Generation Context +- **Date:** 2026-05-01 +- **Decision:** When building a `CityGenerationContext` (D-200), the generator reads exactly 6 fields from the economic pressure snapshot per city. Reading more fields is permitted but these 6 are the minimum required for correct Phase 1 skeleton classification: + 1. `economic_role` — primary function of the city (determines DistrictType distribution via D-194) + 2. `prosperity_baseline` — starting economic health (0.0–1.0, see D-197) + 3. `population` — city population (determines ComplexityTier ceiling, BlockSkeleton density) + 4. `dominant_faction` — faction with highest `faction_influence` at this location (affects Institutional and Restricted district bias) + 5. `founding_age_years` — years since settlement founding (drives BlockIrregularity via D-216, era distribution) + 6. `settlement_class` — `SettlementClass` enum value (D-196, determines whether to generate at all) + - Fields 1–5 are read from `systems.db` (bodies table + economics tables). Field 6 is derived at generator dispatch time. + - All 6 fields must be present before a generation task is dispatched. Missing fields abort the task with a logged error; generation does not proceed with partial context. +- **Rationale:** A fixed minimum read set prevents generators from accumulating unbounded dependencies on simulation state. The 6 fields cover the minimum information needed to produce a correctly-classified skeleton. The abort-on-missing-fields rule ensures generator output is always deterministic from a complete context, never silently degraded from a partial one. +- **Ticket:** #915 (CityGenerationContext implementation) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-196 (SettlementClass), D-197 (prosperity_baseline), D-198 (economic simulation independence), D-200 (CityGenerationContext struct) + +### 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. + 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:** + - Build-time outputs are read-only at runtime. + - Runtime-background tasks read from `systems.db` and write to `BodyWorldState` only. + - Runtime-on-demand reads from `BodyWorldState` and writes to the active ECS world (chunk tile data, NPC spawns). + - No tier may write to a higher tier's outputs. No circular dependencies. + - `CityGenerationContext` struct (see below) is the data contract between tiers 1→2. + ```rust + struct CityGenerationContext { + city_id: u64, + political_archetype: PoliticalArchetype, + prosperity_baseline: f32, + surrounding_biome: SettingType, + road_entry_directions: Vec, // compass octants (0–7) + footprint_radius_km: f32, + founding_orientation: FoundingOrientation, + world_tier: WorldTier, + } + ``` +- **Rationale:** Three tiers with explicit boundaries eliminates the "where does this code run?" question. Build-time is deterministic and committable. Runtime-background is parallelizable. Runtime-on-demand has strict latency budgets. Cross-tier mutation would create race conditions between the Rayon thread pool and the main tick thread. +- **Ticket:** #915 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-026 (simulation tiers), D-194 (district mix), D-203 (BodyWorldState), D-206 (background generation queue), tyre-sw1r3.md Layer 5/6 architecture + +### D-201: Spatial Hierarchy — Eight Tiers with Locked Dimensions +- **Date:** 2026-05-01 +- **Decision:** The generation pipeline has eight spatial tiers from galaxy to tile. Dimensions are locked and cannot be changed without amending this decision: + + | Tier | Name | Dimensions | Purpose | + |------|------|------------|---------| + | 1 | Galaxy | 300 systems | Galaxy graph, gate topology, cultural corridors | + | 2 | System | — | Orbital mechanics, body catalog | + | 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) | + + - 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. + - 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). +- **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) +- **Cross-reference:** D-094 (district hierarchy — Tiers 6–8), D-191 (atlas pipeline — Tier 3), D-205 (province boundaries — Tier 4), D-208 (D8 drainage — Tier 3 analysis) + +### D-202: Heightmap BLOB Storage Schema (atlas_body_heightmaps) +- **Date:** 2026-05-01 +- **Decision:** Heightmap elevation data is stored in `systems.db` as a BLOB in the `atlas_body_heightmaps` table. Schema: + ```sql + CREATE TABLE atlas_body_heightmaps ( + body_id INTEGER PRIMARY KEY REFERENCES bodies(id), + width INTEGER NOT NULL, -- pixel columns (canonical: 512) + height INTEGER NOT NULL, -- pixel rows (canonical: 256) + data BLOB NOT NULL, -- float32 little-endian, row-major, width×height floats + sea_level REAL NOT NULL DEFAULT 0.0, + imported_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + ``` + - `data` is a `float32` little-endian BLOB. Size: `width × height × 4` bytes. Canonical: 512×256×4 = ~512KB per body. + - Values are normalized elevation in [0.0, 1.0]. `sea_level` is the fraction below which terrain is underwater (default 0.0 = no ocean, overridden per body). + - The Rust loader reads the BLOB via `bytemuck::cast_slice::()` 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. +- **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) + +### D-203: BodyWorldState Bevy Resource with LRU Cache +- **Date:** 2026-05-01 +- **Decision:** `BodyWorldState` is a Bevy `Resource` holding the Layer 1–2 output for each recently-accessed planetary body. It functions as an LRU (Least Recently Used) cache: + - **Cache capacity:** 50 bodies. + - **Memory budget:** ~5MB total (50 bodies × ~100KB per entry average). A single body's Layer 1–2 data includes: processed heightmap (float32 grid, ~512KB pre-downsampled to ~8KB working resolution), river network (`RiverNetwork` struct: river cells, confluences, mouths), drainage basin polygons, attractor list, province boundary references. + - **Eviction policy:** On cache overflow, evict the body with the oldest `last_accessed` timestamp. Bodies that are the current player location or adjacent-system neighbors are pinned (not evicted). + - **Population:** The runtime-background tier (D-200) populates cache entries via Rayon tasks. Main thread reads are always from the cache; main thread code must never perform blocking DB reads for heightmap data. + - **Struct:** + ```rust + struct BodyWorldState { + body_id: u64, + heightmap: Vec, // downsampled working grid + river_network: RiverNetwork, // D-208 output + drainage_basins: Vec, + attractors: Vec, // D-195 types + last_accessed: SimTick, + } + ``` + - The resource is initialized empty and populated on demand. Accessing a body not in the cache triggers a background generation task (D-206). +- **Rationale:** The D8 drainage analysis (D-208) and attractor extraction (D-209) are expensive (target: ~50ms/body). Running them on the main tick thread would cause frame drops. The LRU cache ensures the main thread only reads pre-computed data. 50-body capacity covers the typical gameplay scenario (player in one system, neighboring system pre-cached) with margin. +- **Ticket:** #917 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-200 (three-tier execution model), D-202 (heightmap BLOB — input), D-206 (background generation queue — populates cache), D-208 (D8 drainage — produces river network) + +### D-204: body_radius_km Column on bodies Table +- **Date:** 2026-05-01 +- **Decision:** A `body_radius_km REAL` column is added to the `bodies` table in `systems.db`. This value is the mean radius of the planetary body in kilometers, used to: + - Compute `area_count` (number of districts a settlement can contain, scales with surface area) + - Convert province boundary pixel coordinates to real-world km distances + - Derive the `footprint_radius_km` field on `CityGenerationContext` (D-200) + - Schema change: `ALTER TABLE bodies ADD COLUMN body_radius_km REAL` (nullable, populated by import step) + - **Fallback derivation** (applied when `body_radius_km IS NULL`): `planet_class` lookup table with canonical radii: + - `super_earth`: 8,000 km + - `earth_like`: 6,371 km + - `sub_earth`: 4,500 km + - `ocean_world`: 6,500 km + - `arid`: 5,800 km + - `ice_world`: 3,000 km + - `gas_giant`: 50,000 km (no settlements) + - `moon`: 1,737 km + - `other` / unknown: 6,371 km (Earth default) + - Fallback is applied at query time, not stored back. The column remains NULL until authoritative data is available. +- **Rationale:** Surface area scales with radius squared; a body twice Earth's radius has four times the potential settlement density. Without this field the generator must use a flat default for all planets, producing physically implausible city counts on super-earths and moons alike. The fallback ensures the generator works before all bodies have explicit radius data. +- **Ticket:** #905 (schema), #910 (populate from planet_class fallback) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-191 (atlas pipeline — body catalog), D-200 (CityGenerationContext — footprint_radius_km) + +### D-205: Province Boundary Pre-Computation (atlas_province_boundaries) +- **Date:** 2026-05-01 +- **Decision:** Province boundaries (drainage basin divides) are pre-computed at build time from the D8 drainage analysis (D-208) and stored in `atlas_province_boundaries`: + ```sql + CREATE TABLE atlas_province_boundaries ( + body_id INTEGER NOT NULL REFERENCES bodies(id), + basin_id INTEGER NOT NULL, + path TEXT NOT NULL, -- JSON array [[row, col], ...] pixel-space polyline + area_pct REAL NOT NULL, -- fraction of body surface area in this basin + PRIMARY KEY (body_id, basin_id) + ); + ``` + - Boundaries are stored as pixel-space polylines in the same `[row, col]` convention as `markers.json` (D-191 §8 canonical format). + - `area_pct` is the fraction of the body's total surface area contained within this drainage basin. + - Province boundaries are the basis for district-level political zoning and cultural corridor assignment at Tier 4 (Region) in D-201. + - **Province count target:** 4–12 provinces per inhabited body, derived naturally from watershed analysis. Bodies with less topographic relief (plains worlds, ocean worlds) produce fewer, larger provinces. + - **At runtime:** Province boundaries are read from `atlas_province_boundaries` at generation dispatch time and cached in `BodyWorldState` as `drainage_basins` (D-203). They are not re-computed at runtime. +- **Rationale:** Province boundaries define the cultural geography of a world — the mountain ranges and river systems that separated civilizations and produced distinct regional identities. Pre-computing them at build time keeps the runtime-background tier focused on city placement and district generation rather than watershed analysis. Storing as polylines (not rasterized masks) keeps the table compact and human-readable. +- **Ticket:** #904 (schema), #907 (populate from watershed analysis) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-191 (atlas pipeline), D-201 (spatial hierarchy — Tier 4 Region), D-203 (BodyWorldState — caches province data), D-208 (D8 drainage — source of basin divides) + +### D-206: Background Generation Priority Queue and Rayon Thread Infrastructure +- **Date:** 2026-05-01 +- **Decision:** All non-urgent generator work runs through a prioritized Rayon thread pool: + - **Thread count:** `available_parallelism - 2`, minimum 1. Reserves 2 cores for the main tick thread and Bevy scheduler. + - **Priority queue:** Four levels: `Immediate` (player will arrive within 1 game-minute), `High` (player will arrive within 5 minutes), `Medium` (player is in the same system), `Low` (player has seen or heard of this location via NPC or news). Work items at higher priority pre-empt lower-priority items. + - **Work item types:** `AnalyzeBody(body_id)` (D8 drainage + attractor extraction), `GenerateSkeleton(city_id, context)` (Phase 1 DistrictSkeleton), `FillChunk(district_id, block_pos)` (Phase 2 chunk fill for pre-loading). + - **Event-driven pre-generation:** A `SystemNameIndex` (Aho-Corasick automaton over all body/system names from `systems.db`) scans NPC dialogue output and news ticker text. When a scan match hits, the referenced body is queued at `Low` priority if not already cached. This is the mechanism by which "NPC mentions a place → player travels there → world is already generated on arrival." + - **Completion notification:** Completed tasks send a `GenerationComplete` event to the main tick thread via a `crossbeam` channel. The main thread drains this channel once per tick. +- **Rationale:** The Rayon thread pool handles the D-200 runtime-background tier. The priority queue prevents low-priority speculation from blocking urgent work (player approaching). The Aho-Corasick name index enables cheap always-on scanning — NPC dialogue is low-bandwidth enough that scanning every output line has negligible cost. Pre-generation triggered by narrative content (NPC mentions a place) is the mechanism for making the world feel pre-existing rather than loading-on-demand. +- **Ticket:** #924 (background queue), #926 (SystemNameIndex) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-200 (three-tier execution model — runtime-background tier), D-203 (BodyWorldState — output of background tasks), D-208 (D8 drainage — enqueued as AnalyzeBody) + +### D-207: Fully Generative Placement — markers.json Stripped to Topographic Features +- **Date:** 2026-05-01 +- **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 ( + id INTEGER PRIMARY KEY, + body_id INTEGER NOT NULL REFERENCES bodies(id), + name TEXT NOT NULL, + economic_role TEXT NOT NULL, + population INTEGER NOT NULL, + corp_id INTEGER REFERENCES corporations(id), -- nullable, corp HQ if applicable + reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use + kind TEXT NOT NULL DEFAULT 'city' -- 'capital' | 'city' + ); + ``` + - `name` and position in the markers.json come from different sources: position is generated by the city placement algorithm; name is either authored (wiki), LLM-generated (Gemma 2 naming pipeline), or reserved (corp HQ name). The split allows position generation and naming to run independently. + - `corp_id` links to the `corporations` table when a city is a corporation's headquarters or major hub city. + - `reserved = 1` rows are scenario-specific cities that must not be relocated by the generation algorithm; the generator places other cities around them. + - **`markers.json` authored city data** (hand-written city center positions in the 6 hand-authored templates: Lendel, Edict, Vuurkloof, Røros, Cairnside, Estrade) is migrated to `atlas_city_names` and treated as `reserved = 1` rows. The markers.json files for these templates then have their city arrays cleared. +- **Rationale:** Authored city positions in markers.json created a split between hand-authored content and procedurally generated content that was impossible to query, diff, or validate consistently. Moving city identity to a table allows: SQL joins against economic data, corp HQ cross-references, scenario reservations, and attractor-matching validation. The topographic features (rivers, mountains) remain in JSON because they are polygon/polyline geometry better suited to JSON than relational rows. +- **Ticket:** #902 (schema), #908 (populate from wiki), #909 (corp HQ cross-reference) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-191 (atlas pipeline — markers.json canonical format), D-196 (SettlementClass — column in atlas_city_names), D-200 (CityGenerationContext — reads from this table) + +### D-208: D8 Priority-Flood Drainage Routing — Layer 1 Empty World +- **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. + - **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 + - `mouths: Vec<(u16, u16)>` — positions where rivers reach sea level or the heightmap edge + - **Province/basin output:** Cells that divide adjacent drainage basins become province boundary candidates (D-205). Boundaries are traced as polylines after flow accumulation is complete. + - **Performance target:** ~50ms per body on a single Rayon thread for canonical 512×256 resolution. + - **Determinism:** Integer-only arithmetic throughout. No f32 in the priority-flood comparisons (use integer-scaled elevation). D-010 compliant. +- **Rationale:** D8 is the standard GIS drainage routing algorithm and produces the river networks that drive attractor scoring (river mouths, confluences = high-value `RiverMouth` attractors). The flow accumulation threshold of 200 was chosen empirically against the Lendel heightmap to produce ~8–15 named rivers per inhabited body — enough for cultural geography without over-fragmenting the landscape. +- **Ticket:** #918 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-195 (attractor types — river mouth is highest-scoring), D-203 (BodyWorldState — output stored here), D-205 (province boundaries — derived from drainage divides), D-209 (feature tag extraction — reads river network) + +### D-209: Geographic Feature Tag Extraction (7 Settlement Attractor Tags) +- **Date:** 2026-05-01 +- **Decision:** After D8 drainage analysis, 7 `AttractorType` tags are extracted from the heightmap + river network and stored as `Vec` in `BodyWorldState`. Each attractor has a position `[row, col]` and a `strength: f32` (0.0–1.0) derived from local terrain quality. + - **Extraction rules per type:** + - `RiverMouth`: cells in `river_network.mouths`. Strength = `flow_accumulation[cell] / max_flow_accumulation` (normalized). Always high-value. + - `CoastalAccess`: cells within 3 pixels of a sea/ocean polygon (from `oceans[]` in markers.json), not already `RiverMouth`. Strength = 0.6 baseline + coast length bonus. + - `RiverCrossing`: cells at confluences or where a river crosses a topographic saddle. Strength = `flow_accumulation / max_flow_accumulation × 0.7`. + - `ValleyFloor`: local elevation minima in non-river cells with positive habitability score (slope < 5°, elevation 10–60% of range). Strength = habitability score. + - `PassEntrance`: local saddle points between adjacent drainage basins. Strength = inverse of elevation percentile (lower passes score higher). + - `LakeShore`: cells adjacent to `lake` polygons in markers.json. Strength = 0.5 baseline. + - `PlainCenter`: cells in flat terrain (slope < 2°) away from all other attractors. Strength = habitability score × 0.4. + - Sub-biome classification (vegetation, aridity, temperature zones) is derived in parallel and stored as `SubBiomeVariant` on the attractor for use by the ZonePalette modifier system (D-101). +- **Rationale:** The 7 attractor types cover the terrain features that historically determine city placement. Their extraction from the heightmap is deterministic and cheap given the D8 analysis is already complete. The strength normalization ensures attractor scores are comparable across bodies with different elevation ranges. +- **Ticket:** #925 (types), #919 (matching pipeline that consumes these) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-195 (CompatibilityMatrix — attractor types), D-203 (BodyWorldState — storage), D-208 (D8 drainage — prerequisite), D-211 (attractor-matching pipeline — consumer) + +### D-210: Sub-Biome Variant Classification and terrain_modification_cost +- **Date:** 2026-05-01 +- **Decision:** Each `GeographicAttractor` (D-209) carries a `sub_biome: SubBiomeVariant` tag that classifies the local terrain more finely than the top-level `SettingType`. This drives two systems: ZonePalette modifier selection (which visual variant to use) and `terrain_modification_cost` (how expensive it is to build infrastructure at this location). + - `SubBiomeVariant` values: `TropicalWet`, `TemperateForest`, `TemperateGrassland`, `BorealForest`, `Tundra`, `Desert`, `Savanna`, `Alpine`, `Wetland`, `CoastalLowland`, `Volcanic`. + - `terrain_modification_cost: f32` (1.0 = baseline, higher = more expensive): derived from sub-biome + local slope. Flat grassland = 1.0. Volcanic = 4.5. Wetland = 3.2. Alpine = 3.8. Coastal lowland = 1.4. Used by the attractor-matching pipeline (D-211) to penalize high-cost terrain for economically marginal cities. + - Sub-biome classification uses: elevation percentile (of body total), local slope, moisture proxy (distance to nearest river mouth or coast), and temperature proxy (latitude of the equirectangular pixel). + - Sub-biome data is stored in `BodyWorldState` alongside the attractors; it is not a separate DB table. +- **Rationale:** Two cities on coastal terrain feel different when one is a tropical lowland port and the other is a cold Nordic fjord. Sub-biome tags enable the ZonePalette to select the correct visual register (T6 beach/coastal with tropical modifier vs T7 mountain/high with coastal modifier). The `terrain_modification_cost` gives the generator a principled reason to prefer some attractor positions over others for lower-prosperity cities. +- **Ticket:** #919 (attractor matching — uses terrain_modification_cost) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-101 (ZonePalette modifier system — consumes sub_biome), D-195 (attractor types), D-209 (feature tag extraction — assigns sub_biome) + +### D-211: Attractor-Matching Five-Phase Pipeline for Settlement Placement +- **Date:** 2026-05-01 +- **Decision:** Given a body's `Vec` and a set of cities from `atlas_city_names`, settlement placement runs a five-phase matching pipeline: + 1. **Score matrix build:** Compute a `city_count × attractor_count` score matrix. Each cell = `CompatibilityMatrix[economic_role][attractor_type] × attractor.strength × (1.0 / terrain_modification_cost)`. + 2. **Tier A greedy assignment:** For each city with `SettlementClass::NameLocked` or population ≥ 1,000,000, assign the highest-scoring unoccupied attractor using greedy selection. These cities must be placed first to anchor the spatial layout. + 3. **Hungarian algorithm for Tier B+C:** Apply the Hungarian algorithm to the remaining cities (population 50,000–999,999) and remaining attractors. Produces optimal global assignment maximizing total score. + 4. **Synthetic attractor overflow:** Cities that cannot be matched to a real attractor (attractor pool exhausted) receive a synthetic `PlainCenter` attractor generated at a position that respects minimum city spacing (15 pixels minimum on 512×256 grid = ~50km minimum separation). + 5. **Name fulfillment check:** After placement, verify that all `atlas_city_names` entries for this body have been assigned a position. Log a warning for any unplaced city. + - **Two-tier mismatch flagging:** If a matched city-attractor pair has score < 0.35, log a `WARNING` (below expected quality). If score < 0.15, log an `ERROR` and flag for manual review. Generation proceeds in both cases; the flags are for content auditing, not hard blockers. + - **Output:** `Vec` written to `atlas_city_positions` at build time. +- **Rationale:** Greedy-first for large/locked cities ensures anchor cities (capitals, corp HQs, wiki-named cities) are placed at terrain features that match their lore role. Hungarian for medium cities finds the globally optimal assignment, not just locally optimal. Synthetic overflow prevents the algorithm from failing on bodies where city count exceeds natural attractor count (dense, flat worlds). The two-tier warning system enables content QA without blocking generation. +- **Ticket:** #919, #925 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-195 (CompatibilityMatrix), D-207 (atlas_city_names), D-209 (GeographicAttractor — input), D-210 (terrain_modification_cost — input) + +### D-212: TerritorialStatus Priority-Ordered Derivation Algorithm +- **Date:** 2026-05-01 +- **Decision:** Each `Province` (watershed-derived drainage basin, D-205) receives a `TerritorialStatus` value derived by priority-ordered classification at generation time: + ```rust + enum TerritorialStatus { + CommissionControlled, // Commission faction_influence ≥ 0.6 in this province + CorpTerritory, // Single corporation faction_influence ≥ 0.5 + ContestedZone, // Two or more factions each ≥ 0.3, no dominant faction + FrontierUnclaimed, // No faction with influence ≥ 0.2 + IndigenousHeld, // Cultural corridor has indigenous autonomy flag + Derelict, // population_density < 0.01 AND no faction ≥ 0.1 + } + ``` + - Classification applies checks in priority order: `CommissionControlled` checked first, `Derelict` last. The first condition that is true sets the status. + - `placed_at_generation: bool` flag on `Province` distinguishes classification at build time (true) from runtime re-classification during simulation (false). Build-time status is the starting state; simulation can change it, and the flag ensures the original classification is recoverable for reset/new-game scenarios. + - Faction influence values are read from `systems.db` (economics tables) at build time using the same D-199 economic read pattern. +- **Rationale:** Territory status is a high-level descriptor visible to the player on the Atlas overlay (D-191 §7, political zones overlay). It must be derivable from the generation inputs without runtime simulation state. The priority-ordered algorithm ensures clear, predictable classification — no ambiguous provinces. The `placed_at_generation` flag enables the game to show "how this province was at settlement time" vs. "how it is now." +- **Ticket:** #921 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-191 (atlas overlay — political zones), D-199 (economic read set), D-205 (Province — this status is a field on it) + +### D-213: FoundingOrientation Enum and Spatial Grid Rotation +- **Date:** 2026-05-01 +- **Decision:** `FoundingOrientation` describes the primary spatial axis of a city's original street grid, derived from the terrain feature that anchored the founding settlement. It controls the rotation of the district grid skeleton. + ```rust + enum FoundingOrientation { + Coastal { facing_degrees: u16 }, // street grid perpendicular to coastline + RiverAligned { bearing_degrees: u16 }, // street grid parallel to founding river + TerrainFollowing, // grid rotated to follow local contours + Cardinal, // grid aligned to N/S/E/W (commission-planned) + Free { bearing_degrees: u16 }, // arbitrary bearing (pioneer settlements) + } + ``` + - `facing_degrees` and `bearing_degrees` are integer degrees 0–359 (0 = North, clockwise). Integer to preserve D-010 determinism. + - The founding orientation is derived from the matched attractor type (D-211): `RiverMouth` → `Coastal`; `RiverAligned`; `CoastalAccess` → `Coastal`; `ValleyFloor` → `TerrainFollowing`; `PlainCenter` + Commission-controlled province → `Cardinal`; `PlainCenter` + other → `Free`. + - The district skeleton generator (Phase 1) applies `FoundingOrientation` as the base rotation for the outermost district ring. Interior districts inherit the orientation unless overridden by a `PoliticalArchetype` modifier. + - **Hard constraint:** Maximum ±45° deviation from the parent orientation per district (same limit as D-096 `BlockPlacement.rotation_steps`). Beyond ±45°, tile-based pathfinding produces movement artifacts. +- **Rationale:** Street grids reflect the terrain and founding logic of the original settlement. Roman camps faced cardinal directions. River towns align with the river. Coastal cities face the water. Encoding this as a named enum rather than a raw angle makes the orientation legible in the data model and debuggable during generation. +- **Ticket:** #914 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-096 (DistrictLayoutMode — inherits orientation), D-211 (attractor-matching — derives orientation), D-214 (PoliticalArchetype — may override orientation), D-215 (spatial arrangement patterns — uses orientation) + +### D-214: PoliticalArchetype Enum and Settlement Spatial Character +- **Date:** 2026-05-01 +- **Decision:** `PoliticalArchetype` classifies a settlement's dominant power structure and its physical expression in district layout: + ```rust + enum PoliticalArchetype { + Commission, // Top-down Commission planning; rectilinear, institutional core + Corporate, // Corp-dominated; commercial density, restricted zones, campus blocks + Pioneer, // Self-organized; organic growth, mixed use, ad-hoc infrastructure + Military, // Garrison or fortification origin; defensible geometry, restricted perimeter + Academic, // University or research origin; campus-quad structure, green space + Industrial, // Factory-first; large-footprint industrial blocks, worker residential rings + } + ``` + - `PoliticalArchetype` is derived at generation time from `TerritorialStatus` (D-212) + `economic_role`: `CommissionControlled` province → `Commission`; `CorpTerritory` → `Corporate`; `FrontierUnclaimed` → `Pioneer`; military economic role → `Military`; research economic role → `Academic`; manufacturing + extraction → `Industrial`. + - When multiple signals conflict (e.g., Commission-controlled manufacturing hub), `TerritorialStatus` takes precedence over `economic_role` for archetype derivation. + - **Spatial effect on district mix:** See D-194. Each archetype applies weight multipliers to district type selection. + - **`AttractorAssignment` disambiguation:** `OrganicGrowth` (a `DistrictType` value and also an `EraCause` value) is always unambiguous in context. On `DistrictType`, it means the district grew without a planning mandate. As `EraCause`, it means the era tag was acquired through organic settlement expansion rather than a discrete historical event. Both usages are permitted; the type system distinguishes them. +- **Rationale:** Power structure should be legible in a city's spatial form without the player reading a wiki entry. Commission cities look different from Corporate cities look different from Pioneer cities — not just in palette, but in street geometry, district type distribution, and building scale. Encoding this as a named enum ensures the distinction is consistent across all generation code. +- **Ticket:** #914 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-194 (district mix — archetype modifiers), D-212 (TerritorialStatus — primary input), D-213 (FoundingOrientation — archetype may override), D-215 (spatial arrangement patterns) + +### D-215: Five Explicit Political Archetype Spatial Arrangement Patterns +- **Date:** 2026-05-01 +- **Decision:** Each `PoliticalArchetype` maps to one of five spatial arrangement patterns that govern district adjacency and the placement of landmark multi-block reservations: + 1. **Radial core** (Commission, Academic): Central landmark (civic square, institutional plaza, or university quad) surrounded by mixed-use rings. Transit spokes radiate outward. Districts are denser near center. + 2. **Campus grid** (Corporate): Restricted campus block occupies 2–4 blocks in the district interior. Commercial districts ring the exterior. Worker residential on periphery. + 3. **Ribbon development** (Pioneer, Industrial): Districts string along a linear feature (river, road, industrial rail). No dominant center. Mixed adjacency at every edge. + 4. **Fortified perimeter** (Military): Restricted and Secured districts at the edge of the footprint. Open access in the interior core. Single controlled access point per district edge. + 5. **Hub-and-spoke** (transit_hub economic role, any archetype): Transit district at center, all other district types accessible via direct corridors. Maximum 2-district travel between any two districts. + - The arrangement pattern constrains block adjacency during Phase 1 skeleton generation. Specifically: the first 2–3 districts placed in a settlement follow the pattern. Later districts are constrained only by the road network, not by the pattern. + - Arrangement patterns must **vary in angular orientation** per seed (not just position) — the same archetype's radial core must not always face the same direction across seeds. +- **Rationale:** The 14 D-ready items from the generator-architecture workshop established that spatial arrangement should encode power structure. These five patterns are the minimal set to cover the 6 archetypes (Pioneer and Industrial share ribbon development; hub-and-spoke is a cross-archetype pattern for transit-primary cities). Pattern variation in angular orientation prevents players from pattern-matching settlement layout after the first playthrough. +- **Ticket:** #914 (types), #899 (implementation — Phase 1 skeleton generator) +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-094 (spatial hierarchy — district sizes), D-194 (district mix — archetype modifiers), D-214 (PoliticalArchetype — pattern assignment) + +### D-216: BlockIrregularity from founding_age — Layout Age Character +- **Date:** 2026-05-01 +- **Decision:** `block_irregularity: f32` is a derived value on each block (range 0.0–1.0) that controls how much a block deviates from the district's canonical grid. It is computed from `founding_age_years` and `PoliticalArchetype`. The formula: + ``` + base_irregularity = (founding_age_years / 1000.0).min(1.0) + archetype_step = match archetype { + Commission | Military => -0.3, // suppresses organic deviation + Corporate | Academic => -0.1, + Industrial => 0.0, + Pioneer => +0.3, + } + block_irregularity = (base_irregularity + archetype_step).max(0.05).min(1.0) + ``` + - Minimum 0.05 is enforced — no block is perfectly regular, even new Commission-planned settlements. + - `block_irregularity` feeds the `BlockPlacement.offset` magnitude in `DistrictLayoutMode::Organic`: `max_offset_sim_tiles = (block_irregularity × 16.0) as i16`. + - An old Pioneer settlement (age 800+ years) can have `block_irregularity ≈ 1.0`, producing maximum ±16 sim tile offsets and ±45° rotations. A new Commission district (age < 50 years) will have `block_irregularity ≈ 0.05`. + - All arithmetic uses integer-scaled intermediates wherever possible (age is integer years; archetype_step is stored as integer basis points internally). The f32 in the formula above is for documentation clarity only. +- **Rationale:** Age is the single most reliable predictor of urban irregularity in the real world. Old cities that grew organically have crooked streets; new planned cities have grids. Encoding this as a formula rather than a lookup table allows continuous variation along the age axis while preserving the political meaning of the archetype modifier. +- **Ticket:** #922 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-096 (DistrictLayoutMode::Organic — consumes block_irregularity), D-194 (district mix — founding_age is an input), D-214 (PoliticalArchetype — archetype_step source) + +### D-217: Tile Condition Thresholds (0.63 / 0.43 / 0.23) +- **Date:** 2026-05-01 +- **Decision:** A tile's visual condition is derived from the district's `prosperity_score` (live pressure simulation value, 0.0–1.0) using four threshold bands: + | Band | Condition | prosperity_score range | Tile visual state | + |------|-----------|----------------------|-------------------| + | 1 | Intact | > 0.63 | Clean, undamaged, well-maintained | + | 2 | Worn | 0.43 – 0.63 | Scuff marks, minor discoloration, partial repairs | + | 3 | Cracked | 0.23 – 0.43 | Visible damage, incomplete repair, graffiti | + | 4 | Broken | < 0.23 | Structural damage, debris, derelict appearance | + - **Cache invalidation:** A tile's condition only changes when `prosperity_score` crosses a threshold boundary (from band N to band N±1). This avoids per-tick visual updates. The simulation checks threshold crossings once per game-minute (D-031 day-phase tick rate). + - **Baseline floor:** The block's `EraCause` sets a minimum condition floor: + - `Decay` era: minimum Cracked (no tile in a Decay-era block is ever Intact or Worn without an active renovation event) + - `EmergencyExtension` era: minimum Worn + - All other eras: no floor (condition follows prosperity_score freely) + - **Phase 2 application:** Chunk fill applies the baseline condition at fill time. Subsequent condition updates from simulation crossing thresholds are applied as `ChunkMutations.tile_overrides`. + - Condition thresholds are authored constants, not computed. Any change to the thresholds (0.63 / 0.43 / 0.23) requires amending this D-record. +- **Rationale:** Threshold-crossing invalidation is a standard visual LOD technique that avoids expensive per-frame recalculation. The four bands (Intact/Worn/Cracked/Broken) match the visual fidelity budget for the current art direction — more bands require more tile variants per palette. The era-based floor ensures that historical context is always visible: a Decay-era block cannot spontaneously look pristine from a prosperity spike alone. +- **Ticket:** #923 +- **Raised by:** Generation cascade workshop (#897) +- **Cross-reference:** D-100 (DamageOverlay — post-condition modification), D-194 (district mix — prosperity_baseline is the seed for prosperity_score) + +### D-218: WorldTier Enum Canonical Values (Epicenter/Regional/Backwater/Passage/Waypoint) +- **Date:** 2026-05-01 +- **Decision:** The canonical `WorldTier` enum values are: + ```rust + enum WorldTier { + Epicenter, // Hub system. Full simulation. High faction pressure. Multi-district cities. + Regional, // Regional hub. 1–4 districts per city. Partial full-budget districts. + Backwater, // Small community. Dense isolated settlement. Full sim budget — NOT capped. + Passage, // Transit stop. Pass-through. ComplexityTier ceiling: Moderate. + Waypoint, // Not simulated until player approaches. ComplexityTier ceiling: Minimal. + } + ``` + - The values `Peripheral`, `Connected`, and `Core` used in generator.rs prior to Sprint 38 are **incorrect** — they were stubbed values that do not match the workshop design (workshop-outcomes.md §WorldTier and ComplexityTier). They must be replaced with the five canonical values above. + - **ComplexityTier ceiling per WorldTier:** + - `Epicenter` → Full + - `Regional` → Full + - `Backwater` → Full (critical: `Backwater` is network-insignificant, NOT budget-capped; isolated communities can be socially complex) + - `Passage` → Moderate + - `Waypoint` → Minimal + - **Source of truth:** workshop-outcomes.md §WorldTier and ComplexityTier table (generator-architecture workshop, lead decision L-3). + - All code referencing `WorldTier::Peripheral`, `WorldTier::Connected`, or `WorldTier::Core` must be updated to the canonical values. This includes generator.rs, any tests, and any serialized data that references these variants. +- **Rationale:** The three-value stub (Peripheral/Connected/Core) was authored before the generator architecture workshop established the five-value canonical model. The mismatch between the code and the design means any generator code built against the stub types would need rewriting anyway. Correcting it now before the Phase 1 implementation work begins eliminates that rework. The `Backwater` full-budget exception is architecturally significant: dense isolated communities (mining towns, research outposts) should be as socially rich as regional hubs — their isolation is their drama, not their limitation. +- **Ticket:** #900 (bug fix), #912 (full enum implementation) +- **Raised by:** Generation cascade workshop (#897). Original workshop-outcomes.md §WorldTier (generator-architecture workshop, lead decision L-3). +- **Cross-reference:** D-096 (DistrictLayoutMode — WorldTier is a DistrictSkeleton field), D-097 (GuaranteeAuditResult — tier-conditional guarantees), D-201 (spatial hierarchy — WorldTier assigned at Tier 2 System level) + +--- + +*79 decisions (D-001 through D-218, excluding gaps). Last updated: 2026-05-02 (D-218 — WorldTier canonical values, generation cascade workshop Sprint 38)* diff --git a/server/Cargo.lock b/server/Cargo.lock index 7caec66b0..4acd3fdf5 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1296,9 +1296,11 @@ dependencies = [ name = "settled-reach-server" version = "0.1.37" dependencies = [ + "aho-corasick", "bevy_app", "bevy_ecs", "bevy_tasks", + "bytemuck", "clap", "crossbeam-channel", "econ-sim", diff --git a/server/Cargo.toml b/server/Cargo.toml index f6bb67319..1c6639fbe 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -28,7 +28,9 @@ crossbeam-channel = "0.5" sysinfo = "0.35" serde_json = "1" rusqlite = { version = "0.32", features = ["bundled"] } +bytemuck = "1" toml = "0.8" +aho-corasick = "1" # Economics simulation — Leontief + tâtonnement + D-180 event port (#821) econ-sim = { path = "../tooling/econ-sim" } diff --git a/server/data/systems-schema.sql b/server/data/systems-schema.sql index cb2a9b966..3ae46d2a8 100644 --- a/server/data/systems-schema.sql +++ b/server/data/systems-schema.sql @@ -175,6 +175,11 @@ CREATE TABLE IF NOT EXISTS bodies ( cultural_corridor TEXT, -- override system corridor if different industrial_corridor TEXT, -- MVG, Gate_Corp, DSMC, Prometheus, Agricultural_Syndic + -- Physical dimensions (D-204, #905) + -- Mean radius in km. NULL until authoritative data is available; fallback + -- derivation from planet_class is applied at query time by the generator. + body_radius_km REAL, + -- Rendering -- terrain_reference: repo-root-relative path to the body's heightmap PNG. -- Convention (enforced by populate_terrain_reference.py and assumed by @@ -455,6 +460,72 @@ CREATE INDEX IF NOT EXISTS idx_atlas_pois_kind ON atlas_pois(kind); CREATE INDEX IF NOT EXISTS idx_atlas_rivers_body ON atlas_rivers(body_id); CREATE INDEX IF NOT EXISTS idx_atlas_oceans_body ON atlas_oceans(body_id); CREATE INDEX IF NOT EXISTS idx_atlas_mountain_ranges_body ON atlas_mountain_ranges(body_id); +-- Heightmap BLOB storage — float32 LE, row-major (D-202, #901) +-- Only inhabited bodies receive rows at build time; uninhabited bodies are +-- generated on-demand by the runtime-background tier. +CREATE TABLE IF NOT EXISTS atlas_body_heightmaps ( + body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE, + width INTEGER NOT NULL DEFAULT 512, + height INTEGER NOT NULL DEFAULT 256, + data BLOB NOT NULL, -- float32 LE, row-major, width×height values + sea_level REAL NOT NULL DEFAULT 0.0, + imported_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- City name reservations — replaces authored city positions in markers.json (D-207, #902) +-- Position is generated by the city placement algorithm; name is authored or LLM-generated. +CREATE TABLE IF NOT EXISTS atlas_city_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city' + economic_role TEXT NOT NULL, + population INTEGER NOT NULL, + settlement_class TEXT, -- D-196 SettlementClass variant; NULL until placement + corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable + reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Geographic feature name reservations — rivers, mountains, passes (D-207 adjacent, #903) +CREATE TABLE IF NOT EXISTS atlas_feature_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + feature_type TEXT NOT NULL, -- 'river' | 'mountain' | 'pass' | 'ocean' | 'region' + priority INTEGER NOT NULL DEFAULT 0, -- higher = applied first during naming + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Province boundaries — watershed drainage basin polylines (D-205, #904) +-- Pre-computed at build time from D8 drainage analysis. +CREATE TABLE IF NOT EXISTS atlas_province_boundaries ( + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + basin_id INTEGER NOT NULL, + path TEXT NOT NULL, -- JSON array [[row, col], ...] pixel-space polyline + area_pct REAL NOT NULL, -- fraction of body surface area in this basin + PRIMARY KEY (body_id, basin_id) +); + +-- City positions — attractor-matched placement output (D-211, #34) +-- Written at build time by the attractor-matching pipeline. Each row maps one +-- atlas_city_names entry to its terrain position and the attractor that placed it. +CREATE TABLE IF NOT EXISTS atlas_city_positions ( + city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + row INTEGER NOT NULL, -- pixel row in heightmap grid [0, GRID_H) + col INTEGER NOT NULL, -- pixel col in heightmap grid [0, GRID_W) + attractor_type TEXT NOT NULL, -- AttractorType variant name + score REAL NOT NULL -- match quality [0.0, 1.0] +); + +CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id); +CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id); -- END ATLAS INDEX (D-191 §8, #832) -- Indexes diff --git a/server/data/systems.db b/server/data/systems.db index cc02da9a9..d757725f4 100644 Binary files a/server/data/systems.db and b/server/data/systems.db differ diff --git a/server/src/atlas/attractor_matching.rs b/server/src/atlas/attractor_matching.rs new file mode 100644 index 000000000..8fe664ad3 --- /dev/null +++ b/server/src/atlas/attractor_matching.rs @@ -0,0 +1,622 @@ +//! Attractor-matching five-phase pipeline for settlement placement (D-211). +//! +//! Given a body's `Vec` and a list of cities, assigns +//! each city to the terrain feature that best fits its economic role and +//! population tier. +//! +//! **Phases (D-211):** +//! 1. Score matrix build: `CompatibilityMatrix[economic_role][attractor_type] × strength × (1/cost)` +//! 2. Tier A greedy: `NameLocked` or pop ≥ 1,000,000 — assigned first, highest-score greedy. +//! 3. Hungarian (Tier B+C): pop 50,000–999,999 cities — optimal global assignment. +//! 4. Synthetic overflow: any remaining city gets a synthetic `PlainCenter` attractor. +//! 5. Name fulfillment check: warn if any atlas city was not placed. +//! +//! **Mismatch flagging (D-211):** +//! - score < 0.35 → WARNING +//! - score < 0.15 → ERROR (flagged for manual review; generation continues) + +use tracing::{error, warn}; + +use crate::simulation::generator::{ + AttractorType, CompatibilityMatrix, GeographicAttractor, SettlementClass, +}; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +/// One city record from atlas_city_names, projected for matching. +#[derive(Debug, Clone)] +pub struct CityRecord { + pub city_id: u64, + pub name: String, + pub settlement_class: SettlementClass, + pub population: i64, + /// One of: manufacturing, financial, agricultural, extraction, + /// service_mixed, institutional, transit_hub, research, military, residential. + pub economic_role: String, +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +/// Result of matching one city to one attractor (real or synthetic). +#[derive(Debug, Clone)] +pub struct CityPlacement { + pub city_id: u64, + pub position: (u16, u16), + pub attractor_type: AttractorType, + pub score: f32, + pub synthetic: bool, +} + +// --------------------------------------------------------------------------- +// Score matrix helpers +// --------------------------------------------------------------------------- + +/// Row index in CompatibilityMatrix for an economic_role string. +/// Order from D-195: manufacturing(0), financial(1), agricultural(2), extraction(3), +/// service_mixed(4), institutional(5), transit_hub(6), research(7), military(8), residential(9). +fn role_row(economic_role: &str) -> usize { + match economic_role { + "manufacturing" => 0, + "financial" => 1, + "agricultural" => 2, + "extraction" => 3, + "service_mixed" => 4, + "institutional" => 5, + "transit_hub" => 6, + "research" => 7, + "military" => 8, + _ => 9, + } +} + +/// Column index in CompatibilityMatrix for an AttractorType. +/// Order from D-195: RiverMouth(0), CoastalAccess(1), RiverCrossing(2), ValleyFloor(3), +/// PassEntrance(4), LakeShore(5), PlainCenter(6). +fn attractor_col(at: &AttractorType) -> usize { + match at { + AttractorType::RiverMouth => 0, + AttractorType::CoastalAccess => 1, + AttractorType::RiverCrossing => 2, + AttractorType::ValleyFloor => 3, + AttractorType::PassEntrance => 4, + AttractorType::LakeShore => 5, + AttractorType::PlainCenter => 6, + } +} + +/// Compute the raw match score between a city and an attractor. +/// Score = matrix_weight × attractor.strength × (1.0 / terrain_modification_cost). +fn cell_score( + city: &CityRecord, + attractor: &GeographicAttractor, + matrix: &CompatibilityMatrix, + terrain_cost: f32, +) -> f32 { + let row = role_row(&city.economic_role); + let col = attractor_col(&attractor.attractor_type); + let weight = matrix.weights[row][col]; + let cost_factor = if terrain_cost > 0.0 { + 1.0 / terrain_cost + } else { + 1.0 + }; + weight * attractor.strength * cost_factor +} + +// --------------------------------------------------------------------------- +// Phase 3: Hungarian algorithm (minimization) +// --------------------------------------------------------------------------- + +/// O(n³) Hungarian algorithm for assignment problem. +/// +/// Input: `cost[i][j]` — cost of assigning task j to worker i. +/// Lower cost = better fit. Converts the maximization problem to minimization +/// by using `max_score - score` as cost. +/// +/// Returns `assignment[i] = j` for each row i. +fn hungarian(cost: &[Vec]) -> Vec { + let n = cost.len(); + if n == 0 { + return Vec::new(); + } + let m = cost[0].len(); + if m == 0 { + return vec![usize::MAX; n]; + } + + // Pad to square n×n if m < n (more cities than attractors handled by overflow). + let sz = n.max(m); + let mut c: Vec> = vec![vec![0.0; sz]; sz]; + for i in 0..n { + for j in 0..m { + c[i][j] = cost[i][j]; + } + // Pad extra columns with high cost so overflow cities pick them last. + for item in c[i].iter_mut().take(sz).skip(m) { + *item = f32::MAX / 2.0; + } + } + // Pad extra rows with 0 cost (dummy workers). + // Already initialized to 0. + + // Standard O(n³) Hungarian. + let inf = f32::MAX / 2.0; + let mut u = vec![0.0f32; sz + 1]; + let mut v = vec![0.0f32; sz + 1]; + let mut p = vec![0usize; sz + 1]; // p[j] = row assigned to column j (1-indexed) + let mut way = vec![0usize; sz + 1]; + + for i in 1..=sz { + p[0] = i; + let mut j0 = 0usize; + let mut minv = vec![inf; sz + 1]; + let mut used = vec![false; sz + 1]; + loop { + used[j0] = true; + let i0 = p[j0]; + let mut delta = inf; + let mut j1 = 0usize; + for j in 1..=sz { + if used[j] { + continue; + } + let cur = c[i0 - 1][j - 1] - u[i0] - v[j]; + if cur < minv[j] { + minv[j] = cur; + way[j] = j0; + } + if minv[j] < delta { + delta = minv[j]; + j1 = j; + } + } + for j in 0..=sz { + if used[j] { + u[p[j]] += delta; + v[j] -= delta; + } else { + minv[j] -= delta; + } + } + j0 = j1; + if p[j0] == 0 { + break; + } + } + loop { + let j1 = way[j0]; + p[j0] = p[j1]; + j0 = j1; + if j0 == 0 { + break; + } + } + } + + // Extract assignment: for each row i (1-indexed), find column j where p[j] == i. + let mut result = vec![usize::MAX; n]; + for j in 1..=sz { + if p[j] > 0 && p[j] <= n { + let col = j - 1; + if col < m { + result[p[j] - 1] = col; + } + } + } + result +} + +// --------------------------------------------------------------------------- +// Synthetic PlainCenter placement +// --------------------------------------------------------------------------- + +/// Minimum pixel separation between synthetic attractor positions. +const MIN_SPACING: u16 = 15; + +fn synthetic_attractor(placed: &[CityPlacement], grid_w: u32, grid_h: u32) -> GeographicAttractor { + // Place at grid center as default, then walk until spacing is satisfied. + let mut row = (grid_h / 2) as u16; + let mut col = (grid_w / 4) as u16; + + // Simple search: try positions in a grid until spacing is met. + 'outer: for dr in 0..(grid_h as u16 / MIN_SPACING) { + for dc in 0..(grid_w as u16 / MIN_SPACING) { + let r = (dr * MIN_SPACING).min(grid_h as u16 - 1); + let c = (dc * MIN_SPACING).min(grid_w as u16 - 1); + let ok = placed.iter().all(|p| { + let dr2 = (p.position.0 as i32 - r as i32).unsigned_abs() as u16; + let dc2 = (p.position.1 as i32 - c as i32).unsigned_abs() as u16; + dr2.max(dc2) >= MIN_SPACING + }); + if ok { + row = r; + col = c; + break 'outer; + } + } + } + + GeographicAttractor { + position: (row, col), + attractor_type: AttractorType::PlainCenter, + strength: 0.5, + } +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +/// Run the five-phase attractor-matching pipeline (D-211). +/// +/// `terrain_costs` maps attractor index → terrain_modification_cost (1.0 = baseline). +/// If `None`, all costs default to 1.0. +pub fn match_cities( + cities: &[CityRecord], + attractors: &[GeographicAttractor], + matrix: &CompatibilityMatrix, + terrain_costs: Option<&[f32]>, + grid_w: u32, + grid_h: u32, +) -> Vec { + let default_cost = vec![1.0f32; attractors.len()]; + let costs = terrain_costs.unwrap_or(&default_cost); + + let mut placements: Vec = Vec::with_capacity(cities.len()); + let mut used_attractors: Vec = vec![false; attractors.len()]; + + // ------------------------------------------------------------------------- + // Phase 1: Score matrix + // ------------------------------------------------------------------------- + let scores: Vec> = cities + .iter() + .map(|city| { + attractors + .iter() + .zip(costs.iter()) + .map(|(att, &cost)| cell_score(city, att, matrix, cost)) + .collect() + }) + .collect(); + + // ------------------------------------------------------------------------- + // Phase 2: Tier A greedy — NameLocked or pop ≥ 1_000_000 + // ------------------------------------------------------------------------- + let tier_a_indices: Vec = cities + .iter() + .enumerate() + .filter(|(_, c)| { + c.settlement_class == SettlementClass::NameLocked || c.population >= 1_000_000 + }) + .map(|(i, _)| i) + .collect(); + + for &ci in &tier_a_indices { + if attractors.is_empty() { + break; + } + // Highest-scoring unused attractor. + let best = scores[ci] + .iter() + .enumerate() + .filter(|(ai, _)| !used_attractors[*ai]) + .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some((ai, &score)) = best { + used_attractors[ai] = true; + flag_mismatch(&cities[ci].name, score); + placements.push(CityPlacement { + city_id: cities[ci].city_id, + position: attractors[ai].position, + attractor_type: attractors[ai].attractor_type.clone(), + score, + synthetic: false, + }); + } + } + + // ------------------------------------------------------------------------- + // Phase 3: Hungarian — Tier B+C (50,000–999,999) + // ------------------------------------------------------------------------- + let tier_bc_indices: Vec = cities + .iter() + .enumerate() + .filter(|(i, c)| { + !tier_a_indices.contains(i) && c.population >= 50_000 && c.population < 1_000_000 + }) + .map(|(i, _)| i) + .collect(); + + let free_attractors: Vec = (0..attractors.len()) + .filter(|&ai| !used_attractors[ai]) + .collect(); + + if !tier_bc_indices.is_empty() && !free_attractors.is_empty() { + // Build cost sub-matrix (maximization → minimization via complement). + let scores_ref = &scores; + let max_score: f32 = tier_bc_indices + .iter() + .flat_map(|&ci| free_attractors.iter().map(move |&ai| scores_ref[ci][ai])) + .fold(0.0f32, f32::max); + + let cost: Vec> = tier_bc_indices + .iter() + .map(|&ci| { + free_attractors + .iter() + .map(|&ai| max_score - scores_ref[ci][ai]) + .collect() + }) + .collect(); + + let assignment = hungarian(&cost); + + for (local_i, &ci) in tier_bc_indices.iter().enumerate() { + let local_j = assignment[local_i]; + if local_j == usize::MAX || local_j >= free_attractors.len() { + continue; // overflow — handled in phase 4 + } + let ai = free_attractors[local_j]; + let score = scores[ci][ai]; + used_attractors[ai] = true; + flag_mismatch(&cities[ci].name, score); + placements.push(CityPlacement { + city_id: cities[ci].city_id, + position: attractors[ai].position, + attractor_type: attractors[ai].attractor_type.clone(), + score, + synthetic: false, + }); + } + } + + // ------------------------------------------------------------------------- + // Phase 4: Synthetic overflow — all remaining cities + // ------------------------------------------------------------------------- + let placed_ids: std::collections::BTreeSet = + placements.iter().map(|p| p.city_id).collect(); + + for city in cities { + if placed_ids.contains(&city.city_id) { + continue; + } + let synthetic = synthetic_attractor(&placements, grid_w, grid_h); + let score = cell_score(city, &synthetic, matrix, 1.0); + flag_mismatch(&city.name, score); + placements.push(CityPlacement { + city_id: city.city_id, + position: synthetic.position, + attractor_type: AttractorType::PlainCenter, + score, + synthetic: true, + }); + } + + // ------------------------------------------------------------------------- + // Phase 5: Name fulfillment check + // ------------------------------------------------------------------------- + let placed_ids: std::collections::BTreeSet = + placements.iter().map(|p| p.city_id).collect(); + for city in cities { + if !placed_ids.contains(&city.city_id) { + warn!( + city = %city.name, + city_id = city.city_id, + "atlas city was not placed — missing from pipeline output" + ); + } + } + + placements +} + +fn flag_mismatch(city_name: &str, score: f32) { + if score < 0.15 { + error!( + city = %city_name, + score, + "attractor mismatch score < 0.15 — flagged for manual review" + ); + } else if score < 0.35 { + warn!( + city = %city_name, + score, + "attractor mismatch score < 0.35 — below expected quality" + ); + } +} + +// --------------------------------------------------------------------------- +// FoundingOrientation derivation from matched attractor (D-211, D-213) +// --------------------------------------------------------------------------- + +use crate::simulation::generator::FoundingOrientation; +use crate::simulation::generator::TerritorialStatus; + +/// Derive `FoundingOrientation` from the attractor type that anchored the city (D-211, D-213). +/// +/// `river_bearing` and `coastal_facing` are compass degrees 0–359. +/// Pass 0 as default when the terrain doesn't dictate a specific bearing. +pub fn founding_orientation( + attractor_type: &AttractorType, + territorial_status: &TerritorialStatus, + river_bearing: u16, + coastal_facing: u16, +) -> FoundingOrientation { + match attractor_type { + AttractorType::RiverMouth | AttractorType::CoastalAccess => FoundingOrientation::Coastal { + facing_degrees: coastal_facing, + }, + AttractorType::RiverCrossing => FoundingOrientation::RiverAligned { + bearing_degrees: river_bearing, + }, + AttractorType::ValleyFloor => FoundingOrientation::TerrainFollowing, + AttractorType::PlainCenter => { + if matches!(territorial_status, TerritorialStatus::CommissionControlled) { + FoundingOrientation::Cardinal + } else { + FoundingOrientation::Free { bearing_degrees: 0 } + } + } + AttractorType::PassEntrance | AttractorType::LakeShore => { + FoundingOrientation::TerrainFollowing + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::generator::{CompatibilityMatrix, GeographicAttractor}; + + fn uniform_matrix() -> CompatibilityMatrix { + CompatibilityMatrix { + weights: [[1.0; 7]; 10], + } + } + + fn make_attractor(row: u16, col: u16, at: AttractorType, strength: f32) -> GeographicAttractor { + GeographicAttractor { + position: (row, col), + attractor_type: at, + strength, + } + } + + fn make_city(id: u64, class: SettlementClass, pop: i64) -> CityRecord { + CityRecord { + city_id: id, + name: format!("City{id}"), + settlement_class: class, + population: pop, + economic_role: "manufacturing".to_string(), + } + } + + #[test] + fn single_city_single_attractor() { + let cities = vec![make_city(1, SettlementClass::NameLocked, 500_000)]; + let attractors = vec![make_attractor(10, 20, AttractorType::RiverMouth, 0.8)]; + let matrix = uniform_matrix(); + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + assert_eq!(placements.len(), 1); + assert_eq!(placements[0].city_id, 1); + assert_eq!(placements[0].position, (10, 20)); + assert!(!placements[0].synthetic); + } + + #[test] + fn tier_a_gets_priority() { + // NameLocked city should get the best attractor (high strength). + let cities = vec![ + make_city(1, SettlementClass::NameLocked, 100_000), + make_city(2, SettlementClass::PopulationBudget, 200_000), + ]; + let attractors = vec![ + make_attractor(5, 5, AttractorType::RiverMouth, 0.9), // best + make_attractor(10, 10, AttractorType::ValleyFloor, 0.4), // second + ]; + let matrix = uniform_matrix(); + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + let p1 = placements.iter().find(|p| p.city_id == 1).unwrap(); + assert_eq!(p1.position, (5, 5), "NameLocked should get best attractor"); + } + + #[test] + fn overflow_produces_synthetic() { + // 2 cities, 1 attractor → second city gets synthetic. + let cities = vec![ + make_city(1, SettlementClass::NameLocked, 2_000_000), + make_city(2, SettlementClass::PopulationBudget, 60_000), + ]; + let attractors = vec![make_attractor(0, 0, AttractorType::RiverMouth, 1.0)]; + let matrix = uniform_matrix(); + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + assert_eq!(placements.len(), 2); + let p2 = placements.iter().find(|p| p.city_id == 2).unwrap(); + assert!(p2.synthetic); + } + + #[test] + fn all_cities_placed() { + let cities: Vec = (1..=5) + .map(|i| make_city(i, SettlementClass::PopulationBudget, 100_000)) + .collect(); + let attractors = vec![ + make_attractor(10, 10, AttractorType::RiverMouth, 0.9), + make_attractor(20, 20, AttractorType::CoastalAccess, 0.7), + ]; + let matrix = uniform_matrix(); + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + assert_eq!(placements.len(), 5, "all cities must be placed"); + } + + #[test] + fn hungarian_assigns_optimally() { + // 2 cities, 2 attractors. City A scores best on attractor 0, city B best on attractor 1. + let mut matrix = uniform_matrix(); + // agricultural (row 2) scores high on ValleyFloor (col 3) = 3.0 + matrix.weights[2][3] = 3.0; + // transit_hub (row 6) scores high on RiverCrossing (col 2) = 3.0 + matrix.weights[6][2] = 3.0; + let cities = vec![ + CityRecord { + city_id: 1, + name: "Farm".to_string(), + settlement_class: SettlementClass::PopulationBudget, + population: 60_000, + economic_role: "agricultural".to_string(), + }, + CityRecord { + city_id: 2, + name: "Hub".to_string(), + settlement_class: SettlementClass::PopulationBudget, + population: 80_000, + economic_role: "transit_hub".to_string(), + }, + ]; + let attractors = vec![ + make_attractor(5, 5, AttractorType::ValleyFloor, 1.0), + make_attractor(10, 10, AttractorType::RiverCrossing, 1.0), + ]; + let placements = match_cities(&cities, &attractors, &matrix, None, 512, 256); + assert_eq!(placements.len(), 2); + let farm = placements.iter().find(|p| p.city_id == 1).unwrap(); + let hub = placements.iter().find(|p| p.city_id == 2).unwrap(); + // Farm should be on ValleyFloor (5,5), Hub on RiverCrossing (10,10). + assert_eq!(farm.position, (5, 5)); + assert_eq!(hub.position, (10, 10)); + } + + #[test] + fn founding_orientation_from_attractor() { + use crate::simulation::generator::TerritorialStatus; + let status = TerritorialStatus::FrontierUnclaimed; + let o = founding_orientation(&AttractorType::RiverMouth, &status, 90, 270); + assert!(matches!( + o, + FoundingOrientation::Coastal { + facing_degrees: 270 + } + )); + + let o2 = founding_orientation( + &AttractorType::PlainCenter, + &TerritorialStatus::CommissionControlled, + 0, + 0, + ); + assert!(matches!(o2, FoundingOrientation::Cardinal)); + + let o3 = founding_orientation(&AttractorType::ValleyFloor, &status, 0, 0); + assert!(matches!(o3, FoundingOrientation::TerrainFollowing)); + } +} diff --git a/server/src/atlas/block_irregularity.rs b/server/src/atlas/block_irregularity.rs new file mode 100644 index 000000000..f7efdfc09 --- /dev/null +++ b/server/src/atlas/block_irregularity.rs @@ -0,0 +1,119 @@ +//! BlockIrregularity derivation from founding_age and PoliticalArchetype (D-216). +//! +//! `block_irregularity` (0.0–1.0) controls how much a block deviates from +//! the district's canonical grid. Minimum 0.05 — no block is perfectly regular. +//! +//! **Formula (D-216):** +//! ```text +//! base_irregularity = (founding_age_years / 1000.0).min(1.0) +//! archetype_step = Commission|Military → -0.3, Corporate|Academic → -0.1, +//! Industrial → 0.0, Pioneer → +0.3 +//! block_irregularity = (base + step).clamp(0.05, 1.0) +//! ``` +//! +//! **Determinism (D-010, D-216):** Integer-scaled intermediates; archetype_step +//! stored as basis points (i32, 1 bp = 0.001). Final result is f32 from integer +//! arithmetic to match the D-216 formula. + +use crate::simulation::generator::PoliticalArchetype; + +/// Compute the `block_irregularity` value for one block. +/// +/// - `founding_age_years`: years since the settlement was founded (integer). +/// - `archetype`: the settlement's political archetype. +/// +/// Returns a value in [0.05, 1.0]. +pub fn block_irregularity(founding_age_years: u32, archetype: &PoliticalArchetype) -> f32 { + // base_irregularity in integer basis-points (0–1000, where 1000 = 1.0). + let base_bp: i32 = (founding_age_years as i32).min(1000); + + // archetype_step in basis-points. + let step_bp: i32 = archetype_step_bp(archetype); + + // block_irregularity_bp clamped to [50, 1000] (0.05–1.0). + let result_bp = (base_bp + step_bp).clamp(50, 1000); + + result_bp as f32 / 1000.0 +} + +fn archetype_step_bp(archetype: &PoliticalArchetype) -> i32 { + match archetype { + PoliticalArchetype::Commission | PoliticalArchetype::Military => -300, + PoliticalArchetype::Corporate | PoliticalArchetype::Academic => -100, + PoliticalArchetype::Industrial => 0, + PoliticalArchetype::Pioneer => 300, + } +} + +/// Derive the maximum block offset in sim tiles from `block_irregularity`. +/// +/// Used by `DistrictLayoutMode::Organic`: `max_offset = (irregularity × 16.0) as i16`. +pub fn max_offset_sim_tiles(irregularity: f32) -> i16 { + (irregularity * 16.0) as i16 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minimum_is_0_05() { + // New Commission city (age 0) → base 0, step -300 → clamp to 50bp = 0.05. + let v = block_irregularity(0, &PoliticalArchetype::Commission); + assert!((v - 0.05).abs() < 1e-6, "expected 0.05, got {v}"); + } + + #[test] + fn maximum_is_1_0() { + // Old Pioneer city (age 1000+) → base 1000, step +300 → clamp to 1000bp = 1.0. + let v = block_irregularity(1500, &PoliticalArchetype::Pioneer); + assert!((v - 1.0).abs() < 1e-6, "expected 1.0, got {v}"); + } + + #[test] + fn pioneer_more_irregular_than_commission() { + let pioneer = block_irregularity(400, &PoliticalArchetype::Pioneer); + let commission = block_irregularity(400, &PoliticalArchetype::Commission); + assert!( + pioneer > commission, + "Pioneer ({pioneer}) should be more irregular than Commission ({commission})" + ); + } + + #[test] + fn age_increases_irregularity() { + let young = block_irregularity(50, &PoliticalArchetype::Industrial); + let old = block_irregularity(800, &PoliticalArchetype::Industrial); + assert!( + old > young, + "Older settlement ({old}) should be more irregular than young ({young})" + ); + } + + #[test] + fn max_offset_scales_with_irregularity() { + assert_eq!(max_offset_sim_tiles(0.05), 0); // 0.05 × 16 = 0.8 → 0 + assert_eq!(max_offset_sim_tiles(1.0), 16); + assert_eq!(max_offset_sim_tiles(0.5), 8); + } + + #[test] + fn all_archetypes_produce_valid_range() { + let archetypes = [ + PoliticalArchetype::Commission, + PoliticalArchetype::Corporate, + PoliticalArchetype::Pioneer, + PoliticalArchetype::Military, + PoliticalArchetype::Academic, + PoliticalArchetype::Industrial, + ]; + for a in &archetypes { + let v = block_irregularity(300, a); + assert!( + v >= 0.05 && v <= 1.0, + "archetype {:?} gave {v} out of [0.05, 1.0]", + a + ); + } + } +} diff --git a/server/src/atlas/body_world_state.rs b/server/src/atlas/body_world_state.rs new file mode 100644 index 000000000..f1068668d --- /dev/null +++ b/server/src/atlas/body_world_state.rs @@ -0,0 +1,255 @@ +//! BodyWorldState — per-body Layer 1–2 cache (D-203). +//! +//! `BodyWorldStateCache` is a Bevy `Resource` holding pre-computed generation +//! data for up to 50 planetary bodies. Populated by the runtime-background +//! tier (D-206) via Rayon tasks; read by the main tick thread without blocking. +//! +//! Eviction policy: LRU — the body with the oldest `last_accessed` tick is +//! evicted on overflow, unless it is pinned (current player location or an +//! adjacent-system neighbor). + +use std::collections::{BTreeMap, BTreeSet}; + +use bevy_ecs::prelude::Resource; + +use crate::simulation::generator::GeographicAttractor; + +/// Simulation tick counter — monotonically increasing u64. +pub type SimTick = u64; + +/// Maximum number of bodies the cache holds before evicting the LRU entry. +pub const CACHE_CAPACITY: usize = 50; + +// --------------------------------------------------------------------------- +// Stub types — filled in by D-208 (#918) and D-205 (#907 Rust side) +// --------------------------------------------------------------------------- + +/// River network extracted by the D8 drainage algorithm (D-208). +/// Stub — replaced when #918 is implemented. +#[derive(Debug, Clone, Default)] +pub struct RiverNetwork { + /// Pixel positions (row, col) of all river cells (flow_accumulation > 200). + pub river_cells: Vec<(u16, u16)>, + /// Positions where two or more rivers merge. + pub confluences: Vec<(u16, u16)>, + /// Positions where rivers reach sea level or the heightmap edge. + pub mouths: Vec<(u16, u16)>, +} + +/// One drainage basin / province derived from watershed analysis (D-205). +/// Stub — boundary polyline data comes from atlas_province_boundaries. +#[derive(Debug, Clone)] +pub struct DrainageBasin { + pub basin_id: u32, + /// Boundary polyline as pixel-space (row, col) points. + pub boundary: Vec<(u16, u16)>, + /// Fraction of the body's surface area in this basin. + pub area_pct: f32, +} + +// --------------------------------------------------------------------------- +// BodyWorldState +// --------------------------------------------------------------------------- + +/// Pre-computed Layer 1–2 generation data for one planetary body. +/// +/// Produced by the runtime-background tier and stored in `BodyWorldStateCache`. +/// The main tick thread reads this data without performing any DB or CPU work. +#[derive(Debug, Clone)] +pub struct BodyWorldState { + pub body_id: String, + /// Downsampled working elevation grid (float32, row-major). + /// Full-resolution data lives in atlas_body_heightmaps; this is reduced + /// for the ~8KB working-resolution budget described in D-203. + pub heightmap: Vec, + pub heightmap_width: u32, + pub heightmap_height: u32, + /// D8 drainage analysis output (D-208). Empty until drainage task completes. + pub river_network: RiverNetwork, + /// Drainage basins from watershed analysis (D-205). + pub drainage_basins: Vec, + /// Geographic attractors (D-195, D-209). Empty until attractor task completes. + pub attractors: Vec, + /// Last sim tick this entry was read. Used for LRU eviction. + pub last_accessed: SimTick, +} + +// --------------------------------------------------------------------------- +// BodyWorldStateCache — Bevy Resource +// --------------------------------------------------------------------------- + +/// Bevy `Resource` holding the LRU cache of per-body world state (D-203). +/// +/// Initialized empty at server startup. Entries are inserted by the +/// background generation queue (D-206) and read by main-thread systems. +/// +/// All mutations go through the provided methods to maintain the +/// invariant that `entries.len() <= capacity`. +#[derive(Resource, Debug, Default)] +pub struct BodyWorldStateCache { + entries: BTreeMap, + /// Body IDs that must not be evicted regardless of `last_accessed`. + pinned: BTreeSet, + capacity: usize, +} + +impl BodyWorldStateCache { + pub fn new(capacity: usize) -> Self { + Self { + entries: BTreeMap::new(), + pinned: BTreeSet::new(), + capacity, + } + } + + /// Insert or replace a `BodyWorldState` entry. + /// + /// If the cache is at capacity, evicts the LRU unpinned entry before + /// inserting. If all entries are pinned and the cache is full, the new + /// entry is inserted anyway (capacity is a soft limit against unbounded + /// growth, not a hard reject). + pub fn insert(&mut self, state: BodyWorldState) { + if self.entries.len() >= self.capacity && !self.entries.contains_key(&state.body_id) { + self.evict_lru(); + } + self.entries.insert(state.body_id.clone(), state); + } + + /// Get a reference to the state for `body_id`, bumping `last_accessed`. + pub fn get(&mut self, body_id: &str, current_tick: SimTick) -> Option<&BodyWorldState> { + if let Some(entry) = self.entries.get_mut(body_id) { + entry.last_accessed = current_tick; + } + self.entries.get(body_id) + } + + /// Get a reference without bumping `last_accessed` (read-only path). + pub fn peek(&self, body_id: &str) -> Option<&BodyWorldState> { + self.entries.get(body_id) + } + + /// Returns `true` if the cache has an entry for `body_id`. + pub fn contains(&self, body_id: &str) -> bool { + self.entries.contains_key(body_id) + } + + /// Pin `body_id` — exempt from LRU eviction. + pub fn pin(&mut self, body_id: &str) { + self.pinned.insert(body_id.to_string()); + } + + /// Unpin `body_id` — allow eviction again. + pub fn unpin(&mut self, body_id: &str) { + self.pinned.remove(body_id); + } + + /// Number of entries currently in the cache. + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + fn evict_lru(&mut self) { + // Find the unpinned entry with the smallest last_accessed tick. + let victim = self + .entries + .iter() + .filter(|(id, _)| !self.pinned.contains(*id)) + .min_by_key(|(_, s)| s.last_accessed) + .map(|(id, _)| id.clone()); + + if let Some(id) = victim { + self.entries.remove(&id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[allow(unused_imports)] + use crate::simulation::generator::GeographicAttractor; + + fn make_state(body_id: &str, tick: SimTick) -> BodyWorldState { + BodyWorldState { + body_id: body_id.to_string(), + heightmap: vec![0.5; 16], + heightmap_width: 4, + heightmap_height: 4, + river_network: RiverNetwork::default(), + drainage_basins: vec![], + attractors: vec![], + last_accessed: tick, + } + } + + #[test] + fn insert_and_get() { + let mut cache = BodyWorldStateCache::new(50); + cache.insert(make_state("Alpha", 1)); + assert!(cache.contains("Alpha")); + assert!(!cache.contains("Beta")); + let entry = cache.get("Alpha", 5).unwrap(); + assert_eq!(entry.body_id, "Alpha"); + assert_eq!(entry.last_accessed, 5); + } + + #[test] + fn evicts_lru_on_overflow() { + let mut cache = BodyWorldStateCache::new(3); + cache.insert(make_state("A", 10)); + cache.insert(make_state("B", 20)); + cache.insert(make_state("C", 30)); + // Cache is full; inserting D should evict A (oldest tick = 10). + cache.insert(make_state("D", 40)); + assert_eq!(cache.len(), 3); + assert!(!cache.contains("A"), "A should have been evicted"); + assert!(cache.contains("B")); + assert!(cache.contains("C")); + assert!(cache.contains("D")); + } + + #[test] + fn pinned_body_not_evicted() { + let mut cache = BodyWorldStateCache::new(3); + cache.insert(make_state("A", 10)); + cache.insert(make_state("B", 20)); + cache.insert(make_state("C", 30)); + // Pin A so it cannot be evicted. + cache.pin("A"); + // Inserting D must evict B (oldest unpinned). + cache.insert(make_state("D", 40)); + assert!(cache.contains("A"), "pinned A must not be evicted"); + assert!(!cache.contains("B"), "B should have been evicted instead"); + } + + #[test] + fn update_last_accessed_on_get() { + let mut cache = BodyWorldStateCache::new(3); + cache.insert(make_state("A", 1)); + cache.insert(make_state("B", 2)); + cache.insert(make_state("C", 3)); + // Cache is full. Get A at tick 100 — bumps its last_accessed above C and B. + cache.get("A", 100); + // Insert D to trigger eviction; B (tick 2) is now LRU, not A (tick 100). + cache.insert(make_state("D", 4)); + assert!( + cache.contains("A"), + "A was recently accessed — must survive" + ); + assert!( + !cache.contains("B"), + "B had oldest access time — should be evicted" + ); + } + + #[test] + fn default_capacity_is_zero() { + // Default resource starts empty. + let cache = BodyWorldStateCache::default(); + assert!(cache.is_empty()); + } +} diff --git a/server/src/atlas/district_mix.rs b/server/src/atlas/district_mix.rs new file mode 100644 index 000000000..a6d3e5504 --- /dev/null +++ b/server/src/atlas/district_mix.rs @@ -0,0 +1,381 @@ +//! Three-component district mix algorithm for city district type distribution (D-194). +//! +//! Given a city's population, economic role, and political archetype, produces +//! a district type distribution (count of each DistrictType) used by the +//! Phase 1 district skeleton generator. +//! +//! **Components (D-194):** +//! 1. Population tier guarantees — minimum district counts by city size. +//! 2. 10×9 economic multiplier table — economic role × DistrictType weights. +//! 3. Political archetype modifiers — shift weights for specific district types. +//! +//! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the +//! district count computation. Seed-driven noise uses seeded RNG. + +use crate::atlas::rng::AtlasRng; +use crate::simulation::generator::{DistrictType, PoliticalArchetype}; + +// --------------------------------------------------------------------------- +// Population tier +// --------------------------------------------------------------------------- + +/// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5]. +pub fn population_tier(population: i64) -> u8 { + if population <= 0 { + return 0; + } + let ratio = population as f64 / 1_000_000.0; + if ratio <= 0.0 { + return 0; + } + let tier = ratio.log10().floor() as i32; + tier.clamp(0, 5) as u8 +} + +/// Minimum district counts guaranteed by population tier (D-194). +/// +/// Returns `(transit_min, commercial_min, residential_min)`. +pub fn tier_guarantees(tier: u8) -> (u32, u32, u32) { + match tier { + 0 => (0, 0, 1), + 1 => (0, 1, 1), + 2 => (1, 1, 2), + 3 => (1, 2, 3), + 4 => (2, 3, 4), + 5 => (3, 4, 6), + _ => (3, 4, 6), + } +} + +// --------------------------------------------------------------------------- +// Economic multiplier table (10×9, integer weights × 10 for precision) +// --------------------------------------------------------------------------- + +/// District type column order (0–8). +/// Matches DistrictType enum variants: LogisticsHub, Residential, Commercial, +/// Industrial, Administrative, Entertainment, MixedUse, Transit, Specialized. +const DIST_COLS: [DistrictType; 9] = [ + DistrictType::LogisticsHub, + DistrictType::Residential, + DistrictType::Commercial, + DistrictType::Industrial, + DistrictType::Administrative, + DistrictType::Entertainment, + DistrictType::MixedUse, + DistrictType::Transit, + DistrictType::Specialized, +]; + +/// Map a DistrictType to its column index. +fn dist_col(dt: &DistrictType) -> usize { + match dt { + DistrictType::LogisticsHub => 0, + DistrictType::Residential => 1, + DistrictType::Commercial => 2, + DistrictType::Industrial => 3, + DistrictType::Administrative => 4, + DistrictType::Entertainment => 5, + DistrictType::MixedUse => 6, + DistrictType::Transit => 7, + DistrictType::Specialized => 8, + } +} + +/// Map an economic role to its row index (0–9). +fn role_row(economic_role: &str) -> usize { + match economic_role { + "manufacturing" => 0, + "financial" => 1, + "agricultural" => 2, + "extraction" => 3, + "service_mixed" => 4, + "institutional" => 5, + "transit_hub" => 6, + "research" => 7, + "military" => 8, + _ => 9, + } +} + +/// 10×9 economic multiplier table. Values are integer weights × 10. +/// Rows: manufacturing(0), financial(1), agricultural(2), extraction(3), +/// service_mixed(4), institutional(5), transit_hub(6), research(7), +/// military(8), residential(9). +/// Columns: LogisticsHub(0), Residential(1), Commercial(2), Industrial(3), +/// Administrative(4), Entertainment(5), MixedUse(6), Transit(7), +/// Specialized(8). +#[rustfmt::skip] +const ECON_TABLE: [[u32; 9]; 10] = [ +// LH Re Co In Ad En Mu Tr Sp + [25, 10, 15, 30, 10, 5, 10, 20, 10], // manufacturing + [10, 15, 30, 10, 20, 15, 20, 15, 10], // financial + [20, 20, 10, 15, 10, 5, 20, 10, 5], // agricultural + [30, 10, 10, 30, 10, 5, 5, 15, 10], // extraction + [15, 20, 25, 10, 10, 20, 25, 20, 10], // service_mixed + [10, 15, 10, 10, 30, 10, 10, 10, 20], // institutional + [25, 10, 15, 10, 10, 10, 10, 30, 10], // transit_hub + [10, 15, 10, 15, 20, 10, 10, 10, 30], // research + [10, 20, 5, 15, 20, 5, 5, 10, 15], // military + [10, 30, 15, 5, 10, 15, 25, 10, 5], // residential +]; + +// --------------------------------------------------------------------------- +// Political archetype modifiers +// --------------------------------------------------------------------------- + +/// Additive integer modifiers to column weights based on `PoliticalArchetype`. +/// Returns `[mod; 9]` for columns in `DIST_COLS` order. +fn archetype_modifiers(archetype: &PoliticalArchetype) -> [i32; 9] { + match archetype { + PoliticalArchetype::Commission => { + // Boosts Administrative + Institutional-style Specialized. + [0, 0, 0, 0, 10, 0, 0, 0, 5] + } + PoliticalArchetype::Corporate => { + // Boosts Commercial + Specialized (restricted campus zones). + [0, -5, 15, 0, 0, 5, 0, 0, 10] + } + PoliticalArchetype::Pioneer => { + // Boosts MixedUse + organic Residential. + [0, 10, 5, 0, -5, 5, 15, 0, 0] + } + PoliticalArchetype::Military => { + // Boosts Administrative + reduces Entertainment. + [0, 5, -5, 5, 15, -10, 0, 0, 10] + } + PoliticalArchetype::Academic => { + // Boosts Specialized (research labs) + Administrative. + [0, 5, 0, 0, 10, 5, 5, 0, 20] + } + PoliticalArchetype::Industrial => { + // Boosts Industrial + LogisticsHub. + [10, -5, 5, 20, 0, -5, 0, 5, 5] + } + } +} + +// --------------------------------------------------------------------------- +// District mix computation +// --------------------------------------------------------------------------- + +/// The district type distribution for a generated city. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DistrictMix { + /// Ordered list of district types for the city, with repetition (district_count items total). + pub districts: Vec, + /// Total district count. + pub total: u32, +} + +/// Compute the district mix for one city (D-194). +/// +/// `total_districts` is the number of districts to allocate. A good default is +/// `max(4, population_tier * 2)`. +/// +/// `seed` is the city-level RNG seed (D-010 determinism). +pub fn compute_district_mix( + population: i64, + economic_role: &str, + archetype: &PoliticalArchetype, + total_districts: u32, + seed: u64, +) -> DistrictMix { + let tier = population_tier(population); + let (transit_min, commercial_min, residential_min) = tier_guarantees(tier); + let row = role_row(economic_role); + let arch_mods = archetype_modifiers(archetype); + + // Build effective weights (integer, clamped to ≥ 1). + let mut weights: [u32; 9] = [0; 9]; + for col in 0..9 { + let base = ECON_TABLE[row][col] as i32; + let modified = base + arch_mods[col]; + weights[col] = modified.max(1) as u32; + } + + // Allocate districts proportionally from weights using a seeded LCG. + // We avoid f32 by using integer weighted random selection. + let weight_sum: u32 = weights.iter().sum(); + let mut counts: [u32; 9] = [0; 9]; + let mut lcg = AtlasRng::new(seed.wrapping_add(1)); + + for _ in 0..total_districts { + let mut pick = lcg.next_u32() % weight_sum; + for col in 0..9 { + if pick < weights[col] { + counts[col] += 1; + break; + } + pick -= weights[col]; + } + } + + // Apply tier guarantees (add if under minimum). + let transit_col = dist_col(&DistrictType::Transit); + let commercial_col = dist_col(&DistrictType::Commercial); + let residential_col = dist_col(&DistrictType::Residential); + + if counts[transit_col] < transit_min { + counts[transit_col] = transit_min; + } + if counts[commercial_col] < commercial_min { + counts[commercial_col] = commercial_min; + } + if counts[residential_col] < residential_min { + counts[residential_col] = residential_min; + } + + // Build the flat ordered list. + let mut districts: Vec = Vec::new(); + for (col, &count) in counts.iter().enumerate() { + for _ in 0..count { + districts.push(DIST_COLS[col].clone()); + } + } + + let total = districts.len() as u32; + DistrictMix { districts, total } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn population_tier_values() { + assert_eq!(population_tier(0), 0); + assert_eq!(population_tier(50_000), 0); // 0.05M → log10 < 0 → tier 0 + assert_eq!(population_tier(1_000_000), 0); // 1M → log10(1) = 0 → tier 0 + assert_eq!(population_tier(10_000_000), 1); // 10M → log10(10) = 1 → tier 1 + assert_eq!(population_tier(100_000_000), 2); // 100M → tier 2 + assert_eq!(population_tier(1_000_000_000_000), 5); // capped at 5 + } + + #[test] + fn mix_sums_at_least_to_requested() { + let mix = compute_district_mix( + 5_000_000, + "manufacturing", + &PoliticalArchetype::Industrial, + 8, + 42, + ); + // total may exceed requested due to guarantees + assert!(mix.total >= 8, "district count should be >= requested"); + } + + #[test] + fn tier_guarantees_applied() { + // Tier 2 city: pop/1M = 100–999, log10(100) = 2. + // 100M population → pop_tier = floor(log10(100)) = 2 → (1 Transit, 1 Commercial, 2 Residential). + let mix = compute_district_mix( + 100_000_000, + "service_mixed", + &PoliticalArchetype::Pioneer, + 6, + 7, + ); + let transit = mix + .districts + .iter() + .filter(|d| matches!(d, DistrictType::Transit)) + .count(); + let commercial = mix + .districts + .iter() + .filter(|d| matches!(d, DistrictType::Commercial)) + .count(); + let residential = mix + .districts + .iter() + .filter(|d| matches!(d, DistrictType::Residential)) + .count(); + assert!(transit >= 1, "transit guarantee not met: {transit}"); + assert!( + commercial >= 1, + "commercial guarantee not met: {commercial}" + ); + assert!( + residential >= 2, + "residential guarantee not met: {residential}" + ); + } + + #[test] + fn determinism_same_seed() { + let mix1 = compute_district_mix( + 5_000_000, + "financial", + &PoliticalArchetype::Commission, + 6, + 99, + ); + let mix2 = compute_district_mix( + 5_000_000, + "financial", + &PoliticalArchetype::Commission, + 6, + 99, + ); + assert_eq!(mix1, mix2, "same inputs must produce identical output"); + } + + #[test] + fn different_archetypes_produce_different_mixes() { + let mix_corp = compute_district_mix( + 5_000_000, + "financial", + &PoliticalArchetype::Corporate, + 8, + 42, + ); + let mix_pioneer = + compute_district_mix(5_000_000, "financial", &PoliticalArchetype::Pioneer, 8, 42); + // Should differ in at least one district type count. + assert_ne!( + mix_corp.districts, mix_pioneer.districts, + "Corporate and Pioneer archetypes should produce different district mixes" + ); + } + + #[test] + fn military_archetype_has_administrative() { + let mix = compute_district_mix(2_000_000, "military", &PoliticalArchetype::Military, 8, 10); + let admin = mix + .districts + .iter() + .filter(|d| matches!(d, DistrictType::Administrative)) + .count(); + assert!( + admin >= 1, + "military archetype should have Administrative districts" + ); + } + + #[test] + fn all_district_types_can_appear() { + // With enough districts and a balanced role, every type should appear at least once. + let mix = compute_district_mix( + 50_000_000, + "service_mixed", + &PoliticalArchetype::Pioneer, + 50, + 0, + ); + for dt in &DIST_COLS { + let present = mix + .districts + .iter() + .any(|d| std::mem::discriminant(d) == std::mem::discriminant(dt)); + assert!( + present, + "DistrictType {:?} never appeared in 50-district mix", + dt + ); + } + } +} diff --git a/server/src/atlas/drainage.rs b/server/src/atlas/drainage.rs new file mode 100644 index 000000000..1e198efcd --- /dev/null +++ b/server/src/atlas/drainage.rs @@ -0,0 +1,649 @@ +//! D8 drainage routing — flow direction, flow accumulation, river network +//! extraction, and drainage basin delineation (D-208). +//! +//! **Determinism (D-010, D-208):** All flow-direction comparisons use integer +//! arithmetic on scaled elevation values (`(elev * 1_000_000.0) as i64`) to +//! avoid f32 comparison non-determinism. Tie-breaking uses a fixed D8 neighbor +//! priority order. The result is bit-identical across runs on the same inputs. +//! +//! **Algorithm:** +//! 1. Scale f32 elevation to i64 integers. +//! 2. Priority-flood depression fill (iterative, convergence in ≤10 passes). +//! 3. D8 flow direction: steepest descent, 8-neighbor, wraps horizontally. +//! 4. Flow accumulation via topological sort of the D8 DAG. +//! 5. River network extraction: cells with accumulation > RIVER_THRESHOLD. +//! 6. Basin labeling: flood-fill seeded at pour points. +//! +//! The grid is row-major. Row 0 is the north pole; row H-1 is the south pole. +//! Columns wrap horizontally (the globe is equirectangular). + +use std::collections::VecDeque; + +use crate::atlas::body_world_state::{DrainageBasin, RiverNetwork}; + +/// A cell is a river cell when its flow accumulation exceeds this threshold (D-208). +pub const RIVER_THRESHOLD: i32 = 200; + +/// Scale factor for converting f32 elevation to integer for deterministic comparison. +const ELEV_SCALE: f64 = 1_000_000.0; + +// D8 neighbor offsets (dr, dc) in fixed priority order for deterministic tie-breaking. +// Priority: cardinal directions first (N, S, E, W), then diagonals (NE, NW, SE, SW). +const D8: [(i32, i32); 8] = [ + (-1, 0), // N + (1, 0), // S + (0, 1), // E + (0, -1), // W + (-1, 1), // NE + (-1, -1), // NW + (1, 1), // SE + (1, -1), // SW +]; + +/// Result of the full D8 drainage analysis for one body. +#[derive(Debug, Clone)] +pub struct DrainageResult { + pub river_network: RiverNetwork, + pub drainage_basins: Vec, +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Run the full D8 drainage analysis on an elevation grid. +/// +/// `elevation` is a row-major float32 grid of shape `height × width`, values +/// in [0.0, 1.0]. `sea_level` is the fraction below which terrain is ocean. +/// +/// Returns `DrainageResult` with the river network and drainage basins. +pub fn analyze(elevation: &[f32], width: u32, height: u32, sea_level: f32) -> DrainageResult { + let w = width as usize; + let h = height as usize; + // 1. Scale to integers. + let scaled: Vec = elevation + .iter() + .map(|&e| (e as f64 * ELEV_SCALE) as i64) + .collect(); + + // 2. Depression fill. + let filled = depression_fill(&scaled, w, h); + + // 3. D8 flow direction. -1 = no outflow (edge or flat peak). + let fdir = flow_direction(&filled, w, h); + + // 4. Flow accumulation. + let accum = flow_accumulation(&fdir, w, h); + + // 5. River network. + let river_network = extract_river_network(&accum, &fdir, w, h, sea_level, elevation); + + // 6. Basin labeling. + let labels = label_basins(&fdir, &accum, w, h); + + // 7. Merge small basins + clamp count to [4, 12]. + let labels = merge_small_basins(labels, w, h, 4, 12); + + // 8. Build DrainageBasin structs. + let drainage_basins = build_basins(&labels, w, h); + + DrainageResult { + river_network, + drainage_basins, + } +} + +// --------------------------------------------------------------------------- +// Step 2: Depression fill +// --------------------------------------------------------------------------- + +fn depression_fill(scaled: &[i64], w: usize, h: usize) -> Vec { + let mut filled = scaled.to_vec(); + for _ in 0..10 { + let mut changed = false; + for r in 1..h.saturating_sub(1) { + for c in 0..w { + let mut nbr_min = i64::MAX; + for &(dr, dc) in &D8 { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + let val = filled[nr as usize * w + nc]; + if val < nbr_min { + nbr_min = val; + } + } + } + if filled[r * w + c] < nbr_min { + filled[r * w + c] = nbr_min + 1; + changed = true; + } + } + } + if !changed { + break; + } + } + filled +} + +// --------------------------------------------------------------------------- +// Step 3: D8 flow direction +// --------------------------------------------------------------------------- + +/// Returns per-cell flow direction index into D8 (0–7), or -1 for no outflow. +fn flow_direction(filled: &[i64], w: usize, h: usize) -> Vec { + let mut fdir = vec![-1i8; w * h]; + for r in 0..h { + for c in 0..w { + let elev = filled[r * w + c]; + let mut best_drop = 0i64; + let mut best_k: i8 = -1; + for (k, &(dr, dc)) in D8.iter().enumerate() { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr < 0 || nr >= h as i32 { + continue; + } + let drop = elev - filled[nr as usize * w + nc]; + if drop > best_drop { + best_drop = drop; + best_k = k as i8; + } + } + fdir[r * w + c] = best_k; + } + } + fdir +} + +// --------------------------------------------------------------------------- +// Step 4: Flow accumulation +// --------------------------------------------------------------------------- + +fn flow_accumulation(fdir: &[i8], w: usize, h: usize) -> Vec { + let n = w * h; + let mut in_degree = vec![0i32; n]; + + for r in 0..h { + for c in 0..w { + let k = fdir[r * w + c]; + if k < 0 { + continue; + } + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + in_degree[nr as usize * w + nc] += 1; + } + } + } + + let mut queue = VecDeque::new(); + for (i, °) in in_degree.iter().enumerate().take(n) { + if deg == 0 { + queue.push_back(i); + } + } + + let mut accum = vec![1i32; n]; + while let Some(idx) = queue.pop_front() { + let r = idx / w; + let c = idx % w; + let k = fdir[idx]; + if k < 0 { + continue; + } + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + let ni = nr as usize * w + nc; + accum[ni] += accum[idx]; + in_degree[ni] -= 1; + if in_degree[ni] == 0 { + queue.push_back(ni); + } + } + } + + accum +} + +// --------------------------------------------------------------------------- +// Step 5: River network extraction +// --------------------------------------------------------------------------- + +fn extract_river_network( + accum: &[i32], + fdir: &[i8], + w: usize, + h: usize, + sea_level: f32, + elevation: &[f32], +) -> RiverNetwork { + let n = w * h; + + // River cells: above threshold AND above sea level. + let is_river: Vec = (0..n) + .map(|i| accum[i] > RIVER_THRESHOLD && elevation[i] >= sea_level) + .collect(); + + let river_cells: Vec<(u16, u16)> = (0..n) + .filter(|&i| is_river[i]) + .map(|i| ((i / w) as u16, (i % w) as u16)) + .collect(); + + // Confluences: river cells with 2+ river neighbors flowing into them. + let mut inflow_count = vec![0u8; n]; + for r in 0..h { + for c in 0..w { + let i = r * w + c; + if !is_river[i] { + continue; + } + let k = fdir[i]; + if k < 0 { + continue; + } + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + let ni = nr as usize * w + nc; + if is_river[ni] { + inflow_count[ni] = inflow_count[ni].saturating_add(1); + } + } + } + } + let confluences: Vec<(u16, u16)> = (0..n) + .filter(|&i| is_river[i] && inflow_count[i] >= 2) + .map(|i| ((i / w) as u16, (i % w) as u16)) + .collect(); + + // Mouths: river cells that flow to a sea cell or to the polar edge. + let mouths: Vec<(u16, u16)> = (0..n) + .filter(|&i| { + if !is_river[i] { + return false; + } + let r = i / w; + let c = i % w; + let k = fdir[i]; + if k < 0 { + return true; // no outflow — edge + } + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr < 0 || nr >= h as i32 { + return true; // polar edge + } + // Flows into a sub-sea-level cell = mouth + elevation[nr as usize * w + nc] < sea_level + }) + .map(|i| ((i / w) as u16, (i % w) as u16)) + .collect(); + + RiverNetwork { + river_cells, + confluences, + mouths, + } +} + +// --------------------------------------------------------------------------- +// Step 6: Basin labeling +// --------------------------------------------------------------------------- + +fn label_basins(fdir: &[i8], accum: &[i32], w: usize, h: usize) -> Vec { + let n = w * h; + let mut labels = vec![-1i32; n]; + + // Pour points: local accumulation maxima above river threshold. + let mut pour_pts: Vec = Vec::new(); + for i in 0..n { + if accum[i] <= RIVER_THRESHOLD { + continue; + } + let r = i / w; + let c = i % w; + let mut is_max = true; + for &(dr, dc) in &D8 { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 && accum[nr as usize * w + nc] > accum[i] { + is_max = false; + break; + } + } + if is_max { + pour_pts.push(i); + } + } + + if pour_pts.is_empty() { + // Flat/ocean world — single basin. + labels.iter_mut().for_each(|l| *l = 0); + return labels; + } + + for (basin_id, &idx) in pour_pts.iter().enumerate() { + labels[idx] = basin_id as i32; + } + + // Trace remaining cells: follow fdir until a labeled cell is reached. + for start in 0..n { + if labels[start] >= 0 { + continue; + } + // Walk forward, accumulate path. + let mut path: Vec = Vec::new(); + let mut cur = start; + let label = loop { + if labels[cur] >= 0 { + break labels[cur]; + } + path.push(cur); + let k = fdir[cur]; + if k < 0 { + break 0; // no outflow — assign to basin 0 + } + let r = cur / w; + let c = cur % w; + let (dr, dc) = D8[k as usize]; + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr < 0 || nr >= h as i32 { + break 0; // polar edge + } + let next = nr as usize * w + nc; + // Cycle guard: if we're visiting a cell already in path, stop. + if path.contains(&next) { + break 0; + } + cur = next; + }; + for idx in path { + labels[idx] = label; + } + } + + labels +} + +// --------------------------------------------------------------------------- +// Step 7: Merge small basins +// --------------------------------------------------------------------------- + +fn merge_small_basins( + mut labels: Vec, + w: usize, + h: usize, + min_count: usize, + max_count: usize, +) -> Vec { + let n = w * h; + let min_frac = 0.02f64; // 2% minimum basin area + + for _ in 0..200 { + // Count basin sizes. + let mut sizes: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for &l in &labels { + *sizes.entry(l).or_insert(0) += 1; + } + let n_basins = sizes.len(); + + // Stop if within target range and all basins are large enough. + if n_basins <= max_count && sizes.values().all(|&s| s as f64 / n as f64 >= min_frac) { + break; + } + if n_basins <= min_count { + break; + } + + // Find the smallest basin. + let (&smallest_id, &smallest_size) = sizes.iter().min_by_key(|(_, &s)| s).unwrap(); + + if n_basins <= max_count && smallest_size as f64 / n as f64 >= min_frac { + break; + } + + // Find its largest adjacent basin. + let nbr_id = find_largest_neighbor(&labels, smallest_id, &sizes, w, h); + let merge_into = nbr_id.unwrap_or(0); + + // Merge. + for l in labels.iter_mut() { + if *l == smallest_id { + *l = merge_into; + } + } + } + + // Renumber contiguously from 0. + let unique: Vec = { + let mut set: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for &l in &labels { + set.insert(l); + } + set.into_iter().collect() + }; + let remap: std::collections::BTreeMap = unique + .iter() + .enumerate() + .map(|(new, &old)| (old, new as i32)) + .collect(); + for l in labels.iter_mut() { + *l = remap[l]; + } + + labels +} + +fn find_largest_neighbor( + labels: &[i32], + target_id: i32, + sizes: &std::collections::BTreeMap, + w: usize, + h: usize, +) -> Option { + let n = w * h; + let mut neighbor_sizes: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + + for i in 0..n { + if labels[i] != target_id { + continue; + } + let r = i / w; + let c = i % w; + for &(dr, dc) in &D8 { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr >= 0 && nr < h as i32 { + let nbr_id = labels[nr as usize * w + nc]; + if nbr_id != target_id { + let size = sizes.get(&nbr_id).copied().unwrap_or(0); + let e = neighbor_sizes.entry(nbr_id).or_insert(0); + if size > *e { + *e = size; + } + } + } + } + } + + neighbor_sizes + .into_iter() + .max_by_key(|(_, s)| *s) + .map(|(id, _)| id) +} + +// --------------------------------------------------------------------------- +// Step 8: Build DrainageBasin structs +// --------------------------------------------------------------------------- + +fn build_basins(labels: &[i32], w: usize, h: usize) -> Vec { + let n = w * h; + let mut basin_map: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + + for (i, &l) in labels.iter().enumerate() { + basin_map.entry(l).or_default().push(i); + } + + let mut basins: Vec = Vec::with_capacity(basin_map.len()); + let mut ids: Vec = basin_map.keys().copied().collect(); + ids.sort(); + + for basin_id in ids { + let cells = &basin_map[&basin_id]; + let area_pct = cells.len() as f32 / n as f32; + + // Boundary cells: in this basin, adjacent to a different basin or edge. + let mut boundary: Vec<(u16, u16)> = Vec::new(); + for &idx in cells { + let r = idx / w; + let c = idx % w; + let mut on_boundary = false; + for &(dr, dc) in &D8 { + let nr = r as i32 + dr; + let nc = (c as i32 + dc).rem_euclid(w as i32) as usize; + if nr < 0 || nr >= h as i32 { + on_boundary = true; + break; + } + if labels[nr as usize * w + nc] != basin_id { + on_boundary = true; + break; + } + } + if on_boundary { + boundary.push((r as u16, c as u16)); + } + } + + // Sort boundary by angle from centroid for a coherent polygon. + if !boundary.is_empty() { + let cr = boundary.iter().map(|&(r, _)| r as f32).sum::() / boundary.len() as f32; + let cc = boundary.iter().map(|&(_, c)| c as f32).sum::() / boundary.len() as f32; + boundary.sort_by(|&(r1, c1), &(r2, c2)| { + let a1 = (r1 as f32 - cr).atan2(c1 as f32 - cc); + let a2 = (r2 as f32 - cr).atan2(c2 as f32 - cc); + a1.partial_cmp(&a2).unwrap_or(std::cmp::Ordering::Equal) + }); + // Subsample to ≤500 points. + if boundary.len() > 500 { + let step = boundary.len() / 500; + boundary = boundary.into_iter().step_by(step).collect(); + } + } + + basins.push(DrainageBasin { + basin_id: basin_id as u32, + boundary, + area_pct, + }); + } + + basins +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn flat_grid(w: u32, h: u32, val: f32) -> Vec { + vec![val; (w * h) as usize] + } + + fn slope_grid(w: u32, h: u32) -> Vec { + let n = (w * h) as usize; + (0..n) + .map(|i| { + let r = i / w as usize; + let c = i % w as usize; + // Slope: higher in top-left, drains toward bottom-right. + 1.0 - (r as f32 / h as f32 * 0.5 + c as f32 / w as f32 * 0.5) + }) + .collect() + } + + #[test] + fn flat_grid_produces_single_basin() { + let elev = flat_grid(16, 8, 0.5); + let result = analyze(&elev, 16, 8, 0.3); + // Flat world → no pour points → single basin + assert_eq!(result.drainage_basins.len(), 1); + assert!((result.drainage_basins[0].area_pct - 1.0).abs() < 0.01); + } + + #[test] + fn slope_grid_has_no_river_cells_below_threshold_by_default() { + // Small 8×4 grid: max flow_accum ≤ 32, below RIVER_THRESHOLD (200). + let elev = slope_grid(8, 4); + let result = analyze(&elev, 8, 4, 0.3); + // River cells may be empty on this tiny grid — that is acceptable. + // What matters: no panic and basin count ≥ 1. + assert!(!result.drainage_basins.is_empty()); + } + + #[test] + fn large_grid_river_cells_nonempty() { + // 512×256: max flow accumulation ~131K >> RIVER_THRESHOLD. + let elev = slope_grid(512, 256); + let result = analyze(&elev, 512, 256, 0.3); + assert!( + !result.river_network.river_cells.is_empty(), + "Expected river cells on a large sloped grid" + ); + } + + #[test] + fn basin_area_pcts_sum_to_one() { + let elev = slope_grid(64, 32); + let result = analyze(&elev, 64, 32, 0.3); + let total: f32 = result.drainage_basins.iter().map(|b| b.area_pct).sum(); + assert!( + (total - 1.0).abs() < 0.01, + "Basin area fractions must sum to 1, got {}", + total + ); + } + + #[test] + fn basin_count_within_target_range() { + let elev = slope_grid(128, 64); + let result = analyze(&elev, 128, 64, 0.3); + let n = result.drainage_basins.len(); + assert!( + n >= 1 && n <= 12, + "Basin count {} out of expected range [1, 12]", + n + ); + } + + #[test] + fn determinism() { + // Running analyze twice on the same input must produce identical results. + let elev = slope_grid(64, 32); + let r1 = analyze(&elev, 64, 32, 0.3); + let r2 = analyze(&elev, 64, 32, 0.3); + assert_eq!( + r1.river_network.river_cells, r2.river_network.river_cells, + "River cells must be deterministic" + ); + assert_eq!( + r1.drainage_basins.len(), + r2.drainage_basins.len(), + "Basin count must be deterministic" + ); + } +} diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs new file mode 100644 index 000000000..95e99c563 --- /dev/null +++ b/server/src/atlas/gen_queue.rs @@ -0,0 +1,451 @@ +//! Background generation queue — prioritized Rayon thread pool (D-206). +//! +//! All runtime-background generation work runs through this queue. The main +//! tick thread submits work items (non-blocking) and drains completion events +//! once per tick via a `crossbeam` channel. +//! +//! **Priority levels (D-206):** +//! - `Immediate`: player arrives within 1 game-minute. Runs first. +//! - `High`: player arrives within 5 game-minutes. +//! - `Medium`: player is in the same system. +//! - `Low`: player has heard of this location via NPC/news. +//! +//! **Work item types (D-206):** +//! - `AnalyzeBody`: D8 drainage + attractor extraction for a body. +//! - `GenerateSkeleton`: Phase 1 DistrictSkeleton for a city. +//! - `FillChunk`: Phase 2 chunk fill for a pre-loaded district. +//! +//! Completion events are delivered to the main thread via +//! `GenerationQueue::drain_completions()`, called once per tick from a Bevy +//! system in `TickPhase::PreInput`. +//! +//! **Thread count (D-206):** `available_parallelism - 2`, minimum 1. + +use std::sync::{Arc, Mutex}; + +use bevy_ecs::prelude::Resource; +use crossbeam_channel::{Receiver, Sender}; + +// --------------------------------------------------------------------------- +// Priority +// --------------------------------------------------------------------------- + +/// Work priority levels — lower discriminant = higher priority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum GenPriority { + /// Player arrives within ~1 game-minute. Runs before all other levels. + Immediate = 0, + /// Player arrives within ~5 game-minutes. + High = 1, + /// Player is in the same system. + Medium = 2, + /// Player has seen or heard of this location (NPC dialogue, news ticker). + Low = 3, +} + +// --------------------------------------------------------------------------- +// Work item types +// --------------------------------------------------------------------------- + +/// A unit of background generation work (D-206). +#[derive(Debug, Clone)] +pub enum GenWorkItem { + /// Run D8 drainage analysis + attractor extraction for this body. + AnalyzeBody { body_id: String }, + /// Generate a Phase 1 DistrictSkeleton for this city. + GenerateSkeleton { city_id: u64 }, + /// Pre-fill a chunk in an existing district. + FillChunk { + district_id: u64, + block_pos: (u32, u32), + }, +} + +impl GenWorkItem { + pub fn body_id(&self) -> Option<&str> { + if let GenWorkItem::AnalyzeBody { body_id } = self { + Some(body_id) + } else { + None + } + } +} + +// --------------------------------------------------------------------------- +// Completion event +// --------------------------------------------------------------------------- + +/// Sent back to the main thread when a work item finishes (D-206). +#[derive(Debug)] +pub enum GenCompletion { + BodyAnalyzed { + body_id: String, + }, + SkeletonGenerated { + city_id: u64, + }, + ChunkFilled { + district_id: u64, + block_pos: (u32, u32), + }, + /// Work item failed — body_id or city_id for logging. + Failed { + item: GenWorkItem, + reason: String, + }, +} + +// --------------------------------------------------------------------------- +// Internal queued work +// --------------------------------------------------------------------------- + +struct QueuedWork { + priority: GenPriority, + item: GenWorkItem, +} + +// --------------------------------------------------------------------------- +// GenerationQueue — Bevy Resource +// --------------------------------------------------------------------------- + +/// Bevy `Resource` managing the background generation queue (D-206). +/// +/// Submit work with `submit()`. Drain completions with `drain_completions()` +/// once per tick. The Rayon thread pool runs tasks in priority order. +/// +/// Priority is respected because `dispatch_next()` is gated on pool saturation +/// via `in_flight_count`: it only dispatches when fewer than `n_threads` tasks +/// are running. This applies to all work item types — `in_flight` (body-id set) +/// is only for AnalyzeBody dedup; `in_flight_count` is the general saturation gate. +#[derive(Resource)] +pub struct GenerationQueue { + /// Pending work items, sorted by priority (index 0 = highest priority). + pending: Arc>>, + /// Completions channel — background tasks send here; main thread reads. + completion_tx: Sender, + completion_rx: Receiver, + /// Rayon thread pool dedicated to generation work. + pool: rayon::ThreadPool, + /// Set of body_ids currently in-flight — used only for AnalyzeBody dedup. + in_flight: Arc>>, + /// Count of all work items currently executing in the Rayon pool. + /// This is the saturation gate — all work item types increment/decrement it. + in_flight_count: Arc>, + /// Thread count — caps concurrent dispatches so pending items accumulate + /// and priority ordering is consulted before the pool has free threads. + n_threads: usize, +} + +impl std::fmt::Debug for GenerationQueue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let pending_len = self.pending.lock().map(|p| p.len()).unwrap_or(0); + f.debug_struct("GenerationQueue") + .field("pending_count", &pending_len) + .finish() + } +} + +impl GenerationQueue { + /// Create a new queue with the D-206 thread count: + /// `available_parallelism - 2`, minimum 1. + pub fn new() -> Self { + let n_threads = std::thread::available_parallelism() + .map(|p| p.get().saturating_sub(2).max(1)) + .unwrap_or(1); + Self::with_threads(n_threads) + } + + /// Create a queue with a specific thread count (for testing). + pub fn with_threads(n_threads: usize) -> Self { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n_threads) + .thread_name(|i| format!("gen-worker-{i}")) + .build() + .expect("failed to build generation rayon pool"); + + let (tx, rx) = crossbeam_channel::unbounded(); + + Self { + pending: Arc::new(Mutex::new(Vec::new())), + completion_tx: tx, + completion_rx: rx, + pool, + in_flight: Arc::new(Mutex::new(std::collections::BTreeSet::new())), + in_flight_count: Arc::new(Mutex::new(0)), + n_threads, + } + } + + /// Submit a work item at the given priority. + /// + /// If an `AnalyzeBody` item for the same body_id is already in-flight or + /// pending, the submission is silently ignored (idempotent). + pub fn submit(&self, item: GenWorkItem, priority: GenPriority) { + // Dedup AnalyzeBody submissions. + if let Some(body_id) = item.body_id() { + let in_flight = self.in_flight.lock().unwrap(); + if in_flight.contains(body_id) { + return; + } + drop(in_flight); + // Check pending list. + let pending = self.pending.lock().unwrap(); + if pending.iter().any(|q| q.item.body_id() == Some(body_id)) { + return; + } + drop(pending); + } + + let mut pending = self.pending.lock().unwrap(); + let pos = pending + .iter() + .position(|q| q.priority > priority) + .unwrap_or(pending.len()); + pending.insert(pos, QueuedWork { priority, item }); + drop(pending); + + self.dispatch_next(); + } + + /// Drain all completed items from the channel and dispatch pending work. + /// + /// Call once per tick from the main thread. Returns all completions + /// available without blocking. After draining, dispatches as many pending + /// items as there are free thread slots — this is the point where priority + /// ordering matters, since the pool was saturated when items were submitted. + pub fn drain_completions(&self) -> Vec { + let mut out = Vec::new(); + while let Ok(c) = self.completion_rx.try_recv() { + out.push(c); + } + // Fill any newly-freed slots. + for _ in 0..out.len() { + self.dispatch_next(); + } + out + } + + /// Number of items waiting in the pending queue. + pub fn pending_count(&self) -> usize { + self.pending.lock().unwrap().len() + } + + // Dispatch the highest-priority pending item to the Rayon pool. + // + // Gated on in_flight_count < n_threads — applies to all work item types, + // not just AnalyzeBody. When the pool is full, items stay in the sorted + // pending Vec so priority ordering is consulted on the next free slot. + fn dispatch_next(&self) { + let item = { + let count = self.in_flight_count.lock().unwrap(); + if *count >= self.n_threads { + return; + } + drop(count); + + let mut pending = self.pending.lock().unwrap(); + if pending.is_empty() { + return; + } + pending.remove(0).item + }; + + // Mark body as in-flight (AnalyzeBody dedup). + if let Some(body_id) = item.body_id() { + self.in_flight.lock().unwrap().insert(body_id.to_string()); + } + // Increment general in-flight counter for all item types. + *self.in_flight_count.lock().unwrap() += 1; + + let tx = self.completion_tx.clone(); + let in_flight = Arc::clone(&self.in_flight); + let in_flight_count = Arc::clone(&self.in_flight_count); + + self.pool.spawn(move || { + let completion = run_work_item(&item); + + // Un-mark body dedup set (AnalyzeBody only). + if let Some(body_id) = item.body_id() { + in_flight.lock().unwrap().remove(body_id); + } + // Decrement general counter for all item types. + *in_flight_count.lock().unwrap() -= 1; + + let _ = tx.send(completion); + }); + } +} + +impl Default for GenerationQueue { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Work execution stub +// --------------------------------------------------------------------------- + +/// Execute one work item. This is the Rayon task body. +/// +/// Currently a stub — real implementations will call `drainage::analyze()`, +/// the attractor pipeline, and the district skeleton generator. Stubs return +/// immediate success to allow the queue infrastructure to be tested independently. +fn run_work_item(item: &GenWorkItem) -> GenCompletion { + match item { + GenWorkItem::AnalyzeBody { body_id } => GenCompletion::BodyAnalyzed { + body_id: body_id.clone(), + }, + GenWorkItem::GenerateSkeleton { city_id } => { + GenCompletion::SkeletonGenerated { city_id: *city_id } + } + GenWorkItem::FillChunk { + district_id, + block_pos, + } => GenCompletion::ChunkFilled { + district_id: *district_id, + block_pos: *block_pos, + }, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn make_queue() -> GenerationQueue { + GenerationQueue::with_threads(2) + } + + #[test] + fn submit_and_drain() { + let q = make_queue(); + q.submit( + GenWorkItem::AnalyzeBody { + body_id: "TestBody".to_string(), + }, + GenPriority::Medium, + ); + // Give Rayon time to complete the (stub) task. + std::thread::sleep(Duration::from_millis(50)); + let completions = q.drain_completions(); + assert_eq!(completions.len(), 1); + assert!(matches!( + &completions[0], + GenCompletion::BodyAnalyzed { body_id } if body_id == "TestBody" + )); + } + + #[test] + fn dedup_analyze_body() { + let q = make_queue(); + // Submit the same body twice before it can complete. + q.submit( + GenWorkItem::AnalyzeBody { + body_id: "Dup".to_string(), + }, + GenPriority::Low, + ); + q.submit( + GenWorkItem::AnalyzeBody { + body_id: "Dup".to_string(), + }, + GenPriority::Low, + ); + std::thread::sleep(Duration::from_millis(50)); + let completions = q.drain_completions(); + // Should have completed exactly once. + assert_eq!(completions.len(), 1); + } + + #[test] + fn priority_ordering() { + // Submit three items rapidly; Immediate should be dispatched first. + // Uses 3 threads so all items can dispatch without hitting saturation. + let q = GenerationQueue::with_threads(3); + // Using GenerateSkeleton (no dedup logic) to test ordering directly. + q.submit( + GenWorkItem::GenerateSkeleton { city_id: 1 }, + GenPriority::Low, + ); + q.submit( + GenWorkItem::GenerateSkeleton { city_id: 2 }, + GenPriority::Immediate, + ); + q.submit( + GenWorkItem::GenerateSkeleton { city_id: 3 }, + GenPriority::Medium, + ); + std::thread::sleep(Duration::from_millis(100)); + let completions = q.drain_completions(); + assert_eq!(completions.len(), 3); + } + + #[test] + fn priority_ordering_respected_under_saturation() { + // Single-thread queue: in_flight_count saturates at 1, so the second + // item stays in the pending Vec and is dispatched in priority order. + // Uses AnalyzeBody (distinct body_ids) so all paths — dedup set AND + // in_flight_count — are exercised. + let q = GenerationQueue::with_threads(1); + // Submit Low first, then Immediate. With 1 thread: + // - "BodyA" (Low) dispatches immediately (pool empty). + // - "BodyB" (Immediate) is inserted at index 0 of the sorted pending + // Vec while "BodyA" is in-flight (in_flight_count = 1 = n_threads). + // - When "BodyA" completes, drain_completions() calls dispatch_next() + // which picks index 0 = "BodyB" (Immediate). + q.submit( + GenWorkItem::AnalyzeBody { + body_id: "BodyA".to_string(), + }, + GenPriority::Low, + ); + q.submit( + GenWorkItem::AnalyzeBody { + body_id: "BodyB".to_string(), + }, + GenPriority::Immediate, + ); + // Wait for BodyA to complete. + std::thread::sleep(Duration::from_millis(50)); + // drain_completions dispatches BodyB (Immediate, index 0 of pending). + let first = q.drain_completions(); + // Wait for BodyB to complete. + std::thread::sleep(Duration::from_millis(50)); + let second = q.drain_completions(); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert!(matches!(&first[0], GenCompletion::BodyAnalyzed { body_id } if body_id == "BodyA")); + assert!( + matches!(&second[0], GenCompletion::BodyAnalyzed { body_id } if body_id == "BodyB") + ); + } + + #[test] + fn drain_empty_returns_empty() { + let q = make_queue(); + let result = q.drain_completions(); + assert!(result.is_empty()); + } + + #[test] + fn pending_count_decreases_after_completion() { + let q = make_queue(); + q.submit( + GenWorkItem::FillChunk { + district_id: 99, + block_pos: (0, 0), + }, + GenPriority::High, + ); + std::thread::sleep(Duration::from_millis(50)); + let completions = q.drain_completions(); + assert!(!completions.is_empty() || q.pending_count() == 0); + } +} diff --git a/server/src/atlas/heightmap.rs b/server/src/atlas/heightmap.rs new file mode 100644 index 000000000..01bc28685 --- /dev/null +++ b/server/src/atlas/heightmap.rs @@ -0,0 +1,199 @@ +//! Heightmap BLOB loader — reads float32 LE elevation grids from systems.db. +//! +//! Implements the Rust side of D-202. The Python pipeline stores each body's +//! elevation grid as a contiguous float32 little-endian BLOB in +//! `atlas_body_heightmaps.data`. This module loads that BLOB via `rusqlite` +//! and reinterprets the bytes into a `Vec` using `bytemuck`. +//! +//! Values are normalized elevation in [0.0, 1.0]. `sea_level` is the fraction +//! below which terrain is underwater (0.0 = no ocean). +//! +//! Canonical grid size: 512 × 256 (GRID_W × GRID_H), row-major. + +use rusqlite::{params, Connection}; +use thiserror::Error; + +/// Canonical grid dimensions matching the Python pipeline (generate_atlas.py). +pub const GRID_W: u32 = 512; +pub const GRID_H: u32 = 256; + +/// A loaded heightmap for one planetary body. +#[derive(Debug, Clone)] +pub struct BodyHeightmap { + pub body_id: String, + pub width: u32, + pub height: u32, + /// Row-major elevation values, normalized to [0.0, 1.0]. + pub data: Vec, + /// Elevation fraction below which terrain is ocean/sea. + pub sea_level: f32, +} + +impl BodyHeightmap { + /// Returns the elevation at (row, col), or `None` if out of bounds. + #[inline] + pub fn get(&self, row: u32, col: u32) -> Option { + if row < self.height && col < self.width { + Some(self.data[(row * self.width + col) as usize]) + } else { + None + } + } + + /// Returns `true` if the cell at (row, col) is land (above sea level). + #[inline] + pub fn is_land(&self, row: u32, col: u32) -> bool { + self.get(row, col).is_some_and(|e| e >= self.sea_level) + } +} + +#[derive(Debug, Error)] +pub enum HeightmapLoadError { + #[error("no heightmap row for body '{0}'")] + NotFound(String), + #[error("BLOB size {actual} does not match declared grid {w}×{h}×4 = {expected}")] + BlobSizeMismatch { + actual: usize, + w: u32, + h: u32, + expected: usize, + }, + #[error("SQLite error: {0}")] + Sql(#[from] rusqlite::Error), +} + +/// Load the heightmap for `body_id` from the open `conn`. +/// +/// The BLOB is reinterpreted in-place via `bytemuck::cast_slice` — no copy +/// beyond the initial `Vec` read from SQLite. On little-endian hosts +/// (all current targets) this is a zero-cost reinterpret. On big-endian hosts +/// the bytes are already stored LE, so each f32 would be byte-swapped; this +/// function does not perform that swap — big-endian support is deferred. +pub fn load_heightmap( + conn: &Connection, + body_id: &str, +) -> Result { + let result = conn.query_row( + "SELECT width, height, data, sea_level \ + FROM atlas_body_heightmaps WHERE body_id = ?1", + params![body_id], + |row| { + let width: u32 = row.get(0)?; + let height: u32 = row.get(1)?; + let blob: Vec = row.get(2)?; + let sea_level: f64 = row.get(3)?; + Ok((width, height, blob, sea_level as f32)) + }, + ); + + match result { + Err(rusqlite::Error::QueryReturnedNoRows) => { + Err(HeightmapLoadError::NotFound(body_id.to_string())) + } + Err(e) => Err(HeightmapLoadError::Sql(e)), + Ok((width, height, blob, sea_level)) => { + let expected = (width * height * 4) as usize; + if blob.len() != expected { + return Err(HeightmapLoadError::BlobSizeMismatch { + actual: blob.len(), + w: width, + h: height, + expected, + }); + } + // Reinterpret the LE bytes as f32 values. bytemuck::cast_slice + // is safe here: we verified the length is a multiple of 4, and + // f32 has no invalid bit patterns. + let floats: &[f32] = bytemuck::cast_slice(&blob); + let data = floats.to_vec(); + Ok(BodyHeightmap { + body_id: body_id.to_string(), + width, + height, + data, + sea_level, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn make_test_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE atlas_body_heightmaps ( + body_id TEXT PRIMARY KEY, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + data BLOB NOT NULL, + sea_level REAL NOT NULL DEFAULT 0.0, + imported_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ) + .unwrap(); + conn + } + + fn insert_heightmap(conn: &Connection, body_id: &str, w: u32, h: u32, sea_level: f32) { + let floats: Vec = (0..(w * h)).map(|i| i as f32 / (w * h) as f32).collect(); + let bytes: &[u8] = bytemuck::cast_slice(&floats); + conn.execute( + "INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![body_id, w, h, bytes, sea_level], + ) + .unwrap(); + } + + #[test] + fn round_trip_canonical_size() { + let conn = make_test_db(); + insert_heightmap(&conn, "TestBody", GRID_W, GRID_H, 0.3); + let hm = load_heightmap(&conn, "TestBody").unwrap(); + assert_eq!(hm.width, GRID_W); + assert_eq!(hm.height, GRID_H); + assert_eq!(hm.data.len(), (GRID_W * GRID_H) as usize); + assert!((hm.sea_level - 0.3).abs() < 1e-6); + // First cell is 0.0, last approaches 1.0 + assert_eq!(hm.data[0], 0.0); + assert!(hm.data.last().copied().unwrap() < 1.0); + } + + #[test] + fn get_and_is_land() { + let conn = make_test_db(); + insert_heightmap(&conn, "LandBody", 4, 2, 0.5); + let hm = load_heightmap(&conn, "LandBody").unwrap(); + // First cell (index 0) = 0.0 / 8 = 0.0 — below sea level + assert!(!hm.is_land(0, 0)); + // Last cell (index 7) = 7.0 / 8 = 0.875 — above sea level + assert!(hm.is_land(1, 3)); + // Out-of-bounds returns false + assert!(!hm.is_land(99, 99)); + } + + #[test] + fn not_found_error() { + let conn = make_test_db(); + let err = load_heightmap(&conn, "Ghost").unwrap_err(); + assert!(matches!(err, HeightmapLoadError::NotFound(_))); + } + + #[test] + fn blob_size_mismatch_error() { + let conn = make_test_db(); + // Insert a truncated BLOB + conn.execute( + "INSERT INTO atlas_body_heightmaps (body_id, width, height, data, sea_level) + VALUES ('BadBlob', 4, 4, X'DEADBEEF', 0.0)", + [], + ) + .unwrap(); + let err = load_heightmap(&conn, "BadBlob").unwrap_err(); + assert!(matches!(err, HeightmapLoadError::BlobSizeMismatch { .. })); + } +} diff --git a/server/src/atlas/mod.rs b/server/src/atlas/mod.rs new file mode 100644 index 000000000..ff0ccbe2b --- /dev/null +++ b/server/src/atlas/mod.rs @@ -0,0 +1,15 @@ +//! Atlas data loaders — reads pre-computed build-time data from systems.db. +//! +//! These loaders are used by the runtime-background tier (D-200, D-206) when +//! populating BodyWorldState (D-203). They are never called on the main tick thread. + +pub mod attractor_matching; +pub mod block_irregularity; +pub mod body_world_state; +pub mod district_mix; +pub mod drainage; +pub mod gen_queue; +pub mod heightmap; +pub mod rng; +pub mod skeleton_gen; +pub mod tile_condition; diff --git a/server/src/atlas/rng.rs b/server/src/atlas/rng.rs new file mode 100644 index 000000000..1d90c4d0e --- /dev/null +++ b/server/src/atlas/rng.rs @@ -0,0 +1,57 @@ +//! Seeded LCG for deterministic generation (D-010). +//! +//! Shared by all atlas generation modules that need seeded randomness. +//! Uses Knuth's LCG parameters — integer-only arithmetic, no f32, D-010 compliant. +//! +//! Callers are responsible for any seed pre-mixing before calling `AtlasRng::new`. + +/// Seeded linear congruential generator (D-010). +pub struct AtlasRng { + state: u64, +} + +impl AtlasRng { + /// Create a new RNG from a pre-mixed seed. + /// + /// Callers must ensure the seed is non-degenerate (avoid passing 0 directly + /// if the seed could realistically be 0 — add a constant before calling). + pub fn new(seed: u64) -> Self { + Self { state: seed } + } + + fn next_u64(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.state + } + + /// Next pseudorandom `u32` (top 31 bits of the LCG state). + pub fn next_u32(&mut self) -> u32 { + (self.next_u64() >> 33) as u32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_sequence() { + let mut a = AtlasRng::new(42); + let mut b = AtlasRng::new(42); + for _ in 0..100 { + assert_eq!(a.next_u32(), b.next_u32()); + } + } + + #[test] + fn different_seeds_differ() { + let mut a = AtlasRng::new(1); + let mut b = AtlasRng::new(2); + let vals_a: Vec = (0..10).map(|_| a.next_u32()).collect(); + let vals_b: Vec = (0..10).map(|_| b.next_u32()).collect(); + assert_ne!(vals_a, vals_b); + } +} diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs new file mode 100644 index 000000000..cb5009f71 --- /dev/null +++ b/server/src/atlas/skeleton_gen.rs @@ -0,0 +1,575 @@ +//! Phase 1 district skeleton generator (D-194, D-196, D-211, D-213, D-214). +//! +//! Entry point: [`generate_skeleton`]. Consumes a [`CityGenerationContext`] +//! together with the city's raw population and economic role, and produces a +//! fully classified [`DistrictSkeleton`] with: +//! +//! - [`SettingType`] derived from the surrounding biome context. +//! - [`ComplexityTier`] derived from population tier × [`WorldTier`]. +//! - [`DistrictLayoutMode`] derived from [`PoliticalArchetype`]. +//! - 4×4 block grid with [`ZoningType`] assignments from the district-mix +//! algorithm (D-194). +//! - [`MultiBlockReservation`]s for parks (pop tier ≥ 2) and transit +//! terminals (transit_hub role or pop tier ≥ 3). +//! +//! **Phase 1 scope only** — no chunk-level tiles, no NPC placement, no tile +//! condition data. All stub fields (corridors, social_sites, etc.) are empty. +//! +//! **Determinism (D-010):** Seeded LCG via the district seed; no floating-point +//! in block assignment. + +use crate::atlas::block_irregularity::block_irregularity; +use crate::atlas::district_mix::{compute_district_mix, population_tier}; +use crate::atlas::rng::AtlasRng; +use crate::simulation::generator::{ + BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId, + DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype, + ReservationFunction, ReservationId, SettingType, WorldTier, ZoningType, +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Generate a Phase 1 [`DistrictSkeleton`] from a city's generation context. +/// +/// # Parameters +/// - `context`: Build-time city context (archetype, world tier, orientation…). +/// - `population`: Raw population count from atlas_city_names. +/// - `economic_role`: Economic role string (one of the 10 canonical values). +/// - `district_id`: Content-addressable identifier for this district. +/// - `founding_age_years`: Years since founding — controls block irregularity. +/// - `seed`: Deterministic seed for this district (derived from master seed via SeedChain). +pub fn generate_skeleton( + context: &CityGenerationContext, + population: i64, + economic_role: &str, + district_id: DistrictId, + founding_age_years: u32, + seed: u64, +) -> DistrictSkeleton { + // ── 1. SettingType ──────────────────────────────────────────────────── + // Pass through the surrounding_biome from context — it already encodes + // the planet/station/wilderness classification established at atlas time. + let setting = derive_setting(&context.surrounding_biome, economic_role); + + // ── 2. ComplexityTier ───────────────────────────────────────────────── + let tier = population_tier(population); + let complexity = derive_complexity(&context.world_tier, tier, population); + + // ── 3. DistrictLayoutMode ───────────────────────────────────────────── + let irregularity = block_irregularity(founding_age_years, &context.political_archetype); + let layout_mode = derive_layout_mode(&context.political_archetype, irregularity, seed); + + // ── 4. District mix → block grid ───────────────────────────────────── + // A single district occupies a 4×4 block grid = 16 blocks. + let total_blocks: u32 = 16; + let mix = compute_district_mix( + population, + economic_role, + &context.political_archetype, + total_blocks, + seed, + ); + + // ── 5. Multi-block reservations ─────────────────────────────────────── + let reservations = derive_reservations(tier, economic_role, seed); + + // Build the reservation lookup: block position → reservation id. + let mut block_reservation: [[Option; 4]; 4] = [[None, None, None, None]; 4]; + for (idx, res) in reservations.iter().enumerate() { + let rid = idx as u64 + 1; // 1-based stable id within this district + for &(row, col) in &res.blocks { + let r = row as usize; + let c = col as usize; + if r < 4 && c < 4 { + block_reservation[r][c] = Some(rid); + } + } + } + + // ── Build 4×4 block grid ────────────────────────────────────────────── + // Flat district-mix list is already in deterministic order; assign + // row-major (row 0 col 0 → row 0 col 3 → row 1 col 0 …). + let primary_district_type = mix + .districts + .first() + .cloned() + .unwrap_or(DistrictType::MixedUse); + let blocks = build_block_grid(&mix.districts, &block_reservation, &primary_district_type); + + // ── Compute z_levels ────────────────────────────────────────────────── + // Phase 1: single-storey above ground for all non-reserved blocks. + // Reserved blocks carry their own z_levels count. + let z_levels: u8 = 1; + + DistrictSkeleton { + district_id, + seed, + district_type: district_type_from_mix(&primary_district_type), + context: String::new(), // stub — DistrictContext = String + world_tier: context.world_tier.clone(), + complexity, + setting, + layout_mode, + blocks, + reservations, + corridors: Vec::new(), + z_levels, + social_sites: Vec::new(), + access_points: Vec::new(), + society_profile: String::new(), + zone_palette: Vec::new(), + boundaries: String::new(), + guarantee_audit: None, + } +} + +// --------------------------------------------------------------------------- +// SettingType derivation +// --------------------------------------------------------------------------- + +/// Derive SettingType from the city's surrounding biome context. +/// +/// The surrounding_biome on CityGenerationContext already encodes the +/// planet/station classification. For city districts we map it to +/// Urban (the default for settled cities) or pass Station/Maritime/etc. +/// through directly. +fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> SettingType { + match surrounding_biome { + // Station bodies → always Station setting regardless of role. + SettingType::Station => SettingType::Station, + // Orbital platforms. + SettingType::Orbital => SettingType::Orbital, + // Maritime worlds — coastal city districts are Maritime. + SettingType::Maritime => SettingType::Maritime, + // Agricultural worlds → Agricultural districts. + SettingType::Agricultural => SettingType::Agricultural, + // For all other planet classes, city districts are Urban. + // Exception: extraction role on wilderness worlds → Specialized. + SettingType::Wilderness { biome } => { + if economic_role == "extraction" { + SettingType::Specialized { + function: format!("extraction-{biome}"), + } + } else { + SettingType::Urban + } + } + // Transit nodes get Transitional setting. + SettingType::Transitional => SettingType::Transitional, + // Water bodies → Water districts don't host cities; treat as Specialized. + SettingType::Water { .. } => SettingType::Specialized { + function: "waterfront".into(), + }, + // Generic Specialized pass-through. + SettingType::Specialized { function } => SettingType::Specialized { + function: function.clone(), + }, + // Default for Urban and any unknown variant: Urban. + SettingType::Urban => SettingType::Urban, + } +} + +// --------------------------------------------------------------------------- +// ComplexityTier derivation +// --------------------------------------------------------------------------- + +/// Derive ComplexityTier from WorldTier + population tier (D-194, D-218). +/// +/// Backwater is "NOT budget-capped" per D-218 — it joins Epicenter/Regional +/// at Full complexity rather than being capped at Moderate like Passage. +/// +/// | WorldTier | pop_tier ≥ 1 | pop_tier = 0 | +/// |-----------------|---------------|----------------------| +/// | Epicenter | Full | Moderate | +/// | Regional | Full | Moderate | +/// | Backwater | Full | Moderate | +/// | Passage | Moderate | Minimal | +/// | Waypoint | Minimal | Minimal (→ Empty <5K)| +fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier { + // Ghost stub threshold: pop < 5000 on Waypoint → Empty. + if population < 5_000 && matches!(world_tier, WorldTier::Waypoint) { + return ComplexityTier::Empty; + } + + match world_tier { + WorldTier::Epicenter | WorldTier::Regional | WorldTier::Backwater => { + if pop_tier >= 1 { + ComplexityTier::Full + } else { + ComplexityTier::Moderate + } + } + WorldTier::Passage => { + if pop_tier >= 1 { + ComplexityTier::Moderate + } else { + ComplexityTier::Minimal + } + } + WorldTier::Waypoint => ComplexityTier::Minimal, + } +} + +// --------------------------------------------------------------------------- +// DistrictLayoutMode derivation +// --------------------------------------------------------------------------- + +/// Derive DistrictLayoutMode from PoliticalArchetype + block irregularity (D-213, D-214). +/// +/// Commission / Military / Corporate / Academic → Grid (planned geometry). +/// Pioneer / Industrial → Organic (organic growth with per-block offsets). +fn derive_layout_mode( + archetype: &PoliticalArchetype, + irregularity: f32, + seed: u64, +) -> DistrictLayoutMode { + match archetype { + PoliticalArchetype::Commission + | PoliticalArchetype::Military + | PoliticalArchetype::Corporate + | PoliticalArchetype::Academic => DistrictLayoutMode::Grid, + + PoliticalArchetype::Pioneer | PoliticalArchetype::Industrial => { + // Organic: generate per-block offsets and rotations seeded from district seed. + let placements = organic_placements(irregularity, seed); + DistrictLayoutMode::Organic { placements } + } + } +} + +/// Generate 4×4 organic block placements seeded deterministically (D-010). +/// +/// Uses a seeded LCG; offset range controlled by `irregularity` (0.05–1.0) +/// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`. +fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] { + let max_offset = (irregularity * 16.0) as i16; + let mut lcg = AtlasRng::new(seed.wrapping_add(0x9e37_79b9_7f4a_7c15)); + + // Build the 2D array using a flat closure to keep things readable. + let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement { + offset: (0, 0), + rotation_steps: 0, + street_width_bps: 10_000, + }); + + for item in flat.iter_mut() { + let raw_x = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset; + let raw_y = (lcg.next_u32() % (2 * max_offset as u32 + 1)) as i16 - max_offset; + let rot = (lcg.next_u32() % 4) as u8; // 0–3 (15° increments, max 45°) + // Street width 7500–20000 bps proportional to irregularity. + let width_range = 12_500u32; // 20000 - 7500 + let width = 7_500u32 + (lcg.next_u32() % (width_range + 1)); + *item = BlockPlacement { + offset: (raw_x, raw_y), + rotation_steps: rot, + street_width_bps: width as u16, + }; + } + + core::array::from_fn(|row| core::array::from_fn(|col| flat[row * 4 + col].clone())) +} + +// --------------------------------------------------------------------------- +// Block grid construction +// --------------------------------------------------------------------------- + +/// Map a DistrictType to its primary ZoningType (D-194). +fn zoning_for_district(dt: &DistrictType) -> ZoningType { + match dt { + DistrictType::LogisticsHub => ZoningType::Industrial, + DistrictType::Residential => ZoningType::Residential, + DistrictType::Commercial => ZoningType::Commercial, + DistrictType::Industrial => ZoningType::Industrial, + DistrictType::Administrative => ZoningType::Administrative, + DistrictType::Entertainment => ZoningType::Commercial, + DistrictType::MixedUse => ZoningType::Mixed, + DistrictType::Transit => ZoningType::Transit, + DistrictType::Specialized => ZoningType::Restricted, + } +} + +/// Map the primary district type to the DistrictType field on DistrictSkeleton. +fn district_type_from_mix(primary: &DistrictType) -> DistrictType { + primary.clone() +} + +/// Build the 4×4 BlockSkeleton grid from the district mix list and reservation map. +/// +/// Blocks are assigned row-major (index = row * 4 + col). +/// Reserved blocks retain their zoning from the district mix but link to the reservation. +fn build_block_grid( + districts: &[DistrictType], + block_reservation: &[[Option; 4]; 4], + primary: &DistrictType, +) -> [[BlockSkeleton; 4]; 4] { + // Pad or truncate district list to exactly 16. + let district_iter: Vec<&DistrictType> = (0..16) + .map(|i| districts.get(i).unwrap_or(primary)) + .collect(); + + core::array::from_fn(|row| { + core::array::from_fn(|col| { + let idx = row * 4 + col; + let dt = district_iter[idx]; + let zoning = zoning_for_district(dt); + let reservation = block_reservation[row][col]; + + let density = density_for_zoning(&zoning); + BlockSkeleton { + position: (row as u8, col as u8), + zoning, + reservation, + chunk_layout: String::new(), // stub + hosted_sites: Vec::new(), + era: String::new(), // stub + era_modifications: Vec::new(), + era_cause: None, + density_pct: density, + landmark: None, + } + }) + }) +} + +/// Default build density percentage for a zoning type. +fn density_for_zoning(zoning: &ZoningType) -> u8 { + match zoning { + ZoningType::Residential => 60, + ZoningType::Commercial => 80, + ZoningType::Industrial => 70, + ZoningType::Administrative => 75, + ZoningType::Transit => 50, + ZoningType::Recreational => 30, + ZoningType::Restricted => 85, + ZoningType::Mixed => 65, + } +} + +// --------------------------------------------------------------------------- +// Multi-block reservations +// --------------------------------------------------------------------------- + +/// Derive Phase 1 multi-block reservations for a city (D-211, D-194). +/// +/// Reservation rules: +/// - Pop tier ≥ 2 → one 2×2 park reservation at the center-right (blocks (1,2),(1,3),(2,2),(2,3)). +/// - Transit_hub role OR pop tier ≥ 3 → one 1×2 transit terminal at row 0 cols 0–1. +/// +/// Phase 1 produces skeleton-only reservations — floor_zones and vertical_corridors +/// are deferred to Phase 2. +fn derive_reservations( + pop_tier: u8, + economic_role: &str, + _seed: u64, +) -> Vec { + let mut out = Vec::new(); + + // Park: large cities need open space. + if pop_tier >= 2 { + out.push(MultiBlockReservation { + blocks: vec![(1, 2), (1, 3), (2, 2), (2, 3)], + template_tag: "park-central".into(), + function: ReservationFunction::Park, + z_levels: 1, + base_z: 0, + floor_zones: Vec::new(), + z_band_count: 1, + z_band_zones: Vec::new(), + vertical_corridors: Vec::new(), + hosted_sites: Vec::new(), + }); + } + + // Transit terminal: transit-hub economies and major cities. + if economic_role == "transit_hub" || pop_tier >= 3 { + out.push(MultiBlockReservation { + blocks: vec![(0, 0), (0, 1)], + template_tag: "transit-terminal".into(), + function: ReservationFunction::Terminal, + z_levels: 2, + base_z: -1, // one level of underground rail + floor_zones: Vec::new(), + z_band_count: 2, + z_band_zones: Vec::new(), + vertical_corridors: Vec::new(), + hosted_sites: Vec::new(), + }); + } + + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::simulation::generator::{ + CityGenerationContext, FoundingOrientation, PoliticalArchetype, SettingType, WorldTier, + }; + + fn make_context(archetype: PoliticalArchetype, world_tier: WorldTier) -> CityGenerationContext { + CityGenerationContext { + city_id: 1, + political_archetype: archetype, + prosperity_baseline: 0.7, + surrounding_biome: SettingType::Urban, + road_entry_directions: vec![0, 4], + footprint_radius_km: 10.0, + founding_orientation: FoundingOrientation::Cardinal, + world_tier, + } + } + + #[test] + fn setting_station_passthrough() { + let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + ctx.surrounding_biome = SettingType::Station; + let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 200, 42); + assert!(matches!(sk.setting, SettingType::Station)); + } + + #[test] + fn setting_urban_for_city_on_planet() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42); + assert!(matches!(sk.setting, SettingType::Urban)); + } + + #[test] + fn complexity_epicenter_high_pop_is_full() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + // 10M pop → pop_tier = 1 → Full on Epicenter + let sk = generate_skeleton(&ctx, 10_000_000, "financial", 1, 200, 42); + assert_eq!(sk.complexity, ComplexityTier::Full); + } + + #[test] + fn complexity_waypoint_tiny_pop_is_empty() { + let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Waypoint); + let sk = generate_skeleton(&ctx, 1_000, "residential", 1, 50, 42); + assert_eq!(sk.complexity, ComplexityTier::Empty); + } + + #[test] + fn complexity_backwater_low_pop_is_moderate() { + let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater); + // 50_000 pop → pop_tier = 0 → Moderate on Backwater (D-218: not budget-capped) + let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42); + assert_eq!(sk.complexity, ComplexityTier::Moderate); + } + + #[test] + fn complexity_backwater_high_pop_is_full() { + let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater); + // 10M pop → pop_tier = 1 → Full on Backwater (D-218: not budget-capped) + let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, 42); + assert_eq!(sk.complexity, ComplexityTier::Full); + } + + #[test] + fn complexity_passage_low_pop_is_minimal() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Passage); + // 50_000 pop → pop_tier = 0 → Minimal on Passage (transit stop, budget-capped) + let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, 42); + assert_eq!(sk.complexity, ComplexityTier::Minimal); + } + + #[test] + fn layout_commission_is_grid() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + let sk = generate_skeleton(&ctx, 500_000, "institutional", 1, 100, 99); + assert!(matches!(sk.layout_mode, DistrictLayoutMode::Grid)); + } + + #[test] + fn layout_pioneer_is_organic() { + let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Regional); + let sk = generate_skeleton(&ctx, 500_000, "residential", 1, 400, 99); + assert!(matches!(sk.layout_mode, DistrictLayoutMode::Organic { .. })); + } + + #[test] + fn block_grid_is_fully_populated() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + let sk = generate_skeleton(&ctx, 500_000, "financial", 1, 200, 42); + // All 16 blocks must have valid positions. + for row in 0..4 { + for col in 0..4 { + let b = &sk.blocks[row][col]; + assert_eq!(b.position, (row as u8, col as u8)); + assert!(b.density_pct <= 100); + } + } + } + + #[test] + fn no_reservations_for_small_city() { + let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater); + // pop_tier 0, not transit_hub → no reservations. + let sk = generate_skeleton(&ctx, 80_000, "residential", 1, 50, 42); + assert!(sk.reservations.is_empty()); + } + + #[test] + fn park_reservation_for_large_city() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + // 100M pop → pop_tier 2 → park reservation. + let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42); + let has_park = sk + .reservations + .iter() + .any(|r| matches!(r.function, ReservationFunction::Park)); + assert!(has_park); + } + + #[test] + fn transit_terminal_for_transit_hub_role() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional); + // pop_tier 0 but transit_hub → terminal reservation. + let sk = generate_skeleton(&ctx, 80_000, "transit_hub", 1, 200, 42); + let has_terminal = sk + .reservations + .iter() + .any(|r| matches!(r.function, ReservationFunction::Terminal)); + assert!(has_terminal); + } + + #[test] + fn reserved_blocks_linked_in_grid() { + let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter); + // 100M pop → park at (1,2),(1,3),(2,2),(2,3) with reservation id 1. + let sk = generate_skeleton(&ctx, 100_000_000, "financial", 1, 300, 42); + // All park blocks must reference the park reservation (id=1). + for &(row, col) in &[(1u8, 2u8), (1, 3), (2, 2), (2, 3)] { + let b = &sk.blocks[row as usize][col as usize]; + assert!( + b.reservation.is_some(), + "block ({row},{col}) should be reserved" + ); + } + } + + #[test] + fn determinism_same_seed_same_output() { + let ctx = make_context(PoliticalArchetype::Industrial, WorldTier::Regional); + let sk1 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345); + let sk2 = generate_skeleton(&ctx, 2_000_000, "manufacturing", 77, 250, 12345); + // Compare block grid zoning and positions. + for row in 0..4 { + for col in 0..4 { + assert_eq!(sk1.blocks[row][col].zoning, sk2.blocks[row][col].zoning); + assert_eq!(sk1.blocks[row][col].position, sk2.blocks[row][col].position); + assert_eq!( + sk1.blocks[row][col].density_pct, + sk2.blocks[row][col].density_pct + ); + } + } + assert_eq!(sk1.reservations.len(), sk2.reservations.len()); + } +} diff --git a/server/src/atlas/tile_condition.rs b/server/src/atlas/tile_condition.rs new file mode 100644 index 000000000..7645f7449 --- /dev/null +++ b/server/src/atlas/tile_condition.rs @@ -0,0 +1,197 @@ +//! Tile condition thresholds and derivation (D-217). +//! +//! A tile's visual condition is derived from the district's `prosperity_score` +//! (0.0–1.0) using four threshold bands. The block's `EraCause` applies a +//! minimum condition floor that prevents high-prosperity scores from masking +//! historical decay. +//! +//! **Threshold bands (D-217):** +//! | Band | Condition | prosperity_score | +//! |------|-----------|-----------------| +//! | 1 | Intact | > 0.63 | +//! | 2 | Worn | 0.43 – 0.63 | +//! | 3 | Cracked | 0.23 – 0.43 | +//! | 4 | Broken | < 0.23 | +//! +//! **Era-based floor (D-217):** +//! - `EconomicDisruption` (Decay-era): minimum Cracked. +//! - `EmergencyExtension`: minimum Worn. +//! - All other eras: no floor — condition follows prosperity_score freely. +//! +//! **Threshold crossing invalidation:** A tile's condition only changes when +//! `prosperity_score` crosses a band boundary. Checked once per game-minute. +//! +//! Threshold values are authored constants (D-217): 0.63, 0.43, 0.23. + +use crate::simulation::generator::EraCause; + +// --------------------------------------------------------------------------- +// TileCondition +// --------------------------------------------------------------------------- + +/// Visual condition band for a tile, derived from prosperity_score (D-217). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum TileCondition { + /// prosperity_score > 0.63. Clean, undamaged, well-maintained. + Intact, + /// prosperity_score 0.43–0.63. Scuff marks, minor discoloration, partial repairs. + Worn, + /// prosperity_score 0.23–0.43. Visible damage, incomplete repair, graffiti. + Cracked, + /// prosperity_score < 0.23. Structural damage, debris, derelict appearance. + Broken, +} + +// --------------------------------------------------------------------------- +// Threshold constants (D-217 authored — do not compute at runtime) +// --------------------------------------------------------------------------- + +pub const THRESHOLD_INTACT: f32 = 0.63; +pub const THRESHOLD_WORN: f32 = 0.43; +pub const THRESHOLD_CRACKED: f32 = 0.23; + +// --------------------------------------------------------------------------- +// Derivation +// --------------------------------------------------------------------------- + +/// Derive `TileCondition` from `prosperity_score` alone (no era floor). +pub fn condition_from_score(prosperity_score: f32) -> TileCondition { + if prosperity_score > THRESHOLD_INTACT { + TileCondition::Intact + } else if prosperity_score > THRESHOLD_WORN { + TileCondition::Worn + } else if prosperity_score > THRESHOLD_CRACKED { + TileCondition::Cracked + } else { + TileCondition::Broken + } +} + +/// Era-based minimum condition floor (D-217). +/// +/// Returns the minimum `TileCondition` for a block with the given `EraCause`. +/// `None` means no floor — condition follows prosperity_score freely. +pub fn era_condition_floor(era_cause: Option<&EraCause>) -> Option { + match era_cause { + Some(EraCause::EconomicDisruption) => Some(TileCondition::Cracked), + Some(EraCause::EmergencyExtension) => Some(TileCondition::Worn), + _ => None, + } +} + +/// Derive `TileCondition` with era-based floor applied. +/// +/// If the era floor is stricter (lower condition) than the score-derived +/// condition, the floor wins. +pub fn tile_condition(prosperity_score: f32, era_cause: Option<&EraCause>) -> TileCondition { + let from_score = condition_from_score(prosperity_score); + match era_condition_floor(era_cause) { + Some(floor) => { + // Lower enum discriminant = better condition (Intact < Worn < Cracked < Broken). + // Floor is a *minimum degradation* — we want the worse of the two. + if floor > from_score { + floor + } else { + from_score + } + } + None => from_score, + } +} + +/// Check whether a threshold crossing occurred between two prosperity scores. +/// +/// Returns `true` if the tile's condition band changed between `old_score` and +/// `new_score`. Used by the game-minute update loop to decide whether to +/// apply a `ChunkMutation.tile_override`. +pub fn threshold_crossed(old_score: f32, new_score: f32) -> bool { + condition_from_score(old_score) != condition_from_score(new_score) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intact_above_0_63() { + assert_eq!(condition_from_score(0.64), TileCondition::Intact); + assert_eq!(condition_from_score(1.0), TileCondition::Intact); + } + + #[test] + fn worn_between_0_43_and_0_63() { + assert_eq!(condition_from_score(0.63), TileCondition::Worn); + assert_eq!(condition_from_score(0.50), TileCondition::Worn); + assert_eq!(condition_from_score(0.44), TileCondition::Worn); + } + + #[test] + fn cracked_between_0_23_and_0_43() { + assert_eq!(condition_from_score(0.43), TileCondition::Cracked); + assert_eq!(condition_from_score(0.30), TileCondition::Cracked); + assert_eq!(condition_from_score(0.24), TileCondition::Cracked); + } + + #[test] + fn broken_below_0_23() { + assert_eq!(condition_from_score(0.23), TileCondition::Broken); + assert_eq!(condition_from_score(0.10), TileCondition::Broken); + assert_eq!(condition_from_score(0.0), TileCondition::Broken); + } + + #[test] + fn era_floor_decay_enforces_cracked_minimum() { + // Prosperous district in an EconomicDisruption-era block — still Cracked. + let cond = tile_condition(0.90, Some(&EraCause::EconomicDisruption)); + assert_eq!( + cond, + TileCondition::Cracked, + "EconomicDisruption floor must prevent Intact/Worn" + ); + } + + #[test] + fn era_floor_emergency_extension_enforces_worn_minimum() { + // High prosperity EmergencyExtension block should never be Intact. + let cond = tile_condition(0.80, Some(&EraCause::EmergencyExtension)); + assert_eq!(cond, TileCondition::Worn); + } + + #[test] + fn era_floor_does_not_improve_condition() { + // EconomicDisruption floor = Cracked; Broken score stays Broken. + let cond = tile_condition(0.10, Some(&EraCause::EconomicDisruption)); + assert_eq!( + cond, + TileCondition::Broken, + "Era floor must not improve condition below score-derived value" + ); + } + + #[test] + fn no_era_cause_follows_score() { + let cond = tile_condition(0.90, None); + assert_eq!(cond, TileCondition::Intact); + } + + #[test] + fn threshold_crossed_detects_band_change() { + // 0.7 → 0.5 crosses the 0.63 boundary. + assert!(threshold_crossed(0.70, 0.50)); + // 0.55 → 0.48 stays in Worn band. + assert!(!threshold_crossed(0.55, 0.48)); + // 0.40 → 0.20 crosses 0.23 boundary. + assert!(threshold_crossed(0.40, 0.20)); + } + + #[test] + fn condition_ordering_intact_is_best() { + assert!(TileCondition::Intact < TileCondition::Worn); + assert!(TileCondition::Worn < TileCondition::Cracked); + assert!(TileCondition::Cracked < TileCondition::Broken); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index e2706c9cd..23e7bb3e1 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,6 +1,7 @@ // The Settled Reach - Simulation Server // Rust/bevy_ecs simulation server for D-010 client-server architecture +pub mod atlas; pub mod bookmark; pub mod bridge; pub mod cause_chain; diff --git a/server/src/simulation/economy.rs b/server/src/simulation/economy.rs index 928bf0134..4c6401dc4 100644 --- a/server/src/simulation/economy.rs +++ b/server/src/simulation/economy.rs @@ -299,13 +299,19 @@ pub struct EconQueryBuffer { pub fn serve_econ_state_query( mut query_buf: ResMut, econ_state: Option>, - mut snapshot_buf: ResMut, + snapshot_buf: Option>, ) { let system_id = match query_buf.pending.take() { Some(s) => s, None => return, }; + // SnapshotBuffer only exists when BridgePlugin is loaded (not in standalone tests). + let mut snapshot_buf = match snapshot_buf { + Some(b) => b, + None => return, + }; + let econ_state = match econ_state { Some(s) => s, None => { diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 427c19038..819f0a54b 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -100,15 +100,19 @@ pub type PlacedObject = String; /// Network importance of a world in the galaxy. /// Determines simulation fidelity budget and NPC complexity ceiling. /// -/// Source: tyre-round4.md §2.1, workshop-outcomes.md +/// Source: D-218, workshop-outcomes.md #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum WorldTier { - /// Background system — minimal simulation, sparse NPCs. Pure environmental. - Peripheral, - /// Standard Settled Reach system — full simulation, complex social sites. - Connected, - /// Major hub — maximum fidelity, multi-faction politics, all triangle types. - Core, + /// Hub system. Full simulation, high faction pressure. + Epicenter, + /// Regional system. 1–4 districts, partial full-budget simulation. + Regional, + /// Small community. 1 district. Network-insignificant, NOT budget-capped. + Backwater, + /// Transit stop. Pass-through node. Moderate complexity ceiling. + Passage, + /// Not simulated until player approaches. Minimal complexity ceiling. + Waypoint, } /// Generator content budget for a district. @@ -288,6 +292,147 @@ pub enum EraCause { CulturalShift, } +// --------------------------------------------------------------------------- +// Settlement classification enums (D-196, D-212, D-213, D-214, D-215) +// --------------------------------------------------------------------------- + +/// How a settlement enters and exits active simulation. +/// Controls whether generation runs, and at what complexity level. +/// Source: D-196 +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum SettlementClass { + /// Named in wiki; always active regardless of population threshold. + NameLocked, + /// Active if pop ≥ 50_000; ghost stub if pop < 5_000. + PopulationBudget, + /// Active only while the triggering economic condition holds. + EconomicTriggered, + /// Emergent settlement not in atlas at generation time; written during simulation. + OrganicGrowth, +} + +/// Dominant power structure of a settlement and its physical spatial expression. +/// Derived from TerritorialStatus + economic_role at generation time. +/// Source: D-214 +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum PoliticalArchetype { + /// Top-down Commission planning; rectilinear, institutional core, radial-core arrangement. + Commission, + /// Corp-dominated; commercial density, restricted campus blocks, restricted-perimeter adjacent. + Corporate, + /// Self-organized; organic growth, mixed use, ribbon arrangement. + Pioneer, + /// Garrison or fortification origin; defensible geometry, fortified-perimeter arrangement. + Military, + /// University or research origin; campus-quad structure, green space, radial-core arrangement. + Academic, + /// Factory-first; large-footprint industrial blocks, worker residential rings, ribbon arrangement. + Industrial, +} + +/// Primary spatial axis of a city's original street grid. +/// Derived from the matched attractor type (D-211). Controls district grid rotation. +/// Source: D-213 +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum FoundingOrientation { + /// Street grid perpendicular to coastline. `facing_degrees`: compass bearing toward water (0–359). + Coastal { facing_degrees: u16 }, + /// Street grid parallel to founding river. `bearing_degrees`: river flow direction (0–359). + RiverAligned { bearing_degrees: u16 }, + /// Grid rotated to follow local contours (valley floor settlements). + TerrainFollowing, + /// Grid aligned to cardinal N/S/E/W (Commission-planned settlements on flat terrain). + Cardinal, + /// Arbitrary bearing (pioneer settlements on open terrain). `bearing_degrees`: 0–359. + Free { bearing_degrees: u16 }, +} + +/// Territory control status for a province (drainage basin). Priority-ordered derivation. +/// Source: D-212 +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum TerritorialStatus { + /// Commission faction_influence ≥ 0.6 in this province. + CommissionControlled, + /// Single corporation faction_influence ≥ 0.5. + CorpTerritory, + /// Two or more factions each ≥ 0.3; no dominant faction. + ContestedZone, + /// No faction with influence ≥ 0.2. + FrontierUnclaimed, + /// Cultural corridor has indigenous autonomy flag. + IndigenousHeld, + /// Population density < 0.01 AND no faction ≥ 0.1. + Derelict, +} + +// --------------------------------------------------------------------------- +// Attractor types for settlement placement (D-195, D-209, D-211) +// --------------------------------------------------------------------------- + +/// The type of terrain feature that attracts settlement placement. +/// Source: D-195, D-209 +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum AttractorType { + /// Where a river meets sea level or coastline. Historically high-value. + RiverMouth, + /// Proximity to coast without a river mouth. Port access. + CoastalAccess, + /// Where a river crosses a topographic saddle or confluence point. + RiverCrossing, + /// Local elevation minimum; flat, arable, sheltered. + ValleyFloor, + /// Saddle point between adjacent drainage basins; controls a mountain pass. + PassEntrance, + /// Adjacent to a lake polygon. + LakeShore, + /// Flat terrain away from all other attractors; fallback for plains settlements. + PlainCenter, +} + +/// A terrain feature at a specific map position that influences city placement scoring. +/// Source: D-195, D-209 +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GeographicAttractor { + /// Pixel position in heightmap space [row, col]. + pub position: (u16, u16), + pub attractor_type: AttractorType, + /// Normalized strength 0.0–1.0. Derived from flow accumulation or habitability score. + pub strength: f32, +} + +/// Compatibility weights between economic roles and attractor types. +/// A 10×7 matrix (10 economic_role values × 7 AttractorType variants). +/// Each cell is a weight multiplier 0.0–3.0 applied during attractor-matching scoring. +/// Source: D-195 +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CompatibilityMatrix { + /// Row order: manufacturing, financial, agricultural, extraction, service_mixed, + /// institutional, transit_hub, research, military, residential. + /// Column order: RiverMouth, CoastalAccess, RiverCrossing, ValleyFloor, + /// PassEntrance, LakeShore, PlainCenter. + pub weights: [[f32; 7]; 10], +} + +/// Data contract between build-time (systems.db) and the runtime-background +/// generation tier. Populated from atlas_city_names + bodies at generation +/// dispatch time. All 8 fields are required before a generation task may run. +/// Source: D-200, D-199 +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CityGenerationContext { + /// Foreign key into atlas_city_names.id + pub city_id: u64, + pub political_archetype: PoliticalArchetype, + /// Starting economic health seed (0.0–1.0). Derived per D-197. + pub prosperity_baseline: f32, + pub surrounding_biome: SettingType, + /// Compass octants (0=N, 1=NE … 7=NW) where roads enter the city footprint. + pub road_entry_directions: Vec, + /// City footprint radius in km. Derived from body_radius_km (D-204) + population. + pub footprint_radius_km: f32, + pub founding_orientation: FoundingOrientation, + pub world_tier: WorldTier, +} + // --------------------------------------------------------------------------- // Supporting structs // --------------------------------------------------------------------------- diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index b32c752d2..995f50ed2 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -24,6 +24,7 @@ pub mod modification; pub mod monologue; pub mod movement; pub mod movement_plugin; +pub mod name_index; pub mod npc_components; pub mod npc_knowledge_transfer; pub mod path_follow; @@ -59,6 +60,10 @@ pub struct SimulationPlugin { impl Plugin for SimulationPlugin { fn build(&self, app: &mut App) { + // Phase ordering must be configured before any system is registered. + // TickPhase::configure is idempotent — safe if main.rs calls it again. + crate::tick_phases::TickPhase::configure(app); + // Tier marker components (D-026) — must register before behavior systems app.add_plugins(tier::TierPlugin); diff --git a/server/src/simulation/name_index.rs b/server/src/simulation/name_index.rs new file mode 100644 index 000000000..3d877091d --- /dev/null +++ b/server/src/simulation/name_index.rs @@ -0,0 +1,261 @@ +//! SystemNameIndex — Aho-Corasick automaton for event-driven pre-generation (D-206). +//! +//! Loaded once at startup from `systems.db`. Scans NPC dialogue and news ticker +//! text; any match names a body_id to enqueue for background generation at Low +//! priority (D-206 §event-driven pre-generation). +//! +//! The automaton is case-insensitive and matches overlapping patterns so that +//! "New Chengdu" and "Chengdu" both fire independently when present. + +use std::path::Path; + +use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind}; +use bevy_ecs::prelude::*; +use rusqlite::{Connection, OpenFlags}; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// A single match returned by [`SystemNameIndex::scan`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NameMatch { + /// The `body_id` (or `system_id`) that was matched. + pub id: String, + /// The matched text span (byte offsets into the input string). + pub start: usize, + pub end: usize, +} + +// --------------------------------------------------------------------------- +// Resource +// --------------------------------------------------------------------------- + +/// Aho-Corasick automaton over all body/system/station proper names in systems.db. +/// +/// Built once from the DB at startup; immutable thereafter. +/// All queries are `O(n)` in the length of the scanned text regardless of +/// how many names the automaton holds. +/// +/// Returned IDs are body_ids for bodies/stations, or system_ids for star systems +/// that have no body entries. The caller (background generation queue, D-206) +/// decides which IDs are actionable. +#[derive(Resource)] +pub struct SystemNameIndex { + automaton: AhoCorasick, + /// Maps automaton pattern index → the body_id / system_id it represents. + ids: Vec, +} + +impl SystemNameIndex { + /// Build the index from `systems.db` at `path`. + /// + /// Loads proper names from `bodies`, `stations`, and `star_systems`. + /// Returns `None` on DB open failure (logged at warn level; the game + /// runs without the index, just without event-driven pre-generation). + pub fn load(path: &Path) -> Option { + let conn = match Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) { + Ok(c) => c, + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "SystemNameIndex: failed to open systems.db"); + return None; + } + }; + + let entries = match collect_names(&conn) { + Ok(e) => e, + Err(e) => { + tracing::warn!(error = %e, "SystemNameIndex: failed to collect names"); + return None; + } + }; + + if entries.is_empty() { + tracing::warn!("SystemNameIndex: no names found in systems.db — index empty"); + } + + let (patterns, ids): (Vec, Vec) = entries.into_iter().unzip(); + + let automaton = match AhoCorasickBuilder::new() + .ascii_case_insensitive(true) + .match_kind(MatchKind::LeftmostFirst) + .build(&patterns) + { + Ok(a) => a, + Err(e) => { + tracing::error!(error = %e, "SystemNameIndex: automaton build failed — index unavailable"); + return None; + } + }; + + tracing::info!(pattern_count = ids.len(), "SystemNameIndex built"); + Some(Self { automaton, ids }) + } + + /// Scan `text` and return all name matches. + /// + /// Each match carries the body_id / system_id and the byte span. + /// Overlapping matches are not reported (leftmost-first wins per AhoCorasick + /// `MatchKind::LeftmostFirst`). + pub fn scan(&self, text: &str) -> Vec { + self.automaton + .find_iter(text) + .map(|m| NameMatch { + id: self.ids[m.pattern().as_usize()].clone(), + start: m.start(), + end: m.end(), + }) + .collect() + } + + /// How many patterns the automaton holds (for diagnostics). + pub fn pattern_count(&self) -> usize { + self.ids.len() + } +} + +// --------------------------------------------------------------------------- +// DB helpers +// --------------------------------------------------------------------------- + +fn collect_names(conn: &Connection) -> rusqlite::Result> { + let mut entries: Vec<(String, String)> = Vec::new(); + + // Bodies — use proper_name only (body_id like "GJ-15Ab" is not natural language) + { + let mut stmt = conn.prepare( + "SELECT body_id, proper_name FROM bodies WHERE proper_name IS NOT NULL AND proper_name != ''", + )?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?)) + })?; + for row in rows { + entries.push(row?); + } + } + + // Stations + { + let mut stmt = conn.prepare( + "SELECT station_id, proper_name FROM stations WHERE proper_name IS NOT NULL AND proper_name != ''", + )?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(1)?, row.get::<_, String>(0)?)) + })?; + for row in rows { + entries.push(row?); + } + } + + // Star systems — include both system_name and proper_name as separate patterns + // so "Van Maanen's Star" and "GJ 35" both trigger if used in dialogue. + { + let mut stmt = + conn.prepare("SELECT system_id, system_name, proper_name FROM star_systems")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + for row in rows { + let (system_id, system_name, proper_name) = row?; + if let Some(name) = system_name { + if !name.is_empty() { + entries.push((name, system_id.clone())); + } + } + if let Some(name) = proper_name { + if !name.is_empty() { + entries.push((name, system_id.clone())); + } + } + } + } + + Ok(entries) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind}; + + /// Build a minimal index directly (no DB) for unit testing. + fn make_index(pairs: &[(&str, &str)]) -> SystemNameIndex { + let (patterns, ids): (Vec<&str>, Vec) = + pairs.iter().map(|(p, id)| (*p, id.to_string())).unzip(); + let automaton = AhoCorasickBuilder::new() + .ascii_case_insensitive(true) + .match_kind(MatchKind::LeftmostFirst) + .build(&patterns) + .unwrap(); + SystemNameIndex { automaton, ids } + } + + #[test] + fn scan_finds_exact_match() { + let idx = make_index(&[("Xin Chengdu", "GJ-380c")]); + let matches = idx.scan("The freighter docked at Xin Chengdu yesterday."); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, "GJ-380c"); + } + + #[test] + fn scan_is_case_insensitive() { + let idx = make_index(&[("Horizon Station", "GJ-380-oort-S1")]); + let matches = idx.scan("HORIZON STATION cargo rates up 12%."); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, "GJ-380-oort-S1"); + } + + #[test] + fn scan_returns_empty_on_no_match() { + let idx = make_index(&[("Xin Chengdu", "GJ-380c")]); + let matches = idx.scan("Nothing here matches."); + assert!(matches.is_empty()); + } + + #[test] + fn scan_returns_multiple_distinct_matches() { + let idx = make_index(&[ + ("Xin Chengdu", "GJ-380c"), + ("Horizon Station", "GJ-380-oort-S1"), + ]); + let text = "Xin Chengdu imports from Horizon Station."; + let matches = idx.scan(text); + assert_eq!(matches.len(), 2); + let ids: Vec<&str> = matches.iter().map(|m| m.id.as_str()).collect(); + assert!(ids.contains(&"GJ-380c")); + assert!(ids.contains(&"GJ-380-oort-S1")); + } + + #[test] + fn scan_span_is_correct() { + let idx = make_index(&[("Chengdu", "GJ-380c")]); + let text = "0123456Chengdu rest"; + let matches = idx.scan(text); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].start, 7); + assert_eq!(matches[0].end, 14); + assert_eq!(&text[matches[0].start..matches[0].end], "Chengdu"); + } + + #[test] + fn empty_index_scans_without_panic() { + let idx = make_index(&[]); + let matches = idx.scan("Any text at all."); + assert!(matches.is_empty()); + } + + #[test] + fn pattern_count_matches_entries() { + let idx = make_index(&[("Alpha", "sys-1"), ("Beta", "sys-2"), ("Gamma", "sys-3")]); + assert_eq!(idx.pattern_count(), 3); + } +} diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 3e4ec9109..f7da9c7d8 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -38,7 +38,6 @@ "state_hash": 14452262397297540338, "tick": 8, "triangle_crisis_events": [], - "version": 19, "visible_tiles": [ { "tile_kind": "Floor", diff --git a/tooling/check-systems-db-stamp b/tooling/check-systems-db-stamp index db985b8e8..b5caa2791 100644 --- a/tooling/check-systems-db-stamp +++ b/tooling/check-systems-db-stamp @@ -49,6 +49,11 @@ GENERATOR_SOURCES: dict[str, list[Path]] = { ], "generate_atlas": [ REPO_ROOT / "tooling" / "planet-gen" / "generate_atlas.py", + REPO_ROOT / "tooling" / "planet-gen" / "gemma_naming.py", + REPO_ROOT / "tooling" / "planet-gen" / "naming_core.py", + REPO_ROOT / "tooling" / "planet-gen" / "import_city_names.py", + REPO_ROOT / "tooling" / "planet-gen" / "import_heightmaps.py", + REPO_ROOT / "tooling" / "planet-gen" / "import_province_boundaries.py", REPO_ROOT / "tooling" / "schema_version.py", ], } diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index 67b8512d0..096e0bb23 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -24,6 +24,7 @@ Usage: """ import argparse +import glob import hashlib import json import re @@ -44,6 +45,7 @@ COMMODITIES_TOML = REPO_ROOT / "wiki" / "economics" / "commodities.toml" CHAINS_TOML = REPO_ROOT / "wiki" / "economics" / "production_chains.toml" SCHEMA_SQL = REPO_ROOT / "server" / "data" / "systems-schema.sql" CORPORATIONS_DIR = REPO_ROOT / "wiki" / "corporations" +WIKI_STAR_SYSTEMS = REPO_ROOT / "wiki" / "star-systems" BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "brands.toml" GENERATED_BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "generated_brands.toml" # Rust sources for the generate_brands subroutine. import_economics shells out to @@ -287,6 +289,74 @@ CREATE TABLE IF NOT EXISTS meta ( -- own meta row. This DELETE makes the check-systems-db-stamp "unknown generator" -- path (fail-closed per T6) compatible with older DBs that still have the row. DELETE FROM meta WHERE generator_name = 'generate_brands'; + +-- Heightmap BLOB storage (D-202, #901) +CREATE TABLE IF NOT EXISTS atlas_body_heightmaps ( + body_id TEXT PRIMARY KEY REFERENCES bodies(body_id) ON DELETE CASCADE, + width INTEGER NOT NULL DEFAULT 512, + height INTEGER NOT NULL DEFAULT 256, + data BLOB NOT NULL, + sea_level REAL NOT NULL DEFAULT 0.0, + imported_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id); + +-- City name reservations (D-207, #902) +CREATE TABLE IF NOT EXISTS atlas_city_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'city', + economic_role TEXT NOT NULL, + population INTEGER NOT NULL, + settlement_class TEXT, + corp_id TEXT REFERENCES corporations(corp_id), + reserved INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind); +CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id); + +-- Geographic feature name reservations (#903) +CREATE TABLE IF NOT EXISTS atlas_feature_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + name TEXT NOT NULL, + feature_type TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id); + +-- Province boundaries (D-205, #904) +CREATE TABLE IF NOT EXISTS atlas_province_boundaries ( + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + basin_id INTEGER NOT NULL, + path TEXT NOT NULL, + area_pct REAL NOT NULL, + PRIMARY KEY (body_id, basin_id) +); +CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id); + +-- City positions — attractor-matched placement output (D-211, #34) +CREATE TABLE IF NOT EXISTS atlas_city_positions ( + city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE, + body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE, + row INTEGER NOT NULL, + col INTEGER NOT NULL, + attractor_type TEXT NOT NULL, + score REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id); + +-- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911). +-- Idempotent: each UPDATE is a no-op if the old value is already gone. +UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture'); +UPDATE bodies SET economic_role = 'extraction' WHERE economic_role IN ('mining', 'resource_extraction', 'energy'); +UPDATE bodies SET economic_role = 'transit_hub' WHERE economic_role = 'transit'; +UPDATE bodies SET economic_role = 'service_mixed' WHERE economic_role IN ('commercial', 'coordination'); +UPDATE bodies SET economic_role = 'residential' WHERE economic_role = 'frontier'; """ # Columns to add to existing tables (ALTER TABLE is idempotent via try/except) @@ -297,7 +367,9 @@ COLUMN_MIGRATIONS = [ ("corporations", "supply_chain_role", "TEXT"), ("corporations", "shadow_economy_access", "INTEGER DEFAULT 0"), ("brand_products", "price_tier", "TEXT"), + ("bodies", "body_radius_km", "REAL"), # D-204 — physical radius in km, nullable ("meta", "schema_sha", "TEXT"), + ("atlas_city_names", "settlement_class", "TEXT"), # D-196 — NULL until placement (#37) ] @@ -783,6 +855,29 @@ def validate(conn: sqlite3.Connection) -> list[str]: for chain_id, cid in orphan_outputs: errors.append(f"production_chains: chain '{chain_id}' outputs unknown commodity '{cid}'") + # economic_role must be one of the D-194 canonical 10 values + valid_roles = { + 'manufacturing', 'financial', 'agricultural', 'extraction', + 'service_mixed', 'institutional', 'transit_hub', 'research', + 'military', 'residential', + } + bad_roles = conn.execute(""" + SELECT DISTINCT economic_role, COUNT(*) as cnt + FROM bodies + WHERE economic_role IS NOT NULL + AND economic_role NOT IN ( + 'manufacturing', 'financial', 'agricultural', 'extraction', + 'service_mixed', 'institutional', 'transit_hub', 'research', + 'military', 'residential' + ) + GROUP BY economic_role + """).fetchall() + for role, cnt in bad_roles: + errors.append( + f"bodies.economic_role: non-canonical value '{role}' on {cnt} row(s) — " + f"valid values: {sorted(valid_roles)}" + ) + # Chain completeness: every intermediate commodity must have at least one producer missing_chains = conn.execute(""" SELECT c.commodity_id, c.name @@ -987,6 +1082,228 @@ def import_system_fiscal(conn: sqlite3.Connection, dry_run: bool) -> int: return len(rows) +def populate_body_radius_km(conn: sqlite3.Connection, dry_run: bool) -> int: + """Populate body_radius_km column from planet_class fallback (D-204, #910). + + Applies the fallback lookup table to rows where body_radius_km IS NULL. + Does not overwrite rows where body_radius_km is already set (authoritative data). + + Fallback values (km): + super_earth -> 8000 + earth_like -> 6371 + earth -> 6371 (alternate spelling) + sub_earth -> 4500 + ocean_world -> 6500 + arid -> 5800 + frozen -> 4500 + ice_world -> 3000 + barren -> 4500 + volcanic -> 5500 + gas_giant -> 0 (no settlements, skip) + moon -> 1737 + other/unknown -> 6371 (Earth default) + """ + PLANET_CLASS_RADIUS = { + "super_earth": 8000.0, + "earth_like": 6371.0, + "earth": 6371.0, + "sub_earth": 4500.0, + "ocean_world": 6500.0, + "arid": 5800.0, + "frozen": 4500.0, + "ice_world": 3000.0, + "barren": 4500.0, + "volcanic": 5500.0, + "temperate": 6371.0, + "moon": 1737.0, + } + DEFAULT_RADIUS = 6371.0 + + rows = conn.execute( + "SELECT body_id, planet_class FROM bodies WHERE body_radius_km IS NULL" + ).fetchall() + + updates = [] + for body_id, planet_class in rows: + if planet_class and planet_class.lower() == "gas_giant": + continue # gas giants have no settlements; leave NULL + radius = PLANET_CLASS_RADIUS.get( + (planet_class or "").lower(), DEFAULT_RADIUS + ) + updates.append((radius, body_id)) + + if not dry_run and updates: + conn.executemany( + "UPDATE bodies SET body_radius_km = ? WHERE body_id = ?", updates + ) + + return len(updates) + + +def populate_atlas_city_names(conn: sqlite3.Connection, dry_run: bool) -> int: + """Populate atlas_city_names from wiki markers.json city entries (D-207, #908). + + Scans wiki/star-systems/*/bodies/*/markers.json for 'cities' arrays. + Each entry yields one atlas_city_names row: + - body_id : directory name (e.g. GJ0e) + - name : city name from markers.json + - kind : 'capital' or 'city' (default 'city') + - economic_role : inherited from bodies.economic_role; fallback 'mixed' + - population : from markers.json (integer) + - corp_id : NULL — populated by populate_atlas_city_names_corps (#909) + - reserved : 0 + + Uses INSERT OR REPLACE so re-runs are idempotent per (body_id, name). + Skips body directories not found in the bodies table (missing FK). + """ + # Build body_id -> economic_role map + body_roles: dict[str, str] = {} + for body_id, role in conn.execute( + "SELECT body_id, economic_role FROM bodies" + ).fetchall(): + body_roles[body_id] = role or "mixed" + + valid_body_ids: set[str] = set(body_roles.keys()) + + rows: list[tuple] = [] + skipped_bodies: list[str] = [] + + pattern = str(WIKI_STAR_SYSTEMS / "*" / "bodies" / "*" / "markers.json") + for markers_path in sorted(glob.glob(pattern)): + body_id = markers_path.split("/bodies/")[1].split("/")[0] + if body_id not in valid_body_ids: + skipped_bodies.append(body_id) + continue + + with open(markers_path) as fh: + data = json.load(fh) + + for city in data.get("cities", []): + name = city.get("name", "").strip() + if not name: + continue + kind = city.get("kind", "city") + population = int(city.get("population", 0)) + economic_role = body_roles[body_id] + rows.append((body_id, name, kind, economic_role, population)) + + if skipped_bodies: + unique = sorted(set(skipped_bodies)) + print(f" warning: {len(unique)} body dirs not in DB — skipped: {unique[:5]}") + + if not dry_run and rows: + conn.executemany( + """INSERT OR REPLACE INTO atlas_city_names + (body_id, name, kind, economic_role, population) + VALUES (?, ?, ?, ?, ?)""", + rows, + ) + + return len(rows) + + +def populate_atlas_city_names_corps(conn: sqlite3.Connection, dry_run: bool) -> tuple[int, int]: + """Cross-reference corp HQ city names into atlas_city_names (D-207, #909). + + For each corporation with a parseable headquarters field ("City (SYSTEM_ID)"): + - If atlas_city_names already has a row with matching name on a body in that + system: UPDATE the row to set corp_id. + - Otherwise: INSERT a reserved row (reserved=1) so the name is protected. + Attaches to the most-populated body in the system (fallback: any body). + + Returns (n_updated, n_inserted). + """ + # Build system_id -> sorted bodies (by population desc, then body_id) + sys_bodies: dict[str, list[tuple[int, str, str]]] = {} + for body_id, sys_id, pop, role in conn.execute( + "SELECT body_id, system_id, COALESCE(population, 0), COALESCE(economic_role, 'mixed') FROM bodies" + ).fetchall(): + sys_bodies.setdefault(sys_id, []).append((pop, body_id, role)) + for v in sys_bodies.values(): + v.sort(key=lambda x: (-x[0], x[1])) + + # Build (body_id, name_lower) -> id index for existing atlas_city_names rows + existing: dict[tuple[str, str], int] = {} + body_to_sys: dict[str, str] = { + r[0]: r[1] + for r in conn.execute("SELECT body_id, system_id FROM bodies").fetchall() + } + for row_id, body_id, name in conn.execute( + "SELECT id, body_id, name FROM atlas_city_names" + ).fetchall(): + existing[(body_id, name.lower())] = row_id + + # Build system_id -> set of body_ids for quick lookup + sys_body_ids: dict[str, set[str]] = {} + for body_id, sys_id in body_to_sys.items(): + sys_body_ids.setdefault(sys_id, set()).add(body_id) + + updated: list[tuple[str, int]] = [] # (corp_id, atlas_row_id) + inserted: list[tuple] = [] # insert rows + + for corp_id, headquarters_system in conn.execute( + "SELECT corp_id, headquarters_system FROM corporations WHERE headquarters_system IS NOT NULL" + ).fetchall(): + # Retrieve original headquarters string from wiki to get city name + md_file = CORPORATIONS_DIR / f"{corp_id}.md" + if not md_file.exists(): + continue + hq_raw = "" + with open(md_file) as f: + in_fm = False + for line in f: + if line.strip() == "---": + if not in_fm: + in_fm = True + continue + else: + break + if in_fm and line.startswith("headquarters:"): + hq_raw = line.split(":", 1)[1].strip().strip('"') + break + if not hq_raw: + continue + m = re.search(r"\(([^)]+)\)", hq_raw) + city_name = hq_raw[: m.start()].strip() if m else hq_raw.strip() + if not city_name: + continue + + # Try to find a matching atlas_city_names row in the same system + body_ids_in_sys = sys_body_ids.get(headquarters_system, set()) + match_id: int | None = None + for body_id in body_ids_in_sys: + key = (body_id, city_name.lower()) + if key in existing: + match_id = existing[key] + break + + if match_id is not None: + updated.append((corp_id, match_id)) + else: + # Insert a reserved row on the most-populated body in the system + candidates = sys_bodies.get(headquarters_system, []) + if not candidates: + continue + _, target_body_id, body_role = candidates[0] + inserted.append((target_body_id, city_name, "city", body_role, 0, corp_id, 1)) + + if not dry_run: + for corp_id, row_id in updated: + conn.execute( + "UPDATE atlas_city_names SET corp_id = ? WHERE id = ?", + (corp_id, row_id), + ) + if inserted: + conn.executemany( + """INSERT OR IGNORE INTO atlas_city_names + (body_id, name, kind, economic_role, population, corp_id, reserved) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + inserted, + ) + + return len(updated), len(inserted) + + def validate_brands(conn: sqlite3.Connection) -> list[str]: """Brand layer structural validation rules V-B01 through V-B06. @@ -1227,10 +1544,25 @@ def main(): print(f" {n_brands} brand_products, {n_brand_inputs} brand_inputs") # 10. System fiscal parameters (D-189 section 6) - print(" [10/10] Populating system_fiscal...") + print(" [10/13] Populating system_fiscal...") n_fiscal = import_system_fiscal(conn, args.dry_run) print(f" {n_fiscal} system_fiscal rows") + # 11. body_radius_km fallback from planet_class (D-204, #910) + print(" [11/13] Populating body_radius_km fallback...") + n_radius = populate_body_radius_km(conn, args.dry_run) + print(f" {n_radius} bodies updated") + + # 12. atlas_city_names from wiki markers.json (D-207, #908) + print(" [12/13] Populating atlas_city_names from wiki content...") + n_cities = populate_atlas_city_names(conn, args.dry_run) + print(f" {n_cities} city name rows") + + # 13. atlas_city_names corp HQ cross-reference (D-207, #909) + print(" [13/13] Cross-referencing corp HQ cities into atlas_city_names...") + n_updated, n_inserted = populate_atlas_city_names_corps(conn, args.dry_run) + print(f" {n_updated} rows updated, {n_inserted} reserved rows inserted") + # Validate structural integrity (FK, chain refs, chain completeness). # These errors indicate broken imported data — do NOT commit. print("\n Validating structural integrity...") diff --git a/tooling/planet-gen/gemma_naming.py b/tooling/planet-gen/gemma_naming.py index d7b8d35cc..96a788788 100755 --- a/tooling/planet-gen/gemma_naming.py +++ b/tooling/planet-gen/gemma_naming.py @@ -1842,6 +1842,19 @@ def process_body( substyles = CORRIDOR_SUBSTYLES.get(corridor, DEFAULT_SUBSTYLES) mood = mood_for_body(body_id, world_seed) + # Build cultural-history context for the prompt (#886 §6): + # Collect the inflection descriptions of all *secondary* registers in + # this corridor so the model sees the full settlement layering — e.g. + # "Scottish Highland" as primary, but also the Irish and Australian + # substyles that represent earlier or interleaved waves of settlers. + # Limited to 3 secondary styles to keep the prompt concise. + _secondary_inflections = [ + s["inflection"] for s in substyles if s["inflection"] != inflection + ][:3] + cultural_history: str | None = ( + "; ".join(_secondary_inflections) if _secondary_inflections else None + ) + # Helper: batch-name blank features in a marker section def _batch_fill( section_key: str, @@ -1895,6 +1908,7 @@ def process_body( mood=mood, body_id=body_id, world_seed=world_seed, + cultural_history=cultural_history, ctx_size=voice.ctx_size, ) @@ -1926,11 +1940,11 @@ def process_body( _batch_fill("mountain_ranges", lambda f: "mountain_range", "mountain_ranges") _batch_fill("pois", _feature_type_for_poi, "pois") - # Mountain suffix monotony check (#853 §3): + # Mountain suffix monotony check + auto-fix (#853 §3, #886 §3): # If >40% of mountain names on a single body share a trailing word, - # flag it. We don't re-query in the batch pipeline (no voice access here) - # but record a warning so the batch runner can surface bodies that need - # a targeted re-run. + # re-query with the offending names added to `taken` so the model is + # forced to diversify. One retry per body; if the retry still clusters + # (rare), record a warning for post-run inspection. mountain_names = [ f.get("name", "") for f in (markers.get("mountain_ranges") or []) if f.get("name") @@ -1944,11 +1958,64 @@ def process_body( dominant = max(suffix_counts, key=lambda k: suffix_counts[k]) dominant_frac = suffix_counts[dominant] / len(mountain_names) if dominant_frac > 0.40: - counts["suffix_monotony_warning"] = ( - f"mountain suffix '{dominant}' on " - f"{suffix_counts[dominant]}/{len(mountain_names)} " - f"features ({dominant_frac:.0%}) — re-run targeting this body" + # Targeted retry: identify features with the dominant suffix, + # re-request names for them with the monotonous names as `taken`. + offending_features = [ + f for f in (markers.get("mountain_ranges") or []) + if f.get("name") and f["name"].split()[-1].lower() == dominant + ] + retry_taken = ( + list(body_used) + + list(corpus.get((corridor, "mountain_range"), set())) ) + retry_names = name_features_batch( + voice=voice, + feature_type="mountain_range", + count=len(offending_features), + inflection=inflection, + corridor=corridor, + corridor_substyles=substyles, + taken=retry_taken, + prompt_config=_PROMPT_CONFIG, + system_name=ctx.get("system_proper_name"), + body_name=ctx.get("body_proper_name"), + system_hook=system_hook, + mood=mood, + body_id=body_id, + world_seed=world_seed + 1, # bump seed to force different output + cultural_history=cultural_history, + ctx_size=voice.ctx_size, + ) + for i, feat in enumerate(offending_features): + if i < len(retry_names): + old_name = feat["name"] + feat["name"] = retry_names[i] + body_used.discard(old_name) + body_used.add(retry_names[i]) + corpus.setdefault((corridor, "mountain_range"), set()).discard(old_name) + corpus.setdefault((corridor, "mountain_range"), set()).add(retry_names[i]) + changed = True + # Re-check after retry; record warning if still clustered + mountain_names_after = [ + f.get("name", "") for f in (markers.get("mountain_ranges") or []) + if f.get("name") + ] + suffix_counts_after: dict[str, int] = {} + for mn in mountain_names_after: + words = mn.split() + if words: + suffix_counts_after[words[-1].lower()] = ( + suffix_counts_after.get(words[-1].lower(), 0) + 1 + ) + if suffix_counts_after: + dominant_after = max(suffix_counts_after, key=lambda k: suffix_counts_after[k]) + dominant_frac_after = suffix_counts_after[dominant_after] / len(mountain_names_after) + if dominant_frac_after > 0.40: + counts["suffix_monotony_warning"] = ( + f"mountain suffix '{dominant_after}' still on " + f"{suffix_counts_after[dominant_after]}/{len(mountain_names_after)} " + f"features ({dominant_frac_after:.0%}) after retry" + ) # Infrastructure naming (#853 §7): # Assign deterministic city-pair names to unnamed roads and railroads. diff --git a/tooling/planet-gen/generate_atlas.py b/tooling/planet-gen/generate_atlas.py index 4689e3b6b..c12878ad1 100644 --- a/tooling/planet-gen/generate_atlas.py +++ b/tooling/planet-gen/generate_atlas.py @@ -125,7 +125,16 @@ def _write_stamp(conn: sqlite3.Connection) -> None: a double-commit with the atlas data write that precedes it. """ schema_sha = _file_sha1(SYSTEMS_SCHEMA_PATH) - generator_sha = _file_sha1(Path(__file__), REPO_ROOT / "tooling" / "schema_version.py") + _atlas_dir = Path(__file__).parent + generator_sha = _file_sha1( + Path(__file__), + _atlas_dir / "gemma_naming.py", + _atlas_dir / "naming_core.py", + _atlas_dir / "import_city_names.py", + _atlas_dir / "import_heightmaps.py", + _atlas_dir / "import_province_boundaries.py", + REPO_ROOT / "tooling" / "schema_version.py", + ) conn.execute( """INSERT OR REPLACE INTO meta (generator_name, schema_version, schema_sha, generator_sha, generated_at) diff --git a/tooling/planet-gen/import_city_names.py b/tooling/planet-gen/import_city_names.py new file mode 100644 index 000000000..9571563a9 --- /dev/null +++ b/tooling/planet-gen/import_city_names.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +import_city_names.py — Populate atlas_city_names from wiki markers.json content. + +For each inhabited body, reads city records from markers.json and inserts rows +into atlas_city_names with: + - name, kind, population from markers.json + - economic_role from bodies table + - corp_id from corporations.headquarters_body cross-reference (#909) + +Incremental: clears and reimports all rows for each body on every run (the +table has no stable local IDs — city identity is name × body_id). Use --body +to restrict to a single body. + +Usage: + tooling/planet-gen/import_city_names.py + tooling/planet-gen/import_city_names.py --body GJ380c + tooling/planet-gen/import_city_names.py --dry-run + +Exit codes: + 0 completed + 1 fatal error (missing DB, schema error) +""" + +import argparse +import json +import sys +import time +from pathlib import Path + +TOOLING_DIR = Path(__file__).resolve().parent +REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve() + +_venv_python = REPO_ROOT / ".venv" / "bin" / "python" +if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve(): + import os + os.execv(str(_venv_python), [str(_venv_python)] + sys.argv) + +import sqlite3 + +from generate_atlas import ( + DB_PATH, + ensure_atlas_schema, + query_inhabited_bodies, +) + + +def _build_hq_index(conn: sqlite3.Connection) -> dict[str, str]: + """Build a mapping of body_id → corp_id for all corp HQ locations.""" + rows = conn.execute( + "SELECT headquarters_body, corp_id FROM corporations " + "WHERE headquarters_body IS NOT NULL" + ).fetchall() + index: dict[str, str] = {} + for body_id, corp_id in rows: + # If multiple corps have the same HQ body, take the first (alphabetical + # corp_id for determinism). This is unlikely but safe. + if body_id not in index: + index[body_id] = corp_id + return index + + +def _load_city_records(body_dir: Path) -> list[dict]: + """Load named city records from markers.json. Returns empty list if none.""" + markers_path = body_dir / "markers.json" + if not markers_path.exists(): + return [] + try: + markers = json.loads(markers_path.read_text()) + except json.JSONDecodeError: + return [] + return [ + c for c in (markers.get("cities") or []) + if c.get("name") and isinstance(c["name"], str) and c["name"].strip() + ] + + +def import_body_cities( + body_info: dict, + conn: sqlite3.Connection, + hq_index: dict[str, str], + dry_run: bool, + verbose: bool, +) -> dict: + """Import atlas_city_names rows for one body. + + Returns a dict with: + status: 'imported' | 'no_cities' | 'error' + imported: count of rows written + message: detail (on error) + """ + body_id = body_info["body_id"] + terrain_ref = body_info["terrain_reference"] + economic_role = body_info.get("economic_role") or "unknown" + corp_id = hq_index.get(body_id) + + body_dir = REPO_ROOT / Path(terrain_ref).parent + cities = _load_city_records(body_dir) + + if not cities: + return {"status": "no_cities", "imported": 0} + + if verbose: + print(f" {body_id}: {len(cities)} cities, economic_role={economic_role}" + + (f", corp_hq={corp_id}" if corp_id else "")) + + if not dry_run: + # Full rebuild for this body: delete existing rows, re-insert. + conn.execute("DELETE FROM atlas_city_names WHERE body_id = ?", (body_id,)) + + for city in cities: + name = city["name"].strip() + kind = city.get("kind") or "city" + population = int(city.get("population") or 0) + # Only set corp_id on the capital city of a corp HQ body. + city_corp_id = corp_id if (kind == "capital" and corp_id) else None + + conn.execute( + """INSERT INTO atlas_city_names + (body_id, name, kind, economic_role, population, corp_id, + reserved, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, datetime('now'))""", + (body_id, name, kind, economic_role, population, city_corp_id), + ) + + return {"status": "imported", "imported": len(cities)} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Populate atlas_city_names from wiki markers.json (#908, #909)" + ) + parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db") + parser.add_argument("--body", help="Process only this body_id") + parser.add_argument("--dry-run", action="store_true", + help="Read and validate without writing to DB") + parser.add_argument("--verbose", action="store_true", + help="Print per-body detail") + args = parser.parse_args() + + db_path = Path(args.db) + if not db_path.exists(): + print(f"error: {db_path} not found", file=sys.stderr) + sys.exit(1) + + print(f"\n City Names Import (#908 + #909)") + print(f" DB: {db_path}") + if args.dry_run: + print(f" Mode: DRY RUN (no DB writes)") + print() + + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON") + ensure_atlas_schema(conn) + + hq_index = _build_hq_index(conn) + + bodies = query_inhabited_bodies(conn) + if args.body: + bodies = [b for b in bodies if b["body_id"] == args.body] + if not bodies: + print(f"error: body '{args.body}' not found or has no terrain_reference", + file=sys.stderr) + conn.close() + sys.exit(1) + + print(f" {len(bodies)} inhabited bodies with terrain_reference") + print(f" {len(hq_index)} corp HQ body mappings\n") + + t_total = time.time() + n_imported = 0 + n_no_cities = 0 + n_errors = 0 + total_rows = 0 + + for i, body_info in enumerate(bodies): + body_id = body_info["body_id"] + + result = import_body_cities(body_info, conn, hq_index, args.dry_run, args.verbose) + status = result["status"] + + if status == "imported": + n_imported += 1 + total_rows += result["imported"] + if args.verbose: + print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} cities") + elif status == "no_cities": + n_no_cities += 1 + elif status == "error": + n_errors += 1 + print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}") + + if not args.dry_run: + conn.commit() + + conn.close() + + elapsed = time.time() - t_total + print(f"\n Done in {elapsed:.1f}s") + print(f" bodies_with_cities={n_imported} no_cities={n_no_cities} " + f"errors={n_errors} total_rows={total_rows}") + + +if __name__ == "__main__": + main() diff --git a/tooling/planet-gen/import_heightmaps.py b/tooling/planet-gen/import_heightmaps.py new file mode 100644 index 000000000..4b3f53e9b --- /dev/null +++ b/tooling/planet-gen/import_heightmaps.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +import_heightmaps.py — Import terrain elevation grids into atlas_body_heightmaps. + +For each inhabited body with a terrain_reference, simulates the terrain via +planet_simulation.simulate() and stores the float32 LE elevation BLOB plus +sea_level metadata in atlas_body_heightmaps (#906, D-202). + +The BLOB format matches the Rust loader spec (D-202): + - float32 little-endian, row-major + - width × height values, each in [0.0, 1.0] + - width = GRID_W (512), height = GRID_H (256) + +Incremental: bodies that already have a row in atlas_body_heightmaps are +skipped unless --force is passed. + +Usage: + tooling/planet-gen/import_heightmaps.py + tooling/planet-gen/import_heightmaps.py --body GJ380c + tooling/planet-gen/import_heightmaps.py --force + tooling/planet-gen/import_heightmaps.py --dry-run + +Exit codes: + 0 completed (possibly with skipped or errored bodies) + 1 fatal error (missing DB, schema error) +""" + +import argparse +import sys +import time +from pathlib import Path + +TOOLING_DIR = Path(__file__).resolve().parent +REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve() + +_venv_python = REPO_ROOT / ".venv" / "bin" / "python" +if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve(): + import os + os.execv(str(_venv_python), [str(_venv_python)] + sys.argv) + +import numpy as np +import sqlite3 + +from generate_atlas import ( + GRID_W, + GRID_H, + DB_PATH, + ensure_atlas_schema, + load_body_def, + query_inhabited_bodies, +) +from planet_simulation import simulate + + +def _elevation_to_blob(elevation: np.ndarray) -> bytes: + """Convert a float32 elevation grid to a little-endian BLOB.""" + arr = elevation.astype(" dict: + """Import heightmap BLOB for one body. + + Returns a dict with: + status: 'imported' | 'skipped' | 'gas_giant' | 'error' + message: detail (on error or skip) + """ + body_id = body_info["body_id"] + terrain_ref = body_info["terrain_reference"] + + # Incremental check — skip if already imported + if not force: + existing = conn.execute( + "SELECT 1 FROM atlas_body_heightmaps WHERE body_id = ?", (body_id,) + ).fetchone() + if existing: + return {"status": "skipped", "message": "already imported"} + + body_dir = REPO_ROOT / Path(terrain_ref).parent + if not body_dir.exists(): + return {"status": "error", "message": f"body_dir not found: {body_dir}"} + + bd = load_body_def(body_dir) + if not bd: + return {"status": "error", "message": f"no body definition found in {body_dir}"} + + try: + terrain = simulate(bd) + except Exception as exc: + return {"status": "error", "message": f"simulate() failed: {exc}"} + + if not terrain: + return {"status": "gas_giant"} + + elevation = terrain.get("elevation") + if elevation is None: + return {"status": "error", "message": "terrain dict missing 'elevation' key"} + + sea_level = float(terrain.get("sea_level", 0.0)) + blob = _elevation_to_blob(elevation) + + if verbose: + land_pct = float(np.mean(elevation >= sea_level)) * 100 + print(f" {body_id}: {GRID_W}x{GRID_H} grid, sea_level={sea_level:.3f}, " + f"land={land_pct:.1f}%, blob={len(blob)} bytes") + + if not dry_run: + conn.execute( + """INSERT INTO atlas_body_heightmaps + (body_id, width, height, data, sea_level, imported_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(body_id) DO UPDATE SET + width = excluded.width, + height = excluded.height, + data = excluded.data, + sea_level = excluded.sea_level, + imported_at = excluded.imported_at""", + (body_id, GRID_W, GRID_H, blob, sea_level), + ) + + return {"status": "imported"} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Import terrain elevation BLOBs into atlas_body_heightmaps (#906)" + ) + parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db") + parser.add_argument("--body", help="Process only this body_id") + parser.add_argument("--force", action="store_true", + help="Re-import even if a row already exists") + parser.add_argument("--dry-run", action="store_true", + help="Simulate without writing to DB") + parser.add_argument("--verbose", action="store_true", + help="Print per-body detail") + args = parser.parse_args() + + db_path = Path(args.db) + if not db_path.exists(): + print(f"error: {db_path} not found", file=sys.stderr) + sys.exit(1) + + print(f"\n Heightmap BLOB Import (#906)") + print(f" DB: {db_path}") + if args.dry_run: + print(f" Mode: DRY RUN (no DB writes)") + if args.force: + print(f" Force: enabled (will overwrite existing rows)") + print() + + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON") + ensure_atlas_schema(conn) + + bodies = query_inhabited_bodies(conn) + if args.body: + bodies = [b for b in bodies if b["body_id"] == args.body] + if not bodies: + print(f"error: body '{args.body}' not found or has no terrain_reference", + file=sys.stderr) + conn.close() + sys.exit(1) + + print(f" {len(bodies)} inhabited bodies with terrain_reference\n") + + t_total = time.time() + n_imported = 0 + n_skipped = 0 + n_gas = 0 + n_errors = 0 + + for i, body_info in enumerate(bodies): + body_id = body_info["body_id"] + t0 = time.time() + + result = import_body(body_info, conn, args.force, args.dry_run, args.verbose) + elapsed = time.time() - t0 + status = result["status"] + + if status == "imported": + n_imported += 1 + print(f" [{i+1}/{len(bodies)}] {body_id:20s} imported ({elapsed:.1f}s)") + elif status == "skipped": + n_skipped += 1 + if args.verbose: + print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped (already imported)") + elif status == "gas_giant": + n_gas += 1 + if args.verbose: + print(f" [{i+1}/{len(bodies)}] {body_id:20s} gas giant — no surface") + elif status == "error": + n_errors += 1 + print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}") + + if not args.dry_run: + conn.commit() + + conn.close() + + elapsed_total = time.time() - t_total + print(f"\n Done in {elapsed_total:.1f}s") + print(f" imported={n_imported} skipped={n_skipped} " + f"gas_giant={n_gas} errors={n_errors}") + + if n_errors > 0: + print(f"\n {n_errors} error(s) — check output above", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tooling/planet-gen/import_province_boundaries.py b/tooling/planet-gen/import_province_boundaries.py new file mode 100644 index 000000000..3da0f25f6 --- /dev/null +++ b/tooling/planet-gen/import_province_boundaries.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +""" +import_province_boundaries.py — Pre-compute province boundaries from watershed analysis. + +For each inhabited body with a heightmap row in atlas_body_heightmaps, runs D8 +drainage analysis to derive drainage basin boundaries and stores them as pixel-space +polylines in atlas_province_boundaries (D-205, D-208, #907). + +Algorithm: + 1. Load float32 elevation BLOB from atlas_body_heightmaps. + 2. Depression-fill: raise sinks to the lowest-outlet neighbor (iterative). + 3. D8 flow direction: assign each cell to its steepest-descent neighbor. + 4. Flow accumulation: upstream cell count per cell (topological sort). + 5. Basin labeling: seed a basin per pour-point (flow-accumulation > threshold); + flood-fill remaining cells following flow direction. + 6. Merge small basins (< 2% area) into the largest adjacent basin. + 7. Clamp basin count to [4, 12] by iterative merging of smallest basins. + 8. Trace boundary polylines between adjacent basins. + 9. Upsert rows into atlas_province_boundaries. + +Province count target: 4–12 per body (D-205). Bodies with low relief get fewer, +larger provinces; high-relief worlds get more. + +Performance: ~3–5s per body on a single CPU core at canonical 512×256 resolution. +The bottleneck is the pure-Python depression-fill + flow-direction scan (O(H×W) each, +~131k cells). For a full run of ~270 inhabited bodies expect ~15–20 minutes. +Hot loops (_depression_fill, _flow_direction) are candidates for NumPy vectorization +if build time becomes a bottleneck; the current scalar implementation is correct +and deterministic, which takes priority at this stage. + +Incremental: bodies that already have rows in atlas_province_boundaries are skipped +unless --force is passed. + +Usage: + tooling/planet-gen/import_province_boundaries.py + tooling/planet-gen/import_province_boundaries.py --body GJ380c + tooling/planet-gen/import_province_boundaries.py --force + tooling/planet-gen/import_province_boundaries.py --dry-run + +Exit codes: + 0 completed + 1 fatal error (missing DB, schema error) +""" + +import argparse +import json +import sys +import time +from pathlib import Path + +TOOLING_DIR = Path(__file__).resolve().parent +REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve() + +_venv_python = REPO_ROOT / ".venv" / "bin" / "python" +if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve(): + import os + os.execv(str(_venv_python), [str(_venv_python)] + sys.argv) + +import numpy as np +import sqlite3 + +from generate_atlas import ( + DB_PATH, + ensure_atlas_schema, + query_inhabited_bodies, +) + +# D8 neighbor offsets: (dr, dc) +_D8 = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)] + +# River threshold from D-208: cells with flow_accumulation > 200 are river cells. +# Province seeds are local flow-accumulation maxima (watershed pour points). +_FLOW_THRESHOLD = 200 + +# Minimum basin area as fraction of total cells before merging into neighbor. +_MIN_BASIN_FRAC = 0.02 + +_PROVINCE_MIN = 4 +_PROVINCE_MAX = 12 + + +def _load_elevation(body_id: str, conn: sqlite3.Connection) -> np.ndarray | None: + """Load float32 LE elevation BLOB from atlas_body_heightmaps.""" + row = conn.execute( + "SELECT data, width, height FROM atlas_body_heightmaps WHERE body_id = ?", + (body_id,), + ).fetchone() + if not row: + return None + data, width, height = row + arr = np.frombuffer(data, dtype=" np.ndarray: + """Simple iterative depression fill: raise sinks to their lowest outlet. + + Uses a shallow iterative pass — good enough for province-scale basins on + 512×256 grids. Not full priority-flood (which is O(N log N)); this O(N·k) + approach converges in ≤10 passes on real heightmaps. + """ + H, W = elev.shape + filled = elev.copy() + for _ in range(10): + changed = False + for r in range(1, H - 1): + for c in range(W): + nbr_min = float("inf") + for dr, dc in _D8: + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H: + nbr_min = min(nbr_min, filled[nr, nc]) + if filled[r, c] < nbr_min: + filled[r, c] = nbr_min + 1e-6 + changed = True + if not changed: + break + return filled + + +def _flow_direction(filled: np.ndarray) -> np.ndarray: + """D8 flow direction: index into _D8 (0–7), or -1 for no outflow (edge/flat).""" + H, W = filled.shape + fdir = np.full((H, W), -1, dtype=np.int8) + for r in range(H): + for c in range(W): + best_drop = 0.0 + best_k = -1 + for k, (dr, dc) in enumerate(_D8): + nr = r + dr + nc = (c + dc) % W + if nr < 0 or nr >= H: + continue + drop = filled[r, c] - filled[nr, nc] + if drop > best_drop: + best_drop = drop + best_k = k + fdir[r, c] = best_k + return fdir + + +def _flow_accumulation(fdir: np.ndarray) -> np.ndarray: + """Flow accumulation via topological sort of the D8 DAG.""" + H, W = fdir.shape + in_degree = np.zeros((H, W), dtype=np.int32) + + for r in range(H): + for c in range(W): + k = int(fdir[r, c]) + if k < 0: + continue + dr, dc = _D8[k] + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H: + in_degree[nr, nc] += 1 + + from collections import deque + queue = deque() + for r in range(H): + for c in range(W): + if in_degree[r, c] == 0: + queue.append((r, c)) + + accum = np.ones((H, W), dtype=np.int32) + while queue: + r, c = queue.popleft() + k = int(fdir[r, c]) + if k < 0: + continue + dr, dc = _D8[k] + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H: + accum[nr, nc] += accum[r, c] + in_degree[nr, nc] -= 1 + if in_degree[nr, nc] == 0: + queue.append((nr, nc)) + + return accum + + +def _label_basins(fdir: np.ndarray, accum: np.ndarray) -> np.ndarray: + """Label each cell with a basin ID via pour-point flood fill. + + Pour points are local flow-accumulation maxima above the river threshold. + Each pour point seeds a basin; remaining cells are labeled by tracing + flow direction back to their pour-point seed. + """ + H, W = fdir.shape + labels = np.full((H, W), -1, dtype=np.int32) + + # Seed one label per local accum maximum above threshold. + # Use a simple scan: a cell is a local maximum if no neighbor has higher accum. + pour_pts: list[tuple[int, int]] = [] + for r in range(H): + for c in range(W): + if accum[r, c] <= _FLOW_THRESHOLD: + continue + is_max = True + for dr, dc in _D8: + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H and accum[nr, nc] > accum[r, c]: + is_max = False + break + if is_max: + pour_pts.append((r, c)) + + # If no pour points (e.g. flat/ocean world), create a single basin. + if not pour_pts: + labels[:] = 0 + return labels + + for basin_id, (r, c) in enumerate(pour_pts): + labels[r, c] = basin_id + + # BFS flood: for each unlabeled cell, follow flow direction until a labeled + # cell is reached; assign that label back along the path. + + def _trace(r0: int, c0: int) -> int: + path: list[tuple[int, int]] = [] + r, c = r0, c0 + for _ in range(H * W): + if labels[r, c] >= 0: + lbl = labels[r, c] + for pr, pc in path: + labels[pr, pc] = lbl + return lbl + path.append((r, c)) + k = int(fdir[r, c]) + if k < 0: + # No outflow — assign basin 0 + lbl = 0 + for pr, pc in path: + labels[pr, pc] = lbl + return lbl + dr, dc = _D8[k] + nr = r + dr + nc = (c + dc) % W + if nr < 0 or nr >= H: + lbl = 0 + for pr, pc in path: + labels[pr, pc] = lbl + return lbl + r, c = nr, nc + # Cycle guard + lbl = 0 + for pr, pc in path: + labels[pr, pc] = lbl + return lbl + + for r in range(H): + for c in range(W): + if labels[r, c] < 0: + _trace(r, c) + + return labels + + +def _merge_small_basins( + labels: np.ndarray, target_min: int, target_max: int +) -> np.ndarray: + """Merge tiny basins into their largest neighbor until count is in [target_min, target_max].""" + H, W = labels.shape + labels = labels.copy() + + def _basin_sizes() -> dict[int, int]: + ids, counts = np.unique(labels, return_counts=True) + return dict(zip(ids.tolist(), counts.tolist())) + + def _neighbors(basin_id: int) -> set[int]: + mask = labels == basin_id + # Dilate mask by 1 pixel in each direction, find adjacent basin IDs. + nbrs: set[int] = set() + rs, cs = np.where(mask) + for r, c in zip(rs.tolist(), cs.tolist()): + for dr, dc in _D8: + nr = r + dr + nc = (c + dc) % W + if 0 <= nr < H: + nbr_id = int(labels[nr, nc]) + if nbr_id != basin_id: + nbrs.add(nbr_id) + return nbrs + + total = H * W + for _ in range(200): + sizes = _basin_sizes() + n_basins = len(sizes) + if n_basins <= target_max and all( + v / total >= _MIN_BASIN_FRAC for v in sizes.values() + ): + break + if n_basins <= target_min: + break + + # Find the smallest basin + smallest_id = min(sizes, key=lambda b: sizes[b]) + smallest_frac = sizes[smallest_id] / total + + if n_basins <= target_max and smallest_frac >= _MIN_BASIN_FRAC: + break + + # Merge into its largest neighbor + nbrs = _neighbors(smallest_id) + if not nbrs: + break + merge_into = max(nbrs, key=lambda b: sizes.get(b, 0)) + labels[labels == smallest_id] = merge_into + + # Re-number contiguously from 0 + unique_ids = sorted(np.unique(labels).tolist()) + remap = {old: new for new, old in enumerate(unique_ids)} + new_labels = np.zeros_like(labels) + for old, new in remap.items(): + new_labels[labels == old] = new + return new_labels + + +def _trace_boundary(labels: np.ndarray, basin_id: int) -> list[list[int]]: + """Trace the outer boundary of a basin as a pixel-space polyline. + + Returns a list of [row, col] points forming the boundary polygon. + Uses a simple contour walk: find all boundary cells (cells adjacent to a + different basin), then sort them by angle from centroid to approximate a + closed polygon. + """ + H, W = labels.shape + mask = labels == basin_id + + # Boundary cells: in this basin AND adjacent to a different basin + boundary: list[tuple[int, int]] = [] + rs, cs = np.where(mask) + for r, c in zip(rs.tolist(), cs.tolist()): + on_boundary = False + for dr, dc in _D8: + nr = r + dr + nc = (c + dc) % W + if nr < 0 or nr >= H: + on_boundary = True + break + if labels[nr, nc] != basin_id: + on_boundary = True + break + if on_boundary: + boundary.append((r, c)) + + if not boundary: + return [] + + # Sort by angle from centroid — produces a rough polygon outline. + arr = np.array(boundary, dtype=np.float32) + centroid_r = float(np.mean(arr[:, 0])) + centroid_c = float(np.mean(arr[:, 1])) + angles = np.arctan2(arr[:, 0] - centroid_r, arr[:, 1] - centroid_c) + order = np.argsort(angles) + + # Subsample if very large — keep at most 500 points for storage efficiency. + pts = [boundary[i] for i in order.tolist()] + if len(pts) > 500: + step = len(pts) // 500 + pts = pts[::step] + + return [[r, c] for r, c in pts] + + +def compute_province_boundaries( + body_id: str, elevation: np.ndarray +) -> list[dict]: + """Run full watershed analysis; return list of basin dicts. + + Each dict: + basin_id: int + path: JSON-serialisable [[row, col], ...] + area_pct: float + """ + H, W = elevation.shape + total_cells = H * W + + filled = _depression_fill(elevation) + fdir = _flow_direction(filled) + accum = _flow_accumulation(fdir) + labels = _label_basins(fdir, accum) + labels = _merge_small_basins(labels, _PROVINCE_MIN, _PROVINCE_MAX) + + unique_ids = sorted(np.unique(labels).tolist()) + basins = [] + for basin_id in unique_ids: + count = int(np.sum(labels == basin_id)) + area_pct = count / total_cells + path = _trace_boundary(labels, basin_id) + if not path: + continue + basins.append({ + "basin_id": basin_id, + "path": path, + "area_pct": area_pct, + }) + + return basins + + +def import_body_provinces( + body_id: str, + conn: sqlite3.Connection, + force: bool, + dry_run: bool, + verbose: bool, +) -> dict: + """Import province boundary rows for one body. + + Returns dict: + status: 'imported' | 'skipped' | 'no_heightmap' | 'error' + imported: count of basins written + message: detail on error/skip + """ + if not force: + existing = conn.execute( + "SELECT COUNT(*) FROM atlas_province_boundaries WHERE body_id = ?", + (body_id,), + ).fetchone()[0] + if existing > 0: + return {"status": "skipped", "imported": 0, + "message": f"already has {existing} rows"} + + elevation = _load_elevation(body_id, conn) + if elevation is None: + return {"status": "no_heightmap", "imported": 0, + "message": "no row in atlas_body_heightmaps"} + + try: + basins = compute_province_boundaries(body_id, elevation) + except Exception as exc: + return {"status": "error", "imported": 0, "message": str(exc)} + + if not basins: + return {"status": "error", "imported": 0, + "message": "no basins produced from watershed analysis"} + + if verbose: + areas = [f"{b['basin_id']}:{b['area_pct']:.1%}" for b in basins] + print(f" {body_id}: {len(basins)} basins — {', '.join(areas)}") + + if not dry_run: + with conn: # per-body transaction (BEGIN/COMMIT): rolls back this body on exception, keeps prior commits + conn.execute( + "DELETE FROM atlas_province_boundaries WHERE body_id = ?", + (body_id,), + ) + for b in basins: + conn.execute( + """INSERT INTO atlas_province_boundaries + (body_id, basin_id, path, area_pct) + VALUES (?, ?, ?, ?)""", + (body_id, b["basin_id"], json.dumps(b["path"]), b["area_pct"]), + ) + + return {"status": "imported", "imported": len(basins)} + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Pre-compute province boundaries from watershed analysis (D-205, #907)" + ) + parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db") + parser.add_argument("--body", help="Process only this body_id") + parser.add_argument("--force", action="store_true", + help="Re-import even if rows already exist") + parser.add_argument("--dry-run", action="store_true", + help="Analyse without writing to DB") + parser.add_argument("--verbose", action="store_true", + help="Print per-body detail") + args = parser.parse_args() + + db_path = Path(args.db) + if not db_path.exists(): + print(f"error: {db_path} not found", file=sys.stderr) + sys.exit(1) + + print(f"\n Province Boundary Import (#907)") + print(f" DB: {db_path}") + if args.dry_run: + print(f" Mode: DRY RUN (no DB writes)") + if args.force: + print(f" Force: enabled (will overwrite existing rows)") + print() + + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA foreign_keys=ON") + ensure_atlas_schema(conn) + + bodies = query_inhabited_bodies(conn) + if args.body: + bodies = [b for b in bodies if b["body_id"] == args.body] + if not bodies: + print(f"error: body '{args.body}' not found or has no terrain_reference", + file=sys.stderr) + conn.close() + sys.exit(1) + + print(f" {len(bodies)} inhabited bodies with terrain_reference\n") + + t_total = time.time() + n_imported = 0 + n_skipped = 0 + n_no_hmap = 0 + n_errors = 0 + + for i, body_info in enumerate(bodies): + body_id = body_info["body_id"] + t0 = time.time() + + result = import_body_provinces(body_id, conn, args.force, args.dry_run, args.verbose) + elapsed = time.time() - t0 + status = result["status"] + + if status == "imported": + n_imported += 1 + print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)") + elif status == "skipped": + n_skipped += 1 + if args.verbose: + print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})") + elif status == "no_heightmap": + n_no_hmap += 1 + if args.verbose: + print(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping") + elif status == "error": + n_errors += 1 + print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}") + + conn.close() + + elapsed_total = time.time() - t_total + print(f"\n Done in {elapsed_total:.1f}s") + print(f" imported={n_imported} skipped={n_skipped} " + f"no_heightmap={n_no_hmap} errors={n_errors}") + + if n_errors > 0: + print(f"\n {n_errors} error(s) — check output above", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tooling/planet-gen/naming_core.py b/tooling/planet-gen/naming_core.py index f7837f587..92fd9088b 100644 --- a/tooling/planet-gen/naming_core.py +++ b/tooling/planet-gen/naming_core.py @@ -230,6 +230,7 @@ def build_batch_prompt( body_name: str | None = None, system_hook: str | None = None, mood: str | None = None, + cultural_history: str | None = None, ctx_size: int = 1024, ) -> str: """Build a batch naming prompt asking for N names in one call. @@ -238,6 +239,10 @@ def build_batch_prompt( with few-shot examples showing comma-separated lists. The model pattern-completes the list. + `cultural_history` threads secondary cultural registers into the + prompt so names reflect the layered settlement history of a corridor + rather than only the primary inflection style (#886 §6). + The prompt is truncated to fit within ctx_size tokens (rough estimate: 1 token ≈ 4 chars). """ @@ -274,9 +279,11 @@ def build_batch_prompt( lines.append(". ".join(ident) + ".") if system_hook: lines.append(f"About the system: {system_hook}") + if cultural_history: + lines.append(f"Settlement history: {cultural_history}") if taken: lines.append(f"Already used (do NOT repeat): {', '.join(taken)}") - if system_name or body_name or system_hook or taken: + if system_name or body_name or system_hook or cultural_history or taken: lines.append("") # Few-shot examples showing batch format @@ -292,7 +299,7 @@ def build_batch_prompt( max_chars = (ctx_size - 16) * 4 # 16 tokens headroom for output budget = max_chars - len(fixed) - len(tail) - 2 # 2 for newlines if budget < 0: - # Trim the taken list to fit + # Trim taken list first (preserving cultural_history context) while taken and budget < 0: taken = taken[:-1] lines_rebuild = [preamble, ""] @@ -305,6 +312,8 @@ def build_batch_prompt( lines_rebuild.append(". ".join(ident) + ".") if system_hook: lines_rebuild.append(f"About the system: {system_hook}") + if cultural_history: + lines_rebuild.append(f"Settlement history: {cultural_history}") if taken: lines_rebuild.append(f"Already used (do NOT repeat): {', '.join(taken)}") lines_rebuild.append("") @@ -336,6 +345,7 @@ def name_features_batch( mood: str | None, body_id: str, world_seed: int, + cultural_history: str | None = None, ctx_size: int = 1024, ) -> list[str]: """Generate `count` names for a feature type using batch prompting. @@ -346,6 +356,9 @@ def name_features_batch( 3. Rank by distinctiveness via Levenshtein, pick top `count`. 4. If short, refill from the next adjacent register in the corridor. 5. Return the final list of names. + + `cultural_history` is forwarded to build_batch_prompt to enrich the + prompt with secondary cultural context (#886 §6). """ prompt = build_batch_prompt( feature_type=feature_type, @@ -357,6 +370,7 @@ def name_features_batch( body_name=body_name, system_hook=system_hook, mood=mood, + cultural_history=cultural_history, ctx_size=ctx_size, ) @@ -396,6 +410,7 @@ def name_features_batch( body_name=body_name, system_hook=system_hook, mood=mood, + cultural_history=cultural_history, ctx_size=ctx_size, )