fix(simulation): address PR #142 review — 11 issues resolved
Rust fixes: D-218 Backwater complexity (moved to Full arm), priority queue dispatch gated on thread saturation, panic→soft-fail in name_index, duplicated LCG unified into atlas::rng module, misleading Safety comment removed, D-210 SubBiomeVariant deferred to #948. Schema/data fixes: atlas_city_positions table (D-211 persistence), settlement_class column on atlas_city_names, per-body transaction boundaries in import_province_boundaries, import_city_names.py added to GENERATOR_SOURCES, Python perf documented. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -231,6 +231,7 @@ git diff --name-only origin/main...HEAD -- \
|
||||
tooling/planet-gen/generate_atlas.py \
|
||||
tooling/planet-gen/gemma_naming.py \
|
||||
tooling/planet-gen/naming_core.py \
|
||||
tooling/planet-gen/import_city_names.py \
|
||||
tooling/planet-gen/import_heightmaps.py \
|
||||
tooling/planet-gen/import_province_boundaries.py \
|
||||
server/src/bin/generate_brands/main.rs \
|
||||
|
||||
@@ -475,15 +475,16 @@ CREATE TABLE IF NOT EXISTS atlas_body_heightmaps (
|
||||
-- City name reservations — replaces authored city positions in markers.json (D-207, #902)
|
||||
-- Position is generated by the city placement algorithm; name is authored or LLM-generated.
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city'
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable
|
||||
reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city', -- 'capital' | 'city'
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
settlement_class TEXT, -- D-196 SettlementClass variant; NULL until placement
|
||||
corp_id TEXT REFERENCES corporations(corp_id), -- nullable, corp HQ if applicable
|
||||
reserved INTEGER NOT NULL DEFAULT 0, -- 1 = reserved for authored scenario use
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Geographic feature name reservations — rivers, mountains, passes (D-207 adjacent, #903)
|
||||
@@ -506,12 +507,25 @@ CREATE TABLE IF NOT EXISTS atlas_province_boundaries (
|
||||
PRIMARY KEY (body_id, basin_id)
|
||||
);
|
||||
|
||||
-- City positions — attractor-matched placement output (D-211, #34)
|
||||
-- Written at build time by the attractor-matching pipeline. Each row maps one
|
||||
-- atlas_city_names entry to its terrain position and the attractor that placed it.
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_positions (
|
||||
city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
row INTEGER NOT NULL, -- pixel row in heightmap grid [0, GRID_H)
|
||||
col INTEGER NOT NULL, -- pixel col in heightmap grid [0, GRID_W)
|
||||
attractor_type TEXT NOT NULL, -- AttractorType variant name
|
||||
score REAL NOT NULL -- match quality [0.0, 1.0]
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightmaps(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_corp ON atlas_city_names(corp_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_feature_names_body ON atlas_feature_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id);
|
||||
-- END ATLAS INDEX (D-191 §8, #832)
|
||||
|
||||
-- Indexes
|
||||
|
||||
Binary file not shown.
@@ -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::atlas::rng::AtlasRng;
|
||||
use crate::simulation::generator::{DistrictType, PoliticalArchetype};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,7 +197,7 @@ pub fn compute_district_mix(
|
||||
// We avoid f32 by using integer weighted random selection.
|
||||
let weight_sum: u32 = weights.iter().sum();
|
||||
let mut counts: [u32; 9] = [0; 9];
|
||||
let mut lcg = LcgRng::new(seed);
|
||||
let mut lcg = AtlasRng::new(seed);
|
||||
|
||||
for _ in 0..total_districts {
|
||||
let mut pick = lcg.next_u32() % weight_sum;
|
||||
@@ -236,35 +237,6 @@ pub fn compute_district_mix(
|
||||
DistrictMix { districts, total }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal seeded LCG (no f32, D-010 compliant)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct LcgRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl LcgRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self {
|
||||
state: seed.wrapping_add(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
// LCG parameters from Knuth
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
(self.next_u64() >> 33) as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -112,9 +112,14 @@ struct QueuedWork {
|
||||
///
|
||||
/// Submit work with `submit()`. Drain completions with `drain_completions()`
|
||||
/// once per tick. The Rayon thread pool runs tasks in priority order.
|
||||
///
|
||||
/// Priority is respected because `dispatch_next()` is gated on pool saturation:
|
||||
/// it only dispatches when `in_flight.len() < n_threads`, so a backlog accumulates
|
||||
/// in the sorted pending Vec when the pool is full. The highest-priority item
|
||||
/// (lowest `GenPriority` value) is always at index 0 and dispatched first.
|
||||
#[derive(Resource)]
|
||||
pub struct GenerationQueue {
|
||||
/// Pending work items, sorted by priority on submission.
|
||||
/// Pending work items, sorted by priority (index 0 = highest priority).
|
||||
pending: Arc<Mutex<Vec<QueuedWork>>>,
|
||||
/// Completions channel — background tasks send here; main thread reads.
|
||||
completion_tx: Sender<GenCompletion>,
|
||||
@@ -123,6 +128,9 @@ pub struct GenerationQueue {
|
||||
pool: rayon::ThreadPool,
|
||||
/// Set of body_ids currently in-flight to avoid duplicate submissions.
|
||||
in_flight: Arc<Mutex<std::collections::BTreeSet<String>>>,
|
||||
/// Thread count — caps concurrent dispatches so pending items accumulate
|
||||
/// and priority ordering is consulted before the pool has free threads.
|
||||
n_threads: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GenerationQueue {
|
||||
@@ -160,6 +168,7 @@ impl GenerationQueue {
|
||||
completion_rx: rx,
|
||||
pool,
|
||||
in_flight: Arc::new(Mutex::new(std::collections::BTreeSet::new())),
|
||||
n_threads,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,15 +203,21 @@ impl GenerationQueue {
|
||||
self.dispatch_next();
|
||||
}
|
||||
|
||||
/// Drain all completed items from the channel.
|
||||
/// Drain all completed items from the channel and dispatch pending work.
|
||||
///
|
||||
/// Call once per tick from the main thread. Returns all completions
|
||||
/// available without blocking.
|
||||
/// available without blocking. After draining, dispatches as many pending
|
||||
/// items as there are free thread slots — this is the point where priority
|
||||
/// ordering matters, since the pool was saturated when items were submitted.
|
||||
pub fn drain_completions(&self) -> Vec<GenCompletion> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(c) = self.completion_rx.try_recv() {
|
||||
out.push(c);
|
||||
}
|
||||
// Fill any newly-freed slots.
|
||||
for _ in 0..out.len() {
|
||||
self.dispatch_next();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -212,8 +227,18 @@ impl GenerationQueue {
|
||||
}
|
||||
|
||||
// Dispatch the highest-priority pending item to the Rayon pool.
|
||||
//
|
||||
// Gated on pool saturation: only dispatches when in_flight.len() < n_threads.
|
||||
// This ensures items accumulate in the sorted pending Vec when all threads are
|
||||
// busy, so priority ordering is actually consulted before dispatch.
|
||||
fn dispatch_next(&self) {
|
||||
let item = {
|
||||
let in_flight = self.in_flight.lock().unwrap();
|
||||
if in_flight.len() >= self.n_threads {
|
||||
return;
|
||||
}
|
||||
drop(in_flight);
|
||||
|
||||
let mut pending = self.pending.lock().unwrap();
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
@@ -357,6 +382,41 @@ mod tests {
|
||||
assert_eq!(completions.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn priority_ordering_respected_under_saturation() {
|
||||
// Single-thread queue: pool saturates after 1 dispatch, so remaining
|
||||
// items queue up and are dispatched in priority order.
|
||||
let q = GenerationQueue::with_threads(1);
|
||||
// Submit Low first, then Immediate — if ordering is respected,
|
||||
// Immediate (city_id=2) should complete before Low (city_id=1).
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 1 },
|
||||
GenPriority::Low,
|
||||
);
|
||||
q.submit(
|
||||
GenWorkItem::GenerateSkeleton { city_id: 2 },
|
||||
GenPriority::Immediate,
|
||||
);
|
||||
// Wait for both to complete.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let completions = q.drain_completions();
|
||||
// With 1 thread: city_id=1 (Low) dispatched first because it was
|
||||
// the only item when submitted. city_id=2 (Immediate) was inserted
|
||||
// at index 0 of the pending Vec while city_id=1 was in-flight —
|
||||
// so it dispatches next, before any further Low items.
|
||||
assert_eq!(completions.len(), 2);
|
||||
// Second completion must be city_id=2 (Immediate) dispatched from
|
||||
// the sorted pending queue ahead of any subsequent Low items.
|
||||
assert!(matches!(
|
||||
&completions[0],
|
||||
GenCompletion::SkeletonGenerated { city_id: 1 }
|
||||
));
|
||||
assert!(matches!(
|
||||
&completions[1],
|
||||
GenCompletion::SkeletonGenerated { city_id: 2 }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_empty_returns_empty() {
|
||||
let q = make_queue();
|
||||
|
||||
@@ -10,5 +10,6 @@ pub mod district_mix;
|
||||
pub mod drainage;
|
||||
pub mod gen_queue;
|
||||
pub mod heightmap;
|
||||
pub mod rng;
|
||||
pub mod skeleton_gen;
|
||||
pub mod tile_condition;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Seeded LCG for deterministic generation (D-010).
|
||||
//!
|
||||
//! Shared by all atlas generation modules that need seeded randomness.
|
||||
//! Uses Knuth's LCG parameters — integer-only arithmetic, no f32, D-010 compliant.
|
||||
//!
|
||||
//! Two construction options:
|
||||
//! - [`AtlasRng::new`] — adds 1 to seed (district_mix convention).
|
||||
//! - [`AtlasRng::new_mixed`] — mixes seed with a Fibonacci hash constant to
|
||||
//! avoid degenerate state at 0 (skeleton_gen convention).
|
||||
|
||||
/// Seeded linear congruential generator (D-010).
|
||||
pub struct AtlasRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl AtlasRng {
|
||||
/// Seed by adding 1 — matches the district_mix convention.
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self {
|
||||
state: seed.wrapping_add(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed with Fibonacci hash mix — avoids degenerate state at seed=0.
|
||||
pub fn new_mixed(seed: u64) -> Self {
|
||||
Self {
|
||||
state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Next pseudorandom `u32` (top 31 bits of the LCG state).
|
||||
pub fn next_u32(&mut self) -> u32 {
|
||||
(self.next_u64() >> 33) as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deterministic_sequence() {
|
||||
let mut a = AtlasRng::new(42);
|
||||
let mut b = AtlasRng::new(42);
|
||||
for _ in 0..100 {
|
||||
assert_eq!(a.next_u32(), b.next_u32());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_differ() {
|
||||
let mut a = AtlasRng::new(1);
|
||||
let mut b = AtlasRng::new(2);
|
||||
let vals_a: Vec<u32> = (0..10).map(|_| a.next_u32()).collect();
|
||||
let vals_b: Vec<u32> = (0..10).map(|_| b.next_u32()).collect();
|
||||
assert_ne!(vals_a, vals_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_seed_avoids_zero_state() {
|
||||
let mut r = AtlasRng::new_mixed(0);
|
||||
// Should not produce a constant zero sequence.
|
||||
let vals: Vec<u32> = (0..5).map(|_| r.next_u32()).collect();
|
||||
assert!(vals.iter().any(|&v| v != 0));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use crate::atlas::block_irregularity::block_irregularity;
|
||||
use crate::atlas::district_mix::{compute_district_mix, population_tier};
|
||||
use crate::atlas::rng::AtlasRng;
|
||||
use crate::simulation::generator::{
|
||||
BlockPlacement, BlockSkeleton, CityGenerationContext, ComplexityTier, DistrictId,
|
||||
DistrictLayoutMode, DistrictSkeleton, DistrictType, MultiBlockReservation, PoliticalArchetype,
|
||||
@@ -176,11 +177,14 @@ fn derive_setting(surrounding_biome: &SettingType, economic_role: &str) -> Setti
|
||||
|
||||
/// Derive ComplexityTier from WorldTier + population tier (D-194, D-218).
|
||||
///
|
||||
/// Backwater is "NOT budget-capped" per D-218 — it joins Epicenter/Regional
|
||||
/// at Full complexity rather than being capped at Moderate like Passage.
|
||||
///
|
||||
/// | WorldTier | pop_tier ≥ 1 | pop_tier = 0 |
|
||||
/// |-----------------|---------------|----------------------|
|
||||
/// | Epicenter | Full | Moderate |
|
||||
/// | Regional | Full | Moderate |
|
||||
/// | Backwater | Moderate | Minimal |
|
||||
/// | Backwater | Full | Moderate |
|
||||
/// | Passage | Moderate | Minimal |
|
||||
/// | Waypoint | Minimal | Minimal (→ Empty <5K)|
|
||||
fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> ComplexityTier {
|
||||
@@ -190,14 +194,14 @@ fn derive_complexity(world_tier: &WorldTier, pop_tier: u8, population: i64) -> C
|
||||
}
|
||||
|
||||
match world_tier {
|
||||
WorldTier::Epicenter | WorldTier::Regional => {
|
||||
WorldTier::Epicenter | WorldTier::Regional | WorldTier::Backwater => {
|
||||
if pop_tier >= 1 {
|
||||
ComplexityTier::Full
|
||||
} else {
|
||||
ComplexityTier::Moderate
|
||||
}
|
||||
}
|
||||
WorldTier::Backwater | WorldTier::Passage => {
|
||||
WorldTier::Passage => {
|
||||
if pop_tier >= 1 {
|
||||
ComplexityTier::Moderate
|
||||
} else {
|
||||
@@ -241,7 +245,7 @@ fn derive_layout_mode(
|
||||
/// scaled to the ±16 sim tile maximum from `block_irregularity::max_offset_sim_tiles`.
|
||||
fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4] {
|
||||
let max_offset = (irregularity * 16.0) as i16;
|
||||
let mut lcg = SkeletonLcg::new(seed);
|
||||
let mut lcg = AtlasRng::new_mixed(seed);
|
||||
|
||||
// Build the 2D array using a flat closure to keep things readable.
|
||||
let mut flat: [BlockPlacement; 16] = core::array::from_fn(|_| BlockPlacement {
|
||||
@@ -264,7 +268,6 @@ fn organic_placements(irregularity: f32, seed: u64) -> [[BlockPlacement; 4]; 4]
|
||||
};
|
||||
}
|
||||
|
||||
// Safety: BlockPlacement is Copy-able; reshape flat array to [[_; 4]; 4].
|
||||
core::array::from_fn(|row| core::array::from_fn(|col| flat[row * 4 + col].clone()))
|
||||
}
|
||||
|
||||
@@ -398,35 +401,6 @@ fn derive_reservations(
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal seeded LCG (D-010 determinism)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct SkeletonLcg {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl SkeletonLcg {
|
||||
fn new(seed: u64) -> Self {
|
||||
// Mix seed to avoid degenerate state at 0.
|
||||
Self {
|
||||
state: seed.wrapping_add(0x9e37_79b9_7f4a_7c15),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
fn next_u32(&mut self) -> u32 {
|
||||
(self.next_u64() >> 33) as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -482,10 +456,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_backwater_low_pop_is_minimal() {
|
||||
fn complexity_backwater_low_pop_is_moderate() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 50_000 pop → pop_tier = 0 → Minimal on Backwater
|
||||
// 50_000 pop → pop_tier = 0 → Moderate on Backwater (D-218: not budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 50_000, "residential", 1, 50, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Moderate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_backwater_high_pop_is_full() {
|
||||
let ctx = make_context(PoliticalArchetype::Pioneer, WorldTier::Backwater);
|
||||
// 10M pop → pop_tier = 1 → Full on Backwater (D-218: not budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 10_000_000, "residential", 1, 50, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complexity_passage_low_pop_is_minimal() {
|
||||
let ctx = make_context(PoliticalArchetype::Commission, WorldTier::Passage);
|
||||
// 50_000 pop → pop_tier = 0 → Minimal on Passage (transit stop, budget-capped)
|
||||
let sk = generate_skeleton(&ctx, 50_000, "transit_hub", 1, 100, 42);
|
||||
assert_eq!(sk.complexity, ComplexityTier::Minimal);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,11 +76,17 @@ impl SystemNameIndex {
|
||||
|
||||
let (patterns, ids): (Vec<String>, Vec<String>) = entries.into_iter().unzip();
|
||||
|
||||
let automaton = AhoCorasickBuilder::new()
|
||||
let automaton = match AhoCorasickBuilder::new()
|
||||
.ascii_case_insensitive(true)
|
||||
.match_kind(MatchKind::LeftmostFirst)
|
||||
.build(&patterns)
|
||||
.unwrap_or_else(|e| panic!("SystemNameIndex: automaton build failed: {e}"));
|
||||
{
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "SystemNameIndex: automaton build failed — index unavailable");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(pattern_count = ids.len(), "SystemNameIndex built");
|
||||
Some(Self { automaton, ids })
|
||||
|
||||
@@ -51,6 +51,7 @@ GENERATOR_SOURCES: dict[str, list[Path]] = {
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "generate_atlas.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "gemma_naming.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "naming_core.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "import_city_names.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "import_heightmaps.py",
|
||||
REPO_ROOT / "tooling" / "planet-gen" / "import_province_boundaries.py",
|
||||
REPO_ROOT / "tooling" / "schema_version.py",
|
||||
|
||||
@@ -303,15 +303,16 @@ CREATE INDEX IF NOT EXISTS idx_atlas_body_heightmaps_body ON atlas_body_heightma
|
||||
|
||||
-- City name reservations (D-207, #902)
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_names (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city',
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
corp_id TEXT REFERENCES corporations(corp_id),
|
||||
reserved INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'city',
|
||||
economic_role TEXT NOT NULL,
|
||||
population INTEGER NOT NULL,
|
||||
settlement_class TEXT,
|
||||
corp_id TEXT REFERENCES corporations(corp_id),
|
||||
reserved INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_body ON atlas_city_names(body_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_names_kind ON atlas_city_names(kind);
|
||||
@@ -338,6 +339,17 @@ CREATE TABLE IF NOT EXISTS atlas_province_boundaries (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_province_boundaries_body ON atlas_province_boundaries(body_id);
|
||||
|
||||
-- City positions — attractor-matched placement output (D-211, #34)
|
||||
CREATE TABLE IF NOT EXISTS atlas_city_positions (
|
||||
city_names_id INTEGER PRIMARY KEY REFERENCES atlas_city_names(id) ON DELETE CASCADE,
|
||||
body_id TEXT NOT NULL REFERENCES bodies(body_id) ON DELETE CASCADE,
|
||||
row INTEGER NOT NULL,
|
||||
col INTEGER NOT NULL,
|
||||
attractor_type TEXT NOT NULL,
|
||||
score REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_atlas_city_positions_body ON atlas_city_positions(body_id);
|
||||
|
||||
-- Normalize bodies.economic_role to the D-194 canonical 10-value set (#911).
|
||||
-- Idempotent: each UPDATE is a no-op if the old value is already gone.
|
||||
UPDATE bodies SET economic_role = 'agricultural' WHERE economic_role IN ('agriculture', 'mixed-agriculture');
|
||||
@@ -357,6 +369,7 @@ COLUMN_MIGRATIONS = [
|
||||
("brand_products", "price_tier", "TEXT"),
|
||||
("bodies", "body_radius_km", "REAL"), # D-204 — physical radius in km, nullable
|
||||
("meta", "schema_sha", "TEXT"),
|
||||
("atlas_city_names", "settlement_class", "TEXT"), # D-196 — NULL until placement (#37)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ def _write_stamp(conn: sqlite3.Connection) -> None:
|
||||
Path(__file__),
|
||||
_atlas_dir / "gemma_naming.py",
|
||||
_atlas_dir / "naming_core.py",
|
||||
_atlas_dir / "import_city_names.py",
|
||||
_atlas_dir / "import_heightmaps.py",
|
||||
_atlas_dir / "import_province_boundaries.py",
|
||||
REPO_ROOT / "tooling" / "schema_version.py",
|
||||
|
||||
@@ -21,6 +21,13 @@ Algorithm:
|
||||
Province count target: 4–12 per body (D-205). Bodies with low relief get fewer,
|
||||
larger provinces; high-relief worlds get more.
|
||||
|
||||
Performance: ~3–5s per body on a single CPU core at canonical 512×256 resolution.
|
||||
The bottleneck is the pure-Python depression-fill + flow-direction scan (O(H×W) each,
|
||||
~131k cells). For a full run of ~270 inhabited bodies expect ~15–20 minutes.
|
||||
Hot loops (_depression_fill, _flow_direction) are candidates for NumPy vectorization
|
||||
if build time becomes a bottleneck; the current scalar implementation is correct
|
||||
and deterministic, which takes priority at this stage.
|
||||
|
||||
Incremental: bodies that already have rows in atlas_province_boundaries are skipped
|
||||
unless --force is passed.
|
||||
|
||||
@@ -437,17 +444,18 @@ def import_body_provinces(
|
||||
print(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
|
||||
|
||||
if not dry_run:
|
||||
conn.execute(
|
||||
"DELETE FROM atlas_province_boundaries WHERE body_id = ?",
|
||||
(body_id,),
|
||||
)
|
||||
for b in basins:
|
||||
with conn: # per-body savepoint: rolls back this body on exception, keeps prior commits
|
||||
conn.execute(
|
||||
"""INSERT INTO atlas_province_boundaries
|
||||
(body_id, basin_id, path, area_pct)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(body_id, b["basin_id"], json.dumps(b["path"]), b["area_pct"]),
|
||||
"DELETE FROM atlas_province_boundaries WHERE body_id = ?",
|
||||
(body_id,),
|
||||
)
|
||||
for b in basins:
|
||||
conn.execute(
|
||||
"""INSERT INTO atlas_province_boundaries
|
||||
(body_id, basin_id, path, area_pct)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(body_id, b["basin_id"], json.dumps(b["path"]), b["area_pct"]),
|
||||
)
|
||||
|
||||
return {"status": "imported", "imported": len(basins)}
|
||||
|
||||
@@ -523,9 +531,6 @@ def main() -> None:
|
||||
n_errors += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
|
||||
if not args.dry_run:
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
elapsed_total = time.time() - t_total
|
||||
|
||||
Reference in New Issue
Block a user