feat(simulation): D-232 three-phase trait draw + deviation/swerve system (T-994, T-1003)
T-994 — three-phase draw replacing the flat splitmix64 flavor pick: - Phase 1 body K-draw (trait_draw.rs): hard gates -> soft weights (two-part geographic_sector join, PR #148) -> SeedChain::for_body-seeded weighted draw of K (5/3/1/0 by ComplexityTier), pins count toward K, coverage-aware over a new body-level district-type-mix aggregate threaded into CityGenerationContext at L3->L4 dispatch. - Phase 2 district-dominant pick keyed by the D-243 2048m District cell — settlements sharing a cell independently derive the identical template. Resolved at dispatch, never in FillChunk (T-987 stays pure). - trait_catalog_reader.rs: systems.db reader for trait_templates + atlas_body_trait_bias (D-225 resource pattern), registered in main.rs with graceful degradation. - BlockSkeleton carries district_type; assign_block_tags is a pure lookup. T-1003 — deviation/swerve system (trait_swerve.rs): - ArchitectureFlavorRef is now InVocabulary(u8) | Swerve(tag). - Rare per-building wildcard: foreign-import pool (other corridors + cross_corridor) driven by Epicenter/Passage tier, mixed faction, road-graph degree; heritage-callback pool (own-corridor heritage) driven by isolation, remote tier, founding_age_years. Integer bps, base 100, cap 300 per driver — placeholder constants pending calibration. - Sparsity escape hatch = same mechanism, necessity-triggered at the phase-2 pick (deterministic max-weight over the hard-gate-eligible pool). - New SeedDomains: TraitVocabulary(13) / TraitDistrict(14) / TraitSwerve(15). Governance: D-232 amended (district pinned to the D-243 2048m tier — the D-222/D-243 redefinition was never captured; swerve driver mappings + placeholder rates recorded), D-229 flavor_ref field updated to the enum shape, D-235 amended (T-995 vocabulary ratification note; rides here due to single-file entanglement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1605,7 +1605,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- `footprint: TileRect` — integer tile-space rect within the 128-tile block (D-010 integer-only).
|
||||
- `extent: FloorExtent` — floor/basement extent; **resolves Q-104** (below).
|
||||
- `entry_class: BuildingEntryClass` — physical access character: `Public | Commercial | Restricted | BreachOnly`. **Named `BuildingEntryClass`, not "access tier"**, to avoid colliding with D-028's relational dialogue layers. Derived from `zone_type × layout_mode (D-096) × prosperity (D-197)`: Commission-Grid → formal/logged/corporate-or-authority credentials; Organic → social/reputation/unlogged credentials. U-curve degrade: low prosperity on a normally-Commercial zone → `BreachOnly` (derelict).
|
||||
- `flavor_ref: ArchitectureFlavorRef { flavor_index: u8 }` — index into the body's flavor profile (D-232); deterministic `(seed + zone_type) → index`; no rolling-economy read. (The selection mechanism is D-232's weighted `allow`/`block` filter; the tag records the chosen index for Phase-6 to read cold.)
|
||||
- `flavor_ref: ArchitectureFlavorRef` — which trait template characterizes this building (D-232); no rolling-economy read. *(Amended 2026-07-08, T-994/T-1003: now an enum, not a bare index — `InVocabulary(u8)` (index into the body's closed `trait_selection`, resolved via the phase-2 district-dominant pick) `| Swerve(tag)` (the rare out-of-vocabulary deviation draw, or the sparsity escape hatch). The original `(seed + zone_type) → index` mechanism was the pre-three-phase-draw stopgap.)*
|
||||
- `era: ConstructionEra` (`Founding | Established | Modern | Derelict`) + `era_cause: EraCause` — feeds ZonePalette modifier axis C (D-101) and sets the D-217 condition floor. Derived from `founding_age_years + prosperity_baseline + seed`; a body carries mixed-era buildings (founding period anchors the distribution; seed scatters outliers).
|
||||
- `initial_condition: TileCondition` — frozen-amber snapshot from `prosperity_baseline` (D-197/D-217). The rolling condition overlay (D-198) paints *over* this; it never mutates the tag.
|
||||
- **`FloorExtent { base_floor: i8, floor_count: u8, heights: FloorHeightProfile }`** where `FloorHeightProfile = Uniform(u8) | Variable(Vec<u8>)`. **Q-104 resolution (the D-110 ↔ D-227 bridge):** two pure functions — `floor_at_voxel_z(z) -> Option<i8>` and `voxel_range_for_floor(f) -> Option<(i32,i32)>` — map D-110 floor-index addressing onto D-227 physical voxel-z. Default `Uniform(3)` (3 voxels ≈ 3 m/floor, per Jeroen); a cathedral/hangar is `Uniform(10)`; a mixed-use stack is `Variable([5,3,3,3,3])`. The `Variable` branch carries per-floor memory only when floors actually differ.
|
||||
@@ -1660,6 +1660,8 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Catalog population:** ~25–35 templates at floor, 40–45 target. Core hand-curated (Miri authors cultural meaning + eligibility; Araminta authors the matching `visual_bundle`), then a *bounded* Gemma corpus-distillation pass (one read of all wiki, propose new templates, human-gated) — not per-body generation.
|
||||
- **Channel separation from D-233 (held):** D-233 decides **what** a building is (vocabulary, coverage); D-232 decides **how** it is characterized. Same inputs (e.g. `bulk_class`) used non-conflictingly — D-233 as hard function gate, D-232 as soft cultural weight. They compose at fill.
|
||||
- **Amended 2026-05-31 ([D-237](#d-237) — authored specialization layer):** the template draw now reads an authored `cultural_specialization` (new column on `system_economy`) that **selects/biases the template pool** when a system's cultural character diverges from its corridor baseline. The field carries two value sub-types in one column — *activity/character* values (`agrarian`, `industrial_heritage`, `institutional`, `scholarly`, etc.) and *heritage* values (`scottish`, `vietnamese`, `zulu`, `french_provencal`, etc.); heritage values take precedence where present. This is a Phase-4 correctness fix, not just flavor: without it, a system whose founding heritage diverges from the corridor (e.g. Vietnamese-founded Dài Lộ in the east_reach Korean/Japanese corridor) draws the wrong cultural templates. `NULL` = use the existing corridor-pool algorithm unchanged. Consistent with the held channel separation — `cultural_specialization` is a D-232 cultural-weight input, never a D-233 function gate. Singular landmarks (e.g. Groombridge's GSH within a `financial_hub` district) are expressed via D-222 multi-block reservation + an `atlas_body_trait_bias` hero pin, not the system-level field.
|
||||
- **Amended 2026-07-08 (T-1003 — deviation/swerve system implemented, driver inputs pinned):** the two opposed drivers now map to real fields (`server/src/atlas/trait_swerve.rs`): *foreign-import* scaled up by `WorldTier::Epicenter`/`Passage` (transit), `dominant_faction = "mixed"` (cosmopolitanism), and road/rail-graph node degree (centrality); *heritage-callback* scaled up by road-graph isolation (degree ≤ 1), remote tier (Waypoint/Backwater), and `founding_age_years` bands (conservatism). Rates are integer bps: base **100 bps/building** per driver, hard cap **300 bps** — placeholder constants pending Nigel/Burnelli calibration; a `dist_ly`-percentile remoteness input is deferred until the read-set carries it. The wildcard result is recorded as `ArchitectureFlavorRef::Swerve(tag)` (out-of-vocabulary by construction); pools are hard-gate-eligible only (cultural-only rule held). The sparsity escape hatch is implemented as the same mechanism, necessity-triggered at the phase-2 district-dominant pick (deterministic max-weight, no dice). The passive past-vogue holdover stays on the D-217 condition layer, as decided.
|
||||
- **Amended 2026-07-08 (T-994 — phase-2 "district" pinned to the D-243 tier):** the word "district" in the phase-2 *district-dominant* draw means the **D-243 2 048 m District cell** (4 quarters), **not** the 512 m Quarter — this record was written after D-222 renamed the 512 m unit to Quarter, but the pin was never made explicit and the shipped code had no District-tier representation at all (caught in the 2026-07-07 /whats-next refinement; the "one template per district, applied whole" composition rule reads at 2 048 m). Implementation (T-994): the dominant template per `(DistrictType)` is pre-resolved at L3→L4 dispatch time, seeded by `(SeedChain::for_body, district-cell position, district type)` — so any settlements whose quarters share a District cell independently derive the identical dominant template with no cross-settlement coordination, and `assign_block_tags` is a pure lookup. Phase 1's body K-draw is likewise seeded from `SeedChain::for_body` (never the per-settlement chain), preserving the closed-vocabulary invariant.
|
||||
- **Supersedes (architecture/generator domain only):** **D-104** (`HeritageGrammarOverlay` + per-root data → the catalog + `allow`/`block`), **D-105** (heritage-root→informal-zone lookup → flavor-filtered selection; the three zone *types* survive), **D-101 modifier axis A** (`HeritageRoot` → catalog draw; axes B/C + faction/climate/condition/season unchanged), **D-107** (per-root trauma decay → per-template/condition; the "trauma intensifies culture" principle survives). Also retires the round-2 `atlas_body_culture` / `atlas_body_culture_era` tables.
|
||||
- **Storage:** `trait_templates` (the catalog) + `atlas_body_trait_bias` (sparse, hero bodies). The per-body draw result lives as `trait_selection: Vec<String>` on the skeleton — **re-derivable** from catalog + economics + bias + `SeedChain`, no Gemma in the hot path. `CityGenerationContext` carries `trait_selection` + `morphology_zone` (replacing the round-2 `flavor_profile`; D-199 amend).
|
||||
- **Source-location deferred:** the human-authored *source* home (catalog file + `bias.json`) rides on **Q-107** (wiki → Atlas content-set consolidation). The generator-facing tables are invariant to it, so the fill seam is unblocked regardless.
|
||||
@@ -1713,6 +1715,7 @@ Technical foundation decisions that constrain implementation: engine, client-ser
|
||||
- **Exterior vocabulary** (extends D-228's FloorMaterial axis): `WallMaterial` (stone_cut/stone_rough/fired_brick/clay_render/heavy_timber/pile_timber/timber_frame/reinforced_concrete/corrugated_metal/steel_panel/composite_panel/precision_glass/smart_facade), `RoofForm` (pitched_steep/pitched_shallow/flat/composite_curved/corrugate_shed/dome), `FacadeRhythm` (bay_window/grid_panel/solid_punched/arcade/open_front/blind_wall), `StreetSurface` (cobble/packed_earth/poured_slab/elevated_boardwalk/dock_plank/rail_embedded — derives from district + density + template, the same filter as buildings, for a consistent world). **Color** is template-bounded (cultural palette cue), seed-selected within range — always within the template's register.
|
||||
- **Fallback hierarchy (the incremental-content mechanism, D-232).** Every specific texture/material token declares a **generic parent it degrades to**: `temple_wall_wood` → generic `wood_wall` placeholder until the specific asset ships, then it **upgrades in place**. The *logical* token a building uses is fixed at generation (deterministic, frozen); only its rendered *fidelity* sharpens as themes/textures are patched in. This is where incremental content lives — an asset-resolution concern, not a generation one — so no catalog versioning is needed. (`era_fallback` from the round-2 draft survives only in this generalized form — a fallback chain, not a tech ladder.)
|
||||
- Worked example: a fjord port (template `fjord_maritime` → stone base + steep roof + zero-lot + solid-punched + cobble) and a delta port (template `river_delta` → pile-timber + shallow roof + arcade + boardwalk) share density and zone types yet read as completely different cities.
|
||||
- **Amended 2026-07-07 (T-995 — ObjectTag vocabulary ratified, resolves Q-049):** the `WallMaterial`/`RoofForm`/`FacadeRhythm`/`StreetSurface` example token lists above never shipped. The canonical ObjectTag vocabulary is the shipped 28-template `architecture_trait_catalog.toml` (T-1005) material palette, now formalized as a machine-readable registry at `wiki/economics/object_tag_vocabulary.toml`, importer-validated by `economy_import/traits.py` (V-TT-03 existence, V-TT-04 fallback-graph). The ratified tags, by axis: **wall** (10) — `concrete_wall stone_wall brick_wall rendered_wall stucco_wall timber_wall rammed_earth_wall steel_frame glass_curtain_wall composite_panel`; **roof** (7) — `flat_roof pitched_roof corrugated_roof clay_tile_roof terraced_roof vaulted_roof green_roof`; **facade** (8) — `regular_facade ornamental_facade industrial_glazing arcade_facade shuttered_facade screen_facade colonnade lattice_screen`; **street** (7) — `paved cobble packed_earth canal_way elevated_walkway heavy_haul boardwalk`; plus the four fallback-terminal **generic** placeholders — `generic_wall generic_roof generic_facade generic_street`. Every specific tag's registry entry declares its generic fallback parent directly (the per-template `fallback` maps in the catalog remain illustrative/non-exhaustive documentation, not the validated source).
|
||||
- **Formally retires** Araminta's generator-architecture Round-4 `D-READY-9` ten-root heritage-modifier TOML system — superseded by the D-232 trait-template catalog (body-specific draw, allow/block-bounded, no root taxonomy).
|
||||
- **Rationale:** The template is the differentiator, but it needs a concrete visual vocabulary to act on, filtered consistently across walls, roofs, facades, *and* streets or the world reads incoherent. The "old quarter vs new development" texture comes from **wear + occasional past-vogue holdover** (D-232), not from a material-technology ladder — because in a post-space-travel setting there is no such ladder. The fallback hierarchy lets the logical world be complete and frozen at launch while the art catches up over patches.
|
||||
- **Implementation:** Phase 4+ (the token logic + Atlas-level data); textured render + the bulk of the theme library are Phase 5+ and post-launch, behind the fallback chain. `ObjectTag`/material vocabulary is Miri + Araminta co-maintained.
|
||||
|
||||
@@ -23,9 +23,18 @@
|
||||
//! - `founding_orientation` — Cardinal stub (attractor matching deferred)
|
||||
//! - `world_tier` — Waypoint default (`system_economy.economic_tier` derivation deferred)
|
||||
//! - `morphology_zone` — AlluvialPlain default (D-228 deferred)
|
||||
//! - `trait_selection` — empty (trait catalog #1005 deferred)
|
||||
//! - `trait_selection` — empty (the D-232 phase-1 K-draw runs at L3→L4 dispatch
|
||||
//! time, T-994 — see `atlas::plugin::build_skeleton_work_item` — this reader
|
||||
//! has no body-wide coverage view)
|
||||
//! - `dominant_bulk_class` — NonPhysical default (#982 design-blocked)
|
||||
//! - `dominant_production_ubiquity` — Common default (#982 design-blocked)
|
||||
//! - `body_district_type_mix` / `settlement_district_pos` / `district_dominant_by_type`
|
||||
//! — T-994 additions, all require body-wide aggregation this per-city reader
|
||||
//! doesn't have; overridden at L3→L4 dispatch time
|
||||
//!
|
||||
//! **Field read, not deferred (T-994 addition):** `geographic_sector` — from
|
||||
//! `star_systems.geographic_sector` (via `bodies.system_id`). A single
|
||||
//! per-system value, unlike the four fields above.
|
||||
//!
|
||||
//! **Prosperity derivation (D-197, partial — integer basis points, D-010):**
|
||||
//! `prosperity_baseline_bps = clamp(role_base_bps + pop_bonus_bps + noise_bps, 1000, 9500)`
|
||||
@@ -38,6 +47,7 @@
|
||||
//! Read-only `systems.db` access follows the same pattern as
|
||||
//! [`crate::atlas::source_resolver::BodySourceResolver`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -90,6 +100,10 @@ pub struct CityEconomicReadSet {
|
||||
pub founding_age_years: u32,
|
||||
/// D-199 field 6.
|
||||
pub settlement_class: SettlementClass,
|
||||
/// Not a D-199 field — T-994 (D-232) addition. This city's system corridor
|
||||
/// (`star_systems.geographic_sector`). `None` if unset. A soft weight on the
|
||||
/// trait-template draw only, never a gate.
|
||||
pub geographic_sector: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -136,26 +150,31 @@ impl CityContextReader {
|
||||
// atlas_city_names carries economic_role, population, settlement_class.
|
||||
// bodies carries founding_age_years (via the city's body_id).
|
||||
// system_factions carries dominant_faction (via bodies.system_id).
|
||||
// star_systems carries geographic_sector (via bodies.system_id, T-994).
|
||||
//
|
||||
// settlement_class is nullable (NULL until attractor placement runs).
|
||||
// dominant_faction is nullable (some systems have no faction data).
|
||||
// founding_age_years is nullable (uninhabited bodies).
|
||||
// geographic_sector is nullable (some systems have no recorded corridor).
|
||||
let row: rusqlite::Result<(
|
||||
Option<String>, // acn.economic_role
|
||||
i64, // acn.population
|
||||
Option<String>, // acn.settlement_class
|
||||
Option<i64>, // b.founding_age_years
|
||||
Option<String>, // sf.dominant_faction
|
||||
Option<String>, // ss.geographic_sector
|
||||
)> = conn.query_row(
|
||||
"SELECT
|
||||
acn.economic_role,
|
||||
acn.population,
|
||||
acn.settlement_class,
|
||||
b.founding_age_years,
|
||||
sf.dominant_faction
|
||||
sf.dominant_faction,
|
||||
ss.geographic_sector
|
||||
FROM atlas_city_names AS acn
|
||||
JOIN bodies AS b ON b.body_id = acn.body_id
|
||||
LEFT JOIN system_factions AS sf ON sf.system_id = b.system_id
|
||||
LEFT JOIN star_systems AS ss ON ss.system_id = b.system_id
|
||||
WHERE acn.id = ?1",
|
||||
[city_id as i64],
|
||||
|row| {
|
||||
@@ -165,6 +184,7 @@ impl CityContextReader {
|
||||
row.get(2)?,
|
||||
row.get(3)?,
|
||||
row.get(4)?,
|
||||
row.get(5)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
@@ -183,6 +203,7 @@ impl CityContextReader {
|
||||
settlement_class_opt,
|
||||
founding_age_opt,
|
||||
dominant_faction,
|
||||
geographic_sector,
|
||||
) = row;
|
||||
|
||||
let economic_role = economic_role_opt.ok_or(CityContextReadError::MissingField {
|
||||
@@ -209,6 +230,7 @@ impl CityContextReader {
|
||||
dominant_faction,
|
||||
founding_age_years,
|
||||
settlement_class,
|
||||
geographic_sector,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -486,12 +508,33 @@ pub fn context_from_read_set(city_id: u64, rs: CityEconomicReadSet) -> CityGener
|
||||
world_tier: WorldTier::Waypoint,
|
||||
// morphology_zone: Layer-1 output, D-228 deferred.
|
||||
morphology_zone: MorphologyZone::AlluvialPlain,
|
||||
// trait_selection: trait catalog #1005 deferred.
|
||||
// trait_selection: the D-232 phase-1 K-draw runs at L3→L4 dispatch time
|
||||
// (build_skeleton_work_item, T-994) — this reader has no body-wide view
|
||||
// (coverage aggregate, catalog reader) so it is left empty here and
|
||||
// overridden by the caller, same pattern as morphology_zone/political_archetype.
|
||||
trait_selection: Vec::new(),
|
||||
// dominant_bulk_class: dominant-commodity derivation #982 design-blocked.
|
||||
dominant_bulk_class: BulkClass::NonPhysical,
|
||||
// dominant_production_ubiquity: same blocker as above.
|
||||
dominant_production_ubiquity: ProductionUbiquity::Common,
|
||||
|
||||
// ── T-994 additions ────────────────────────────────────────────────
|
||||
// geographic_sector: the one D-199-style read-set field this ticket adds
|
||||
// (star_systems.geographic_sector via the city's system_id) — real value,
|
||||
// not a stub.
|
||||
geographic_sector: rs.geographic_sector,
|
||||
// body_district_type_mix / settlement_district_pos / district_dominant_by_type:
|
||||
// all three require body-wide aggregation (every settlement's district
|
||||
// mix, the settlement's world position) that this per-city reader doesn't
|
||||
// have. Overridden at L3→L4 dispatch time (build_skeleton_work_item).
|
||||
body_district_type_mix: Vec::new(),
|
||||
settlement_district_pos: (0, 0),
|
||||
district_dominant_by_type: BTreeMap::new(),
|
||||
// ── T-1003 additions — same dispatch-time override story as above:
|
||||
// driver rates need the road graph, pools need the resolved catalog.
|
||||
swerve_rates_bps: (0, 0),
|
||||
swerve_foreign_pool: Vec::new(),
|
||||
swerve_heritage_pool: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,7 +582,7 @@ mod tests {
|
||||
let conn = Connection::open(&path).expect("create db");
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE star_systems (system_id TEXT PRIMARY KEY);
|
||||
"CREATE TABLE star_systems (system_id TEXT PRIMARY KEY, geographic_sector TEXT);
|
||||
CREATE TABLE bodies (
|
||||
body_id TEXT PRIMARY KEY,
|
||||
system_id TEXT NOT NULL,
|
||||
@@ -734,6 +777,37 @@ mod tests {
|
||||
assert_eq!(rs.founding_age_years, 450);
|
||||
// Field 6 — settlement_class
|
||||
assert!(matches!(rs.settlement_class, SettlementClass::NameLocked));
|
||||
// geographic_sector: absent in this fixture (no UPDATE below) -> None.
|
||||
assert_eq!(rs.geographic_sector, None);
|
||||
}
|
||||
|
||||
// ─── geographic_sector (T-994) ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn read_set_threads_geographic_sector_from_star_systems() {
|
||||
let (db, city_id) = make_test_db(
|
||||
"GJ8d",
|
||||
"GJ-8",
|
||||
"financial",
|
||||
1_000_000,
|
||||
Some("NameLocked"),
|
||||
Some(100),
|
||||
None,
|
||||
);
|
||||
let conn = Connection::open(&db).expect("reopen");
|
||||
conn.execute(
|
||||
"UPDATE star_systems SET geographic_sector = 'east_reach' WHERE system_id = 'GJ-8'",
|
||||
[],
|
||||
)
|
||||
.expect("set sector");
|
||||
drop(conn);
|
||||
|
||||
let reader = CityContextReader::open(&db).expect("open");
|
||||
let rs = reader.read_set(city_id as u64, 42).expect("read set");
|
||||
assert_eq!(rs.geographic_sector.as_deref(), Some("east_reach"));
|
||||
|
||||
let ctx = context_from_read_set(city_id as u64, rs);
|
||||
assert_eq!(ctx.geographic_sector.as_deref(), Some("east_reach"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -578,6 +578,13 @@ mod tests {
|
||||
trait_selection: vec![],
|
||||
dominant_bulk_class: BulkClass::NonPhysical,
|
||||
dominant_production_ubiquity: ProductionUbiquity::Common,
|
||||
geographic_sector: None,
|
||||
body_district_type_mix: vec![],
|
||||
settlement_district_pos: (0, 0),
|
||||
district_dominant_by_type: Default::default(),
|
||||
swerve_rates_bps: (0, 0),
|
||||
swerve_foreign_pool: vec![],
|
||||
swerve_heritage_pool: vec![],
|
||||
}),
|
||||
quarter_id: city_id * 10,
|
||||
chain: SeedChain::root(42 + city_id),
|
||||
@@ -710,7 +717,7 @@ mod tests {
|
||||
heights: FloorHeightProfile::Uniform(3),
|
||||
},
|
||||
entry_class: BuildingEntryClass::Public,
|
||||
flavor_ref: ArchitectureFlavorRef { flavor_index: 0 },
|
||||
flavor_ref: ArchitectureFlavorRef::InVocabulary(0),
|
||||
era: ConstructionEra::Founding,
|
||||
era_cause: EraCause::Original,
|
||||
initial_condition: TileCondition::Intact,
|
||||
|
||||
@@ -31,6 +31,9 @@ pub mod skeleton_gen;
|
||||
pub mod source_resolver;
|
||||
pub mod subbiome;
|
||||
pub mod tile_condition;
|
||||
pub mod trait_catalog_reader;
|
||||
pub mod trait_draw;
|
||||
pub mod trait_swerve;
|
||||
pub mod voxel;
|
||||
|
||||
pub use plugin::GenerationPlugin;
|
||||
|
||||
+232
-1
@@ -19,15 +19,27 @@ use crate::atlas::body_world_state::{BodyWorldStateCache, CACHE_CAPACITY};
|
||||
use crate::atlas::city_context_reader::{
|
||||
context_from_read_set, CityContextReaderResource, CityEconomicReadSet,
|
||||
};
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::atlas::district_profile::{DistrictPos, DistrictProfile};
|
||||
use crate::atlas::gen_queue::{GenCompletion, GenPriority, GenWorkItem, GenerationQueue};
|
||||
use crate::atlas::layer_proxy::{handle_atlas_request, AtlasLayerResponse, AtlasLayerStatus};
|
||||
use crate::atlas::road_graph::{RoadGraph, RoadNode};
|
||||
use crate::atlas::scale;
|
||||
use crate::atlas::skeleton_gen::derive_complexity;
|
||||
use crate::atlas::source_resolver::BodySourceResolverResource;
|
||||
use crate::atlas::trait_catalog_reader::{TraitBias, TraitCatalogReaderResource, TraitTemplate};
|
||||
use crate::atlas::trait_draw::{
|
||||
complexity_k, draw_body_vocabulary, hard_gate_eligible, pick_district_dominant_by_type,
|
||||
VocabularyDrawInputs,
|
||||
};
|
||||
use crate::atlas::trait_swerve::{
|
||||
build_swerve_pools, compute_swerve_rates, SwerveDrivers, SwervePools,
|
||||
};
|
||||
use crate::bridge::{AtlasRequestBuffer, AtlasResponseBuffer};
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use crate::simulation::generator::{MaintenanceAuthority, MorphologyZone};
|
||||
use crate::simulation::generator::{
|
||||
BulkClass, DistrictType, MaintenanceAuthority, MorphologyZone, ProductionUbiquity, WorldTier,
|
||||
};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use crate::tick_phases::TickPhase;
|
||||
@@ -100,6 +112,7 @@ fn drain_generation_completions(
|
||||
queue: Res<GenerationQueue>,
|
||||
mut cache: ResMut<BodyWorldStateCache>,
|
||||
city_reader: Option<Res<CityContextReaderResource>>,
|
||||
trait_catalog: Option<Res<TraitCatalogReaderResource>>,
|
||||
rng: Option<Res<SimRng>>,
|
||||
) {
|
||||
for completion in queue.drain_completions() {
|
||||
@@ -116,6 +129,19 @@ fn drain_generation_completions(
|
||||
let reader = &city_reader.0;
|
||||
let world_seed = rng.seed();
|
||||
let body_id = state.body_id.clone();
|
||||
|
||||
// ── T-994 (D-232): body-level aggregation for the phase-1
|
||||
// trait-vocabulary K-draw ───────────────────────────────────
|
||||
// Read each placement's D-199 read-set once — reused both to
|
||||
// build the body-wide coverage aggregate here and to build
|
||||
// that placement's own skeleton work item below, so this
|
||||
// dispatch pass makes exactly one `read_set` DB round trip per
|
||||
// settlement (same as before this ticket).
|
||||
let mut resolved: Vec<(&CityPlacement, CityEconomicReadSet)> = Vec::new();
|
||||
let mut body_district_type_mix: std::collections::BTreeSet<DistrictType> =
|
||||
Default::default();
|
||||
let mut max_prosperity_bps: u32 = 0;
|
||||
let mut max_k: usize = 0;
|
||||
for placement in &state.placements {
|
||||
let read_set = match reader.read_set(placement.city_id, world_seed) {
|
||||
Ok(rs) => rs,
|
||||
@@ -129,6 +155,91 @@ fn drain_generation_completions(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
max_prosperity_bps =
|
||||
max_prosperity_bps.max(read_set.prosperity_baseline_bps);
|
||||
// Re-derives the identical DistrictType mix
|
||||
// `generate_quarter_skeleton` computes later for this same
|
||||
// placement (same chain, same inputs) — cheap (a 16-draw
|
||||
// seeded LCG) and gives the aggregation loop the *actual*
|
||||
// district-type mix rather than a proxy.
|
||||
let mix_chain = SeedChain::for_body(world_seed, &body_id)
|
||||
.derive(SeedDomain::Layer4Quarter, placement.city_id);
|
||||
let mix = compute_district_mix(
|
||||
read_set.population,
|
||||
&read_set.economic_role,
|
||||
&placement.political_archetype,
|
||||
16,
|
||||
mix_chain,
|
||||
);
|
||||
body_district_type_mix.extend(mix.districts);
|
||||
// world_tier has no real derivation yet (city_context_reader's
|
||||
// #TBD stub, always Waypoint) — tracked here via population
|
||||
// tier alone so a future world_tier derivation slots into
|
||||
// this MAX-K aggregate without revisiting this loop.
|
||||
let tier = derive_complexity(
|
||||
&WorldTier::Waypoint,
|
||||
population_tier(read_set.population),
|
||||
read_set.population,
|
||||
);
|
||||
max_k = max_k.max(complexity_k(&tier));
|
||||
|
||||
resolved.push((placement, read_set));
|
||||
}
|
||||
let body_district_type_mix: Vec<DistrictType> =
|
||||
body_district_type_mix.into_iter().collect();
|
||||
|
||||
// Phase-1 K-draw (D-232): computed once, shared by every
|
||||
// settlement on this body — the closed-vocabulary invariant.
|
||||
// An absent reader (tests) degrades to an empty catalog:
|
||||
// draw/pools/eligible all no-op identically.
|
||||
let (catalog, bias): (Vec<TraitTemplate>, Vec<TraitBias>) =
|
||||
match trait_catalog.as_ref() {
|
||||
Some(tc) => (
|
||||
tc.0.read_catalog().unwrap_or_else(|e| {
|
||||
tracing::warn!(body_id = %body_id, error = %e, "trait catalog read failed — empty vocabulary");
|
||||
Vec::new()
|
||||
}),
|
||||
tc.0.read_body_bias(&body_id).unwrap_or_else(|e| {
|
||||
tracing::warn!(body_id = %body_id, error = %e, "trait bias read failed — no bias applied");
|
||||
Vec::new()
|
||||
}),
|
||||
),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let body_sector: Option<&str> = resolved
|
||||
.first()
|
||||
.and_then(|(_, rs)| rs.geographic_sector.as_deref());
|
||||
// dominant_bulk_class/dominant_production_ubiquity are
|
||||
// #982 design-blocked stubs (always NonPhysical/Common) —
|
||||
// see CityGenerationContext's field docs.
|
||||
let inputs = VocabularyDrawInputs {
|
||||
k: max_k,
|
||||
dominant_bulk_class: &BulkClass::NonPhysical,
|
||||
dominant_production_ubiquity: &ProductionUbiquity::Common,
|
||||
max_prosperity_bps,
|
||||
geographic_sector: body_sector,
|
||||
coverage_district_types: &body_district_type_mix,
|
||||
};
|
||||
// Hard-gate-eligible pool — shared by the K-draw, the T-1003
|
||||
// swerve pools, and the phase-2 necessity escape hatch (the
|
||||
// swerve is cultural-only; the D-233 gates always hold).
|
||||
let eligible = hard_gate_eligible(&catalog, &inputs);
|
||||
let trait_selection = draw_body_vocabulary(
|
||||
&catalog,
|
||||
&bias,
|
||||
&inputs,
|
||||
SeedChain::for_body(world_seed, &body_id),
|
||||
);
|
||||
let swerve_pools = build_swerve_pools(&eligible, &trait_selection, body_sector);
|
||||
let vocab = BodyVocabularyContext {
|
||||
trait_selection: &trait_selection,
|
||||
body_district_type_mix: &body_district_type_mix,
|
||||
catalog: &catalog,
|
||||
eligible: &eligible,
|
||||
swerve_pools: &swerve_pools,
|
||||
};
|
||||
|
||||
for (placement, read_set) in resolved {
|
||||
queue.submit(
|
||||
build_skeleton_work_item(
|
||||
&body_id,
|
||||
@@ -137,6 +248,7 @@ fn drain_generation_completions(
|
||||
read_set,
|
||||
&state.districts,
|
||||
&state.road_graph,
|
||||
&vocab,
|
||||
),
|
||||
GenPriority::Low,
|
||||
);
|
||||
@@ -195,6 +307,53 @@ fn drain_generation_completions(
|
||||
}
|
||||
}
|
||||
|
||||
/// Body-level D-232 trait-vocabulary draw outputs, threaded into
|
||||
/// [`build_skeleton_work_item`] (T-994). Bundled into one struct purely to keep
|
||||
/// that function's argument count under the clippy `too_many_arguments`
|
||||
/// threshold — see its doc comment.
|
||||
struct BodyVocabularyContext<'a> {
|
||||
/// Phase-1 K-draw result (D-232), computed once per body by the caller —
|
||||
/// identical for every settlement on the body (the closed-vocabulary
|
||||
/// invariant). Empty when no `TraitCatalogReaderResource` is wired (tests)
|
||||
/// or the body's K is 0 (`ComplexityTier::Empty` everywhere on the body).
|
||||
trait_selection: &'a [String],
|
||||
/// Every `DistrictType` present anywhere on the body (T-994 coverage
|
||||
/// aggregate) — threaded onto `CityGenerationContext.body_district_type_mix`
|
||||
/// verbatim (design point 4: visible on the context, not just consumed
|
||||
/// internally by the draw).
|
||||
body_district_type_mix: &'a [DistrictType],
|
||||
/// The full trait-template catalog, needed to resolve phase 2
|
||||
/// (`district_dominant_by_type`) for each settlement's District cell —
|
||||
/// `zone_affinity` lives on the catalog row, not on `trait_selection`'s tags.
|
||||
catalog: &'a [TraitTemplate],
|
||||
/// Hard-gate-eligible subset of `catalog` (T-1003) — the phase-2 necessity
|
||||
/// escape hatch reaches this pool when the vocabulary can't serve a district
|
||||
/// type (the swerve is cultural-only; D-233 gates always hold).
|
||||
eligible: &'a [&'a TraitTemplate],
|
||||
/// Body-level T-1003 swerve candidate pools (foreign-import /
|
||||
/// heritage-callback), cloned onto each settlement's context — the
|
||||
/// per-building wildcard draws from these at `assign_block_tags` time.
|
||||
swerve_pools: &'a SwervePools,
|
||||
}
|
||||
|
||||
/// A city's node degree in the T-1038 road/rail graph — the T-1003 swerve's
|
||||
/// centrality (high) / isolation (low) driver input. 0 when the city has no
|
||||
/// node or no edges (matching `road_entry_directions_for_city`'s fallback).
|
||||
fn road_degree_for_city(city_id: u64, road_graph: &RoadGraph) -> u32 {
|
||||
let Some(idx) = road_graph
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|n: &RoadNode| n.city_id == Some(city_id))
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
road_graph
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| e.from == idx || e.to == idx)
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
/// Build the Layer-4 `GenerateSkeleton` work item for one settlement placement
|
||||
/// (T-1022, T-1039, T-1043, D-234). Builds the D-199 context from the read-set
|
||||
/// (mirroring
|
||||
@@ -223,6 +382,12 @@ fn drain_generation_completions(
|
||||
/// `quarter_id` is the canonical D-194/D-230 derivation from `(world_seed, body,
|
||||
/// city)` — not the `city_id * 10` placeholder.
|
||||
///
|
||||
/// `vocab` carries the D-232 three-phase draw's body-level outputs (T-994):
|
||||
/// `trait_selection` (phase 1, computed once per body by the caller) and the
|
||||
/// `catalog` needed to resolve phase 2 (`district_dominant_by_type`) for this
|
||||
/// specific settlement's District cell. Bundled into one struct to keep this
|
||||
/// function's argument count under the clippy `too_many_arguments` threshold.
|
||||
///
|
||||
/// Pure (no queue/cache access) so it unit-tests without a `systems.db`.
|
||||
fn build_skeleton_work_item(
|
||||
body_id: &str,
|
||||
@@ -231,6 +396,7 @@ fn build_skeleton_work_item(
|
||||
read_set: CityEconomicReadSet,
|
||||
districts: &BTreeMap<DistrictPos, DistrictProfile>,
|
||||
road_graph: &RoadGraph,
|
||||
vocab: &BodyVocabularyContext,
|
||||
) -> GenWorkItem {
|
||||
// The D-199 raw fields ride alongside the context (generate_quarter_skeleton
|
||||
// takes them separately), so capture them before context_from_read_set consumes
|
||||
@@ -238,6 +404,8 @@ fn build_skeleton_work_item(
|
||||
let economic_role = read_set.economic_role.clone();
|
||||
let population = read_set.population;
|
||||
let founding_age_years = read_set.founding_age_years;
|
||||
// T-1003 driver input (cosmopolitanism) — captured here for the same reason.
|
||||
let faction_mixed = read_set.dominant_faction.as_deref() == Some("mixed");
|
||||
|
||||
let mut context = context_from_read_set(placement.city_id, read_set);
|
||||
|
||||
@@ -276,6 +444,44 @@ fn build_skeleton_work_item(
|
||||
}
|
||||
};
|
||||
|
||||
// ── T-994 / D-232: three-phase trait-template draw ──────────────────────────
|
||||
// Phase 1 (trait_selection) and its inputs (body_district_type_mix) were
|
||||
// computed once per body by the caller (drain_generation_completions) — the
|
||||
// closed-vocabulary invariant requires every settlement on the body to carry
|
||||
// the identical `trait_selection`, so this function only threads it through,
|
||||
// never re-derives it. Phase 2 (district_dominant_by_type) IS settlement-
|
||||
// specific (keyed by this settlement's own District cell) and is resolved
|
||||
// here, at dispatch time — not inside the GenerateSkeleton Rayon task, and
|
||||
// never inside FillChunk (T-987 keeps fill pure/cache-free).
|
||||
context.trait_selection = vocab.trait_selection.to_vec();
|
||||
context.body_district_type_mix = vocab.body_district_type_mix.to_vec();
|
||||
context.settlement_district_pos = district_pos;
|
||||
context.district_dominant_by_type = pick_district_dominant_by_type(
|
||||
vocab.catalog,
|
||||
vocab.eligible,
|
||||
vocab.trait_selection,
|
||||
SeedChain::for_body(world_seed, body_id),
|
||||
district_pos,
|
||||
);
|
||||
|
||||
// ── T-1003 / D-232: deviation/swerve driver rates + candidate pools ─────────
|
||||
// Pools are body-level (same eligible catalog + vocabulary everywhere on the
|
||||
// body); the driver RATES are per-settlement — centrality/isolation from the
|
||||
// road graph, cosmopolitanism from the faction read, conservatism from
|
||||
// founding age. `context.world_tier` rides the reader's Waypoint stub today
|
||||
// (same caveat as the K aggregation) — Epicenter/Passage multipliers activate
|
||||
// once a real derivation lands.
|
||||
let drivers = SwerveDrivers {
|
||||
world_tier: &context.world_tier,
|
||||
faction_mixed,
|
||||
road_degree: road_degree_for_city(placement.city_id, road_graph),
|
||||
founding_age_years,
|
||||
};
|
||||
let rates = compute_swerve_rates(&drivers);
|
||||
context.swerve_rates_bps = (rates.foreign_bps, rates.heritage_bps);
|
||||
context.swerve_foreign_pool = vocab.swerve_pools.foreign.clone();
|
||||
context.swerve_heritage_pool = vocab.swerve_pools.heritage.clone();
|
||||
|
||||
// ── T-1043: road_entry_directions from road_graph ───────────────────────────
|
||||
// Find this city's settlement node index in the road graph (O(n) scan on a
|
||||
// small slice — settlement counts are single-digit to low hundreds per body).
|
||||
@@ -542,6 +748,24 @@ mod tests {
|
||||
dominant_faction: None,
|
||||
founding_age_years: 200,
|
||||
settlement_class: SettlementClass::PopulationBudget,
|
||||
geographic_sector: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty D-232 draw context (T-994/T-1003) — no catalog reader wired,
|
||||
/// matching the production behaviour when `TraitCatalogReaderResource` is
|
||||
/// absent.
|
||||
fn empty_vocab() -> BodyVocabularyContext<'static> {
|
||||
static EMPTY_POOLS: SwervePools = SwervePools {
|
||||
foreign: Vec::new(),
|
||||
heritage: Vec::new(),
|
||||
};
|
||||
BodyVocabularyContext {
|
||||
trait_selection: &[],
|
||||
body_district_type_mix: &[],
|
||||
catalog: &[],
|
||||
eligible: &[],
|
||||
swerve_pools: &EMPTY_POOLS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,6 +825,7 @@ mod tests {
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
@@ -639,6 +864,7 @@ mod tests {
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
) else {
|
||||
unreachable!()
|
||||
};
|
||||
@@ -699,6 +925,7 @@ mod tests {
|
||||
sample_read_set(),
|
||||
&districts,
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
) else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
};
|
||||
@@ -731,6 +958,7 @@ mod tests {
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
) else {
|
||||
panic!("expected GenerateSkeleton")
|
||||
};
|
||||
@@ -918,6 +1146,7 @@ mod tests {
|
||||
sample_read_set(),
|
||||
&districts,
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
@@ -1188,6 +1417,7 @@ mod tests {
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&road_graph,
|
||||
&empty_vocab(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
@@ -1251,6 +1481,7 @@ mod tests {
|
||||
sample_read_set(),
|
||||
&BTreeMap::new(),
|
||||
&RoadGraph::default(),
|
||||
&empty_vocab(),
|
||||
)
|
||||
else {
|
||||
panic!("expected GenerateSkeleton");
|
||||
|
||||
@@ -308,7 +308,7 @@ mod tests {
|
||||
heights: FloorHeightProfile::Uniform(3),
|
||||
},
|
||||
entry_class: BuildingEntryClass::Public,
|
||||
flavor_ref: ArchitectureFlavorRef { flavor_index: 0 },
|
||||
flavor_ref: ArchitectureFlavorRef::InVocabulary(0),
|
||||
era: ConstructionEra::Founding,
|
||||
era_cause: EraCause::Original,
|
||||
initial_condition: TileCondition::Intact,
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
use crate::atlas::block_irregularity::block_irregularity;
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::atlas::tile_condition::{tile_condition, TileCondition};
|
||||
use crate::atlas::trait_swerve::{roll_building_swerve, SwerveRates};
|
||||
use crate::seed::splitmix64;
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use std::collections::BTreeMap;
|
||||
@@ -199,7 +200,15 @@ fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> Setti
|
||||
/// | Backwater | Full | Moderate |
|
||||
/// | Passage | Moderate | Minimal |
|
||||
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
|
||||
fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier {
|
||||
///
|
||||
/// `pub(crate)` (T-994): also called from `atlas::plugin`'s body-level dispatch
|
||||
/// aggregation to compute each settlement's D-232 phase-1 K contribution
|
||||
/// (`trait_draw::complexity_k`) ahead of `generate_quarter_skeleton` itself.
|
||||
pub(crate) 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;
|
||||
@@ -334,6 +343,7 @@ fn build_block_grid(
|
||||
BlockSkeleton {
|
||||
position: (row as u8, col as u8),
|
||||
zoning,
|
||||
district_type: dt.clone(),
|
||||
reservation,
|
||||
// Density-based spacing; layout_mode offset/rotation applied in
|
||||
// the street-network step (D-234).
|
||||
@@ -863,7 +873,23 @@ fn assign_block_tags(
|
||||
}
|
||||
let prosperity = context.prosperity_baseline_bps;
|
||||
let setting = &context.surrounding_biome;
|
||||
let flavor_n = context.trait_selection.len().max(1) as u64;
|
||||
// D-232 phase 2 (T-994): the dominant template for this block's DistrictType
|
||||
// was already pre-resolved at L3→L4 dispatch time (`context.district_dominant_by_type`
|
||||
// — see `atlas::trait_draw::pick_district_dominant_by_type`), keyed by the
|
||||
// settlement's D-243 District cell so every block of this DistrictType across
|
||||
// the whole quarter (and any sibling quarter in the same District) reads the
|
||||
// identical template. Falls back to index 0 if the type has no entry (should
|
||||
// not happen — the map always has all 9 DistrictType keys). Usually
|
||||
// InVocabulary; already a Swerve when the sparsity escape hatch fired (T-1003).
|
||||
let district_dominant = context
|
||||
.district_dominant_by_type
|
||||
.get(&block.district_type)
|
||||
.cloned()
|
||||
.unwrap_or(ArchitectureFlavorRef::InVocabulary(0));
|
||||
let swerve_rates = SwerveRates {
|
||||
foreign_bps: context.swerve_rates_bps.0,
|
||||
heritage_bps: context.swerve_rates_bps.1,
|
||||
};
|
||||
|
||||
let footprints = subdivide_block_footprints(
|
||||
block.density_pct,
|
||||
@@ -897,17 +923,26 @@ fn assign_block_tags(
|
||||
);
|
||||
let initial = initial_condition(prosperity, &era_cause);
|
||||
let extent = floor_extent(block.density_pct, fp_chain.derive(SeedDomain::Block, 2));
|
||||
// D-232: deterministic (seed + zone_type) → flavor index into the
|
||||
// body's K-tag trait selection.
|
||||
let flavor_seed = fp_chain.derive(SeedDomain::Block, 3).seed();
|
||||
let flavor_index =
|
||||
(splitmix64(flavor_seed ^ zone_type_hash(&zone_type_id)) % flavor_n) as u8;
|
||||
// T-1003 (D-232 deviation system): the rare per-building wildcard —
|
||||
// its own seed domain off the footprint chain, so the roll can never
|
||||
// correlate with the zone/era/extent draws above. Overwhelmingly
|
||||
// None → the district-dominant template applies as usual.
|
||||
let mut swerve_rng = fp_chain.derive(SeedDomain::TraitSwerve, 0).atlas_rng();
|
||||
let flavor_ref = match roll_building_swerve(
|
||||
swerve_rates,
|
||||
&context.swerve_foreign_pool,
|
||||
&context.swerve_heritage_pool,
|
||||
&mut swerve_rng,
|
||||
) {
|
||||
Some(tag) => ArchitectureFlavorRef::Swerve(tag),
|
||||
None => district_dominant.clone(),
|
||||
};
|
||||
BuildingPropertyTag {
|
||||
zone_type_id,
|
||||
footprint,
|
||||
extent,
|
||||
entry_class,
|
||||
flavor_ref: ArchitectureFlavorRef { flavor_index },
|
||||
flavor_ref,
|
||||
era,
|
||||
era_cause,
|
||||
initial_condition: initial,
|
||||
@@ -917,16 +952,6 @@ fn assign_block_tags(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stable hash of a zone-type id for the D-232 flavor draw (FNV-1a over bytes).
|
||||
fn zone_type_hash(id: &ZoneTypeId) -> u64 {
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for b in id.as_str().bytes() {
|
||||
h ^= b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Build the full `block_tags` map for a quarter (D-229/D-230): subdivide and tag
|
||||
/// every non-reserved block's footprints. Keyed by 4×4 block grid position.
|
||||
pub fn assign_all_block_tags(
|
||||
@@ -1225,6 +1250,16 @@ mod tests {
|
||||
trait_selection: Vec::new(),
|
||||
dominant_bulk_class: BulkClass::NonPhysical,
|
||||
dominant_production_ubiquity: ProductionUbiquity::Common,
|
||||
// T-994 additions — sensible stubs for existing tests.
|
||||
geographic_sector: None,
|
||||
body_district_type_mix: Vec::new(),
|
||||
settlement_district_pos: (0, 0),
|
||||
district_dominant_by_type: BTreeMap::new(),
|
||||
// T-1003 additions — zero rates / empty pools: no swerve in
|
||||
// existing tests.
|
||||
swerve_rates_bps: (0, 0),
|
||||
swerve_foreign_pool: Vec::new(),
|
||||
swerve_heritage_pool: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1685,6 +1720,127 @@ mod tests {
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
// ── D-232 phase-2 dominant-template lookup (T-994) ───────────────────────
|
||||
|
||||
#[test]
|
||||
fn flavor_ref_reads_the_pre_resolved_district_dominant_index() {
|
||||
// The three-phase draw pre-resolves one dominant trait-selection index
|
||||
// per DistrictType at dispatch time (context.district_dominant_by_type);
|
||||
// assign_block_tags must be a pure lookup over it — no independent RNG
|
||||
// draw of its own. Residential is unconditionally present (D-194 pop-tier
|
||||
// guarantee, tier_guarantees(0).2 == 1) for a sub-1M-population city, so
|
||||
// this isn't a vacuous check regardless of seed.
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
ctx.trait_selection = vec!["a".into(), "b".into(), "c".into()];
|
||||
ctx.district_dominant_by_type.insert(
|
||||
DistrictType::Residential,
|
||||
ArchitectureFlavorRef::InVocabulary(2),
|
||||
);
|
||||
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
|
||||
let mut found_residential = false;
|
||||
for (pos, block_tags) in &tags {
|
||||
let block = &sk.blocks[pos.0 as usize][pos.1 as usize];
|
||||
if block.district_type != DistrictType::Residential {
|
||||
continue;
|
||||
}
|
||||
found_residential = true;
|
||||
for tag in block_tags {
|
||||
assert_eq!(
|
||||
tag.flavor_ref,
|
||||
ArchitectureFlavorRef::InVocabulary(2),
|
||||
"Residential block {pos:?} should read the pre-resolved index"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_residential,
|
||||
"expected at least one Residential block (D-194 pop-tier guarantee)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flavor_ref_falls_back_to_zero_for_unmapped_district_type() {
|
||||
// An empty district_dominant_by_type (e.g. no trait catalog reader wired,
|
||||
// or K=0) must not panic — every block falls back to index 0, matching
|
||||
// the harmless pre-T-994 degenerate behaviour.
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
for block_tags in tags.values() {
|
||||
for tag in block_tags {
|
||||
assert_eq!(tag.flavor_ref, ArchitectureFlavorRef::InVocabulary(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── D-232 deviation/swerve wildcard (T-1003) ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn swerve_wildcard_is_rare_deterministic_and_draws_from_the_pools() {
|
||||
// With both pools populated and rates at the 300 bps cap (6% per
|
||||
// building total), a full quarter must still be overwhelmingly
|
||||
// district-dominant, any swerved building must carry a pool tag, and
|
||||
// the whole assignment must be reproducible (D-010).
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Epicenter);
|
||||
ctx.trait_selection = vec!["own".into()];
|
||||
ctx.swerve_rates_bps = (300, 300);
|
||||
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
|
||||
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
|
||||
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags_a = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
let tags_b = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
assert_eq!(tags_a, tags_b, "same seeds → same swerves (D-010)");
|
||||
|
||||
let mut total = 0usize;
|
||||
let mut swerved = 0usize;
|
||||
for block_tags in tags_a.values() {
|
||||
for tag in block_tags {
|
||||
total += 1;
|
||||
match &tag.flavor_ref {
|
||||
ArchitectureFlavorRef::InVocabulary(_) => {}
|
||||
ArchitectureFlavorRef::Swerve(t) => {
|
||||
swerved += 1;
|
||||
assert!(
|
||||
t == "foreign_temple" || t == "old_hacienda",
|
||||
"swerve must draw a pool tag, got '{t}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
total > 20,
|
||||
"expected a meaningful building count, got {total}"
|
||||
);
|
||||
assert!(
|
||||
swerved * 100 < total * 25,
|
||||
"swerves must stay rare: {swerved}/{total}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_rates_never_swerve_even_with_populated_pools() {
|
||||
let mut ctx = make_context(PoliticalArchetype::Commission, WorldTier::Regional);
|
||||
ctx.trait_selection = vec!["own".into()];
|
||||
ctx.swerve_foreign_pool = vec![("foreign_temple".to_string(), 10_000)];
|
||||
ctx.swerve_heritage_pool = vec![("old_hacienda".to_string(), 10_000)];
|
||||
// rates stay (0, 0) from make_context
|
||||
let sk = generate_quarter_skeleton(&ctx, 500_000, "financial", 1, 200, SeedChain::root(42));
|
||||
let tags = assign_all_block_tags(&sk, &ctx, "financial", 200, SeedChain::root(42));
|
||||
for block_tags in tags.values() {
|
||||
for tag in block_tags {
|
||||
assert!(matches!(
|
||||
tag.flavor_ref,
|
||||
ArchitectureFlavorRef::InVocabulary(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Street network (#957, D-234) ─────────────────────────────────────────
|
||||
|
||||
use crate::simulation::generator::AccessKind;
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Build-time reader for the D-232 architecture-flavor trait-template catalog
|
||||
//! (T-994) — `trait_templates` (the shared catalog) + `atlas_body_trait_bias`
|
||||
//! (sparse per-body hero pins/boosts/suppressions).
|
||||
//!
|
||||
//! Read-only `systems.db` access, following the same pattern as
|
||||
//! [`crate::atlas::city_context_reader::CityContextReader`]: opened once at
|
||||
//! server startup, queried at L3→L4 dispatch time so the generation cascade
|
||||
//! stays DB-free downstream (D-225).
|
||||
//!
|
||||
//! The catalog itself (`trait_templates`) is baked by
|
||||
//! `tooling/economy-db/economy_import/traits.py` from
|
||||
//! `wiki/economics/architecture_trait_catalog.toml` (#993, #1005) — see
|
||||
//! `.claude/rules/asset-pipeline.md`. This reader only *consumes* the baked
|
||||
//! table; it never writes to `systems.db`.
|
||||
//!
|
||||
//! **Scope (T-994):** only the fields the three-phase draw mechanism needs are
|
||||
//! parsed — `allow_tags`/`block_tags`/`era_scope`/`visual_bundle`/
|
||||
//! `cultural_description` are D-235 (visual bundle resolution) territory and are
|
||||
//! left unparsed here.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::simulation::generator::{BulkClass, DistrictType, ProductionUbiquity};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TraitCatalogReadError {
|
||||
#[error("systems.db error: {0}")]
|
||||
Db(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One holistic template from the D-232 catalog (`trait_templates` row).
|
||||
///
|
||||
/// Fields mirror `wiki/economics/architecture_trait_catalog.toml` /
|
||||
/// `server/data/systems-schema.sql`; JSON-text columns are parsed eagerly so
|
||||
/// downstream draw logic (`atlas::trait_draw`) never touches raw JSON.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TraitTemplate {
|
||||
pub tag: String,
|
||||
/// `baseline` | `heritage` | `cross_corridor` — informational (T-994 does not
|
||||
/// implement the heritage-callback / cross-corridor swerve dials; that's
|
||||
/// T-1003's deviation system).
|
||||
pub corridor_pool: String,
|
||||
/// The corridor this template is authored for; `None` = shared/cross-corridor.
|
||||
/// A SOFT pool-narrowing hint (D-232) — never a hard gate.
|
||||
pub geographic_sector: Option<String>,
|
||||
/// Hard gate: eligible `BulkClass`es. Empty = eligible for all.
|
||||
pub bulk_class_gate: Vec<BulkClass>,
|
||||
/// Hard gate: eligible `ProductionUbiquity` values. Empty = eligible for all.
|
||||
pub production_ubiquity_gate: Vec<ProductionUbiquity>,
|
||||
/// Hard gate: minimum prosperity in basis points (D-010 integer).
|
||||
pub min_prosperity_bps: u32,
|
||||
/// Base weight in basis points (D-010 integer; 10 000 = 1.0×).
|
||||
pub base_weight: u32,
|
||||
/// Soft weight modifiers: dimension → value → multiplier_bps.
|
||||
/// Dimensions per the catalog: `economic_role`, `dominant_faction`,
|
||||
/// `founding_age`, `geographic_sector`, `morphology_zone`. T-994 applies only
|
||||
/// `geographic_sector` (the one dimension with an unambiguous single
|
||||
/// body-level value); the others are per-settlement and D-232 round 3 doesn't
|
||||
/// specify how a multi-settlement body should combine them for one shared
|
||||
/// vocabulary draw — parsed here for completeness, left un-applied.
|
||||
pub weight_mods: std::collections::BTreeMap<String, std::collections::BTreeMap<String, u32>>,
|
||||
/// `DistrictType` → weight_bps. The phase-2 dominant-template pick's sole
|
||||
/// input (D-232: "district-dominant by zone_affinity").
|
||||
pub zone_affinity: std::collections::BTreeMap<DistrictType, u32>,
|
||||
}
|
||||
|
||||
/// Bias kind on an `atlas_body_trait_bias` row (D-232 hero-body wiki bias).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BiasKind {
|
||||
/// Mandatory — counts toward K, forces inclusion in `trait_selection`.
|
||||
Pin,
|
||||
/// Weight multiplier > 1×, ≤ 3× (`weight_multiplier_bps` 10001..=30000).
|
||||
Boost,
|
||||
/// Weight multiplier < 1×, ≥ 0.33× — never 0 (`weight_multiplier_bps` 3300..=9999).
|
||||
Suppress,
|
||||
}
|
||||
|
||||
/// One sparse per-body bias row (hero bodies only, ~30–40 per D-232).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TraitBias {
|
||||
pub template_tag: String,
|
||||
pub bias_kind: BiasKind,
|
||||
/// `None` for `Pin` (no multiplier — pins are forced, not weighted).
|
||||
pub weight_multiplier_bps: Option<u32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Reads the D-232 trait-template catalog + per-body bias from `systems.db`.
|
||||
///
|
||||
/// Analogous to [`crate::atlas::city_context_reader::CityContextReader`]: a
|
||||
/// read-only connection opened once at server startup.
|
||||
pub struct TraitCatalogReader {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl TraitCatalogReader {
|
||||
/// Open a read-only connection to `systems_db`.
|
||||
pub fn open(systems_db: &Path) -> Result<Self, TraitCatalogReadError> {
|
||||
let conn = Connection::open_with_flags(systems_db, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the full shared catalog, ordered by `tag` (D-010 — deterministic
|
||||
/// iteration order for the weighted draw's tie-breaks).
|
||||
pub fn read_catalog(&self) -> Result<Vec<TraitTemplate>, TraitCatalogReadError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT tag, corridor_pool, geographic_sector, bulk_class_gate,
|
||||
production_ubiquity_gate, min_prosperity_bps, base_weight,
|
||||
weight_mods, zone_affinity
|
||||
FROM trait_templates
|
||||
ORDER BY tag",
|
||||
)
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
row.get::<_, i64>(5)?,
|
||||
row.get::<_, i64>(6)?,
|
||||
row.get::<_, Option<String>>(7)?,
|
||||
row.get::<_, Option<String>>(8)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let (
|
||||
tag,
|
||||
corridor_pool,
|
||||
geographic_sector,
|
||||
bulk_class_gate_json,
|
||||
production_ubiquity_gate_json,
|
||||
min_prosperity_bps,
|
||||
base_weight,
|
||||
weight_mods_json,
|
||||
zone_affinity_json,
|
||||
) = r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
|
||||
out.push(TraitTemplate {
|
||||
bulk_class_gate: parse_bulk_class_gate(bulk_class_gate_json.as_deref(), &tag),
|
||||
production_ubiquity_gate: parse_production_ubiquity_gate(
|
||||
production_ubiquity_gate_json.as_deref(),
|
||||
&tag,
|
||||
),
|
||||
min_prosperity_bps: min_prosperity_bps.max(0) as u32,
|
||||
base_weight: base_weight.max(0) as u32,
|
||||
weight_mods: parse_weight_mods(weight_mods_json.as_deref(), &tag),
|
||||
zone_affinity: parse_zone_affinity(zone_affinity_json.as_deref(), &tag),
|
||||
tag,
|
||||
corridor_pool,
|
||||
geographic_sector,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read the sparse hero-body bias rows for `body_id` (empty for the ~240
|
||||
/// non-hero bodies, D-232).
|
||||
pub fn read_body_bias(&self, body_id: &str) -> Result<Vec<TraitBias>, TraitCatalogReadError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|e| TraitCatalogReadError::Db(format!("mutex poisoned: {e}")))?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT template_tag, bias_kind, weight_multiplier_bps
|
||||
FROM atlas_body_trait_bias
|
||||
WHERE body_id = ?1
|
||||
ORDER BY template_tag",
|
||||
)
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([body_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<i64>>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let (template_tag, kind_str, mult) =
|
||||
r.map_err(|e| TraitCatalogReadError::Db(e.to_string()))?;
|
||||
let Some(bias_kind) = parse_bias_kind(&kind_str) else {
|
||||
tracing::warn!(
|
||||
body_id,
|
||||
template_tag,
|
||||
kind = kind_str,
|
||||
"unrecognized bias_kind — skipping row"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
out.push(TraitBias {
|
||||
template_tag,
|
||||
bias_kind,
|
||||
weight_multiplier_bps: mult.map(|m| m.max(0) as u32),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn parse_bias_kind(s: &str) -> Option<BiasKind> {
|
||||
match s {
|
||||
"pin" => Some(BiasKind::Pin),
|
||||
"boost" => Some(BiasKind::Boost),
|
||||
"suppress" => Some(BiasKind::Suppress),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bulk_class(s: &str) -> Option<BulkClass> {
|
||||
match s {
|
||||
"BulkSolid" => Some(BulkClass::BulkSolid),
|
||||
"BulkLiquid" => Some(BulkClass::BulkLiquid),
|
||||
"PrecisionDense" => Some(BulkClass::PrecisionDense),
|
||||
"Perishable" => Some(BulkClass::Perishable),
|
||||
"NonPhysical" => Some(BulkClass::NonPhysical),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_production_ubiquity(s: &str) -> Option<ProductionUbiquity> {
|
||||
match s {
|
||||
"Ubiquitous" => Some(ProductionUbiquity::Ubiquitous),
|
||||
"Common" => Some(ProductionUbiquity::Common),
|
||||
"Specialist" => Some(ProductionUbiquity::Specialist),
|
||||
"MonopolySource" => Some(ProductionUbiquity::MonopolySource),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DistrictType` from its exact catalog string (matches the Rust enum variant
|
||||
/// names 1:1 — see `architecture_trait_catalog.toml`'s canonical-enum comment).
|
||||
pub(crate) fn parse_district_type(s: &str) -> Option<DistrictType> {
|
||||
match s {
|
||||
"LogisticsHub" => Some(DistrictType::LogisticsHub),
|
||||
"Residential" => Some(DistrictType::Residential),
|
||||
"Commercial" => Some(DistrictType::Commercial),
|
||||
"Industrial" => Some(DistrictType::Industrial),
|
||||
"Administrative" => Some(DistrictType::Administrative),
|
||||
"Entertainment" => Some(DistrictType::Entertainment),
|
||||
"MixedUse" => Some(DistrictType::MixedUse),
|
||||
"Transit" => Some(DistrictType::Transit),
|
||||
"Specialized" => Some(DistrictType::Specialized),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bulk_class_gate(json: Option<&str>, tag: &str) -> Vec<BulkClass> {
|
||||
let Some(json) = json else { return Vec::new() };
|
||||
let raw: Vec<String> = serde_json::from_str(json).unwrap_or_default();
|
||||
raw.iter()
|
||||
.filter_map(|s| {
|
||||
let parsed = parse_bulk_class(s);
|
||||
if parsed.is_none() {
|
||||
tracing::warn!(tag, value = s, "unrecognized bulk_class_gate value");
|
||||
}
|
||||
parsed
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_production_ubiquity_gate(json: Option<&str>, tag: &str) -> Vec<ProductionUbiquity> {
|
||||
let Some(json) = json else { return Vec::new() };
|
||||
let raw: Vec<String> = serde_json::from_str(json).unwrap_or_default();
|
||||
raw.iter()
|
||||
.filter_map(|s| {
|
||||
let parsed = parse_production_ubiquity(s);
|
||||
if parsed.is_none() {
|
||||
tracing::warn!(
|
||||
tag,
|
||||
value = s,
|
||||
"unrecognized production_ubiquity_gate value"
|
||||
);
|
||||
}
|
||||
parsed
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_weight_mods(
|
||||
json: Option<&str>,
|
||||
tag: &str,
|
||||
) -> std::collections::BTreeMap<String, std::collections::BTreeMap<String, u32>> {
|
||||
let Some(json) = json else {
|
||||
return Default::default();
|
||||
};
|
||||
match serde_json::from_str::<
|
||||
std::collections::BTreeMap<String, std::collections::BTreeMap<String, u32>>,
|
||||
>(json)
|
||||
{
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(tag, error = %e, "malformed weight_mods JSON — treating as empty");
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_zone_affinity(
|
||||
json: Option<&str>,
|
||||
tag: &str,
|
||||
) -> std::collections::BTreeMap<DistrictType, u32> {
|
||||
let Some(json) = json else {
|
||||
return Default::default();
|
||||
};
|
||||
let raw: std::collections::BTreeMap<String, u32> = match serde_json::from_str(json) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(tag, error = %e, "malformed zone_affinity JSON — treating as empty");
|
||||
return Default::default();
|
||||
}
|
||||
};
|
||||
raw.into_iter()
|
||||
.filter_map(|(k, v)| match parse_district_type(&k) {
|
||||
Some(dt) => Some((dt, v)),
|
||||
None => {
|
||||
tracing::warn!(tag, key = k, "unrecognized zone_affinity DistrictType key");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bevy resource wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bevy `Resource` wrapper — `Res<TraitCatalogReaderResource>` in systems.
|
||||
/// Mirrors `CityContextReaderResource`.
|
||||
#[derive(bevy_ecs::prelude::Resource)]
|
||||
pub struct TraitCatalogReaderResource(pub TraitCatalogReader);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn make_test_db() -> PathBuf {
|
||||
let n = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("sr_traitcat_{}_{n}.db", std::process::id()));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let conn = Connection::open(&path).expect("create db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE trait_templates (
|
||||
tag TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
cultural_description TEXT,
|
||||
corridor_pool TEXT NOT NULL DEFAULT 'baseline',
|
||||
geographic_sector TEXT,
|
||||
bulk_class_gate TEXT,
|
||||
production_ubiquity_gate TEXT,
|
||||
min_prosperity_bps INTEGER NOT NULL DEFAULT 0,
|
||||
base_weight INTEGER NOT NULL DEFAULT 10000,
|
||||
weight_mods TEXT,
|
||||
zone_affinity TEXT,
|
||||
allow_tags TEXT,
|
||||
block_tags TEXT,
|
||||
era_scope TEXT,
|
||||
visual_bundle TEXT
|
||||
);
|
||||
CREATE TABLE atlas_body_trait_bias (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL,
|
||||
template_tag TEXT NOT NULL,
|
||||
bias_kind TEXT NOT NULL,
|
||||
weight_multiplier_bps INTEGER,
|
||||
note TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create tables");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO trait_templates
|
||||
(tag, label, corridor_pool, geographic_sector, bulk_class_gate,
|
||||
production_ubiquity_gate, min_prosperity_bps, base_weight,
|
||||
weight_mods, zone_affinity)
|
||||
VALUES ('generic_baseline', 'Generic Baseline', 'cross_corridor', NULL,
|
||||
NULL, NULL, 0, 8000, NULL,
|
||||
'{\"Residential\":10000,\"Commercial\":10000,\"MixedUse\":11000}')",
|
||||
[],
|
||||
)
|
||||
.expect("insert generic_baseline");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO trait_templates
|
||||
(tag, label, corridor_pool, geographic_sector, bulk_class_gate,
|
||||
production_ubiquity_gate, min_prosperity_bps, base_weight,
|
||||
weight_mods, zone_affinity)
|
||||
VALUES ('extraction_camp', 'Extraction Camp', 'cross_corridor', NULL,
|
||||
'[\"BulkSolid\",\"BulkLiquid\"]', '[\"MonopolySource\",\"Specialist\"]',
|
||||
0, 12000,
|
||||
'{\"economic_role\":{\"mining\":20000},\"geographic_sector\":{\"east_reach\":13000}}',
|
||||
'{\"Industrial\":20000,\"LogisticsHub\":13000}')",
|
||||
[],
|
||||
)
|
||||
.expect("insert extraction_camp");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO atlas_body_trait_bias (body_id, template_tag, bias_kind, weight_multiplier_bps)
|
||||
VALUES ('HeroBody', 'generic_baseline', 'pin', NULL)",
|
||||
[],
|
||||
)
|
||||
.expect("insert bias");
|
||||
conn.execute(
|
||||
"INSERT INTO atlas_body_trait_bias (body_id, template_tag, bias_kind, weight_multiplier_bps)
|
||||
VALUES ('HeroBody', 'extraction_camp', 'boost', 20000)",
|
||||
[],
|
||||
)
|
||||
.expect("insert bias 2");
|
||||
|
||||
drop(conn);
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_catalog_parses_gates_and_maps() {
|
||||
let db = make_test_db();
|
||||
let reader = TraitCatalogReader::open(&db).expect("open");
|
||||
let catalog = reader.read_catalog().expect("read catalog");
|
||||
assert_eq!(catalog.len(), 2);
|
||||
// Ordered by tag: extraction_camp < generic_baseline.
|
||||
assert_eq!(catalog[0].tag, "extraction_camp");
|
||||
assert_eq!(
|
||||
catalog[0].bulk_class_gate,
|
||||
vec![BulkClass::BulkSolid, BulkClass::BulkLiquid]
|
||||
);
|
||||
assert_eq!(
|
||||
catalog[0].production_ubiquity_gate,
|
||||
vec![
|
||||
ProductionUbiquity::MonopolySource,
|
||||
ProductionUbiquity::Specialist
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
catalog[0].zone_affinity.get(&DistrictType::Industrial),
|
||||
Some(&20000)
|
||||
);
|
||||
assert_eq!(
|
||||
catalog[0]
|
||||
.weight_mods
|
||||
.get("geographic_sector")
|
||||
.and_then(|m| m.get("east_reach")),
|
||||
Some(&13000)
|
||||
);
|
||||
|
||||
assert_eq!(catalog[1].tag, "generic_baseline");
|
||||
assert!(catalog[1].bulk_class_gate.is_empty());
|
||||
assert_eq!(
|
||||
catalog[1].zone_affinity.get(&DistrictType::MixedUse),
|
||||
Some(&11000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_body_bias_returns_sparse_rows() {
|
||||
let db = make_test_db();
|
||||
let reader = TraitCatalogReader::open(&db).expect("open");
|
||||
let bias = reader.read_body_bias("HeroBody").expect("read bias");
|
||||
assert_eq!(bias.len(), 2);
|
||||
assert_eq!(bias[0].template_tag, "extraction_camp");
|
||||
assert_eq!(bias[0].bias_kind, BiasKind::Boost);
|
||||
assert_eq!(bias[0].weight_multiplier_bps, Some(20000));
|
||||
assert_eq!(bias[1].template_tag, "generic_baseline");
|
||||
assert_eq!(bias[1].bias_kind, BiasKind::Pin);
|
||||
assert_eq!(bias[1].weight_multiplier_bps, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_body_bias_empty_for_non_hero_body() {
|
||||
let db = make_test_db();
|
||||
let reader = TraitCatalogReader::open(&db).expect("open");
|
||||
let bias = reader
|
||||
.read_body_bias("SomeOrdinaryBody")
|
||||
.expect("read bias");
|
||||
assert!(bias.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,821 @@
|
||||
//! D-232 three-phase trait-template draw (T-994) — replaces the flat
|
||||
//! `flavor_index = splitmix64(seed ^ zone_type_hash) % trait_selection.len()`
|
||||
//! pick that shipped in `skeleton_gen.rs`.
|
||||
//!
|
||||
//! **Phase 1 — body vocabulary K-draw** ([`draw_body_vocabulary`]): hard-gate
|
||||
//! filter (`bulk_class`/`production_ubiquity`/`min_prosperity_bps`) → weight
|
||||
//! (`base_weight` × soft modifiers × hero-body bias) → a `SeedChain`-seeded
|
||||
//! weighted draw of K templates (K locked to `ComplexityTier`), coverage-aware
|
||||
//! over the body's actual district-type mix. Seeded from
|
||||
//! `SeedChain::for_body` (not the per-settlement chain) so every settlement on
|
||||
//! the same body draws the identical closed vocabulary (D-232's closed-vocabulary
|
||||
//! invariant).
|
||||
//!
|
||||
//! **Phase 2 — district-dominant pick** ([`pick_district_dominant_by_type`]):
|
||||
//! for each `DistrictType`, one template from the body vocabulary is chosen by
|
||||
//! `zone_affinity`, keyed by the settlement's D-243 2 048 m District cell so
|
||||
//! every settlement sharing a district independently derives the identical
|
||||
//! answer (no cross-settlement coordination needed — same seed, same key).
|
||||
//!
|
||||
//! **Phase 3 — within-template seed picks** is the existing per-building
|
||||
//! variation in `skeleton_gen.rs` (zone type, era, floor extent) — untouched by
|
||||
//! this ticket.
|
||||
//!
|
||||
//! This module is pure (no DB access) — callers pre-resolve the catalog +
|
||||
//! per-body bias via [`crate::atlas::trait_catalog_reader::TraitCatalogReader`]
|
||||
//! at L3→L4 dispatch time (D-225 pattern), then call these functions.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::atlas::chunk_context::pos_to_id;
|
||||
use crate::atlas::trait_catalog_reader::{BiasKind, TraitBias, TraitTemplate};
|
||||
use crate::atlas::trait_swerve::necessity_swerve;
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use crate::simulation::generator::{
|
||||
ArchitectureFlavorRef, BulkClass, ComplexityTier, DistrictType, ProductionUbiquity,
|
||||
};
|
||||
|
||||
// `ComplexityTier` is body-consumed but fundamentally per-settlement (it derives
|
||||
// from a settlement's own population + the body's WorldTier, D-194/D-218) — a
|
||||
// body with settlements of mixed complexity has no single "body ComplexityTier".
|
||||
// `draw_body_vocabulary` therefore takes a raw `k: usize` (see
|
||||
// `VocabularyDrawInputs::k`) rather than a `&ComplexityTier`; callers aggregating
|
||||
// across a body's settlements compute `k` as the MAX of `complexity_k(tier)` over
|
||||
// every settlement (a smaller settlement drawing from a richer shared vocabulary
|
||||
// is harmless — phase 2's `zone_affinity` weighting still favours what that
|
||||
// settlement actually needs).
|
||||
|
||||
/// All 9 `DistrictType` variants, in a fixed deterministic order (D-010) — the
|
||||
/// phase-2 pick resolves one dominant template per entry.
|
||||
const ALL_DISTRICT_TYPES: [DistrictType; 9] = [
|
||||
DistrictType::LogisticsHub,
|
||||
DistrictType::Residential,
|
||||
DistrictType::Commercial,
|
||||
DistrictType::Industrial,
|
||||
DistrictType::Administrative,
|
||||
DistrictType::Entertainment,
|
||||
DistrictType::MixedUse,
|
||||
DistrictType::Transit,
|
||||
DistrictType::Specialized,
|
||||
];
|
||||
|
||||
/// Stable 0..=8 ordinal for a `DistrictType` — the phase-2 seed's second-level id.
|
||||
fn district_type_ordinal(dt: &DistrictType) -> u64 {
|
||||
ALL_DISTRICT_TYPES.iter().position(|d| d == dt).unwrap_or(0) as u64
|
||||
}
|
||||
|
||||
/// K locked to `ComplexityTier` (D-232 round 3, Nigel's birthday math).
|
||||
pub fn complexity_k(complexity: &ComplexityTier) -> usize {
|
||||
match complexity {
|
||||
ComplexityTier::Full => 5,
|
||||
ComplexityTier::Moderate => 3,
|
||||
ComplexityTier::Minimal => 1,
|
||||
ComplexityTier::Empty => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Soft down-weight applied when a template's own `geographic_sector` column
|
||||
/// names a *different* corridor than the body's (basis points; 10 000 = 1.0×).
|
||||
///
|
||||
/// Pragmatic placeholder — D-232 pins "soft, never a gate" but does not specify
|
||||
/// a magnitude; 4000 bps (0.4×) meaningfully narrows the pool toward the body's
|
||||
/// own corridor without approaching exclusion. Needs Nigel/Burnelli calibration
|
||||
/// once real multi-corridor bodies are authored.
|
||||
const SECTOR_MISMATCH_BPS: u64 = 4_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 1 — body vocabulary K-draw
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Body-level inputs to the phase-1 K-draw (T-994). Grouped into a struct to
|
||||
/// keep the function signature under the clippy `too_many_arguments` threshold.
|
||||
pub struct VocabularyDrawInputs<'a> {
|
||||
/// Number of templates to draw. Callers derive this via [`complexity_k`];
|
||||
/// see the module-level note on why this is a raw count, not a
|
||||
/// `&ComplexityTier`.
|
||||
pub k: usize,
|
||||
/// Hard gate. Currently a single settlement-level stub (D-233's dominant
|
||||
/// commodity derivation, #982, is design-blocked) rather than a body-wide
|
||||
/// aggregate — see the module-level note in `trait_catalog_reader.rs` on
|
||||
/// why `weight_mods.economic_role`/`founding_age` are similarly left
|
||||
/// un-aggregated. Revisit once #982 lands.
|
||||
pub dominant_bulk_class: &'a BulkClass,
|
||||
pub dominant_production_ubiquity: &'a ProductionUbiquity,
|
||||
/// MAX `prosperity_baseline_bps` across every settlement on the body — the
|
||||
/// gate is coverage-aware (a rich settlement's needs should not be excluded
|
||||
/// by a poor sibling's economics).
|
||||
pub max_prosperity_bps: u32,
|
||||
/// The body's system corridor (`star_systems.geographic_sector`). A soft
|
||||
/// weight only (D-232, PR #148 review note — two-part join with each
|
||||
/// template's own `geographic_sector` column).
|
||||
pub geographic_sector: Option<&'a str>,
|
||||
/// Every `DistrictType` present anywhere on the body (deduped). The draw
|
||||
/// guarantees ≥1 eligible template with nonzero `zone_affinity` for each.
|
||||
pub coverage_district_types: &'a [DistrictType],
|
||||
}
|
||||
|
||||
/// Effective weight (basis points) for one template, given the body's
|
||||
/// geographic_sector and this body's hero bias (D-232). Never returns 0 — a
|
||||
/// zero weight would make the template undrawable by chance alone and unable
|
||||
/// to satisfy a coverage repair, defeating the "closed but always coherent"
|
||||
/// invariant; the floor mirrors the authored bias range (suppress ≥ 0.33×).
|
||||
fn effective_weight_bps(
|
||||
t: &TraitTemplate,
|
||||
bias_by_tag: &BTreeMap<&str, &TraitBias>,
|
||||
geographic_sector: Option<&str>,
|
||||
) -> u64 {
|
||||
let mut w = t.base_weight as u64;
|
||||
|
||||
// (a) geographic_sector COLUMN — soft pool-narrowing when the template is
|
||||
// pinned to a different corridor than the body's own. `None` on either side
|
||||
// (shared/cross-corridor template, or a body with no recorded sector) never
|
||||
// narrows (PR #148: the column is a hint, not a gate).
|
||||
if let (Some(sector), Some(body_sector)) = (t.geographic_sector.as_deref(), geographic_sector) {
|
||||
if sector != body_sector {
|
||||
w = (w * SECTOR_MISMATCH_BPS) / 10_000;
|
||||
}
|
||||
}
|
||||
|
||||
// (b) weight_mods.geographic_sector — the template's own authored boost/cut
|
||||
// for this exact sector (two-part join per PR #148: (a) and (b) both apply).
|
||||
if let Some(body_sector) = geographic_sector {
|
||||
if let Some(mult) = t
|
||||
.weight_mods
|
||||
.get("geographic_sector")
|
||||
.and_then(|m| m.get(body_sector))
|
||||
{
|
||||
w = (w * (*mult as u64)) / 10_000;
|
||||
}
|
||||
}
|
||||
|
||||
// Hero-body wiki bias (boost/suppress; pin is handled separately as forced
|
||||
// inclusion, not a weight multiplier).
|
||||
if let Some(b) = bias_by_tag.get(t.tag.as_str()) {
|
||||
if let Some(mult) = b.weight_multiplier_bps {
|
||||
w = (w * (mult as u64)) / 10_000;
|
||||
}
|
||||
}
|
||||
|
||||
w.max(1)
|
||||
}
|
||||
|
||||
/// Whether `tag` (looked up in `catalog`) has nonzero `zone_affinity` for `dt`.
|
||||
fn covers_district_type(catalog: &[TraitTemplate], tag: &str, dt: &DistrictType) -> bool {
|
||||
catalog
|
||||
.iter()
|
||||
.find(|t| t.tag == tag)
|
||||
.and_then(|t| t.zone_affinity.get(dt))
|
||||
.is_some_and(|w| *w > 0)
|
||||
}
|
||||
|
||||
/// The D-233 hard-gate filter (D-232 two-tier eligibility, tier 1): a template
|
||||
/// excluded here is out of the pool entirely — for the phase-1 vocabulary draw
|
||||
/// AND the T-1003 swerve pools (the swerve is *cultural only*; a building's
|
||||
/// function still passes the economic hard gates, D-232).
|
||||
pub fn hard_gate_eligible<'a>(
|
||||
catalog: &'a [TraitTemplate],
|
||||
inputs: &VocabularyDrawInputs,
|
||||
) -> Vec<&'a TraitTemplate> {
|
||||
catalog
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.bulk_class_gate.is_empty() || t.bulk_class_gate.contains(inputs.dominant_bulk_class)
|
||||
})
|
||||
.filter(|t| {
|
||||
t.production_ubiquity_gate.is_empty()
|
||||
|| t.production_ubiquity_gate
|
||||
.contains(inputs.dominant_production_ubiquity)
|
||||
})
|
||||
.filter(|t| t.min_prosperity_bps <= inputs.max_prosperity_bps)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Phase 1 (D-232): draw the body's closed K-template vocabulary.
|
||||
///
|
||||
/// `chain` must be `SeedChain::for_body(world_seed, body_id)` — **not** a
|
||||
/// per-settlement chain — so every settlement on the body draws the identical
|
||||
/// vocabulary (the closed-vocabulary invariant this whole mechanism exists to
|
||||
/// protect). This function derives its own `SeedDomain::TraitVocabulary`
|
||||
/// sub-stream internally.
|
||||
///
|
||||
/// Returns the selected tags in draw order (pins first, then the weighted
|
||||
/// draw, then any coverage-repair substitutions). Empty when `K == 0`
|
||||
/// (`ComplexityTier::Empty`) or the catalog has no hard-gate-eligible template.
|
||||
pub fn draw_body_vocabulary(
|
||||
catalog: &[TraitTemplate],
|
||||
bias: &[TraitBias],
|
||||
inputs: &VocabularyDrawInputs,
|
||||
chain: SeedChain,
|
||||
) -> Vec<String> {
|
||||
let k = inputs.k;
|
||||
if k == 0 || catalog.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let eligible = hard_gate_eligible(catalog, inputs);
|
||||
if eligible.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let bias_by_tag: BTreeMap<&str, &TraitBias> =
|
||||
bias.iter().map(|b| (b.template_tag.as_str(), b)).collect();
|
||||
|
||||
// ── Pins (mandatory, count toward K) ────────────────────────────────────
|
||||
// Pins still pass the hard gates above — a hero pin represents an iconic
|
||||
// building for that body, but its *function* must still make economic
|
||||
// sense (channel separation, D-232/D-233).
|
||||
let mut selection: Vec<(String, u64, bool)> = Vec::new(); // (tag, weight, pinned)
|
||||
for t in &eligible {
|
||||
if matches!(
|
||||
bias_by_tag.get(t.tag.as_str()),
|
||||
Some(b) if b.bias_kind == BiasKind::Pin
|
||||
) {
|
||||
selection.push((t.tag.clone(), 0, true));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weighted draw without replacement for the remaining slots ───────────
|
||||
let mut pool: Vec<(&TraitTemplate, u64)> = eligible
|
||||
.iter()
|
||||
.filter(|t| !selection.iter().any(|(tag, _, _)| tag == &t.tag))
|
||||
.map(|t| {
|
||||
(
|
||||
*t,
|
||||
effective_weight_bps(t, &bias_by_tag, inputs.geographic_sector),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut rng = chain.derive(SeedDomain::TraitVocabulary, 0).atlas_rng();
|
||||
let mut remaining = k.saturating_sub(selection.len());
|
||||
while remaining > 0 && !pool.is_empty() {
|
||||
let total: u64 = pool.iter().map(|(_, w)| *w).sum();
|
||||
let mut roll = (rng.next_u32() as u64) % total.max(1);
|
||||
let mut idx = 0;
|
||||
for (i, (_, w)) in pool.iter().enumerate() {
|
||||
if roll < *w {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
roll -= w;
|
||||
}
|
||||
let (picked, w) = pool.remove(idx);
|
||||
selection.push((picked.tag.clone(), w, false));
|
||||
remaining -= 1;
|
||||
}
|
||||
|
||||
// ── Coverage repair (D-232: "must cover the body's actual district-type
|
||||
// mix, not draw K templates that all starve the civic district") ────────
|
||||
for dt in inputs.coverage_district_types {
|
||||
if selection
|
||||
.iter()
|
||||
.any(|(tag, _, _)| covers_district_type(catalog, tag, dt))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Find the best not-yet-selected eligible candidate covering `dt`.
|
||||
// `max_by_key` returns the LAST maximal element on ties — deterministic
|
||||
// given the catalog's stable tag-sorted order (D-010).
|
||||
let candidate = eligible
|
||||
.iter()
|
||||
.filter(|t| !selection.iter().any(|(tag, _, _)| tag == &t.tag))
|
||||
.filter(|t| t.zone_affinity.get(dt).copied().unwrap_or(0) > 0)
|
||||
.map(|t| {
|
||||
(
|
||||
*t,
|
||||
effective_weight_bps(t, &bias_by_tag, inputs.geographic_sector),
|
||||
)
|
||||
})
|
||||
.max_by_key(|(_, w)| *w);
|
||||
let Some((winner, w)) = candidate else {
|
||||
// No eligible template anywhere covers this district type — a
|
||||
// catalog content gap (the CI guardrails, V-TT-01/V-TT-02, are
|
||||
// meant to prevent this), not something the draw can fix.
|
||||
tracing::debug!(?dt, "no eligible trait template covers this DistrictType");
|
||||
continue;
|
||||
};
|
||||
// Swap out the lowest-weight non-pinned member to keep K fixed (D-232:
|
||||
// "K is not a range"); if every current member is pinned, push anyway
|
||||
// — the sparsity escape hatch (D-232: "reaches the full catalog" —
|
||||
// here, the wider hard-gate-eligible pool).
|
||||
let swap_idx = selection
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (_, _, pinned))| !pinned)
|
||||
.min_by_key(|(_, (_, w, _))| *w)
|
||||
.map(|(i, _)| i);
|
||||
match swap_idx {
|
||||
Some(i) => selection[i] = (winner.tag.clone(), w, false),
|
||||
None => selection.push((winner.tag.clone(), w, false)),
|
||||
}
|
||||
}
|
||||
|
||||
selection.into_iter().map(|(tag, _, _)| tag).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 2 — district-dominant pick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Phase 2 (D-232): resolve the dominant template for every `DistrictType`,
|
||||
/// keyed by the settlement's D-243 2 048 m District cell.
|
||||
///
|
||||
/// `body_chain` must be `SeedChain::for_body(world_seed, body_id)` — two
|
||||
/// settlements whose quarters share `district_pos` independently derive the
|
||||
/// identical dominant template for a given `DistrictType` (same seed, same
|
||||
/// key), which is exactly the "coherent 2 048 m area reads as one style"
|
||||
/// invariant — no cross-settlement coordination is needed.
|
||||
///
|
||||
/// Pre-resolved at L3→L4 dispatch time (T-994), **not** inside `FillChunk`
|
||||
/// (T-987 keeps fill pure/cache-free) and not even inside the `GenerateSkeleton`
|
||||
/// Rayon task — the inputs (`trait_selection` + the catalog's `zone_affinity`)
|
||||
/// are already known once `trait_selection` is drawn, so resolving here keeps
|
||||
/// the Rayon task's `assign_block_tags` a cheap infallible `BTreeMap` lookup.
|
||||
///
|
||||
/// Always returns all 9 `DistrictType` entries. A type with no covering
|
||||
/// candidate in `trait_selection` triggers the **sparsity escape hatch**
|
||||
/// (T-1003, D-232: "the SAME mechanism triggered by necessity rather than
|
||||
/// dice") — the pick reaches the full hard-gate-eligible catalog
|
||||
/// (`trait_swerve::necessity_swerve`) and records the result as an
|
||||
/// out-of-vocabulary `ArchitectureFlavorRef::Swerve`. Only when *nothing*
|
||||
/// eligible covers the type either (a catalog content gap the V-TT-01/V-TT-02
|
||||
/// guardrails exist to prevent) does it fall back to `InVocabulary(0)` — the
|
||||
/// pre-T-994 degenerate behaviour.
|
||||
pub fn pick_district_dominant_by_type(
|
||||
catalog: &[TraitTemplate],
|
||||
eligible: &[&TraitTemplate],
|
||||
trait_selection: &[String],
|
||||
body_chain: SeedChain,
|
||||
district_pos: (i32, i32),
|
||||
) -> BTreeMap<DistrictType, ArchitectureFlavorRef> {
|
||||
let mut out = BTreeMap::new();
|
||||
let pos_id = pos_to_id(district_pos);
|
||||
|
||||
for dt in &ALL_DISTRICT_TYPES {
|
||||
let candidates: Vec<(u8, u32)> = trait_selection
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, tag)| {
|
||||
catalog
|
||||
.iter()
|
||||
.find(|t| &t.tag == tag)
|
||||
.and_then(|t| t.zone_affinity.get(dt))
|
||||
.filter(|w| **w > 0)
|
||||
.map(|w| (i as u8, *w))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if candidates.is_empty() {
|
||||
let picked = match necessity_swerve(eligible, |t| {
|
||||
t.zone_affinity.get(dt).copied().unwrap_or(0) > 0
|
||||
}) {
|
||||
Some(tag) => ArchitectureFlavorRef::Swerve(tag),
|
||||
None => {
|
||||
tracing::debug!(
|
||||
?dt,
|
||||
"no eligible template covers this DistrictType — catalog content gap"
|
||||
);
|
||||
ArchitectureFlavorRef::InVocabulary(0)
|
||||
}
|
||||
};
|
||||
out.insert(dt.clone(), picked);
|
||||
continue;
|
||||
}
|
||||
|
||||
let picked = {
|
||||
let mut rng = body_chain
|
||||
.derive(SeedDomain::TraitDistrict, pos_id)
|
||||
.derive(SeedDomain::TraitDistrict, district_type_ordinal(dt))
|
||||
.atlas_rng();
|
||||
let total: u64 = candidates.iter().map(|(_, w)| *w as u64).sum();
|
||||
let mut roll = (rng.next_u32() as u64) % total.max(1);
|
||||
let mut picked = candidates[0].0;
|
||||
for (idx, w) in &candidates {
|
||||
if roll < *w as u64 {
|
||||
picked = *idx;
|
||||
break;
|
||||
}
|
||||
roll -= *w as u64;
|
||||
}
|
||||
picked
|
||||
};
|
||||
out.insert(dt.clone(), ArchitectureFlavorRef::InVocabulary(picked));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmpl(
|
||||
tag: &str,
|
||||
base_weight: u32,
|
||||
bulk_gate: &[BulkClass],
|
||||
min_prosperity_bps: u32,
|
||||
zone_affinity: &[(DistrictType, u32)],
|
||||
) -> TraitTemplate {
|
||||
TraitTemplate {
|
||||
tag: tag.to_string(),
|
||||
corridor_pool: "cross_corridor".to_string(),
|
||||
geographic_sector: None,
|
||||
bulk_class_gate: bulk_gate.to_vec(),
|
||||
production_ubiquity_gate: Vec::new(),
|
||||
min_prosperity_bps,
|
||||
base_weight,
|
||||
weight_mods: BTreeMap::new(),
|
||||
zone_affinity: zone_affinity.iter().cloned().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_inputs<'a>(
|
||||
k: usize,
|
||||
dominant_bulk_class: &'a BulkClass,
|
||||
dominant_production_ubiquity: &'a ProductionUbiquity,
|
||||
coverage: &'a [DistrictType],
|
||||
) -> VocabularyDrawInputs<'a> {
|
||||
VocabularyDrawInputs {
|
||||
k,
|
||||
dominant_bulk_class,
|
||||
dominant_production_ubiquity,
|
||||
max_prosperity_bps: 9_000,
|
||||
geographic_sector: None,
|
||||
coverage_district_types: coverage,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_k_matches_d232_table() {
|
||||
assert_eq!(complexity_k(&ComplexityTier::Full), 5);
|
||||
assert_eq!(complexity_k(&ComplexityTier::Moderate), 3);
|
||||
assert_eq!(complexity_k(&ComplexityTier::Minimal), 1);
|
||||
assert_eq!(complexity_k(&ComplexityTier::Empty), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_complexity_draws_nothing() {
|
||||
let catalog = vec![tmpl(
|
||||
"a",
|
||||
10_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::Residential, 10_000)],
|
||||
)];
|
||||
let inputs = base_inputs(
|
||||
complexity_k(&ComplexityTier::Empty),
|
||||
&BulkClass::NonPhysical,
|
||||
&ProductionUbiquity::Common,
|
||||
&[],
|
||||
);
|
||||
let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(1, "Body"));
|
||||
assert!(sel.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn k_locked_to_complexity_tier() {
|
||||
let catalog: Vec<TraitTemplate> = (0..10)
|
||||
.map(|i| {
|
||||
tmpl(
|
||||
&format!("t{i}"),
|
||||
10_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::MixedUse, 10_000)],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let full = base_inputs(
|
||||
complexity_k(&ComplexityTier::Full),
|
||||
&BulkClass::NonPhysical,
|
||||
&ProductionUbiquity::Common,
|
||||
&[],
|
||||
);
|
||||
let sel = draw_body_vocabulary(&catalog, &[], &full, SeedChain::for_body(1, "Body"));
|
||||
assert_eq!(sel.len(), 5);
|
||||
|
||||
let minimal = base_inputs(
|
||||
complexity_k(&ComplexityTier::Minimal),
|
||||
&BulkClass::NonPhysical,
|
||||
&ProductionUbiquity::Common,
|
||||
&[],
|
||||
);
|
||||
let sel = draw_body_vocabulary(&catalog, &[], &minimal, SeedChain::for_body(1, "Body"));
|
||||
assert_eq!(sel.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_gate_excludes_wrong_bulk_class() {
|
||||
let catalog = vec![
|
||||
tmpl(
|
||||
"solid_only",
|
||||
10_000,
|
||||
&[BulkClass::BulkSolid],
|
||||
0,
|
||||
&[(DistrictType::Industrial, 10_000)],
|
||||
),
|
||||
tmpl(
|
||||
"any_bulk",
|
||||
10_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::Residential, 10_000)],
|
||||
),
|
||||
];
|
||||
let inputs = base_inputs(
|
||||
complexity_k(&ComplexityTier::Minimal),
|
||||
&BulkClass::NonPhysical,
|
||||
&ProductionUbiquity::Common,
|
||||
&[],
|
||||
);
|
||||
let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(7, "Body"));
|
||||
assert_eq!(sel, vec!["any_bulk".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_prosperity_gate_excludes_below_threshold() {
|
||||
let catalog = vec![tmpl(
|
||||
"expensive",
|
||||
10_000,
|
||||
&[],
|
||||
5_000,
|
||||
&[(DistrictType::Residential, 10_000)],
|
||||
)];
|
||||
let mut inputs = base_inputs(
|
||||
complexity_k(&ComplexityTier::Minimal),
|
||||
&BulkClass::NonPhysical,
|
||||
&ProductionUbiquity::Common,
|
||||
&[],
|
||||
);
|
||||
inputs.max_prosperity_bps = 2_000;
|
||||
let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(7, "Body"));
|
||||
assert!(sel.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_forces_inclusion_and_counts_toward_k() {
|
||||
let catalog = vec![
|
||||
tmpl(
|
||||
"hero_pin",
|
||||
1,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::Administrative, 10_000)],
|
||||
),
|
||||
tmpl(
|
||||
"filler",
|
||||
10_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::MixedUse, 10_000)],
|
||||
),
|
||||
];
|
||||
let bias = vec![TraitBias {
|
||||
template_tag: "hero_pin".to_string(),
|
||||
bias_kind: BiasKind::Pin,
|
||||
weight_multiplier_bps: None,
|
||||
}];
|
||||
let inputs = base_inputs(
|
||||
complexity_k(&ComplexityTier::Minimal), // K=1
|
||||
&BulkClass::NonPhysical,
|
||||
&ProductionUbiquity::Common,
|
||||
&[],
|
||||
);
|
||||
let sel = draw_body_vocabulary(&catalog, &bias, &inputs, SeedChain::for_body(3, "Body"));
|
||||
assert_eq!(sel, vec!["hero_pin".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_repair_swaps_in_a_template_for_an_uncovered_district_type() {
|
||||
// Two templates only cover MixedUse; K=1 draw would starve Administrative
|
||||
// if it were present in the body's coverage — the repair pass must pull
|
||||
// in a template that covers it, even though it's not the highest weight.
|
||||
let catalog = vec![
|
||||
tmpl(
|
||||
"mixed_a",
|
||||
20_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::MixedUse, 10_000)],
|
||||
),
|
||||
tmpl(
|
||||
"mixed_b",
|
||||
15_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::MixedUse, 10_000)],
|
||||
),
|
||||
tmpl(
|
||||
"civic",
|
||||
5_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::Administrative, 10_000)],
|
||||
),
|
||||
];
|
||||
let coverage = vec![DistrictType::MixedUse, DistrictType::Administrative];
|
||||
let inputs = base_inputs(
|
||||
complexity_k(&ComplexityTier::Minimal), // K=1
|
||||
&BulkClass::NonPhysical,
|
||||
&ProductionUbiquity::Common,
|
||||
&coverage,
|
||||
);
|
||||
let sel = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(11, "Body"));
|
||||
assert_eq!(sel.len(), 1, "K stays fixed at 1 even after repair");
|
||||
assert_eq!(
|
||||
sel[0], "civic",
|
||||
"the sole slot must cover Administrative since MixedUse alone starves it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_body_same_world_seed_draws_identical_vocabulary() {
|
||||
let catalog: Vec<TraitTemplate> = (0..8)
|
||||
.map(|i| {
|
||||
tmpl(
|
||||
&format!("t{i}"),
|
||||
10_000 + i * 500,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::MixedUse, 10_000)],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let inputs = base_inputs(
|
||||
complexity_k(&ComplexityTier::Full),
|
||||
&BulkClass::NonPhysical,
|
||||
&ProductionUbiquity::Common,
|
||||
&[],
|
||||
);
|
||||
let a = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1c"));
|
||||
let b = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1c"));
|
||||
assert_eq!(a, b, "identical inputs must draw the identical vocabulary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_body_id_draws_different_vocabulary_stream() {
|
||||
let catalog: Vec<TraitTemplate> = (0..12)
|
||||
.map(|i| {
|
||||
tmpl(
|
||||
&format!("t{i}"),
|
||||
10_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::MixedUse, 10_000)],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let inputs = base_inputs(
|
||||
complexity_k(&ComplexityTier::Full),
|
||||
&BulkClass::NonPhysical,
|
||||
&ProductionUbiquity::Common,
|
||||
&[],
|
||||
);
|
||||
let a = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1c"));
|
||||
let b = draw_body_vocabulary(&catalog, &[], &inputs, SeedChain::for_body(42, "GJ1d"));
|
||||
assert_ne!(a, b, "distinct bodies must not share the exact same draw");
|
||||
}
|
||||
|
||||
// ── Phase 2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn district_dominant_covers_all_nine_types() {
|
||||
let catalog = vec![tmpl(
|
||||
"generic",
|
||||
10_000,
|
||||
&[],
|
||||
0,
|
||||
&ALL_DISTRICT_TYPES.map(|dt| (dt, 10_000)),
|
||||
)];
|
||||
let selection = vec!["generic".to_string()];
|
||||
let map = pick_district_dominant_by_type(
|
||||
&catalog,
|
||||
&[],
|
||||
&selection,
|
||||
SeedChain::for_body(1, "Body"),
|
||||
(3, 5),
|
||||
);
|
||||
assert_eq!(map.len(), 9);
|
||||
for dt in &ALL_DISTRICT_TYPES {
|
||||
assert_eq!(map.get(dt), Some(&ArchitectureFlavorRef::InVocabulary(0)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_district_pos_same_body_gives_identical_dominant_pick() {
|
||||
let catalog = vec![
|
||||
tmpl("a", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]),
|
||||
tmpl("b", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]),
|
||||
];
|
||||
let selection = vec!["a".to_string(), "b".to_string()];
|
||||
let chain = SeedChain::for_body(9, "Body");
|
||||
let m1 = pick_district_dominant_by_type(&catalog, &[], &selection, chain, (2, 2));
|
||||
let m2 = pick_district_dominant_by_type(&catalog, &[], &selection, chain, (2, 2));
|
||||
assert_eq!(
|
||||
m1.get(&DistrictType::Commercial),
|
||||
m2.get(&DistrictType::Commercial),
|
||||
"identical (body, district_pos) must derive the identical dominant pick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_district_pos_can_pick_differently() {
|
||||
// Not a strict guarantee for any two positions, but across a spread of
|
||||
// positions the picks must not all collapse to one constant index —
|
||||
// otherwise the seed derivation isn't actually keyed by position.
|
||||
let catalog = vec![
|
||||
tmpl("a", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]),
|
||||
tmpl("b", 10_000, &[], 0, &[(DistrictType::Commercial, 10_000)]),
|
||||
];
|
||||
let selection = vec!["a".to_string(), "b".to_string()];
|
||||
let chain = SeedChain::for_body(9, "Body");
|
||||
let picks: std::collections::BTreeSet<u8> = (0..20)
|
||||
.map(|i| {
|
||||
let map = pick_district_dominant_by_type(
|
||||
&catalog,
|
||||
&[],
|
||||
&selection,
|
||||
chain,
|
||||
(i, i * 3 + 1),
|
||||
);
|
||||
match map.get(&DistrictType::Commercial) {
|
||||
Some(ArchitectureFlavorRef::InVocabulary(idx)) => *idx,
|
||||
other => panic!("expected an in-vocabulary pick, got {other:?}"),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
picks.len() > 1,
|
||||
"expected variation in the dominant pick across distinct district positions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn district_dominant_falls_back_to_zero_when_nothing_eligible_covers_type() {
|
||||
let catalog = vec![tmpl(
|
||||
"only_residential",
|
||||
10_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::Residential, 10_000)],
|
||||
)];
|
||||
let selection = vec!["only_residential".to_string()];
|
||||
let map = pick_district_dominant_by_type(
|
||||
&catalog,
|
||||
&[],
|
||||
&selection,
|
||||
SeedChain::for_body(1, "Body"),
|
||||
(0, 0),
|
||||
);
|
||||
// Administrative has no candidate anywhere (empty eligible catalog) ->
|
||||
// degenerate InVocabulary(0) fallback, matching pre-T-994 behaviour.
|
||||
assert_eq!(
|
||||
map.get(&DistrictType::Administrative),
|
||||
Some(&ArchitectureFlavorRef::InVocabulary(0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn district_dominant_necessity_swerve_reaches_eligible_catalog() {
|
||||
// The vocabulary only covers Residential; Administrative IS covered by an
|
||||
// eligible out-of-vocabulary template — the sparsity escape hatch (T-1003,
|
||||
// D-232 "same mechanism triggered by necessity") must surface it as a
|
||||
// Swerve rather than defaulting to index 0.
|
||||
let catalog = vec![
|
||||
tmpl(
|
||||
"only_residential",
|
||||
10_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::Residential, 10_000)],
|
||||
),
|
||||
tmpl(
|
||||
"civic_hall",
|
||||
8_000,
|
||||
&[],
|
||||
0,
|
||||
&[(DistrictType::Administrative, 10_000)],
|
||||
),
|
||||
];
|
||||
let eligible: Vec<&TraitTemplate> = catalog.iter().collect();
|
||||
let selection = vec!["only_residential".to_string()];
|
||||
let map = pick_district_dominant_by_type(
|
||||
&catalog,
|
||||
&eligible,
|
||||
&selection,
|
||||
SeedChain::for_body(1, "Body"),
|
||||
(0, 0),
|
||||
);
|
||||
assert_eq!(
|
||||
map.get(&DistrictType::Administrative),
|
||||
Some(&ArchitectureFlavorRef::Swerve("civic_hall".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
map.get(&DistrictType::Residential),
|
||||
Some(&ArchitectureFlavorRef::InVocabulary(0)),
|
||||
"covered types stay in-vocabulary"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! D-232 deviation/swerve system (T-1003) — the rare per-building wildcard that
|
||||
//! draws a COHERENT whole template from *outside* the body's closed K-vocabulary.
|
||||
//!
|
||||
//! **Three sources, two opposed active drivers (D-232):**
|
||||
//! - *foreign import* — another corridor's grammar (that corridor's baseline +
|
||||
//! the shared cross-corridor pool), driven **up** by cosmopolitanism /
|
||||
//! centrality / transit / Epicenter tier;
|
||||
//! - *heritage callback* — the body's own corridor **heritage sub-pool**, driven
|
||||
//! **up** by remoteness / isolation / conservatism;
|
||||
//! - the passive *past-vogue holdover* is **not** drawn here — it rides the
|
||||
//! D-217 wear/era condition layer (`era_cause`), no new mechanism (D-232:
|
||||
//! "the temporal sibling of the spatial swerve").
|
||||
//!
|
||||
//! The swerve is **cultural only**: candidate pools are built from the
|
||||
//! hard-gate-*eligible* catalog (D-233 economic gates still hold — "function
|
||||
//! still passes the normal economic hard gates; never an axis-scramble").
|
||||
//!
|
||||
//! The **sparsity escape hatch is the same mechanism** triggered by necessity
|
||||
//! rather than dice ([`necessity_swerve`]): when the closed vocabulary genuinely
|
||||
//! cannot serve a district type, the phase-2 dominant pick reaches the full
|
||||
//! eligible catalog (see `trait_draw::pick_district_dominant_by_type`).
|
||||
//!
|
||||
//! Like `trait_draw`, this module is pure — the driver rates and candidate
|
||||
//! pools are resolved once per settlement at L3→L4 dispatch time
|
||||
//! (`atlas::plugin`) and threaded through `CityGenerationContext`; the
|
||||
//! per-building roll happens in `skeleton_gen::assign_block_tags` off the
|
||||
//! footprint's own `SeedChain` (never in `FillChunk` — T-987 keeps fill pure).
|
||||
//!
|
||||
//! All numbers are integer basis points (D-010). Every constant below is a
|
||||
//! **T-1003 refinement placeholder** (base 100 bps ≈ 1 %/building, hard cap
|
||||
//! 300 bps per driver) — needs Nigel/Burnelli calibration once real
|
||||
//! multi-corridor bodies are authored, same status as
|
||||
//! `trait_draw::SECTOR_MISMATCH_BPS`.
|
||||
|
||||
use crate::atlas::trait_catalog_reader::TraitTemplate;
|
||||
use crate::seed::AtlasRng;
|
||||
use crate::simulation::generator::WorldTier;
|
||||
|
||||
/// Baseline per-building wildcard chance (bps of 10 000) before driver scaling.
|
||||
const SWERVE_BASE_BPS: u32 = 100;
|
||||
/// Hard cap per driver after scaling (refinement: "hard cap 300 bps").
|
||||
const SWERVE_DRIVER_CAP_BPS: u32 = 300;
|
||||
|
||||
// ── Foreign-import driver multipliers (bps, 10 000 = 1.0×) ──────────────────
|
||||
/// Epicenter tier — the cosmopolitan hub end of the dial.
|
||||
const FOREIGN_EPICENTER_MULT_BPS: u32 = 20_000;
|
||||
/// Passage tier — the refinement's "transit" input.
|
||||
const FOREIGN_PASSAGE_MULT_BPS: u32 = 15_000;
|
||||
/// `dominant_faction == "mixed"` — the refinement's "cosmopolitanism" input.
|
||||
const FOREIGN_MIXED_FACTION_MULT_BPS: u32 = 15_000;
|
||||
/// Per road/rail-graph link (centrality), additive on the multiplier.
|
||||
const FOREIGN_PER_ROAD_DEGREE_BPS: u32 = 1_000;
|
||||
/// Degree contribution cap — beyond 5 links a hub is a hub.
|
||||
const FOREIGN_ROAD_DEGREE_CAP: u32 = 5;
|
||||
|
||||
// ── Heritage-callback driver multipliers ─────────────────────────────────────
|
||||
/// Road/rail degree ≤ 1 — the refinement's "isolation" input.
|
||||
const HERITAGE_ISOLATED_MULT_BPS: u32 = 20_000;
|
||||
/// Waypoint/Backwater tier — remoteness proxy. (The refinement floated a
|
||||
/// `star_systems.dist_ly` percentile; that column is not in the D-199 read-set
|
||||
/// today, and tier + graph degree are the in-world signals the percentile was
|
||||
/// approximating. Slot a distance band in here if a reader field ever lands.)
|
||||
const HERITAGE_REMOTE_TIER_MULT_BPS: u32 = 15_000;
|
||||
/// `founding_age_years ≥ 300` — the refinement's "conservatism" input.
|
||||
const HERITAGE_OLD_FOUNDING_MULT_BPS: u32 = 15_000;
|
||||
/// `founding_age_years ≥ 150` (and < 300).
|
||||
const HERITAGE_MID_FOUNDING_MULT_BPS: u32 = 12_500;
|
||||
|
||||
/// Per-settlement driver inputs, all resolvable at L3→L4 dispatch time from
|
||||
/// data that exists today (T-1003 refinement: "mappings to REAL fields").
|
||||
///
|
||||
/// `world_tier` currently rides the `city_context_reader` Waypoint stub in
|
||||
/// production (same caveat as the T-994 `max_k` aggregation) — the Epicenter/
|
||||
/// Passage multipliers activate for real once a `world_tier` derivation lands.
|
||||
pub struct SwerveDrivers<'a> {
|
||||
pub world_tier: &'a WorldTier,
|
||||
/// `dominant_faction == Some("mixed")` (cosmopolitanism).
|
||||
pub faction_mixed: bool,
|
||||
/// This settlement's node degree in the T-1038 road/rail graph
|
||||
/// (centrality high — isolation low).
|
||||
pub road_degree: u32,
|
||||
/// D-199 field 5 (conservatism: older settlements reach back harder).
|
||||
pub founding_age_years: u32,
|
||||
}
|
||||
|
||||
/// Per-building swerve chances (bps of 10 000), one per active driver. A
|
||||
/// driver whose candidate pool is empty contributes no roll mass — enforced
|
||||
/// inside [`roll_building_swerve`], not here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SwerveRates {
|
||||
pub foreign_bps: u32,
|
||||
pub heritage_bps: u32,
|
||||
}
|
||||
|
||||
/// Scale the base rate by the two opposed driver stacks (D-232). A settlement
|
||||
/// can plausibly score on both (an old, well-connected regional town keeps a
|
||||
/// nonzero heritage pull) — the drivers oppose in *what they favour*, not as a
|
||||
/// zero-sum split.
|
||||
pub fn compute_swerve_rates(drivers: &SwerveDrivers) -> SwerveRates {
|
||||
let mut foreign_mult: u64 = 10_000;
|
||||
match drivers.world_tier {
|
||||
WorldTier::Epicenter => foreign_mult = FOREIGN_EPICENTER_MULT_BPS as u64,
|
||||
WorldTier::Passage => foreign_mult = FOREIGN_PASSAGE_MULT_BPS as u64,
|
||||
_ => {}
|
||||
}
|
||||
if drivers.faction_mixed {
|
||||
foreign_mult = foreign_mult * FOREIGN_MIXED_FACTION_MULT_BPS as u64 / 10_000;
|
||||
}
|
||||
foreign_mult +=
|
||||
(drivers.road_degree.min(FOREIGN_ROAD_DEGREE_CAP) * FOREIGN_PER_ROAD_DEGREE_BPS) as u64;
|
||||
|
||||
let mut heritage_mult: u64 = 10_000;
|
||||
if drivers.road_degree <= 1 {
|
||||
heritage_mult = HERITAGE_ISOLATED_MULT_BPS as u64;
|
||||
}
|
||||
if matches!(
|
||||
drivers.world_tier,
|
||||
WorldTier::Waypoint | WorldTier::Backwater
|
||||
) {
|
||||
heritage_mult = heritage_mult * HERITAGE_REMOTE_TIER_MULT_BPS as u64 / 10_000;
|
||||
}
|
||||
if drivers.founding_age_years >= 300 {
|
||||
heritage_mult = heritage_mult * HERITAGE_OLD_FOUNDING_MULT_BPS as u64 / 10_000;
|
||||
} else if drivers.founding_age_years >= 150 {
|
||||
heritage_mult = heritage_mult * HERITAGE_MID_FOUNDING_MULT_BPS as u64 / 10_000;
|
||||
}
|
||||
|
||||
SwerveRates {
|
||||
foreign_bps: ((SWERVE_BASE_BPS as u64 * foreign_mult / 10_000) as u32)
|
||||
.min(SWERVE_DRIVER_CAP_BPS),
|
||||
heritage_bps: ((SWERVE_BASE_BPS as u64 * heritage_mult / 10_000) as u32)
|
||||
.min(SWERVE_DRIVER_CAP_BPS),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two out-of-vocabulary candidate pools, weighted by `base_weight`
|
||||
/// (hero-body bias intentionally not applied — bias shapes the body's *own*
|
||||
/// vocabulary, D-232; a swerve is by definition from elsewhere/elsewhen).
|
||||
/// Tags, not indices: the swerve result is recorded as
|
||||
/// `ArchitectureFlavorRef::Swerve(tag)`, re-derivable like everything else.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct SwervePools {
|
||||
/// Foreign import: eligible templates outside the vocabulary from *another*
|
||||
/// corridor's grammar or the shared `cross_corridor` pool.
|
||||
pub foreign: Vec<(String, u32)>,
|
||||
/// Heritage callback: eligible `heritage`-pool templates of the body's own
|
||||
/// corridor (or sector-unpinned heritage entries).
|
||||
pub heritage: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
/// Partition the hard-gate-eligible catalog (minus the body's own vocabulary)
|
||||
/// into the two swerve pools (D-232 corridor = two-part pool).
|
||||
///
|
||||
/// `eligible` must already have passed the D-233 hard gates
|
||||
/// (`trait_draw::hard_gate_eligible`) — the cultural-only rule.
|
||||
pub fn build_swerve_pools(
|
||||
eligible: &[&TraitTemplate],
|
||||
trait_selection: &[String],
|
||||
body_sector: Option<&str>,
|
||||
) -> SwervePools {
|
||||
let mut pools = SwervePools::default();
|
||||
for t in eligible {
|
||||
if trait_selection.iter().any(|tag| tag == &t.tag) {
|
||||
continue; // in-vocabulary — the closed draw already covers it
|
||||
}
|
||||
let entry = (t.tag.clone(), t.base_weight.max(1));
|
||||
match t.corridor_pool.as_str() {
|
||||
// Another corridor's baseline grammar, or the shared cross-corridor
|
||||
// pool that D-232 says "feeds the foreign-import swerves".
|
||||
"cross_corridor" => pools.foreign.push(entry),
|
||||
"baseline" => match (t.geographic_sector.as_deref(), body_sector) {
|
||||
(Some(sector), Some(own)) if sector != own => pools.foreign.push(entry),
|
||||
(Some(_), None) => pools.foreign.push(entry),
|
||||
_ => {} // own-corridor (or unpinned) baseline — not foreign
|
||||
},
|
||||
// The body's own corridor heritage sub-pool ("the remoteness dial
|
||||
// draws specifically from it"). Other corridors' heritage is *not*
|
||||
// a foreign-import source — D-232 scopes foreign import to grammar,
|
||||
// heritage callback to one's own past.
|
||||
"heritage" => match (t.geographic_sector.as_deref(), body_sector) {
|
||||
(Some(sector), Some(own)) if sector == own => pools.heritage.push(entry),
|
||||
(None, _) => pools.heritage.push(entry),
|
||||
_ => {}
|
||||
},
|
||||
other => {
|
||||
tracing::debug!(tag = %t.tag, corridor_pool = %other, "unknown corridor_pool — excluded from swerve pools");
|
||||
}
|
||||
}
|
||||
}
|
||||
pools
|
||||
}
|
||||
|
||||
/// Weighted pick from one pool. `None` on an empty pool.
|
||||
fn pick_weighted(pool: &[(String, u32)], rng: &mut AtlasRng) -> Option<String> {
|
||||
if pool.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let total: u64 = pool.iter().map(|(_, w)| *w as u64).sum();
|
||||
let mut roll = (rng.next_u32() as u64) % total.max(1);
|
||||
for (tag, w) in pool {
|
||||
if roll < *w as u64 {
|
||||
return Some(tag.clone());
|
||||
}
|
||||
roll -= *w as u64;
|
||||
}
|
||||
pool.last().map(|(tag, _)| tag.clone())
|
||||
}
|
||||
|
||||
/// The per-building wildcard roll (D-232 deviation system). One `u32` roll in
|
||||
/// `[0, 10 000)`: below `foreign_bps` → foreign-import pick; below
|
||||
/// `foreign_bps + heritage_bps` → heritage-callback pick; otherwise `None`
|
||||
/// (the overwhelmingly common case — the building takes the district-dominant
|
||||
/// template as usual). An empty pool's driver contributes no roll mass — a hit
|
||||
/// would have nothing to draw.
|
||||
///
|
||||
/// Takes the pools as slices (the `CityGenerationContext` fields) so per-block
|
||||
/// callers never construct anything. `rng` must be a footprint-scoped stream
|
||||
/// (`SeedDomain::TraitSwerve` off the footprint's own chain) so the roll is
|
||||
/// deterministic per building (D-010) and uncorrelated with the zone/era/
|
||||
/// extent draws.
|
||||
pub fn roll_building_swerve(
|
||||
rates: SwerveRates,
|
||||
foreign_pool: &[(String, u32)],
|
||||
heritage_pool: &[(String, u32)],
|
||||
rng: &mut AtlasRng,
|
||||
) -> Option<String> {
|
||||
let foreign_bps = if foreign_pool.is_empty() {
|
||||
0
|
||||
} else {
|
||||
rates.foreign_bps
|
||||
};
|
||||
let heritage_bps = if heritage_pool.is_empty() {
|
||||
0
|
||||
} else {
|
||||
rates.heritage_bps
|
||||
};
|
||||
let total = foreign_bps + heritage_bps;
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let roll = rng.next_u32() % 10_000;
|
||||
if roll < foreign_bps {
|
||||
pick_weighted(foreign_pool, rng)
|
||||
} else if roll < total {
|
||||
pick_weighted(heritage_pool, rng)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The sparsity escape hatch (D-232: "the SAME mechanism triggered by necessity
|
||||
/// rather than dice"): when zero vocabulary templates serve a district type at
|
||||
/// phase-2 dominant-pick time, reach the full hard-gate-eligible catalog for
|
||||
/// the best-weighted template that covers it. Deterministic (max-weight,
|
||||
/// last-on-tie over the catalog's stable tag-sorted order — D-010), no dice:
|
||||
/// necessity is not random.
|
||||
pub fn necessity_swerve(
|
||||
eligible: &[&TraitTemplate],
|
||||
covers: impl Fn(&TraitTemplate) -> bool,
|
||||
) -> Option<String> {
|
||||
eligible
|
||||
.iter()
|
||||
.filter(|t| covers(t))
|
||||
.max_by_key(|t| t.base_weight)
|
||||
.map(|t| t.tag.clone())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::seed::{SeedChain, SeedDomain};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn tmpl(tag: &str, pool: &str, sector: Option<&str>, base_weight: u32) -> TraitTemplate {
|
||||
TraitTemplate {
|
||||
tag: tag.to_string(),
|
||||
corridor_pool: pool.to_string(),
|
||||
geographic_sector: sector.map(str::to_string),
|
||||
bulk_class_gate: Vec::new(),
|
||||
production_ubiquity_gate: Vec::new(),
|
||||
min_prosperity_bps: 0,
|
||||
base_weight,
|
||||
weight_mods: BTreeMap::new(),
|
||||
zone_affinity: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn quiet_drivers() -> SwerveDrivers<'static> {
|
||||
SwerveDrivers {
|
||||
world_tier: &WorldTier::Regional,
|
||||
faction_mixed: false,
|
||||
road_degree: 2,
|
||||
founding_age_years: 50,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_rates_are_the_base_bps() {
|
||||
let r = compute_swerve_rates(&quiet_drivers());
|
||||
assert_eq!(
|
||||
r.foreign_bps,
|
||||
SWERVE_BASE_BPS + 2 * FOREIGN_PER_ROAD_DEGREE_BPS / 100
|
||||
);
|
||||
assert_eq!(r.heritage_bps, SWERVE_BASE_BPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epicenter_hub_boosts_foreign_and_caps() {
|
||||
let drivers = SwerveDrivers {
|
||||
world_tier: &WorldTier::Epicenter,
|
||||
faction_mixed: true,
|
||||
road_degree: 9,
|
||||
founding_age_years: 50,
|
||||
};
|
||||
let r = compute_swerve_rates(&drivers);
|
||||
assert_eq!(
|
||||
r.foreign_bps, SWERVE_DRIVER_CAP_BPS,
|
||||
"×2.0 ×1.5 + degree hits the cap"
|
||||
);
|
||||
assert_eq!(r.heritage_bps, SWERVE_BASE_BPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_old_backwater_boosts_heritage_and_caps() {
|
||||
let drivers = SwerveDrivers {
|
||||
world_tier: &WorldTier::Backwater,
|
||||
faction_mixed: false,
|
||||
road_degree: 1,
|
||||
founding_age_years: 400,
|
||||
};
|
||||
let r = compute_swerve_rates(&drivers);
|
||||
assert_eq!(
|
||||
r.heritage_bps, SWERVE_DRIVER_CAP_BPS,
|
||||
"×2.0 ×1.5 ×1.5 hits the cap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pools_partition_by_corridor_and_exclude_vocabulary() {
|
||||
let own = tmpl("own_baseline", "baseline", Some("east_reach"), 10_000);
|
||||
let foreign = tmpl("west_baseline", "baseline", Some("west_reach"), 10_000);
|
||||
let shared = tmpl("shared", "cross_corridor", None, 10_000);
|
||||
let own_heritage = tmpl("east_temple", "heritage", Some("east_reach"), 10_000);
|
||||
let foreign_heritage = tmpl("west_hacienda", "heritage", Some("west_reach"), 10_000);
|
||||
let in_vocab = tmpl("in_vocab", "cross_corridor", None, 10_000);
|
||||
let eligible: Vec<&TraitTemplate> = vec![
|
||||
&own,
|
||||
&foreign,
|
||||
&shared,
|
||||
&own_heritage,
|
||||
&foreign_heritage,
|
||||
&in_vocab,
|
||||
];
|
||||
|
||||
let pools = build_swerve_pools(&eligible, &["in_vocab".to_string()], Some("east_reach"));
|
||||
let foreign_tags: Vec<&str> = pools.foreign.iter().map(|(t, _)| t.as_str()).collect();
|
||||
let heritage_tags: Vec<&str> = pools.heritage.iter().map(|(t, _)| t.as_str()).collect();
|
||||
assert_eq!(foreign_tags, ["west_baseline", "shared"]);
|
||||
assert_eq!(
|
||||
heritage_tags,
|
||||
["east_temple"],
|
||||
"other corridors' heritage is not drawn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pools_never_swerve_regardless_of_rates() {
|
||||
let rates = SwerveRates {
|
||||
foreign_bps: 10_000,
|
||||
heritage_bps: 10_000,
|
||||
};
|
||||
let mut rng = SeedChain::root(1)
|
||||
.derive(SeedDomain::TraitSwerve, 0)
|
||||
.atlas_rng();
|
||||
for _ in 0..100 {
|
||||
assert_eq!(roll_building_swerve(rates, &[], &[], &mut rng), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swerve_is_rare_and_deterministic() {
|
||||
let shared = tmpl("shared", "cross_corridor", None, 10_000);
|
||||
let temple = tmpl("temple", "heritage", None, 10_000);
|
||||
let eligible: Vec<&TraitTemplate> = vec![&shared, &temple];
|
||||
let pools = build_swerve_pools(&eligible, &[], None);
|
||||
let rates = SwerveRates {
|
||||
foreign_bps: 100,
|
||||
heritage_bps: 100,
|
||||
}; // 2% total
|
||||
|
||||
let count_hits = || {
|
||||
let mut hits = 0;
|
||||
for i in 0..10_000u64 {
|
||||
let mut rng = SeedChain::root(7)
|
||||
.derive(SeedDomain::TraitSwerve, i)
|
||||
.atlas_rng();
|
||||
if roll_building_swerve(rates, &pools.foreign, &pools.heritage, &mut rng).is_some()
|
||||
{
|
||||
hits += 1;
|
||||
}
|
||||
}
|
||||
hits
|
||||
};
|
||||
let hits = count_hits();
|
||||
assert_eq!(hits, count_hits(), "same seeds → same swerves (D-010)");
|
||||
// 2% nominal over 10k rolls — generous band, this is a rarity check
|
||||
// not a distribution test.
|
||||
assert!(
|
||||
(100..=400).contains(&hits),
|
||||
"expected ~200 swerves in 10k rolls, got {hits}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn necessity_swerve_picks_max_weight_covering_template() {
|
||||
let a = tmpl("light", "baseline", None, 5_000);
|
||||
let b = tmpl("heavy", "baseline", None, 9_000);
|
||||
let c = tmpl("heavier_but_not_covering", "baseline", None, 12_000);
|
||||
let eligible: Vec<&TraitTemplate> = vec![&a, &b, &c];
|
||||
let picked = necessity_swerve(&eligible, |t| t.tag != "heavier_but_not_covering");
|
||||
assert_eq!(picked.as_deref(), Some("heavy"));
|
||||
assert_eq!(necessity_swerve(&eligible, |_| false), None);
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,28 @@ fn main() {
|
||||
),
|
||||
}
|
||||
|
||||
// D-232 trait-template catalog reader (T-994): reads `trait_templates` +
|
||||
// `atlas_body_trait_bias` on a body-analysis completion so the L3→L4 dispatch
|
||||
// aggregation (atlas::plugin::drain_generation_completions) can run the
|
||||
// three-phase draw. Absent → trait_selection stays empty for every body
|
||||
// (the pre-T-994 degenerate behaviour), not a hard failure.
|
||||
match settled_reach_server::atlas::trait_catalog_reader::TraitCatalogReader::open(
|
||||
&systems_db_path,
|
||||
) {
|
||||
Ok(reader) => {
|
||||
tracing::info!("Trait catalog reader opened: {:?}", systems_db_path);
|
||||
app.insert_resource(
|
||||
settled_reach_server::atlas::trait_catalog_reader::TraitCatalogReaderResource(
|
||||
reader,
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
"Trait catalog reader unavailable ({}). Architecture-flavor draw will stay empty.",
|
||||
e
|
||||
),
|
||||
}
|
||||
|
||||
// Initialize SQLite settings store (#627).
|
||||
// Path: alongside save files in the server's working directory.
|
||||
let settings_path = std::path::PathBuf::from("settings.db");
|
||||
|
||||
@@ -120,6 +120,22 @@ pub enum SeedDomain {
|
||||
/// keyed by a single constant id). Distinct domain so the mosaic-selection lattice
|
||||
/// can never correlate with the relief, cover, or per-voxel terrain streams.
|
||||
VoxelMosaic = 12,
|
||||
/// Architecture-flavor body-vocabulary K-draw (D-232 phase 1, T-994). Keyed by
|
||||
/// a constant id (one draw per body — `SeedChain::for_body` already isolates
|
||||
/// bodies). Distinct domain so the vocabulary draw can never correlate with
|
||||
/// any other body-scoped stream.
|
||||
TraitVocabulary = 13,
|
||||
/// Architecture-flavor district-dominant template pick (D-232 phase 2, T-994).
|
||||
/// Keyed by a packed `(DistrictPos, DistrictType)` id so every settlement whose
|
||||
/// quarter falls in the same D-243 2 048 m district independently derives the
|
||||
/// *identical* dominant template for a given district type — same body seed +
|
||||
/// same key, no cross-quarter coordination required.
|
||||
TraitDistrict = 14,
|
||||
/// Per-building deviation/swerve roll (D-232 deviation system, T-1003).
|
||||
/// Derived off the footprint's own chain, keyed by footprint index. Distinct
|
||||
/// domain so the rare-wildcard roll can never correlate with the zone/era/
|
||||
/// extent draws sharing that chain.
|
||||
TraitSwerve = 15,
|
||||
}
|
||||
|
||||
/// A position in the deterministic seed tree (D-224).
|
||||
@@ -281,6 +297,9 @@ mod tests {
|
||||
assert_eq!(SeedDomain::Cover as u64, 10);
|
||||
assert_eq!(SeedDomain::VoxelRelief as u64, 11);
|
||||
assert_eq!(SeedDomain::VoxelMosaic as u64, 12);
|
||||
assert_eq!(SeedDomain::TraitVocabulary as u64, 13);
|
||||
assert_eq!(SeedDomain::TraitDistrict as u64, 14);
|
||||
assert_eq!(SeedDomain::TraitSwerve as u64, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -198,7 +198,13 @@ pub enum SettingType {
|
||||
}
|
||||
|
||||
/// Classification of a district (high-level function).
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
|
||||
///
|
||||
/// `Ord`/`PartialOrd` (T-994): lets callers dedup a body-wide coverage set into a
|
||||
/// `BTreeSet`/`BTreeMap` key (D-010 determinism) — the D-232 trait-template draw's
|
||||
/// `zone_affinity` lookup and the body-level district-type-mix coverage aggregate.
|
||||
/// Declaration order is not a stability-pinned wire format (unlike `MorphologyZone`
|
||||
/// or `SeedDomain`), so this is a safe additive derive.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||
pub enum DistrictType {
|
||||
LogisticsHub,
|
||||
Residential,
|
||||
@@ -817,15 +823,31 @@ impl TileRect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Architecture flavor index into the body's trait-template draw (D-229, D-232).
|
||||
/// Architecture flavor reference into the body's trait-template draw (D-229, D-232).
|
||||
///
|
||||
/// Records which template the generator selected at skeleton time for Phase-6 to
|
||||
/// read cold. The selection mechanism is D-232's weighted `allow`/`block` filter;
|
||||
/// the index is frozen-amber once written.
|
||||
/// read cold. The selection mechanism is D-232's three-phase draw (T-994): a body
|
||||
/// vocabulary K-draw, then a per-district dominant-template pick by `zone_affinity`,
|
||||
/// then within-template seed picks (not represented here — that's the D-235 visual
|
||||
/// bundle resolution). The reference is frozen-amber once written.
|
||||
///
|
||||
/// Shaped as an enum (rather than a bare index) so the closed-vocabulary case and
|
||||
/// the future out-of-vocabulary swerve (T-1003, D-232's deviation system) share one
|
||||
/// wire type without a breaking change when the swerve lands — `T-994` introduces
|
||||
/// the shape only; swerve *logic* (foreign-import / heritage-callback draws) is not
|
||||
/// implemented here.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArchitectureFlavorRef {
|
||||
/// Index into `CityGenerationContext::trait_selection` (0-based).
|
||||
pub flavor_index: u8,
|
||||
pub enum ArchitectureFlavorRef {
|
||||
/// Index into `CityGenerationContext::trait_selection` (0-based) — the body's
|
||||
/// closed K-template vocabulary (D-232 phase 1).
|
||||
InVocabulary(u8),
|
||||
/// A template tag drawn from *outside* the body's closed vocabulary (T-1003,
|
||||
/// D-232 deviation system): the rare per-building foreign-import /
|
||||
/// heritage-callback wildcard (`trait_swerve::roll_building_swerve`), or the
|
||||
/// sparsity escape hatch at district-dominant time
|
||||
/// (`trait_swerve::necessity_swerve`). The passive past-vogue holdover is NOT
|
||||
/// a swerve — it rides the D-217 wear/era condition layer (`era_cause`).
|
||||
Swerve(String),
|
||||
}
|
||||
|
||||
/// A single building footprint tag — the frozen step-3 output placed on every
|
||||
@@ -1116,11 +1138,57 @@ pub struct CityGenerationContext {
|
||||
/// Body vocabulary draw result — K trait-template tags selected at skeleton
|
||||
/// time (D-232). K is locked to `complexity_tier`: Full=5, Moderate=3,
|
||||
/// Minimal=1, Empty=0. NOT named `flavor_profile` (that was the round-2 name).
|
||||
/// Populated by the T-994 phase-1 K-draw; empty when the trait catalog reader
|
||||
/// is unavailable or `complexity_tier == Empty` (K=0).
|
||||
pub trait_selection: Vec<String>,
|
||||
/// Dominant `BulkClass` for this settlement's primary commodity (D-233).
|
||||
pub dominant_bulk_class: BulkClass,
|
||||
/// Spatial concentration of dominant production (D-233).
|
||||
pub dominant_production_ubiquity: ProductionUbiquity,
|
||||
|
||||
// ── T-994 additions (D-232 three-phase draw) ───────────────────────────
|
||||
/// This system's corridor (`star_systems.geographic_sector` —
|
||||
/// core/north_reach/south_reach/west_reach/east_reach/deep_frontier; `None`
|
||||
/// if unset). A SOFT weight on the phase-1 body-vocabulary draw only — composes
|
||||
/// with a template's own `geographic_sector` pool-narrowing column AND its
|
||||
/// `weight_mods.geographic_sector` map (PR #148 review note: a two-part join,
|
||||
/// never a hard gate — D-232 "corridors are tendencies, not borders").
|
||||
pub geographic_sector: Option<String>,
|
||||
/// Body-level district-type coverage: every `DistrictType` present anywhere
|
||||
/// among this body's settlements, deduped (T-994). Threaded from the L3→L4
|
||||
/// dispatch aggregation so the phase-1 K-draw is coverage-aware up front
|
||||
/// (≥1 eligible template per district type actually present on the body).
|
||||
pub body_district_type_mix: Vec<DistrictType>,
|
||||
/// The D-243 2 048 m District cell (`atlas::scale::DistrictPos`) this
|
||||
/// settlement's quarter falls in (T-994). Two settlements sharing a
|
||||
/// `settlement_district_pos` independently derive the identical phase-2
|
||||
/// dominant template for a given `DistrictType` — same body seed + same key,
|
||||
/// so a coherent 2 048 m area reads as one style with no cross-quarter
|
||||
/// coordination required.
|
||||
pub settlement_district_pos: (i32, i32),
|
||||
/// Phase-2 dominant-template pick (D-232), pre-resolved at L3→L4 dispatch time
|
||||
/// (T-994) — **not** at `FillChunk` (T-987 keeps fill pure/cache-free) and not
|
||||
/// even inside the `GenerateSkeleton` Rayon task itself, since the inputs
|
||||
/// (`trait_selection` + the catalog's `zone_affinity`) are already known at
|
||||
/// dispatch. One entry per `DistrictType` (all 9, so `assign_block_tags` is a
|
||||
/// cheap infallible lookup). Usually `InVocabulary` (an index into
|
||||
/// `trait_selection`); `Swerve` when the sparsity escape hatch fired (T-1003).
|
||||
/// `BTreeMap` for D-010 determinism.
|
||||
pub district_dominant_by_type: BTreeMap<DistrictType, ArchitectureFlavorRef>,
|
||||
|
||||
// ── T-1003 additions (D-232 deviation/swerve system) ────────────────────
|
||||
/// Per-building wildcard chances (bps of 10 000): `(foreign_bps,
|
||||
/// heritage_bps)`, resolved once per settlement at dispatch time from the
|
||||
/// driver inputs (`trait_swerve::compute_swerve_rates`). Both zero when no
|
||||
/// catalog reader is wired.
|
||||
pub swerve_rates_bps: (u32, u32),
|
||||
/// Foreign-import swerve candidates — hard-gate-eligible templates outside
|
||||
/// the body vocabulary, from another corridor's grammar or the shared
|
||||
/// `cross_corridor` pool: `(tag, weight_bps)` (`trait_swerve` module).
|
||||
pub swerve_foreign_pool: Vec<(String, u32)>,
|
||||
/// Heritage-callback swerve candidates — the body's own corridor heritage
|
||||
/// sub-pool: `(tag, weight_bps)`.
|
||||
pub swerve_heritage_pool: Vec<(String, u32)>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1146,6 +1214,12 @@ pub struct BlockSkeleton {
|
||||
/// Grid position (0–3, 0–3).
|
||||
pub position: (u8, u8),
|
||||
pub zoning: ZoningType,
|
||||
/// The D-194 district-mix `DistrictType` this block was assigned (T-994).
|
||||
/// `zoning` is the *function* derived from it (`zoning_for_district`); this
|
||||
/// field keeps the source classification around because the D-232 phase-2
|
||||
/// dominant-template pick keys `zone_affinity` by `DistrictType`, not
|
||||
/// `ZoningType` — dropping it here would make the pick unrecoverable.
|
||||
pub district_type: DistrictType,
|
||||
/// Which multi-block reservation this block belongs to (if any).
|
||||
pub reservation: Option<ReservationId>,
|
||||
pub chunk_layout: ChunkLayout,
|
||||
|
||||
Reference in New Issue
Block a user