Merge remote-tracking branch 'origin/server'
This commit is contained in:
@@ -178,11 +178,14 @@ impl Plugin for BridgePlugin {
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction),
|
||||
crate::simulation::dialogue::process_confrontation_response
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::perception::observer::compute_observer_snapshot
|
||||
.after(crate::perception::observer::compute_visibility_geometry)
|
||||
.after(crate::simulation::interaction::compute_nearby_interactions)
|
||||
.after(crate::simulation::monologue::process_sprint_anomaly_monologue)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction)
|
||||
.after(crate::simulation::dialogue::process_confrontation_response)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
crate::perception::observation::emit_observation_events
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
|
||||
@@ -159,6 +159,12 @@ pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String {
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Blocked entities (debug, #514)
|
||||
if !snapshot.blocked_entities.is_empty() {
|
||||
let ids: Vec<String> = snapshot.blocked_entities.iter().map(|id| id.to_string()).collect();
|
||||
writeln!(out, "Blocked (LOS): {} [{}]", snapshot.blocked_entities.len(), ids.join(", ")).ok();
|
||||
}
|
||||
|
||||
writeln!(out, "===").ok();
|
||||
out
|
||||
}
|
||||
@@ -281,6 +287,7 @@ mod tests {
|
||||
}),
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,10 +409,27 @@ mod tests {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
assert!(text.contains("Player (-1,-1)"));
|
||||
assert!(text.contains("Tiles: 0 visible"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_rendered() {
|
||||
let mut snap = make_snapshot();
|
||||
snap.blocked_entities = vec![42, 99, 1024];
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Blocked (LOS): 3"));
|
||||
assert!(text.contains("[42, 99, 1024]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_empty_not_rendered() {
|
||||
let snap = make_snapshot();
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(!text.contains("Blocked (LOS)"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use crate::simulation::time::{DayPhase, TickRate};
|
||||
/// 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 = 8;
|
||||
pub const PROTOCOL_VERSION: u8 = 9;
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
@@ -27,10 +27,11 @@ pub const PROTOCOL_VERSION: u8 = 8;
|
||||
/// v6 adds: player_stance (#449, D-053), player_inventory (#449, D-065).
|
||||
/// v7 adds: pending_recognitions (#423, D-060 cognitive delay).
|
||||
/// v8 adds: dialogue_response (#305, D-028 dialogue pipeline).
|
||||
/// v9 adds: blocked_entities (#514, debug field for LOS-blocked entities).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 6.
|
||||
/// Protocol version for forward compatibility. Current: 9.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
@@ -68,6 +69,11 @@ pub struct ObserverSnapshot {
|
||||
/// Client shows speaker name + dialogue text in a dialogue box.
|
||||
#[serde(default)]
|
||||
pub dialogue_response: Option<DialogueResponseEvent>,
|
||||
/// Debug field: entity IDs on the same z-level that are not visible due to
|
||||
/// LOS obstruction or being outside the vision cone (#514).
|
||||
/// Sorted ascending for deterministic output. Client can safely ignore.
|
||||
#[serde(default)]
|
||||
pub blocked_entities: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
@@ -316,6 +322,10 @@ pub enum PlayerAction {
|
||||
ToggleStanceUp,
|
||||
/// Move one step down the stance ladder (toward Crouch) per D-053
|
||||
ToggleStanceDown,
|
||||
/// Teleport player to the Gauntlet hub spawn point (#491).
|
||||
/// Clears dialogue, monologue, and interaction buffers.
|
||||
/// Rejected with a log warning on non-Gauntlet maps.
|
||||
TeleportToHub,
|
||||
}
|
||||
|
||||
impl PlayerAction {
|
||||
|
||||
@@ -129,6 +129,23 @@ pub enum RelationshipState {
|
||||
Hostile,
|
||||
}
|
||||
|
||||
impl RelationshipState {
|
||||
/// Decrement relationship state toward more negative (D-063 confrontation response).
|
||||
///
|
||||
/// Friendly → Known → PersonOfInterest → Hostile.
|
||||
/// Unknown stays Unknown (can't confront a stranger meaningfully).
|
||||
/// Hostile stays Hostile — floor, does not wrap or panic.
|
||||
pub fn decrement(self) -> Self {
|
||||
match self {
|
||||
Self::Friendly => Self::Known,
|
||||
Self::Known => Self::PersonOfInterest,
|
||||
Self::PersonOfInterest => Self::Hostile,
|
||||
Self::Unknown => Self::Unknown, // no-op: can't confront a stranger
|
||||
Self::Hostile => Self::Hostile, // floor: already worst state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Entity Knowledge ---
|
||||
|
||||
/// What entity A knows about entity B.
|
||||
|
||||
@@ -35,6 +35,42 @@ impl Plugin for NpcPlugin {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct Npc;
|
||||
|
||||
/// NPC animation tier (D-047).
|
||||
///
|
||||
/// Tier 1 (clear): public daily activities — instantly readable.
|
||||
/// Tier 2 (ambiguous): privately motivated behaviors — player sees the action
|
||||
/// but cannot determine the intention.
|
||||
///
|
||||
/// NPCs start at Tier 1. Confrontation (D-063) and other triggers shift to Tier 2.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum AnimationTier {
|
||||
/// Clear, readable public activities (walking, working, talking).
|
||||
#[default]
|
||||
Tier1,
|
||||
/// Ambiguous, privately motivated behaviors (pausing, lingering, looking around).
|
||||
Tier2,
|
||||
}
|
||||
|
||||
/// Tracks why and when an NPC's routine deviated from normal (D-064 Phase 2).
|
||||
///
|
||||
/// Inserted when a player action causes an NPC to break from their scheduled
|
||||
/// behavior. Acts as a hook for the storyteller system and affects future
|
||||
/// interactions (e.g., second-approach dialogue differences).
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct RoutineDeviation {
|
||||
pub trigger: DeviationTrigger,
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
/// What caused an NPC's routine deviation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum DeviationTrigger {
|
||||
/// Player walked away mid-dialogue (D-064 Phase 2).
|
||||
WalkAway,
|
||||
/// Player delivered a confrontation (D-063).
|
||||
Confrontation,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis 1: Want (D-024)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -142,10 +142,8 @@ pub fn emit_observation_events(
|
||||
let pending_ids: Vec<crate::knowledge::types::StableId> =
|
||||
delay.pending().iter().map(|p| p.stable_id).collect();
|
||||
for sid in pending_ids {
|
||||
if !visible_stable_ids.contains(&sid.0) {
|
||||
if delay.cancel(&sid).is_some() {
|
||||
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
|
||||
}
|
||||
if !visible_stable_ids.contains(&sid.0) && delay.cancel(&sid).is_some() {
|
||||
tracing::debug!("Cognitive delay cancelled: stable_id={} left LOS", sid.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ pub fn compute_observer_snapshot(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let (mut entities, visible_ids) =
|
||||
let (mut entities, visible_ids, blocked_entities) =
|
||||
filter_visible_entities(&geometry, ®istry, observer_kg, &all_entities);
|
||||
|
||||
collect_remembered_entities(
|
||||
@@ -206,11 +206,13 @@ pub fn compute_observer_snapshot(
|
||||
current_monologue,
|
||||
pending_recognitions,
|
||||
dialogue_response,
|
||||
blocked_entities,
|
||||
});
|
||||
}
|
||||
|
||||
/// Filter entities by visibility using precomputed geometry.
|
||||
/// Returns (visible entities, set of visible wire IDs).
|
||||
/// Returns (visible entities, set of visible wire IDs, blocked entity IDs).
|
||||
/// Blocked entities are on the same z-level but not in visible_positions (#514).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn filter_visible_entities(
|
||||
geometry: &VisibilityGeometry,
|
||||
@@ -222,15 +224,31 @@ fn filter_visible_entities(
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
)>,
|
||||
) -> (Vec<VisibleEntity>, BTreeSet<u64>) {
|
||||
) -> (Vec<VisibleEntity>, BTreeSet<u64>, Vec<u64>) {
|
||||
let mut entities = Vec::new();
|
||||
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut blocked_ids: BTreeSet<u64> = BTreeSet::new();
|
||||
|
||||
for (entity, pos, is_player, is_npc) in all_entities.iter() {
|
||||
if pos.z != geometry.observer_z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve wire ID early — needed for both visible and blocked paths
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::error!(?entity, "entity not in EntityRegistry");
|
||||
entity.to_bits()
|
||||
});
|
||||
|
||||
if !geometry.visible_positions.contains(&(pos.x, pos.y)) {
|
||||
// Same z-level but not visible — blocked by LOS or outside vision cone.
|
||||
// Exclude the player entity (always at origin, always visible).
|
||||
if is_player.is_none() {
|
||||
blocked_ids.insert(wire_id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -257,16 +275,6 @@ fn filter_visible_entities(
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
// Fallback to Entity::to_bits() is intentional for per-frame systems:
|
||||
// panicking would crash the server every tick. The error log makes this
|
||||
// loud enough to catch in testing while keeping the server alive.
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::error!(?entity, "entity visible but not in EntityRegistry");
|
||||
entity.to_bits()
|
||||
});
|
||||
visible_ids.insert(wire_id);
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: wire_id,
|
||||
@@ -280,7 +288,9 @@ fn filter_visible_entities(
|
||||
});
|
||||
}
|
||||
|
||||
(entities, visible_ids)
|
||||
// BTreeSet iteration is sorted — deterministic output guaranteed
|
||||
let blocked_vec: Vec<u64> = blocked_ids.into_iter().collect();
|
||||
(entities, visible_ids, blocked_vec)
|
||||
}
|
||||
|
||||
/// Collect remembered entities from the knowledge graph — entities the observer
|
||||
|
||||
@@ -2030,3 +2030,213 @@ fn no_cognitive_delay_component_means_empty_pending_recognitions() {
|
||||
"no CognitiveDelay component should produce empty pending_recognitions"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// blocked_entities debug field tests (#514)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_empty_when_all_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
registry.register(npc);
|
||||
|
||||
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_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
snapshot.blocked_entities.is_empty(),
|
||||
"no blocked entities when NPC is in LOS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_wall_appears_in_blocked_entities() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Wall between player and NPC
|
||||
world
|
||||
.resource_mut::<WalkabilityMap>()
|
||||
.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
|
||||
// NPC behind the wall
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
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_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
snapshot.blocked_entities.contains(&npc_sid.0),
|
||||
"NPC behind wall should appear in blocked_entities"
|
||||
);
|
||||
// Not in visible entities
|
||||
let npc_visible = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.any(|e| e.entity_id == npc_sid.0);
|
||||
assert!(!npc_visible, "NPC should not be in visible entities");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_player_appears_in_blocked_entities() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC far behind player (south, outside vision cone)
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
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_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
snapshot.blocked_entities.contains(&npc_sid.0),
|
||||
"NPC in blind spot should appear in blocked_entities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_z_level_not_in_blocked_entities() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC on a different z-level
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
!snapshot.blocked_entities.contains(&npc_sid.0),
|
||||
"NPC on different z-level should NOT be in blocked_entities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_entities_sorted_ascending() {
|
||||
// Multiple blocked NPCs should appear in ascending entity_id order
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Wall blocks north
|
||||
world
|
||||
.resource_mut::<WalkabilityMap>()
|
||||
.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
|
||||
// Two NPCs behind wall + one behind player
|
||||
let npc_a = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)))
|
||||
.id();
|
||||
let npc_a_sid = registry.register(npc_a);
|
||||
|
||||
let npc_b = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 10, 0)))
|
||||
.id();
|
||||
let npc_b_sid = registry.register(npc_b);
|
||||
|
||||
let npc_c = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)))
|
||||
.id();
|
||||
let npc_c_sid = registry.register(npc_c);
|
||||
|
||||
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_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
assert!(snapshot.blocked_entities.len() >= 3);
|
||||
// Must be sorted ascending (BTreeSet guarantee)
|
||||
for i in 1..snapshot.blocked_entities.len() {
|
||||
assert!(
|
||||
snapshot.blocked_entities[i - 1] < snapshot.blocked_entities[i],
|
||||
"blocked_entities not sorted: {:?}",
|
||||
snapshot.blocked_entities
|
||||
);
|
||||
}
|
||||
// All three NPCs should be present
|
||||
assert!(snapshot.blocked_entities.contains(&npc_a_sid.0));
|
||||
assert!(snapshot.blocked_entities.contains(&npc_b_sid.0));
|
||||
assert!(snapshot.blocked_entities.contains(&npc_c_sid.0));
|
||||
}
|
||||
|
||||
@@ -18,12 +18,13 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::bridge::types::{DialogueResponseEvent, RelationshipState};
|
||||
use crate::bridge::types::{DialogueResponseEvent, MonologueEvent, RelationshipState};
|
||||
use crate::content::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, Mood, Situation, Topic, TrustTier,
|
||||
};
|
||||
use crate::content::LinePoolIndexResource;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::simulation::monologue::{MonologueBuffer, MonologueState};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
@@ -117,6 +118,18 @@ pub struct ActiveDialogue {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct WalkAwayRequest;
|
||||
|
||||
/// Marker: player delivered a confrontation this tick (#520, D-063).
|
||||
///
|
||||
/// Set by process_player_input when Interact{verb: "Confront"} is received.
|
||||
/// Consumed by process_confrontation_response each tick. Triggers:
|
||||
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
|
||||
/// 2. Observer KG relationship state decremented (D-033 color fade)
|
||||
/// 3. Monologue spike event emitted
|
||||
#[derive(Component, Debug)]
|
||||
pub struct ConfrontationDelivered {
|
||||
pub target: Entity,
|
||||
}
|
||||
|
||||
/// Buffer holding the dialogue response for snapshot inclusion.
|
||||
///
|
||||
/// Consumed once per snapshot via `take()`. Cleared at snapshot build time.
|
||||
@@ -299,7 +312,7 @@ pub fn select_dialogue_line<'a>(
|
||||
/// line to DialogueResponseBuffer.
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub fn process_talk_interaction(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
@@ -478,13 +491,15 @@ pub fn process_talk_interaction(
|
||||
// System: process_walk_away (D-064)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process walk-away requests during active dialogue.
|
||||
/// Process walk-away requests during active dialogue (D-064 Phases 2+3).
|
||||
///
|
||||
/// When the player moves (WASD) during an active dialogue, the client sends
|
||||
/// PlayerAction::WalkAway which sets WalkAwayRequest. This system:
|
||||
/// 1. Emits IncompleteInteraction knowledge event (recorded in KG)
|
||||
/// 2. Clears ActiveDialogue state
|
||||
/// 3. Removes the WalkAwayRequest marker
|
||||
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047 ambiguous animation)
|
||||
/// 2. NPC routine deviation recorded (storyteller hook)
|
||||
/// 3. Emits IncompleteInteraction knowledge event (recorded in KG)
|
||||
/// 4. Clears ActiveDialogue state
|
||||
/// 5. Removes the WalkAwayRequest marker
|
||||
///
|
||||
/// If no ActiveDialogue is present, removes WalkAwayRequest silently (no-op).
|
||||
///
|
||||
@@ -500,21 +515,38 @@ pub fn process_walk_away(
|
||||
};
|
||||
|
||||
if let Some(active_dialogue) = active_dialogue_opt {
|
||||
// Emit IncompleteInteraction knowledge event
|
||||
let target = active_dialogue.target;
|
||||
|
||||
// Phase 2, Effect 1: Shift target NPC to Tier 2 animation (D-047)
|
||||
commands
|
||||
.entity(target)
|
||||
.insert(crate::npc::AnimationTier::Tier2);
|
||||
|
||||
// Phase 2, Effect 2: Record routine deviation on target NPC
|
||||
commands
|
||||
.entity(target)
|
||||
.insert(crate::npc::RoutineDeviation {
|
||||
trigger: crate::npc::DeviationTrigger::WalkAway,
|
||||
tick: time.tick,
|
||||
});
|
||||
|
||||
// Phase 3: Emit IncompleteInteraction knowledge event
|
||||
event_queue.push(crate::knowledge::KnowledgeEvent {
|
||||
observer: player_entity,
|
||||
tick: time.tick,
|
||||
event_type: crate::knowledge::KnowledgeEventType::IncompleteInteraction {
|
||||
target: active_dialogue.target,
|
||||
target,
|
||||
interaction_type: active_dialogue.interaction_type,
|
||||
},
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
"Walk-away during {:?} dialogue at tick {} (started tick {})",
|
||||
"Walk-away during {:?} dialogue at tick {} (started tick {}): \
|
||||
target {:?} → Tier2 animation + routine deviation",
|
||||
active_dialogue.interaction_type,
|
||||
time.tick,
|
||||
active_dialogue.started_tick,
|
||||
target,
|
||||
);
|
||||
|
||||
commands.entity(player_entity).remove::<ActiveDialogue>();
|
||||
@@ -525,6 +557,109 @@ pub fn process_walk_away(
|
||||
commands.entity(player_entity).remove::<WalkAwayRequest>();
|
||||
}
|
||||
|
||||
/// Hardcoded confrontation monologue lines (D-063).
|
||||
/// Fired as a monologue spike when the player delivers a confrontation.
|
||||
/// Future: move to content pools with trigger="confrontation_delivered".
|
||||
const CONFRONTATION_LINES: &[(&str, &str)] = &[
|
||||
(
|
||||
"confront_01",
|
||||
"That changed everything between us. No going back.",
|
||||
),
|
||||
(
|
||||
"confront_02",
|
||||
"The look on their face... they know I know.",
|
||||
),
|
||||
(
|
||||
"confront_03",
|
||||
"Cards on the table. Let's see what happens next.",
|
||||
),
|
||||
];
|
||||
|
||||
/// Process confrontation world response (#520, D-063).
|
||||
///
|
||||
/// Reads ConfrontationDelivered marker (set by input system), applies three
|
||||
/// server-authoritative effects:
|
||||
/// 1. Target NPC shifts to AnimationTier::Tier2 (D-047)
|
||||
/// 2. Observer's KG relationship state decremented (D-033 color fade)
|
||||
/// 3. Monologue spike: immediate monologue line bypassing cooldown
|
||||
///
|
||||
/// System ordering: after process_player_input, before compute_observer_snapshot.
|
||||
pub fn process_confrontation_response(
|
||||
mut commands: Commands,
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut rng: ResMut<crate::simulation::rng::SimRng>,
|
||||
mut query: Query<
|
||||
(
|
||||
Entity,
|
||||
&ConfrontationDelivered,
|
||||
&mut KnowledgeGraph,
|
||||
&mut MonologueBuffer,
|
||||
&mut MonologueState,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
) {
|
||||
let Ok((
|
||||
player_entity,
|
||||
confrontation,
|
||||
mut observer_kg,
|
||||
mut monologue_buf,
|
||||
mut monologue_state,
|
||||
)) = query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target = confrontation.target;
|
||||
|
||||
// Effect 1: Shift target NPC to Tier 2 animation (D-047)
|
||||
// + record routine deviation (symmetric with walk-away path)
|
||||
commands.entity(target).insert((
|
||||
crate::npc::AnimationTier::Tier2,
|
||||
crate::npc::RoutineDeviation {
|
||||
trigger: crate::npc::DeviationTrigger::Confrontation,
|
||||
tick: time.tick,
|
||||
},
|
||||
));
|
||||
|
||||
// Effect 2: Decrement observer's relationship with the target (D-033 color fade)
|
||||
if let Some(target_sid) = registry.to_stable(target) {
|
||||
let old_rel = observer_kg.relationship_with(&target_sid);
|
||||
let new_rel = old_rel.decrement();
|
||||
if new_rel != old_rel {
|
||||
observer_kg.set_relationship(&target_sid, new_rel);
|
||||
tracing::info!(
|
||||
target_id = target_sid.0,
|
||||
?old_rel,
|
||||
?new_rel,
|
||||
"Confrontation: relationship decremented"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Effect 3: Monologue spike — bypass cooldown, fire immediately
|
||||
let idx = rng.rng.random_range(0..CONFRONTATION_LINES.len());
|
||||
let (id, text) = CONFRONTATION_LINES[idx];
|
||||
monologue_buf.set(MonologueEvent {
|
||||
id: id.to_string(),
|
||||
text: text.to_string(),
|
||||
duration_seconds: 5.0,
|
||||
});
|
||||
monologue_state.last_fired_tick = time.tick;
|
||||
|
||||
tracing::info!(
|
||||
tick = time.tick,
|
||||
monologue_id = id,
|
||||
"Confrontation delivered: Tier 2 anim + relationship decrement + monologue spike"
|
||||
);
|
||||
|
||||
// Clean up marker
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<ConfrontationDelivered>();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1362,5 +1497,288 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Walk-away Phase 2 tests (D-064, #519) ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn walk_away_shifts_npc_to_tier2_animation() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
use crate::npc::AnimationTier;
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 10,
|
||||
},
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let tier = world.get::<AnimationTier>(npc).unwrap();
|
||||
assert_eq!(
|
||||
*tier,
|
||||
AnimationTier::Tier2,
|
||||
"Walk-away should shift NPC to Tier 2 animation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_records_routine_deviation() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
use crate::npc::{DeviationTrigger, RoutineDeviation};
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.resource_mut::<SimulationTime>().tick = 42;
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 10,
|
||||
},
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let deviation = world.get::<RoutineDeviation>(npc).unwrap();
|
||||
assert_eq!(
|
||||
deviation.trigger,
|
||||
DeviationTrigger::WalkAway,
|
||||
"Deviation trigger should be WalkAway"
|
||||
);
|
||||
assert_eq!(deviation.tick, 42, "Deviation should record the walk-away tick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_without_dialogue_does_not_affect_npcs() {
|
||||
use crate::knowledge::KnowledgeEventQueue;
|
||||
use crate::npc::{AnimationTier, RoutineDeviation};
|
||||
|
||||
let mut world = setup_dialogue_world();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
|
||||
let npc = world.spawn_empty().id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Player with WalkAwayRequest but NO ActiveDialogue
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
WalkAwayRequest,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_walk_away);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnimationTier>(npc).is_none(),
|
||||
"NPC should not get AnimationTier when no dialogue was active"
|
||||
);
|
||||
assert!(
|
||||
world.get::<RoutineDeviation>(npc).is_none(),
|
||||
"NPC should not get RoutineDeviation when no dialogue was active"
|
||||
);
|
||||
}
|
||||
|
||||
use rand::SeedableRng;
|
||||
|
||||
// === Confrontation Response Tests (#520, D-063) ===
|
||||
|
||||
#[test]
|
||||
fn confrontation_shifts_npc_to_tier2() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Known);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
let tier = world.get::<crate::npc::AnimationTier>(npc);
|
||||
assert_eq!(
|
||||
tier,
|
||||
Some(&crate::npc::AnimationTier::Tier2),
|
||||
"NPC should shift to Tier2 after confrontation"
|
||||
);
|
||||
|
||||
// RoutineDeviation should be recorded (symmetric with walk-away)
|
||||
let deviation = world.get::<crate::npc::RoutineDeviation>(npc);
|
||||
assert!(deviation.is_some(), "NPC should get RoutineDeviation after confrontation");
|
||||
assert_eq!(
|
||||
deviation.unwrap().trigger,
|
||||
crate::npc::DeviationTrigger::Confrontation,
|
||||
"Deviation trigger should be Confrontation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_decrements_relationship() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Known);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let player_kg = world.get::<KnowledgeGraph>(player).unwrap();
|
||||
assert_eq!(
|
||||
player_kg.relationship_with(&npc_sid),
|
||||
RelationshipState::PersonOfInterest,
|
||||
"Known → PersonOfInterest after confrontation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_emits_monologue_spike() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buffer = world.get_mut::<MonologueBuffer>(player).unwrap();
|
||||
let event = buffer.take();
|
||||
assert!(event.is_some(), "Monologue spike should be emitted");
|
||||
assert!(
|
||||
event.unwrap().id.starts_with("confront_"),
|
||||
"Should be a confrontation monologue line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confrontation_clears_marker() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 6, 0)))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
ConfrontationDelivered { target: npc },
|
||||
MonologueBuffer::default(),
|
||||
MonologueState::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_confrontation_response);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<ConfrontationDelivered>(player).is_none(),
|
||||
"Marker should be removed after processing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ impl InputQueue {
|
||||
|
||||
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
|
||||
/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424).
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||
pub fn process_player_input(
|
||||
mut input_queue: ResMut<InputQueue>,
|
||||
mut time: ResMut<SimulationTime>,
|
||||
@@ -104,8 +104,14 @@ pub fn process_player_input(
|
||||
|
||||
for input in inputs {
|
||||
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
|
||||
// Only Pause/Unpause are processed — everything else is discarded.
|
||||
if paused && !matches!(input.action, PlayerAction::Pause | PlayerAction::Unpause) {
|
||||
// Only Pause/Unpause/TeleportToHub are processed — everything else is discarded.
|
||||
// TeleportToHub is exempted because it's a Gauntlet QA action (#491).
|
||||
if paused
|
||||
&& !matches!(
|
||||
input.action,
|
||||
PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match input.action {
|
||||
@@ -196,6 +202,15 @@ pub fn process_player_input(
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Confront") => {
|
||||
handle_confront(
|
||||
&mut commands,
|
||||
®istry,
|
||||
&player_query,
|
||||
&all_positions,
|
||||
target_entity_id,
|
||||
);
|
||||
}
|
||||
Some("Reset") => {
|
||||
handle_reset(
|
||||
&mut commands,
|
||||
@@ -222,6 +237,9 @@ pub fn process_player_input(
|
||||
tracing::debug!("WalkAway: marker set on player");
|
||||
}
|
||||
}
|
||||
PlayerAction::TeleportToHub => {
|
||||
handle_teleport_to_hub(&mut player_query, &mut commands);
|
||||
}
|
||||
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||
}
|
||||
@@ -400,6 +418,65 @@ fn handle_talk(
|
||||
tracing::debug!(target_id, "Talk: TalkRequest marker set on player");
|
||||
}
|
||||
|
||||
/// Handle Confront verb: set ConfrontationDelivered marker on the player entity (#520, D-063).
|
||||
/// The confrontation response system runs in process_confrontation_response (dialogue.rs).
|
||||
/// Server-side range check: Confront requires CLOSE_RANGE (same as Talk).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_confront(
|
||||
commands: &mut Commands,
|
||||
registry: &EntityRegistry,
|
||||
player_query: &Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut Stance>,
|
||||
Option<&mut PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_positions: &Query<&TilePosition>,
|
||||
target_entity_id: Option<u64>,
|
||||
) {
|
||||
let Some(target_id) = target_entity_id else {
|
||||
tracing::warn!("Confront verb without target_entity_id");
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let target_stable = StableId(target_id);
|
||||
let Some(target_entity) = registry.to_entity(&target_stable) else {
|
||||
tracing::warn!(target_id, "Confront: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject Confront if target is beyond close range
|
||||
if let Ok(target_pos) = all_positions.get(target_entity) {
|
||||
let distance = player_pos
|
||||
.manhattan_distance(target_pos)
|
||||
.unwrap_or(u32::MAX);
|
||||
if distance > crate::simulation::interaction::CLOSE_RANGE {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
distance,
|
||||
"Confront: target out of range (max {})",
|
||||
crate::simulation::interaction::CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(crate::simulation::dialogue::ConfrontationDelivered {
|
||||
target: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(target_id, "Confront: ConfrontationDelivered marker set on player");
|
||||
}
|
||||
|
||||
/// Handle Place verb: remove an item from inventory and place it on the ground
|
||||
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
|
||||
/// TilePosition at the player's current tile.
|
||||
@@ -512,6 +589,63 @@ fn handle_reset(
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle TeleportToHub: move player to hub spawn, clear interaction state (#491).
|
||||
///
|
||||
/// Gauntlet-only action. On non-Gauntlet maps (feature disabled), logs a warning
|
||||
/// and returns. On Gauntlet maps, moves the player to HUB.spawn and removes
|
||||
/// dialogue, monologue, and interaction markers to prevent stale state.
|
||||
///
|
||||
/// Does NOT affect: room state, inventory, game time, knowledge graph.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_teleport_to_hub(
|
||||
player_query: &mut Query<
|
||||
(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&mut Stance>,
|
||||
Option<&mut PlayerMoveCooldown>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
commands: &mut Commands,
|
||||
) {
|
||||
#[cfg(not(feature = "gauntlet"))]
|
||||
{
|
||||
tracing::warn!("TeleportToHub rejected: not a Gauntlet map");
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
{
|
||||
let Ok((player_entity, _, _, _)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
|
||||
// Move player to hub spawn
|
||||
commands.entity(player_entity).insert(hub_spawn);
|
||||
|
||||
// Clear any pending movement
|
||||
commands.entity(player_entity).remove::<MoveIntent>();
|
||||
|
||||
// Clear dialogue/interaction markers (including mid-confrontation state)
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.remove::<crate::simulation::dialogue::TalkRequest>()
|
||||
.remove::<crate::simulation::dialogue::ActiveDialogue>()
|
||||
.remove::<crate::simulation::dialogue::WalkAwayRequest>()
|
||||
.remove::<crate::simulation::dialogue::ConfrontationDelivered>();
|
||||
|
||||
tracing::info!(
|
||||
x = hub_spawn.x,
|
||||
y = hub_spawn.y,
|
||||
z = hub_spawn.z,
|
||||
"TeleportToHub: player moved to hub spawn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1690,4 +1824,163 @@ mod tests {
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world); // should not panic
|
||||
}
|
||||
|
||||
// === TeleportToHub Tests (#491) ===
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
#[test]
|
||||
fn teleport_to_hub_moves_player() {
|
||||
// #491: TeleportToHub moves player to hub spawn position.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Spawn player at a non-hub position
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world.get::<TilePosition>(player).expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn");
|
||||
assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn");
|
||||
assert_eq!(pos.z, hub_spawn.z, "player z at hub spawn");
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
#[test]
|
||||
fn teleport_to_hub_clears_dialogue_markers() {
|
||||
// #491: TeleportToHub removes ActiveDialogue, TalkRequest,
|
||||
// WalkAwayRequest, and ConfrontationDelivered.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Spawn a fake NPC target
|
||||
let npc = world.spawn(TilePosition::new(10, 10, 0)).id();
|
||||
|
||||
// Spawn player with active dialogue state + mid-confrontation marker
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(84, 58, 0),
|
||||
crate::simulation::dialogue::TalkRequest { target: npc },
|
||||
crate::simulation::dialogue::ActiveDialogue {
|
||||
target: npc,
|
||||
interaction_type: crate::knowledge::events::InteractionType::Talk,
|
||||
started_tick: 0,
|
||||
},
|
||||
crate::simulation::dialogue::WalkAwayRequest,
|
||||
crate::simulation::dialogue::ConfrontationDelivered { target: npc },
|
||||
))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::TalkRequest>(player)
|
||||
.is_none(),
|
||||
"TalkRequest cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::ActiveDialogue>(player)
|
||||
.is_none(),
|
||||
"ActiveDialogue cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::WalkAwayRequest>(player)
|
||||
.is_none(),
|
||||
"WalkAwayRequest cleared after teleport"
|
||||
);
|
||||
assert!(
|
||||
world
|
||||
.get::<crate::simulation::dialogue::ConfrontationDelivered>(player)
|
||||
.is_none(),
|
||||
"ConfrontationDelivered cleared after teleport"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
#[test]
|
||||
fn teleport_to_hub_clears_move_intent() {
|
||||
// #491: TeleportToHub removes any pending MoveIntent.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(84, 58, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(85, 58, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_none(),
|
||||
"MoveIntent cleared after teleport"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
#[test]
|
||||
fn teleport_to_hub_allowed_while_paused() {
|
||||
// #491: TeleportToHub is a QA action — allowed even when paused.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick_rate = TickRate::Paused;
|
||||
world.insert_resource(time);
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(84, 58, 0)))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::TeleportToHub,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world.get::<TilePosition>(player).expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,12 @@ impl MonologueBuffer {
|
||||
pub fn take(&mut self) -> Option<MonologueEvent> {
|
||||
self.event.take()
|
||||
}
|
||||
|
||||
/// Set a monologue event, replacing any pending event.
|
||||
/// Used by confrontation response (#520, D-063) to emit a monologue spike.
|
||||
pub fn set(&mut self, event: MonologueEvent) {
|
||||
self.event = Some(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Queued sprint anomaly for delayed "double-take" monologue (#428, D-055).
|
||||
|
||||
@@ -189,16 +189,19 @@ pub const INTERACTION_GALLERY_STABLE_IDS: (u64, u64) = (24, 28);
|
||||
pub const PAUSE_CHAMBER_STABLE_IDS: (u64, u64) = (29, 29);
|
||||
pub const DIALOGUE_ROOM_STABLE_IDS: (u64, u64) = (30, 33);
|
||||
pub const CROWD_PLAZA_STABLE_IDS: (u64, u64) = (34, 48);
|
||||
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 51);
|
||||
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 55);
|
||||
|
||||
/// Number of actively-spawned entities in the current Gauntlet build.
|
||||
/// Derived from StableId ranges of built rooms + player + reset plates.
|
||||
/// Reserved (unbuilt) rooms do not contribute entities.
|
||||
/// Derived from StableId ranges of all rooms + player + reset plates.
|
||||
pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
|
||||
+ (HUB_STABLE_IDS.1 - HUB_STABLE_IDS.0 + 1) as usize
|
||||
+ (FOG_THEATER_STABLE_IDS.1 - FOG_THEATER_STABLE_IDS.0 + 1) as usize
|
||||
+ (OCCLUSION_STABLE_IDS.1 - OCCLUSION_STABLE_IDS.0 + 1) as usize
|
||||
+ (INVENTORY_STABLE_IDS.1 - INVENTORY_STABLE_IDS.0 + 1) as usize
|
||||
+ (INTERACTION_GALLERY_STABLE_IDS.1 - INTERACTION_GALLERY_STABLE_IDS.0 + 1) as usize
|
||||
+ (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize
|
||||
+ (DIALOGUE_ROOM_STABLE_IDS.1 - DIALOGUE_ROOM_STABLE_IDS.0 + 1) as usize
|
||||
+ (CROWD_PLAZA_STABLE_IDS.1 - CROWD_PLAZA_STABLE_IDS.0 + 1) as usize
|
||||
+ (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -233,6 +236,34 @@ mod tests {
|
||||
assert_eq!(room.name, "pause_chamber");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_fog_theater() {
|
||||
let pos = TilePosition { x: 56, y: 18, z: 0 };
|
||||
let room = room_at(&pos).expect("Fog Theater observer should be in a room");
|
||||
assert_eq!(room.name, "fog_theater");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_interaction_gallery() {
|
||||
let pos = TilePosition { x: 14, y: 92, z: 0 };
|
||||
let room = room_at(&pos).expect("Interaction Gallery observer should be in a room");
|
||||
assert_eq!(room.name, "interaction_gallery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_dialogue_room() {
|
||||
let pos = TilePosition { x: 50, y: 114, z: 0 };
|
||||
let room = room_at(&pos).expect("Dialogue Room observer should be in a room");
|
||||
assert_eq!(room.name, "dialogue_room");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_crowd_plaza() {
|
||||
let pos = TilePosition { x: 96, y: 94, z: 0 };
|
||||
let room = room_at(&pos).expect("Crowd Plaza observer should be in a room");
|
||||
assert_eq!(room.name, "crowd_plaza");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_returns_none_for_corridor() {
|
||||
// Point inside corridor-E (between Hub and Occlusion)
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
//! StableId ranges (from gestalt-round3.md):
|
||||
//! Player: 0
|
||||
//! Hub signs: 1-4
|
||||
//! Fog Theater: 5-8 (reserved, not yet built)
|
||||
//! Fog Theater: 5-8
|
||||
//! Occlusion Corridor: 9-12
|
||||
//! Inventory Warehouse: 13-23
|
||||
//! Interaction Gallery: 24-28 (reserved, not yet built)
|
||||
//! Interaction Gallery: 24-28
|
||||
//! Pause Chamber: 29
|
||||
//! Dialogue Room: 30-33 (reserved, not yet built)
|
||||
//! Crowd Plaza: 34-48 (reserved, not yet built)
|
||||
//! Reset plates: 49-51 (Occlusion, Inventory, Pause)
|
||||
//! Dialogue Room: 30-33
|
||||
//! Crowd Plaza: 34-48
|
||||
//! Reset plates: 49-55
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod constants;
|
||||
@@ -86,14 +86,22 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
|
||||
// Carve room interiors (2-tile-thick walls → interior starts 2 tiles in)
|
||||
carve_room_interior(&mut walkability, 38, 46, 24, 24); // Hub
|
||||
carve_room_interior(&mut walkability, 28, 2, 44, 32); // Fog Theater
|
||||
carve_room_interior(&mut walkability, 74, 48, 42, 22); // Occlusion Corridor
|
||||
carve_room_interior(&mut walkability, 2, 40, 30, 28); // Inventory Warehouse
|
||||
carve_room_interior(&mut walkability, 2, 82, 24, 20); // Interaction Gallery
|
||||
carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber
|
||||
carve_room_interior(&mut walkability, 36, 104, 28, 20); // Dialogue Room
|
||||
carve_room_interior(&mut walkability, 80, 78, 32, 32); // Crowd Plaza
|
||||
|
||||
// Carve corridors between hub and rooms
|
||||
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
|
||||
carve_corridor(&mut walkability, 62, 55, 12, 6); // corridor-E: Hub ↔ Occlusion
|
||||
carve_corridor(&mut walkability, 32, 55, 6, 6); // corridor-W: Hub ↔ Inventory
|
||||
carve_corridor(&mut walkability, 47, 70, 6, 8); // corridor-S: Hub ↔ Pause Chamber
|
||||
carve_corridor(&mut walkability, 12, 68, 6, 14); // corridor-SW: Inventory ↔ Interaction Gallery
|
||||
carve_corridor(&mut walkability, 48, 94, 6, 10); // corridor-S2: Pause ↔ Dialogue Room
|
||||
carve_corridor(&mut walkability, 58, 84, 22, 6); // corridor-E2: Pause ↔ Crowd Plaza
|
||||
|
||||
// Set up Occlusion Corridor walls (relative positions converted to absolute)
|
||||
// North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall
|
||||
@@ -142,8 +150,8 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// --- Hub signs (StableId 1-4) ---
|
||||
rooms::hub::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Fog Theater (StableId 5-8) — reserved, not yet built ---
|
||||
registry.reserve_up_to(9);
|
||||
// --- Fog Theater (StableId 5-8) ---
|
||||
rooms::fog_theater::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Occlusion Corridor (StableId 9-12) ---
|
||||
rooms::occlusion_corridor::spawn_entities(app, &mut registry);
|
||||
@@ -151,23 +159,29 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// --- Inventory Warehouse (StableId 13-23) ---
|
||||
rooms::inventory_warehouse::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Interaction Gallery (StableId 24-28) — reserved, not yet built ---
|
||||
registry.reserve_up_to(29);
|
||||
// --- Interaction Gallery (StableId 24-28) ---
|
||||
rooms::interaction_gallery::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Pause Chamber (StableId 29) ---
|
||||
rooms::pause_chamber::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Dialogue Room (StableId 30-33) — reserved, not yet built ---
|
||||
// --- Crowd Plaza (StableId 34-48) — reserved, not yet built ---
|
||||
registry.reserve_up_to(49);
|
||||
// --- Dialogue Room (StableId 30-33) ---
|
||||
rooms::dialogue_room::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Reset plates (StableId 49-51) ---
|
||||
// --- Crowd Plaza (StableId 34-48) ---
|
||||
rooms::crowd_plaza::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Reset plates (StableId 49-55) ---
|
||||
// Spawned at corridor entrances per workshop-outcomes.md Section 8.
|
||||
// Each plate triggers reset of its associated room.
|
||||
let reset_plates: &[(&str, TilePosition)] = &[
|
||||
("occlusion_corridor", constants::OCCLUSION_CORRIDOR.reset_plate.expect("occlusion_corridor should have a reset_plate")),
|
||||
("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.expect("inventory_warehouse should have a reset_plate")),
|
||||
("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.expect("pause_chamber should have a reset_plate")),
|
||||
("fog_theater", constants::FOG_THEATER.reset_plate.expect("fog_theater should have a reset_plate")),
|
||||
("interaction_gallery", constants::INTERACTION_GALLERY.reset_plate.expect("interaction_gallery should have a reset_plate")),
|
||||
("dialogue_room", constants::DIALOGUE_ROOM.reset_plate.expect("dialogue_room should have a reset_plate")),
|
||||
("crowd_plaza", constants::CROWD_PLAZA.reset_plate.expect("crowd_plaza should have a reset_plate")),
|
||||
];
|
||||
for &(room_name, pos) in reset_plates {
|
||||
let entity = app
|
||||
@@ -189,6 +203,15 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// --- Populate RoomSnapshots for reset mechanism (#490) ---
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
|
||||
// Fog Theater entities (StableId 5-8): NPCs only
|
||||
for id in constants::FOG_THEATER_STABLE_IDS.0..=constants::FOG_THEATER_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("fog_theater", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Occlusion Corridor entities (StableId 9-12): NPCs only, no floor items
|
||||
for id in 9..=12 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
@@ -208,6 +231,15 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
// Interaction Gallery entities (StableId 24-28): objects only, no floor items
|
||||
for id in constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("interaction_gallery", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pause Chamber entity (StableId 29): NPC only
|
||||
if let Some(entity) = registry.to_entity(&StableId(29)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
@@ -215,6 +247,24 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogue Room entities (StableId 30-33): NPCs only
|
||||
for id in constants::DIALOGUE_ROOM_STABLE_IDS.0..=constants::DIALOGUE_ROOM_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("dialogue_room", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Crowd Plaza entities (StableId 34-48): NPCs only
|
||||
for id in constants::CROWD_PLAZA_STABLE_IDS.0..=constants::CROWD_PLAZA_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("crowd_plaza", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(snapshots);
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
@@ -331,9 +381,9 @@ mod tests {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
|
||||
}
|
||||
|
||||
// Fog Theater 5-8 reserved (no entities)
|
||||
// Fog Theater at 5-8
|
||||
for id in 5..=8 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_none(), "Fog Theater {} reserved", id);
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Fog Theater at StableId {}", id);
|
||||
}
|
||||
|
||||
// Occlusion Corridor at 9-12
|
||||
@@ -346,15 +396,25 @@ mod tests {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id);
|
||||
}
|
||||
|
||||
// Interaction Gallery 24-28 reserved
|
||||
// Interaction Gallery at 24-28
|
||||
for id in 24..=28 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_none(), "Gallery {} reserved", id);
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Gallery at StableId {}", id);
|
||||
}
|
||||
|
||||
// Pause Chamber at 29
|
||||
assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29");
|
||||
|
||||
// Reset plates at 49-51
|
||||
// Dialogue Room at 30-33
|
||||
for id in 30..=33 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Dialogue Room at StableId {}", id);
|
||||
}
|
||||
|
||||
// Crowd Plaza at 34-48
|
||||
for id in 34..=48 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Crowd Plaza at StableId {}", id);
|
||||
}
|
||||
|
||||
// Reset plates at 49-55
|
||||
for id in constants::RESET_PLATE_STABLE_IDS.0..=constants::RESET_PLATE_STABLE_IDS.1 {
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Crowd Plaza — Room 7 (32x32)
|
||||
//!
|
||||
//! Density stress test room. 15 NPCs in a 5x3 grid to test perception,
|
||||
//! snapshot, and tick-budget performance under high entity density.
|
||||
//! Validates D-026 tick budget holds with many visible entities.
|
||||
//!
|
||||
//! Observer position: (16, 16) relative = (96, 94) absolute, facing West.
|
||||
//!
|
||||
//! Entities (StableId 34-48):
|
||||
//! crowd_npc_00..crowd_npc_14 — 15 NPCs in a 5-column x 3-row grid
|
||||
//! Grid starts at rel (4, 4) = abs (84, 82), spacing: 5x cols, 8y rows.
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 80;
|
||||
const ORIGIN_Y: i32 = 78;
|
||||
|
||||
/// Grid layout constants.
|
||||
const GRID_COLS: usize = 5;
|
||||
const GRID_ROWS: usize = 3;
|
||||
const GRID_START_X: i32 = 4;
|
||||
const GRID_START_Y: i32 = 4;
|
||||
const GRID_SPACING_X: i32 = 5;
|
||||
const GRID_SPACING_Y: i32 = 8;
|
||||
|
||||
/// WantKind cycle for variety across 15 NPCs.
|
||||
const WANT_CYCLE: &[WantKind] = &[
|
||||
WantKind::Wealth,
|
||||
WantKind::Safety,
|
||||
WantKind::Knowledge,
|
||||
WantKind::Connection,
|
||||
WantKind::Power,
|
||||
WantKind::Freedom,
|
||||
WantKind::Justice,
|
||||
WantKind::Revenge,
|
||||
WantKind::Happiness,
|
||||
];
|
||||
|
||||
/// Spawn Crowd Plaza entities in canonical order (StableId 34-48).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
let mut index = 0usize;
|
||||
for row in 0..GRID_ROWS {
|
||||
for col in 0..GRID_COLS {
|
||||
let rx = GRID_START_X + col as i32 * GRID_SPACING_X;
|
||||
let ry = GRID_START_Y + row as i32 * GRID_SPACING_Y;
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let want_kind = WANT_CYCLE[index % WANT_CYCLE.len()];
|
||||
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity: ((index % 8) + 2) as u8, // 2-9 range
|
||||
description: format!("Crowd Plaza NPC #{:02}", index),
|
||||
},
|
||||
Contentment {
|
||||
level: (index as i16 * 7 - 50).clamp(-100, 100),
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: (index as i16 * 5) % 80,
|
||||
threshold: 40 + (index as i16 % 4) * 10,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Dialogue Room — Room 6 (28x20)
|
||||
//!
|
||||
//! Tests D-041 (knowledge graph), D-028 (dialogue filtering pipeline).
|
||||
//!
|
||||
//! Layout: Four NPCs with DialogueProfile components, enabling the full
|
||||
//! dialogue selection pipeline (access tier, situation, trust, mood scoring).
|
||||
//! NPCs have varying trust/contentment to test different dialogue branches.
|
||||
//!
|
||||
//! Observer position: (14, 10) relative = (50, 114) absolute, facing North.
|
||||
//!
|
||||
//! Entities (StableId 30-33):
|
||||
//! npc_dialogue_a (44, 112) — Relaxed dock worker
|
||||
//! npc_dialogue_b (50, 110) — Guarded technician
|
||||
//! npc_dialogue_c (56, 112) — Stressed supervisor
|
||||
//! npc_dialogue_d (50, 118) — Distant observer (range test)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::dialogue::{CurrentMood, DialogueProfile};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 36;
|
||||
const ORIGIN_Y: i32 = 104;
|
||||
|
||||
/// NPC definitions: (name, rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, location, role).
|
||||
#[allow(clippy::type_complexity)]
|
||||
const NPCS: &[(&str, i32, i32, WantKind, u8, i16, i16, i16, &str, &str)] = &[
|
||||
(
|
||||
"npc_dialogue_a", 8, 8, WantKind::Connection, 4, 20, 10, 60,
|
||||
"the-terminal", "dock-worker",
|
||||
),
|
||||
(
|
||||
"npc_dialogue_b", 14, 6, WantKind::Safety, 6, 0, 25, 45,
|
||||
"the-terminal", "technician",
|
||||
),
|
||||
(
|
||||
"npc_dialogue_c", 20, 8, WantKind::Power, 7, -15, 40, 50,
|
||||
"the-terminal", "supervisor",
|
||||
),
|
||||
(
|
||||
"npc_dialogue_d", 14, 14, WantKind::Knowledge, 3, 10, 5, 70,
|
||||
"the-terminal", "observer",
|
||||
),
|
||||
];
|
||||
|
||||
/// Spawn Dialogue Room entities in canonical order (StableId 30-33).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(name, rx, ry, want_kind, intensity, contentment, stress, threshold, location, role) in
|
||||
NPCS
|
||||
{
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity,
|
||||
description: format!("Dialogue Room test NPC: {}", name),
|
||||
},
|
||||
Contentment {
|
||||
level: contentment,
|
||||
},
|
||||
ToleranceThreshold {
|
||||
current_stress: stress,
|
||||
threshold,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
DialogueProfile {
|
||||
location: location.to_string(),
|
||||
role: role.to_string(),
|
||||
},
|
||||
CurrentMood::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Fog Theater — Room 1 (44x32)
|
||||
//!
|
||||
//! Tests D-059 (fog layers), D-060 (cognitive delay for fog recognition).
|
||||
//!
|
||||
//! Layout: Large open room with NPCs at varying distances from observer.
|
||||
//! Tests visibility at clear, peripheral, deep-fog, and edge-of-range
|
||||
//! distances. No internal walls — fog layers are distance-based, not
|
||||
//! occlusion-based (that's the Occlusion Corridor's job).
|
||||
//!
|
||||
//! Observer position: (28, 16) relative = (56, 18) absolute, facing South.
|
||||
//!
|
||||
//! Entities (StableId 5-8):
|
||||
//! npc_fog_clear (56, 22) — 4 tiles south, clear vision cone
|
||||
//! npc_fog_peripheral (46, 18) — 10 tiles west, peripheral sector
|
||||
//! npc_fog_deep (34, 10) — far NW corner, deep fog range
|
||||
//! npc_fog_edge (68, 28) — far SE, edge-of-range test
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 28;
|
||||
const ORIGIN_Y: i32 = 2;
|
||||
|
||||
/// NPC definitions: (name, relative_x, relative_y, want_kind, want_intensity).
|
||||
const NPCS: &[(&str, i32, i32, WantKind, u8)] = &[
|
||||
("npc_fog_clear", 28, 20, WantKind::Safety, 5),
|
||||
("npc_fog_peripheral", 18, 16, WantKind::Knowledge, 6),
|
||||
("npc_fog_deep", 6, 8, WantKind::Freedom, 3),
|
||||
("npc_fog_edge", 40, 26, WantKind::Wealth, 4),
|
||||
];
|
||||
|
||||
/// Spawn Fog Theater entities in canonical order (StableId 5-8).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(name, rx, ry, want_kind, intensity) in NPCS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity,
|
||||
description: format!("Fog Theater test NPC: {}", name),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Interaction Gallery — Room 4 (24x20)
|
||||
//!
|
||||
//! Tests D-057 (entity interaction vertical list, verb generation per type).
|
||||
//!
|
||||
//! Layout: One entity of each ObjectType to test that each type generates
|
||||
//! the correct interaction verb(s). Container is already tested in
|
||||
//! Inventory Warehouse, so this room covers the remaining 5 types.
|
||||
//!
|
||||
//! Observer position: (12, 10) relative = (14, 92) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 24-28):
|
||||
//! obj_notice (8, 88) — Readable (Read verb)
|
||||
//! obj_terminal (8, 94) — Terminal (Use verb)
|
||||
//! obj_hatch (20, 88) — Door (Open/Close verb)
|
||||
//! obj_pickup (20, 94) — Pickup (Pick up verb)
|
||||
//! obj_bench (14, 96) — Furniture (Sit verb)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::bridge::types::ObjectType;
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 2;
|
||||
const ORIGIN_Y: i32 = 82;
|
||||
|
||||
/// Object definitions: (name, relative_x, relative_y, object_type).
|
||||
const OBJECTS: &[(&str, i32, i32, ObjectType)] = &[
|
||||
("obj_notice", 6, 6, ObjectType::Readable),
|
||||
("obj_terminal", 6, 12, ObjectType::Terminal),
|
||||
("obj_hatch", 18, 6, ObjectType::Door),
|
||||
("obj_pickup", 18, 12, ObjectType::Pickup),
|
||||
("obj_bench", 12, 14, ObjectType::Furniture),
|
||||
];
|
||||
|
||||
/// Spawn Interaction Gallery entities in canonical order (StableId 24-28).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(_name, rx, ry, obj_type) in OBJECTS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((Interactable, obj_type, pos))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
//! Each room module exports a `spawn_entities()` function that creates
|
||||
//! entities in canonical order for deterministic StableId assignment.
|
||||
|
||||
pub mod crowd_plaza;
|
||||
pub mod dialogue_room;
|
||||
pub mod fog_theater;
|
||||
pub mod hub;
|
||||
pub mod interaction_gallery;
|
||||
pub mod inventory_warehouse;
|
||||
pub mod occlusion_corridor;
|
||||
pub mod pause_chamber;
|
||||
|
||||
@@ -59,6 +59,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -45,6 +45,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use bevy_app::prelude::*;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -51,6 +52,11 @@ fn content_runtime_boot_tick_10_snapshot() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
let server_addr = listener.local_addr().expect("get local addr");
|
||||
|
||||
// Barrier keeps the server thread alive until the client has finished
|
||||
// reading all snapshots, preventing a TCP RST race under parallel execution.
|
||||
let barrier = Arc::new(Barrier::new(2));
|
||||
let server_barrier = barrier.clone();
|
||||
|
||||
// Server thread: full plugin stack with real content
|
||||
let server_handle = thread::spawn(move || {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("accept connection");
|
||||
@@ -96,6 +102,9 @@ fn content_runtime_boot_tick_10_snapshot() {
|
||||
for _ in 0..10 {
|
||||
app.update();
|
||||
}
|
||||
|
||||
// Wait for client to finish reading before dropping the TCP socket
|
||||
server_barrier.wait();
|
||||
});
|
||||
|
||||
// Client: connect with read timeout and receive 10 snapshots
|
||||
@@ -129,6 +138,9 @@ fn content_runtime_boot_tick_10_snapshot() {
|
||||
drop(reader);
|
||||
drop(writer);
|
||||
|
||||
// Signal server thread that client is done reading
|
||||
barrier.wait();
|
||||
|
||||
// Server thread must not have panicked
|
||||
server_handle
|
||||
.join()
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Content scaling test (#500, D-026).
|
||||
//!
|
||||
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
|
||||
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
|
||||
//! NPCs and compares:
|
||||
//! 1. Tick timing stays within D-026 budget (100ms)
|
||||
//! 2. Baseline entities still behave identically (deterministic)
|
||||
//!
|
||||
//! Run with: cargo test --test content_scaling -- --nocapture
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use std::time::Instant;
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::BridgePlugin;
|
||||
use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use settled_reach_server::knowledge::KnowledgePlugin;
|
||||
use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind};
|
||||
use settled_reach_server::simulation::interaction::Interactable;
|
||||
use settled_reach_server::simulation::movement::TilePosition;
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
/// Number of ticks to run for timing measurements.
|
||||
const TIMING_TICKS: usize = 50;
|
||||
|
||||
/// D-026 budget: 100ms per tick maximum.
|
||||
const MAX_TICK_MS: f64 = 100.0;
|
||||
|
||||
/// Extra NPC counts for scaling tiers.
|
||||
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
|
||||
|
||||
/// Set up a Gauntlet world and return the app.
|
||||
fn setup_baseline() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
settled_reach_server::test_world::setup_gauntlet(&mut app);
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
/// Spawn N extra NPCs spread across the Gauntlet hub area.
|
||||
/// NPCs are placed in a grid starting at (40, 48) to stay within walkable space.
|
||||
fn spawn_extra_npcs(app: &mut App, count: usize) {
|
||||
// Remove registry from world so we can mutate it while also spawning entities.
|
||||
let mut registry = app
|
||||
.world_mut()
|
||||
.remove_resource::<EntityRegistry>()
|
||||
.expect("EntityRegistry should exist after setup_gauntlet");
|
||||
let cols = 10;
|
||||
|
||||
for i in 0..count {
|
||||
let x = 40 + (i % cols) as i32;
|
||||
let y = 48 + (i / cols) as i32;
|
||||
let pos = TilePosition::new(x, y, 0);
|
||||
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 5,
|
||||
description: format!("extra_npc_{}", i),
|
||||
},
|
||||
Contentment { level: 0 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 0,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
|
||||
/// Tick the app N times and return average milliseconds per tick.
|
||||
fn measure_tick_timing(app: &mut App, ticks: usize) -> f64 {
|
||||
// Warm-up tick (first tick has startup overhead)
|
||||
app.update();
|
||||
|
||||
let start = Instant::now();
|
||||
for _ in 0..ticks {
|
||||
app.update();
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
elapsed.as_secs_f64() * 1000.0 / ticks as f64
|
||||
}
|
||||
|
||||
/// Collect snapshot entity IDs from the VisibilityGeometry and entity count.
|
||||
fn count_entities(app: &App) -> usize {
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
registry.len() as usize
|
||||
}
|
||||
|
||||
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn baseline_tick_timing_within_budget() {
|
||||
let mut app = setup_baseline();
|
||||
let entity_count = count_entities(&app);
|
||||
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
|
||||
|
||||
eprintln!(
|
||||
"Baseline: {} entities, avg {:.3}ms/tick over {} ticks",
|
||||
entity_count, avg_ms, TIMING_TICKS
|
||||
);
|
||||
|
||||
assert!(
|
||||
avg_ms < MAX_TICK_MS,
|
||||
"Baseline tick timing ({:.3}ms) exceeds D-026 budget ({}ms)",
|
||||
avg_ms,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
}
|
||||
|
||||
/// Scaling test: adding NPCs keeps tick timing within D-026 budget.
|
||||
/// Tests 0 (baseline), 15, and 50 extra NPCs.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn scaling_tick_timing_within_budget() {
|
||||
let mut results: Vec<(usize, usize, f64)> = Vec::new();
|
||||
|
||||
for &extra_count in EXTRA_NPC_COUNTS {
|
||||
let mut app = setup_baseline();
|
||||
if extra_count > 0 {
|
||||
spawn_extra_npcs(&mut app, extra_count);
|
||||
}
|
||||
let total_entities = count_entities(&app);
|
||||
let avg_ms = measure_tick_timing(&mut app, TIMING_TICKS);
|
||||
results.push((extra_count, total_entities, avg_ms));
|
||||
}
|
||||
|
||||
eprintln!("\n=== Content Scaling Results (D-026: {}ms budget) ===", MAX_TICK_MS);
|
||||
eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick");
|
||||
eprintln!("{:-<37}", "");
|
||||
for &(extra, total, avg_ms) in &results {
|
||||
let status = if avg_ms < MAX_TICK_MS { "OK" } else { "OVER" };
|
||||
eprintln!("{:<12} {:<10} {:<15.3} {}", extra, total, avg_ms, status);
|
||||
}
|
||||
|
||||
// Assert all tiers stay within budget
|
||||
for &(extra, _total, avg_ms) in &results {
|
||||
assert!(
|
||||
avg_ms < MAX_TICK_MS,
|
||||
"Tick timing with +{} NPCs ({:.3}ms) exceeds D-026 budget ({}ms)",
|
||||
extra,
|
||||
avg_ms,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
}
|
||||
|
||||
// Assert scaling is reasonable: +50 NPCs shouldn't more than 5x the baseline
|
||||
if results.len() >= 2 {
|
||||
let baseline_ms = results[0].2;
|
||||
let max_extra_ms = results.last().unwrap().2;
|
||||
let scaling_factor = max_extra_ms / baseline_ms;
|
||||
eprintln!(
|
||||
"\nScaling factor (baseline → +{} NPCs): {:.2}x",
|
||||
results.last().unwrap().0,
|
||||
scaling_factor
|
||||
);
|
||||
assert!(
|
||||
scaling_factor < 5.0,
|
||||
"Scaling factor {:.2}x exceeds 5x threshold — possible O(n^2) regression",
|
||||
scaling_factor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Determinism test: baseline entities produce identical snapshots regardless
|
||||
/// of extra NPCs being present. The original Gauntlet entities (StableId 0
|
||||
/// through RESET_PLATE_STABLE_IDS.1) should have the same positions and
|
||||
/// visibility after the same number of ticks.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
// Run baseline
|
||||
let mut baseline_app = setup_baseline();
|
||||
for _ in 0..10 {
|
||||
baseline_app.update();
|
||||
}
|
||||
let baseline_buffer = baseline_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
|
||||
// Run with extra NPCs
|
||||
let mut scaled_app = setup_baseline();
|
||||
spawn_extra_npcs(&mut scaled_app, 15);
|
||||
for _ in 0..10 {
|
||||
scaled_app.update();
|
||||
}
|
||||
let scaled_buffer = scaled_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
|
||||
let baseline_snap = baseline_buffer.expect("baseline should produce a snapshot");
|
||||
let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot");
|
||||
|
||||
// Same tick
|
||||
assert_eq!(baseline_snap.tick, scaled_snap.tick, "tick count should match");
|
||||
|
||||
// Same game time
|
||||
assert_eq!(
|
||||
baseline_snap.game_time.time_of_day, scaled_snap.game_time.time_of_day,
|
||||
"game time should match"
|
||||
);
|
||||
|
||||
// Player position should be identical
|
||||
let baseline_player = baseline_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
|
||||
let scaled_player = scaled_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
|
||||
assert!(baseline_player.is_some(), "baseline should have player");
|
||||
assert!(scaled_player.is_some(), "scaled should have player");
|
||||
|
||||
let bp = baseline_player.unwrap();
|
||||
let sp = scaled_player.unwrap();
|
||||
assert_eq!(bp.x, sp.x, "player x should match");
|
||||
assert_eq!(bp.y, sp.y, "player y should match");
|
||||
|
||||
// Original entities (entity_id <= max Gauntlet StableId) visible in baseline
|
||||
// should still be visible in scaled run. Extra NPCs may add to the visible
|
||||
// set, but shouldn't remove baseline visibility.
|
||||
let max_baseline_id = settled_reach_server::test_world::constants::RESET_PLATE_STABLE_IDS.1;
|
||||
let baseline_original_ids: Vec<u64> = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_baseline_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
let scaled_original_ids: Vec<u64> = scaled_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_baseline_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
baseline_original_ids, scaled_original_ids,
|
||||
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +205,7 @@ fn generate_msgpack_fixtures() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_v2_full",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"blocked_entities": [
|
||||
2
|
||||
],
|
||||
"current_monologue": null,
|
||||
"dialogue_response": null,
|
||||
"entities": [
|
||||
@@ -62,7 +65,7 @@
|
||||
"player_inventory": [],
|
||||
"player_stance": "Sprint",
|
||||
"tick": 8,
|
||||
"version": 8,
|
||||
"version": 9,
|
||||
"visible_tiles": [
|
||||
{
|
||||
"tile_kind": "Wall",
|
||||
|
||||
@@ -24,6 +24,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +251,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
@@ -304,7 +306,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 8,
|
||||
PROTOCOL_VERSION, 9,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
@@ -342,6 +344,7 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -469,6 +472,10 @@ fn v5_payload_deserializes_into_v6_struct() {
|
||||
decoded.pending_recognitions.is_empty(),
|
||||
"missing pending_recognitions should default to empty"
|
||||
);
|
||||
assert!(
|
||||
decoded.blocked_entities.is_empty(),
|
||||
"missing blocked_entities should default to empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal)
|
||||
@@ -1095,6 +1102,90 @@ fn gdscript_generated_fixtures_deserialize() {
|
||||
eprintln!("Verified {} GDScript-generated fixtures", count);
|
||||
}
|
||||
|
||||
/// blocked_entities Vec<u64> round-trips through MessagePack (#514).
|
||||
/// Guards the debug field survives serialization.
|
||||
#[test]
|
||||
fn blocked_entities_roundtrip() {
|
||||
let mut snapshot = test_snapshot(0, vec![]);
|
||||
snapshot.blocked_entities = vec![42, 99, 1024];
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(
|
||||
decoded.blocked_entities,
|
||||
vec![42, 99, 1024],
|
||||
"blocked_entities should survive roundtrip"
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty blocked_entities round-trips correctly (#514).
|
||||
#[test]
|
||||
fn blocked_entities_empty_roundtrip() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert!(snapshot.blocked_entities.is_empty());
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert!(
|
||||
decoded.blocked_entities.is_empty(),
|
||||
"empty blocked_entities should survive roundtrip"
|
||||
);
|
||||
}
|
||||
|
||||
/// v8 payloads (without blocked_entities) must deserialize into the v9 struct
|
||||
/// via #[serde(default)]. Guards backwards compat during migration (#514).
|
||||
#[test]
|
||||
fn v8_payload_deserializes_into_v9_struct() {
|
||||
#[derive(serde::Serialize)]
|
||||
struct ObserverSnapshotV8 {
|
||||
version: u8,
|
||||
tick: u64,
|
||||
game_time: GameTime,
|
||||
player_facing: FacingDirection,
|
||||
player_stance: MovementStance,
|
||||
player_inventory: Vec<InventoryItem>,
|
||||
entities: Vec<VisibleEntity>,
|
||||
visible_tiles: Vec<VisibleTile>,
|
||||
nearby_interactions: Vec<NearbyInteraction>,
|
||||
current_monologue: Option<MonologueEvent>,
|
||||
pending_recognitions: Vec<PendingRecognitionWire>,
|
||||
dialogue_response: Option<DialogueResponseEvent>,
|
||||
}
|
||||
|
||||
let v8 = ObserverSnapshotV8 {
|
||||
version: 8,
|
||||
tick: 100,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
player_stance: MovementStance::Walk,
|
||||
player_inventory: vec![],
|
||||
entities: vec![],
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&v8).expect("serialize v8");
|
||||
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(),
|
||||
"missing blocked_entities should default to empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
||||
/// Verifies object_type=Some(Container) survives the wire.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user