From 0bd895fcacc7427d1f50eb6f705513267af2fb47 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 12 Jun 2026 16:22:28 +0200 Subject: [PATCH] =?UTF-8?q?chore(engine):=20server=20hygiene=20batch=20?= =?UTF-8?q?=E2=80=94=20workers=20tests,=20surname=20dedup,=20save=20pin=20?= =?UTF-8?q?(T-1063,=20T-1064)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .claude/skills/pr-process/SKILL.md | 1 + server/Cargo.lock | 1 - server/Cargo.toml | 20 +- server/src/bin/generate_brands/main.rs | 2 + server/src/bin/generate_brands/names.rs | 333 +---------------- server/src/bin/generate_corporations/main.rs | 17 + server/src/bin/generate_corporations/names.rs | 333 +---------------- server/src/bin/shared/surname_corpus.rs | 339 ++++++++++++++++++ server/src/perception/interpretation.rs | 3 + server/src/perception/query.rs | 3 + server/src/perception/shadowcast.rs | 3 + server/src/simulation/economy.rs | 254 +++++++++++++ server/src/simulation/mod.rs | 8 +- server/src/tick_phases.rs | 10 + server/src/workers/pool.rs | 217 ++++++++++- server/src/workers/stubs.rs | 5 +- tooling/check-systems-db-stamp | 2 + tooling/econ-sim/src/sim.rs | 14 +- tooling/economy-db/import_economics.py | 11 +- 19 files changed, 903 insertions(+), 673 deletions(-) create mode 100644 server/src/bin/shared/surname_corpus.rs diff --git a/.claude/skills/pr-process/SKILL.md b/.claude/skills/pr-process/SKILL.md index 85b6f9ef0..ab4e4f6c6 100644 --- a/.claude/skills/pr-process/SKILL.md +++ b/.claude/skills/pr-process/SKILL.md @@ -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/ \ diff --git a/server/Cargo.lock b/server/Cargo.lock index eac2ab22c..36c81a93a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1476,7 +1476,6 @@ dependencies = [ "bevy_app", "bevy_ecs", "bevy_tasks", - "bytemuck", "clap", "crossbeam-channel", "econ-sim", diff --git a/server/Cargo.toml b/server/Cargo.toml index d8f6dc1bc..0bdbbb625 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -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: diff --git a/server/src/bin/generate_brands/main.rs b/server/src/bin/generate_brands/main.rs index dd8099cc0..2510c91e1 100644 --- a/server/src/bin/generate_brands/main.rs +++ b/server/src/bin/generate_brands/main.rs @@ -32,6 +32,8 @@ use rusqlite::Connection; use serde::{Deserialize, Serialize}; mod names; +#[path = "../shared/surname_corpus.rs"] +mod surname_corpus; // --------------------------------------------------------------------------- // CLI diff --git a/server/src/bin/generate_brands/names.rs b/server/src/bin/generate_brands/names.rs index 1489b28f0..b3080f63a 100644 --- a/server/src/bin/generate_brands/names.rs +++ b/server/src/bin/generate_brands/names.rs @@ -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 { diff --git a/server/src/bin/generate_corporations/main.rs b/server/src/bin/generate_corporations/main.rs index b105deac0..f7836948f 100644 --- a/server/src/bin/generate_corporations/main.rs +++ b/server/src/bin/generate_corporations/main.rs @@ -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, } 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, diff --git a/server/src/bin/generate_corporations/names.rs b/server/src/bin/generate_corporations/names.rs index 2eff3d766..6e51f1d10 100644 --- a/server/src/bin/generate_corporations/names.rs +++ b/server/src/bin/generate_corporations/names.rs @@ -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 diff --git a/server/src/bin/shared/surname_corpus.rs b/server/src/bin/shared/surname_corpus.rs new file mode 100644 index 000000000..b9097674c --- /dev/null +++ b/server/src/bin/shared/surname_corpus.rs @@ -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", +]; diff --git a/server/src/perception/interpretation.rs b/server/src/perception/interpretation.rs index b8913e0fe..d7a978c77 100644 --- a/server/src/perception/interpretation.rs +++ b/server/src/perception/interpretation.rs @@ -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::*; diff --git a/server/src/perception/query.rs b/server/src/perception/query.rs index ed0b7b0fd..c9e5e95b1 100644 --- a/server/src/perception/query.rs +++ b/server/src/perception/query.rs @@ -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}; diff --git a/server/src/perception/shadowcast.rs b/server/src/perception/shadowcast.rs index 2b1b46155..cf112d4f9 100644 --- a/server/src/perception/shadowcast.rs +++ b/server/src/perception/shadowcast.rs @@ -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; diff --git a/server/src/simulation/economy.rs b/server/src/simulation/economy.rs index 4c6401dc4..9480bfc6e 100644 --- a/server/src/simulation/economy.rs +++ b/server/src/simulation/economy.rs @@ -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()))); + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 995f50ed2..220dda390 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -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), ); diff --git a/server/src/tick_phases.rs b/server/src/tick_phases.rs index da9705736..abd5d2146 100644 --- a/server/src/tick_phases.rs +++ b/server/src/tick_phases.rs @@ -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::)` 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::*; diff --git a/server/src/workers/pool.rs b/server/src/workers/pool.rs index 2376fd6dc..b84fd639c 100644 --- a/server/src/workers/pool.rs +++ b/server/src/workers/pool.rs @@ -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,9 +93,27 @@ 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() { - break; // response channel closed + // 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, @@ -159,3 +182,179 @@ impl Drop for BackgroundWorkerPool { } } } + +// --------------------------------------------------------------------------- +// 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" + ); + } +} diff --git a/server/src/workers/stubs.rs b/server/src/workers/stubs.rs index 626c44c52..216e56863 100644 --- a/server/src/workers/stubs.rs +++ b/server/src/workers/stubs.rs @@ -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::*; diff --git a/tooling/check-systems-db-stamp b/tooling/check-systems-db-stamp index 9aa3226ae..38c71c46d 100644 --- a/tooling/check-systems-db-stamp +++ b/tooling/check-systems-db-stamp @@ -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 diff --git a/tooling/econ-sim/src/sim.rs b/tooling/econ-sim/src/sim.rs index a183b260f..49fe90fcc 100644 --- a/tooling/econ-sim/src/sim.rs +++ b/tooling/econ-sim/src/sim.rs @@ -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). diff --git a/tooling/economy-db/import_economics.py b/tooling/economy-db/import_economics.py index a0798ce94..625c3ef5b 100755 --- a/tooling/economy-db/import_economics.py +++ b/tooling/economy-db/import_economics.py @@ -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