diff --git a/decisions/perception.md b/decisions/perception.md index 1ab7bea96..e645a8051 100644 --- a/decisions/perception.md +++ b/decisions/perception.md @@ -283,9 +283,9 @@ How the player observes and interacts with the world: camera, fog, line-of-sight ### D-061: Dialogue box — unified conversation log, bottom screen, max 20% height, no portraits - **Date:** 2026-02-13 - **Decision:** Dialogue occupies the bottom of the screen, max 20% height, max-width 1200px ([D-076](#d-076-dialogue-box-max-width--1200px-oq-29-resolution)). NO portraits — the NPC is on screen, a portrait is redundant. Monologue floats ABOVE the dialogue box on z-layer 7 — spatial separation allows monologue to contradict dialogue visually (character thinks one thing while NPC says another). Walk-away via WASD, dialogue fades over 300ms, no close button ([D-064](content.md#d-064-walk-away--three-phase-consequences)). Auto-pause in single-player when implant UI is open; overlay design for multiplayer readiness. -- **Unified conversation log (Sprint 14, #535):** The dialogue box is a single chronological log — not separate panels for active vs passive dialogue. Player-NPC conversations and overheard NPC-NPC conversations ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter)) flow into the same scrolling log. Each entry shows `Speaker → Target: text` with per-character name colours (hash-indexed from configurable palette in `data/dialogue-theme.yaml`). Player response options render below the log; max 3 visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)). +- **Unified conversation log (Sprint 14, #535):** The dialogue box is a single chronological log for player-NPC conversations. Each entry shows `Speaker → Target: text` with per-character name colours (hash-indexed from configurable palette in `data/dialogue-theme.yaml`). Player response options render below the log; max 3 visible. Locked options invisible ([D-062](content.md#d-062-invisible-locked-dialogue-options)). - **Entry lifecycle:** All entries share the same timeout (15s + 3s fade, configurable via theme YAML). Walk-away clears response options but preserves log entries — earned information is fair game. Panel auto-hides when all entries expire and no active conversation is in progress. -- **Passive overheard lines:** Rendered at reduced opacity (0.9) per [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter). No response options for overheard content. Walk-away does not fire for passive-only display — the panel dismisses naturally when entries expire or the player walks out of earshot. +- **Passive overheard lines:** D-078 (overheard NPC conversations) was scrapped per R-012. Passive dialogue display will be redesigned after Phase 5 walkable environment. - **Rationale:** Game world stays live above the dialogue box — player sees NPC body language while talking. Monologue above + dialogue below = the character can think one thing while saying another. Max 3 options + invisible locks = player never knows what they're missing. No portrait because the NPC IS on screen. A single unified log avoids a separate UI element for overheard content and makes the flow of conversation feel natural — active and passive dialogue interleave chronologically. - **Cross-reference:** Invisible locks ([D-062](content.md#d-062-invisible-locked-dialogue-options)), confrontation ([D-063](content.md#d-063-confrontation--same-box-different-weight)), walk-away ([D-064](content.md#d-064-walk-away--three-phase-consequences)), z-stack ([D-049](#d-049-z-level-rendering-stack-8-layers)), max-width ([D-076](#d-076-dialogue-box-max-width--1200px-oq-29-resolution)), overheard NPC conversation ([D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter)) - **Source:** Control & Interaction Workshop (2026-02-13). Amended Sprint 14 (#535): unified log architecture. @@ -424,7 +424,7 @@ How the player observes and interacts with the world: camera, fog, line-of-sight - **Raised by:** Workshop — unanimous - **Dissent:** None - **Implements:** Ticket #548 -- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-083](#d-083-contradiction-detection-pipeline), [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter) +- **Cross-reference:** [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-083](#d-083-contradiction-detection-pipeline), [D-078](#d-078-overheard-npc-conversation--passive-dialogue-panel-with-occlusion-filter) (D-078 scrapped per R-012; knowledge propagation system remains active independently) ### D-081: Unprompted Disclosure Design - **Date:** 2026-02-24 diff --git a/server/src/bin/generate_brands/main.rs b/server/src/bin/generate_brands/main.rs index dde9831d9..afa4181ae 100644 --- a/server/src/bin/generate_brands/main.rs +++ b/server/src/bin/generate_brands/main.rs @@ -279,18 +279,14 @@ fn corp_eligible(corp: &Corp, template_scale: &str, template_corridor: &str) -> // Match sector to corridor let sector = corp.geographic_sector.as_deref().unwrap_or("core"); - let corridor_matches_sector = match template_corridor { + match template_corridor { "north_reach" | "north_reach/compact" => sector == "north_reach", "south_reach" => sector == "south_reach", "west_reach" | "west_reach/compact" => sector == "west_reach", - "east_reach" | "inner_corridor/east_reach" => { - sector == "east_reach" || sector == "core" - } + "east_reach" | "inner_corridor/east_reach" => sector == "east_reach" || sector == "core", "frontier" => sector == "deep_frontier", _ => false, - }; - - corridor_matches_sector + } } // --------------------------------------------------------------------------- @@ -308,13 +304,7 @@ fn scale_abbrev(scale_tier: &str) -> &'static str { fn to_slug(s: &str) -> String { s.chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c - } else { - '-' - } - }) + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect::() .split('-') .filter(|p| !p.is_empty()) @@ -371,8 +361,16 @@ fn generate_pair( valid_commodities: &BTreeSet, ) -> Option { let corridor = parse_template_corridor(&template.naming_pattern); - let halo_bid = halo_id(&corp.corp_id, &template.archetype_group, &template.scale_tier); - let vol_bid = volume_id(&corp.corp_id, &template.archetype_group, &template.scale_tier); + let halo_bid = halo_id( + &corp.corp_id, + &template.archetype_group, + &template.scale_tier, + ); + let vol_bid = volume_id( + &corp.corp_id, + &template.archetype_group, + &template.scale_tier, + ); let halo_name = names::generate_halo_name(rng, corridor, &template.brand_category); let vol_name = names::generate_volume_name(rng, corridor, &template.brand_category); @@ -493,16 +491,16 @@ fn round2(v: f64) -> f64 { // Output serialization // --------------------------------------------------------------------------- -fn write_output( - path: &PathBuf, - products: &[BrandProduct], - inputs: &[BrandInput], -) { +fn write_output(path: &PathBuf, products: &[BrandProduct], inputs: &[BrandInput]) { let mut out = String::new(); out.push_str("# Generated Minor Brand Products — The Settled Reach\n"); out.push_str("# Auto-generated by generate_brands binary. Do not hand-edit.\n"); out.push_str("# Re-run: tooling/generate-brands\n"); - out.push_str(&format!("# Total: {} brand_products, {} brand_inputs\n\n", products.len(), inputs.len())); + out.push_str(&format!( + "# Total: {} brand_products, {} brand_inputs\n\n", + products.len(), + inputs.len() + )); for p in products { out.push_str("[[brand_products]]\n"); @@ -524,7 +522,10 @@ fn write_output( out.push_str(&format!("origin_system = {:?}\n", sys)); } out.push_str(&format!("terroir_locked = {}\n", p.terroir_locked)); - out.push_str(&format!("currency_denomination = {:?}\n", p.currency_denomination)); + out.push_str(&format!( + "currency_denomination = {:?}\n", + p.currency_denomination + )); out.push_str(&format!("shadow_viable = {}\n", p.shadow_viable)); out.push_str(&format!("brand_tier = {:?}\n", p.brand_tier)); if let Some(ref hid) = p.halo_brand_id { @@ -578,7 +579,10 @@ fn print_coverage(products: &[BrandProduct]) { let min_per_corp = by_corp.values().min().copied().unwrap_or(0); let max_per_corp = by_corp.values().max().copied().unwrap_or(0); println!(" Corps covered: {}", by_corp.len()); - println!(" Brands per corp: min={} max={}", min_per_corp, max_per_corp); + println!( + " Brands per corp: min={} max={}", + min_per_corp, max_per_corp + ); } // --------------------------------------------------------------------------- @@ -628,7 +632,11 @@ fn main() { println!(" [2/5] Loading corporations and commodities..."); let corps = load_corps(&conn); let valid_commodities = load_valid_commodity_ids(&conn); - println!(" {} corps, {} valid commodities", corps.len(), valid_commodities.len()); + println!( + " {} corps, {} valid commodities", + corps.len(), + valid_commodities.len() + ); // Generate brand pairs println!(" [3/5] Generating brand pairs..."); @@ -640,12 +648,24 @@ fn main() { for (template_key, template) in &templates { for corp in &corps { - if !corp_eligible(corp, &template.scale_tier, parse_template_corridor(&template.naming_pattern)) { + if !corp_eligible( + corp, + &template.scale_tier, + parse_template_corridor(&template.naming_pattern), + ) { continue; } - let halo_bid = halo_id(&corp.corp_id, &template.archetype_group, &template.scale_tier); - let vol_bid = volume_id(&corp.corp_id, &template.archetype_group, &template.scale_tier); + let halo_bid = halo_id( + &corp.corp_id, + &template.archetype_group, + &template.scale_tier, + ); + let vol_bid = volume_id( + &corp.corp_id, + &template.archetype_group, + &template.scale_tier, + ); // Skip if IDs already generated (name-collision guard) if seen_ids.contains(&halo_bid) || seen_ids.contains(&vol_bid) { @@ -653,7 +673,9 @@ fn main() { continue; } - if let Some(pair) = generate_pair(&mut rng, corp, template_key, template, &valid_commodities) { + if let Some(pair) = + generate_pair(&mut rng, corp, template_key, template, &valid_commodities) + { seen_ids.insert(halo_bid); seen_ids.insert(vol_bid); products.push(pair.halo); @@ -664,7 +686,11 @@ fn main() { } } - println!(" {} brand_products generated ({} skipped)", products.len(), skipped); + println!( + " {} brand_products generated ({} skipped)", + products.len(), + skipped + ); // Gap-fill: if under target, add more by re-applying reach_wide templates // with a counter suffix to avoid ID collisions. @@ -679,7 +705,8 @@ fn main() { let mut template_idx = 0usize; while products.len() < cli.min_brands && !reach_wide_templates.is_empty() { - let (template_key, template) = reach_wide_templates[template_idx % reach_wide_templates.len()]; + let (template_key, template) = + reach_wide_templates[template_idx % reach_wide_templates.len()]; let corp = &corps[counter % corps.len()]; counter += 1; @@ -704,7 +731,9 @@ fn main() { continue; } - if let Some(pair) = generate_pair(&mut rng, corp, template_key, template, &valid_commodities) { + if let Some(pair) = + generate_pair(&mut rng, corp, template_key, template, &valid_commodities) + { let mut halo = pair.halo; let mut volume = pair.volume; halo.brand_product_id = halo_bid.clone(); @@ -713,8 +742,12 @@ fn main() { let mut inputs_halo = pair.inputs_halo; let mut inputs_volume = pair.inputs_volume; - for i in &mut inputs_halo { i.brand_product_id = halo_bid.clone(); } - for i in &mut inputs_volume { i.brand_product_id = vol_bid.clone(); } + for i in &mut inputs_halo { + i.brand_product_id = halo_bid.clone(); + } + for i in &mut inputs_volume { + i.brand_product_id = vol_bid.clone(); + } seen_ids.insert(halo_bid); seen_ids.insert(vol_bid); @@ -741,7 +774,11 @@ fn main() { // Write output println!("\n [5/5] Writing output..."); write_output(&output_path, &products, &inputs); - println!(" {} brand_products, {} brand_inputs", products.len(), inputs.len()); + println!( + " {} brand_products, {} brand_inputs", + products.len(), + inputs.len() + ); println!(" Output: {}", output_path.display()); println!(" Done.\n"); } diff --git a/server/src/bin/generate_brands/names.rs b/server/src/bin/generate_brands/names.rs index 5aa2f2297..1489b28f0 100644 --- a/server/src/bin/generate_brands/names.rs +++ b/server/src/bin/generate_brands/names.rs @@ -13,60 +13,321 @@ use rand_chacha::ChaCha8Rng; // --------------------------------------------------------------------------- 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", + "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", + "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", + "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", + "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", + "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", + "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] { @@ -85,83 +346,206 @@ fn names_for_corridor(corridor: &str) -> &'static [&'static str] { // --------------------------------------------------------------------------- const TERROIR_HALO: &[&str] = &[ - "Reserve", "Single", "Estate", "Vintage", "Heritage", "Grand", "Limited", "Prestige", - "Signature", "Cellar", "Select", "Cru", "Premier", "Old", "Aged", + "Reserve", + "Single", + "Estate", + "Vintage", + "Heritage", + "Grand", + "Limited", + "Prestige", + "Signature", + "Cellar", + "Select", + "Cru", + "Premier", + "Old", + "Aged", ]; const TERROIR_VOLUME: &[&str] = &[ - "Standard", "Export", "Blend", "Classic", "Field", "Ordinary", "Running", "Table", - "Regular", "Common", "House", "Station", "Corridor", "Transit", + "Standard", "Export", "Blend", "Classic", "Field", "Ordinary", "Running", "Table", "Regular", + "Common", "House", "Station", "Corridor", "Transit", ]; const HERITAGE_CRAFT_HALO: &[&str] = &[ - "Heritage", "Limited", "Artisan", "Master", "Guild", "Prestige", "Premium", "Classic", - "Signature", "Original", "Bespoke", "Traditional", "First", + "Heritage", + "Limited", + "Artisan", + "Master", + "Guild", + "Prestige", + "Premium", + "Classic", + "Signature", + "Original", + "Bespoke", + "Traditional", + "First", ]; const HERITAGE_CRAFT_VOLUME: &[&str] = &[ - "Standard", "Classic", "Working", "Everyday", "Regular", "Plain", "Field", "Grade", - "Basic", "Workshop", "Studio", "Common", + "Standard", "Classic", "Working", "Everyday", "Regular", "Plain", "Field", "Grade", "Basic", + "Workshop", "Studio", "Common", ]; const TECH_PREMIUM_HALO: &[&str] = &[ - "Elite", "Pro", "Advanced", "Precision", "Superior", "Grand", "Signature", "First", - "Prime", "Expert", "Master", "Apex", "Summit", + "Elite", + "Pro", + "Advanced", + "Precision", + "Superior", + "Grand", + "Signature", + "First", + "Prime", + "Expert", + "Master", + "Apex", + "Summit", ]; const TECH_PREMIUM_VOLUME: &[&str] = &[ - "Standard", "Series", "Base", "Classic", "Regular", "Field", "Grade", "Value", - "Essential", "Core", "Basic", + "Standard", + "Series", + "Base", + "Classic", + "Regular", + "Field", + "Grade", + "Value", + "Essential", + "Core", + "Basic", ]; const CULTURAL_HALO: &[&str] = &[ - "Archive", "Heritage", "Classic", "Definitive", "Prestige", "Limited", "Master", - "Collected", "Curated", "Canonical", "Flagship", "Grand", + "Archive", + "Heritage", + "Classic", + "Definitive", + "Prestige", + "Limited", + "Master", + "Collected", + "Curated", + "Canonical", + "Flagship", + "Grand", ]; const CULTURAL_VOLUME: &[&str] = &[ - "Standard", "Classic", "Essential", "Value", "Regular", "Base", "Field", - "Running", "Everyday", "Popular", + "Standard", + "Classic", + "Essential", + "Value", + "Regular", + "Base", + "Field", + "Running", + "Everyday", + "Popular", ]; const SERVICE_PREMIUM_HALO: &[&str] = &[ - "Premier", "Elite", "Priority", "Signature", "Prestige", "Grand", "First", "Select", - "Platinum", "Gold", "Senior", "Executive", + "Premier", + "Elite", + "Priority", + "Signature", + "Prestige", + "Grand", + "First", + "Select", + "Platinum", + "Gold", + "Senior", + "Executive", ]; const SERVICE_PREMIUM_VOLUME: &[&str] = &[ - "Standard", "Basic", "Classic", "Regular", "Field", "Value", "General", "Common", - "Ordinary", "Essential", + "Standard", + "Basic", + "Classic", + "Regular", + "Field", + "Value", + "General", + "Common", + "Ordinary", + "Essential", ]; const COMMODITY_BRANDED_HALO: &[&str] = &[ - "Original", "Select", "Premium", "Reserve", "Classic", "Heritage", "Signature", - "Superior", "First", "Grade", "Certified", + "Original", + "Select", + "Premium", + "Reserve", + "Classic", + "Heritage", + "Signature", + "Superior", + "First", + "Grade", + "Certified", ]; const COMMODITY_BRANDED_VOLUME: &[&str] = &[ - "Standard", "Basic", "Regular", "Field", "Value", "Economy", "Bulk", "Run", - "Common", "Grade", "Plain", + "Standard", "Basic", "Regular", "Field", "Value", "Economy", "Bulk", "Run", "Common", "Grade", + "Plain", ]; const DESIGN_HERITAGE_HALO: &[&str] = &[ - "Heritage", "Limited", "Prestige", "Grand", "Signature", "Edition", "Series", - "Classic", "Archive", "Collector", "Retrospective", + "Heritage", + "Limited", + "Prestige", + "Grand", + "Signature", + "Edition", + "Series", + "Classic", + "Archive", + "Collector", + "Retrospective", ]; const DESIGN_HERITAGE_VOLUME: &[&str] = &[ - "Standard", "Classic", "Regular", "Field", "Value", "Base", "Essential", - "Running", "Contemporary", "Current", + "Standard", + "Classic", + "Regular", + "Field", + "Value", + "Base", + "Essential", + "Running", + "Contemporary", + "Current", ]; const PLATFORM_CATALOGUE_HALO: &[&str] = &[ - "Premium", "Pro", "Plus", "Elite", "Advanced", "Signature", "Select", "Grand", - "Unlimited", "Complete", "Full", + "Premium", + "Pro", + "Plus", + "Elite", + "Advanced", + "Signature", + "Select", + "Grand", + "Unlimited", + "Complete", + "Full", ]; const PLATFORM_CATALOGUE_VOLUME: &[&str] = &[ - "Standard", "Basic", "Classic", "Regular", "Field", "Value", "Entry", "Lite", - "Essential", "Free", + "Standard", + "Basic", + "Classic", + "Regular", + "Field", + "Value", + "Entry", + "Lite", + "Essential", + "Free", ]; fn halo_descriptors(brand_category: &str) -> &'static [&'static str] { diff --git a/server/src/bookmark/mod.rs b/server/src/bookmark/mod.rs index 0cb2fbc5a..de591333d 100644 --- a/server/src/bookmark/mod.rs +++ b/server/src/bookmark/mod.rs @@ -14,7 +14,7 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use crate::bridge::types::{BookmarkCatalog, BookmarkWire, CareerKindWire, SnapshotBuffer}; -use crate::knowledge::{CultureResolver, resolve_culture}; +use crate::knowledge::{resolve_culture, CultureResolver}; /// Immutable registry of bookmark definitions. /// @@ -190,7 +190,10 @@ mod tests { assert_eq!(wire.id, "tycoon"); assert_eq!(wire.career, CareerKindWire::Tycoon); assert!(!wire.default_location.is_empty()); - assert_eq!(wire.allowed_locations.len(), wire.allowed_locations_cultures.len()); + assert_eq!( + wire.allowed_locations.len(), + wire.allowed_locations_cultures.len() + ); } #[test] @@ -216,6 +219,8 @@ mod tests { let r = make_registry(); let defn = r.get("tycoon").unwrap(); assert!(defn.allowed_locations.contains(&"GJ 35".to_string())); - assert!(!defn.allowed_locations.contains(&"Unknown System".to_string())); + assert!(!defn + .allowed_locations + .contains(&"Unknown System".to_string())); } } diff --git a/server/src/bridge/debug.rs b/server/src/bridge/debug.rs index dd5287cab..ad3cbb4ba 100644 --- a/server/src/bridge/debug.rs +++ b/server/src/bridge/debug.rs @@ -14,9 +14,9 @@ use crate::bridge::types::{ }; use crate::knowledge::EntityRegistry; use crate::npc::Npc; -use crate::simulation::conversation::NpcName; use crate::simulation::economy::{EconSimResource, EconStateResource}; use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::npc_components::NpcName; use crate::simulation::tier::ActiveSim; use crate::simulation::time::SimulationTime; use crate::simulation::triangle::TriangleState; diff --git a/server/src/bridge/mod.rs b/server/src/bridge/mod.rs index b37d5e24d..d26099ccc 100644 --- a/server/src/bridge/mod.rs +++ b/server/src/bridge/mod.rs @@ -244,7 +244,6 @@ impl Plugin for BridgePlugin { crate::simulation::monologue::trigger_event_monologue .after(crate::simulation::monologue::process_sprint_anomaly_monologue) .after(crate::simulation::sound::collect_sound_events) - .after(crate::simulation::conversation::run_npc_conversations) .after(crate::simulation::dialogue::process_walk_away), crate::simulation::monologue::process_contradiction_monologue .after(crate::simulation::monologue::trigger_event_monologue), diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index 6cd25b552..bbe7c05d2 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -304,8 +304,7 @@ mod tests { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, sound_events: vec![], @@ -447,8 +446,7 @@ mod tests { dialogue_response: None, blocked_entities: vec![], scan_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, sound_events: vec![], diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index 3b0e0b65f..85b38b8b4 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -17,7 +17,7 @@ pub use crate::simulation::time::{DayPhase, TickRate}; /// negotiation is unnecessary. Client should reject snapshots with version != /// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration /// period, then the default is removed once both sides are updated. -pub const PROTOCOL_VERSION: u8 = 22; +pub const PROTOCOL_VERSION: u8 = 23; /// Handshake message sent as the very first framed message after connection (#555). /// Client reads this before entering the normal tick loop and validates @@ -85,6 +85,7 @@ pub struct StartupMessage { /// EconStateQuery PlayerAction variant (#822). /// v22 adds: bookmark_catalog (#614, D-115/D-117 CK3-style bookmark system), /// RequestBookmarkCatalog + ConfirmBookmark PlayerAction variants (#614). +/// v23 removes: conversation_events, conversation_ended (D-078 scrapped per R-012). /// Future fields: ambient sound events, HUD state (D-020 expansion). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObserverSnapshot { @@ -142,15 +143,6 @@ pub struct ObserverSnapshot { /// Empty when no sounds are in range. #[serde(default)] pub sound_events: Vec, - /// Overheard NPC-to-NPC conversation lines this tick (#247, D-078). - /// Each event carries pre-occluded text — client renders verbatim. - /// Empty when no conversations are overheard. - #[serde(default)] - pub conversation_events: Vec, - /// Conversations that ended this tick (#247, D-078). - /// Client dismisses the passive dialogue panel for these pairs. - #[serde(default)] - pub conversation_ended: Vec, /// Follow-mode state for client HUD display (#241). /// Present when the player is actively following an NPC. /// Client shows follow indicator with distance, LOS, and tension. diff --git a/server/src/knowledge/culture.rs b/server/src/knowledge/culture.rs index f38cadbf5..fb0df84b8 100644 --- a/server/src/knowledge/culture.rs +++ b/server/src/knowledge/culture.rs @@ -92,7 +92,10 @@ pub struct CultureResolverResource(pub CultureResolver); /// - `CultureError::UnknownLocation` — not found in any table. /// - `CultureError::NoCulture` — found but culture column is NULL (data bug). /// - `CultureError::Db` — SQLite I/O failure. -pub fn resolve_culture(resolver: &CultureResolver, location_id: &str) -> Result { +pub fn resolve_culture( + resolver: &CultureResolver, + location_id: &str, +) -> Result { let conn = resolver .conn .lock() @@ -187,8 +190,8 @@ mod tests { use super::*; fn fixture_db() -> CultureResolver { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/knowledge/fixtures/culture_test.db"); + let path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("src/knowledge/fixtures/culture_test.db"); CultureResolver::open(&path).expect("open fixture DB") } @@ -257,8 +260,8 @@ mod tests { use std::sync::Arc; use std::thread; - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("src/knowledge/fixtures/culture_test.db"); + let path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("src/knowledge/fixtures/culture_test.db"); let r = Arc::new(CultureResolver::open(&path).expect("open")); let handles: Vec<_> = (0..4) diff --git a/server/src/knowledge/events.rs b/server/src/knowledge/events.rs index e131bd6af..a43c56845 100644 --- a/server/src/knowledge/events.rs +++ b/server/src/knowledge/events.rs @@ -185,7 +185,7 @@ pub fn process_knowledge_events( mut queue: ResMut, mut contradiction_queue: ResMut, registry: Res, - npc_names: Query<&crate::simulation::conversation::NpcName>, + npc_names: Query<&crate::simulation::npc_components::NpcName>, mut knowledge_query: Query<&mut KnowledgeGraph>, ) { let events = queue.drain(); diff --git a/server/src/knowledge/mod.rs b/server/src/knowledge/mod.rs index c6aa7a4fd..46bd3eaf1 100644 --- a/server/src/knowledge/mod.rs +++ b/server/src/knowledge/mod.rs @@ -15,7 +15,9 @@ pub mod registry; pub mod types; pub use content_registry::ContentEntityRegistry; -pub use culture::{CultureError, CultureResolver, CultureResolverResource, CultureTag, resolve_culture}; +pub use culture::{ + resolve_culture, CultureError, CultureResolver, CultureResolverResource, CultureTag, +}; pub use events::{ ContradictionDetectedEvent, ContradictionDetectedQueue, InteractionType, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType, ProcessedEntityGrant, ProcessedFactGrant, diff --git a/server/src/main.rs b/server/src/main.rs index f9452fd98..c07ba57cc 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -161,7 +161,9 @@ fn main() { match settled_reach_server::knowledge::CultureResolver::open(&systems_db_path) { Ok(resolver) => { tracing::info!("Culture resolver opened: {:?}", systems_db_path); - app.insert_resource(settled_reach_server::knowledge::CultureResolverResource(resolver)); + app.insert_resource(settled_reach_server::knowledge::CultureResolverResource( + resolver, + )); } Err(e) => { tracing::warn!( @@ -343,8 +345,6 @@ fn send_panic_error(app: &App, panic_msg: &str) { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], follow_state: None, character_pressure: None, rng_seed: None, diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index 3d4eedc21..950e2dd89 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -17,7 +17,6 @@ use crate::perception::cognitive_delay::CognitiveDelay; use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry}; use crate::perception::vision_cone::Facing; use crate::simulation::contraband::ScanEventBuffer; -use crate::simulation::conversation::ConversationEventBuffer; use crate::simulation::dialogue::DialogueResponseBuffer; use crate::simulation::examine::ExamineResultBuffer; use crate::simulation::follow::FollowTarget; @@ -84,7 +83,6 @@ pub fn compute_observer_snapshot( Option<&CognitiveDelay>, Option<&mut DialogueResponseBuffer>, Option<&mut ScanEventBuffer>, - Option<&mut ConversationEventBuffer>, Option<&FollowTarget>, Option<&mut ExamineResultBuffer>, ), @@ -121,7 +119,6 @@ pub fn compute_observer_snapshot( cognitive_delay_opt, mut dialogue_response_opt, mut scan_event_buffer_opt, - mut conversation_buffer_opt, follow_target_opt, mut examine_result_buffer_opt, )) = observer_query.single_mut() @@ -225,12 +222,6 @@ pub fn compute_observer_snapshot( .map(|buf| buf.take()) .unwrap_or_default(); - // Drain NPC-to-NPC conversation events (#247, D-078) - let (conversation_events, conversation_ended) = conversation_buffer_opt - .as_mut() - .map(|buf| (buf.take_events(), buf.take_ended())) - .unwrap_or_default(); - // Collect sound events audible to the observer (D-038, #124). // Filter by D-018 range: only events the player can hear based on distance. let sound_events = if let Some(ref queue) = sound_queue { @@ -476,8 +467,6 @@ pub fn compute_observer_snapshot( dialogue_response, blocked_entities, scan_events, - conversation_events, - conversation_ended, follow_state, character_pressure: pressure_query .iter() diff --git a/server/src/simulation/conversation.rs b/server/src/simulation/conversation.rs deleted file mode 100644 index 4d1048041..000000000 --- a/server/src/simulation/conversation.rs +++ /dev/null @@ -1,1440 +0,0 @@ -//! NPC-to-NPC conversation system (#247, D-078). -//! -//! NPCs in the Active tier who are in proximity (≤3 tiles) and share a social -//! site occasionally enter conversations. Conversations emit Voice SoundEvents -//! and produce ConversationEvents with server-authoritative per-word occlusion -//! for the player's ObserverSnapshot. -//! -//! Per-word occlusion algorithm (D-078): -//! For each word, an independent Bernoulli trial determines whether the player -//! hears it. Drop probability = f(distance, ambient_noise, listening_focus). -//! Words that fail are replaced with "..." in `occluded_line`. -//! All arithmetic uses integer percentages (0-100) for D-010 determinism. - -use bevy_ecs::prelude::*; -use rand::Rng; -use serde::{Deserialize, Serialize}; - -use crate::knowledge::registry::StableEntityId; -use crate::knowledge::types::SoundRange; -use crate::knowledge::EntityRegistry; -use crate::knowledge::KnowledgeGraph; -use crate::npc::Npc; -use crate::simulation::dialogue::DialogueProfile; -use crate::simulation::listening::ListeningFocus; -use crate::simulation::movement::{PlayerCharacter, TilePosition}; -use crate::simulation::rng::SimRng; -use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind}; -use crate::simulation::tier::ActiveSim; -use crate::simulation::time::SimulationTime; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/// Maximum tile distance for two NPCs to start a conversation. -const CONVERSATION_PROXIMITY: u32 = 3; - -/// Minimum conversation duration in ticks (3 game-minutes at 10 ticks/min). -const MIN_DURATION_TICKS: u64 = 30; - -/// Maximum conversation duration in ticks (12 game-minutes). -const MAX_DURATION_TICKS: u64 = 120; - -/// Cooldown ticks before an NPC can enter another conversation. -/// 5 game-minutes = 50 ticks. -pub(crate) const CONVERSATION_COOLDOWN_TICKS: u64 = 50; - -/// Chance (0-100) per tick that an eligible NPC pair starts a conversation. -/// Low to prevent every pair chatting every tick. ~2% per tick. -const CONVERSATION_CHANCE_PERCENT: u32 = 2; - -/// Voice sound range boundary in tiles (D-018 Medium = 8). -const VOICE_RANGE_TILES: u32 = 8; - -/// Ticks between conversation lines (~2 game-minutes at 10 ticks/min). -const LINE_INTERVAL_TICKS: u64 = 20; - -// --------------------------------------------------------------------------- -// Components -// --------------------------------------------------------------------------- - -/// Display name for an NPC, used on the wire for conversation events. -/// Attached during content spawn. -#[derive(Component, Debug, Clone, Serialize, Deserialize)] -pub struct NpcName(pub String); - -/// Map a dialogue role string to a display label for use when the player -/// does not yet know the NPC's real name. -pub fn display_label_for_role(role: &str) -> String { - match role { - "dock-worker" => "Dock Worker", - "courier" => "Courier", - "maintenance-tech" => "Technician", - "new-hire" | "day-worker" | "transit-worker" => "Worker", - "scheduler" => "Scheduler", - "shift-supervisor" => "Supervisor", - "bartender" => "Bartender", - "bar-regular" => "Patron", - _ => "Bystander", - } - .to_string() -} - -/// Color index (0-7) for rendering this NPC with a distinct color in the -/// conversation log. Assigned at spawn time as `(stable_id % 8)`. -#[derive(Component, Debug, Clone, Copy, Serialize, Deserialize)] -pub struct NpcColorIndex(pub u8); - -/// Active NPC-to-NPC conversation session. -/// Attached to the "speaker" NPC (the one who initiated). -/// The "listener" is tracked by entity reference. -#[derive(Component, Debug)] -pub struct NpcConversation { - /// The other NPC in the conversation. - pub partner: Entity, - /// Tick when the conversation started. - pub started_tick: u64, - /// Tick when the conversation will end. - pub end_tick: u64, - /// Ticks since last line was spoken (for pacing). - pub ticks_since_last_line: u64, -} - -/// Cooldown preventing an NPC from entering another conversation too soon. -#[derive(Component, Debug)] -pub struct ConversationCooldown { - pub until_tick: u64, -} - -// --------------------------------------------------------------------------- -// Wire types (cross bridge boundary) -// --------------------------------------------------------------------------- - -/// Conversation event included in ObserverSnapshot when the player overhears -/// an NPC-to-NPC conversation (D-078). -/// -/// The server performs per-word occlusion before emission — the client receives -/// `occluded_line` and renders it verbatim. No stochastic logic on the client. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConversationEvent { - /// The dialogue line with dropped words replaced by "...". - pub occluded_line: String, - /// Wire-format entity ID of the speaking NPC. - pub speaker_id: u64, - /// Wire-format entity ID of the NPC being spoken to. - pub target_id: u64, - /// Display name of the speaker (real name if known to player, else role label). - #[serde(default)] - pub speaker_name: String, - /// Display name of the target (real name if known to player, else role label). - #[serde(default)] - pub target_name: String, - /// Color index (0-7) for the speaker's conversation log entry. - #[serde(default)] - pub speaker_color_index: u8, - /// Color index (0-7) for the target's conversation log entry. - #[serde(default)] - pub target_color_index: u8, -} - -/// End-of-conversation event. Client dismisses the passive dialogue panel. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConversationEndEvent { - /// Wire-format entity ID of speaker. - pub speaker_id: u64, - /// Wire-format entity ID of target. - pub target_id: u64, -} - -/// Buffer holding conversation events for snapshot inclusion. -/// Drained once per snapshot via `take()`. -#[derive(Component, Debug, Default)] -pub struct ConversationEventBuffer { - pub events: Vec, - pub ended: Vec, -} - -impl ConversationEventBuffer { - /// Drain and return all conversation events. - pub fn take_events(&mut self) -> Vec { - std::mem::take(&mut self.events) - } - - /// Drain and return all end events. - pub fn take_ended(&mut self) -> Vec { - std::mem::take(&mut self.ended) - } -} - -// --------------------------------------------------------------------------- -// Per-word occlusion (D-078) -// --------------------------------------------------------------------------- - -/// Compute the per-word drop probability as an integer percentage (0-100). -/// -/// Inputs: -/// - `distance`: Manhattan tile distance from player to speaker. -/// - `ambient_noise_pct`: Ambient noise at player position as 0-100 integer. -/// Maps to up to +30 percentage points of drop probability. -/// - `listening_focus`: Whether the player has ListeningFocus active (-20pp). -/// -/// Formula: -/// base = distance * 100 / VOICE_RANGE_TILES (linear 0→100 over range) -/// noise_bonus = ambient_noise_pct * 30 / 100 (up to +30) -/// focus_bonus = if listening_focus { -20 } else { 0 } -/// result = clamp(base + noise_bonus + focus_bonus, 0, 100) -/// -/// All integer arithmetic — no floats (D-010). -pub fn compute_drop_probability( - distance: u32, - ambient_noise_pct: u32, - listening_focus: bool, -) -> u32 { - // Linear distance decay: 0% at distance 0, 100% at VOICE_RANGE_TILES - let base = (distance.min(VOICE_RANGE_TILES) * 100) / VOICE_RANGE_TILES; - - // Ambient noise: scales 0-100 input to 0-30 contribution - let noise_bonus = (ambient_noise_pct.min(100) * 30) / 100; - - // ListeningFocus subtracts 20 - let focus_bonus: i32 = if listening_focus { -20 } else { 0 }; - - let raw = base as i32 + noise_bonus as i32 + focus_bonus; - raw.clamp(0, 100) as u32 -} - -/// Apply per-word occlusion to a dialogue line. -/// -/// Each word undergoes an independent Bernoulli trial: if a random value -/// in [0, 100) is less than `drop_pct`, the word is replaced with "...". -/// Consecutive dropped words collapse into a single "..." per the D-078 spec. -/// -/// Uses SimRng for deterministic replay (D-010). -pub fn occlude_line(line: &str, drop_pct: u32, rng: &mut impl Rng) -> String { - if drop_pct == 0 { - return line.to_string(); - } - if drop_pct >= 100 { - // All words dropped — single ellipsis - if line.split_whitespace().count() > 0 { - return "...".to_string(); - } - return String::new(); - } - - let mut result = Vec::new(); - let mut last_was_dropped = false; - - for word in line.split_whitespace() { - let roll: u32 = rng.random_range(0..100); - if roll < drop_pct { - // Drop this word — collapse consecutive drops - if !last_was_dropped { - result.push("..."); - last_was_dropped = true; - } - } else { - result.push(word); - last_was_dropped = false; - } - } - - result.join(" ") -} - -// --------------------------------------------------------------------------- -// Placeholder line selection -// --------------------------------------------------------------------------- - -/// Placeholder NPC-to-NPC conversation lines. -/// Content sourced from #536 (copy team) — these are development placeholders. -const NPC_CONVERSATION_LINES: &[&str] = &[ - "Heard anything from the night shift?", - "Cargo manifests don't add up again.", - "Keep your head down today.", - "The new arrival's been asking questions.", - "Terminal three has been acting up.", - "Did you see the Commission officer?", - "I need to talk to you about something.", - "Another long shift ahead.", -]; - -// --------------------------------------------------------------------------- -// Systems -// --------------------------------------------------------------------------- - -/// System: initiate new NPC-to-NPC conversations and tick existing ones. -/// -/// Phase 1: Check for eligible NPC pairs (ActiveSim, proximity ≤3, not already -/// in conversation, not on cooldown) and probabilistically start conversations. -/// -/// Phase 2: Tick active conversations — emit Voice SoundEvents and -/// ConversationEvents (with per-word occlusion) for the player's snapshot. -/// Terminate conversations when duration expires or NPCs move apart. -/// -/// System ordering: after validate_movement, before collect_sound_events. -#[allow(clippy::type_complexity, clippy::too_many_arguments)] -pub fn run_npc_conversations( - mut commands: Commands, - time: Res, - registry: Res, - mut rng: ResMut, - // All ActiveSim NPCs — candidates for conversation initiation - npc_query: Query< - ( - Entity, - &TilePosition, - Option<&NpcName>, - Option<&NpcConversation>, - Option<&ConversationCooldown>, - Option<&StableEntityId>, - Option<&NpcColorIndex>, - Option<&DialogueProfile>, - ), - (With, With), - >, - // Player query for occlusion computation - mut player_query: Query< - ( - &TilePosition, - Option<&ListeningFocus>, - &mut ConversationEventBuffer, - &KnowledgeGraph, - ), - With, - >, -) { - // --- Phase 1: Initiate new conversations --- - - // Collect eligible NPCs (not in conversation, not on cooldown). - // Sorted by StableId for deterministic pairing order (D-010). - let mut eligible: Vec<(Entity, TilePosition, u64)> = npc_query - .iter() - .filter(|(_, _, _, conv, cooldown, _, _, _)| { - conv.is_none() - && cooldown - .map(|cd| time.tick >= cd.until_tick) - .unwrap_or(true) - }) - .map(|(entity, pos, _, _, _, sid, _, _)| { - (entity, *pos, sid.map(|s| s.0 .0).unwrap_or(u64::MAX)) - }) - .collect(); - eligible.sort_by_key(|&(_, _, sid)| sid); - - // Try to pair eligible NPCs within proximity. - // O(N^2) pair scan — acceptable for v0.1 Active-tier counts (30-80 NPCs, D-026). - // If NPC population grows beyond ~200, consider spatial indexing. - // Only one new conversation per tick to avoid spam. - let mut started_this_tick = false; - - for i in 0..eligible.len() { - if started_this_tick { - break; - } - for j in (i + 1)..eligible.len() { - let (entity_a, pos_a, _) = eligible[i]; - let (entity_b, pos_b, _) = eligible[j]; - - let Some(distance) = pos_a.manhattan_distance(&pos_b) else { - continue; // different z-levels - }; - - if distance > CONVERSATION_PROXIMITY { - continue; - } - - // Probabilistic start - let roll: u32 = rng.rng.random_range(0..100); - if roll >= CONVERSATION_CHANCE_PERCENT { - continue; - } - - // Start conversation - let duration = rng - .rng - .random_range(MIN_DURATION_TICKS..=MAX_DURATION_TICKS); - commands.entity(entity_a).insert(NpcConversation { - partner: entity_b, - started_tick: time.tick, - end_tick: time.tick + duration, - ticks_since_last_line: 0, - }); - - started_this_tick = true; - tracing::debug!( - "NPC conversation started: {:?} ↔ {:?}, duration={} ticks", - entity_a, - entity_b, - duration, - ); - break; - } - } - - // --- Phase 2: Tick active conversations --- - - // Collect active conversations — need mutable access later, so collect first. - // Tuple: (entity, conv, pos, npc_real_name, npc_role, npc_color_index) - let active_conversations: Vec<( - Entity, - NpcConversation, - TilePosition, - Option, - Option, - Option, - )> = npc_query - .iter() - .filter_map(|(entity, pos, name, conv, _, _, color_idx, profile)| { - conv.map(|c| { - ( - entity, - NpcConversation { - partner: c.partner, - started_tick: c.started_tick, - end_tick: c.end_tick, - ticks_since_last_line: c.ticks_since_last_line, - }, - *pos, - name.map(|n| n.0.clone()), - profile.map(|p| p.role.clone()), - color_idx.map(|ci| ci.0), - ) - }) - }) - .collect(); - - for (speaker_entity, conv, speaker_pos, speaker_name, speaker_role, speaker_color) in - &active_conversations - { - let speaker_entity = *speaker_entity; - - // Check termination: duration expired - if time.tick >= conv.end_tick { - terminate_conversation( - &mut commands, - ®istry, - &mut player_query, - speaker_entity, - conv.partner, - time.tick, - ); - continue; - } - - // Check termination: partner moved away or no longer ActiveSim - let partner_ok = npc_query - .get(conv.partner) - .ok() - .map(|(_, pos, _, _, _, _, _, _)| { - speaker_pos - .manhattan_distance(pos) - .map(|d| d <= CONVERSATION_PROXIMITY) - .unwrap_or(false) - }); - - if partner_ok != Some(true) { - terminate_conversation( - &mut commands, - ®istry, - &mut player_query, - speaker_entity, - conv.partner, - time.tick, - ); - continue; - } - - // Emit Voice SoundEvent at speaker position - if let Some(speaker_sid) = registry.to_stable(speaker_entity) { - let voice_event = SoundEvent::at( - speaker_pos, - SoundEventKind::Voice, - 0.6, - SoundRange::Medium, - Some(speaker_sid.0), - ); - commands - .entity(speaker_entity) - .insert(SoundEventEmitter::new(voice_event)); - } - - // Emit conversation line periodically - if conv.ticks_since_last_line >= LINE_INTERVAL_TICKS || conv.ticks_since_last_line == 0 { - // Select a placeholder line - let line_idx = rng.rng.random_range(0..NPC_CONVERSATION_LINES.len()); - let line_text = NPC_CONVERSATION_LINES[line_idx]; - - // Collect partner display info (real name, role, color) once — used - // per-observer below to resolve display names against each observer's KG. - let (partner_real_name, partner_role, partner_color) = npc_query - .get(conv.partner) - .ok() - .map(|(_, _, pname, _, _, _, pcolor, pprofile)| { - ( - pname.map(|n| n.0.clone()), - pprofile.map(|p| p.role.clone()), - pcolor.map(|ci| ci.0).unwrap_or(0u8), - ) - }) - .unwrap_or((None, None, 0u8)); - - let speaker_sid = registry.to_stable(speaker_entity); - let target_sid = registry.to_stable(conv.partner); - - // Compute per-observer occlusion (D-078, D-010 principle 3). - // Iterates all observers — supports future multi-observer scenarios (D-027). - for (player_pos, listening_focus_opt, mut conv_buffer, player_kg) in - player_query.iter_mut() - { - let distance = speaker_pos - .manhattan_distance(player_pos) - .unwrap_or(u32::MAX); - - // Only emit if within Voice range - if distance <= VOICE_RANGE_TILES { - let listening = listening_focus_opt - .map(|lf| lf.is_eavesdropping()) - .unwrap_or(false); - - // Zone ambient noise — stubbed at 0 until zone-conspicuousness - // (D-071) wires in. Function signature already accepts the value. - let ambient_noise_pct = 0u32; - - let drop_pct = compute_drop_probability(distance, ambient_noise_pct, listening); - let occluded = occlude_line(line_text, drop_pct, &mut rng.rng); - - if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { - // Resolve speaker display name per this observer's KG. - let speaker_display = { - let known = player_kg - .entity_knowledge(&s_sid) - .map(|e| e.known_attributes.contains_key("name")) - .unwrap_or(false); - if known { - speaker_name - .clone() - .unwrap_or_else(|| "Unknown".to_string()) - } else { - speaker_role - .as_deref() - .map(display_label_for_role) - .unwrap_or_else(|| "Bystander".to_string()) - } - }; - - // Resolve target display name per this observer's KG. - let target_display = { - let known = player_kg - .entity_knowledge(&t_sid) - .map(|e| e.known_attributes.contains_key("name")) - .unwrap_or(false); - if known { - partner_real_name - .clone() - .unwrap_or_else(|| "Unknown".to_string()) - } else { - partner_role - .as_deref() - .map(display_label_for_role) - .unwrap_or_else(|| "Bystander".to_string()) - } - }; - - conv_buffer.events.push(ConversationEvent { - occluded_line: occluded, - speaker_id: s_sid.0, - target_id: t_sid.0, - speaker_name: speaker_display, - target_name: target_display, - speaker_color_index: speaker_color.unwrap_or(0), - target_color_index: partner_color, - }); - } - } - } - - // Reset line timer - commands.entity(speaker_entity).insert(NpcConversation { - partner: conv.partner, - started_tick: conv.started_tick, - end_tick: conv.end_tick, - ticks_since_last_line: 0, - }); - } else { - // Increment line timer - commands.entity(speaker_entity).insert(NpcConversation { - partner: conv.partner, - started_tick: conv.started_tick, - end_tick: conv.end_tick, - ticks_since_last_line: conv.ticks_since_last_line + 1, - }); - } - } -} - -/// Terminate a conversation: remove NpcConversation, apply cooldowns, emit end event. -fn terminate_conversation( - commands: &mut Commands, - registry: &EntityRegistry, - player_query: &mut Query< - ( - &TilePosition, - Option<&ListeningFocus>, - &mut ConversationEventBuffer, - &KnowledgeGraph, - ), - With, - >, - speaker: Entity, - partner: Entity, - current_tick: u64, -) { - commands.entity(speaker).remove::(); - - // Apply cooldown to both participants - let until = current_tick + CONVERSATION_COOLDOWN_TICKS; - commands - .entity(speaker) - .insert(ConversationCooldown { until_tick: until }); - commands - .entity(partner) - .insert(ConversationCooldown { until_tick: until }); - - // Emit conversation_end event to all observers (D-010 principle 3). - let speaker_sid = registry.to_stable(speaker); - let target_sid = registry.to_stable(partner); - - if let (Some(s_sid), Some(t_sid)) = (speaker_sid, target_sid) { - for (_, _, mut conv_buffer, _) in player_query.iter_mut() { - conv_buffer.ended.push(ConversationEndEvent { - speaker_id: s_sid.0, - target_id: t_sid.0, - }); - } - } - - tracing::debug!("NPC conversation ended: {:?} ↔ {:?}", speaker, partner,); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::knowledge::KnowledgeGraph; - use rand::SeedableRng; - use rand_chacha::ChaCha20Rng; - - // -- Per-word occlusion tests ------------------------------------------- - - #[test] - fn occlusion_drops_words_with_distance() { - // At max range (8 tiles), drop probability is 100% — all words dropped - let drop = compute_drop_probability(VOICE_RANGE_TILES, 0, false); - assert_eq!(drop, 100); - - let mut rng = ChaCha20Rng::seed_from_u64(42); - let result = occlude_line("Hello there friend", drop, &mut rng); - assert_eq!(result, "..."); - } - - #[test] - fn occlusion_preserves_all_words_at_zero_distance() { - let drop = compute_drop_probability(0, 0, false); - assert_eq!(drop, 0); - - let mut rng = ChaCha20Rng::seed_from_u64(42); - let result = occlude_line("Hello there friend", drop, &mut rng); - assert_eq!(result, "Hello there friend"); - } - - #[test] - fn occlusion_suppressed_by_listening_focus() { - // At distance 2 (25% base), no noise, with focus (-20%) → 5% - let without_focus = compute_drop_probability(2, 0, false); - let with_focus = compute_drop_probability(2, 0, true); - - assert!( - with_focus < without_focus, - "focus should reduce drop probability" - ); - assert_eq!(without_focus, 25); // 2 * 100 / 8 = 25 - assert_eq!(with_focus, 5); // 25 - 20 = 5 - } - - #[test] - fn occlusion_deterministic_with_same_seed() { - let line = "The cargo manifests don't add up at all"; - let drop_pct = 50; - - let mut rng1 = ChaCha20Rng::seed_from_u64(42); - let mut rng2 = ChaCha20Rng::seed_from_u64(42); - - let result1 = occlude_line(line, drop_pct, &mut rng1); - let result2 = occlude_line(line, drop_pct, &mut rng2); - - assert_eq!(result1, result2, "same seed must produce same occlusion"); - } - - #[test] - fn occlusion_ambient_noise_adds_up_to_30() { - // Max ambient noise (100%) adds 30 percentage points - let no_noise = compute_drop_probability(0, 0, false); - let max_noise = compute_drop_probability(0, 100, false); - - assert_eq!(no_noise, 0); - assert_eq!(max_noise, 30); - } - - #[test] - fn occlusion_clamps_to_zero() { - // Very close + listening focus → should clamp at 0, not go negative - let drop = compute_drop_probability(0, 0, true); - assert_eq!(drop, 0); // 0 - 20 clamped to 0 - } - - #[test] - fn occlusion_clamps_to_100() { - // Far away + max noise → should cap at 100 - let drop = compute_drop_probability(VOICE_RANGE_TILES, 100, false); - assert_eq!(drop, 100); // 100 + 30 clamped to 100 - } - - #[test] - fn occlusion_consecutive_drops_collapse() { - // Ensure consecutive dropped words become a single "..." - let mut rng = ChaCha20Rng::seed_from_u64(0); - // At 100% drop, everything collapses - let result = occlude_line("one two three four five", 100, &mut rng); - assert_eq!(result, "..."); - } - - #[test] - fn occlusion_empty_line() { - let mut rng = ChaCha20Rng::seed_from_u64(42); - let result = occlude_line("", 50, &mut rng); - assert_eq!(result, ""); - } - - #[test] - fn occlusion_linear_distance_scaling() { - // Distance 4 out of 8 = 50% - assert_eq!(compute_drop_probability(4, 0, false), 50); - // Distance 1 out of 8 = 12% (integer division: 1*100/8 = 12) - assert_eq!(compute_drop_probability(1, 0, false), 12); - // Distance 6 out of 8 = 75% - assert_eq!(compute_drop_probability(6, 0, false), 75); - } - - // -- Conversation lifecycle tests (ECS) -------------------------------- - - fn setup_conversation_world() -> bevy_ecs::world::World { - let mut world = bevy_ecs::world::World::new(); - world.init_resource::(); - world.insert_resource(SimRng::new(42)); - world.init_resource::(); - world - } - - #[test] - fn npc_conversation_emits_voice_event_when_in_range() { - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - NpcName("Bob".to_string()), - )) - .id(); - world.resource_mut::().register(npc_b); - - // Fix the partner reference - world.get_mut::(npc_a).unwrap().partner = npc_b; - - // Spawn player within voice range - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 8, 0), // distance 3 from speaker - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - // Check that a SoundEventEmitter with Voice was attached to the speaker - let emitter = world.get::(npc_a); - assert!( - emitter.is_some(), - "Speaker should have a SoundEventEmitter after conversation tick" - ); - assert_eq!(emitter.unwrap().pending[0].kind, SoundEventKind::Voice); - - // Check that a ConversationEvent was buffered for the player - let buffer = world.get::(player).unwrap(); - assert_eq!( - buffer.events.len(), - 1, - "Player in range should receive a conversation event" - ); - // Player KG has no "name" attribute for either NPC, and NPCs have no - // DialogueProfile, so both should fall back to the Bystander label. - assert_eq!(buffer.events[0].speaker_name, "Bystander"); - assert_eq!(buffer.events[0].target_name, "Bystander"); - // Color index defaults to 0 when NpcColorIndex is not attached. - assert_eq!(buffer.events[0].speaker_color_index, 0); - assert_eq!(buffer.events[0].target_color_index, 0); - } - - #[test] - fn npc_conversation_terminates_when_apart() { - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - // Partner is far away (>3 tiles) - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(20, 20, 0), - NpcName("Bob".to_string()), - )) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - // Conversation should be removed - assert!( - world.get::(npc_a).is_none(), - "Conversation should terminate when NPCs are apart" - ); - - // End event should be emitted - let buffer = world.get::(player).unwrap(); - assert_eq!( - buffer.ended.len(), - 1, - "conversation_end event should be emitted" - ); - } - - #[test] - fn conversation_terminates_on_duration_expiry() { - let mut world = setup_conversation_world(); - world.resource_mut::().tick = 101; // Past end_tick - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0))) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - assert!( - world.get::(npc_a).is_none(), - "Conversation should terminate when duration expires" - ); - } - - #[test] - fn player_out_of_range_gets_no_event() { - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - NpcName("Bob".to_string()), - )) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - // Player far away (distance > 8 = VOICE_RANGE_TILES) - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(30, 30, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - let buffer = world.get::(player).unwrap(); - assert!( - buffer.events.is_empty(), - "Player out of voice range should not receive conversation events" - ); - } - - #[test] - fn drop_probability_formula_matches_spec() { - // D-078 spec: linear decay from 0.0 at 0 tiles to 1.0 at range boundary - assert_eq!(compute_drop_probability(0, 0, false), 0); - assert_eq!(compute_drop_probability(VOICE_RANGE_TILES, 0, false), 100); - - // Ambient noise adds up to 0.3 (30pp) - assert_eq!(compute_drop_probability(0, 100, false), 30); - assert_eq!(compute_drop_probability(0, 50, false), 15); - - // ListeningFocus subtracts 0.2 (20pp) - assert_eq!(compute_drop_probability(4, 0, true), 30); // 50 - 20 - } - - // -- Additional QA coverage (Hoshe, Sprint 14) -------------------------- - - #[test] - fn cooldown_applied_to_both_npcs_after_distance_termination() { - // When a conversation terminates (NPCs drift apart), both NPCs must - // receive ConversationCooldown to prevent immediate re-pairing. - let mut world = setup_conversation_world(); - world.resource_mut::().tick = 100; - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 50, - end_tick: 200, - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - // Partner far away — conversation should terminate this tick - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(20, 20, 0), - NpcName("Bob".to_string()), - )) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - // Both NPCs must have ConversationCooldown applied - let cooldown_a = world.get::(npc_a); - assert!( - cooldown_a.is_some(), - "Speaker (npc_a) must get ConversationCooldown after termination" - ); - assert_eq!( - cooldown_a.unwrap().until_tick, - 100 + CONVERSATION_COOLDOWN_TICKS, - "Cooldown until_tick must be current_tick + CONVERSATION_COOLDOWN_TICKS" - ); - - let cooldown_b = world.get::(npc_b); - assert!( - cooldown_b.is_some(), - "Partner (npc_b) must get ConversationCooldown after termination" - ); - assert_eq!( - cooldown_b.unwrap().until_tick, - 100 + CONVERSATION_COOLDOWN_TICKS, - "Both NPCs receive the same cooldown duration" - ); - } - - #[test] - fn cooldown_applied_after_duration_expiry() { - // Termination by duration should also apply cooldowns. - let mut world = setup_conversation_world(); - world.resource_mut::().tick = 200; - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, // expired - ticks_since_last_line: 0, - }, - )) - .id(); - world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn((Npc, ActiveSim, TilePosition::new(5, 6, 0))) - .id(); - world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - assert!( - world.get::(npc_a).is_some(), - "Speaker must get cooldown after duration expiry" - ); - assert!( - world.get::(npc_b).is_some(), - "Partner must get cooldown after duration expiry" - ); - } - - #[test] - fn npc_on_active_cooldown_cannot_start_conversation() { - // An NPC with ConversationCooldown (until_tick > current_tick) must - // not be eligible for new conversation initiation. - let mut world = setup_conversation_world(); - world.resource_mut::().tick = 50; - - // NPC on cooldown (expires at tick 100, current is 50) - world.spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - ConversationCooldown { until_tick: 100 }, - )); - - world.spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - ConversationCooldown { until_tick: 100 }, - )); - - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )); - - // Run many ticks — no conversation should ever start because all NPCs are on cooldown - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - - for _ in 0..50 { - schedule.run(&mut world); - world.flush(); - } - - // Verify no NpcConversation was created - let mut conv_query = world.query::<&NpcConversation>(); - assert!( - conv_query.iter(&world).count() == 0, - "NPCs on cooldown must not enter conversations" - ); - } - - #[test] - fn expired_cooldown_allows_conversation_initiation() { - // A cooldown whose until_tick <= current_tick should not block the NPC. - let mut world = setup_conversation_world(); - // Set tick high enough that the cooldown has expired - world.resource_mut::().tick = 200; - - // Both NPCs have cooldowns that expired at tick 100 - world.spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - ConversationCooldown { until_tick: 100 }, // expired at 200 - )); - world.spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - ConversationCooldown { until_tick: 100 }, // expired at 200 - )); - world.spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - KnowledgeGraph::new(), - )); - - // With 2% chance per tick, over 300 ticks a conversation is extremely likely. - // Use a fresh world per attempt but share the schedule. - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - - // Run until we see a conversation or hit max attempts - let mut found = false; - for _ in 0..300 { - schedule.run(&mut world); - world.flush(); - - let mut conv_query = world.query::<&NpcConversation>(); - if conv_query.iter(&world).count() > 0 { - found = true; - break; - } - } - - assert!( - found, - "Expired cooldown should allow conversation initiation (2% per tick, 300 attempts)" - ); - } - - // -- Name masking tests (Sprint 15) -------------------------------------- - - #[test] - fn conversation_uses_role_label_when_name_not_in_player_kg() { - // Player KG has an entry for the NPC but no "name" attribute. - // ConversationEvent.speaker_name should be the role label. - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - DialogueProfile { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - }, - )) - .id(); - let npc_a_sid = world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - NpcName("Bob".to_string()), - DialogueProfile { - location: "the-terminal".to_string(), - role: "courier".to_string(), - }, - )) - .id(); - let npc_b_sid = world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - // Player KG observes both NPCs but has NO "name" attribute for either. - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_a_sid, TilePosition::new(5, 5, 0), 0); - kg.observe_entity(npc_b_sid, TilePosition::new(5, 6, 0), 0); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - kg, - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - let buffer = world.get::(player).unwrap(); - assert_eq!(buffer.events.len(), 1); - // No "name" attribute → falls back to role label - assert_eq!( - buffer.events[0].speaker_name, "Dock Worker", - "speaker with no KG name attribute should show role label" - ); - assert_eq!( - buffer.events[0].target_name, "Courier", - "target with no KG name attribute should show role label" - ); - } - - #[test] - fn conversation_uses_real_name_when_name_in_player_kg() { - // Player KG has a "name" attribute for the speaker. - // ConversationEvent.speaker_name should use NpcName.0. - let mut world = setup_conversation_world(); - - let npc_a = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 5, 0), - NpcName("Alice".to_string()), - NpcConversation { - partner: Entity::PLACEHOLDER, - started_tick: 0, - end_tick: 100, - ticks_since_last_line: 0, - }, - DialogueProfile { - location: "the-terminal".to_string(), - role: "dock-worker".to_string(), - }, - )) - .id(); - let npc_a_sid = world.resource_mut::().register(npc_a); - - let npc_b = world - .spawn(( - Npc, - ActiveSim, - TilePosition::new(5, 6, 0), - NpcName("Bob".to_string()), - DialogueProfile { - location: "the-terminal".to_string(), - role: "courier".to_string(), - }, - )) - .id(); - let npc_b_sid = world.resource_mut::().register(npc_b); - - world.get_mut::(npc_a).unwrap().partner = npc_b; - - // Player KG has "name" attribute for both NPCs (name has been revealed). - let mut kg = KnowledgeGraph::new(); - kg.observe_entity(npc_a_sid, TilePosition::new(5, 5, 0), 0); - kg.entities - .get_mut(&npc_a_sid) - .unwrap() - .known_attributes - .insert("name".to_string(), "Alice".to_string()); - kg.observe_entity(npc_b_sid, TilePosition::new(5, 6, 0), 0); - kg.entities - .get_mut(&npc_b_sid) - .unwrap() - .known_attributes - .insert("name".to_string(), "Bob".to_string()); - - let player = world - .spawn(( - PlayerCharacter, - TilePosition::new(5, 5, 0), - ConversationEventBuffer::default(), - kg, - )) - .id(); - world.resource_mut::().register(player); - - let mut schedule = bevy_ecs::schedule::Schedule::default(); - schedule.add_systems(run_npc_conversations); - schedule.run(&mut world); - world.flush(); - - let buffer = world.get::(player).unwrap(); - assert_eq!(buffer.events.len(), 1); - // "name" attribute present → use NpcName.0 - assert_eq!( - buffer.events[0].speaker_name, "Alice", - "speaker with KG name attribute should show real name" - ); - assert_eq!( - buffer.events[0].target_name, "Bob", - "target with KG name attribute should show real name" - ); - } - - #[test] - fn buffer_take_events_drains_and_returns_events() { - let mut buffer = ConversationEventBuffer::default(); - buffer.events.push(ConversationEvent { - occluded_line: "Hello".to_string(), - speaker_id: 1, - target_id: 2, - speaker_name: "Alice".to_string(), - target_name: "Bob".to_string(), - speaker_color_index: 0, - target_color_index: 1, - }); - buffer.events.push(ConversationEvent { - occluded_line: "World".to_string(), - speaker_id: 1, - target_id: 2, - speaker_name: "Alice".to_string(), - target_name: "Bob".to_string(), - speaker_color_index: 0, - target_color_index: 1, - }); - - let taken = buffer.take_events(); - assert_eq!(taken.len(), 2, "take_events should return all events"); - assert!( - buffer.events.is_empty(), - "Buffer should be empty after take_events" - ); - - // Second call returns empty - let taken2 = buffer.take_events(); - assert!( - taken2.is_empty(), - "Second take_events call should return empty vec" - ); - } - - #[test] - fn buffer_take_ended_drains_and_returns_end_events() { - let mut buffer = ConversationEventBuffer::default(); - buffer.ended.push(ConversationEndEvent { - speaker_id: 10, - target_id: 20, - }); - - let taken = buffer.take_ended(); - assert_eq!(taken.len(), 1, "take_ended should return all end events"); - assert!( - buffer.ended.is_empty(), - "ended buffer should be empty after take_ended" - ); - - // Second call returns empty - assert!(buffer.take_ended().is_empty()); - } -} diff --git a/server/src/simulation/dialogue.rs b/server/src/simulation/dialogue.rs index acfef774a..3ebd46c8f 100644 --- a/server/src/simulation/dialogue.rs +++ b/server/src/simulation/dialogue.rs @@ -27,7 +27,6 @@ use crate::knowledge::types::{FactId, KnowledgeConfidence, KnowledgeSource, Stab use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::interaction::{InteractionEvent, InteractionEventKind, InteractionMemory}; use crate::npc::relationships::{TrustEvent, TrustEventQueue}; -use crate::simulation::conversation::{display_label_for_role, NpcColorIndex, NpcName}; use crate::simulation::knowledge_grant::KnowledgeGrant; use crate::simulation::line_pool::LinePoolIndexResource; use crate::simulation::line_pool::{ @@ -35,6 +34,7 @@ use crate::simulation::line_pool::{ }; use crate::simulation::monologue::{MonologueBuffer, MonologueState}; use crate::simulation::movement::PlayerCharacter; +use crate::simulation::npc_components::{display_label_for_role, NpcColorIndex, NpcName}; use crate::simulation::rng::SimRng; use crate::simulation::time::SimulationTime; use crate::storyteller::EngagementRecord; diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 69b837ad6..84281b3b4 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -3,11 +3,11 @@ // PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode, ToggleStance) use crate::bookmark::{BookmarkRegistry, SelectedBookmark}; -use crate::knowledge::CultureResolverResource; use crate::bridge::debug::DebugCommandBuffer; use crate::bridge::types::{ FacingDirection, ObjectType, PlayerAction, PlayerInput, SimError, SimErrorKind, SnapshotBuffer, }; +use crate::knowledge::CultureResolverResource; use crate::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::settings::{SettingsCommand, SettingsCommandBuffer}; @@ -449,7 +449,9 @@ pub fn process_player_input( buf.pending_bookmark_catalog = Some(registry.build_catalog(resolver)); tracing::debug!("RequestBookmarkCatalog: catalog staged"); } else { - tracing::warn!("RequestBookmarkCatalog: BookmarkRegistry or SnapshotBuffer not available"); + tracing::warn!( + "RequestBookmarkCatalog: BookmarkRegistry or SnapshotBuffer not available" + ); } } PlayerAction::ConfirmBookmark { @@ -1208,7 +1210,11 @@ fn handle_confirm_bookmark( if let Some(ref mut sel) = selected_bookmark { sel.bookmark_id = Some(bookmark_id.clone()); sel.starting_location_id = Some(starting_location_id.clone()); - tracing::info!(bookmark_id, starting_location_id, "ConfirmBookmark: selection recorded"); + tracing::info!( + bookmark_id, + starting_location_id, + "ConfirmBookmark: selection recorded" + ); } } diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 944e65e78..b32c752d2 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -7,7 +7,6 @@ use bevy_ecs::schedule::IntoScheduleConfigs; pub mod chunk_streaming; // Phase sub-plugins (#843) pub mod contraband; -pub mod conversation; pub mod dialogue; pub mod economy; pub mod economy_plugin; @@ -25,6 +24,7 @@ pub mod modification; pub mod monologue; pub mod movement; pub mod movement_plugin; +pub mod npc_components; pub mod npc_knowledge_transfer; pub mod path_follow; pub mod pathfinding; diff --git a/server/src/simulation/monologue.rs b/server/src/simulation/monologue.rs index 8b345de9f..039a867ec 100644 --- a/server/src/simulation/monologue.rs +++ b/server/src/simulation/monologue.rs @@ -16,8 +16,8 @@ use rand::Rng; use crate::bridge::types::MonologueEvent; use crate::knowledge::{ContradictionDetectedQueue, EntityRegistry}; use crate::perception::interpretation::ObservationTrigger; -use crate::simulation::conversation::NpcName; use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::npc_components::NpcName; use crate::simulation::rng::EntityRng; use crate::simulation::time::SimulationTime; use crate::storyteller::EngagementRecord; @@ -84,15 +84,6 @@ const HEAR_SOUND_LINES: &[(&str, &str)] = &[ ("hear_sound_03", "Something just happened nearby."), ]; -/// Hardcoded v0.1 witness_interaction monologue lines. -/// Fire when the player overhears an NPC-to-NPC conversation (D-078). -/// Future: move to content pools with trigger="witness_interaction". -const WITNESS_INTERACTION_LINES: &[(&str, &str)] = &[ - ("witness_01", "Interesting. Wonder what that was about."), - ("witness_02", "I should remember what they just said."), - ("witness_03", "They didn't know I was listening."), -]; - /// Hardcoded v0.1 post_conversation monologue lines. /// Fire after a player-NPC dialogue concludes (walk-away or natural end). /// Future: move to content pools with trigger="post_conversation". @@ -375,7 +366,6 @@ fn select_hardcoded_fallback(trigger: &str, rng: &mut impl Rng) -> (String, Stri let lines = match trigger { "observe_npc" => OBSERVE_NPC_LINES, "hear_sound" => HEAR_SOUND_LINES, - "witness_interaction" => WITNESS_INTERACTION_LINES, "post_conversation" => POST_CONVERSATION_LINES, unknown => { tracing::warn!( @@ -413,8 +403,7 @@ fn sound_range_tiles(range: &crate::knowledge::types::SoundRange) -> u32 { /// Priority order (first match wins): /// 1. observe_npc (new entity spotted — uses previous-tick observation events) /// 2. hear_sound (non-routine sound: Machinery, Alert) -/// 3. witness_interaction (overheard NPC-to-NPC conversation, D-078) -/// 4. post_conversation (player-NPC dialogue concluded) +/// 3. post_conversation (player-NPC dialogue concluded) /// /// System ordering: after all event producers + recognition/anomaly monologue /// systems, before compute_observer_snapshot. @@ -429,7 +418,6 @@ pub fn trigger_event_monologue( &TilePosition, &mut MonologueState, &mut MonologueBuffer, - Option<&crate::simulation::conversation::ConversationEventBuffer>, &mut EntityRng, ), With, @@ -441,9 +429,7 @@ pub fn trigger_event_monologue( // Saved for NPC attribution (engagement tracking #570) and trigger detection. let post_conv_npcs: Vec = post_conv_queue.drain(); - let Ok((player_pos, mut state, mut buffer, conv_buffer_opt, mut entity_rng)) = - query.single_mut() - else { + let Ok((player_pos, mut state, mut buffer, mut entity_rng)) = query.single_mut() else { return; }; @@ -468,11 +454,6 @@ pub fn trigger_event_monologue( .unwrap_or(false) { Some("hear_sound") - } else if conv_buffer_opt - .map(|b| !b.events.is_empty()) - .unwrap_or(false) - { - Some("witness_interaction") } else if !post_conv_npcs.is_empty() { Some("post_conversation") } else { @@ -511,7 +492,7 @@ pub fn trigger_event_monologue( ); // Engagement tracking (#570): attribute monologue_trigger_count to specific NPCs. - // Only NPC-context triggers are attributed — hear_sound/witness_interaction are not NPC-specific. + // Only NPC-context triggers are attributed — hear_sound is not NPC-specific. match trigger { "observe_npc" => { // Attribute to all NPCs whose NewEntity event triggered this monologue @@ -537,7 +518,7 @@ pub fn trigger_event_monologue( } } } - _ => {} // hear_sound, witness_interaction: no NPC-specific attribution + _ => {} // hear_sound: no NPC-specific attribution } } @@ -1345,7 +1326,6 @@ mod tests { use crate::perception::interpretation::{ ObservationEvent, ObservationEventQueue, ObservationTrigger, }; - use crate::simulation::conversation::ConversationEventBuffer; use crate::simulation::sound::{SoundEvent, SoundEventKind, SoundEventQueue}; fn setup_event_world() -> World { @@ -1366,7 +1346,6 @@ mod tests { TilePosition::new(10, 10, 0), MonologueState::default(), MonologueBuffer::default(), - ConversationEventBuffer::default(), )) .id() } @@ -1521,36 +1500,6 @@ mod tests { ); } - #[test] - fn witness_interaction_fires_on_conversation_event() { - let mut world = setup_event_world(); - let player = spawn_event_player(&mut world); - - // Pre-fill ConversationEventBuffer with an overheard conversation - world - .get_mut::(player) - .unwrap() - .events - .push(crate::simulation::conversation::ConversationEvent { - occluded_line: "Keep your head down today.".to_string(), - speaker_id: 100, - target_id: 101, - speaker_name: "Worker".to_string(), - target_name: "Courier".to_string(), - speaker_color_index: 0, - target_color_index: 1, - }); - - run_event_system(&mut world); - - let buf = world.get::(player).unwrap(); - assert!( - buf.event.is_some(), - "witness_interaction should fire when conversation overheard" - ); - assert!(buf.event.as_ref().unwrap().id.starts_with("witness_")); - } - #[test] fn post_conversation_fires_on_queue_entry() { let mut world = setup_event_world(); @@ -1668,47 +1617,6 @@ mod tests { ); } - #[test] - fn priority_hear_sound_over_witness_interaction() { - let mut world = setup_event_world(); - let player = spawn_event_player(&mut world); - - // Sound event - world - .resource_mut::() - .events - .push(SoundEvent::at( - &TilePosition::new(11, 10, 0), - SoundEventKind::Alert, - 1.0, - crate::knowledge::types::SoundRange::Medium, - None, - )); - - // Conversation event - world - .get_mut::(player) - .unwrap() - .events - .push(crate::simulation::conversation::ConversationEvent { - occluded_line: "Test".to_string(), - speaker_id: 100, - target_id: 101, - speaker_name: "A".to_string(), - target_name: "B".to_string(), - speaker_color_index: 0, - target_color_index: 1, - }); - - run_event_system(&mut world); - - let buf = world.get::(player).unwrap(); - assert!( - buf.event.as_ref().unwrap().id.starts_with("hear_sound_"), - "hear_sound should have priority over witness_interaction" - ); - } - #[test] fn event_trigger_updates_last_fired_tick() { let mut world = setup_event_world(); @@ -1813,12 +1721,7 @@ mod tests { #[test] fn hardcoded_lines_all_valid() { - for lines in &[ - OBSERVE_NPC_LINES, - HEAR_SOUND_LINES, - WITNESS_INTERACTION_LINES, - POST_CONVERSATION_LINES, - ] { + for lines in &[OBSERVE_NPC_LINES, HEAR_SOUND_LINES, POST_CONVERSATION_LINES] { assert!(!lines.is_empty()); for (id, text) in *lines { assert!(!id.is_empty(), "line id should not be empty"); diff --git a/server/src/simulation/npc_components.rs b/server/src/simulation/npc_components.rs new file mode 100644 index 000000000..4cb3c83be --- /dev/null +++ b/server/src/simulation/npc_components.rs @@ -0,0 +1,50 @@ +// NPC component types shared across simulation systems. +// +// Extracted from conversation.rs (D-078 scrapped per R-012). These types +// survive because they are used by dialogue, debug, knowledge transfer, +// monologue, and the D-080 knowledge propagation system. + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Display name for an NPC. +/// Attached during content spawn. +#[derive(Component, Debug, Clone, Serialize, Deserialize)] +pub struct NpcName(pub String); + +/// Map a dialogue role string to a display label for use when the player +/// does not yet know the NPC's real name. +pub fn display_label_for_role(role: &str) -> String { + match role { + "dock-worker" => "Dock Worker", + "courier" => "Courier", + "maintenance-tech" => "Technician", + "new-hire" | "day-worker" | "transit-worker" => "Worker", + "scheduler" => "Scheduler", + "shift-supervisor" => "Supervisor", + "bartender" => "Bartender", + "bar-regular" => "Patron", + _ => "Bystander", + } + .to_string() +} + +/// Color index (0-7) for rendering this NPC with a distinct color. +/// Assigned at spawn time as `(stable_id % 8)`. +#[derive(Component, Debug, Clone, Copy, Serialize, Deserialize)] +pub struct NpcColorIndex(pub u8); + +/// Active NPC-to-NPC conversation session (D-080 knowledge propagation). +/// Attached to the "speaker" NPC (the one who initiated). +/// The "listener" is tracked by entity reference. +#[derive(Component, Debug)] +pub struct NpcConversation { + /// The other NPC in the conversation. + pub partner: Entity, + /// Tick when the conversation started. + pub started_tick: u64, + /// Tick when the conversation will end. + pub end_tick: u64, + /// Ticks since last line was spoken (for pacing). + pub ticks_since_last_line: u64, +} diff --git a/server/src/simulation/npc_knowledge_transfer.rs b/server/src/simulation/npc_knowledge_transfer.rs index 34fd7d84c..16681cb8f 100644 --- a/server/src/simulation/npc_knowledge_transfer.rs +++ b/server/src/simulation/npc_knowledge_transfer.rs @@ -28,8 +28,8 @@ use crate::knowledge::{ }; use crate::npc::relationships::RelationshipGraph; use crate::npc::Npc; -use crate::simulation::conversation::NpcConversation; use crate::simulation::movement::{PlayerCharacter, TilePosition}; +use crate::simulation::npc_components::NpcConversation; use crate::simulation::rng::SimRng; use crate::simulation::tier::ActiveSim; use crate::simulation::time::SimulationTime; @@ -438,8 +438,8 @@ mod tests { use crate::knowledge::{EntityRegistry, KnowledgeGraph}; use crate::npc::relationships::{RelationshipEdge, RelationshipGraph}; use crate::npc::RelationshipKind; - use crate::simulation::conversation::NpcConversation; use crate::simulation::movement::TilePosition; + use crate::simulation::npc_components::NpcConversation; use crate::simulation::rng::SimRng; use crate::simulation::tier::ActiveSim; use crate::simulation::time::SimulationTime; diff --git a/server/src/simulation/social_plugin.rs b/server/src/simulation/social_plugin.rs index f142c9c71..765ceb190 100644 --- a/server/src/simulation/social_plugin.rs +++ b/server/src/simulation/social_plugin.rs @@ -1,8 +1,6 @@ -//! Social simulation plugin — NPC conversations, knowledge transfer, disclosure. +//! Social simulation plugin — NPC knowledge transfer, disclosure, and social systems. //! -//! All systems run in [`TickPhase::Simulation`]. Intra-phase ordering: -//! - conversations → knowledge_transfer (transfer reads conversation results) -//! - conversations → sound collection (sound reads conversation events) +//! All systems run in [`TickPhase::Simulation`]. use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; @@ -20,16 +18,11 @@ impl Plugin for SocialPlugin { .add_systems( Update, ( - super::conversation::run_npc_conversations, - super::npc_knowledge_transfer::transfer_npc_knowledge - .after(super::conversation::run_npc_conversations), - super::sound::collect_sound_events - .after(super::conversation::run_npc_conversations), + super::npc_knowledge_transfer::transfer_npc_knowledge, + super::sound::collect_sound_events, // Voice enrichment (D-138) — rewrite NPC text with voiced variants. // No-op when VoiceCacheResource is absent. crate::voice::integration::voice_enrich_dialogue_response, - crate::voice::integration::voice_enrich_conversation_events - .after(super::conversation::run_npc_conversations), // POI discovery reads visibility geometry (also Simulation phase) super::poi_discovery::discover_pois, // Follow state reads visibility geometry + movement diff --git a/server/src/test_world/mod.rs b/server/src/test_world/mod.rs index b70705d0a..196d0ac25 100644 --- a/server/src/test_world/mod.rs +++ b/server/src/test_world/mod.rs @@ -614,8 +614,8 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA // can respond to Talk using content from the YAML dialogue pools. { use crate::npc::Npc; - use crate::simulation::conversation::NpcColorIndex; use crate::simulation::dialogue::{CurrentMood, DialogueProfile}; + use crate::simulation::npc_components::NpcColorIndex; // (location, role) pairs matching server/content/campaigns/.../dialogue/ YAML pools. // Cycling through these gives NPC variety across rooms. diff --git a/server/src/voice/integration.rs b/server/src/voice/integration.rs index 91fa08704..0c4f7801e 100644 --- a/server/src/voice/integration.rs +++ b/server/src/voice/integration.rs @@ -1,15 +1,13 @@ //! Observer integration for the voice pipeline (D-138, Phase 3). //! -//! Two enrichment systems run before `compute_observer_snapshot` and rewrite -//! NPC text in the dialogue and conversation buffers with voiced variants -//! looked up from the cache. Cache miss → base text (never blocks). +//! One enrichment system runs before `compute_observer_snapshot` and rewrites +//! NPC dialogue text with voiced variants looked up from the cache. +//! Cache miss → base text (never blocks). //! //! ## System ordering //! //! ```text -//! process_talk_interaction ─┐ -//! run_npc_conversations ─┤─► voice_enrich_dialogue_response ─┐ -//! └─► voice_enrich_conversation_events ─► compute_observer_snapshot +//! process_talk_interaction ─► voice_enrich_dialogue_response ─► compute_observer_snapshot //! ``` //! //! ## Content index derivation @@ -24,7 +22,6 @@ use bevy_ecs::prelude::*; use crate::knowledge::{EntityRegistry, StableId}; use crate::npc::tell_state::DerivedTellState; use crate::npc::{Npc, NpcVoiceProfile}; -use crate::simulation::conversation::ConversationEventBuffer; use crate::simulation::dialogue::DialogueResponseBuffer; use crate::simulation::movement::{PlayerCharacter, TilePosition}; use crate::simulation::zone::ZoneMap; @@ -127,85 +124,6 @@ pub fn voice_enrich_dialogue_response( response.text = voiced; } -/// Enrich the conversation event buffer with voiced lines from the cache. -/// -/// Must run after `run_npc_conversations` and before -/// `compute_observer_snapshot`. No-op when the voice cache resource is absent. -/// -/// Each event in the buffer is processed: the pre-occlusion base text is -/// not available at this point (occlusion has already been applied), so the -/// `occluded_line` is treated as the base text for the voice lookup. -/// This means the voice register wraps the already-occluded line. -/// -/// ## Accepted risk: re-voicing of heavily occluded text -/// -/// When many words are dropped by D-078 occlusion, the remaining text may -/// be fragmentary ("... the ... came in ..."). Re-voicing such fragments can -/// produce incoherent output. This is acceptable for two reasons: -/// 1. Cache misses are common for conversation text (no pre-baking pipeline), -/// so the base text fallback in `voiced_behavior()` fires most of the time. -/// 2. Even incoherent voiced output is no worse than the already-degraded -/// overheard line — the occlusion itself has already broken coherence. -/// The player's inability to fully parse overheard speech is the mechanic. -#[allow(clippy::type_complexity)] -pub fn voice_enrich_conversation_events( - voice_cache: Option>, - registry: Res, - zone_map: Option>, - player_query: Query<&TilePosition, With>, - npc_voice_query: Query<(&NpcVoiceProfile, Option<&DerivedTellState>), With>, - mut conversation_buffer: Query<&mut ConversationEventBuffer, With>, -) { - let Some(voice_cache) = voice_cache else { - return; - }; - - let Ok(mut buffer) = conversation_buffer.single_mut() else { - return; - }; - - if buffer.events.is_empty() { - return; - } - - let zone_id = player_zone_id(&player_query, zone_map.as_deref()); - - for event in &mut buffer.events { - let speaker_entity = registry.to_entity(&StableId(event.speaker_id)); - let Some(entity) = speaker_entity else { - continue; - }; - - let Ok((voice_profile, tell_state_opt)) = npc_voice_query.get(entity) else { - continue; - }; - - let tell_state = tell_state_opt.and_then(|t| t.category); - // Conversation lines don't have a stable line_id; derive content_index - // from the occluded line text. The full cache key is - // (culture_id, npc_stable_id, content_type, content_index, tell_state), - // so a u16 hash collision requires two different lines from the same - // speaker with the same tell state to hash identically — at ~65K - // possible values this is extremely rare, and the worst outcome is - // a stale voiced line being served instead of base text. Acceptable. - let content_index = content_index_from_line_id(&event.occluded_line); - - let voiced = voiced_behavior( - &voice_cache.cache, - zone_id, - &voice_profile.culture_id, - event.speaker_id, - ContentType::Dialogue, - content_index, - tell_state, - &event.occluded_line, - false, - ); - - event.occluded_line = voiced; - } -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 3f03310c6..b7170a206 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -63,8 +63,7 @@ fn snapshot_roundtrip_over_unix_socket() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, rng_seed: None, diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index f13264122..43dac3268 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -49,8 +49,7 @@ fn snapshot_roundtrip_over_tcp() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, rng_seed: None, diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs index 66513a8a9..9b57743e8 100644 --- a/server/tests/error_handling.rs +++ b/server/tests/error_handling.rs @@ -288,8 +288,7 @@ fn snapshot_with_sim_errors_roundtrips() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, rng_seed: None, diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index 8f2fe5971..47a1a9933 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -39,8 +39,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot blocked_entities: vec![], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, rng_seed: None, @@ -238,8 +237,7 @@ fn generate_msgpack_fixtures() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, rng_seed: None, @@ -379,8 +377,7 @@ fn generate_msgpack_fixtures() { blocked_entities: vec![5, 6], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, rng_seed: Some(0xDEADBEEF), diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 77a1c37e3..3e4ec9109 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -5,8 +5,6 @@ 3 ], "character_pressure": null, - "conversation_ended": [], - "conversation_events": [], "current_monologue": null, "dialogue_response": null, "entities": [ diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 8ffa79777..9d3cc6f69 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -27,8 +27,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, rng_seed: None, @@ -293,8 +292,7 @@ fn snapshot_v2_fields_roundtrip() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, rng_seed: None, @@ -405,8 +403,7 @@ fn all_facing_direction_variants_roundtrip() { blocked_entities: vec![], scan_events: vec![], sound_events: vec![], - conversation_events: vec![], - conversation_ended: vec![], + follow_state: None, character_pressure: None, rng_seed: None, @@ -1456,8 +1453,7 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { "blocked_entities": [], "scan_events": [], "sound_events": [], - "conversation_events": [], - "conversation_ended": [], + "follow_state": null, "rng_seed": null });