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:
2026-06-12 16:22:28 +02:00
co-authored by Claude Fable 5
parent c64231e8ee
commit 0bd895fcac
19 changed files with 903 additions and 673 deletions
+1
View File
@@ -235,6 +235,7 @@ git diff --name-only origin/main...HEAD -- \
tooling/planet-gen/import_province_boundaries.py \ tooling/planet-gen/import_province_boundaries.py \
server/src/bin/generate_brands/main.rs \ server/src/bin/generate_brands/main.rs \
server/src/bin/generate_brands/names.rs \ server/src/bin/generate_brands/names.rs \
server/src/bin/shared/surname_corpus.rs \
tooling/generate-brands \ tooling/generate-brands \
server/data/systems-schema.sql \ server/data/systems-schema.sql \
wiki/star-systems/ \ wiki/star-systems/ \
-1
View File
@@ -1476,7 +1476,6 @@ dependencies = [
"bevy_app", "bevy_app",
"bevy_ecs", "bevy_ecs",
"bevy_tasks", "bevy_tasks",
"bytemuck",
"clap", "clap",
"crossbeam-channel", "crossbeam-channel",
"econ-sim", "econ-sim",
+17 -3
View File
@@ -10,8 +10,9 @@ bevy_app = "0.18"
# multi_threaded enables Bevy's parallel system executor (no explicit import needed — # 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_ecs detects the feature on its bevy_tasks dependency at compile time).
bevy_tasks = { version = "0.18", features = ["multi_threaded"] } bevy_tasks = { version = "0.18", features = ["multi_threaded"] }
# par_iter infrastructure for data-parallel systems. Not yet called — candidate # Thread-pool/parallelism infrastructure. Used by the atlas generation worker
# systems marked with TODO comments. Active use begins when profiling shows bottlenecks. # pool (atlas/gen_queue.rs); data-parallel (par_iter) use in systems begins when
# profiling shows bottlenecks.
rayon = "1" rayon = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
ron = "0.8" ron = "0.8"
@@ -27,7 +28,6 @@ crossbeam-channel = "0.5"
sysinfo = "0.35" sysinfo = "0.35"
serde_json = "1" serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
bytemuck = "1"
png = "0.17" png = "0.17"
toml = "0.8" toml = "0.8"
aho-corasick = "1" aho-corasick = "1"
@@ -37,6 +37,20 @@ econ-sim = { path = "../tooling/econ-sim" }
[features] [features]
default = ["gauntlet"] default = ["gauntlet"]
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] [dev-dependencies]
# Maintained drop-in fork of the deprecated serde_yaml 0.9 (#966). Test-only: # Maintained drop-in fork of the deprecated serde_yaml 0.9 (#966). Test-only:
+2
View File
@@ -32,6 +32,8 @@ use rusqlite::Connection;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
mod names; mod names;
#[path = "../shared/surname_corpus.rs"]
mod surname_corpus;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CLI // CLI
+8 -325
View File
@@ -1,334 +1,17 @@
//! Deterministic product name generation for minor brand instances. //! Deterministic product name generation for minor brand instances.
//! //!
//! Reuses the corridor surname pools from generate_corporations/names.rs //! Reuses the corridor surname pools shared with generate_corporations
//! but combines them with category-specific product descriptors rather //! (shared/surname_corpus.rs) but combines them with category-specific
//! than business suffixes. Halo and volume tiers get distinct descriptor //! product descriptors rather than business suffixes. Halo and volume
//! pools so the output sounds differentiated. //! tiers get distinct descriptor pools so the output sounds differentiated.
use rand::prelude::*; use rand::prelude::*;
use rand_chacha::ChaCha8Rng; use rand_chacha::ChaCha8Rng;
// --------------------------------------------------------------------------- use crate::surname_corpus::{
// Surname pools (same corpus as generate_corporations/names.rs) CORE_NAMES, EAST_REACH_NAMES, FRONTIER_NAMES, NORTH_REACH_NAMES, SOUTH_REACH_NAMES,
// --------------------------------------------------------------------------- WEST_REACH_NAMES,
};
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",
];
fn names_for_corridor(corridor: &str) -> &'static [&'static str] { fn names_for_corridor(corridor: &str) -> &'static [&'static str] {
match corridor { match corridor {
@@ -28,6 +28,8 @@ use rusqlite::Connection;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
mod names; mod names;
#[path = "../shared/surname_corpus.rs"]
mod surname_corpus;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// CLI // CLI
@@ -66,6 +68,15 @@ struct Cli {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Archetype data structures (deserialized from TOML) // 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)] #[derive(Debug, Deserialize)]
@@ -91,6 +102,10 @@ struct LoreArchetype {
generation_notes: String, 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)] #[derive(Debug, Deserialize)]
struct BehavioralArchetype { struct BehavioralArchetype {
#[allow(dead_code)] #[allow(dead_code)]
@@ -135,12 +150,14 @@ struct Location {
system_id: String, system_id: String,
population: i64, population: i64,
geographic_sector: String, geographic_sector: String,
// Unused, but selecting it asserts the systems.db column exists (T-1064).
#[allow(dead_code)] #[allow(dead_code)]
economic_role: Option<String>, economic_role: Option<String>,
} }
struct ExistingCorp { struct ExistingCorp {
corp_id: String, corp_id: String,
// Unused, but selecting it asserts the systems.db column exists (T-1064).
#[allow(dead_code)] #[allow(dead_code)]
proper_name: String, proper_name: String,
specialization: Option<String>, specialization: Option<String>,
+6 -327
View File
@@ -3,6 +3,8 @@
//! Names are composed from culture-specific pools matching the Settled Reach's //! Names are composed from culture-specific pools matching the Settled Reach's
//! geographic sectors. Each sector has dominant cultural influences derived //! geographic sectors. Each sector has dominant cultural influences derived
//! from lore (wiki settlements, founding cultures, corridor identities). //! 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 //! Pattern: `{surname/word} {business_suffix}` where surname draws from
//! the sector's cultural pool and suffix from the lore category. //! the sector's cultural pool and suffix from the lore category.
@@ -10,333 +12,10 @@
use rand::prelude::*; use rand::prelude::*;
use rand_chacha::ChaCha8Rng; use rand_chacha::ChaCha8Rng;
// --------------------------------------------------------------------------- use crate::surname_corpus::{
// Surname pools by sector (drawn from founding cultures in wiki canon) CORE_NAMES, EAST_REACH_NAMES, FRONTIER_NAMES, NORTH_REACH_NAMES, SOUTH_REACH_NAMES,
// --------------------------------------------------------------------------- WEST_REACH_NAMES,
};
/// 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",
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Business suffix pools by lore category // Business suffix pools by lore category
+339
View File
@@ -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",
];
+3
View File
@@ -6,6 +6,9 @@
//! //!
//! Note: HashSet is used as a per-frame lookup table (visible tiles/NPCs). //! Note: HashSet is used as a per-frame lookup table (visible tiles/NPCs).
//! Only membership checks — iteration order is irrelevant. Not simulation state. //! 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)] #![allow(clippy::disallowed_types)]
use bevy_ecs::prelude::*; use bevy_ecs::prelude::*;
+3
View File
@@ -8,6 +8,9 @@
//! Note: HashMap is used for `sector_lookup` — a per-frame scratch buffer //! 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 //! looked up only by key. Iteration order is irrelevant here. Not subject to
//! the simulation determinism constraint (see server/.clippy.toml). //! 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)] #![allow(clippy::disallowed_types)]
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap};
+3
View File
@@ -14,6 +14,9 @@
//! tile positions during the FOV sweep. Only `insert` and `contains` are used; //! tile positions during the FOV sweep. Only `insert` and `contains` are used;
//! iteration order never affects the output (results are handed to BTreeSet in //! iteration order never affects the output (results are handed to BTreeSet in
//! query.rs). Not simulation state — exempt from the determinism constraint. //! 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)] #![allow(clippy::disallowed_types)]
use std::collections::HashSet; use std::collections::HashSet;
+254
View File
@@ -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())));
}
}
+7 -1
View File
@@ -107,10 +107,16 @@ impl Plugin for SimulationPlugin {
// save_load is an exclusive system (takes &mut World). // save_load is an exclusive system (takes &mut World).
// Exclusive systems in Bevy 0.18 cannot use .in_set() — use .before()/.after() // 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( app.add_systems(
Update, Update,
save_io::execute_save_load save_io::execute_save_load
.after(crate::tick_phases::TickPhase::Storyteller)
.before(crate::perception::observer::compute_observer_snapshot), .before(crate::perception::observer::compute_observer_snapshot),
); );
+10
View File
@@ -17,6 +17,16 @@
//! the system is in the wrong phase. //! the system is in the wrong phase.
//! 3. Intra-phase `.after()`/`.before()` is allowed for systems within //! 3. Intra-phase `.after()`/`.before()` is allowed for systems within
//! the same phase that have a real data dependency. //! 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_app::prelude::*;
use bevy_ecs::prelude::*; use bevy_ecs::prelude::*;
+208 -9
View File
@@ -5,12 +5,17 @@
//! //!
//! ## Worker crash behavior //! ## Worker crash behavior
//! //!
//! TODO (#843): If a worker thread panics, in-flight requests on that thread //! Handler panics are contained per-request (T-1063): the worker thread
//! are lost. The pool does NOT currently re-enqueue them. For Fallback-strategy //! catches the unwind, logs an error, and keeps serving the queue — a
//! workers (voice, LLM) this is acceptable — the tick loop uses the default. //! poisoned request cannot permanently shrink the pool. The in-flight
//! For ModalLock-strategy workers (chunk gen on gate transit), a lost request //! request is still LOST: no result is ever delivered for it.
//! means the player waits forever. Future: add heartbeat monitoring and //!
//! automatic re-enqueue on worker death. //! 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::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -88,9 +93,27 @@ where
match rx.recv_timeout(std::time::Duration::from_millis(100)) { match rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(work) => { Ok(work) => {
let strategy = work.strategy; let strategy = work.strategy;
let result = handler(work.payload); // Contain handler panics (T-1063): the thread must
if tx.send((result, strategy)).is_err() { // survive a poisoned request or the pool permanently
break; // response channel closed // 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::Timeout) => continue,
@@ -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 -1
View File
@@ -4,7 +4,10 @@
//! The actual computation logic is a no-op placeholder — implementations //! The actual computation logic is a no-op placeholder — implementations
//! plug in when the phases that need them arrive (Phase 5+). //! 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::*; use bevy_ecs::prelude::*;
+2
View File
@@ -50,6 +50,8 @@ GENERATOR_SOURCES: dict[str, list[Path]] = {
REPO_ROOT / "tooling" / "economy-db" / "import_economics.py", REPO_ROOT / "tooling" / "economy-db" / "import_economics.py",
REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs", REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs",
REPO_ROOT / "server" / "src" / "bin" / "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).
REPO_ROOT / "server" / "src" / "bin" / "shared" / "surname_corpus.rs",
REPO_ROOT / "tooling" / "generate-brands", REPO_ROOT / "tooling" / "generate-brands",
REPO_ROOT / "tooling" / "schema_version.py", REPO_ROOT / "tooling" / "schema_version.py",
# D-237 authored specialization layer data TOMLs (#1013). Keep in sync # D-237 authored specialization layer data TOMLs (#1013). Keep in sync
+12 -2
View File
@@ -54,13 +54,23 @@ impl Simulation {
let conn = db::open_db(&db_pathbuf); let conn = db::open_db(&db_pathbuf);
let economy = db::load_economy(&conn); 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 productivity = seed::seed_all_productivity(&economy, run_seed);
let shadow = currency::seed_shadow_economy(&economy, run_seed); let shadow = currency::seed_shadow_economy(&economy, run_seed);
let adjacency = trade::build_adjacency(&economy); let adjacency = trade::build_adjacency(&economy);
let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone()); let archetypes = agents::build_archetype_map(economy.corp_archetype_data.clone());
let nodes = model::init_nodes(&economy); let nodes = model::init_nodes(&economy);
Ok(Simulation { Simulation {
economy, economy,
productivity, productivity,
shadow, shadow,
@@ -72,7 +82,7 @@ impl Simulation {
tick: 0, tick: 0,
alpha: model::ALPHA, alpha: model::ALPHA,
beta: trade::BETA, beta: trade::BETA,
}) }
} }
/// Try to load from the auto-detected DB path (same search as the CLI binary). /// Try to load from the auto-detected DB path (same search as the CLI binary).
+7 -4
View File
@@ -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. # 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_RS = REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs"
GENERATE_BRANDS_NAMES_RS = REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "names.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" GENERATE_BRANDS_WRAPPER = REPO_ROOT / "tooling" / "generate-brands"
@@ -87,6 +89,7 @@ IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = (
Path(__file__), Path(__file__),
GENERATE_BRANDS_RS, GENERATE_BRANDS_RS,
GENERATE_BRANDS_NAMES_RS, GENERATE_BRANDS_NAMES_RS,
GENERATE_BRANDS_SURNAMES_RS,
GENERATE_BRANDS_WRAPPER, GENERATE_BRANDS_WRAPPER,
REPO_ROOT / "tooling" / "schema_version.py", REPO_ROOT / "tooling" / "schema_version.py",
# D-237 authored specialization layer: these data TOMLs feed the DB, so a # 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" 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" 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" 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(" 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(" ProductionUbiquity: " + " | ".join(f"{k}={v}" for k, v in sorted(pu_dist.items())))
if warnings: if warnings:
print(f" Specialization warnings ({len(warnings)}):") print(f" Specialization warnings ({len(warnings)}):")
for w in warnings: for w in warnings:
@@ -2363,10 +2366,10 @@ def main():
print(f"error: {db_path} not found", file=sys.stderr) print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1) sys.exit(1)
print(f"\n Economics Import Pipeline") print("\n Economics Import Pipeline")
print(f" DB: {db_path}") print(f" DB: {db_path}")
if args.dry_run: if args.dry_run:
print(f" Mode: DRY RUN") print(" Mode: DRY RUN")
print() print()
# Load wiki corps before opening DB — allows early exit on parse failures # Load wiki corps before opening DB — allows early exit on parse failures