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:
+15
-100
@@ -10,24 +10,15 @@ pub use crate::knowledge::types::{
|
||||
};
|
||||
pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
|
||||
/// Wire protocol version for ObserverSnapshot.
|
||||
///
|
||||
/// Versioning strategy: flat struct + serde defaults for field evolution.
|
||||
/// Client and server are co-versioned (subprocess IPC per D-020), so protocol
|
||||
/// negotiation is unnecessary. Client should reject snapshots with version !=
|
||||
/// PROTOCOL_VERSION. New fields use #[serde(default)] only during the migration
|
||||
/// period, then the default is removed once both sides are updated.
|
||||
pub const PROTOCOL_VERSION: u8 = 23;
|
||||
|
||||
/// Handshake message sent as the very first framed message after connection (#555).
|
||||
/// Client reads this before entering the normal tick loop and validates
|
||||
/// `protocol_version` against its own `PROTOCOL_VERSION` constant.
|
||||
/// Client reads this before entering the normal tick loop, then sends StartupMessage.
|
||||
/// Wire format: MessagePack, same 4-byte length-prefixed framing as ObserverSnapshot.
|
||||
///
|
||||
/// No version field — D-192 dropped the lockstep version check. Client and server
|
||||
/// are always co-shipped (D-005); genuine schema drift surfaces as a downstream
|
||||
/// MessagePack missing-field error rather than an eager handshake rejection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HandshakeMessage {
|
||||
/// Must match client's PROTOCOL_VERSION or the client should disconnect.
|
||||
pub protocol_version: u8,
|
||||
}
|
||||
pub struct HandshakeMessage {}
|
||||
|
||||
/// Startup message sent by the client after receiving HandshakeMessage (#175).
|
||||
/// Contains the world seed for deterministic simulation (D-010, D-029).
|
||||
@@ -46,12 +37,6 @@ pub struct StartupMessage {
|
||||
/// Generated by SessionManager.new_game() on the client.
|
||||
/// Same seed → same EntanglementConfig → same NPC population (D-029).
|
||||
pub world_seed: u64,
|
||||
/// Character archetype selected by the player (#587).
|
||||
/// Gates monologue pool selection, verb labels, and examine text.
|
||||
/// Defaults to Detective for backward compatibility (old clients
|
||||
/// that omit this field).
|
||||
#[serde(default)]
|
||||
pub character_archetype: CharacterArchetype,
|
||||
}
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
@@ -79,7 +64,8 @@ pub struct StartupMessage {
|
||||
/// v17 adds: state_hash (#85, desync detection — fast hash of player pos + NPC count + tick),
|
||||
/// sim_errors (#85, structured error reporting to client).
|
||||
/// v18 adds: debug_response (#580, debug console server — command/response wire).
|
||||
/// v19 adds: character_archetype on StartupMessage (#587), current_ticker (#591).
|
||||
/// v19 adds: current_ticker (#591). (character_archetype on StartupMessage was
|
||||
/// added in #587 and removed in Sprint 37 per D-032 purge / cascade cleanup.)
|
||||
/// v20 adds: settings_response (#627, SQLite settings IPC).
|
||||
/// v21 adds: economy_snapshot (#822, D-181 7-signal snapshot per queried system),
|
||||
/// EconStateQuery PlayerAction variant (#822).
|
||||
@@ -89,8 +75,6 @@ pub struct StartupMessage {
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. See [`PROTOCOL_VERSION`].
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
/// Game time data for client HUD display (D-031)
|
||||
@@ -495,28 +479,6 @@ pub enum ObjectType {
|
||||
Furniture,
|
||||
}
|
||||
|
||||
/// Character archetype for Phase 2 verb filtering (#422) and monologue pool
|
||||
/// selection. Determines how the character perceives and labels interactions.
|
||||
/// v0.1: Smuggler and Detective (the two playable characters).
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum CharacterArchetype {
|
||||
/// Smuggler character — sees Move/Stash on containers, physical manipulation verbs
|
||||
Smuggler,
|
||||
/// Detective character — sees Scan/Flag on containers, investigation verbs
|
||||
#[default]
|
||||
Detective,
|
||||
}
|
||||
|
||||
impl CharacterArchetype {
|
||||
/// String key for monologue pool filtering (#587).
|
||||
pub fn as_monologue_key(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Smuggler => "smuggler",
|
||||
Self::Detective => "detective",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic player actions, not raw key events (D-020)
|
||||
/// Timestamped for deterministic processing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -1105,73 +1067,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn handshake_message_roundtrip() {
|
||||
let msg = HandshakeMessage {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
};
|
||||
// D-192: HandshakeMessage carries no version field; roundtrip verifies
|
||||
// the empty struct serialises and deserialises cleanly.
|
||||
let msg = HandshakeMessage {};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded, msg);
|
||||
assert_eq!(decoded.protocol_version, PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handshake_message_rejects_wrong_version() {
|
||||
let msg = HandshakeMessage {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
// Simulate client-side validation: version mismatch should be detectable
|
||||
let wrong_version = PROTOCOL_VERSION.wrapping_add(1);
|
||||
assert_ne!(decoded.protocol_version, wrong_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_roundtrip() {
|
||||
let msg = StartupMessage {
|
||||
world_seed: 0xDEADBEEF,
|
||||
character_archetype: CharacterArchetype::Detective,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded, msg);
|
||||
assert_eq!(decoded.world_seed, 0xDEADBEEF);
|
||||
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_smuggler_roundtrip() {
|
||||
let msg = StartupMessage {
|
||||
world_seed: 42,
|
||||
character_archetype: CharacterArchetype::Smuggler,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded, msg);
|
||||
assert_eq!(decoded.character_archetype, CharacterArchetype::Smuggler);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_missing_archetype_defaults_to_detective() {
|
||||
// Simulate an old client that sends only world_seed (no character_archetype).
|
||||
// serde(default) on StartupMessage.character_archetype should default to Detective.
|
||||
#[derive(Serialize)]
|
||||
struct OldStartupMessage {
|
||||
world_seed: u64,
|
||||
}
|
||||
let old = OldStartupMessage { world_seed: 99 };
|
||||
let bytes = rmp_serde::to_vec_named(&old).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.world_seed, 99);
|
||||
assert_eq!(decoded.character_archetype, CharacterArchetype::Detective);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_message_zero_seed() {
|
||||
let msg = StartupMessage {
|
||||
world_seed: 0,
|
||||
character_archetype: CharacterArchetype::default(),
|
||||
};
|
||||
let msg = StartupMessage { world_seed: 0 };
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.world_seed, 0);
|
||||
@@ -1181,7 +1098,6 @@ mod tests {
|
||||
fn startup_message_max_seed() {
|
||||
let msg = StartupMessage {
|
||||
world_seed: u64::MAX,
|
||||
character_archetype: CharacterArchetype::default(),
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let decoded: StartupMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -1191,10 +1107,9 @@ mod tests {
|
||||
#[test]
|
||||
fn handshake_is_distinct_from_snapshot() {
|
||||
// HandshakeMessage and ObserverSnapshot are different types on the wire.
|
||||
// A HandshakeMessage should NOT deserialize as an ObserverSnapshot.
|
||||
let msg = HandshakeMessage {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
};
|
||||
// A HandshakeMessage (empty map) must NOT deserialize as an ObserverSnapshot
|
||||
// because ObserverSnapshot has required fields (tick, game_time, etc.).
|
||||
let msg = HandshakeMessage {};
|
||||
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
|
||||
let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes);
|
||||
assert!(
|
||||
|
||||
@@ -78,7 +78,6 @@ pub fn compute_observer_snapshot(
|
||||
&mut NearbyInteractionBuffer,
|
||||
&mut MonologueBuffer,
|
||||
Option<&Stance>,
|
||||
Option<&CharacterArchetype>,
|
||||
Option<&mut SprintAnomalyQueue>,
|
||||
Option<&CognitiveDelay>,
|
||||
Option<&mut DialogueResponseBuffer>,
|
||||
@@ -114,7 +113,6 @@ pub fn compute_observer_snapshot(
|
||||
mut interaction_buffer,
|
||||
mut monologue_buffer,
|
||||
stance_opt,
|
||||
archetype_opt,
|
||||
mut anomaly_queue_opt,
|
||||
cognitive_delay_opt,
|
||||
mut dialogue_response_opt,
|
||||
@@ -135,8 +133,6 @@ pub fn compute_observer_snapshot(
|
||||
.map(|f| f.0)
|
||||
.unwrap_or(FacingDirection::default());
|
||||
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
// Collect player inventory (D-065 info boundary: only own items)
|
||||
let player_inventory = registry
|
||||
.to_stable(observer_entity)
|
||||
@@ -197,7 +193,7 @@ pub fn compute_observer_snapshot(
|
||||
|
||||
// Take interactions and apply Phase 2 verb filter (D-057, #422)
|
||||
let mut nearby_interactions = interaction_buffer.take();
|
||||
apply_phase2_verb_filter(&mut nearby_interactions, observer_kg, archetype);
|
||||
apply_phase2_verb_filter(&mut nearby_interactions, observer_kg);
|
||||
|
||||
tracing::trace!(
|
||||
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
|
||||
@@ -453,7 +449,6 @@ pub fn compute_observer_snapshot(
|
||||
};
|
||||
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: facing,
|
||||
@@ -654,7 +649,6 @@ fn collect_remembered_entities(
|
||||
/// 1. POI priority flips (D-060) — ExamineNpc above Talk for POI entities
|
||||
/// 2. Confront injection — adds Confront verb for NPCs when KnowsDetails+
|
||||
/// 3. Contradiction marking — sets contradicted flag when entity knowledge is Contradicted
|
||||
/// 4. Archetype label relabeling — smuggler/detective see different labels for same verb
|
||||
///
|
||||
/// Phase boundary: Phase 1 (interaction.rs) determines verb availability from
|
||||
/// ObjectType + proximity. Phase 2 (here) reads the observer's KnowledgeGraph
|
||||
@@ -663,7 +657,6 @@ fn collect_remembered_entities(
|
||||
fn apply_phase2_verb_filter(
|
||||
interactions: &mut [NearbyInteraction],
|
||||
observer_kg: &KnowledgeGraph,
|
||||
archetype: CharacterArchetype,
|
||||
) {
|
||||
for interaction in interactions.iter_mut() {
|
||||
let stable_id = StableId(interaction.entity_id);
|
||||
@@ -713,18 +706,6 @@ fn apply_phase2_verb_filter(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Archetype label relabeling ---
|
||||
// Phase 2 swaps verb labels based on character archetype.
|
||||
// The VerbKind stays the same (same handler), only the display label changes.
|
||||
// This implements D-057: "Character differentiation via Phase 2 observer
|
||||
// filter, not separate verb systems."
|
||||
for verb in &mut interaction.verbs {
|
||||
if let Some(label) = archetype_verb_label(archetype, interaction.object_type, verb.kind)
|
||||
{
|
||||
verb.label = label.into();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sort after priority changes and verb additions
|
||||
interaction
|
||||
.verbs
|
||||
@@ -732,37 +713,5 @@ fn apply_phase2_verb_filter(
|
||||
}
|
||||
}
|
||||
|
||||
/// Archetype-specific verb label overrides (#422, D-057).
|
||||
///
|
||||
/// Returns a replacement label for the given (archetype, object_type, verb_kind)
|
||||
/// combination, or None to keep the Phase 1 default label.
|
||||
///
|
||||
/// v0.1: Container verbs differ by archetype. Other object types keep defaults.
|
||||
/// Add match arms here for future archetype-specific labels.
|
||||
fn archetype_verb_label(
|
||||
archetype: CharacterArchetype,
|
||||
object_type: Option<ObjectType>,
|
||||
kind: VerbKind,
|
||||
) -> Option<&'static str> {
|
||||
match (archetype, object_type, kind) {
|
||||
// Smuggler: Container verbs — physical manipulation vocabulary
|
||||
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Open) => Some("Move"),
|
||||
(CharacterArchetype::Smuggler, Some(ObjectType::Container), VerbKind::Search) => {
|
||||
Some("Stash")
|
||||
}
|
||||
|
||||
// Detective: Container verbs — investigation vocabulary
|
||||
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Open) => {
|
||||
Some("Scan")
|
||||
}
|
||||
(CharacterArchetype::Detective, Some(ObjectType::Container), VerbKind::Search) => {
|
||||
Some("Flag")
|
||||
}
|
||||
|
||||
// All other combinations: keep Phase 1 default label
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -59,7 +59,6 @@ fn player_always_visible_in_snapshot() {
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
|
||||
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
|
||||
@@ -679,10 +678,6 @@ fn snapshot_v6_fields_default_through_pipeline() {
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
|
||||
assert_eq!(
|
||||
snapshot.version, PROTOCOL_VERSION,
|
||||
"should be current protocol version"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.player_stance,
|
||||
MovementStance::Walk,
|
||||
@@ -694,29 +689,6 @@ fn snapshot_v6_fields_default_through_pipeline() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_v6_version_is_protocol_version() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.version,
|
||||
crate::bridge::types::PROTOCOL_VERSION,
|
||||
"snapshot version must match PROTOCOL_VERSION constant"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2 verb filter tests (#422, D-057)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -967,148 +939,9 @@ fn phase2_no_contradiction_for_active_knowledge() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase2_smuggler_relabels_container_verbs() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Container at close range, north of player
|
||||
let container = world
|
||||
.spawn((
|
||||
TilePosition::new(16, 15, 0),
|
||||
crate::simulation::interaction::Interactable,
|
||||
ObjectType::Container,
|
||||
))
|
||||
.id();
|
||||
registry.register(container);
|
||||
|
||||
// Smuggler player
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
CharacterArchetype::Smuggler,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_full_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.nearby_interactions.len(), 1);
|
||||
let interaction = &snapshot.nearby_interactions[0];
|
||||
// Container at close range: Open→"Move", Search→"Stash", Observe stays "Observe"
|
||||
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
|
||||
let search_verb = interaction
|
||||
.verbs
|
||||
.iter()
|
||||
.find(|v| v.kind == VerbKind::Search);
|
||||
let observe_verb = interaction
|
||||
.verbs
|
||||
.iter()
|
||||
.find(|v| v.kind == VerbKind::Observe);
|
||||
assert_eq!(open_verb.unwrap().label, "Move", "smuggler Open→Move");
|
||||
assert_eq!(search_verb.unwrap().label, "Stash", "smuggler Search→Stash");
|
||||
assert_eq!(observe_verb.unwrap().label, "Observe", "Observe unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase2_detective_relabels_container_verbs() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let container = world
|
||||
.spawn((
|
||||
TilePosition::new(16, 15, 0),
|
||||
crate::simulation::interaction::Interactable,
|
||||
ObjectType::Container,
|
||||
))
|
||||
.id();
|
||||
registry.register(container);
|
||||
|
||||
// Detective player (explicit)
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
CharacterArchetype::Detective,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_full_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.nearby_interactions.len(), 1);
|
||||
let interaction = &snapshot.nearby_interactions[0];
|
||||
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
|
||||
let search_verb = interaction
|
||||
.verbs
|
||||
.iter()
|
||||
.find(|v| v.kind == VerbKind::Search);
|
||||
assert_eq!(open_verb.unwrap().label, "Scan", "detective Open→Scan");
|
||||
assert_eq!(search_verb.unwrap().label, "Flag", "detective Search→Flag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase2_default_archetype_is_detective() {
|
||||
// When no CharacterArchetype component attached, defaults to Detective
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let container = world
|
||||
.spawn((
|
||||
TilePosition::new(16, 15, 0),
|
||||
crate::simulation::interaction::Interactable,
|
||||
ObjectType::Container,
|
||||
))
|
||||
.id();
|
||||
registry.register(container);
|
||||
|
||||
// Player WITHOUT CharacterArchetype component
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_full_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.nearby_interactions.len(), 1);
|
||||
let interaction = &snapshot.nearby_interactions[0];
|
||||
// Default = Detective labels
|
||||
let open_verb = interaction.verbs.iter().find(|v| v.kind == VerbKind::Open);
|
||||
assert_eq!(
|
||||
open_verb.unwrap().label,
|
||||
"Scan",
|
||||
"default archetype should use Detective labels"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase2_non_container_keeps_default_labels() {
|
||||
// Readable objects should keep their default labels regardless of archetype
|
||||
// Readable objects keep their default labels (post-archetype cleanup, D-032 SUPERSEDED).
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
@@ -1129,7 +962,6 @@ fn phase2_non_container_keeps_default_labels() {
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
CharacterArchetype::Smuggler,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
@@ -1145,7 +977,7 @@ fn phase2_non_container_keeps_default_labels() {
|
||||
assert_eq!(
|
||||
read_verb.unwrap().label,
|
||||
"Read",
|
||||
"Readable labels unchanged for smuggler"
|
||||
"Readable labels unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::bridge::types::CharacterArchetype;
|
||||
use crate::knowledge::events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||
use crate::knowledge::EntityRegistry;
|
||||
use crate::npc::mood::{MoodState, NpcMood};
|
||||
@@ -37,11 +36,12 @@ pub struct ExamineRequest {
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// Character-filtered examination result for snapshot delivery.
|
||||
/// Examination result for snapshot delivery.
|
||||
///
|
||||
/// Content differs per CharacterArchetype:
|
||||
/// Smuggler — physical threat read, cargo-handling posture, opportunity windows.
|
||||
/// Detective — procedural tells, behavioral inconsistencies, stress indicators.
|
||||
/// Phase 6 note: per-archetype text variants (smuggler/detective flavor) were
|
||||
/// removed during the cascade cleanup. Text is a single unified "subject read"
|
||||
/// until archetype differentiation is reintroduced per the culture-driven
|
||||
/// voice system (D-121) in a later cascade phase.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExamineResultEvent {
|
||||
/// Character-filtered observation text for client display.
|
||||
@@ -106,12 +106,15 @@ fn has_trait(traits_opt: Option<&PersonalityTraits>, t: PersonalityTrait) -> boo
|
||||
traits_opt.map(|p| p.traits.contains(&t)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Generate character-filtered examination text from NPC component state.
|
||||
/// Generate examination text from NPC component state.
|
||||
/// All logic is pure, deterministic, and integer-based (D-010).
|
||||
///
|
||||
/// Archetype-specific text variants were removed during the cascade cleanup
|
||||
/// (D-032 / Sprint 37). Reintroduce per-culture voice when the cascade reaches
|
||||
/// the Phase 6 character/NPC layer (see D-121).
|
||||
pub fn generate_examine_text(
|
||||
mood: NpcMood,
|
||||
ratio: u8,
|
||||
archetype: CharacterArchetype,
|
||||
traits_opt: Option<&PersonalityTraits>,
|
||||
) -> String {
|
||||
let stress_label = match ratio {
|
||||
@@ -123,53 +126,29 @@ pub fn generate_examine_text(
|
||||
|
||||
let mood_label = mood_word(mood);
|
||||
|
||||
match archetype {
|
||||
CharacterArchetype::Smuggler => {
|
||||
// Physical threat read + cargo opportunity window
|
||||
let threat = if matches!(mood, NpcMood::Hostile | NpcMood::Suspicious) {
|
||||
"Threat posture. Don't push it."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Bold) {
|
||||
"Confident bearing. Will push back if cornered."
|
||||
} else if has_trait(traits_opt, PersonalityTrait::Cautious) {
|
||||
"Nervous type. Predictable under pressure."
|
||||
} else {
|
||||
"No obvious threat read."
|
||||
};
|
||||
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
||||
"Controlled affect — practiced concealment."
|
||||
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
||||
"Involuntary stress markers present."
|
||||
} else if matches!(mood, NpcMood::Suspicious) {
|
||||
"Scanning. Aware of being observed."
|
||||
} else if matches!(mood, NpcMood::Hostile) {
|
||||
"Threat posture. Aware of being observed."
|
||||
} else {
|
||||
"Baseline presentation."
|
||||
};
|
||||
|
||||
let window = if ratio > 60 {
|
||||
"Too distracted to track cargo movement."
|
||||
} else if matches!(mood, NpcMood::Focused) {
|
||||
"Paying close attention to this section."
|
||||
} else {
|
||||
"Standard patrol pattern. Window is there."
|
||||
};
|
||||
let read = if ratio > 60 {
|
||||
"Under pressure — potential liability or asset."
|
||||
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
||||
"Comfortable. Less guarded than usual."
|
||||
} else if matches!(mood, NpcMood::Focused) {
|
||||
"Paying close attention."
|
||||
} else {
|
||||
"Routine behavior pattern."
|
||||
};
|
||||
|
||||
format!("Appears {mood_label}, {stress_label}. {threat} {window}")
|
||||
}
|
||||
|
||||
CharacterArchetype::Detective => {
|
||||
// Procedural tells + behavioral read
|
||||
let tell = if has_trait(traits_opt, PersonalityTrait::Deceptive) {
|
||||
"Controlled affect — practiced concealment."
|
||||
} else if matches!(mood, NpcMood::Anxious | NpcMood::Frustrated) {
|
||||
"Involuntary stress markers present."
|
||||
} else if matches!(mood, NpcMood::Suspicious) {
|
||||
"Scanning. Aware of being observed."
|
||||
} else {
|
||||
"Baseline presentation."
|
||||
};
|
||||
|
||||
let read = if ratio > 60 {
|
||||
"Under pressure — potential liability or asset."
|
||||
} else if matches!(mood, NpcMood::Content | NpcMood::Warm) {
|
||||
"Comfortable. Less guarded than usual."
|
||||
} else {
|
||||
"Routine behavior pattern."
|
||||
};
|
||||
|
||||
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
||||
}
|
||||
}
|
||||
format!("Subject: {mood_label}, {stress_label}. {tell} {read}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,7 +175,6 @@ pub fn process_examine_interaction(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
&ExamineRequest,
|
||||
Option<&CharacterArchetype>,
|
||||
&mut ExamineResultBuffer,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
@@ -212,14 +190,13 @@ pub fn process_examine_interaction(
|
||||
>,
|
||||
examine_text_query: Query<(&TilePosition, Option<&ExamineText>)>,
|
||||
) {
|
||||
let Ok((player_entity, player_pos, examine_req, archetype_opt, mut result_buffer)) =
|
||||
let Ok((player_entity, player_pos, examine_req, mut result_buffer)) =
|
||||
player_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = examine_req.target;
|
||||
let archetype = archetype_opt.copied().unwrap_or_default();
|
||||
|
||||
// Try NPC examine path first
|
||||
if let Ok((target_pos, mood_opt, tolerance_opt, traits_opt)) = npc_query.get(target) {
|
||||
@@ -238,7 +215,7 @@ pub fn process_examine_interaction(
|
||||
|
||||
let mood = mood_opt.map(|m| m.mood).unwrap_or(NpcMood::Neutral);
|
||||
let ratio = tolerance_opt.map(stress_ratio).unwrap_or(0);
|
||||
let text = generate_examine_text(mood, ratio, archetype, traits_opt);
|
||||
let text = generate_examine_text(mood, ratio, traits_opt);
|
||||
|
||||
kg_events.push(KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
@@ -323,8 +300,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_hostile_npc_gives_threat_read() {
|
||||
let text = generate_examine_text(NpcMood::Hostile, 20, CharacterArchetype::Smuggler, None);
|
||||
fn hostile_npc_gives_threat_read() {
|
||||
let text = generate_examine_text(NpcMood::Hostile, 20, None);
|
||||
assert!(
|
||||
text.contains("Threat posture"),
|
||||
"expected threat read, got: {text}"
|
||||
@@ -332,32 +309,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_focused_npc_notes_attention() {
|
||||
let text = generate_examine_text(NpcMood::Focused, 30, CharacterArchetype::Smuggler, None);
|
||||
fn focused_npc_notes_attention() {
|
||||
let text = generate_examine_text(NpcMood::Focused, 30, None);
|
||||
assert!(
|
||||
text.contains("close attention"),
|
||||
text.contains("Paying close attention"),
|
||||
"expected attention note, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smuggler_high_stress_identifies_distraction() {
|
||||
let text = generate_examine_text(NpcMood::Anxious, 80, CharacterArchetype::Smuggler, None);
|
||||
fn high_stress_identifies_pressure() {
|
||||
let text = generate_examine_text(NpcMood::Anxious, 80, None);
|
||||
assert!(
|
||||
text.contains("Too distracted"),
|
||||
"expected distraction read, got: {text}"
|
||||
text.contains("Under pressure"),
|
||||
"expected pressure read, got: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_deceptive_npc_notes_concealment() {
|
||||
fn deceptive_npc_notes_concealment() {
|
||||
let t = traits(&[PersonalityTrait::Deceptive]);
|
||||
let text = generate_examine_text(
|
||||
NpcMood::Neutral,
|
||||
20,
|
||||
CharacterArchetype::Detective,
|
||||
Some(&t),
|
||||
);
|
||||
let text = generate_examine_text(NpcMood::Neutral, 20, Some(&t));
|
||||
assert!(
|
||||
text.contains("Controlled affect"),
|
||||
"expected concealment note, got: {text}"
|
||||
@@ -365,8 +337,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_anxious_npc_notes_stress_markers() {
|
||||
let text = generate_examine_text(NpcMood::Anxious, 50, CharacterArchetype::Detective, None);
|
||||
fn anxious_npc_notes_stress_markers() {
|
||||
let text = generate_examine_text(NpcMood::Anxious, 50, None);
|
||||
assert!(
|
||||
text.contains("stress markers"),
|
||||
"expected stress markers, got: {text}"
|
||||
@@ -374,8 +346,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detective_content_npc_notes_low_guard() {
|
||||
let text = generate_examine_text(NpcMood::Content, 10, CharacterArchetype::Detective, None);
|
||||
fn content_npc_notes_low_guard() {
|
||||
let text = generate_examine_text(NpcMood::Content, 10, None);
|
||||
assert!(
|
||||
text.contains("Less guarded"),
|
||||
"expected low guard note, got: {text}"
|
||||
|
||||
@@ -72,8 +72,6 @@ pub type EconomicModifier = String;
|
||||
pub type FactionModifier = String;
|
||||
/// Condition modifier on a zone palette (worn, pristine, damaged). Stub.
|
||||
pub type ConditionModifier = String;
|
||||
/// Heritage root modifier (Settled Reach cultural grammar layer). Stub.
|
||||
pub type HeritageRoot = String;
|
||||
/// Season modifier (affects palette and ambient conditions). Stub.
|
||||
pub type Season = String;
|
||||
/// Role slot within a social site template. Stub.
|
||||
@@ -366,7 +364,6 @@ pub enum PaletteModifier {
|
||||
Era(Era),
|
||||
FactionPresence(FactionModifier),
|
||||
Condition(ConditionModifier),
|
||||
Heritage(HeritageRoot),
|
||||
Season(Season),
|
||||
}
|
||||
|
||||
|
||||
@@ -131,8 +131,6 @@ pub struct MonologueState {
|
||||
pub entered: bool,
|
||||
/// IDs of lines already shown (dedup within session).
|
||||
pub shown_ids: BTreeSet<String>,
|
||||
/// Character type for pool filtering. Set from CharacterArchetype (#587).
|
||||
pub character: String,
|
||||
/// Tick of the last observation event we reacted to (#119, observe_npc).
|
||||
/// Observation events arrive one tick after the snapshot that caused them,
|
||||
/// so we track which tick's events we've already processed.
|
||||
@@ -147,8 +145,6 @@ impl Default for MonologueState {
|
||||
idle_ticks: 0,
|
||||
entered: false,
|
||||
shown_ids: BTreeSet::new(),
|
||||
// Default to detective; overridden by CharacterArchetype at spawn (#587)
|
||||
character: "detective".to_string(),
|
||||
last_observation_tick: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ pub const MAP_HEIGHT: i32 = 125;
|
||||
/// is intentional for deterministic test setups but should be revisited
|
||||
/// if Gauntlet is ever served by the production startup pipeline.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterArchetype) {
|
||||
pub fn setup_gauntlet(app: &mut App) {
|
||||
// Start with a fully blocked map, then carve rooms and corridors.
|
||||
let mut walkability = WalkabilityMap::new_blocked(MAP_WIDTH, MAP_HEIGHT, 1);
|
||||
|
||||
@@ -190,12 +190,8 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
|
||||
|
||||
// --- Player (StableId 0) ---
|
||||
// Spawn at Hub center: absolute (50, 58)
|
||||
let profile = MovementProfile::smuggler();
|
||||
let profile = MovementProfile::default();
|
||||
let player_pos = TilePosition::new(50, 58, 0);
|
||||
let monologue_state = MonologueState {
|
||||
character: archetype.as_monologue_key().to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
@@ -204,7 +200,7 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
monologue_state,
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
ScanEventBuffer::default(),
|
||||
@@ -216,7 +212,6 @@ pub fn setup_gauntlet(app: &mut App, archetype: crate::bridge::types::CharacterA
|
||||
crate::simulation::pressure::CharacterPressure::default(),
|
||||
))
|
||||
.id();
|
||||
app.world_mut().entity_mut(player).insert(archetype);
|
||||
registry.register(player);
|
||||
|
||||
// --- Hub signs (StableId 1-4) ---
|
||||
@@ -699,10 +694,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
// Run all 29 world-query invariants against the fully-initialized gauntlet world.
|
||||
invariants::run_invariants(app.world_mut());
|
||||
@@ -722,10 +714,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// Hub center at (50, 58) must be walkable
|
||||
@@ -739,10 +728,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// North wall segment at absolute (90, 54) should be blocked
|
||||
@@ -758,10 +744,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let wm = app.world().resource::<WalkabilityMap>();
|
||||
// corridor-E center should be walkable
|
||||
@@ -775,10 +758,7 @@ mod tests {
|
||||
app.add_plugins(crate::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(crate::npc::NpcPlugin);
|
||||
|
||||
setup_gauntlet(
|
||||
&mut app,
|
||||
crate::bridge::types::CharacterArchetype::default(),
|
||||
);
|
||||
setup_gauntlet(&mut app);
|
||||
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user