chore(engine): server hygiene batch — workers tests, surname dedup, save pin (T-1063, T-1064)
- workers/pool.rs: 5 new tests; catch_unwind keeps worker threads alive on handler panic (in-flight request loss unchanged, pinned by test + #843 docs); stubs.rs no longer falsely claims the pool is tested - save/load: execute_save_load pinned .after(Storyteller) so the scheduler cannot legally save pre-Input state; exclusive-system exception recorded in tick_phases.rs rules - surname corpus extracted to bin/shared/surname_corpus.rs (both economy generators import it; byte-identical output verified on 23.6MB+1.45MB TOMLs); all three stamp/watch registries updated - generator_spike gated behind non-default 'generator-spike' feature - economy.rs: 11 new D-181 signal-derivation tests on the new econ_sim Simulation::from_economy in-memory constructor - perception exemption comments now state the consumer sort contract; unused bytemuck removed; rayon comment corrected; the 22 allow(dead_code) documented as serde schema enforcement Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -235,6 +235,7 @@ git diff --name-only origin/main...HEAD -- \
|
||||
tooling/planet-gen/import_province_boundaries.py \
|
||||
server/src/bin/generate_brands/main.rs \
|
||||
server/src/bin/generate_brands/names.rs \
|
||||
server/src/bin/shared/surname_corpus.rs \
|
||||
tooling/generate-brands \
|
||||
server/data/systems-schema.sql \
|
||||
wiki/star-systems/ \
|
||||
|
||||
Generated
-1
@@ -1476,7 +1476,6 @@ dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
"bevy_tasks",
|
||||
"bytemuck",
|
||||
"clap",
|
||||
"crossbeam-channel",
|
||||
"econ-sim",
|
||||
|
||||
+17
-3
@@ -10,8 +10,9 @@ bevy_app = "0.18"
|
||||
# multi_threaded enables Bevy's parallel system executor (no explicit import needed —
|
||||
# bevy_ecs detects the feature on its bevy_tasks dependency at compile time).
|
||||
bevy_tasks = { version = "0.18", features = ["multi_threaded"] }
|
||||
# par_iter infrastructure for data-parallel systems. Not yet called — candidate
|
||||
# systems marked with TODO comments. Active use begins when profiling shows bottlenecks.
|
||||
# Thread-pool/parallelism infrastructure. Used by the atlas generation worker
|
||||
# pool (atlas/gen_queue.rs); data-parallel (par_iter) use in systems begins when
|
||||
# profiling shows bottlenecks.
|
||||
rayon = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
ron = "0.8"
|
||||
@@ -27,7 +28,6 @@ crossbeam-channel = "0.5"
|
||||
sysinfo = "0.35"
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
bytemuck = "1"
|
||||
png = "0.17"
|
||||
toml = "0.8"
|
||||
aho-corasick = "1"
|
||||
@@ -37,6 +37,20 @@ econ-sim = { path = "../tooling/econ-sim" }
|
||||
[features]
|
||||
default = ["gauntlet"]
|
||||
gauntlet = []
|
||||
# Gates the generator_spike binary (Sprint 25 proof-of-life, #612). The spike
|
||||
# reimplements npc/generate.rs axes outside ECS and is kept for reference only —
|
||||
# excluded from default builds so it cannot drift into production paths (T-1064).
|
||||
generator-spike = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generator_spike is declared explicitly (instead of relying on src/bin auto-
|
||||
# discovery) so it can be feature-gated: `cargo build --bin generator_spike
|
||||
# --features generator-spike`. Other binaries remain auto-discovered.
|
||||
# ---------------------------------------------------------------------------
|
||||
[[bin]]
|
||||
name = "generator_spike"
|
||||
path = "src/bin/generator_spike.rs"
|
||||
required-features = ["generator-spike"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Maintained drop-in fork of the deprecated serde_yaml 0.9 (#966). Test-only:
|
||||
|
||||
@@ -32,6 +32,8 @@ use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod names;
|
||||
#[path = "../shared/surname_corpus.rs"]
|
||||
mod surname_corpus;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
|
||||
@@ -1,334 +1,17 @@
|
||||
//! Deterministic product name generation for minor brand instances.
|
||||
//!
|
||||
//! Reuses the corridor surname pools from generate_corporations/names.rs
|
||||
//! but combines them with category-specific product descriptors rather
|
||||
//! than business suffixes. Halo and volume tiers get distinct descriptor
|
||||
//! pools so the output sounds differentiated.
|
||||
//! Reuses the corridor surname pools shared with generate_corporations
|
||||
//! (shared/surname_corpus.rs) but combines them with category-specific
|
||||
//! product descriptors rather than business suffixes. Halo and volume
|
||||
//! tiers get distinct descriptor pools so the output sounds differentiated.
|
||||
|
||||
use rand::prelude::*;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Surname pools (same corpus as generate_corporations/names.rs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CORE_NAMES: &[&str] = &[
|
||||
"Alvarez",
|
||||
"Benoit",
|
||||
"Carvalho",
|
||||
"Durand",
|
||||
"Eriksen",
|
||||
"Fournier",
|
||||
"Gao",
|
||||
"Hartmann",
|
||||
"Ishida",
|
||||
"Johansson",
|
||||
"Kirchner",
|
||||
"Lemaire",
|
||||
"Moreau",
|
||||
"Nakamura",
|
||||
"Olsson",
|
||||
"Pelletier",
|
||||
"Richter",
|
||||
"Saito",
|
||||
"Torres",
|
||||
"Ueda",
|
||||
"Vasquez",
|
||||
"Werner",
|
||||
"Xu",
|
||||
"Yamada",
|
||||
"Zhou",
|
||||
"Andersen",
|
||||
"Beaumont",
|
||||
"Costa",
|
||||
"Delacroix",
|
||||
"Engel",
|
||||
"Fujita",
|
||||
"Gutierrez",
|
||||
"Hayashi",
|
||||
"Ibarra",
|
||||
"Jensen",
|
||||
"Klein",
|
||||
"Laurent",
|
||||
"Mercier",
|
||||
"Novak",
|
||||
"Ortiz",
|
||||
"Park",
|
||||
"Reuter",
|
||||
"Suzuki",
|
||||
"Takahashi",
|
||||
"Ulrich",
|
||||
"Valentin",
|
||||
"Wagner",
|
||||
"Xie",
|
||||
"Yilmaz",
|
||||
"Zhang",
|
||||
];
|
||||
|
||||
const NORTH_REACH_NAMES: &[&str] = &[
|
||||
"Andersson",
|
||||
"Bjornsson",
|
||||
"Calloway",
|
||||
"Dalsgaard",
|
||||
"Eklund",
|
||||
"Falk",
|
||||
"Grimstad",
|
||||
"Hedlund",
|
||||
"Ivarsson",
|
||||
"Jonasson",
|
||||
"Kirkpatrick",
|
||||
"Lindqvist",
|
||||
"MacLeod",
|
||||
"Nordstrom",
|
||||
"Olafsson",
|
||||
"Pettersson",
|
||||
"Rehn",
|
||||
"Strandberg",
|
||||
"Thorsen",
|
||||
"Ulvskog",
|
||||
"Vikstrom",
|
||||
"Wahlberg",
|
||||
"Aberg",
|
||||
"Berglund",
|
||||
"Carlsen",
|
||||
"Dalgaard",
|
||||
"Engstrom",
|
||||
"Forsell",
|
||||
"Gustafsson",
|
||||
"Halvorsen",
|
||||
"Ingvarsson",
|
||||
"Jansson",
|
||||
"Knudsen",
|
||||
"Lundin",
|
||||
"MacPherson",
|
||||
"Nylund",
|
||||
"Ostergaard",
|
||||
"Palsson",
|
||||
"Rasmussen",
|
||||
"Sjoberg",
|
||||
"Toft",
|
||||
"Ulfsson",
|
||||
"Vestergaard",
|
||||
"Wiklund",
|
||||
"Aasen",
|
||||
"Brannstrom",
|
||||
"Dahl",
|
||||
"Eide",
|
||||
"Friberg",
|
||||
"Gren",
|
||||
];
|
||||
|
||||
const SOUTH_REACH_NAMES: &[&str] = &[
|
||||
"Adamski",
|
||||
"Baranov",
|
||||
"Chernov",
|
||||
"Dubois",
|
||||
"Egorov",
|
||||
"Filipov",
|
||||
"Gromov",
|
||||
"Horvat",
|
||||
"Ivanova",
|
||||
"Jankovic",
|
||||
"Kowalski",
|
||||
"Lazarev",
|
||||
"Morozov",
|
||||
"Novikov",
|
||||
"Ostrowski",
|
||||
"Petrov",
|
||||
"Reznik",
|
||||
"Sokolov",
|
||||
"Tkachenko",
|
||||
"Uvarov",
|
||||
"Volkov",
|
||||
"Wojcik",
|
||||
"Yakimov",
|
||||
"Zheng",
|
||||
"Babic",
|
||||
"Chernyshev",
|
||||
"Dragunov",
|
||||
"Fedorov",
|
||||
"Grushevsky",
|
||||
"Havel",
|
||||
"Ito",
|
||||
"Jovanovic",
|
||||
"Katsaros",
|
||||
"Lebedev",
|
||||
"Mazur",
|
||||
"Nemec",
|
||||
"Ochoa",
|
||||
"Popov",
|
||||
"Radic",
|
||||
"Smirnov",
|
||||
"Tanaka",
|
||||
"Urasawa",
|
||||
"Vasiliev",
|
||||
"Watanabe",
|
||||
"Xiang",
|
||||
"Yegorov",
|
||||
"Zaytsev",
|
||||
"Borysko",
|
||||
"Chen",
|
||||
"Dimitrov",
|
||||
];
|
||||
|
||||
const WEST_REACH_NAMES: &[&str] = &[
|
||||
"Albrecht",
|
||||
"Baumann",
|
||||
"Christensen",
|
||||
"Dietrich",
|
||||
"Eisenberg",
|
||||
"Fischer",
|
||||
"Gruber",
|
||||
"Hoffmann",
|
||||
"Ingolstadt",
|
||||
"Jaeger",
|
||||
"Kessler",
|
||||
"Lehmann",
|
||||
"Mueller",
|
||||
"Neumann",
|
||||
"Obermann",
|
||||
"Pfeiffer",
|
||||
"Quandt",
|
||||
"Roth",
|
||||
"Schaefer",
|
||||
"Thiel",
|
||||
"Urban",
|
||||
"Vogt",
|
||||
"Weidenfeld",
|
||||
"Ziegler",
|
||||
"Becker",
|
||||
"Claussen",
|
||||
"Dorfmann",
|
||||
"Eberhardt",
|
||||
"Fleischer",
|
||||
"Gerstner",
|
||||
"Haber",
|
||||
"Imhof",
|
||||
"Jung",
|
||||
"Kraemer",
|
||||
"Linden",
|
||||
"Metzger",
|
||||
"Niedermann",
|
||||
"Opitz",
|
||||
"Preuss",
|
||||
"Raabe",
|
||||
"Steinbach",
|
||||
"Trautmann",
|
||||
"Unger",
|
||||
"Vollmer",
|
||||
"Winterberg",
|
||||
"Zahn",
|
||||
"Auerbach",
|
||||
"Bruckner",
|
||||
"Dahlem",
|
||||
"Eckhardt",
|
||||
];
|
||||
|
||||
const EAST_REACH_NAMES: &[&str] = &[
|
||||
"Aquino",
|
||||
"Bautista",
|
||||
"Cruz",
|
||||
"Dalisay",
|
||||
"Espiritu",
|
||||
"Flores",
|
||||
"Garcia",
|
||||
"Hernandez",
|
||||
"Ilagan",
|
||||
"Jeon",
|
||||
"Kim",
|
||||
"Lim",
|
||||
"Magalang",
|
||||
"Navarro",
|
||||
"Ocampo",
|
||||
"Park",
|
||||
"Quijano",
|
||||
"Reyes",
|
||||
"Santos",
|
||||
"Tan",
|
||||
"Uy",
|
||||
"Villanueva",
|
||||
"Wong",
|
||||
"Yoo",
|
||||
"Aguilar",
|
||||
"Buenaventura",
|
||||
"Castillo",
|
||||
"Dizon",
|
||||
"Enriquez",
|
||||
"Fernandez",
|
||||
"Gonzales",
|
||||
"Hwang",
|
||||
"Ignacio",
|
||||
"Jeong",
|
||||
"Kwon",
|
||||
"Lee",
|
||||
"Marasigan",
|
||||
"Nakamura",
|
||||
"Oh",
|
||||
"Perez",
|
||||
"Ramos",
|
||||
"Son",
|
||||
"Tolentino",
|
||||
"Umali",
|
||||
"Valdez",
|
||||
"Yun",
|
||||
"Zamora",
|
||||
"Baek",
|
||||
"Choi",
|
||||
"Dela Cruz",
|
||||
];
|
||||
|
||||
const FRONTIER_NAMES: &[&str] = &[
|
||||
"Adeyemi",
|
||||
"Bergstrom",
|
||||
"Chandra",
|
||||
"Duval",
|
||||
"Emeka",
|
||||
"Fonseca",
|
||||
"Gupta",
|
||||
"Hassan",
|
||||
"Ibrahim",
|
||||
"Jansson",
|
||||
"Kovac",
|
||||
"Liu",
|
||||
"Martinez",
|
||||
"Nkosi",
|
||||
"Okafor",
|
||||
"Patel",
|
||||
"Quinn",
|
||||
"Rodriguez",
|
||||
"Sousa",
|
||||
"Thorne",
|
||||
"Uddin",
|
||||
"Varga",
|
||||
"Wu",
|
||||
"Xiong",
|
||||
"Yoshida",
|
||||
"Zhao",
|
||||
"Abara",
|
||||
"Beaumont",
|
||||
"Cardenas",
|
||||
"Doyle",
|
||||
"Ekwueme",
|
||||
"Ferreira",
|
||||
"Gomes",
|
||||
"Henriksen",
|
||||
"Idris",
|
||||
"Juma",
|
||||
"Kato",
|
||||
"Larsen",
|
||||
"Morales",
|
||||
"Ndlovu",
|
||||
"Osei",
|
||||
"Petrov",
|
||||
"Ruiz",
|
||||
"Singh",
|
||||
"Tavares",
|
||||
"Uchida",
|
||||
"Volkov",
|
||||
"Wang",
|
||||
"Yang",
|
||||
"Zaman",
|
||||
];
|
||||
use crate::surname_corpus::{
|
||||
CORE_NAMES, EAST_REACH_NAMES, FRONTIER_NAMES, NORTH_REACH_NAMES, SOUTH_REACH_NAMES,
|
||||
WEST_REACH_NAMES,
|
||||
};
|
||||
|
||||
fn names_for_corridor(corridor: &str) -> &'static [&'static str] {
|
||||
match corridor {
|
||||
|
||||
@@ -28,6 +28,8 @@ use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
mod names;
|
||||
#[path = "../shared/surname_corpus.rs"]
|
||||
mod surname_corpus;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
@@ -66,6 +68,15 @@ struct Cli {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Archetype data structures (deserialized from TOML)
|
||||
//
|
||||
// On the `#[allow(dead_code)]` fields below (T-1064): serde requires every
|
||||
// non-`#[serde(default)]` field to be present and well-typed, so fields the
|
||||
// generator never reads still act as schema validation for the archetype
|
||||
// TOMLs — a missing or mistyped field in wiki/economics/archetypes/ fails
|
||||
// loudly here instead of silently producing a half-parsed archetype. Do not
|
||||
// prune them; they are kept deliberately even though the Rust code never
|
||||
// reads them (the economy simulation consumes these values via the importer,
|
||||
// not via this binary).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -91,6 +102,10 @@ struct LoreArchetype {
|
||||
generation_notes: String,
|
||||
}
|
||||
|
||||
/// Every field except the map key is unused by this binary: behavioral
|
||||
/// archetypes are picked by id via `LoreArchetype::behavioral_affinity`.
|
||||
/// The struct exists to schema-validate behavioral.toml (see the comment on
|
||||
/// the section header above).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BehavioralArchetype {
|
||||
#[allow(dead_code)]
|
||||
@@ -135,12 +150,14 @@ struct Location {
|
||||
system_id: String,
|
||||
population: i64,
|
||||
geographic_sector: String,
|
||||
// Unused, but selecting it asserts the systems.db column exists (T-1064).
|
||||
#[allow(dead_code)]
|
||||
economic_role: Option<String>,
|
||||
}
|
||||
|
||||
struct ExistingCorp {
|
||||
corp_id: String,
|
||||
// Unused, but selecting it asserts the systems.db column exists (T-1064).
|
||||
#[allow(dead_code)]
|
||||
proper_name: String,
|
||||
specialization: Option<String>,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! Names are composed from culture-specific pools matching the Settled Reach's
|
||||
//! geographic sectors. Each sector has dominant cultural influences derived
|
||||
//! from lore (wiki settlements, founding cultures, corridor identities).
|
||||
//! The surname pools live in shared/surname_corpus.rs (also used by
|
||||
//! generate_brands).
|
||||
//!
|
||||
//! Pattern: `{surname/word} {business_suffix}` where surname draws from
|
||||
//! the sector's cultural pool and suffix from the lore category.
|
||||
@@ -10,333 +12,10 @@
|
||||
use rand::prelude::*;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Surname pools by sector (drawn from founding cultures in wiki canon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Core systems: cosmopolitan mix — the Reach's center of gravity.
|
||||
const CORE_NAMES: &[&str] = &[
|
||||
"Alvarez",
|
||||
"Benoit",
|
||||
"Carvalho",
|
||||
"Durand",
|
||||
"Eriksen",
|
||||
"Fournier",
|
||||
"Gao",
|
||||
"Hartmann",
|
||||
"Ishida",
|
||||
"Johansson",
|
||||
"Kirchner",
|
||||
"Lemaire",
|
||||
"Moreau",
|
||||
"Nakamura",
|
||||
"Olsson",
|
||||
"Pelletier",
|
||||
"Richter",
|
||||
"Saito",
|
||||
"Torres",
|
||||
"Ueda",
|
||||
"Vasquez",
|
||||
"Werner",
|
||||
"Xu",
|
||||
"Yamada",
|
||||
"Zhou",
|
||||
"Andersen",
|
||||
"Beaumont",
|
||||
"Costa",
|
||||
"Delacroix",
|
||||
"Engel",
|
||||
"Fujita",
|
||||
"Gutierrez",
|
||||
"Hayashi",
|
||||
"Ibarra",
|
||||
"Jensen",
|
||||
"Klein",
|
||||
"Laurent",
|
||||
"Mercier",
|
||||
"Novak",
|
||||
"Ortiz",
|
||||
"Park",
|
||||
"Reuter",
|
||||
"Suzuki",
|
||||
"Takahashi",
|
||||
"Ulrich",
|
||||
"Valentin",
|
||||
"Wagner",
|
||||
"Xie",
|
||||
"Yilmaz",
|
||||
"Zhang",
|
||||
];
|
||||
|
||||
/// North reach: Nordic, Scottish, northern European — Calloway heritage.
|
||||
const NORTH_REACH_NAMES: &[&str] = &[
|
||||
"Andersson",
|
||||
"Bjornsson",
|
||||
"Calloway",
|
||||
"Dalsgaard",
|
||||
"Eklund",
|
||||
"Falk",
|
||||
"Grimstad",
|
||||
"Hedlund",
|
||||
"Ivarsson",
|
||||
"Jonasson",
|
||||
"Kirkpatrick",
|
||||
"Lindqvist",
|
||||
"MacLeod",
|
||||
"Nordstrom",
|
||||
"Olafsson",
|
||||
"Pettersson",
|
||||
"Rehn",
|
||||
"Strandberg",
|
||||
"Thorsen",
|
||||
"Ulvskog",
|
||||
"Vikstrom",
|
||||
"Wahlberg",
|
||||
"Aberg",
|
||||
"Berglund",
|
||||
"Carlsen",
|
||||
"Dalgaard",
|
||||
"Engstrom",
|
||||
"Forsell",
|
||||
"Gustafsson",
|
||||
"Halvorsen",
|
||||
"Ingvarsson",
|
||||
"Jansson",
|
||||
"Knudsen",
|
||||
"Lundin",
|
||||
"MacPherson",
|
||||
"Nylund",
|
||||
"Ostergaard",
|
||||
"Palsson",
|
||||
"Rasmussen",
|
||||
"Sjoberg",
|
||||
"Toft",
|
||||
"Ulfsson",
|
||||
"Vestergaard",
|
||||
"Wiklund",
|
||||
"Aasen",
|
||||
"Brannstrom",
|
||||
"Dahl",
|
||||
"Eide",
|
||||
"Friberg",
|
||||
"Gren",
|
||||
];
|
||||
|
||||
/// South reach: Eastern European, East Asian industrial — Stalownia corridor.
|
||||
const SOUTH_REACH_NAMES: &[&str] = &[
|
||||
"Adamski",
|
||||
"Baranov",
|
||||
"Chernov",
|
||||
"Dubois",
|
||||
"Egorov",
|
||||
"Filipov",
|
||||
"Gromov",
|
||||
"Horvat",
|
||||
"Ivanova",
|
||||
"Jankovic",
|
||||
"Kowalski",
|
||||
"Lazarev",
|
||||
"Morozov",
|
||||
"Novikov",
|
||||
"Ostrowski",
|
||||
"Petrov",
|
||||
"Reznik",
|
||||
"Sokolov",
|
||||
"Tkachenko",
|
||||
"Uvarov",
|
||||
"Volkov",
|
||||
"Wojcik",
|
||||
"Yakimov",
|
||||
"Zheng",
|
||||
"Babic",
|
||||
"Chernyshev",
|
||||
"Dragunov",
|
||||
"Fedorov",
|
||||
"Grushevsky",
|
||||
"Havel",
|
||||
"Ito",
|
||||
"Jovanovic",
|
||||
"Katsaros",
|
||||
"Lebedev",
|
||||
"Mazur",
|
||||
"Nemec",
|
||||
"Ochoa",
|
||||
"Popov",
|
||||
"Radic",
|
||||
"Smirnov",
|
||||
"Tanaka",
|
||||
"Urasawa",
|
||||
"Vasiliev",
|
||||
"Watanabe",
|
||||
"Xiang",
|
||||
"Yegorov",
|
||||
"Zaytsev",
|
||||
"Borysko",
|
||||
"Chen",
|
||||
"Dimitrov",
|
||||
];
|
||||
|
||||
/// West reach: German, Central European — Compact territory, Westphalian influence.
|
||||
const WEST_REACH_NAMES: &[&str] = &[
|
||||
"Albrecht",
|
||||
"Baumann",
|
||||
"Christensen",
|
||||
"Dietrich",
|
||||
"Eisenberg",
|
||||
"Fischer",
|
||||
"Gruber",
|
||||
"Hoffmann",
|
||||
"Ingolstadt",
|
||||
"Jaeger",
|
||||
"Kessler",
|
||||
"Lehmann",
|
||||
"Mueller",
|
||||
"Neumann",
|
||||
"Obermann",
|
||||
"Pfeiffer",
|
||||
"Quandt",
|
||||
"Roth",
|
||||
"Schaefer",
|
||||
"Thiel",
|
||||
"Urban",
|
||||
"Vogt",
|
||||
"Weidenfeld",
|
||||
"Ziegler",
|
||||
"Becker",
|
||||
"Claussen",
|
||||
"Dorfmann",
|
||||
"Eberhardt",
|
||||
"Fleischer",
|
||||
"Gerstner",
|
||||
"Haber",
|
||||
"Imhof",
|
||||
"Jung",
|
||||
"Kraemer",
|
||||
"Linden",
|
||||
"Metzger",
|
||||
"Niedermann",
|
||||
"Opitz",
|
||||
"Preuss",
|
||||
"Raabe",
|
||||
"Steinbach",
|
||||
"Trautmann",
|
||||
"Unger",
|
||||
"Vollmer",
|
||||
"Winterberg",
|
||||
"Zahn",
|
||||
"Auerbach",
|
||||
"Bruckner",
|
||||
"Dahlem",
|
||||
"Eckhardt",
|
||||
];
|
||||
|
||||
/// East reach: Filipino, Korean, maritime Asian — distinctive identity.
|
||||
const EAST_REACH_NAMES: &[&str] = &[
|
||||
"Aquino",
|
||||
"Bautista",
|
||||
"Cruz",
|
||||
"Dalisay",
|
||||
"Espiritu",
|
||||
"Flores",
|
||||
"Garcia",
|
||||
"Hernandez",
|
||||
"Ilagan",
|
||||
"Jeon",
|
||||
"Kim",
|
||||
"Lim",
|
||||
"Magalang",
|
||||
"Navarro",
|
||||
"Ocampo",
|
||||
"Park",
|
||||
"Quijano",
|
||||
"Reyes",
|
||||
"Santos",
|
||||
"Tan",
|
||||
"Uy",
|
||||
"Villanueva",
|
||||
"Wong",
|
||||
"Yoo",
|
||||
"Aguilar",
|
||||
"Buenaventura",
|
||||
"Castillo",
|
||||
"Dizon",
|
||||
"Enriquez",
|
||||
"Fernandez",
|
||||
"Gonzales",
|
||||
"Hwang",
|
||||
"Ignacio",
|
||||
"Jeong",
|
||||
"Kwon",
|
||||
"Lee",
|
||||
"Marasigan",
|
||||
"Nakamura",
|
||||
"Oh",
|
||||
"Perez",
|
||||
"Ramos",
|
||||
"Son",
|
||||
"Tolentino",
|
||||
"Umali",
|
||||
"Valdez",
|
||||
"Yun",
|
||||
"Zamora",
|
||||
"Baek",
|
||||
"Choi",
|
||||
"Dela Cruz",
|
||||
];
|
||||
|
||||
/// Deep frontier: mixed backgrounds from all settler waves — no dominant culture.
|
||||
const FRONTIER_NAMES: &[&str] = &[
|
||||
"Adeyemi",
|
||||
"Bergstrom",
|
||||
"Chandra",
|
||||
"Duval",
|
||||
"Emeka",
|
||||
"Fonseca",
|
||||
"Gupta",
|
||||
"Hassan",
|
||||
"Ibrahim",
|
||||
"Jansson",
|
||||
"Kovac",
|
||||
"Liu",
|
||||
"Martinez",
|
||||
"Nkosi",
|
||||
"Okafor",
|
||||
"Patel",
|
||||
"Quinn",
|
||||
"Rodriguez",
|
||||
"Sousa",
|
||||
"Thorne",
|
||||
"Uddin",
|
||||
"Varga",
|
||||
"Wu",
|
||||
"Xiong",
|
||||
"Yoshida",
|
||||
"Zhao",
|
||||
"Abara",
|
||||
"Beaumont",
|
||||
"Cardenas",
|
||||
"Doyle",
|
||||
"Ekwueme",
|
||||
"Ferreira",
|
||||
"Gomes",
|
||||
"Henriksen",
|
||||
"Idris",
|
||||
"Juma",
|
||||
"Kato",
|
||||
"Larsen",
|
||||
"Morales",
|
||||
"Ndlovu",
|
||||
"Osei",
|
||||
"Petrov",
|
||||
"Ruiz",
|
||||
"Singh",
|
||||
"Tavares",
|
||||
"Uchida",
|
||||
"Volkov",
|
||||
"Wang",
|
||||
"Yang",
|
||||
"Zaman",
|
||||
];
|
||||
use crate::surname_corpus::{
|
||||
CORE_NAMES, EAST_REACH_NAMES, FRONTIER_NAMES, NORTH_REACH_NAMES, SOUTH_REACH_NAMES,
|
||||
WEST_REACH_NAMES,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Business suffix pools by lore category
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Shared surname corpus for the economy generator binaries (T-1064).
|
||||
//!
|
||||
//! Single source of truth for the corridor/sector surname pools used by both
|
||||
//! `generate_brands` (product names) and `generate_corporations` (business
|
||||
//! names). The pools were previously duplicated in each binary's `names.rs`;
|
||||
//! they are extracted here so the corpora cannot drift apart.
|
||||
//!
|
||||
//! Included via `#[path = "../shared/surname_corpus.rs"]` from each binary's
|
||||
//! `main.rs` — this directory is not a cargo bin target (no `main.rs`).
|
||||
//!
|
||||
//! NOTE: both binaries feed the systems.db meta stamp — this file is part of
|
||||
//! the `import_economics` source set in `tooling/check-systems-db-stamp`
|
||||
//! (GENERATOR_SOURCES). Changing any pool requires `make regen-db`.
|
||||
//! Pool contents are surname data drawn from founding cultures in wiki canon;
|
||||
//! ordering is load-bearing (index-based RNG draws), so never reorder.
|
||||
|
||||
/// Core systems: cosmopolitan mix — the Reach's center of gravity.
|
||||
pub const CORE_NAMES: &[&str] = &[
|
||||
"Alvarez",
|
||||
"Benoit",
|
||||
"Carvalho",
|
||||
"Durand",
|
||||
"Eriksen",
|
||||
"Fournier",
|
||||
"Gao",
|
||||
"Hartmann",
|
||||
"Ishida",
|
||||
"Johansson",
|
||||
"Kirchner",
|
||||
"Lemaire",
|
||||
"Moreau",
|
||||
"Nakamura",
|
||||
"Olsson",
|
||||
"Pelletier",
|
||||
"Richter",
|
||||
"Saito",
|
||||
"Torres",
|
||||
"Ueda",
|
||||
"Vasquez",
|
||||
"Werner",
|
||||
"Xu",
|
||||
"Yamada",
|
||||
"Zhou",
|
||||
"Andersen",
|
||||
"Beaumont",
|
||||
"Costa",
|
||||
"Delacroix",
|
||||
"Engel",
|
||||
"Fujita",
|
||||
"Gutierrez",
|
||||
"Hayashi",
|
||||
"Ibarra",
|
||||
"Jensen",
|
||||
"Klein",
|
||||
"Laurent",
|
||||
"Mercier",
|
||||
"Novak",
|
||||
"Ortiz",
|
||||
"Park",
|
||||
"Reuter",
|
||||
"Suzuki",
|
||||
"Takahashi",
|
||||
"Ulrich",
|
||||
"Valentin",
|
||||
"Wagner",
|
||||
"Xie",
|
||||
"Yilmaz",
|
||||
"Zhang",
|
||||
];
|
||||
|
||||
/// North reach: Nordic, Scottish, northern European — Calloway heritage.
|
||||
pub const NORTH_REACH_NAMES: &[&str] = &[
|
||||
"Andersson",
|
||||
"Bjornsson",
|
||||
"Calloway",
|
||||
"Dalsgaard",
|
||||
"Eklund",
|
||||
"Falk",
|
||||
"Grimstad",
|
||||
"Hedlund",
|
||||
"Ivarsson",
|
||||
"Jonasson",
|
||||
"Kirkpatrick",
|
||||
"Lindqvist",
|
||||
"MacLeod",
|
||||
"Nordstrom",
|
||||
"Olafsson",
|
||||
"Pettersson",
|
||||
"Rehn",
|
||||
"Strandberg",
|
||||
"Thorsen",
|
||||
"Ulvskog",
|
||||
"Vikstrom",
|
||||
"Wahlberg",
|
||||
"Aberg",
|
||||
"Berglund",
|
||||
"Carlsen",
|
||||
"Dalgaard",
|
||||
"Engstrom",
|
||||
"Forsell",
|
||||
"Gustafsson",
|
||||
"Halvorsen",
|
||||
"Ingvarsson",
|
||||
"Jansson",
|
||||
"Knudsen",
|
||||
"Lundin",
|
||||
"MacPherson",
|
||||
"Nylund",
|
||||
"Ostergaard",
|
||||
"Palsson",
|
||||
"Rasmussen",
|
||||
"Sjoberg",
|
||||
"Toft",
|
||||
"Ulfsson",
|
||||
"Vestergaard",
|
||||
"Wiklund",
|
||||
"Aasen",
|
||||
"Brannstrom",
|
||||
"Dahl",
|
||||
"Eide",
|
||||
"Friberg",
|
||||
"Gren",
|
||||
];
|
||||
|
||||
/// South reach: Eastern European, East Asian industrial — Stalownia corridor.
|
||||
pub const SOUTH_REACH_NAMES: &[&str] = &[
|
||||
"Adamski",
|
||||
"Baranov",
|
||||
"Chernov",
|
||||
"Dubois",
|
||||
"Egorov",
|
||||
"Filipov",
|
||||
"Gromov",
|
||||
"Horvat",
|
||||
"Ivanova",
|
||||
"Jankovic",
|
||||
"Kowalski",
|
||||
"Lazarev",
|
||||
"Morozov",
|
||||
"Novikov",
|
||||
"Ostrowski",
|
||||
"Petrov",
|
||||
"Reznik",
|
||||
"Sokolov",
|
||||
"Tkachenko",
|
||||
"Uvarov",
|
||||
"Volkov",
|
||||
"Wojcik",
|
||||
"Yakimov",
|
||||
"Zheng",
|
||||
"Babic",
|
||||
"Chernyshev",
|
||||
"Dragunov",
|
||||
"Fedorov",
|
||||
"Grushevsky",
|
||||
"Havel",
|
||||
"Ito",
|
||||
"Jovanovic",
|
||||
"Katsaros",
|
||||
"Lebedev",
|
||||
"Mazur",
|
||||
"Nemec",
|
||||
"Ochoa",
|
||||
"Popov",
|
||||
"Radic",
|
||||
"Smirnov",
|
||||
"Tanaka",
|
||||
"Urasawa",
|
||||
"Vasiliev",
|
||||
"Watanabe",
|
||||
"Xiang",
|
||||
"Yegorov",
|
||||
"Zaytsev",
|
||||
"Borysko",
|
||||
"Chen",
|
||||
"Dimitrov",
|
||||
];
|
||||
|
||||
/// West reach: German, Central European — Compact territory, Westphalian influence.
|
||||
pub const WEST_REACH_NAMES: &[&str] = &[
|
||||
"Albrecht",
|
||||
"Baumann",
|
||||
"Christensen",
|
||||
"Dietrich",
|
||||
"Eisenberg",
|
||||
"Fischer",
|
||||
"Gruber",
|
||||
"Hoffmann",
|
||||
"Ingolstadt",
|
||||
"Jaeger",
|
||||
"Kessler",
|
||||
"Lehmann",
|
||||
"Mueller",
|
||||
"Neumann",
|
||||
"Obermann",
|
||||
"Pfeiffer",
|
||||
"Quandt",
|
||||
"Roth",
|
||||
"Schaefer",
|
||||
"Thiel",
|
||||
"Urban",
|
||||
"Vogt",
|
||||
"Weidenfeld",
|
||||
"Ziegler",
|
||||
"Becker",
|
||||
"Claussen",
|
||||
"Dorfmann",
|
||||
"Eberhardt",
|
||||
"Fleischer",
|
||||
"Gerstner",
|
||||
"Haber",
|
||||
"Imhof",
|
||||
"Jung",
|
||||
"Kraemer",
|
||||
"Linden",
|
||||
"Metzger",
|
||||
"Niedermann",
|
||||
"Opitz",
|
||||
"Preuss",
|
||||
"Raabe",
|
||||
"Steinbach",
|
||||
"Trautmann",
|
||||
"Unger",
|
||||
"Vollmer",
|
||||
"Winterberg",
|
||||
"Zahn",
|
||||
"Auerbach",
|
||||
"Bruckner",
|
||||
"Dahlem",
|
||||
"Eckhardt",
|
||||
];
|
||||
|
||||
/// East reach: Filipino, Korean, maritime Asian — distinctive identity.
|
||||
pub const EAST_REACH_NAMES: &[&str] = &[
|
||||
"Aquino",
|
||||
"Bautista",
|
||||
"Cruz",
|
||||
"Dalisay",
|
||||
"Espiritu",
|
||||
"Flores",
|
||||
"Garcia",
|
||||
"Hernandez",
|
||||
"Ilagan",
|
||||
"Jeon",
|
||||
"Kim",
|
||||
"Lim",
|
||||
"Magalang",
|
||||
"Navarro",
|
||||
"Ocampo",
|
||||
"Park",
|
||||
"Quijano",
|
||||
"Reyes",
|
||||
"Santos",
|
||||
"Tan",
|
||||
"Uy",
|
||||
"Villanueva",
|
||||
"Wong",
|
||||
"Yoo",
|
||||
"Aguilar",
|
||||
"Buenaventura",
|
||||
"Castillo",
|
||||
"Dizon",
|
||||
"Enriquez",
|
||||
"Fernandez",
|
||||
"Gonzales",
|
||||
"Hwang",
|
||||
"Ignacio",
|
||||
"Jeong",
|
||||
"Kwon",
|
||||
"Lee",
|
||||
"Marasigan",
|
||||
"Nakamura",
|
||||
"Oh",
|
||||
"Perez",
|
||||
"Ramos",
|
||||
"Son",
|
||||
"Tolentino",
|
||||
"Umali",
|
||||
"Valdez",
|
||||
"Yun",
|
||||
"Zamora",
|
||||
"Baek",
|
||||
"Choi",
|
||||
"Dela Cruz",
|
||||
];
|
||||
|
||||
/// Deep frontier: mixed backgrounds from all settler waves — no dominant culture.
|
||||
pub const FRONTIER_NAMES: &[&str] = &[
|
||||
"Adeyemi",
|
||||
"Bergstrom",
|
||||
"Chandra",
|
||||
"Duval",
|
||||
"Emeka",
|
||||
"Fonseca",
|
||||
"Gupta",
|
||||
"Hassan",
|
||||
"Ibrahim",
|
||||
"Jansson",
|
||||
"Kovac",
|
||||
"Liu",
|
||||
"Martinez",
|
||||
"Nkosi",
|
||||
"Okafor",
|
||||
"Patel",
|
||||
"Quinn",
|
||||
"Rodriguez",
|
||||
"Sousa",
|
||||
"Thorne",
|
||||
"Uddin",
|
||||
"Varga",
|
||||
"Wu",
|
||||
"Xiong",
|
||||
"Yoshida",
|
||||
"Zhao",
|
||||
"Abara",
|
||||
"Beaumont",
|
||||
"Cardenas",
|
||||
"Doyle",
|
||||
"Ekwueme",
|
||||
"Ferreira",
|
||||
"Gomes",
|
||||
"Henriksen",
|
||||
"Idris",
|
||||
"Juma",
|
||||
"Kato",
|
||||
"Larsen",
|
||||
"Morales",
|
||||
"Ndlovu",
|
||||
"Osei",
|
||||
"Petrov",
|
||||
"Ruiz",
|
||||
"Singh",
|
||||
"Tavares",
|
||||
"Uchida",
|
||||
"Volkov",
|
||||
"Wang",
|
||||
"Yang",
|
||||
"Zaman",
|
||||
];
|
||||
@@ -6,6 +6,9 @@
|
||||
//!
|
||||
//! Note: HashSet is used as a per-frame lookup table (visible tiles/NPCs).
|
||||
//! Only membership checks — iteration order is irrelevant. Not simulation state.
|
||||
//! Consumer contract: every consumer must sort (or otherwise impose a
|
||||
//! deterministic order on) this data before it touches simulation state or
|
||||
//! the wire.
|
||||
#![allow(clippy::disallowed_types)]
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
//! Note: HashMap is used for `sector_lookup` — a per-frame scratch buffer
|
||||
//! looked up only by key. Iteration order is irrelevant here. Not subject to
|
||||
//! the simulation determinism constraint (see server/.clippy.toml).
|
||||
//! Consumer contract: every consumer must sort (or otherwise impose a
|
||||
//! deterministic order on) this data before it touches simulation state or
|
||||
//! the wire.
|
||||
#![allow(clippy::disallowed_types)]
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
//! tile positions during the FOV sweep. Only `insert` and `contains` are used;
|
||||
//! iteration order never affects the output (results are handed to BTreeSet in
|
||||
//! query.rs). Not simulation state — exempt from the determinism constraint.
|
||||
//! Consumer contract: every consumer must sort (or otherwise impose a
|
||||
//! deterministic order on) this data before it touches simulation state or
|
||||
//! the wire.
|
||||
#![allow(clippy::disallowed_types)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -380,3 +380,257 @@ pub fn try_load_economy(run_seed: u64) -> Option<(EconSimResource, EconStateReso
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests (T-1064): D-181 signal derivation in rebuild_signals.
|
||||
//
|
||||
// Built on a minimal in-memory econ_sim::Simulation (one system, one
|
||||
// commodity) via Simulation::from_economy — no systems.db involved. Node
|
||||
// commodity state is set directly through the public `sim.nodes` field so
|
||||
// each test controls the exact price/supply/stockpile/demand sequence the
|
||||
// signals are derived from.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use econ_sim::db::{Commodity, Economy, SystemInfo};
|
||||
|
||||
const SYS: &str = "sys-test";
|
||||
const COM: &str = "ore";
|
||||
|
||||
fn minimal_economy() -> Economy {
|
||||
let commodity = Commodity {
|
||||
id: COM.to_string(),
|
||||
name: "Test Ore".to_string(),
|
||||
tier: "raw".to_string(),
|
||||
base_price: 10.0,
|
||||
elasticity: "normal".to_string(),
|
||||
production_ubiquity: None,
|
||||
demand_model: "population".to_string(),
|
||||
};
|
||||
let system = SystemInfo {
|
||||
system_id: SYS.to_string(),
|
||||
proper_name: None,
|
||||
// Non-zero population activates the node in model::init_nodes.
|
||||
population: 1_000,
|
||||
cultural_corridor: None,
|
||||
gate_energy_connected: true,
|
||||
currency_zone: "TRACTUS_PRIMARY".to_string(),
|
||||
hop_distance: 0,
|
||||
gate_topology: None,
|
||||
political_zone: None,
|
||||
};
|
||||
Economy {
|
||||
commodities: vec![commodity.clone()],
|
||||
commodity_map: BTreeMap::from([(COM.to_string(), commodity)]),
|
||||
chains: Vec::new(),
|
||||
chains_by_output: BTreeMap::new(),
|
||||
systems: BTreeMap::from([(SYS.to_string(), system)]),
|
||||
corp_presences: Vec::new(),
|
||||
presences_by_system: BTreeMap::new(),
|
||||
gate_links: Vec::new(),
|
||||
corp_archetype_data: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_resources() -> (EconSimResource, EconStateResource) {
|
||||
let sim = Simulation::from_economy(minimal_economy(), 42);
|
||||
let econ = EconSimResource::new(sim);
|
||||
assert!(
|
||||
econ.sim.nodes.contains_key(SYS),
|
||||
"test system must be an active node"
|
||||
);
|
||||
(econ, EconStateResource::default())
|
||||
}
|
||||
|
||||
/// Set the test node's commodity state, then rebuild signals as one
|
||||
/// economy tick would.
|
||||
fn set_state_and_rebuild(
|
||||
econ: &mut EconSimResource,
|
||||
state: &mut EconStateResource,
|
||||
econ_tick: u64,
|
||||
price: f64,
|
||||
supply: f64,
|
||||
stockpile: f64,
|
||||
demand: f64,
|
||||
) {
|
||||
let cs = econ
|
||||
.sim
|
||||
.nodes
|
||||
.get_mut(SYS)
|
||||
.expect("test node active")
|
||||
.commodities
|
||||
.get_mut(COM)
|
||||
.expect("test commodity present");
|
||||
cs.price = price;
|
||||
cs.supply = supply;
|
||||
cs.stockpile = stockpile;
|
||||
cs.demand = demand;
|
||||
rebuild_signals(econ, state, econ_tick, 1.0);
|
||||
}
|
||||
|
||||
fn signals(state: &EconStateResource) -> &EconNodeSignals {
|
||||
state
|
||||
.signals
|
||||
.get(&(SYS.to_string(), COM.to_string()))
|
||||
.expect("signals present for active node")
|
||||
}
|
||||
|
||||
// -- Signal 2: price trend windowing --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn trend_is_zero_with_single_history_entry() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 1.0, 1.0, 1.0);
|
||||
assert_eq!(signals(&state).price_trend, 0.0);
|
||||
assert_eq!(signals(&state).price_current, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trend_under_window_spans_full_history() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
// 3 ticks (< TREND_WINDOW = 5): trend = current − oldest = 16 − 10.
|
||||
for (tick, price) in [(1u64, 10.0), (2, 12.0), (3, 16.0)] {
|
||||
set_state_and_rebuild(&mut econ, &mut state, tick, price, 1.0, 1.0, 1.0);
|
||||
}
|
||||
assert_eq!(signals(&state).price_trend, 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trend_at_exact_window_spans_window() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
// Exactly TREND_WINDOW prices: 10..14 → trend = 14 − 10.
|
||||
for i in 0..TREND_WINDOW {
|
||||
let price = 10.0 + i as f64;
|
||||
set_state_and_rebuild(&mut econ, &mut state, i as u64 + 1, price, 1.0, 1.0, 1.0);
|
||||
}
|
||||
assert_eq!(signals(&state).price_trend, (TREND_WINDOW - 1) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trend_over_window_slides_oldest_out() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
// 7 monotonically rising prices 10..16; the ring buffer keeps the last
|
||||
// TREND_WINDOW (5) entries [12..16] → trend = 16 − 12, NOT 16 − 10.
|
||||
for i in 0..7u64 {
|
||||
let price = 10.0 + i as f64;
|
||||
set_state_and_rebuild(&mut econ, &mut state, i + 1, price, 1.0, 1.0, 1.0);
|
||||
}
|
||||
assert_eq!(signals(&state).price_trend, (TREND_WINDOW - 1) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trend_is_negative_when_price_falls() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
for (tick, price) in [(1u64, 20.0), (2, 15.0)] {
|
||||
set_state_and_rebuild(&mut econ, &mut state, tick, price, 1.0, 1.0, 1.0);
|
||||
}
|
||||
assert_eq!(signals(&state).price_trend, -5.0);
|
||||
}
|
||||
|
||||
// -- Signal 6: first-tick baseline capture --------------------------------
|
||||
|
||||
#[test]
|
||||
fn baseline_captured_on_first_tick_and_held() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
// First rebuild records supply 50 as the permanent baseline.
|
||||
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 50.0, 1.0, 1.0);
|
||||
assert_eq!(signals(&state).production_vs_baseline, 1.0);
|
||||
|
||||
// Later supply is measured against that first-tick baseline.
|
||||
set_state_and_rebuild(&mut econ, &mut state, 2, 10.0, 25.0, 1.0, 1.0);
|
||||
assert_eq!(signals(&state).production_vs_baseline, 0.5);
|
||||
|
||||
set_state_and_rebuild(&mut econ, &mut state, 3, 10.0, 100.0, 1.0, 1.0);
|
||||
assert_eq!(signals(&state).production_vs_baseline, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_baseline_yields_unity_ratio() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
// init_nodes warm-starts supply at 0.0 — a zero first-tick baseline
|
||||
// must not divide; the signal pins at 1.0 (at-baseline) instead.
|
||||
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 0.0, 1.0, 1.0);
|
||||
assert_eq!(signals(&state).production_vs_baseline, 1.0);
|
||||
|
||||
set_state_and_rebuild(&mut econ, &mut state, 2, 10.0, 37.0, 1.0, 1.0);
|
||||
let s = signals(&state);
|
||||
assert_eq!(s.production_vs_baseline, 1.0);
|
||||
assert!(s.production_vs_baseline.is_finite());
|
||||
}
|
||||
|
||||
// -- Signal 5: stockpile_weeks zero-demand edge ----------------------------
|
||||
|
||||
#[test]
|
||||
fn zero_demand_stockpile_weeks_is_zero_not_infinite() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 1.0, 500.0, 0.0);
|
||||
let s = signals(&state);
|
||||
assert_eq!(s.stockpile_weeks, 0.0);
|
||||
assert!(s.stockpile_weeks.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stockpile_weeks_divides_by_weekly_demand() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
// demand 10/tick → 70/week; stockpile 140 → 2 weeks.
|
||||
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 1.0, 140.0, 10.0);
|
||||
assert_eq!(signals(&state).stockpile_weeks, 2.0);
|
||||
}
|
||||
|
||||
// -- Cross-cutting: remaining signals + state bookkeeping ------------------
|
||||
|
||||
#[test]
|
||||
fn rebuild_populates_remaining_signals_and_metadata() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
set_state_and_rebuild(&mut econ, &mut state, 9, 12.5, 33.0, 70.0, 10.0);
|
||||
|
||||
assert_eq!(state.econ_tick, 9);
|
||||
assert_eq!(state.tractus_mark_rate, 1.0);
|
||||
assert_eq!(state.signals.len(), 1);
|
||||
|
||||
let shadow_intensity = econ
|
||||
.sim
|
||||
.shadow()
|
||||
.intensity
|
||||
.get(SYS)
|
||||
.copied()
|
||||
.expect("shadow intensity seeded for test system");
|
||||
let s = signals(&state);
|
||||
assert_eq!(s.system_id, SYS);
|
||||
assert_eq!(s.commodity_id, COM);
|
||||
assert_eq!(s.price_current, 12.5);
|
||||
// Signal 3 is a supply proxy in Phase 2.
|
||||
assert_eq!(s.trade_flow_volume, 33.0);
|
||||
// No corp presences in the minimal economy.
|
||||
assert_eq!(s.corporate_presence, 0);
|
||||
// Signal 7 derives directly from the seeded shadow intensity.
|
||||
assert_eq!(s.official_coverage_ratio, 1.0 - shadow_intensity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_clears_stale_signals() {
|
||||
let (mut econ, mut state) = test_resources();
|
||||
// Seed a stale entry for a node that no longer exists.
|
||||
state.signals.insert(
|
||||
("ghost-system".to_string(), COM.to_string()),
|
||||
EconNodeSignals {
|
||||
system_id: "ghost-system".to_string(),
|
||||
commodity_id: COM.to_string(),
|
||||
price_current: 0.0,
|
||||
price_trend: 0.0,
|
||||
trade_flow_volume: 0.0,
|
||||
corporate_presence: 0,
|
||||
stockpile_weeks: 0.0,
|
||||
production_vs_baseline: 0.0,
|
||||
official_coverage_ratio: 0.0,
|
||||
},
|
||||
);
|
||||
set_state_and_rebuild(&mut econ, &mut state, 1, 10.0, 1.0, 1.0, 1.0);
|
||||
assert_eq!(state.signals.len(), 1);
|
||||
assert!(!state
|
||||
.signals
|
||||
.contains_key(&("ghost-system".to_string(), COM.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +107,16 @@ impl Plugin for SimulationPlugin {
|
||||
|
||||
// save_load is an exclusive system (takes &mut World).
|
||||
// Exclusive systems in Bevy 0.18 cannot use .in_set() — use .before()/.after()
|
||||
// to position it within the Snapshot phase window.
|
||||
// to position it within the Snapshot phase window (the sanctioned exception
|
||||
// to the tick_phases.rs rules; see the note there).
|
||||
// Both bounds matter: `.after(Storyteller)` pins the lower bound so the
|
||||
// scheduler cannot legally run the save before Input/Simulation/Storyteller
|
||||
// have executed — a save must capture post-Storyteller state, matching what
|
||||
// compute_observer_snapshot is about to serialize (T-1064 / audit S-09).
|
||||
app.add_systems(
|
||||
Update,
|
||||
save_io::execute_save_load
|
||||
.after(crate::tick_phases::TickPhase::Storyteller)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
//! the system is in the wrong phase.
|
||||
//! 3. Intra-phase `.after()`/`.before()` is allowed for systems within
|
||||
//! the same phase that have a real data dependency.
|
||||
//!
|
||||
//! **Exception — exclusive systems.** Exclusive systems (`&mut World`) cannot
|
||||
//! use `.in_set()` in Bevy 0.18, so rule 1 is unsatisfiable for them. Instead
|
||||
//! they MUST be pinned into their phase window with explicit constraints on
|
||||
//! BOTH sides: `.after(TickPhase::<previous phase>)` for the lower bound and
|
||||
//! `.before(...)`/`.after(...)` against systems or the phase sets around them
|
||||
//! for the upper bound. A one-sided constraint leaves the scheduler free to
|
||||
//! run the system anywhere earlier/later in the tick. Sole current instance:
|
||||
//! `save_io::execute_save_load` (simulation/mod.rs), pinned after
|
||||
//! `Storyteller` and before `compute_observer_snapshot` (Snapshot phase).
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
+207
-8
@@ -5,12 +5,17 @@
|
||||
//!
|
||||
//! ## Worker crash behavior
|
||||
//!
|
||||
//! TODO (#843): If a worker thread panics, in-flight requests on that thread
|
||||
//! are lost. The pool does NOT currently re-enqueue them. For Fallback-strategy
|
||||
//! workers (voice, LLM) this is acceptable — the tick loop uses the default.
|
||||
//! For ModalLock-strategy workers (chunk gen on gate transit), a lost request
|
||||
//! means the player waits forever. Future: add heartbeat monitoring and
|
||||
//! automatic re-enqueue on worker death.
|
||||
//! Handler panics are contained per-request (T-1063): the worker thread
|
||||
//! catches the unwind, logs an error, and keeps serving the queue — a
|
||||
//! poisoned request cannot permanently shrink the pool. The in-flight
|
||||
//! request is still LOST: no result is ever delivered for it.
|
||||
//!
|
||||
//! TODO (#843): re-enqueue lost requests (needs heartbeat/ack bookkeeping).
|
||||
//! For Fallback-strategy workers (voice, LLM) the loss is acceptable — the
|
||||
//! tick loop uses the default. For ModalLock-strategy workers (chunk gen on
|
||||
//! gate transit), a lost request means the player waits forever. Before any
|
||||
//! ModalLock consumer ships (Phase 5), implement re-enqueue or downgrade
|
||||
//! ModalLock to an error path.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -88,11 +93,29 @@ where
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(work) => {
|
||||
let strategy = work.strategy;
|
||||
let result = handler(work.payload);
|
||||
if tx.send((result, strategy)).is_err() {
|
||||
// Contain handler panics (T-1063): the thread must
|
||||
// survive a poisoned request or the pool permanently
|
||||
// loses capacity. The in-flight request is still lost
|
||||
// (no result delivered) — re-enqueue is future work,
|
||||
// see the module-level TODO (#843).
|
||||
let result = std::panic::catch_unwind(
|
||||
std::panic::AssertUnwindSafe(|| handler(work.payload)),
|
||||
);
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
if tx.send((resp, strategy)).is_err() {
|
||||
break; // response channel closed
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::error!(
|
||||
strategy = ?strategy,
|
||||
"worker handler panicked — in-flight request lost, \
|
||||
no result will be delivered (#843)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue,
|
||||
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
@@ -159,3 +182,179 @@ impl<Req, Resp> Drop for BackgroundWorkerPool<Req, Resp> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests (T-1063): spawn/push/poll/shutdown lifecycle + worker-panic behavior.
|
||||
//
|
||||
// Every test also exercises Drop implicitly — a pool whose threads fail to
|
||||
// exit would hang the test binary at scope end.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Spin until `cond` holds, panicking after `WAIT`. Worker completion is
|
||||
/// timing-dependent (thread scheduling), so tests wait on observable
|
||||
/// state instead of sleeping fixed amounts.
|
||||
fn wait_for(what: &str, mut cond: impl FnMut() -> bool) {
|
||||
let start = Instant::now();
|
||||
while !cond() {
|
||||
if start.elapsed() > WAIT {
|
||||
panic!("timed out after {WAIT:?} waiting for: {what}");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_push_poll_round_trip() {
|
||||
let pool = BackgroundWorkerPool::spawn(2, |x: u32| x * 2);
|
||||
assert!(pool.poll().is_none(), "fresh pool has no results");
|
||||
assert!(!pool.has_results());
|
||||
assert_eq!(pool.pending_count(), 0);
|
||||
|
||||
pool.submit(WorkRequest {
|
||||
payload: 21,
|
||||
strategy: DeliveryStrategy::Fallback,
|
||||
});
|
||||
|
||||
wait_for("result ready", || pool.has_results());
|
||||
let (resp, strategy) = pool.poll().expect("has_results implies poll succeeds");
|
||||
assert_eq!(resp, 42);
|
||||
assert_eq!(strategy, DeliveryStrategy::Fallback);
|
||||
assert!(pool.poll().is_none(), "single request yields single result");
|
||||
assert_eq!(pool.pending_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_collects_all_results_and_preserves_strategies() {
|
||||
let pool = BackgroundWorkerPool::spawn(2, |x: u32| x + 1);
|
||||
for i in 0..10u32 {
|
||||
pool.submit(WorkRequest {
|
||||
payload: i,
|
||||
strategy: if i % 2 == 0 {
|
||||
DeliveryStrategy::GracefulDegrade
|
||||
} else {
|
||||
DeliveryStrategy::ModalLock
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let mut results: Vec<(u32, DeliveryStrategy)> = Vec::new();
|
||||
wait_for("all 10 results drained", || {
|
||||
results.extend(pool.drain());
|
||||
results.len() == 10
|
||||
});
|
||||
|
||||
// Two worker threads — completion order is not guaranteed. Each
|
||||
// request must come back exactly once, paired with its own strategy.
|
||||
results.sort_by_key(|(resp, _)| *resp);
|
||||
for (i, (resp, strategy)) in results.iter().enumerate() {
|
||||
assert_eq!(*resp, i as u32 + 1);
|
||||
let expected = if i % 2 == 0 {
|
||||
DeliveryStrategy::GracefulDegrade
|
||||
} else {
|
||||
DeliveryStrategy::ModalLock
|
||||
};
|
||||
assert_eq!(*strategy, expected, "strategy must travel with request {i}");
|
||||
}
|
||||
assert!(!pool.has_results(), "drain leaves the queue empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_stops_workers_and_drops_later_submissions() {
|
||||
let pool = BackgroundWorkerPool::spawn(2, |x: u32| x);
|
||||
pool.shutdown();
|
||||
wait_for("all workers exited", || {
|
||||
pool.workers.iter().all(|h| h.is_finished())
|
||||
});
|
||||
|
||||
// Once every worker has exited, all Receiver clones are gone (spawn
|
||||
// drops the original), so the request channel is disconnected:
|
||||
// submit() silently discards the request — by design (`let _ =` on
|
||||
// send). Nothing is queued and no result ever appears.
|
||||
pool.submit(WorkRequest {
|
||||
payload: 7,
|
||||
strategy: DeliveryStrategy::Fallback,
|
||||
});
|
||||
assert_eq!(
|
||||
pool.pending_count(),
|
||||
0,
|
||||
"post-shutdown submissions are silently dropped, not queued"
|
||||
);
|
||||
assert!(pool.poll().is_none());
|
||||
assert!(!pool.has_results());
|
||||
}
|
||||
|
||||
// -- Worker-panic behavior (T-1063) ----------------------------------------
|
||||
|
||||
/// Handler that panics on payload 0 and echoes anything else.
|
||||
fn poison_handler(x: u32) -> u32 {
|
||||
if x == 0 {
|
||||
panic!("poisoned request");
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_panic_does_not_kill_the_worker_thread() {
|
||||
// Single thread: if the panic killed it, the second request would
|
||||
// never be processed and this test would time out.
|
||||
let pool = BackgroundWorkerPool::spawn(1, poison_handler);
|
||||
pool.submit(WorkRequest {
|
||||
payload: 0, // poison
|
||||
strategy: DeliveryStrategy::ModalLock,
|
||||
});
|
||||
pool.submit(WorkRequest {
|
||||
payload: 5,
|
||||
strategy: DeliveryStrategy::Fallback,
|
||||
});
|
||||
|
||||
wait_for("post-panic result", || pool.has_results());
|
||||
let (resp, _) = pool.poll().expect("worker survived the panic");
|
||||
assert_eq!(resp, 5);
|
||||
assert!(
|
||||
!pool.workers[0].is_finished(),
|
||||
"worker thread must survive a handler panic"
|
||||
);
|
||||
}
|
||||
|
||||
/// KNOWN LIMITATION (#843): the panicked request is silently lost — no
|
||||
/// result is ever delivered for it and it is not re-enqueued. For a
|
||||
/// ModalLock consumer this means the player waits forever; heartbeat +
|
||||
/// re-enqueue (or downgrading ModalLock to an error path) must land
|
||||
/// before any ModalLock consumer ships (Phase 5). This test pins the
|
||||
/// current loss behavior so the eventual fix has to update it visibly.
|
||||
#[test]
|
||||
fn handler_panic_loses_the_inflight_request() {
|
||||
let pool = BackgroundWorkerPool::spawn(1, poison_handler);
|
||||
pool.submit(WorkRequest {
|
||||
payload: 0, // poison — would be the ModalLock request a player waits on
|
||||
strategy: DeliveryStrategy::ModalLock,
|
||||
});
|
||||
// Sentinel: once its result arrives, the poison request has
|
||||
// definitely been taken off the queue (single worker, FIFO channel).
|
||||
pool.submit(WorkRequest {
|
||||
payload: 99,
|
||||
strategy: DeliveryStrategy::Fallback,
|
||||
});
|
||||
|
||||
wait_for("sentinel result", || pool.has_results());
|
||||
let results = pool.drain();
|
||||
assert_eq!(
|
||||
results.len(),
|
||||
1,
|
||||
"only the sentinel completes — the poisoned request produced no result"
|
||||
);
|
||||
assert_eq!(results[0].0, 99);
|
||||
assert_eq!(
|
||||
pool.pending_count(),
|
||||
0,
|
||||
"the poisoned request is gone from the queue, not re-enqueued"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! The actual computation logic is a no-op placeholder — implementations
|
||||
//! plug in when the phases that need them arrive (Phase 5+).
|
||||
//!
|
||||
//! The infrastructure (channels, threads, push/poll) is real and tested.
|
||||
//! The infrastructure (channels, threads, push/poll) is real — unit tests
|
||||
//! live in `pool.rs` (T-1063). Known limitation (#843): a handler panic
|
||||
//! loses the in-flight request (no result is ever delivered, no re-enqueue);
|
||||
//! see the crash-behavior notes in `pool.rs`.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ GENERATOR_SOURCES: dict[str, list[Path]] = {
|
||||
REPO_ROOT / "tooling" / "economy-db" / "import_economics.py",
|
||||
REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs",
|
||||
REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "names.rs",
|
||||
# Shared surname corpus extracted from the two names.rs copies (T-1064).
|
||||
REPO_ROOT / "server" / "src" / "bin" / "shared" / "surname_corpus.rs",
|
||||
REPO_ROOT / "tooling" / "generate-brands",
|
||||
REPO_ROOT / "tooling" / "schema_version.py",
|
||||
# D-237 authored specialization layer data TOMLs (#1013). Keep in sync
|
||||
|
||||
@@ -54,13 +54,23 @@ impl Simulation {
|
||||
|
||||
let conn = db::open_db(&db_pathbuf);
|
||||
let economy = db::load_economy(&conn);
|
||||
Ok(Self::from_economy(economy, run_seed))
|
||||
}
|
||||
|
||||
/// Build a simulation directly from an in-memory [`db::Economy`] — no DB file.
|
||||
///
|
||||
/// Identical initialization to [`Simulation::load`] after the DB read:
|
||||
/// deterministic productivity/shadow seeding from `run_seed`, warm-start
|
||||
/// node states. Intended for unit tests and embedded callers that
|
||||
/// construct small, fully-controlled economies (T-1064).
|
||||
pub fn from_economy(economy: db::Economy, run_seed: u64) -> Self {
|
||||
let productivity = seed::seed_all_productivity(&economy, run_seed);
|
||||
let shadow = currency::seed_shadow_economy(&economy, run_seed);
|
||||
let adjacency = trade::build_adjacency(&economy);
|
||||
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
|
||||
let nodes = model::init_nodes(&economy);
|
||||
|
||||
Ok(Simulation {
|
||||
Simulation {
|
||||
economy,
|
||||
productivity,
|
||||
shadow,
|
||||
@@ -72,7 +82,7 @@ impl Simulation {
|
||||
tick: 0,
|
||||
alpha: model::ALPHA,
|
||||
beta: trade::BETA,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to load from the auto-detected DB path (same search as the CLI binary).
|
||||
|
||||
@@ -60,6 +60,8 @@ GENERATED_BRANDS_TOML = REPO_ROOT / "wiki" / "economics" / "corporations" / "gen
|
||||
# either must invalidate the meta stamp even though Python hasn't changed.
|
||||
GENERATE_BRANDS_RS = REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs"
|
||||
GENERATE_BRANDS_NAMES_RS = REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "names.rs"
|
||||
# Shared surname corpus extracted from the two names.rs copies (T-1064).
|
||||
GENERATE_BRANDS_SURNAMES_RS = REPO_ROOT / "server" / "src" / "bin" / "shared" / "surname_corpus.rs"
|
||||
GENERATE_BRANDS_WRAPPER = REPO_ROOT / "tooling" / "generate-brands"
|
||||
|
||||
|
||||
@@ -87,6 +89,7 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = (
|
||||
Path(__file__),
|
||||
GENERATE_BRANDS_RS,
|
||||
GENERATE_BRANDS_NAMES_RS,
|
||||
GENERATE_BRANDS_SURNAMES_RS,
|
||||
GENERATE_BRANDS_WRAPPER,
|
||||
REPO_ROOT / "tooling" / "schema_version.py",
|
||||
# D-237 authored specialization layer: these data TOMLs feed the DB, so a
|
||||
@@ -2155,8 +2158,8 @@ def _specialization_checks(conn, vocab, systems, strict):
|
||||
print(f" economic: {n_econ} authored ({n_inhabited} inhabited; rest on fallback once #1014 lands)")
|
||||
print(f" cultural: {n_cult} authored ({n_named} named; rest on corridor default)")
|
||||
print(f" faction: {n_fac} authored ({n_named} named; rest on derivation)")
|
||||
print(f" BulkClass: " + " | ".join(f"{k}={v}" for k, v in sorted(bulk_dist.items())))
|
||||
print(f" ProductionUbiquity: " + " | ".join(f"{k}={v}" for k, v in sorted(pu_dist.items())))
|
||||
print(" BulkClass: " + " | ".join(f"{k}={v}" for k, v in sorted(bulk_dist.items())))
|
||||
print(" ProductionUbiquity: " + " | ".join(f"{k}={v}" for k, v in sorted(pu_dist.items())))
|
||||
if warnings:
|
||||
print(f" Specialization warnings ({len(warnings)}):")
|
||||
for w in warnings:
|
||||
@@ -2363,10 +2366,10 @@ def main():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n Economics Import Pipeline")
|
||||
print("\n Economics Import Pipeline")
|
||||
print(f" DB: {db_path}")
|
||||
if args.dry_run:
|
||||
print(f" Mode: DRY RUN")
|
||||
print(" Mode: DRY RUN")
|
||||
print()
|
||||
|
||||
# Load wiki corps before opening DB — allows early exit on parse failures
|
||||
|
||||
Reference in New Issue
Block a user