- HashMap → BTreeMap throughout econ-sim for deterministic iteration (D-010) - Fix cost_factor: multiplicative gate×zone instead of additive (trade.rs) - Extract derive_seed to shared prng.rs, consolidate FNV-1a implementation - Rename run_shock_test → run_no_explosion_check (not D-179 Test 3) - Deduplicate cross-zone FX rate collection in Test 4 - Replace ORDER BY RANDOM() with deterministic ordering + ChaCha8Rng - Make commodity coverage failure a hard error consistent with D-175 - Fix gap-fill off-by-one (4 corps → 3 when coverage = 0) - Correct test report: EconEvent exists, location_type is body/station All four D-179 stability tests still pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
358 lines
11 KiB
Rust
358 lines
11 KiB
Rust
//! Database loading — reads economy data from systems.db.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
use std::process;
|
|
|
|
use rusqlite::Connection;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Data types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Commodity {
|
|
pub id: String,
|
|
// Display name — used in reporting (#807+):
|
|
#[allow(dead_code)]
|
|
pub name: String,
|
|
pub tier: String,
|
|
pub base_price: f64,
|
|
// Used by Layer 2+ pricing (#807, #808):
|
|
#[allow(dead_code)]
|
|
pub elasticity: String,
|
|
#[allow(dead_code)]
|
|
pub production_ubiquity: Option<String>,
|
|
#[allow(dead_code)]
|
|
pub demand_model: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ChainInput {
|
|
pub commodity_id: String,
|
|
pub quantity: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProductionChain {
|
|
pub chain_id: String,
|
|
pub output_commodity_id: String,
|
|
pub output_quantity: f64,
|
|
// Used by Layer 2+ for location-constrained production (#807):
|
|
#[allow(dead_code)]
|
|
pub location_bound: bool,
|
|
pub inputs: Vec<ChainInput>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CorpPresence {
|
|
pub corp_id: String,
|
|
pub system_id: String,
|
|
pub primary_operation: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SystemInfo {
|
|
pub system_id: String,
|
|
// Used for display/reporting in #807+:
|
|
#[allow(dead_code)]
|
|
pub proper_name: Option<String>,
|
|
pub population: i64,
|
|
pub cultural_corridor: Option<String>,
|
|
pub gate_energy_connected: bool,
|
|
/// Currency zone: TRACTUS_PRIMARY | MARK_PRIMARY | MIXED (D-171, D-172)
|
|
pub currency_zone: String,
|
|
/// Hop count from the nearest gateway — used for shadow economy seeding (D-174)
|
|
pub hop_distance: i64,
|
|
/// Gate topology type — used for shadow economy seeding (D-174)
|
|
pub gate_topology: Option<String>,
|
|
/// Political zone — used for shadow economy seeding (D-174)
|
|
pub political_zone: Option<String>,
|
|
}
|
|
|
|
/// A directed gate link between two systems.
|
|
#[derive(Debug, Clone)]
|
|
pub struct GateLink {
|
|
pub from_system_id: String,
|
|
pub to_system_id: String,
|
|
}
|
|
|
|
/// The complete economics dataset loaded from systems.db.
|
|
pub struct Economy {
|
|
pub commodities: Vec<Commodity>,
|
|
pub commodity_map: BTreeMap<String, Commodity>,
|
|
pub chains: Vec<ProductionChain>,
|
|
/// Map: output_commodity_id → list of chains that produce it
|
|
pub chains_by_output: BTreeMap<String, Vec<ProductionChain>>,
|
|
/// Map: system_id → SystemInfo
|
|
pub systems: BTreeMap<String, SystemInfo>,
|
|
pub corp_presences: Vec<CorpPresence>,
|
|
/// Map: system_id → list of corp presences
|
|
pub presences_by_system: BTreeMap<String, Vec<CorpPresence>>,
|
|
/// Bidirectional gate links (transport graph)
|
|
pub gate_links: Vec<GateLink>,
|
|
/// Raw corp data for archetype inference: (corp_id, behavioral_archetype?, specialization?)
|
|
pub corp_archetype_data: Vec<(String, Option<String>, Option<String>)>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DB helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn resolve_db_path(explicit: Option<PathBuf>) -> PathBuf {
|
|
if let Some(p) = explicit {
|
|
return p;
|
|
}
|
|
let mut dir = std::env::current_dir().expect("Cannot determine CWD");
|
|
loop {
|
|
let candidate = dir.join("server").join("data").join("systems.db");
|
|
if candidate.exists() {
|
|
return candidate;
|
|
}
|
|
if !dir.pop() {
|
|
break;
|
|
}
|
|
}
|
|
eprintln!("error: cannot find server/data/systems.db — pass --db explicitly");
|
|
process::exit(1);
|
|
}
|
|
|
|
pub fn open_db(path: &PathBuf) -> Connection {
|
|
let conn = Connection::open(path).unwrap_or_else(|e| {
|
|
eprintln!("error: cannot open {}: {}", path.display(), e);
|
|
process::exit(1);
|
|
});
|
|
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
|
|
.expect("PRAGMA setup failed");
|
|
conn
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Loaders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn load_commodities(conn: &Connection) -> Vec<Commodity> {
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT commodity_id, name, tier, base_price, elasticity,
|
|
production_ubiquity, demand_model
|
|
FROM commodities ORDER BY commodity_id",
|
|
)
|
|
.expect("prepare commodities");
|
|
|
|
stmt.query_map([], |row| {
|
|
Ok(Commodity {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
tier: row.get(2)?,
|
|
base_price: row.get(3)?,
|
|
elasticity: row.get(4)?,
|
|
production_ubiquity: row.get(5)?,
|
|
demand_model: row.get::<_, Option<String>>(6)?.unwrap_or_default(),
|
|
})
|
|
})
|
|
.expect("query commodities")
|
|
.filter_map(|r| r.ok())
|
|
.collect()
|
|
}
|
|
|
|
fn load_chains(conn: &Connection) -> Vec<ProductionChain> {
|
|
let mut chain_stmt = conn
|
|
.prepare(
|
|
"SELECT chain_id, output_commodity_id, output_quantity, location_bound
|
|
FROM production_chains ORDER BY chain_id",
|
|
)
|
|
.expect("prepare chains");
|
|
|
|
let mut chains: Vec<ProductionChain> = chain_stmt
|
|
.query_map([], |row| {
|
|
Ok(ProductionChain {
|
|
chain_id: row.get(0)?,
|
|
output_commodity_id: row.get(1)?,
|
|
output_quantity: row.get(2)?,
|
|
location_bound: row.get::<_, i32>(3)? != 0,
|
|
inputs: Vec::new(),
|
|
})
|
|
})
|
|
.expect("query chains")
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
// Load inputs for each chain
|
|
let mut input_stmt = conn
|
|
.prepare(
|
|
"SELECT chain_id, input_commodity_id, quantity
|
|
FROM chain_inputs ORDER BY chain_id, input_commodity_id",
|
|
)
|
|
.expect("prepare chain_inputs");
|
|
|
|
let all_inputs: Vec<(String, String, f64)> = input_stmt
|
|
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
|
.expect("query chain_inputs")
|
|
.filter_map(|r| r.ok())
|
|
.collect();
|
|
|
|
// Build index of chain_id → inputs
|
|
let mut input_map: BTreeMap<String, Vec<ChainInput>> = BTreeMap::new();
|
|
for (chain_id, commodity_id, quantity) in all_inputs {
|
|
input_map.entry(chain_id).or_default().push(ChainInput {
|
|
commodity_id,
|
|
quantity,
|
|
});
|
|
}
|
|
|
|
for chain in &mut chains {
|
|
if let Some(inputs) = input_map.remove(&chain.chain_id) {
|
|
chain.inputs = inputs;
|
|
}
|
|
}
|
|
|
|
chains
|
|
}
|
|
|
|
fn load_systems(conn: &Connection) -> BTreeMap<String, SystemInfo> {
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT ss.system_id, ss.proper_name, ss.cultural_corridor,
|
|
ss.gate_energy_connected,
|
|
COALESCE(se.population, 0) as population,
|
|
COALESCE(ss.currency_zone, 'TRACTUS_PRIMARY') as currency_zone,
|
|
COALESCE(sg.hop_distance_from_gateway, 5) as hop_distance,
|
|
sg.gate_topology,
|
|
ss.political_zone
|
|
FROM star_systems ss
|
|
LEFT JOIN system_economy se ON ss.system_id = se.system_id
|
|
LEFT JOIN system_gates sg ON ss.system_id = sg.system_id
|
|
ORDER BY ss.system_id",
|
|
)
|
|
.expect("prepare systems");
|
|
|
|
stmt.query_map([], |row| {
|
|
Ok(SystemInfo {
|
|
system_id: row.get(0)?,
|
|
proper_name: row.get(1)?,
|
|
cultural_corridor: row.get(2)?,
|
|
gate_energy_connected: row.get::<_, Option<i32>>(3)?.unwrap_or(1) != 0,
|
|
population: row.get(4)?,
|
|
currency_zone: row
|
|
.get::<_, Option<String>>(5)?
|
|
.unwrap_or_else(|| "TRACTUS_PRIMARY".to_string()),
|
|
hop_distance: row.get::<_, Option<i64>>(6)?.unwrap_or(5),
|
|
gate_topology: row.get(7)?,
|
|
political_zone: row.get(8)?,
|
|
})
|
|
})
|
|
.expect("query systems")
|
|
.filter_map(|r| r.ok())
|
|
.map(|s| (s.system_id.clone(), s))
|
|
.collect()
|
|
}
|
|
|
|
fn load_corp_archetype_data(conn: &Connection) -> Vec<(String, Option<String>, Option<String>)> {
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT corp_id, behavioral_archetype, specialization
|
|
FROM corporations ORDER BY corp_id",
|
|
)
|
|
.expect("prepare corp archetype data");
|
|
|
|
stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
|
.expect("query corp archetype data")
|
|
.filter_map(|r| r.ok())
|
|
.collect()
|
|
}
|
|
|
|
fn load_gate_links(conn: &Connection) -> Vec<GateLink> {
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT from_system_id, to_system_id FROM gate_links
|
|
ORDER BY from_system_id, to_system_id",
|
|
)
|
|
.expect("prepare gate_links");
|
|
|
|
stmt.query_map([], |row| {
|
|
Ok(GateLink {
|
|
from_system_id: row.get(0)?,
|
|
to_system_id: row.get(1)?,
|
|
})
|
|
})
|
|
.expect("query gate_links")
|
|
.filter_map(|r| r.ok())
|
|
.collect()
|
|
}
|
|
|
|
fn load_corp_presences(conn: &Connection) -> Vec<CorpPresence> {
|
|
// Resolve body/station location_id back to system_id via LEFT JOINs.
|
|
// corp_presence.location_type is 'body' | 'station' per schema.
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT cp.corp_id,
|
|
COALESCE(b.system_id, s.system_id) AS system_id,
|
|
cp.primary_operation
|
|
FROM corp_presence cp
|
|
LEFT JOIN bodies b ON cp.location_type = 'body' AND cp.location_id = b.body_id
|
|
LEFT JOIN stations s ON cp.location_type = 'station' AND cp.location_id = s.station_id
|
|
WHERE COALESCE(b.system_id, s.system_id) IS NOT NULL
|
|
ORDER BY system_id, cp.corp_id",
|
|
)
|
|
.expect("prepare corp_presence");
|
|
|
|
stmt.query_map([], |row| {
|
|
Ok(CorpPresence {
|
|
corp_id: row.get(0)?,
|
|
system_id: row.get(1)?,
|
|
primary_operation: row.get(2)?,
|
|
})
|
|
})
|
|
.expect("query corp_presence")
|
|
.filter_map(|r| r.ok())
|
|
.collect()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main loader
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub fn load_economy(conn: &Connection) -> Economy {
|
|
let commodities = load_commodities(conn);
|
|
let commodity_map: BTreeMap<String, Commodity> = commodities
|
|
.iter()
|
|
.map(|c| (c.id.clone(), c.clone()))
|
|
.collect();
|
|
|
|
let chains = load_chains(conn);
|
|
let mut chains_by_output: BTreeMap<String, Vec<ProductionChain>> = BTreeMap::new();
|
|
for chain in &chains {
|
|
chains_by_output
|
|
.entry(chain.output_commodity_id.clone())
|
|
.or_default()
|
|
.push(chain.clone());
|
|
}
|
|
|
|
let systems = load_systems(conn);
|
|
let corp_presences = load_corp_presences(conn);
|
|
|
|
let mut presences_by_system: BTreeMap<String, Vec<CorpPresence>> = BTreeMap::new();
|
|
for cp in &corp_presences {
|
|
presences_by_system
|
|
.entry(cp.system_id.clone())
|
|
.or_default()
|
|
.push(cp.clone());
|
|
}
|
|
|
|
let gate_links = load_gate_links(conn);
|
|
let corp_archetype_data = load_corp_archetype_data(conn);
|
|
|
|
Economy {
|
|
commodities,
|
|
commodity_map,
|
|
chains,
|
|
chains_by_output,
|
|
systems,
|
|
corp_presences,
|
|
presences_by_system,
|
|
gate_links,
|
|
corp_archetype_data,
|
|
}
|
|
}
|