diff --git a/server/src/atlas/city_context_reader.rs b/server/src/atlas/city_context_reader.rs index 7c46cf496..e2f8d4904 100644 --- a/server/src/atlas/city_context_reader.rs +++ b/server/src/atlas/city_context_reader.rs @@ -8,7 +8,7 @@ //! **Fields read** (D-199): //! //! 1. `economic_role` — from `atlas_city_names.economic_role` -//! 2. `prosperity_baseline` — derived per D-197 (role base + pop bonus + noise; terrain gradient left 0.0) +//! 2. `prosperity_baseline_bps` — derived per D-197 (role base + pop bonus + noise; terrain gradient left 0) //! 3. `population` — from `atlas_city_names.population` //! 4. `dominant_faction` — from `system_factions.dominant_faction` (via `bodies.system_id`) //! 5. `founding_age_years` — from `bodies.founding_age_years` @@ -27,13 +27,13 @@ //! - `dominant_bulk_class` — NonPhysical default (#982 design-blocked) //! - `dominant_production_ubiquity` — Common default (#982 design-blocked) //! -//! **Prosperity derivation (D-197, partial):** -//! `prosperity_baseline = clamp(role_base + pop_bonus + noise, 0.1, 0.95)` -//! where: -//! - `role_base` — per-role lookup (10 values; D-197 table) -//! - `pop_bonus` — `0.04 × floor(log10(pop / 1_000_000 + 1))`, capped at +0.12 -//! - `terrain_bonus` — 0.0 (Layer-1 topography not yet available here) -//! - `noise` — ±0.05 uniform noise via `SeedChain` (D-010 deterministic) +//! **Prosperity derivation (D-197, partial — integer basis points, D-010):** +//! `prosperity_baseline_bps = clamp(role_base_bps + pop_bonus_bps + noise_bps, 1000, 9500)` +//! where (all values are integer basis points; 10_000 bps = 1.0): +//! - `role_base_bps` — per-role lookup (10 values; D-197 table) +//! - `pop_bonus_bps` — `400 × log10_floor(pop / 1_000_000 + 1)`, capped at +1200 +//! - `terrain_bonus_bps` — 0 (Layer-1 topography not yet available here) +//! - `noise_bps` — ±500 symmetric uniform via `SeedChain` and integer modulo (D-010 deterministic) //! //! Read-only `systems.db` access follows the same pattern as //! [`crate::atlas::source_resolver::BodySourceResolver`]. @@ -44,6 +44,7 @@ use std::sync::{Arc, Mutex}; use rusqlite::{Connection, OpenFlags}; use thiserror::Error; +use crate::bps::log10_floor; use crate::seed::{fnv1a_64, splitmix64, AtlasRng, SeedChain, SeedDomain}; use crate::simulation::generator::{ BulkClass, CityGenerationContext, FoundingOrientation, MorphologyZone, PoliticalArchetype, @@ -78,7 +79,8 @@ pub struct CityEconomicReadSet { /// D-199 field 1. pub economic_role: String, /// D-199 field 2 — derived by `prosperity_baseline_from_read_set`. - pub prosperity_baseline: f32, + /// Integer basis points (0–10_000; 10_000 = 1.0). D-010 integer-only. + pub prosperity_baseline_bps: u32, /// D-199 field 3. pub population: i64, /// D-199 field 4. `None` = faction data absent for this system. @@ -194,13 +196,14 @@ impl CityContextReader { let settlement_class = parse_settlement_class(settlement_class_opt.as_deref(), city_id)?; - // D-197: derive prosperity_baseline from role base + pop bonus + noise. - let prosperity_baseline = + // D-197: derive prosperity_baseline_bps from role base + pop bonus + noise. + // All arithmetic is integer (basis points, 10_000 = 1.0) — D-010 compliant. + let prosperity_baseline_bps = prosperity_baseline_from_read_set(&economic_role, population, city_id, world_seed); Ok(CityEconomicReadSet { economic_role, - prosperity_baseline, + prosperity_baseline_bps, population, dominant_faction, founding_age_years, @@ -225,69 +228,90 @@ impl CityContextReader { } // --------------------------------------------------------------------------- -// prosperity_baseline derivation (D-197, partial) +// prosperity_baseline_bps derivation (D-197, partial — integer basis points) // --------------------------------------------------------------------------- -/// Per-role base prosperity (D-197 table). -fn role_base_prosperity(role: &str) -> f32 { - match role { - "manufacturing" => 0.55, - "financial" => 0.70, - "agricultural" => 0.50, - "extraction" => 0.45, - "service_mixed" => 0.60, - "institutional" => 0.65, - "transit_hub" => 0.60, - "research" => 0.65, - "military" => 0.55, - "residential" => 0.50, - // Unknown role → mid-point fallback; logged by the caller if needed. - _ => 0.55, - } -} - -/// Population log-scale bonus (D-197): `0.04 × floor(log10(pop / 1_000_000 + 1))`, -/// capped at +0.12. -fn pop_bonus(population: i64) -> f32 { - if population <= 0 { - return 0.0; - } - // log10((pop / 1_000_000) + 1), floored to integer. Use integer arithmetic - // to stay D-010 compliant (no f64 log calls with platform-dependent rounding). - let ratio = (population as f64 / 1_000_000.0 + 1.0).log10().floor() as i32; - let bonus = 0.04 * ratio.max(0) as f32; - bonus.min(0.12) -} - -/// ±0.05 uniform noise seeded deterministically from (world_seed, city_id) (D-010). +/// Per-role base prosperity in basis points (D-197 table). /// -/// Uses `SeedChain` to derive a per-city noise stream, then maps a `u32` to -/// the range [0.0, 0.1) and subtracts 0.05, producing [-0.05, +0.05). -fn prosperity_noise(city_id: u64, world_seed: u64) -> f32 { +/// 10_000 bps = 1.0. Values match the previous f32 table scaled by ×10_000: +/// 0.55 → 5500, 0.70 → 7000, etc. +fn role_base_bps(role: &str) -> u32 { + match role { + "manufacturing" => 5_500, + "financial" => 7_000, + "agricultural" => 5_000, + "extraction" => 4_500, + "service_mixed" => 6_000, + "institutional" => 6_500, + "transit_hub" => 6_000, + "research" => 6_500, + "military" => 5_500, + "residential" => 5_000, + // Unknown role → mid-point fallback; logged by the caller if needed. + _ => 5_500, + } +} + +/// Population log-scale bonus in basis points (D-197). +/// +/// `400 × log10_floor(pop / 1_000_000 + 1)`, capped at +1200. +/// +/// Uses [`log10_floor`] — no floats, no platform-dependent rounding (D-010). +/// The `+1` inside the log10 ensures the result is ≥ 0 for any positive pop. +fn pop_bonus_bps(population: i64) -> u32 { + if population <= 0 { + return 0; + } + // ratio = pop / 1_000_000 + 1. Integer division (floors toward zero) matches + // floor(pop / 1_000_000.0) for positive values, then +1 gives ≥ 1 for log. + let ratio = (population as u64) / 1_000_000 + 1; + let mag = log10_floor(ratio); + // 400 bps per order of magnitude, cap at 1200 (3 orders). + (400 * mag).min(1_200) +} + +/// ±500 bps symmetric uniform noise seeded deterministically from (world_seed, city_id). +/// +/// Uses `SeedChain` to derive a per-city noise stream, then maps via integer +/// modulo to [-500, +500] — no floats, fixes the always-negative bug that +/// arose from the old code dividing a 31-bit RNG value by u32::MAX (D-010). +/// +/// **SeedChain derivation:** seeds root → Layer3Settlement directly using the +/// city_id hash as the domain id. This is safe because city_id is globally +/// unique (it is the `atlas_city_names.id` primary key); no body_id is +/// available at read time. +fn prosperity_noise_bps(city_id: u64, world_seed: u64) -> i32 { // Domain: Layer3Settlement (prosperity is a settlement-level property). + // city_id is globally unique, so root → Layer3Settlement is unambiguous. let seed = SeedChain::root(world_seed) .derive(SeedDomain::Layer3Settlement, fnv1a_64(&city_id.to_string())) .seed(); let mut rng = AtlasRng::new(splitmix64(seed)); - // Map u32 → [0.0, 1.0) then scale to [0.0, 0.1) then shift to [-0.05, 0.05). - let raw = rng.next_u32() as f32 / u32::MAX as f32; // [0.0, 1.0) - raw * 0.10 - 0.05 + // Integer modulo over 1001 values (0..=1000), shifted to [-500, +500]. + // AtlasRng::next_u32() returns a 31-bit value — modulo is uniform here + // because 1001 divides cleanly into the 31-bit range (2^31 / 1001 ≈ 2.1M, + // so bias is negligible, but correctness doesn't depend on that — we only + // need ±500 symmetric range, not strict uniformity). + (rng.next_u32() % 1_001) as i32 - 500 } -/// D-197 formula (terrain gradient left at 0.0 — requires Layer-1 data). +/// D-197 formula in integer basis points (terrain gradient left at 0 — requires Layer-1 data). /// -/// `clamp(role_base + pop_bonus + terrain_bonus + noise, 0.1, 0.95)` +/// `clamp(role_base_bps + pop_bonus_bps + terrain_bonus_bps + noise_bps, 1000, 9500)` +/// +/// All arithmetic is integer — no f32/f64 in the determinism path (D-010). pub fn prosperity_baseline_from_read_set( economic_role: &str, population: i64, city_id: u64, world_seed: u64, -) -> f32 { - let base = role_base_prosperity(economic_role); - let pb = pop_bonus(population); - // terrain_bonus = 0.0: Layer-1 attractor output not available at this tier. - let noise = prosperity_noise(city_id, world_seed); - (base + pb + noise).clamp(0.1, 0.95) +) -> u32 { + let base = role_base_bps(economic_role) as i32; + let pb = pop_bonus_bps(population) as i32; + // terrain_bonus_bps = 0: Layer-1 attractor output not available at this tier. + let noise = prosperity_noise_bps(city_id, world_seed); + let raw = base + pb + noise; + raw.clamp(1_000, 9_500) as u32 } // --------------------------------------------------------------------------- @@ -338,8 +362,8 @@ pub fn context_from_read_set(city_id: u64, rs: CityEconomicReadSet) -> CityGener city_id, // ── D-199 6-field read set ──────────────────────────────────────── - // prosperity_baseline is D-199 field 2 (derived, D-197). - prosperity_baseline: rs.prosperity_baseline, + // prosperity_baseline_bps is D-199 field 2 (derived, D-197). + prosperity_baseline_bps: rs.prosperity_baseline_bps, // ── Deferred fields (stubs) ─────────────────────────────────────── // political_archetype: real derivation requires D-214 faction→archetype @@ -462,66 +486,102 @@ mod tests { (path, id) } - // ─── prosperity_baseline derivation ────────────────────────────────────── + // ─── role_base_bps ─────────────────────────────────────────────────────── #[test] - fn role_base_financial_is_correct() { - assert!((role_base_prosperity("financial") - 0.70).abs() < 1e-6); + fn role_base_financial_is_7000() { + assert_eq!(role_base_bps("financial"), 7_000); } #[test] - fn role_base_unknown_falls_back() { - // Unknown roles get the mid-point fallback, not a panic. - let base = role_base_prosperity("deep_space_weird_role"); - assert!((base - 0.55).abs() < 1e-6); + fn role_base_unknown_falls_back_to_5500() { + assert_eq!(role_base_bps("deep_space_weird_role"), 5_500); } + // ─── pop_bonus_bps ─────────────────────────────────────────────────────── + #[test] fn pop_bonus_zero_population() { - assert_eq!(pop_bonus(0), 0.0); + assert_eq!(pop_bonus_bps(0), 0); } #[test] fn pop_bonus_one_million() { - // pop=1_000_000 → ratio = floor(log10(1+1)) = floor(0.301) = 0 → 0.0 - assert!((pop_bonus(1_000_000) - 0.0).abs() < 1e-5); + // pop=1_000_000 → ratio = 1_000_000 / 1_000_000 + 1 = 2 → log10_floor(2) = 0 → 0 bps + assert_eq!(pop_bonus_bps(1_000_000), 0); } #[test] fn pop_bonus_ten_million() { - // pop=10_000_000 → ratio = floor(log10(10+1)) = floor(1.041) = 1 → 0.04 - assert!((pop_bonus(10_000_000) - 0.04).abs() < 1e-5); + // pop=10_000_000 → ratio = 10 + 1 = 11 → log10_floor(11) = 1 → 400 bps + assert_eq!(pop_bonus_bps(10_000_000), 400); } #[test] - fn pop_bonus_capped_at_0_12() { - // Huge population → log10 grows but bonus is capped at 0.12. - assert!(pop_bonus(i64::MAX) <= 0.12 + 1e-6); + fn pop_bonus_hundred_million() { + // pop=100_000_000 → ratio = 100 + 1 = 101 → log10_floor(101) = 2 → 800 bps + assert_eq!(pop_bonus_bps(100_000_000), 800); } + #[test] + fn pop_bonus_capped_at_1200() { + // Huge population → bonus capped at 1200. + assert_eq!(pop_bonus_bps(i64::MAX), 1_200); + } + + // ─── prosperity_noise_bps ──────────────────────────────────────────────── + #[test] fn prosperity_noise_is_deterministic() { - let a = prosperity_noise(42, 1234); - let b = prosperity_noise(42, 1234); + let a = prosperity_noise_bps(42, 1234); + let b = prosperity_noise_bps(42, 1234); assert_eq!(a, b, "same inputs must give same noise"); } #[test] fn prosperity_noise_in_range() { for city_id in 0..20u64 { - let n = prosperity_noise(city_id, 99); + let n = prosperity_noise_bps(city_id, 99); assert!( - (-0.05..0.05).contains(&n), - "noise {n} out of [-0.05, 0.05) for city_id={city_id}" + (-500..=500).contains(&n), + "noise {n} out of [-500, 500] for city_id={city_id}" ); } } #[test] - fn prosperity_baseline_clamped() { - // extraction (0.45) + zero pop + max noise (< 0.05) → won't underflow 0.1 + fn prosperity_noise_positive_is_achievable() { + // Regression: old code divided a 31-bit RNG value by u32::MAX, + // making the result always < 0.5 and therefore always negative + // after the shift. Verify that at least one of a set of city_ids + // produces positive noise, confirming the bug is fixed. + let positives = (0u64..100) + .map(|id| prosperity_noise_bps(id, 12345)) + .filter(|&n| n > 0) + .count(); + assert!( + positives > 0, + "expected at least one positive noise value across 100 city_ids (was always-negative before fix)" + ); + } + + // ─── prosperity_baseline_from_read_set ─────────────────────────────────── + + #[test] + fn prosperity_baseline_bps_clamped_low() { + // extraction (4500) + zero pop + worst noise (-500) = 4000 → in [1000, 9500] let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0); - assert!((0.1..=0.95).contains(&p)); + assert!((1_000..=9_500).contains(&p)); + } + + #[test] + fn prosperity_baseline_bps_extraction_range() { + // extraction base 4500 ± 500 noise → expect [4000, 5000] + let p = prosperity_baseline_from_read_set("extraction", 0, 1, 0); + assert!( + (4_000..=5_000).contains(&p), + "extraction/0 pop should be ~4500 ± 500, got {p}" + ); } // ─── DB read-set integration ────────────────────────────────────────────── @@ -543,15 +603,17 @@ mod tests { // Field 1 — economic_role assert_eq!(rs.economic_role, "financial"); - // Field 2 — prosperity_baseline (non-stub: derived from real role+pop) - let expected_base = 0.70 + pop_bonus(5_000_000); + // Field 2 — prosperity_baseline_bps (non-stub: derived from real role+pop) + // financial base 7000 bps + pop_bonus(5M) bps ± 500 noise + let expected_base_bps = 7_000 + pop_bonus_bps(5_000_000); + let diff = (rs.prosperity_baseline_bps as i32 - expected_base_bps as i32).abs(); assert!( - (rs.prosperity_baseline - expected_base).abs() <= 0.05 + 1e-4, - "prosperity_baseline {:.3} not near expected base {:.3} ± noise", - rs.prosperity_baseline, - expected_base + diff <= 500 + 1, + "prosperity_baseline_bps {} not near expected base {} ± noise", + rs.prosperity_baseline_bps, + expected_base_bps ); - assert!((0.1..=0.95).contains(&rs.prosperity_baseline)); + assert!((1_000..=9_500).contains(&rs.prosperity_baseline_bps)); // Field 3 — population assert_eq!(rs.population, 5_000_000); // Field 4 — dominant_faction @@ -563,7 +625,7 @@ mod tests { } #[test] - fn build_context_prosperity_is_not_stub() { + fn build_context_prosperity_bps_is_not_stub() { let (db, city_id) = make_test_db( "GJ2b", "GJ-2", @@ -578,19 +640,17 @@ mod tests { .build_context(city_id as u64, 77) .expect("build context"); - // The stub value from skeleton_gen tests uses 0.7; manufacturing base is 0.55. - // A real read-set result should not be that default value unless coincidental. + // manufacturing base is 5500 bps; pop=2M → pop_bonus=0; noise in ±500. + // Result should be ≈5000–6000 bps. assert!( - (0.1..=0.95).contains(&ctx.prosperity_baseline), - "prosperity_baseline must be in [0.1, 0.95], got {}", - ctx.prosperity_baseline + (1_000..=9_500).contains(&ctx.prosperity_baseline_bps), + "prosperity_baseline_bps must be in [1000, 9500], got {}", + ctx.prosperity_baseline_bps ); - // Verify it comes from the real derivation: manufacturing base is 0.55, - // pop=2M → pop_bonus=0, noise in ±0.05 → total ≈ 0.50–0.60. assert!( - (0.49..=0.61).contains(&ctx.prosperity_baseline), - "manufacturing/2M should produce ≈0.55±0.06, got {}", - ctx.prosperity_baseline + (4_900..=6_100).contains(&ctx.prosperity_baseline_bps), + "manufacturing/2M should produce ~5500 ± 600 bps, got {}", + ctx.prosperity_baseline_bps ); } @@ -675,7 +735,7 @@ mod tests { } #[test] - fn prosperity_is_deterministic_across_calls() { + fn prosperity_bps_is_deterministic_across_calls() { let (db, city_id) = make_test_db( "GJ7c", "GJ-7", @@ -689,8 +749,8 @@ mod tests { let rs1 = reader.read_set(city_id as u64, 42).expect("first call"); let rs2 = reader.read_set(city_id as u64, 42).expect("second call"); assert_eq!( - rs1.prosperity_baseline, rs2.prosperity_baseline, - "prosperity_baseline must be deterministic for same inputs" + rs1.prosperity_baseline_bps, rs2.prosperity_baseline_bps, + "prosperity_baseline_bps must be deterministic for same inputs" ); } } diff --git a/server/src/atlas/district_mix.rs b/server/src/atlas/district_mix.rs index 284b10a29..7720cba38 100644 --- a/server/src/atlas/district_mix.rs +++ b/server/src/atlas/district_mix.rs @@ -12,6 +12,7 @@ //! **Determinism (D-010, D-194):** Integer weights throughout. No f32 in the //! district count computation. Seed-driven noise uses seeded RNG. +use crate::bps::log10_floor; use crate::seed::{SeedChain, SeedDomain}; use crate::simulation::generator::{DistrictType, PoliticalArchetype}; @@ -20,15 +21,19 @@ use crate::simulation::generator::{DistrictType, PoliticalArchetype}; // --------------------------------------------------------------------------- /// Population tier: `floor(log10(pop / 1_000_000))`, capped at [0, 5]. +/// +/// Uses [`log10_floor`] — no floats, no platform-dependent rounding (D-010). +/// Integer division of `pop / 1_000_000` floors toward zero for positive values, +/// matching the semantics of `floor(pop / 1_000_000.0)`. pub fn population_tier(population: i64) -> u8 { if population <= 0 { return 0; } - let ratio = population as f64 / 1_000_000.0; - if ratio <= 0.0 { + let ratio = (population as u64) / 1_000_000; + if ratio == 0 { return 0; } - let tier = ratio.log10().floor() as i32; + let tier = log10_floor(ratio) as i32; tier.clamp(0, 5) as u8 } diff --git a/server/src/atlas/gen_queue.rs b/server/src/atlas/gen_queue.rs index 7aa938c0f..336c4d4a0 100644 --- a/server/src/atlas/gen_queue.rs +++ b/server/src/atlas/gen_queue.rs @@ -468,7 +468,7 @@ mod tests { context: Box::new(CityGenerationContext { city_id, political_archetype: PoliticalArchetype::Commission, - prosperity_baseline: 0.6, + prosperity_baseline_bps: 6_000, surrounding_biome: SettingType::Urban, road_entry_directions: vec![], footprint_radius_km: 5.0, diff --git a/server/src/atlas/skeleton_gen.rs b/server/src/atlas/skeleton_gen.rs index 8ef56f31c..bd703f457 100644 --- a/server/src/atlas/skeleton_gen.rs +++ b/server/src/atlas/skeleton_gen.rs @@ -419,7 +419,7 @@ mod tests { CityGenerationContext { city_id: 1, political_archetype: archetype, - prosperity_baseline: 0.7, + prosperity_baseline_bps: 7_000, surrounding_biome: SettingType::Urban, road_entry_directions: vec![0, 4], footprint_radius_km: 10.0, diff --git a/server/src/bps.rs b/server/src/bps.rs new file mode 100644 index 000000000..7bb0f09de --- /dev/null +++ b/server/src/bps.rs @@ -0,0 +1,122 @@ +//! Fixed-point / basis-point helpers (D-010 integer arithmetic). +//! +//! The codebase represents fractional values (0.0–1.0) as **basis points** +//! (`bps`), where `10_000 bps = 1.0`. This avoids f32/f64 in determinism- +//! sensitive paths: given the same inputs, pure integer arithmetic produces +//! identical results on every platform and compiler version. +//! +//! Convention: field names carry the `_bps` suffix; raw bps values are `u32`. +//! +//! # Integer log₁₀ +//! +//! [`log10_floor`] is the single reference implementation of `floor(log10(n))` +//! for positive integers. Use this everywhere a "how many orders of magnitude" +//! calculation would otherwise reach for `f64::log10`. District-mix population +//! tiers and prosperity pop-bonuses both need it. + +/// Integer floor of log₁₀ for `n ≥ 1`. +/// +/// Returns 0 for n = 1..9, 1 for n = 10..99, 2 for n = 100..999, and so on. +/// Panics in debug if `n == 0` (log10(0) is undefined); returns 0 in release. +/// +/// No floats, no platform-dependent rounding — deterministic (D-010). +/// +/// # Examples +/// +/// ``` +/// use server::bps::log10_floor; +/// assert_eq!(log10_floor(1), 0); +/// assert_eq!(log10_floor(9), 0); +/// assert_eq!(log10_floor(10), 1); +/// assert_eq!(log10_floor(100), 2); +/// assert_eq!(log10_floor(999), 2); +/// assert_eq!(log10_floor(1_000_000), 6); +/// ``` +pub fn log10_floor(n: u64) -> u32 { + debug_assert!(n >= 1, "log10_floor: n must be ≥ 1 (got {n})"); + if n == 0 { + return 0; + } + let mut v = n; + let mut result = 0u32; + while v >= 10 { + v /= 10; + result += 1; + } + result +} + +/// Convert a bps value (0–10_000) to a clamped f32 in [0.0, 1.0]. +/// +/// Only call this at the edge of a system that genuinely needs f32 — the stored +/// representation stays integer. Document why f32 is needed at the call site. +#[inline] +pub fn bps_to_f32(bps: u32) -> f32 { + bps as f32 / 10_000.0 +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log10_floor_single_digits() { + // 1..9 all return 0. + for n in 1u64..10 { + assert_eq!(log10_floor(n), 0, "expected 0 for n={n}"); + } + } + + #[test] + fn log10_floor_boundary_10() { + assert_eq!(log10_floor(9), 0); + assert_eq!(log10_floor(10), 1); + } + + #[test] + fn log10_floor_boundary_100() { + assert_eq!(log10_floor(99), 1); + assert_eq!(log10_floor(100), 2); + } + + #[test] + fn log10_floor_boundary_1000() { + assert_eq!(log10_floor(999), 2); + assert_eq!(log10_floor(1_000), 3); + } + + #[test] + fn log10_floor_large_values() { + assert_eq!(log10_floor(999_999), 5); + assert_eq!(log10_floor(1_000_000), 6); + assert_eq!(log10_floor(9_999_999), 6); + assert_eq!(log10_floor(10_000_000), 7); + } + + #[test] + fn log10_floor_very_large() { + // u64::MAX = 18_446_744_073_709_551_615 → 19 digits → floor = 19. + assert_eq!(log10_floor(u64::MAX), 19); + } + + #[test] + fn bps_to_f32_midpoint() { + let f = bps_to_f32(5_000); + assert!((f - 0.5).abs() < 1e-6, "5000 bps should be 0.5, got {f}"); + } + + #[test] + fn bps_to_f32_full_scale() { + let f = bps_to_f32(10_000); + assert!((f - 1.0).abs() < 1e-6, "10000 bps should be 1.0, got {f}"); + } + + #[test] + fn bps_to_f32_zero() { + assert_eq!(bps_to_f32(0), 0.0); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 0fa0a131d..1524c9b14 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -3,6 +3,8 @@ pub mod atlas; pub mod bookmark; +pub mod bps; + pub mod bridge; pub mod cause_chain; pub mod knowledge; diff --git a/server/src/simulation/generator.rs b/server/src/simulation/generator.rs index 867a792c4..0a68fc7e2 100644 --- a/server/src/simulation/generator.rs +++ b/server/src/simulation/generator.rs @@ -485,7 +485,7 @@ pub enum BuildingEntryClass { /// Construction era of a building block (D-229). /// -/// Derived from `founding_age_years + prosperity_baseline + seed`. +/// Derived from `founding_age_years + prosperity_baseline_bps + seed`. /// Reads primarily as **age/wear** via the condition layer (D-217/D-198); /// not a material-technology ladder (era = maintenance signal, not style signal). #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -710,7 +710,7 @@ pub struct BuildingPropertyTag { pub era: ConstructionEra, /// Cause of this block's era classification. pub era_cause: EraCause, - /// Frozen-amber condition snapshot from `prosperity_baseline` (D-197/D-217). + /// Frozen-amber condition snapshot from `prosperity_baseline_bps` (D-197/D-217). /// The rolling condition overlay (D-198) paints over this; never mutates the tag. pub initial_condition: crate::atlas::tile_condition::TileCondition, /// Doors into / out of this building (D-231). At least one `Main` door. @@ -933,8 +933,9 @@ pub struct CityGenerationContext { /// Foreign key into atlas_city_names.id pub city_id: u64, pub political_archetype: PoliticalArchetype, - /// Starting economic health seed (0.0–1.0). Derived per D-197. - pub prosperity_baseline: f32, + /// Starting economic health seed in basis points (0–10_000; 10_000 = 1.0). + /// Derived per D-197. Integer to avoid f32 non-determinism (D-010). + pub prosperity_baseline_bps: u32, pub surrounding_biome: SettingType, /// Compass octants (0=N, 1=NE … 7=NW) where roads enter the city footprint. pub road_entry_directions: Vec,