refactor(simulation): strip archetype trace + HeritageRoot per cascade (#877, #878)

Sprint 37 dead-code sweep closing out two stale supersession chains:

#877 (D-167, 2026-03-24): Removes HeritageRoot type alias and
ZonePaletteModifier::Heritage variant from server/src/simulation/
generator.rs. The 7 abstract heritage roots were retired in favour of
the corridor cultural system; these two stubs were the only remaining
references.

#878 (D-032 + cascade rule): Strips the entire CharacterArchetype
(Smuggler/Detective) trace from the server. Per lead direction
2026-04-21 and the development cascade (CLAUDE.md), character/NPC/
verb-differentiation/monologue code is Phase 6 detail that should
not exist in code yet. The running archetype trace was pre-cascade
filler, not production — production is only the client's character-
creation UI and insert screens (client follow-up in #882).

Deleted:
- CharacterArchetype enum + StartupMessage.character_archetype field
- archetype_verb_label() + archetype branch of apply_phase2_verb_filter
  (D-057 character-verb differentiation — marked superseded)
- MonologueState.character partitioning
- Gauntlet archetype plumbing (setup_gauntlet no longer takes an archetype)
- server/content/schemas/drama_module.schema.yaml (zero Rust consumers)
- server/content/modules/tier1/smuggling_ring_v0_1.yaml
- server/tests/archetype_monologue.rs (regression guard for the removed system)
- server/tests/v01_integration_playthrough.rs (archetype-dependent)

Decision updates:
- decisions/content.md D-032 supersession rewritten to cite the cascade
  (v0.2 drop invalidated the prior D-117 framing).
- decisions/content.md D-035 tag taxonomy: `character` enum footnote
  updated; field noted as unused, do not reintroduce without a
  confirmed Phase 6 design.
- decisions/perception.md D-057: archetype-verb differentiation marked
  superseded.

Also bundles the types.rs version-field removal from #874 since the
file was already touched here.

Full trace audit in docs/architecture/sprint-37-878-audit.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-22 08:55:48 +02:00
co-authored by Claude Opus 4.6
parent c640563fc0
commit cae3d3ab85
22 changed files with 93 additions and 2444 deletions
-154
View File
@@ -1,154 +0,0 @@
//! 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 { seed: 0 });
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)"
);
}
}
@@ -432,6 +432,7 @@ fn minimal_save() -> SaveStateV1 {
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(),
}
}
-1
View File
@@ -268,7 +268,6 @@ fn snapshot_with_sim_errors_roundtrips() {
use settled_reach_server::simulation::time::{DayPhase, TickRate};
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 10,
game_time: GameTime {
day: 0,
-1
View File
@@ -68,7 +68,6 @@ fn player_moves_north_through_full_pipeline() {
rmp_serde::from_slice(&response).expect("deserialize snapshot");
// Snapshot captures state at end of tick 0 (before advance_tick increments to 1)
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(snapshot.tick, 0);
assert_eq!(snapshot.entities.len(), 1);
+3 -6
View File
@@ -16,10 +16,9 @@ fn write_fixture(name: &str, bytes: &[u8]) {
eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len());
}
/// Helper to create a minimal v2 snapshot for fixtures
/// Helper to create a minimal snapshot for fixtures (D-192: no version field)
fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
ObserverSnapshot {
version: PROTOCOL_VERSION,
tick,
game_time: GameTime {
day: 0,
@@ -181,7 +180,6 @@ fn generate_msgpack_fixtures() {
// v2 snapshot with visible_tiles and game_time populated
let snapshot_v2_full = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 500,
game_time: GameTime {
day: 1,
@@ -302,7 +300,7 @@ fn generate_msgpack_fixtures() {
// === #271 fixtures: named fixtures for cross-language Layer 1 testing ===
// snapshot_minimal: version=PROTOCOL_VERSION, tick=0, one Player entity, all optionals absent
// snapshot_minimal: tick=0, one Player entity, all optionals absent
let snapshot_minimal = fixture_snapshot(
0,
vec![VisibleEntity {
@@ -322,9 +320,8 @@ fn generate_msgpack_fixtures() {
&rmp_serde::to_vec_named(&snapshot_minimal).unwrap(),
);
// snapshot_full: version=PROTOCOL_VERSION, tick=42, monologue + dialogue + inventory + POIs + KG dump
// snapshot_full: tick=42, monologue + dialogue + inventory + POIs + KG dump
let snapshot_full = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 42,
game_time: GameTime {
day: 3,
+1 -4
View File
@@ -45,10 +45,7 @@ fn build_gauntlet(seed: u64) -> App {
app.add_plugins(NpcPlugin);
app.insert_resource(SimRng::new(seed));
test_world::setup_gauntlet(
&mut app,
settled_reach_server::bridge::types::CharacterArchetype::default(),
);
test_world::setup_gauntlet(&mut app);
app
}
+1
View File
@@ -214,6 +214,7 @@ fn save_state_npc_kg_isolation() {
contamination_active: false,
activated_count: 0,
last_activation_tick: None,
selected_bookmark: settled_reach_server::bookmark::SelectedBookmark::default(),
};
// Roundtrip: serialize → deserialize.
+3 -15
View File
@@ -76,19 +76,12 @@ fn server_subprocess_sends_snapshot_on_connect() {
let handshake_frame = read_framed(&mut reader)
.expect("read handshake frame")
.expect("server closed connection before sending handshake");
let handshake: HandshakeMessage =
// D-192: HandshakeMessage carries no version field. Verify it deserialises cleanly.
let _handshake: HandshakeMessage =
rmp_serde::from_slice(&handshake_frame).expect("deserialize HandshakeMessage");
assert_eq!(
handshake.protocol_version, PROTOCOL_VERSION,
"handshake protocol_version mismatch: got {}, expected {}",
handshake.protocol_version, PROTOCOL_VERSION
);
// 5. Send StartupMessage with world_seed (#175)
let startup = StartupMessage {
world_seed: 42,
character_archetype: settled_reach_server::bridge::types::CharacterArchetype::default(),
};
let startup = StartupMessage { world_seed: 42 };
let startup_payload = rmp_serde::to_vec_named(&startup).expect("serialize StartupMessage");
write_framed(&mut writer, &startup_payload).expect("send StartupMessage to server");
@@ -108,11 +101,6 @@ fn server_subprocess_sends_snapshot_on_connect() {
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
// 8. Assert protocol correctness (D-020)
assert_eq!(
snapshot.version, PROTOCOL_VERSION,
"protocol version mismatch: got {}, expected {}",
snapshot.version, PROTOCOL_VERSION
);
assert!(
snapshot.entities.len() > 0,
"snapshot should contain at least one entity (the player), got 0"
+3 -84
View File
@@ -4,10 +4,9 @@ use settled_reach_server::bridge::types::*;
use settled_reach_server::simulation::time::{DayPhase, TickRate};
use std::fs;
/// Helper to create a minimal v2 snapshot for tests
/// Helper to create a minimal snapshot for tests (D-192: no version field)
fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
ObserverSnapshot {
version: PROTOCOL_VERSION,
tick,
game_time: GameTime {
day: 0,
@@ -65,7 +64,6 @@ fn observer_snapshot_roundtrip() {
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.version, PROTOCOL_VERSION);
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);
assert_eq!(decoded.entities[0].entity_id, 1);
@@ -171,18 +169,13 @@ fn all_fixtures_deserialize() {
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
if name.starts_with("snapshot_boundary") {
// Boundary snapshot fixtures (#472): tick may exceed PROTOCOL_VERSION check
// Boundary snapshot fixtures (#472)
rmp_serde::from_slice::<ObserverSnapshot>(&bytes).unwrap_or_else(|e| {
panic!("deserialize boundary snapshot fixture {}: {}", name, e)
});
} else if name.starts_with("snapshot") {
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
assert_eq!(
snap.version, PROTOCOL_VERSION,
"fixture {} has wrong version",
name
);
} else if name.starts_with("input_batch") {
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
@@ -244,7 +237,6 @@ fn all_entity_kind_variants_roundtrip() {
#[test]
fn snapshot_v2_fields_roundtrip() {
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 100,
game_time: GameTime {
day: 3,
@@ -311,7 +303,6 @@ fn snapshot_v2_fields_roundtrip() {
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded.version, PROTOCOL_VERSION);
assert_eq!(decoded.game_time.day, 3);
assert_eq!(decoded.game_time.time_of_day, 720);
assert_eq!(decoded.game_time.day_phase, DayPhase::Evening);
@@ -354,17 +345,6 @@ fn entity_to_bits_roundtrip() {
}
}
/// PROTOCOL_VERSION constant matches snapshot version field
#[test]
fn protocol_version_constant_matches_snapshot() {
let snapshot = test_snapshot(0, vec![]);
assert_eq!(snapshot.version, PROTOCOL_VERSION);
assert_eq!(
PROTOCOL_VERSION, 21,
"bump this assertion when protocol version changes"
);
}
/// All FacingDirection variants round-trip
#[test]
fn all_facing_direction_variants_roundtrip() {
@@ -381,7 +361,6 @@ fn all_facing_direction_variants_roundtrip() {
for dir in directions {
let snapshot = ObserverSnapshot {
version: PROTOCOL_VERSION,
tick: 0,
game_time: GameTime {
day: 0,
@@ -525,7 +504,6 @@ fn v5_payload_deserializes_into_v6_struct() {
.expect("v5 payload should deserialize into v6 struct via serde(default)");
// New fields should get their defaults
assert_eq!(decoded.version, 5, "version field preserved from v5");
assert_eq!(decoded.tick, 42);
assert_eq!(
decoded.player_stance,
@@ -717,23 +695,6 @@ fn verb_kind_confront_roundtrip() {
assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront");
}
/// CharacterArchetype enum round-trips through MessagePack (#422).
/// Used in Phase 2 label relabeling — must survive the wire.
#[test]
fn all_character_archetype_variants_roundtrip() {
let archetypes = [CharacterArchetype::Smuggler, CharacterArchetype::Detective];
for archetype in archetypes {
let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize");
let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(
decoded, archetype,
"CharacterArchetype::{:?} roundtrip failed",
archetype
);
}
}
/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422).
/// Guards the contradiction flag survives serialization.
#[test]
@@ -1247,7 +1208,6 @@ fn v8_payload_deserializes_into_v9_struct() {
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
.expect("v8 payload should deserialize into v9 struct via serde(default)");
assert_eq!(decoded.version, 8, "version field preserved from v8");
assert_eq!(decoded.tick, 100);
assert!(
decoded.blocked_entities.is_empty(),
@@ -1321,7 +1281,6 @@ fn v9_payload_deserializes_into_v10_struct() {
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
.expect("v9 payload should deserialize into v10 struct via serde(default)");
assert_eq!(decoded.version, 9, "version field preserved from v9");
assert_eq!(decoded.tick, 200);
assert_eq!(
decoded.rng_seed, None,
@@ -1397,7 +1356,6 @@ fn v10_payload_deserializes_into_v11_struct() {
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
.expect("v10 payload should deserialize into v11 struct via serde(default)");
assert_eq!(decoded.version, 10, "version field preserved from v10");
assert_eq!(decoded.tick, 300);
assert_eq!(decoded.visible_tiles.len(), 1);
assert_eq!(
@@ -1458,8 +1416,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
let decoded: ObserverSnapshot =
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
// Version matches what was in the wire
assert_eq!(decoded.version, 13);
assert_eq!(decoded.tick, 42);
assert_eq!(decoded.entities.len(), 1);
@@ -1492,28 +1448,6 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
);
}
/// A snapshot with version != PROTOCOL_VERSION can be detected by checking
/// the version field after deserialization (#232 compatibility checking).
#[test]
fn snapshot_version_mismatch_is_detectable() {
let mut snapshot = test_snapshot(0, vec![]);
let future_version: u8 = PROTOCOL_VERSION + 1;
snapshot.version = future_version;
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
// The version field faithfully preserves the value — caller detects mismatch
assert_eq!(
decoded.version, future_version,
"version field must survive round-trip unchanged"
);
assert_ne!(
decoded.version, PROTOCOL_VERSION,
"client should detect this as a version mismatch"
);
}
/// tell_state=None is skipped in msgpack serialization (skip_serializing_if).
/// A snapshot with tell_state=None produces fewer bytes than one with
/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract.
@@ -1627,19 +1561,6 @@ fn all_verb_kind_variants_roundtrip_v232() {
}
}
/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232).
/// This guards against accidental widening of the version type.
#[test]
fn protocol_version_fits_in_u8() {
// u8 max is 255 — enough for ~242 more protocol iterations.
// If PROTOCOL_VERSION ever reaches 200, consider migrating to u16.
assert!(
PROTOCOL_VERSION <= 200,
"PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type",
PROTOCOL_VERSION
);
}
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
/// Verifies object_type=Some(Container) survives the wire.
#[test]
@@ -1687,7 +1608,6 @@ fn fixture_snapshot_minimal_fields() {
let snap: ObserverSnapshot =
rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal");
assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch");
assert_eq!(snap.tick, 0, "tick should be 0");
assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity");
assert_eq!(snap.entities[0].entity_id, 1);
@@ -1707,7 +1627,6 @@ fn fixture_snapshot_full_fields() {
let bytes = read_named_fixture("snapshot_full");
let snap: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize snapshot_full");
assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch");
assert_eq!(snap.tick, 42, "tick should be 42");
// Monologue
+2 -4
View File
@@ -108,12 +108,10 @@ fn no_deviation_component_does_not_produce_deviation_tell() {
/// 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,
};
use settled_reach_server::{simulation::SimulationPlugin, test_world};
let mut app = App::new();
app.add_plugins(SimulationPlugin { seed: 0 });
test_world::setup_gauntlet(&mut app, CharacterArchetype::default());
test_world::setup_gauntlet(&mut app);
app
}
-328
View File
@@ -1,328 +0,0 @@
//! 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();
}