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
+15 -100
View File
@@ -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!(
+1 -52
View File
@@ -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;
+2 -170
View File
@@ -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"
);
}
+48 -76
View File
@@ -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}"
-3
View File
@@ -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),
}
-4
View File
@@ -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,
}
}
+8 -28
View File
@@ -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>();