Merge remote-tracking branch 'origin/server'
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
[{"tick":0,"action":"MoveNorth"}]
|
||||
[{"tick":1,"action":"MoveNorth"}]
|
||||
[{"tick":2,"action":"MoveEast"}]
|
||||
[{"tick":3,"action":"TeleportToHub"}]
|
||||
[{"tick":4,"action":"MoveNorth"}]
|
||||
@@ -0,0 +1,5 @@
|
||||
[]
|
||||
[]
|
||||
[]
|
||||
[]
|
||||
[]
|
||||
@@ -0,0 +1,5 @@
|
||||
[{"tick":0,"action":"MoveNorth"}]
|
||||
[{"tick":1,"action":"MoveNorth"}]
|
||||
[{"tick":2,"action":"MoveNorth"}]
|
||||
[{"tick":3,"action":"MoveNorth"}]
|
||||
[{"tick":4,"action":"MoveNorth"}]
|
||||
Generated
+44
-1
@@ -556,6 +556,16 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -799,6 +809,12 @@ version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
@@ -1067,6 +1083,19 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -1143,7 +1172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.0"
|
||||
version = "0.1.9"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
@@ -1168,6 +1197,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"settled-reach-server",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1241,6 +1271,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
|
||||
@@ -14,3 +14,6 @@ clap = { version = "4", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
rmp-serde = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -6,6 +6,10 @@ use settled_reach_server::bridge::types::PlayerInput;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load a JSONL replay file. Returns one Vec<PlayerInput> per tick.
|
||||
///
|
||||
/// Format: one JSON array per line. Each array contains PlayerInput objects
|
||||
/// for that tick. Blank lines are skipped. Returns Err with line number on
|
||||
/// parse failure.
|
||||
pub fn load_replay(path: &Path) -> Result<Vec<Vec<PlayerInput>>, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read replay file {}: {}", path.display(), e))?;
|
||||
@@ -22,3 +26,152 @@ pub fn load_replay(path: &Path) -> Result<Vec<Vec<PlayerInput>>, String> {
|
||||
}
|
||||
Ok(ticks)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use settled_reach_server::bridge::types::PlayerAction;
|
||||
use std::io::Write;
|
||||
|
||||
fn write_temp_file(content: &str) -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||
f.write_all(content.as_bytes()).unwrap();
|
||||
f.flush().unwrap();
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_single_tick_single_action() {
|
||||
let f = write_temp_file(r#"[{"tick":0,"action":"MoveNorth"}]"#);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
assert_eq!(ticks[0].len(), 1);
|
||||
assert!(ticks[0][0].action.is_movement());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_multiple_ticks() {
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"}]
|
||||
[{"tick":1,"action":"MoveEast"}]
|
||||
[{"tick":2,"action":"MoveSouth"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_multiple_actions_per_tick() {
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"},{"tick":0,"action":"Pause"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
assert_eq!(ticks[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_empty_array_idle_tick() {
|
||||
let content = "[]";
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
assert!(ticks[0].is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_skipped() {
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"}]
|
||||
|
||||
[{"tick":2,"action":"MoveSouth"}]
|
||||
|
||||
"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 2, "blank lines should be skipped, not counted as ticks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_returns_empty_vec() {
|
||||
let f = write_temp_file("");
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert!(ticks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_file_returns_empty_vec() {
|
||||
let f = write_temp_file(" \n \n\n ");
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert!(ticks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_reports_line_number() {
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"}]
|
||||
not valid json
|
||||
[{"tick":2,"action":"MoveSouth"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let err = load_replay(f.path()).unwrap_err();
|
||||
assert!(err.contains("replay line 2"), "error should reference line 2, got: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_returns_error() {
|
||||
let err = load_replay(Path::new("/nonexistent/replay.jsonl")).unwrap_err();
|
||||
assert!(err.contains("failed to read replay file"), "got: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interact_action_parses() {
|
||||
let content =
|
||||
r#"[{"tick":0,"action":{"Interact":{"target_entity_id":42,"verb":"Talk"}}}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
match &ticks[0][0].action {
|
||||
PlayerAction::Interact {
|
||||
target_entity_id,
|
||||
verb,
|
||||
} => {
|
||||
assert_eq!(*target_entity_id, Some(42));
|
||||
assert_eq!(verb.as_deref(), Some("Talk"));
|
||||
}
|
||||
other => panic!("expected Interact, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teleport_to_hub_parses() {
|
||||
let content = r#"[{"tick":0,"action":"TeleportToHub"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 1);
|
||||
assert!(matches!(ticks[0][0].action, PlayerAction::TeleportToHub));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_away_parses() {
|
||||
let content = r#"[{"tick":0,"action":"WalkAway"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert!(matches!(ticks[0][0].action, PlayerAction::WalkAway));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_replay_scenario() {
|
||||
// Simulates a realistic Gauntlet replay: move, idle, interact, move, teleport
|
||||
let content = r#"[{"tick":0,"action":"MoveNorth"}]
|
||||
[{"tick":1,"action":"MoveNorth"}]
|
||||
[]
|
||||
[{"tick":3,"action":{"Interact":{"target_entity_id":100,"verb":"Talk"}}}]
|
||||
[{"tick":4,"action":"MoveEast"}]
|
||||
[{"tick":5,"action":"TeleportToHub"}]"#;
|
||||
let f = write_temp_file(content);
|
||||
let ticks = load_replay(f.path()).unwrap();
|
||||
assert_eq!(ticks.len(), 6);
|
||||
assert_eq!(ticks[0].len(), 1); // MoveNorth
|
||||
assert_eq!(ticks[1].len(), 1); // MoveNorth
|
||||
assert_eq!(ticks[2].len(), 0); // Idle
|
||||
assert_eq!(ticks[3].len(), 1); // Interact
|
||||
assert_eq!(ticks[4].len(), 1); // MoveEast
|
||||
assert_eq!(ticks[5].len(), 1); // TeleportToHub
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user