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:
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user