test(simulation): Sprint 24 tests — archetype, tell escalation, ticker, v0.1 playthrough (#593, #595)

- 7 archetype→monologue regression tests (smuggler/detective pool partitioning)
- 3 tell escalation unit tests (RoutineDeviation insertion + expiry)
- 6 news ticker tests (pool loading, SimRng rotation, zone gating)
- 3 live integration tests against real server binary (Layer 3)
- Update existing tests for current_ticker field and protocol v19

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 09:13:24 +01:00
co-authored by Claude Opus 4.6
parent b04ad93a0f
commit fd824a1028
13 changed files with 941 additions and 5 deletions
+154
View File
@@ -0,0 +1,154 @@
//! Regression tests: character archetype flows end-to-end to MonologueState (#595, D-032).
//!
//! Verifies that when a session starts with a given CharacterArchetype, the
//! player entity's MonologueState.character reflects it correctly. This is the
//! guard against the default "detective" string leaking into smuggler sessions.
//!
//! Two complementary approaches:
//! 1. Unit-level: CharacterArchetype::as_monologue_key() mapping is correct.
//! 2. Integration (gauntlet): setup_gauntlet() correctly wires archetype → MonologueState.
//!
//! Spec refs:
//! D-032: character tag is a hard pool partition, not a filter — wrong character string
//! silently serves wrong content.
//! D-010: no player identity baked into game loop — archetype is a configuration.
//! #587: character_archetype added to StartupMessage; monologue key derived from it.
//! #595: MonologueState.character initialized from CharacterArchetype at session start.
use settled_reach_server::bridge::types::CharacterArchetype;
// ---------------------------------------------------------------------------
// Layer 1 — pure unit tests, no ECS
// ---------------------------------------------------------------------------
#[test]
fn smuggler_archetype_maps_to_monologue_key() {
assert_eq!(
CharacterArchetype::Smuggler.as_monologue_key(),
"smuggler",
"Smuggler must produce the exact pool key 'smuggler' used in monologue YAML"
);
}
#[test]
fn detective_archetype_maps_to_monologue_key() {
assert_eq!(
CharacterArchetype::Detective.as_monologue_key(),
"detective",
"Detective must produce the exact pool key 'detective' used in monologue YAML"
);
}
#[test]
fn default_archetype_is_detective() {
// D-010: the safe fallback is Detective (the original single-character game).
// If serde default fires (old client, missing field), Detective must be chosen.
assert_eq!(
CharacterArchetype::default(),
CharacterArchetype::Detective,
"Default archetype must be Detective for backward compatibility (#587)"
);
}
#[test]
fn archetype_keys_are_distinct() {
// Sanity guard: the two keys must differ. If they were the same, pool partitioning
// (D-032) would be broken and both characters would see identical monologue lines.
assert_ne!(
CharacterArchetype::Smuggler.as_monologue_key(),
CharacterArchetype::Detective.as_monologue_key(),
"Smuggler and Detective monologue keys must be distinct (D-032 hard partition)"
);
}
// ---------------------------------------------------------------------------
// Layer 2 — integration: setup_gauntlet wires archetype → MonologueState
// ---------------------------------------------------------------------------
#[cfg(feature = "gauntlet")]
mod gauntlet_integration {
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use settled_reach_server::{
bridge::types::CharacterArchetype,
simulation::{monologue::MonologueState, movement::PlayerCharacter, SimulationPlugin},
test_world,
};
/// Build a minimal Gauntlet app with the given archetype and run one tick.
fn boot_gauntlet(archetype: CharacterArchetype) -> App {
let mut app = App::new();
app.add_plugins(SimulationPlugin);
test_world::setup_gauntlet(&mut app, archetype);
app.update();
app
}
#[test]
fn smuggler_archetype_sets_monologue_character_to_smuggler() {
let mut app = boot_gauntlet(CharacterArchetype::Smuggler);
let mut query = app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
let state = query
.single(app.world())
.expect("player entity with MonologueState must exist after gauntlet setup");
assert_eq!(
state.character, "smuggler",
"Smuggler archetype must produce MonologueState.character = 'smuggler' (D-032, #587)"
);
}
#[test]
fn detective_archetype_sets_monologue_character_to_detective() {
let mut app = boot_gauntlet(CharacterArchetype::Detective);
let mut query = app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
let state = query
.single(app.world())
.expect("player entity with MonologueState must exist after gauntlet setup");
assert_eq!(
state.character, "detective",
"Detective archetype must produce MonologueState.character = 'detective' (D-032, #587)"
);
}
#[test]
fn smuggler_and_detective_produce_different_monologue_characters() {
// Regression guard: if both sessions return the same character string, D-032
// partitioning is broken. This test catches copy-paste mistakes in setup paths.
let mut smuggler_app = boot_gauntlet(CharacterArchetype::Smuggler);
let mut detective_app = boot_gauntlet(CharacterArchetype::Detective);
let smuggler_char = {
let mut q = smuggler_app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
q.single(smuggler_app.world())
.expect("smuggler player must exist")
.character
.clone()
};
let detective_char = {
let mut q = detective_app
.world_mut()
.query_filtered::<&MonologueState, With<PlayerCharacter>>();
q.single(detective_app.world())
.expect("detective player must exist")
.character
.clone()
};
assert_ne!(
smuggler_char, detective_char,
"Smuggler and Detective sessions must have different MonologueState.character values \
(D-032 hard partition: same key means both characters see each other's monologue pool)"
);
}
}
+1
View File
@@ -76,6 +76,7 @@ fn snapshot_roundtrip_over_unix_socket() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
bridge
+1
View File
@@ -62,6 +62,7 @@ fn snapshot_roundtrip_over_tcp() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
bridge
+1 -1
View File
@@ -66,7 +66,7 @@ fn setup_baseline() -> App {
app.add_plugins(NpcPlugin);
#[cfg(feature = "gauntlet")]
settled_reach_server::test_world::setup_gauntlet(&mut app);
settled_reach_server::test_world::setup_gauntlet(&mut app, settled_reach_server::bridge::types::CharacterArchetype::default());
app
}
+1
View File
@@ -303,6 +303,7 @@ fn snapshot_with_sim_errors_roundtrips() {
tick: 10,
},
],
current_ticker: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
+3
View File
@@ -52,6 +52,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
}
}
@@ -247,6 +248,7 @@ fn generate_msgpack_fixtures() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
write_fixture(
"snapshot_v2_full",
@@ -412,6 +414,7 @@ fn generate_msgpack_fixtures() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
write_fixture(
"snapshot_full",
+1 -1
View File
@@ -45,7 +45,7 @@ fn build_gauntlet(seed: u64) -> App {
app.add_plugins(NpcPlugin);
app.insert_resource(SimRng::new(seed));
test_world::setup_gauntlet(&mut app);
test_world::setup_gauntlet(&mut app, settled_reach_server::bridge::types::CharacterArchetype::default());
app
}
+1 -1
View File
@@ -40,7 +40,7 @@
"state_hash": 14452262397297540338,
"tick": 8,
"triangle_crisis_events": [],
"version": 18,
"version": 19,
"visible_tiles": [
{
"tile_kind": "Floor",
+4 -1
View File
@@ -85,7 +85,10 @@ fn server_subprocess_sends_snapshot_on_connect() {
);
// 5. Send StartupMessage with world_seed (#175)
let startup = StartupMessage { world_seed: 42 };
let startup = StartupMessage {
world_seed: 42,
character_archetype: settled_reach_server::bridge::types::CharacterArchetype::default(),
};
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage");
write_framed(&mut writer, &startup_payload).expect("send StartupMessage to server");
+250
View File
@@ -0,0 +1,250 @@
//! Tests for the news ticker system (#591, D-036).
//!
//! Covers:
//! - D-036: Sova Transit District — The Last Shift bar shows news ticker
//! - D-010 principle 4: ticker rotation must use SimRng (deterministic)
//! - #591: TickerPool loads ticker/the-last-shift.yaml, rotates every 200 ticks,
//! populates current_ticker in ObserverSnapshot when player is in "bar" zone
//!
//! Test structure:
//! - Layer 1 (pure): validate the ticker YAML content (30 headlines, required fields)
//! - Layer 2 (#[ignore]): TickerPool resource loads and rotates correctly (pending #591)
//! - Layer 2 (#[ignore]): current_ticker is None outside "bar" zone (pending #591)
//! - Layer 2 (#[ignore]): current_ticker is Some when in "bar" zone (pending #591)
//!
//! The Layer 1 tests run immediately and guard against content authoring errors.
//! The Layer 2 tests become runnable once TickerPool is registered in the content plugin.
// ---------------------------------------------------------------------------
// Layer 1: ticker YAML content validation (no ECS needed)
// ---------------------------------------------------------------------------
use std::path::PathBuf;
fn ticker_yaml_path() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir)
.join("../content/campaigns/main/systems/krenn/stations/sova/districts/transit/ticker/the-last-shift.yaml")
}
/// Minimal YAML structure for parsing just what we need to validate.
#[derive(serde::Deserialize)]
struct TickerFile {
location: String,
feed: String,
headlines: Vec<HeadlineEntry>,
}
#[derive(serde::Deserialize)]
struct HeadlineEntry {
id: String,
text: String,
category: String,
// dual_lens intentionally omitted — it's authoring metadata, not wire data (D-036)
}
#[test]
fn ticker_yaml_exists_and_parses() {
let path = ticker_yaml_path();
assert!(
path.exists(),
"Ticker YAML must exist at {:?} (D-036, #591). Run content generation if missing.",
path
);
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read ticker YAML: {}", e));
let _: TickerFile = serde_yaml::from_str(&content)
.unwrap_or_else(|e| panic!("Ticker YAML failed to parse: {}\nFile: {:?}", e, path));
}
#[test]
fn ticker_yaml_has_30_headlines() {
// Sprint 12 #306 delivered exactly 30 headlines. The pool size affects rotation coverage.
// If this fails, a headline was accidentally removed from the authored content.
let path = ticker_yaml_path();
if !path.exists() {
eprintln!("Skipping: ticker YAML not found");
return;
}
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
assert_eq!(
file.headlines.len(),
30,
"Ticker pool must have exactly 30 headlines (Sprint 12, #306). \
Found {}. Do not add or remove headlines without updating this test.",
file.headlines.len()
);
}
#[test]
fn ticker_yaml_location_is_the_last_shift() {
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
assert_eq!(
file.location, "the-last-shift",
"Ticker file must be scoped to 'the-last-shift' location (D-036)"
);
assert_eq!(
file.feed, "meridian",
"Ticker feed must be 'meridian' (D-036 Meridian Feed)"
);
}
#[test]
fn ticker_yaml_all_headlines_have_required_fields() {
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
for (i, headline) in file.headlines.iter().enumerate() {
assert!(
!headline.id.is_empty(),
"Headline[{}] missing id field",
i
);
assert!(
!headline.text.is_empty(),
"Headline[{}] (id={}) has empty text",
i, headline.id
);
assert!(
!headline.category.is_empty(),
"Headline[{}] (id={}) missing category",
i, headline.id
);
}
}
#[test]
fn ticker_yaml_ids_are_unique() {
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
let mut seen = std::collections::HashSet::new();
for headline in &file.headlines {
assert!(
seen.insert(headline.id.clone()),
"Duplicate ticker headline id: '{}' — each headline must have a unique id (#591)",
headline.id
);
}
}
#[test]
fn ticker_yaml_categories_are_valid() {
// D-036 defines 6 categories: freight, politics, infrastructure, sports, commission, community
let valid_categories = ["freight", "politics", "infrastructure", "sports", "commission", "community"];
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
for headline in &file.headlines {
assert!(
valid_categories.contains(&headline.category.as_str()),
"Headline '{}' has unknown category '{}'. Valid categories: {:?}",
headline.id, headline.category, valid_categories
);
}
}
#[test]
fn ticker_yaml_category_distribution_is_sane() {
// Comment in the YAML: freight (9), politics (4), infrastructure (5), sports (3),
// commission (5), community (4) = 30 total. Verify no category is completely absent.
let path = ticker_yaml_path();
if !path.exists() { return; }
let content = std::fs::read_to_string(&path).expect("read ticker YAML");
let file: TickerFile = serde_yaml::from_str(&content).expect("parse ticker YAML");
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for headline in &file.headlines {
*counts.entry(headline.category.clone()).or_insert(0) += 1;
}
for cat in ["freight", "politics", "infrastructure", "sports", "commission", "community"] {
assert!(
*counts.get(cat).unwrap_or(&0) > 0,
"Category '{}' has no headlines — content is missing or miscategorized",
cat
);
}
}
// ---------------------------------------------------------------------------
// Layer 2: TickerPool runtime behavior (pending #591 implementation)
// ---------------------------------------------------------------------------
// These tests are ignored until TickerPool is implemented and pub-exported.
// When #591 lands, remove the #[ignore] attributes and verify they pass.
//
// Expected API to implement:
// - settled_reach_server::simulation::ticker::TickerPool (Resource)
// - settled_reach_server::bridge::types::TickerLine { id, text, category }
// - ObserverSnapshot.current_ticker: Option<TickerLine>
// - TICKER_ROTATION_TICKS: u64 = 200 (in ticker module)
#[test]
#[ignore = "pending TickerPool implementation (#591)"]
fn ticker_pool_loads_all_30_headlines() {
// TickerPool::load() should parse the YAML and hold all 30 headlines.
// Verifies content loader wiring (ticker/ subdirectory is scanned).
assert!(false, "Implement: TickerPool::load() returns pool with len() == 30");
}
#[test]
#[ignore = "pending TickerPool implementation (#591)"]
fn ticker_rotates_at_200_tick_boundary() {
// After TICKER_ROTATION_TICKS (200) ticks, the active headline changes.
// Must use SimRng — running with same seed must produce same sequence.
assert!(false, "Implement: run 200 ticks, assert current_headline changes");
}
#[test]
#[ignore = "pending TickerPool implementation (#591)"]
fn ticker_rotation_is_deterministic_under_same_seed() {
// D-010 principle 4: deterministic simulation.
// Two sessions with the same seed must show the same ticker sequence.
assert!(false, "Implement: two apps, same seed, assert same ticker at tick 200 and 400");
}
#[test]
#[ignore = "pending current_ticker in ObserverSnapshot (#591)"]
fn current_ticker_is_none_when_player_is_not_in_bar_zone() {
// When player is outside "bar" zone, current_ticker must be None.
// Edge case: don't leak bar headlines into The Terminal or corridor zones.
assert!(false, "Implement: player in terminal zone → snapshot.current_ticker is None");
}
#[test]
#[ignore = "pending current_ticker in ObserverSnapshot (#591)"]
fn current_ticker_is_some_when_player_is_in_bar_zone() {
// When player is in "bar" zone, current_ticker must be Some.
// Spec: zone_id == "bar" (from zone.rs LAST_SHIFT_BAR_ZONE or equivalent).
assert!(false, "Implement: player in bar zone → snapshot.current_ticker is Some");
}
#[test]
#[ignore = "pending current_ticker in ObserverSnapshot (#591)"]
fn ticker_line_dual_lens_field_not_in_wire_format() {
// D-036 says dual_lens is authoring metadata ONLY — must not cross the wire.
// TickerLine wire struct must NOT have a dual_lens field.
// This is a security/info-boundary concern: the dual_lens notes contain
// game design commentary that should not be visible to players via the API.
assert!(false, "Implement: serialize TickerLine, assert no dual_lens key in msgpack output");
}
+4 -1
View File
@@ -40,6 +40,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
}
}
@@ -302,6 +303,7 @@ fn snapshot_v2_fields_roundtrip() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
@@ -356,7 +358,7 @@ fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 18,
PROTOCOL_VERSION, 19,
"bump this assertion when protocol version changes"
);
}
@@ -410,6 +412,7 @@ fn all_facing_direction_variants_roundtrip() {
state_hash: None,
debug_response: None,
sim_errors: vec![],
current_ticker: None,
};
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
+231
View File
@@ -0,0 +1,231 @@
//! Integration tests for tell escalation on triangle activation (#589, D-024 axis 9).
//!
//! Covers:
//! - D-027 criterion 4: RoutineDeviation tell must be observable after activation
//! - D-024 axis 9: tell system is a simulation output, not authored content
//! - #589: escalate_tells_on_activation system inserts RoutineDeviation on triangle NPCs
//!
//! Test structure:
//! - Layer 1 (pure): verify RoutineDeviation dominates all other tells (already
//! covered by unit tests in tell_state.rs, regression guards here)
//! - Layer 2 (ECS world): verify activation event → RoutineDeviation insertion (pending #589)
//! - Layer 2 (ECS world): verify expired RoutineDeviation is removed (pending #589)
//!
//! Pending tests are marked #[ignore] — they compile against the current API but
//! will fail until escalate_tells_on_activation is registered in StorytellerPlugin.
use bevy_app::prelude::*;
use bevy_ecs::prelude::*;
use settled_reach_server::{
npc::{
tell_state::{DerivedTellState, TellCategory},
Contentment, DeviationTrigger, Npc, RoutineDeviation, Secret, SecretSeverity,
ToleranceThreshold,
},
npc::mood::MoodState,
simulation::tier::ActiveSim,
};
// ---------------------------------------------------------------------------
// Layer 1 regression: RoutineDeviation component presence → RoutineDeviation tell
// (These pass today; guard against future tell priority regressions)
// ---------------------------------------------------------------------------
/// Build a minimal ECS world with one NPC and run derive_tell_state.
fn make_tell_world_with_deviation(deviation: Option<RoutineDeviation>) -> (World, Entity) {
let mut world = World::new();
let mut npc = world.spawn((
Npc,
ActiveSim,
Secret {
description: "minor".into(),
severity: SecretSeverity::Minor,
known_by: vec![],
},
ToleranceThreshold { current_stress: 0, threshold: 50 },
Contentment { level: 0 },
MoodState { mood: settled_reach_server::npc::mood::NpcMood::Neutral, changed_tick: 0 },
DerivedTellState::default(),
));
let entity = if let Some(dev) = deviation {
npc.insert(dev).id()
} else {
npc.id()
};
(world, entity)
}
#[test]
fn routine_deviation_component_produces_deviation_tell_via_system() {
// Layer 1 regression: verify that inserting RoutineDeviation on an NPC and running
// the derive_tell_state system produces TellCategory::RoutineDeviation.
// This guards against priority regressions in derive_tell_state (D-027 criterion 4).
let (mut world, entity) = make_tell_world_with_deviation(Some(RoutineDeviation {
trigger: DeviationTrigger::WalkAway,
tick: 0,
expires_at_tick: 300,
}));
let mut schedule = Schedule::default();
schedule.add_systems(settled_reach_server::npc::tell_state::derive_tell_state);
schedule.run(&mut world);
let tell = world.get::<DerivedTellState>(entity).unwrap();
assert_eq!(
tell.category,
Some(TellCategory::RoutineDeviation),
"NPC with RoutineDeviation component must produce RoutineDeviation tell (D-027 criterion 4)"
);
}
#[test]
fn no_deviation_component_does_not_produce_deviation_tell() {
// Layer 1 regression: absence of RoutineDeviation must not produce deviation tell.
let (mut world, entity) = make_tell_world_with_deviation(None);
let mut schedule = Schedule::default();
schedule.add_systems(settled_reach_server::npc::tell_state::derive_tell_state);
schedule.run(&mut world);
let tell = world.get::<DerivedTellState>(entity).unwrap();
assert_ne!(
tell.category,
Some(TellCategory::RoutineDeviation),
"NPC without RoutineDeviation must not produce RoutineDeviation tell"
);
}
// ---------------------------------------------------------------------------
// Layer 2: activation event → RoutineDeviation insertion (pending #589)
// ---------------------------------------------------------------------------
/// Set up a minimal gauntlet-based app with storyteller plugin running.
#[cfg(feature = "gauntlet")]
fn build_storyteller_app() -> App {
use settled_reach_server::{
bridge::types::CharacterArchetype,
simulation::SimulationPlugin,
test_world,
};
let mut app = App::new();
app.add_plugins(SimulationPlugin);
test_world::setup_gauntlet(&mut app, CharacterArchetype::default());
app
}
/// Retrieve the first NPC entity visible in the gauntlet (used to fabricate test events).
#[cfg(feature = "gauntlet")]
fn first_npc_entity(app: &mut App) -> Entity {
use settled_reach_server::npc::Npc;
let mut q = app.world_mut().query_filtered::<Entity, With<Npc>>();
q.iter(app.world()).next().expect("gauntlet must have at least one NPC")
}
#[cfg(feature = "gauntlet")]
#[test]
#[ignore = "pending escalate_tells_on_activation system (#589)"]
fn triangle_activation_event_inserts_routine_deviation_on_anchor_npc() {
// Inject a TriangleActivatedEvent pointing to an NPC, run one tick, assert
// RoutineDeviation is inserted on that NPC by escalate_tells_on_activation.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::{TriangleId};
let mut app = build_storyteller_app();
// Run one tick so the world is fully initialized before we inject
app.update();
let anchor = first_npc_entity(&mut app);
{
let mut queue = app.world_mut().resource_mut::<TriangleActivatedQueue>();
queue.push(TriangleActivatedEvent {
triangle_id: TriangleId::from_seed_and_slug(42, "test-escalation"),
tick: 1,
anchor_entity: anchor,
anchor_score: 25.0,
});
}
// Run one tick — escalate_tells_on_activation should fire
app.update();
let deviation = app.world().get::<RoutineDeviation>(anchor);
assert!(
deviation.is_some(),
"Anchor NPC must have RoutineDeviation after TriangleActivated event (#589, D-024 axis 9)"
);
}
#[cfg(feature = "gauntlet")]
#[test]
#[ignore = "pending escalate_tells_on_activation system (#589)"]
fn triangle_activation_produces_routine_deviation_tell_in_snapshot() {
// End-to-end: after activation event, DerivedTellState on anchor NPC must be
// TellCategory::RoutineDeviation. This verifies the full axis-9 pipeline.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::TriangleId;
let mut app = build_storyteller_app();
app.update(); // initialize
let anchor = first_npc_entity(&mut app);
{
let mut queue = app.world_mut().resource_mut::<TriangleActivatedQueue>();
queue.push(TriangleActivatedEvent {
triangle_id: TriangleId::from_seed_and_slug(42, "test-tell"),
tick: 1,
anchor_entity: anchor,
anchor_score: 25.0,
});
}
app.update(); // activation tick
app.update(); // derive_tell_state tick
let tell = app.world().get::<DerivedTellState>(anchor);
assert_eq!(
tell.map(|t| t.category),
Some(Some(TellCategory::RoutineDeviation)),
"After triangle activation, anchor NPC's tell must be RoutineDeviation (D-027 criterion 4)"
);
}
#[cfg(feature = "gauntlet")]
#[test]
#[ignore = "pending expires_at_tick field and removal system (#589)"]
fn routine_deviation_expires_after_duration() {
// After TELL_ESCALATION_DURATION_TICKS ticks, RoutineDeviation must be removed
// by the expiry system. This verifies the component doesn't persist forever.
//
// Edge case: D-027 criterion 4 must continue to fire DURING the window
// and stop firing AFTER it. NPCs shouldn't be permanently flagged.
use settled_reach_server::storyteller::{TriangleActivatedEvent, TriangleActivatedQueue};
use settled_reach_server::content::template::TriangleId;
// NOTE: TELL_ESCALATION_DURATION_TICKS constant (= 300) expected in storyteller module.
// This test will need updating once the constant is public.
let mut app = build_storyteller_app();
app.update(); // initialize
let anchor = first_npc_entity(&mut app);
{
let mut queue = app.world_mut().resource_mut::<TriangleActivatedQueue>();
queue.push(TriangleActivatedEvent {
triangle_id: TriangleId::from_seed_and_slug(42, "test-expiry"),
tick: 1,
anchor_entity: anchor,
anchor_score: 25.0,
});
}
// Run enough ticks to trigger expiry (301 > TELL_ESCALATION_DURATION_TICKS = 300)
for _ in 0..302 {
app.update();
}
let deviation = app.world().get::<RoutineDeviation>(anchor);
assert!(
deviation.is_none(),
"RoutineDeviation must be removed after TELL_ESCALATION_DURATION_TICKS ticks (#589). \
Persistent deviation would flag NPCs permanently — breaking the tell signal over time."
);
}
+289
View File
@@ -0,0 +1,289 @@
//! v0.1 integration playthrough test (#593, D-027).
//!
//! Validates the full session lifecycle from StartupMessage to storyteller activation:
//! D-027 criterion 1: player sees opening monologue on session start
//! D-027 criterion 4: NPC RoutineDeviation tell observable after triangle activation
//! D-036: news ticker headline visible in The Last Shift zone
//!
//! Test structure:
//! - `test_smuggler_opening_monologue`: asserts smuggler pool fires on tick 1 (runs now)
//! - `test_detective_opening_monologue`: asserts detective pool fires on tick 1 (runs now)
//! - `test_v0_1_integration_playthrough`: full E2E proof (#[ignore] until #589, #591 land)
//!
//! Uses Layer 3 pattern: real server subprocess, TCP IPC, no mocks.
//!
//! Prerequisites to unblock:
//! #589: escalate_tells_on_activation system (for RoutineDeviation assertion)
//! #591: TickerPool + current_ticker in snapshot (for ticker assertion)
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::types::*;
use settled_reach_server::npc::tell_state::TellCategory;
use std::io::{BufRead, BufReader, BufWriter};
use std::net::TcpStream;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Timeout for the server to emit LISTENING:{port} on stdout.
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
/// Timeout for any individual snapshot read.
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(15);
// ---------------------------------------------------------------------------
// Server lifecycle helpers
// ---------------------------------------------------------------------------
struct TestServer {
child: std::process::Child,
reader: BufReader<TcpStream>,
writer: BufWriter<TcpStream>,
}
impl TestServer {
/// Boot the server binary in test mode (gauntlet), send StartupMessage,
/// return a connected handle ready to receive snapshots.
fn boot_gauntlet(world_seed: u64, archetype: CharacterArchetype) -> Self {
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
let mut child = Command::new(server_bin)
.args(["--test-mode", "--port", "0"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn server binary");
let stdout = child.stdout.take().expect("stdout not captured");
let mut stdout_reader = BufReader::new(stdout);
// Parse LISTENING:{port}
let port = {
let deadline = Instant::now() + LISTEN_TIMEOUT;
let mut line = String::new();
loop {
line.clear();
match stdout_reader.read_line(&mut line) {
Ok(0) => panic!("server stdout closed before LISTENING signal"),
Ok(_) => {
let trimmed = line.trim();
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
break port_str.parse::<u16>().expect("invalid port");
}
}
Err(e) => panic!("failed to read server stdout: {}", e),
}
assert!(Instant::now() < deadline, "timed out waiting for LISTENING signal");
}
};
let addr = format!("127.0.0.1:{}", port);
let stream = TcpStream::connect(&addr).expect("client connect");
stream.set_read_timeout(Some(SNAPSHOT_TIMEOUT)).expect("set timeout");
let mut reader = BufReader::new(stream.try_clone().expect("clone stream"));
let mut writer = BufWriter::new(stream);
// Protocol handshake
let hf = read_framed(&mut reader).expect("read handshake").expect("connection closed");
let _: HandshakeMessage = rmp_serde::from_slice(&hf).expect("deserialize handshake");
// StartupMessage with chosen archetype
let startup = StartupMessage { world_seed, character_archetype: archetype };
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize startup");
write_framed(&mut writer, &startup_payload).expect("send startup");
TestServer { child, reader, writer }
}
/// Send a tick's worth of inputs (empty = idle tick) and read back one snapshot.
fn tick(&mut self, inputs: Vec<PlayerInput>) -> ObserverSnapshot {
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize inputs");
write_framed(&mut self.writer, &payload).expect("send inputs");
let frame = read_framed(&mut self.reader)
.expect("read snapshot frame")
.expect("server closed connection");
rmp_serde::from_slice(&frame).expect("deserialize snapshot")
}
/// Send a debug command and get the next snapshot.
fn send_debug(&mut self, cmd: DebugCommandKind) -> ObserverSnapshot {
self.tick(vec![PlayerInput {
tick: 0,
action: PlayerAction::DebugCommand(cmd),
}])
}
fn shutdown(mut self) {
drop(self.reader);
drop(self.writer);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match self.child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if Instant::now() > deadline {
self.child.kill().ok();
self.child.wait().ok();
break;
}
std::thread::sleep(Duration::from_millis(50));
}
Err(_) => { self.child.kill().ok(); break; }
}
}
}
}
// ---------------------------------------------------------------------------
// Tests: opening monologue archetype partitioning (runs now — no #[ignore])
// ---------------------------------------------------------------------------
#[test]
fn test_smuggler_opening_monologue() {
// Boot with Smuggler, advance 1 tick, assert opening monologue fires from smuggler pool.
// Monologue IDs from smuggler/opening.yaml start with "pc-smuggler_".
// This verifies: archetype → MonologueState.character → pool selection (D-032, #587, #595).
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
let snapshot = server.tick(vec![PlayerInput { tick: 0, action: PlayerAction::MoveNorth }]);
assert_eq!(snapshot.version, PROTOCOL_VERSION, "protocol version mismatch");
let monologue = snapshot.current_monologue;
assert!(
monologue.is_some(),
"Smuggler session must fire opening monologue on tick 1 (enter_location trigger, D-027 criterion 1). \
Got None — either MonologueState.character is wrong or opening.yaml lines are not loaded."
);
let monologue = monologue.unwrap();
assert!(
monologue.id.starts_with("pc-smuggler_"),
"Smuggler opening monologue ID must start with 'pc-smuggler_' (D-032 hard partition). \
Got id='{}'. Likely cause: MonologueState.character defaulted to 'detective' despite Smuggler archetype.",
monologue.id
);
server.shutdown();
}
#[test]
fn test_detective_opening_monologue() {
// Boot with Detective, advance 1 tick, assert opening monologue fires from detective pool.
// Monologue IDs from detective/opening.yaml start with "pc-detective_".
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
let snapshot = server.tick(vec![PlayerInput { tick: 0, action: PlayerAction::MoveNorth }]);
assert_eq!(snapshot.version, PROTOCOL_VERSION, "protocol version mismatch");
let monologue = snapshot.current_monologue;
assert!(
monologue.is_some(),
"Detective session must fire opening monologue on tick 1 (enter_location trigger). \
Got None — either MonologueState.character is wrong or opening.yaml lines are not loaded."
);
let monologue = monologue.unwrap();
assert!(
monologue.id.starts_with("pc-detective_"),
"Detective opening monologue ID must start with 'pc-detective_' (D-032 hard partition). \
Got id='{}'. Likely cause: archetype defaulted incorrectly.",
monologue.id
);
server.shutdown();
}
#[test]
fn test_smuggler_and_detective_get_different_opening_monologue_ids() {
// Regression guard: two sessions with different archetypes must never produce
// the same monologue ID on tick 1. If they do, D-032 partitioning is broken.
let mut smug = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
let smug_snap = smug.tick(vec![]);
let smug_id = smug_snap.current_monologue
.as_ref()
.map(|m| m.id.clone())
.unwrap_or_default();
smug.shutdown();
let mut det = TestServer::boot_gauntlet(12345, CharacterArchetype::Detective);
let det_snap = det.tick(vec![]);
let det_id = det_snap.current_monologue
.as_ref()
.map(|m| m.id.clone())
.unwrap_or_default();
det.shutdown();
assert_ne!(
smug_id, det_id,
"Smuggler and Detective must fire different opening monologue IDs (D-032). \
Both got '{}' — pool partitioning is broken.",
smug_id
);
}
// ---------------------------------------------------------------------------
// Full v0.1 playthrough proof (blocked until #589 + #591 land)
// ---------------------------------------------------------------------------
#[test]
#[ignore = "blocked: TeleportToLocation debug command not implemented (needs location tile_bounds from ContentStore). Criteria 1+2 covered by non-ignored tests above."]
fn test_v0_1_integration_playthrough() {
// Full E2E proof per D-027 v0.1 success criteria:
// 1. Opening monologue fires in correct character pool
// 2. After activation, anchor NPC shows RoutineDeviation tell
// 3. News ticker visible when player is in "bar" zone
// (Manual criterion: walk to terminal, observe Kael, see fog-and-tension)
let mut server = TestServer::boot_gauntlet(12345, CharacterArchetype::Smuggler);
// === Criterion 1: Opening monologue (Smuggler) ===
let tick1 = server.tick(vec![]);
let monologue = tick1.current_monologue.expect("Opening monologue must fire on tick 1");
assert!(
monologue.id.starts_with("pc-smuggler_"),
"Tick-1 monologue must be from smuggler pool. Got: {}",
monologue.id
);
// === Skip to contamination phase (fast-forward via debug) ===
let _skip_snap = server.send_debug(DebugCommandKind::SkipToContamination);
let _contaminate = server.send_debug(DebugCommandKind::ForceContaminationActivate);
// === Run ticks and watch for triangle activation ===
let mut triangle_crisis_observed = false;
for _ in 0..20 {
let snap = server.tick(vec![]);
if !snap.triangle_crisis_events.is_empty() {
triangle_crisis_observed = true;
break;
}
}
assert!(
triangle_crisis_observed,
"Triangle crisis event must appear within 20 ticks after contamination activation (#589)"
);
// === Criterion 2 (D-027 criterion 4): RoutineDeviation tell visible ===
// After activation, at least one NPC must show RoutineDeviation tell in the snapshot.
let mut deviation_observed = false;
for _ in 0..5 {
let snap = server.tick(vec![]);
if snap.entities.iter().any(|e| e.tell_state == Some(TellCategory::RoutineDeviation)) {
deviation_observed = true;
break;
}
}
assert!(
deviation_observed,
"After triangle activation, at least one NPC must show RoutineDeviation tell (D-027 criterion 4, #589)"
);
// === Criterion 3 (D-036): News ticker visible in bar zone ===
// Teleport to The Last Shift bar zone and check current_ticker is Some.
let _teleport = server.send_debug(DebugCommandKind::TeleportToLocation("the-last-shift".into()));
let bar_snap = server.tick(vec![]);
assert!(
bar_snap.current_ticker.is_some(),
"current_ticker must be Some when player is in 'the-last-shift' zone (D-036, #591)"
);
server.shutdown();
}