Merge remote-tracking branch 'origin/server'
This commit is contained in:
@@ -6,6 +6,47 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- Speaker wire ID silent fallback — dialogue now warns and skips when target entity missing from registry (was silently using 0)
|
||||
- Cross-plugin system ordering — trigger_recognition_monologue now runs after detect_anomalies (latent determinism bug)
|
||||
- Walk-away ordering — process_walk_away now runs after process_talk_interaction (prevents same-tick race)
|
||||
- ActiveDialogue overwrite — new Talk while in existing dialogue now emits IncompleteInteraction before replacing
|
||||
- Server-side Talk range check — handle_talk now enforces CLOSE_RANGE before setting TalkRequest (was client-only)
|
||||
- ExamineNpc label collision — VerbKind::ExamineNpc now uses "Examine NPC" label (was "Observe", same as generic Observe)
|
||||
- Dead conditional in main.rs collapsed (both branches were identical)
|
||||
- WalkAway variant added to all_player_action_variants_roundtrip serialization test
|
||||
|
||||
### Changed
|
||||
- DialogueCooldownTracker.used changed from Vec to BTreeMap for O(log n) lookup (D-041 compliance)
|
||||
- MonologueState.shown_ids changed from Vec to HashSet for O(1) contains check (was O(n) per tick)
|
||||
- Secret trust tier documented as unreachable with TODO for Phase 2 KG-gated unlock
|
||||
|
||||
### Added
|
||||
- Determinism test: different_seed_produces_different_replay — exercises SimRng via dialogue weighted selection
|
||||
- Dialogue selection pipeline (#305, D-028) — 4-layer filtering engine: access tier from KG relationship, situation derivation from game state, trust tier, weighted topic+mood scoring via SimRng. Full Talk verb → selected line → ObserverSnapshot pipeline with cooldown tracking
|
||||
- ContentSlug component (#452) — stable content identity from YAML (e.g. "kael-davan") independent of runtime Entity handles, for knowledge graph interaction memory across save/load
|
||||
- Walk-away KG recording (#427, D-064) — IncompleteInteraction knowledge events with Talk/Confront type, ActiveDialogue tracking, walk-away detection clears dialogue and records in KG
|
||||
- Anomaly detection for urgent recognition (#450, D-060) — AnomalyMarker flags PersonOfInterest/Contradicted entities for 0.3s cognitive delay instead of 0.6s normal
|
||||
- Recognition monologue during cognitive delay (#451, D-060) — monologue fires at delay START (grey blob phase), not completion. v0.1 fallback lines, anomaly prioritization, cooldown tracking
|
||||
- Server --test-mode, --port, --seed CLI flags (#459) — LISTENING:{port} stdout signal, OS-assigned ports, deterministic seed override, stderr-only tracing
|
||||
- Determinism gauntlet test (#466) — 20-tick replay determinism regression test with movement, stance, pause/unpause exercise
|
||||
- Pause guard test suite (#461-463, #468) — 7 tests covering movement, unpause, roundtrip, stance, interact, batch, tick_rate during pause
|
||||
- EntityRegistry lifecycle tests (#469) — stale mapping, re-register, unknown unregister edge cases
|
||||
- Boundary value encode/roundtrip tests (#471) — 41 values across all MessagePack integer format boundaries
|
||||
- Encoding asymmetry tests (#473) — Rust decoder accepts GDScript-style signed encodings for unsigned fields
|
||||
- Boundary fixture generation (#472) — 14 raw + 5 snapshot fixtures at integer format boundaries
|
||||
- Malformed batch rejection test (#479) — truncated, garbage, and mixed payloads rejected atomically
|
||||
- Per-fix determinism unit tests (#467) — equidistant NPC ordering, visible tile sorting, same-tile mover resolution
|
||||
|
||||
### Fixed
|
||||
- Determinism: visible_ids HashSet → BTreeSet for stable iteration order (#456)
|
||||
- Determinism: visible entities in snapshot sorted by entity_id (#457)
|
||||
- Determinism: movers sorted by Entity bits in validate_movement (#458)
|
||||
- Pause guard blocks all actions except Pause/Unpause while paused (previously only blocked movement)
|
||||
|
||||
### Changed
|
||||
- Protocol version bumped from v7 to v8 (dialogue_response field in ObserverSnapshot)
|
||||
|
||||
### Added
|
||||
- AudioManager autoload (#255, D-068/D-069/D-073) — 5-bus architecture (Music, Ambient, WorldSFX, PlayerActions, UISounds), directory-scan asset registry, spatial/non-spatial playback, audio dip profiles (dialogue, confrontation, listening_focus) with low-pass filter sweep, zone crossfade stub
|
||||
- Dialogue response selection (#435, D-061/D-062) — structured options with response_id, priority sorting, max 3 visible, invisible locked options, RichTextLabel for BBCode support
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
̀
|
||||
@@ -0,0 +1 @@
|
||||
����
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
�����
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
���
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
��������
|
||||
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.
@@ -167,12 +167,21 @@ impl Plugin for BridgePlugin {
|
||||
.after(crate::simulation::movement::validate_movement),
|
||||
crate::simulation::monologue::trigger_monologue
|
||||
.after(crate::simulation::movement::validate_movement),
|
||||
crate::simulation::monologue::trigger_recognition_monologue
|
||||
.after(crate::simulation::monologue::trigger_monologue)
|
||||
.after(crate::perception::anomaly::detect_anomalies),
|
||||
crate::simulation::monologue::process_sprint_anomaly_monologue
|
||||
.after(crate::simulation::monologue::trigger_monologue),
|
||||
.after(crate::simulation::monologue::trigger_recognition_monologue),
|
||||
crate::simulation::dialogue::process_talk_interaction
|
||||
.after(crate::simulation::input::process_player_input),
|
||||
crate::simulation::dialogue::process_walk_away
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.after(crate::simulation::dialogue::process_talk_interaction),
|
||||
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)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
crate::perception::observation::emit_observation_events
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
|
||||
@@ -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 = 7;
|
||||
pub const PROTOCOL_VERSION: u8 = 8;
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
@@ -26,6 +26,7 @@ pub const PROTOCOL_VERSION: u8 = 7;
|
||||
/// v5 adds: current_monologue (#414 internal monologue pipeline).
|
||||
/// 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).
|
||||
/// Future fields: ambient sound events, HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
@@ -62,6 +63,11 @@ pub struct ObserverSnapshot {
|
||||
/// Empty when no recognitions are pending.
|
||||
#[serde(default)]
|
||||
pub pending_recognitions: Vec<PendingRecognitionWire>,
|
||||
/// Dialogue response from Talk verb interaction (#305, D-028).
|
||||
/// Present when the player talked to an NPC this tick and a line was selected.
|
||||
/// Client shows speaker name + dialogue text in a dialogue box.
|
||||
#[serde(default)]
|
||||
pub dialogue_response: Option<DialogueResponseEvent>,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
@@ -300,6 +306,10 @@ pub enum PlayerAction {
|
||||
UsePerceptionMode(String),
|
||||
Pause,
|
||||
Unpause,
|
||||
/// Player walked away during active dialogue (WASD during conversation, D-064).
|
||||
/// Client sends this when movement input is detected while dialogue box is visible.
|
||||
/// Server records incomplete interaction in KG and clears dialogue state.
|
||||
WalkAway,
|
||||
/// Set tick rate: Full (1.0), Half (0.5), or Paused (0.0) per D-052
|
||||
SetTickRate(TickRate),
|
||||
/// Move one step up the stance ladder (toward Sprint) per D-053
|
||||
@@ -434,6 +444,18 @@ pub struct MonologueEvent {
|
||||
pub duration_seconds: f32,
|
||||
}
|
||||
|
||||
/// Dialogue response event sent to the client for display (#305, D-028).
|
||||
/// Contains the selected line and speaker identity. Client renders a dialogue box.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DialogueResponseEvent {
|
||||
/// Dialogue line ID (for dedup and cooldown tracking)
|
||||
pub line_id: String,
|
||||
/// The dialogue text to display
|
||||
pub text: String,
|
||||
/// Wire-format entity identifier of the speaking NPC
|
||||
pub speaker_entity_id: u64,
|
||||
}
|
||||
|
||||
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct SnapshotBuffer {
|
||||
|
||||
@@ -32,6 +32,19 @@ use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::time::DayPhase;
|
||||
|
||||
/// Stable content identifier from YAML (e.g., "kael-davan", "sera-venn").
|
||||
///
|
||||
/// Bridges authoring identity to ECS entities. Independent of StableId —
|
||||
/// StableId is runtime entity tracking (KG references), ContentSlug is
|
||||
/// authoring/content identity (which authored NPC template). Not all entities
|
||||
/// have ContentSlugs (e.g., procedurally spawned NPCs, furniture).
|
||||
///
|
||||
/// Used by #427 (walk-away KG recording) to record interaction memory
|
||||
/// against a stable content identity rather than an Entity (which is
|
||||
/// unstable across save/load).
|
||||
#[derive(Component, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ContentSlug(pub String);
|
||||
|
||||
/// Result of spawning content into the ECS world.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SpawnResult {
|
||||
@@ -184,7 +197,9 @@ fn spawn_npc(world: &mut World, profile: &types::NpcProfile, result: &mut SpawnR
|
||||
|
||||
// Register in EntityRegistry for StableId mapping
|
||||
let stable_id = world.resource_mut::<EntityRegistry>().register(entity);
|
||||
world.entity_mut(entity).insert(StableEntityId(stable_id));
|
||||
world
|
||||
.entity_mut(entity)
|
||||
.insert((StableEntityId(stable_id), ContentSlug(profile.canonical_id.clone())));
|
||||
|
||||
result
|
||||
.npc_ids
|
||||
@@ -722,6 +737,26 @@ mod tests {
|
||||
assert!(!skills.combat_trained);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_npc_attaches_content_slug() {
|
||||
let mut world = create_test_world();
|
||||
let profile = create_test_profile();
|
||||
let mut result = SpawnResult::default();
|
||||
|
||||
spawn_npc(&mut world, &profile, &mut result);
|
||||
|
||||
let stable_id = result.npc_ids["test-npc"];
|
||||
let entity = world
|
||||
.resource::<EntityRegistry>()
|
||||
.to_entity(&stable_id)
|
||||
.unwrap();
|
||||
|
||||
let slug = world
|
||||
.get::<ContentSlug>(entity)
|
||||
.expect("ContentSlug should be attached during spawn");
|
||||
assert_eq!(slug.0, "test-npc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_npc_minimal_profile() {
|
||||
let mut world = create_test_world();
|
||||
|
||||
@@ -29,6 +29,25 @@ pub enum KnowledgeEventType {
|
||||
},
|
||||
/// Entity left observer's LOS (downgrades from Direct).
|
||||
LeftLOS { target: Entity },
|
||||
/// Observer walked away from an active interaction (D-064).
|
||||
/// Records incompleteness in the target's known_attributes for future
|
||||
/// dialogue/monologue consequences.
|
||||
IncompleteInteraction {
|
||||
target: Entity,
|
||||
interaction_type: InteractionType,
|
||||
},
|
||||
}
|
||||
|
||||
/// Type of interaction for walk-away recording (D-064).
|
||||
/// Differentiates casual conversation from confrontation —
|
||||
/// future dialogue may react differently.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InteractionType {
|
||||
/// Normal Talk conversation.
|
||||
Talk,
|
||||
/// Confrontation (D-063). Walking away from confrontation
|
||||
/// carries heavier consequences than casual talk.
|
||||
Confront,
|
||||
}
|
||||
|
||||
/// Resource: queue of pending knowledge events.
|
||||
@@ -98,6 +117,24 @@ pub fn process_knowledge_events(
|
||||
tracing::error!("LeftLOS target {:?} not in EntityRegistry", target);
|
||||
}
|
||||
}
|
||||
KnowledgeEventType::IncompleteInteraction {
|
||||
target,
|
||||
interaction_type,
|
||||
} => {
|
||||
if let Some(stable_id) = registry.to_stable(target) {
|
||||
observer_kg.record_incomplete_interaction(
|
||||
&stable_id,
|
||||
interaction_type,
|
||||
event.tick,
|
||||
);
|
||||
tracing::debug!(
|
||||
"Recorded incomplete {:?} interaction with {:?} at tick {}",
|
||||
interaction_type,
|
||||
stable_id,
|
||||
event.tick,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,61 @@ impl KnowledgeGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an incomplete interaction with an entity (D-064 walk-away).
|
||||
///
|
||||
/// Appends to known_attributes["incomplete_interactions"] as a
|
||||
/// comma-separated list of "tick:type" entries. Creates the entity
|
||||
/// entry if it doesn't exist (at Suspects confidence).
|
||||
pub fn record_incomplete_interaction(
|
||||
&mut self,
|
||||
target: &StableId,
|
||||
interaction_type: super::events::InteractionType,
|
||||
tick: u64,
|
||||
) {
|
||||
let entry = self
|
||||
.entities
|
||||
.entry(*target)
|
||||
.or_insert_with(|| EntityKnowledge {
|
||||
last_known_position: None,
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 0,
|
||||
confidence: KnowledgeConfidence::Suspects,
|
||||
source: KnowledgeSource::DirectObservation { tick },
|
||||
state: KnowledgeState::Active,
|
||||
relationship: RelationshipState::Unknown,
|
||||
known_attributes: BTreeMap::new(),
|
||||
});
|
||||
|
||||
let type_str = match interaction_type {
|
||||
super::events::InteractionType::Talk => "talk",
|
||||
super::events::InteractionType::Confront => "confront",
|
||||
};
|
||||
let record = format!("{}:{}", tick, type_str);
|
||||
|
||||
entry
|
||||
.known_attributes
|
||||
.entry("incomplete_interactions".to_string())
|
||||
.and_modify(|v| {
|
||||
v.push(',');
|
||||
v.push_str(&record);
|
||||
})
|
||||
.or_insert(record);
|
||||
|
||||
entry.last_updated_tick = tick;
|
||||
}
|
||||
|
||||
/// Check if the observer has any incomplete interactions with an entity.
|
||||
///
|
||||
/// Returns true if known_attributes["incomplete_interactions"] exists
|
||||
/// and is non-empty. Used by dialogue/monologue systems to gate
|
||||
/// post-conversation reactions (D-064 phase 3).
|
||||
pub fn has_incomplete_interaction(&self, target: &StableId) -> bool {
|
||||
self.entities
|
||||
.get(target)
|
||||
.and_then(|e| e.known_attributes.get("incomplete_interactions"))
|
||||
.is_some_and(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Set relationship state for an entity.
|
||||
pub fn set_relationship(&mut self, target: &StableId, state: RelationshipState) {
|
||||
if let Some(entry) = self.entities.get_mut(target) {
|
||||
|
||||
@@ -12,7 +12,7 @@ pub mod graph;
|
||||
pub mod registry;
|
||||
pub mod types;
|
||||
|
||||
pub use events::{KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||
pub use events::{InteractionType, KnowledgeEvent, KnowledgeEventQueue, KnowledgeEventType};
|
||||
pub use graph::KnowledgeGraph;
|
||||
pub use registry::{EntityRegistry, StableEntityId};
|
||||
pub use types::*;
|
||||
|
||||
@@ -158,4 +158,96 @@ mod tests {
|
||||
assert_eq!(registry.to_entity(&StableId(999)), None);
|
||||
assert_eq!(registry.to_stable(e1), None);
|
||||
}
|
||||
|
||||
// === EntityRegistry lifecycle edge cases (#469) ===
|
||||
|
||||
#[test]
|
||||
fn stale_mapping_after_despawn() {
|
||||
// #469: Registry returns stale Entity after world despawn.
|
||||
// This documents the expected behavior — caller must unregister after despawn.
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let id = registry.register(e1);
|
||||
|
||||
// Despawn from world — registry doesn't know
|
||||
world.despawn(e1);
|
||||
|
||||
// Registry still maps the StableId to the (now stale) Entity
|
||||
let stale_entity = registry.to_entity(&id);
|
||||
assert!(
|
||||
stale_entity.is_some(),
|
||||
"Registry still holds mapping after world despawn"
|
||||
);
|
||||
|
||||
// But the world no longer recognizes the entity
|
||||
assert!(
|
||||
world.get_entity(stale_entity.unwrap()).is_err(),
|
||||
"World rejects stale entity — caller must call unregister()"
|
||||
);
|
||||
|
||||
// After proper cleanup, mapping is gone
|
||||
registry.unregister(e1);
|
||||
assert_eq!(
|
||||
registry.to_entity(&id),
|
||||
None,
|
||||
"Mapping gone after unregister"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_after_unregister_assigns_new_id() {
|
||||
// #469: Re-registering the same entity after unregister gets a new StableId.
|
||||
// StableId counter is monotonic — never recycles.
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
let id_first = registry.register(e1);
|
||||
assert_eq!(id_first, StableId(0));
|
||||
|
||||
registry.unregister(e1);
|
||||
|
||||
let id_second = registry.register(e1);
|
||||
assert_ne!(
|
||||
id_first, id_second,
|
||||
"Re-registration must assign a new StableId"
|
||||
);
|
||||
assert_eq!(
|
||||
id_second,
|
||||
StableId(1),
|
||||
"Counter advances monotonically"
|
||||
);
|
||||
assert_eq!(registry.len(), 1);
|
||||
|
||||
// New mapping is bidirectionally correct
|
||||
assert_eq!(registry.to_entity(&id_second), Some(e1));
|
||||
assert_eq!(registry.to_stable(e1), Some(id_second));
|
||||
|
||||
// Old StableId no longer resolves
|
||||
assert_eq!(
|
||||
registry.to_entity(&id_first),
|
||||
None,
|
||||
"Old StableId must not resolve"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_unknown_entity_is_noop() {
|
||||
// #469: Unregistering an entity that was never registered must not panic.
|
||||
let mut world = World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
let e2 = world.spawn_empty().id();
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
registry.register(e1);
|
||||
|
||||
// Unregister e2 which was never registered — should be a no-op
|
||||
registry.unregister(e2);
|
||||
|
||||
// e1's registration is unaffected
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(registry.to_stable(e1), Some(StableId(0)));
|
||||
}
|
||||
}
|
||||
|
||||
+142
-56
@@ -1,66 +1,175 @@
|
||||
// The Settled Reach - Simulation Server
|
||||
// Entry point for standalone simulation binary
|
||||
//
|
||||
// Supports --test-mode for automated testing:
|
||||
// --test-mode Enable test mode (fixed seed, LISTENING signal, quieter logs)
|
||||
// --port <PORT> Bind to specific port (0 = OS-assigned). Overrides positional addr.
|
||||
// --seed <SEED> RNG seed (default: 0, test-mode default: 42)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
|
||||
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use settled_reach_server::npc::{
|
||||
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry, ToleranceThreshold,
|
||||
Want, WantKind,
|
||||
};
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
|
||||
use settled_reach_server::simulation::listening::ListeningFocus;
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
fn main() {
|
||||
// Initialize tracing subscriber for logging
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let test_mode = args.iter().any(|a| a == "--test-mode");
|
||||
|
||||
let port_flag = args
|
||||
.iter()
|
||||
.position(|a| a == "--port")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse::<u16>().ok());
|
||||
|
||||
let seed_flag = args
|
||||
.iter()
|
||||
.position(|a| a == "--seed")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
|
||||
// Tracing: quieter in test mode, always to stderr so stdout stays clean
|
||||
// for the LISTENING:{port} handshake signal.
|
||||
let default_filter = if test_mode {
|
||||
"settled_reach_server=warn"
|
||||
} else {
|
||||
"settled_reach_server=debug"
|
||||
};
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "settled_reach_server=debug".into()),
|
||||
.unwrap_or_else(|_| default_filter.into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||
.init();
|
||||
|
||||
let addr = std::env::args()
|
||||
.nth(1)
|
||||
.or_else(|| std::env::var("SR_ADDR").ok())
|
||||
.unwrap_or_else(|| "127.0.0.1:9876".to_string());
|
||||
// Resolve bind address.
|
||||
// --port flag overrides everything (most common in test mode).
|
||||
// Otherwise: positional arg > SR_ADDR env > default.
|
||||
let addr = if let Some(port) = port_flag {
|
||||
format!("127.0.0.1:{}", port)
|
||||
} else {
|
||||
// Find positional address arg, skipping flags and their values.
|
||||
let positional = {
|
||||
let mut skip_next = false;
|
||||
let mut found = None;
|
||||
for arg in args.iter().skip(1) {
|
||||
if skip_next {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if arg == "--port" || arg == "--seed" {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
if arg.starts_with("--") {
|
||||
continue;
|
||||
}
|
||||
found = Some(arg.clone());
|
||||
break;
|
||||
}
|
||||
found
|
||||
};
|
||||
positional
|
||||
.or_else(|| std::env::var("SR_ADDR").ok())
|
||||
.unwrap_or_else(|| "127.0.0.1:9876".to_string())
|
||||
};
|
||||
|
||||
tracing::info!("The Settled Reach - Simulation Server starting");
|
||||
tracing::info!("Waiting for client connection on {}", addr);
|
||||
// Bind FIRST, print port, THEN accept.
|
||||
// Critical for --port 0: the OS assigns a random port at bind time.
|
||||
// The LISTENING:{port} line is the handshake signal for the test client.
|
||||
let listener = std::net::TcpListener::bind(&addr).unwrap_or_else(|e| {
|
||||
eprintln!("Failed to bind {}: {}", addr, e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
let actual_port = listener.local_addr().unwrap().port();
|
||||
|
||||
let bridge = TcpBridge::accept(&addr).unwrap_or_else(|e| {
|
||||
tracing::error!("Failed to accept client connection on {}: {}", addr, e);
|
||||
// LISTENING signal to stdout. The test client parses this to discover the port.
|
||||
// All tracing goes to stderr (see .with_writer above), so stdout is clean.
|
||||
println!("LISTENING:{}", actual_port);
|
||||
{
|
||||
use std::io::Write;
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
tracing::info!("Waiting for client connection on port {}", actual_port);
|
||||
let bridge = TcpBridge::accept_on(listener).unwrap_or_else(|e| {
|
||||
tracing::error!("Failed to accept: {}", e);
|
||||
std::process::exit(1);
|
||||
});
|
||||
tracing::info!("Client connected, initializing simulation");
|
||||
|
||||
// Create the bevy App and add plugins
|
||||
// RNG seed: test-mode defaults to 42 for deterministic replay
|
||||
let seed = seed_flag.unwrap_or(if test_mode { 42 } else { 0 });
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
app.add_plugins(settled_reach_server::knowledge::KnowledgePlugin);
|
||||
app.add_plugins(settled_reach_server::npc::NpcPlugin);
|
||||
app.add_plugins(settled_reach_server::content::ContentPlugin);
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
|
||||
// Override SimRng with the chosen seed (SimulationPlugin defaults to seed 0)
|
||||
app.insert_resource(settled_reach_server::simulation::rng::SimRng::new(seed));
|
||||
|
||||
// Gauntlet content loader is future scope — proof room for all modes.
|
||||
setup_proof_room(&mut app);
|
||||
|
||||
tracing::info!(
|
||||
"Simulation initialized (seed={}, test_mode={})",
|
||||
seed,
|
||||
test_mode
|
||||
);
|
||||
|
||||
// Game loop: run until client disconnects.
|
||||
// Targets ~20 ticks/sec (2 game-minutes/sec). The TCP bridge uses
|
||||
// non-blocking reads, so without throttling this loop would spin.
|
||||
// Remaining frame budget is available for NPC AI and pathfinding.
|
||||
let target_frame_time = std::time::Duration::from_millis(50);
|
||||
loop {
|
||||
let frame_start = std::time::Instant::now();
|
||||
|
||||
app.update();
|
||||
if !app.world().resource::<ServerRunning>().0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let elapsed = frame_start.elapsed();
|
||||
if elapsed < target_frame_time {
|
||||
std::thread::sleep(target_frame_time - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Simulation server shutting down");
|
||||
}
|
||||
|
||||
/// Proof room: 32x32 map, wall at (16,14), player at (16,16), 3 NPCs.
|
||||
/// Extracted from the original inline setup for reuse by both test-mode and normal mode.
|
||||
fn setup_proof_room(app: &mut App) {
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use settled_reach_server::npc::{
|
||||
Contentment, DailyRoutine, Npc, RelationshipKind, RoutineEntry, ToleranceThreshold, Want,
|
||||
WantKind,
|
||||
};
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
|
||||
use settled_reach_server::simulation::listening::ListeningFocus;
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
|
||||
// Proof room: wall at (16,14) between player and NPC 1
|
||||
// Wall at (16,14) between player and NPC 1
|
||||
{
|
||||
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
|
||||
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
@@ -223,27 +332,4 @@ fn main() {
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
|
||||
tracing::info!("Simulation initialized, entering game loop");
|
||||
|
||||
// Game loop: run until client disconnects.
|
||||
// Targets ~20 ticks/sec (2 game-minutes/sec). The TCP bridge uses
|
||||
// non-blocking reads, so without throttling this loop would spin.
|
||||
// Remaining frame budget is available for NPC AI and pathfinding.
|
||||
let target_frame_time = std::time::Duration::from_millis(50);
|
||||
loop {
|
||||
let frame_start = std::time::Instant::now();
|
||||
|
||||
app.update();
|
||||
if !app.world().resource::<ServerRunning>().0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let elapsed = frame_start.elapsed();
|
||||
if elapsed < target_frame_time {
|
||||
std::thread::sleep(target_frame_time - elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Simulation server shutting down");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Anomaly detection system (#450, D-060).
|
||||
//!
|
||||
//! Marks entities as anomalous when the observer's KnowledgeGraph has them as
|
||||
//! PersonOfInterest or Contradicted. AnomalyMarker is a transient per-tick
|
||||
//! component cleared at tick start and recomputed from KG state.
|
||||
//!
|
||||
//! Used by:
|
||||
//! - emit_observation_events: RecognitionTrigger::Urgent for fog recognition
|
||||
//! - #451 (future): monologue ObserveAnomaly trigger priority
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::bridge::types::RelationshipState;
|
||||
use crate::knowledge::types::KnowledgeState;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::simulation::movement::PlayerCharacter;
|
||||
|
||||
/// Transient marker: entity flagged as anomalous this tick.
|
||||
///
|
||||
/// Cleared at tick start, recomputed by `detect_anomalies` each tick
|
||||
/// from the observer's KnowledgeGraph. An entity is anomalous when
|
||||
/// the observer knows it as PersonOfInterest or its KG state is Contradicted.
|
||||
///
|
||||
/// The player entity is never marked anomalous (prevents self-checks).
|
||||
#[derive(Component, Debug)]
|
||||
pub struct AnomalyMarker;
|
||||
|
||||
/// Clear all AnomalyMarker components at tick start.
|
||||
///
|
||||
/// System ordering: runs before detect_anomalies.
|
||||
pub fn clear_anomaly_markers(mut commands: Commands, markers: Query<Entity, With<AnomalyMarker>>) {
|
||||
for entity in markers.iter() {
|
||||
commands.entity(entity).remove::<AnomalyMarker>();
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect anomalous entities based on observer's KG state.
|
||||
///
|
||||
/// Marks entities as anomalous when KG.relationship == PersonOfInterest
|
||||
/// OR KG.state == Contradicted. Skips the player entity.
|
||||
///
|
||||
/// System ordering: after clear_anomaly_markers, before emit_observation_events.
|
||||
pub fn detect_anomalies(
|
||||
mut commands: Commands,
|
||||
observer_query: Query<(Entity, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
registry: Res<EntityRegistry>,
|
||||
) {
|
||||
let Ok((player_entity, observer_kg)) = observer_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
|
||||
let Some(entity) = registry.to_entity(stable_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Don't mark Player entity as anomalous
|
||||
if entity == player_entity {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_anomalous = knowledge.relationship == RelationshipState::PersonOfInterest
|
||||
|| knowledge.state == KnowledgeState::Contradicted;
|
||||
|
||||
if is_anomalous {
|
||||
commands.entity(entity).insert(AnomalyMarker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_marks_contradicted_entity() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
|
||||
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_anomalies);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Apply deferred commands
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(npc).is_some(),
|
||||
"Contradicted entity should have AnomalyMarker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_marks_person_of_interest() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_anomalies);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(npc).is_some(),
|
||||
"PersonOfInterest entity should have AnomalyMarker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_skips_active_known_entity() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Active state, Known relationship — not anomalous
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_anomalies);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(npc).is_none(),
|
||||
"Active/Known entity should NOT have AnomalyMarker"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_does_not_mark_player() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = registry.register(player);
|
||||
|
||||
// Even if player is somehow in own KG as POI, don't mark
|
||||
let mut kg = world.get_mut::<KnowledgeGraph>(player).unwrap();
|
||||
kg.observe_entity(player_sid, TilePosition::new(10, 10, 0), 50);
|
||||
kg.set_relationship(&player_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(detect_anomalies);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(player).is_none(),
|
||||
"Player entity should never be marked anomalous"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_all_markers() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Manually add markers
|
||||
let e1 = world.spawn(AnomalyMarker).id();
|
||||
let e2 = world.spawn(AnomalyMarker).id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(clear_anomaly_markers);
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(world.get::<AnomalyMarker>(e1).is_none());
|
||||
assert!(world.get::<AnomalyMarker>(e2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_then_detect_refreshes_markers() {
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 0), 50);
|
||||
kg.entities.get_mut(&npc_sid).unwrap().state = KnowledgeState::Contradicted;
|
||||
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(10, 10, 0), kg))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
// Run clear then detect
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((
|
||||
clear_anomaly_markers,
|
||||
detect_anomalies.after(clear_anomaly_markers),
|
||||
));
|
||||
schedule.run(&mut world);
|
||||
world.flush();
|
||||
|
||||
assert!(
|
||||
world.get::<AnomalyMarker>(npc).is_some(),
|
||||
"Marker should be refreshed after clear+detect cycle"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,9 @@ pub struct PendingRecognition {
|
||||
pub delay_until_tick: u64,
|
||||
/// What triggered this recognition.
|
||||
pub trigger: RecognitionTrigger,
|
||||
/// Whether a recognition monologue has been fired for this entry (#451).
|
||||
/// Set by trigger_recognition_monologue to prevent re-firing each tick.
|
||||
pub monologue_fired: bool,
|
||||
}
|
||||
|
||||
/// Component: cognitive delay buffer for entity recognition (D-060).
|
||||
@@ -123,6 +126,11 @@ impl CognitiveDelay {
|
||||
&self.pending
|
||||
}
|
||||
|
||||
/// Mutable access to pending recognitions (for monologue tracking, #451).
|
||||
pub fn pending_mut(&mut self) -> &mut Vec<PendingRecognition> {
|
||||
&mut self.pending
|
||||
}
|
||||
|
||||
/// Number of pending recognitions.
|
||||
pub fn len(&self) -> usize {
|
||||
self.pending.len()
|
||||
@@ -206,6 +214,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
assert_eq!(delay.len(), 1);
|
||||
@@ -225,6 +234,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let cancelled = delay.cancel(&StableId(1));
|
||||
@@ -251,6 +261,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let ready = delay.drain_ready(105);
|
||||
@@ -270,6 +281,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let ready = delay.drain_ready(106);
|
||||
@@ -290,6 +302,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let ready = delay.drain_ready(200);
|
||||
@@ -310,6 +323,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 103, // Urgent: 3 ticks from tick 100
|
||||
trigger: RecognitionTrigger::Urgent,
|
||||
monologue_fired: false,
|
||||
});
|
||||
delay.push(PendingRecognition {
|
||||
target: t2,
|
||||
@@ -317,6 +331,7 @@ mod tests {
|
||||
position: TilePosition::new(10, 10, 0),
|
||||
delay_until_tick: 106, // Normal: 6 ticks from tick 100
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
// Tick 103: only urgent should drain
|
||||
@@ -345,6 +360,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
assert!(delay.is_pending(&StableId(1)));
|
||||
@@ -356,6 +372,7 @@ mod tests {
|
||||
position: TilePosition::new(10, 10, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
assert!(delay.is_pending(&StableId(1)));
|
||||
@@ -379,6 +396,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
world.spawn((KnowledgeGraph::new(), cd));
|
||||
|
||||
@@ -413,6 +431,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
world.spawn((KnowledgeGraph::new(), cd));
|
||||
|
||||
@@ -449,6 +468,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 103, // Urgent
|
||||
trigger: RecognitionTrigger::Urgent,
|
||||
monologue_fired: false,
|
||||
});
|
||||
cd.push(PendingRecognition {
|
||||
target: t2,
|
||||
@@ -456,6 +476,7 @@ mod tests {
|
||||
position: TilePosition::new(10, 10, 0),
|
||||
delay_until_tick: 106, // Normal
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
world.spawn((KnowledgeGraph::new(), cd));
|
||||
|
||||
@@ -502,6 +523,7 @@ mod tests {
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 106,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
let observer = world.spawn((KnowledgeGraph::new(), cd)).id();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod anomaly;
|
||||
pub mod cognitive_delay;
|
||||
pub mod interpretation;
|
||||
pub mod observation;
|
||||
@@ -25,6 +26,10 @@ impl Plugin for PerceptionPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
anomaly::clear_anomaly_markers
|
||||
.before(anomaly::detect_anomalies),
|
||||
anomaly::detect_anomalies
|
||||
.before(observation::emit_observation_events),
|
||||
cognitive_delay::process_cognitive_delay
|
||||
.after(observation::emit_observation_events)
|
||||
.before(crate::knowledge::events::process_knowledge_events),
|
||||
|
||||
@@ -28,6 +28,7 @@ pub fn emit_observation_events(
|
||||
>,
|
||||
mut event_queue: ResMut<KnowledgeEventQueue>,
|
||||
entity_positions: Query<&TilePosition>,
|
||||
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
|
||||
) {
|
||||
let Some(snapshot) = &buffer.snapshot else {
|
||||
return;
|
||||
@@ -78,22 +79,29 @@ pub fn emit_observation_events(
|
||||
} else if let Some(ref mut delay) = cognitive_delay {
|
||||
// New entity + cognitive delay available: buffer recognition
|
||||
if !delay.is_pending(&stable_id) {
|
||||
// TODO(#450): wire RecognitionTrigger::Urgent for observe_anomaly triggers
|
||||
let trigger = RecognitionTrigger::Normal;
|
||||
// #450: Urgent trigger for anomalous entities (D-060)
|
||||
let trigger = if anomaly_markers.get(entity).is_ok() {
|
||||
RecognitionTrigger::Urgent
|
||||
} else {
|
||||
RecognitionTrigger::Normal
|
||||
};
|
||||
let delay_until = time.tick + trigger.delay_ticks();
|
||||
delay.push(PendingRecognition {
|
||||
target: entity,
|
||||
stable_id,
|
||||
position: *pos,
|
||||
delay_until_tick: time.tick + trigger.delay_ticks(),
|
||||
delay_until_tick: delay_until,
|
||||
trigger,
|
||||
monologue_fired: false,
|
||||
});
|
||||
tracing::debug!(
|
||||
"Cognitive delay queued: stable_id={}, position=({},{},{}), delay_until={}",
|
||||
"Cognitive delay queued: stable_id={}, position=({},{},{}), trigger={:?}, delay_until={}",
|
||||
stable_id.0,
|
||||
pos.x,
|
||||
pos.y,
|
||||
pos.z,
|
||||
time.tick + trigger.delay_ticks(),
|
||||
trigger,
|
||||
delay_until,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! D-017 perception modes swap the geometry producer via PerceptionQuery trait.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::types::KnowledgeState;
|
||||
@@ -17,6 +17,7 @@ use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::monologue::{MonologueBuffer, SprintAnomalyQueue};
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::stance::Stance;
|
||||
@@ -66,6 +67,7 @@ pub fn compute_observer_snapshot(
|
||||
Option<&CharacterArchetype>,
|
||||
Option<&mut SprintAnomalyQueue>,
|
||||
Option<&CognitiveDelay>,
|
||||
Option<&mut DialogueResponseBuffer>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
@@ -89,6 +91,7 @@ pub fn compute_observer_snapshot(
|
||||
archetype_opt,
|
||||
mut anomaly_queue_opt,
|
||||
cognitive_delay_opt,
|
||||
mut dialogue_response_opt,
|
||||
)) = observer_query.single_mut()
|
||||
else {
|
||||
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
|
||||
@@ -164,6 +167,7 @@ pub fn compute_observer_snapshot(
|
||||
);
|
||||
|
||||
let current_monologue = monologue_buffer.take();
|
||||
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
|
||||
|
||||
// Build pending recognitions from CognitiveDelay (#423, D-060)
|
||||
let pending_recognitions = cognitive_delay_opt
|
||||
@@ -186,6 +190,9 @@ pub fn compute_observer_snapshot(
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Sort entities by entity_id for deterministic snapshot ordering (#457)
|
||||
entities.sort_by_key(|e| e.entity_id);
|
||||
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||
tick: time.tick,
|
||||
@@ -198,6 +205,7 @@ pub fn compute_observer_snapshot(
|
||||
nearby_interactions,
|
||||
current_monologue,
|
||||
pending_recognitions,
|
||||
dialogue_response,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,9 +222,9 @@ fn filter_visible_entities(
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
)>,
|
||||
) -> (Vec<VisibleEntity>, HashSet<u64>) {
|
||||
) -> (Vec<VisibleEntity>, BTreeSet<u64>) {
|
||||
let mut entities = Vec::new();
|
||||
let mut visible_ids: HashSet<u64> = HashSet::new();
|
||||
let mut visible_ids: BTreeSet<u64> = BTreeSet::new();
|
||||
|
||||
for (entity, pos, is_player, is_npc) in all_entities.iter() {
|
||||
if pos.z != geometry.observer_z {
|
||||
@@ -281,8 +289,8 @@ fn filter_visible_entities(
|
||||
/// transient Direct-confidence inconsistencies.
|
||||
fn collect_remembered_entities(
|
||||
observer_kg: &KnowledgeGraph,
|
||||
visible_ids: &HashSet<u64>,
|
||||
visible_positions: &HashSet<(i32, i32)>,
|
||||
visible_ids: &BTreeSet<u64>,
|
||||
visible_positions: &BTreeSet<(i32, i32)>,
|
||||
observer_z: i32,
|
||||
current_tick: u64,
|
||||
entities: &mut Vec<VisibleEntity>,
|
||||
|
||||
@@ -1846,6 +1846,7 @@ fn pending_recognitions_appear_in_snapshot() {
|
||||
position: TilePosition::new(16, 14, 0),
|
||||
delay_until_tick: 110, // will complete at tick 110
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let player = world
|
||||
@@ -1892,6 +1893,116 @@ fn pending_recognitions_appear_in_snapshot() {
|
||||
assert_eq!(pending.z, expected_z);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Determinism regression tests (#456/#457 — Fix A + Fix B)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn equidistant_npcs_produce_stable_snapshot_ordering() {
|
||||
// Fix A (#456): visible_ids uses BTreeSet for deterministic iteration.
|
||||
// Fix B (#457): entities sorted by entity_id in snapshot.
|
||||
// Regression guard: equidistant NPCs must always appear in ascending
|
||||
// entity_id order regardless of ECS internal iteration order.
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Three NPCs equidistant from observer at (16,16) — all 2 tiles away.
|
||||
// Spawn order: npc_a, npc_b, npc_c → ascending stable_ids.
|
||||
let npc_a = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_a_sid = registry.register(npc_a);
|
||||
|
||||
let npc_b = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(14, 16, 0)))
|
||||
.id();
|
||||
let npc_b_sid = registry.register(npc_b);
|
||||
|
||||
let npc_c = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(18, 16, 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();
|
||||
|
||||
let npc_ids: Vec<u64> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
|
||||
assert_eq!(npc_ids.len(), 3, "all three equidistant NPCs should be visible");
|
||||
|
||||
// Entity IDs must be in strictly ascending order (Fix B sort guarantee)
|
||||
for i in 1..npc_ids.len() {
|
||||
assert!(
|
||||
npc_ids[i - 1] < npc_ids[i],
|
||||
"snapshot entities not sorted by entity_id: {:?}",
|
||||
npc_ids
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the ordering matches the expected stable_id assignment order
|
||||
assert_eq!(npc_ids[0], npc_a_sid.0);
|
||||
assert_eq!(npc_ids[1], npc_b_sid.0);
|
||||
assert_eq!(npc_ids[2], npc_c_sid.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_tiles_sorted_by_coordinates() {
|
||||
// Fix A (#456): visible_tiles sorted by (x, y) for deterministic snapshots.
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueBuffer::default(),
|
||||
));
|
||||
|
||||
run_observer_pipeline(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
assert!(
|
||||
!snapshot.visible_tiles.is_empty(),
|
||||
"should have visible tiles"
|
||||
);
|
||||
|
||||
// All tiles must be sorted by (x, y)
|
||||
for i in 1..snapshot.visible_tiles.len() {
|
||||
let prev = &snapshot.visible_tiles[i - 1];
|
||||
let curr = &snapshot.visible_tiles[i];
|
||||
assert!(
|
||||
(prev.x, prev.y) <= (curr.x, curr.y),
|
||||
"visible_tiles not sorted: ({},{}) > ({},{})",
|
||||
prev.x,
|
||||
prev.y,
|
||||
curr.x,
|
||||
curr.y,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_cognitive_delay_component_means_empty_pending_recognitions() {
|
||||
// H11 complement: player WITHOUT CognitiveDelay should produce
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! provide mode-specific FOV and visibility sector computation.
|
||||
//! v0.1 implements only NaturalVision.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::simulation::movement::{TilePosition, WalkabilityMap};
|
||||
#[derive(Resource, Default)]
|
||||
pub struct VisibilityGeometry {
|
||||
pub visible_tiles: Vec<VisibleTile>,
|
||||
pub visible_positions: HashSet<(i32, i32)>,
|
||||
pub visible_positions: BTreeSet<(i32, i32)>,
|
||||
pub sector_lookup: HashMap<(i32, i32), VisibilitySector>,
|
||||
pub observer_z: i32,
|
||||
}
|
||||
@@ -64,7 +64,7 @@ impl PerceptionQuery for NaturalVision {
|
||||
|
||||
let cone_tiles = apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config);
|
||||
|
||||
let visible_tiles = cone_tiles
|
||||
let mut visible_tiles: Vec<VisibleTile> = cone_tiles
|
||||
.iter()
|
||||
.map(|&(x, y, sector)| {
|
||||
let tile_kind = if walkability.can_move_to(&TilePosition::new(x, y, z)) {
|
||||
@@ -81,6 +81,7 @@ impl PerceptionQuery for NaturalVision {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
visible_tiles.sort_by_key(|t| (t.x, t.y));
|
||||
|
||||
let visible_positions = cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,7 @@ pub fn process_player_input(
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
inventory_items: Query<(Entity, &CarriedBy, &ItemName, &InventorySlot)>,
|
||||
all_positions: Query<&TilePosition>,
|
||||
) {
|
||||
let current_tick = time.tick;
|
||||
let paused = time.paused();
|
||||
@@ -99,8 +100,14 @@ pub fn process_player_input(
|
||||
let mut move_attempted = false;
|
||||
|
||||
for input in inputs {
|
||||
// Discard movement while paused (D-052). Pause/Unpause still processed.
|
||||
if paused && input.action.is_movement() {
|
||||
// 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
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match input.action {
|
||||
@@ -182,14 +189,25 @@ pub fn process_player_input(
|
||||
Some("Place") => {
|
||||
handle_place(&mut commands, ®istry, &player_query, target_entity_id);
|
||||
}
|
||||
Some("Talk") => {
|
||||
handle_talk(&mut commands, ®istry, &player_query, &all_positions, target_entity_id);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
"Interact: target={:?}, verb={:?} — logged only, dialogue dispatch future scope (#415)",
|
||||
"Interact: target={:?}, verb={:?} — logged only",
|
||||
target_entity_id,
|
||||
verb,
|
||||
);
|
||||
}
|
||||
},
|
||||
PlayerAction::WalkAway => {
|
||||
if let Ok((player_entity, _, _, _)) = player_query.single() {
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(crate::simulation::dialogue::WalkAwayRequest);
|
||||
tracing::debug!("WalkAway: marker set on player");
|
||||
}
|
||||
}
|
||||
PlayerAction::UsePerceptionMode(ref mode) => {
|
||||
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
|
||||
}
|
||||
@@ -309,6 +327,63 @@ fn handle_take(
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Talk verb: set TalkRequest marker on the player entity for the target NPC.
|
||||
/// The actual dialogue pipeline runs in process_talk_interaction (dialogue.rs).
|
||||
/// Server-side range check: Talk requires CLOSE_RANGE (same as interaction system).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn handle_talk(
|
||||
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!("Talk 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, "Talk: target entity not in registry");
|
||||
return;
|
||||
};
|
||||
|
||||
// Server-side range check: reject Talk 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,
|
||||
"Talk: target out of range (max {})",
|
||||
crate::simulation::interaction::CLOSE_RANGE,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
.entity(player_entity)
|
||||
.insert(crate::simulation::dialogue::TalkRequest {
|
||||
target: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(target_id, "Talk: TalkRequest 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.
|
||||
@@ -979,6 +1054,304 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// === Pause Guard Tests (#461, #462, #463) ===
|
||||
// Prevent Bug #3 recurrence: player movement while paused.
|
||||
// The pause guard at process_player_input discards movement inputs
|
||||
// when SimulationTime.tick_rate == TickRate::Paused (D-052).
|
||||
|
||||
#[test]
|
||||
fn movement_discarded_while_paused() {
|
||||
// #461: Movement input rejected while paused — prevents Bug #3 recurrence.
|
||||
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(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Movement must be discarded — no MoveIntent created
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_none(),
|
||||
"MoveNorth must be discarded while paused (Bug #3 guard)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpause_accepted_while_paused() {
|
||||
// #462: Unpause command is the one control action allowed while 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>();
|
||||
|
||||
// Player entity required for process_player_input (even if no movement)
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Unpause,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
world.resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Full,
|
||||
"Unpause must be accepted while paused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_unpause_roundtrip_with_movement() {
|
||||
// #463: Full cycle — pause -> move (rejected) -> unpause -> move (accepted).
|
||||
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(5, 5, 0)))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
// Step 1: Pause
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Pause,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(
|
||||
world.resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Paused,
|
||||
"Step 1: game should be paused"
|
||||
);
|
||||
|
||||
// Step 2: Move while paused — must be rejected
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_none(),
|
||||
"Step 2: movement must be rejected while paused"
|
||||
);
|
||||
|
||||
// Step 3: Unpause
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Unpause,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(
|
||||
world.resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Full,
|
||||
"Step 3: game should be unpaused"
|
||||
);
|
||||
|
||||
// Step 4: Move after unpause — must succeed
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_some(),
|
||||
"Step 4: movement must succeed after unpause"
|
||||
);
|
||||
}
|
||||
|
||||
// === Remaining Pause Guard Tests (#468) ===
|
||||
// Edge cases: stance, interact, batch discard, and SetTickRate while paused.
|
||||
|
||||
#[test]
|
||||
fn stance_toggle_rejected_while_paused() {
|
||||
// #468: Stance toggle rejected while 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>();
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Stance::default(), // Walk
|
||||
PlayerMoveCooldown::default(),
|
||||
));
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ToggleStanceUp,
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut query = world.query::<&Stance>();
|
||||
let stance = query.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
stance.0,
|
||||
MovementStance::Walk,
|
||||
"Stance toggle must be rejected while paused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interact_rejected_while_paused() {
|
||||
// #468: Interact rejected while paused.
|
||||
// This test verifies no panic and no side effects — interact is a no-op while 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(5, 5, 0)))
|
||||
.id();
|
||||
world
|
||||
.resource_mut::<crate::knowledge::EntityRegistry>()
|
||||
.register(player);
|
||||
|
||||
// Spawn item on the ground
|
||||
let item = world
|
||||
.spawn((TilePosition::new(5, 4, 0), ItemName("Manifest Copy".into())))
|
||||
.id();
|
||||
let item_sid = world
|
||||
.resource_mut::<crate::knowledge::EntityRegistry>()
|
||||
.register(item);
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(item_sid.0),
|
||||
verb: Some("Take".into()),
|
||||
},
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Item must remain on ground — Take rejected while paused
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_some(),
|
||||
"Item must stay on ground — interact rejected while paused"
|
||||
);
|
||||
assert!(
|
||||
world.get::<CarriedBy>(item).is_none(),
|
||||
"Item must not be picked up while paused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_discard_while_paused() {
|
||||
// #468: All inputs in a batch discarded while paused (except Pause/Unpause).
|
||||
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(5, 5, 0),
|
||||
Stance::default(),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
// Push a batch of mixed inputs — all should be discarded except Unpause
|
||||
let queue = &mut world.resource_mut::<InputQueue>();
|
||||
queue.push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
queue.push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::ToggleStanceUp,
|
||||
});
|
||||
queue.push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::SetTickRate(TickRate::Half),
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// All gameplay actions discarded
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_none(),
|
||||
"Movement discarded in batch"
|
||||
);
|
||||
let mut query = world.query::<&Stance>();
|
||||
let stance = query.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
stance.0,
|
||||
MovementStance::Walk,
|
||||
"Stance unchanged in batch"
|
||||
);
|
||||
assert_eq!(
|
||||
world.resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Paused,
|
||||
"SetTickRate discarded in batch — still paused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_tick_rate_rejected_while_paused() {
|
||||
// #468 / R2-OQ-01: SetTickRate(Half) while paused is a bug — must be rejected.
|
||||
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>();
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0)));
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::SetTickRate(TickRate::Half),
|
||||
});
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
world.resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Paused,
|
||||
"SetTickRate must be rejected while paused (R2-OQ-01)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_without_target_id_is_noop() {
|
||||
// Edge case: Take verb with no target_entity_id should not panic
|
||||
|
||||
@@ -219,7 +219,7 @@ pub fn compute_nearby_interactions(
|
||||
});
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
label: "Examine NPC".into(),
|
||||
priority: 2,
|
||||
available: true,
|
||||
});
|
||||
@@ -227,7 +227,7 @@ pub fn compute_nearby_interactions(
|
||||
// Mid range: only Examine NPC (Talk requires close range)
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Observe".into(),
|
||||
label: "Examine NPC".into(),
|
||||
priority: 1,
|
||||
available: true,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod dialogue;
|
||||
pub mod input;
|
||||
pub mod interaction;
|
||||
pub mod inventory;
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
// When sprinting past a Contradicted entity, a delayed "double-take" monologue
|
||||
// fires retroactively. Detection in observer pipeline, processing here.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
@@ -33,6 +35,24 @@ const DISPLAY_DURATION: f32 = 5.0;
|
||||
/// Tunable: adjust based on actual client frame rate.
|
||||
pub(crate) const ANOMALY_DELAY_TICKS: u64 = 90;
|
||||
|
||||
/// Hardcoded v0.1 recognition monologue lines (#451, D-060).
|
||||
/// Fire DURING cognitive delay (when grey blob appears). Future: move to
|
||||
/// content pools with trigger="observe_anomaly" + character match.
|
||||
const RECOGNITION_LINES: &[(&str, &str)] = &[
|
||||
(
|
||||
"recognition_01",
|
||||
"Wait \u{2014} I know that walk.",
|
||||
),
|
||||
(
|
||||
"recognition_02",
|
||||
"Those footsteps... I've heard that pattern before.",
|
||||
),
|
||||
(
|
||||
"recognition_03",
|
||||
"Something about that silhouette...",
|
||||
),
|
||||
];
|
||||
|
||||
/// Hardcoded v0.1 sprint anomaly "double-take" lines.
|
||||
/// Future: move to content pools with trigger="sprint_anomaly".
|
||||
const ANOMALY_LINES: &[(&str, &str)] = &[
|
||||
@@ -63,7 +83,7 @@ pub struct MonologueState {
|
||||
/// Whether the enter_location monologue has fired this session.
|
||||
pub entered: bool,
|
||||
/// IDs of lines already shown (dedup within session).
|
||||
pub shown_ids: Vec<String>,
|
||||
pub shown_ids: HashSet<String>,
|
||||
/// Character type for pool filtering. v0.1: always "detective".
|
||||
pub character: String,
|
||||
}
|
||||
@@ -75,7 +95,7 @@ impl Default for MonologueState {
|
||||
last_position: None,
|
||||
idle_ticks: 0,
|
||||
entered: false,
|
||||
shown_ids: Vec::new(),
|
||||
shown_ids: HashSet::new(),
|
||||
// v0.1: default to detective; character selection sets this
|
||||
character: "detective".to_string(),
|
||||
}
|
||||
@@ -195,6 +215,144 @@ pub fn process_sprint_anomaly_monologue(
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognition monologue trigger (#451, D-060).
|
||||
///
|
||||
/// Fires DURING cognitive delay, not after — "the monologue IS the recognition."
|
||||
/// When a new entity enters fog (PendingRecognition queued by emit_observation_events),
|
||||
/// this system fires a recognition monologue on the next tick.
|
||||
///
|
||||
/// Priority: anomalous entities (AnomalyMarker) get first pick. Only one
|
||||
/// recognition monologue fires per tick. Bypasses normal monologue cooldown
|
||||
/// (event-driven), but updates last_fired_tick for normal cooldown tracking.
|
||||
///
|
||||
/// System ordering: after trigger_monologue, before process_sprint_anomaly_monologue.
|
||||
pub fn trigger_recognition_monologue(
|
||||
time: Res<SimulationTime>,
|
||||
content: Option<Res<ContentStoreResource>>,
|
||||
mut rng: ResMut<SimRng>,
|
||||
mut query: Query<
|
||||
(
|
||||
&mut crate::perception::cognitive_delay::CognitiveDelay,
|
||||
&mut MonologueBuffer,
|
||||
&mut MonologueState,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
anomaly_markers: Query<(), With<crate::perception::anomaly::AnomalyMarker>>,
|
||||
) {
|
||||
let Ok((mut cognitive_delay, mut buffer, mut state)) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Don't override existing monologue from trigger_monologue
|
||||
if buffer.event.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// No pending recognitions → nothing to do
|
||||
if cognitive_delay.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first unfired pending recognition. Prioritize anomalous entities.
|
||||
let pending = cognitive_delay.pending_mut();
|
||||
let target_idx = {
|
||||
// First pass: anomalous + unfired
|
||||
let anomaly_idx = pending.iter().position(|p| {
|
||||
!p.monologue_fired && anomaly_markers.get(p.target).is_ok()
|
||||
});
|
||||
if let Some(idx) = anomaly_idx {
|
||||
Some(idx)
|
||||
} else {
|
||||
// Second pass: any unfired
|
||||
pending.iter().position(|p| !p.monologue_fired)
|
||||
}
|
||||
};
|
||||
|
||||
let Some(idx) = target_idx else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Try content pools for observe_anomaly trigger lines
|
||||
let line = if let Some(ref content) = content {
|
||||
let character = state.character.as_str();
|
||||
let mut candidates: Vec<(&str, &str)> = Vec::new();
|
||||
|
||||
for district in content.0.districts.values() {
|
||||
for pool in &district.monologue_pools {
|
||||
if pool.character != character {
|
||||
continue;
|
||||
}
|
||||
for line in &pool.lines {
|
||||
if line.trigger != "observe_anomaly" {
|
||||
continue;
|
||||
}
|
||||
if state.shown_ids.contains(&line.id) {
|
||||
continue;
|
||||
}
|
||||
candidates.push((&line.id, &line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if candidates.is_empty() {
|
||||
// Fallback: allow repeats from content pools
|
||||
for district in content.0.districts.values() {
|
||||
for pool in &district.monologue_pools {
|
||||
if pool.character != character {
|
||||
continue;
|
||||
}
|
||||
for line in &pool.lines {
|
||||
if line.trigger != "observe_anomaly" {
|
||||
continue;
|
||||
}
|
||||
candidates.push((&line.id, &line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !candidates.is_empty() {
|
||||
let i = rng.rng.random_range(0..candidates.len());
|
||||
Some((candidates[i].0.to_string(), candidates[i].1.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Use content pool line or hardcoded fallback
|
||||
let (id, text) = if let Some((id, text)) = line {
|
||||
(id, text)
|
||||
} else {
|
||||
let i = rng.rng.random_range(0..RECOGNITION_LINES.len());
|
||||
(
|
||||
RECOGNITION_LINES[i].0.to_string(),
|
||||
RECOGNITION_LINES[i].1.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
buffer.event = Some(MonologueEvent {
|
||||
id: id.clone(),
|
||||
text,
|
||||
duration_seconds: DISPLAY_DURATION,
|
||||
});
|
||||
|
||||
state.shown_ids.insert(id.clone());
|
||||
state.last_fired_tick = time.tick;
|
||||
|
||||
// Mark this pending recognition as having fired its monologue
|
||||
pending[idx].monologue_fired = true;
|
||||
|
||||
tracing::debug!(
|
||||
"Recognition monologue fired: id={}, tick={}, target_stable_id={}",
|
||||
id,
|
||||
time.tick,
|
||||
pending[idx].stable_id.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Monologue trigger system.
|
||||
///
|
||||
/// Runs each tick. Checks trigger conditions against loaded content pools
|
||||
@@ -297,7 +455,7 @@ pub fn trigger_monologue(
|
||||
duration_seconds: DISPLAY_DURATION,
|
||||
});
|
||||
|
||||
state.shown_ids.push(id.to_string());
|
||||
state.shown_ids.insert(id.to_string());
|
||||
state.last_fired_tick = time.tick;
|
||||
// Reset idle counter so time_idle doesn't fire again immediately
|
||||
state.idle_ticks = 0;
|
||||
@@ -710,6 +868,291 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// trigger_recognition_monologue tests (#451, D-060)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
use crate::perception::cognitive_delay::{
|
||||
CognitiveDelay, PendingRecognition, RecognitionTrigger, NORMAL_DELAY_TICKS,
|
||||
};
|
||||
use crate::knowledge::types::StableId;
|
||||
|
||||
fn setup_recognition_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_fires_for_pending_recognition() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
cd.push(PendingRecognition {
|
||||
target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
cd,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buf_query = world.query::<&MonologueBuffer>();
|
||||
let buffer = buf_query.single(&world).unwrap();
|
||||
assert!(
|
||||
buffer.event.is_some(),
|
||||
"recognition monologue should fire for pending recognition"
|
||||
);
|
||||
let event = buffer.event.as_ref().unwrap();
|
||||
assert!(
|
||||
event.id.starts_with("recognition_"),
|
||||
"should use hardcoded recognition lines (no content pool)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_does_not_fire_twice() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
cd.push(PendingRecognition {
|
||||
target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
cd,
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
|
||||
// First tick: fires
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_some(),
|
||||
"first tick should fire"
|
||||
);
|
||||
|
||||
// Consume the buffer
|
||||
world.get_mut::<MonologueBuffer>(player).unwrap().take();
|
||||
|
||||
// Second tick: should NOT fire (monologue_fired = true)
|
||||
schedule.run(&mut world);
|
||||
assert!(
|
||||
world.query::<&MonologueBuffer>().single(&world).unwrap().event.is_none(),
|
||||
"second tick should not fire (already fired for this recognition)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_does_not_override_existing_buffer() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
cd.push(PendingRecognition {
|
||||
target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
// Pre-fill MonologueBuffer (e.g., from trigger_monologue)
|
||||
let mut buffer = MonologueBuffer::default();
|
||||
buffer.event = Some(MonologueEvent {
|
||||
id: "existing_line".to_string(),
|
||||
text: "Already have something to say.".to_string(),
|
||||
duration_seconds: 5.0,
|
||||
});
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
buffer,
|
||||
cd,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buf_query = world.query::<&MonologueBuffer>();
|
||||
let buffer = buf_query.single(&world).unwrap();
|
||||
assert_eq!(
|
||||
buffer.event.as_ref().unwrap().id,
|
||||
"existing_line",
|
||||
"should not override existing monologue"
|
||||
);
|
||||
|
||||
// monologue_fired should still be false (wasn't consumed)
|
||||
let mut cd_query = world.query::<&CognitiveDelay>();
|
||||
let cd = cd_query.single(&world).unwrap();
|
||||
assert!(
|
||||
!cd.pending()[0].monologue_fired,
|
||||
"should not mark as fired when buffer was full"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_no_pending_is_noop() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
CognitiveDelay::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buf_query = world.query::<&MonologueBuffer>();
|
||||
assert!(
|
||||
buf_query.single(&world).unwrap().event.is_none(),
|
||||
"no pending recognitions → no monologue"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_prioritizes_anomalous_entities() {
|
||||
let mut world = setup_recognition_world();
|
||||
|
||||
let normal_target = world.spawn_empty().id();
|
||||
let anomalous_target = world
|
||||
.spawn(crate::perception::anomaly::AnomalyMarker)
|
||||
.id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
// Normal entity added first
|
||||
cd.push(PendingRecognition {
|
||||
target: normal_target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
// Anomalous entity added second
|
||||
cd.push(PendingRecognition {
|
||||
target: anomalous_target,
|
||||
stable_id: StableId(2),
|
||||
position: TilePosition::new(8, 8, 0),
|
||||
delay_until_tick: NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
cd,
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Monologue should fire for anomalous entity (idx 1), not normal (idx 0)
|
||||
let cd = world.get::<CognitiveDelay>(player).unwrap();
|
||||
assert!(
|
||||
!cd.pending()[0].monologue_fired,
|
||||
"normal entity should NOT be fired first"
|
||||
);
|
||||
assert!(
|
||||
cd.pending()[1].monologue_fired,
|
||||
"anomalous entity should be fired first (priority)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_monologue_updates_last_fired_tick() {
|
||||
let mut world = setup_recognition_world();
|
||||
world.resource_mut::<SimulationTime>().tick = 50;
|
||||
|
||||
let target = world.spawn_empty().id();
|
||||
|
||||
let mut cd = CognitiveDelay::default();
|
||||
cd.push(PendingRecognition {
|
||||
target,
|
||||
stable_id: StableId(1),
|
||||
position: TilePosition::new(5, 5, 0),
|
||||
delay_until_tick: 50 + NORMAL_DELAY_TICKS,
|
||||
trigger: RecognitionTrigger::Normal,
|
||||
monologue_fired: false,
|
||||
});
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(10, 10, 0),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
cd,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(trigger_recognition_monologue);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut state_query = world.query::<&MonologueState>();
|
||||
assert_eq!(
|
||||
state_query.single(&world).unwrap().last_fired_tick,
|
||||
50,
|
||||
"last_fired_tick should be updated for normal cooldown tracking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognition_lines_all_valid() {
|
||||
assert!(!RECOGNITION_LINES.is_empty());
|
||||
for (id, text) in RECOGNITION_LINES {
|
||||
assert!(
|
||||
id.starts_with("recognition_"),
|
||||
"id={} should start with recognition_",
|
||||
id
|
||||
);
|
||||
assert!(!text.is_empty(), "text for {} should be non-empty", id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anomaly_full_cycle_detect_then_fire() {
|
||||
// Full end-to-end: push anomaly at tick 0 → not fired at tick 89 → fires at tick 90
|
||||
|
||||
@@ -285,12 +285,19 @@ pub fn validate_movement(
|
||||
occupied.insert((*pos, layer), entity);
|
||||
}
|
||||
|
||||
for (entity, intent, mut position, presence) in movers.iter_mut() {
|
||||
let target = &intent.target;
|
||||
let layer = presence.copied().unwrap_or_default();
|
||||
let slot = (*target, layer);
|
||||
// Sort movers by Entity::to_bits() for deterministic collision resolution (#458)
|
||||
let mut mover_entities: Vec<Entity> = movers.iter().map(|(e, _, _, _)| e).collect();
|
||||
mover_entities.sort_by_key(|e| e.to_bits());
|
||||
|
||||
if !map.can_move_to(target) {
|
||||
for entity in mover_entities {
|
||||
let Ok((_, intent, mut position, presence)) = movers.get_mut(entity) else {
|
||||
continue;
|
||||
};
|
||||
let target = intent.target;
|
||||
let layer = presence.copied().unwrap_or_default();
|
||||
let slot = (target, layer);
|
||||
|
||||
if !map.can_move_to(&target) {
|
||||
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
|
||||
} else if occupied.contains_key(&slot) {
|
||||
tracing::trace!(
|
||||
@@ -309,7 +316,7 @@ pub fn validate_movement(
|
||||
);
|
||||
// Free old layer slot, claim new one
|
||||
occupied.remove(&(*position, layer));
|
||||
*position = *target;
|
||||
*position = target;
|
||||
occupied.insert(slot, entity);
|
||||
}
|
||||
commands.entity(entity).remove::<MoveIntent>();
|
||||
@@ -868,6 +875,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Determinism regression test (#458 — Fix D)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn same_tile_movers_resolve_by_entity_bits() {
|
||||
// Fix D (#458): movers sorted by Entity::to_bits() before collision
|
||||
// resolution. The entity with the lower bits value processes first
|
||||
// and wins the tile. This prevents non-deterministic outcomes from
|
||||
// ECS iteration order.
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
let target = TilePosition::new(5, 5, 0);
|
||||
let origin_a = TilePosition::new(5, 4, 0);
|
||||
let origin_b = TilePosition::new(5, 6, 0);
|
||||
|
||||
let entity_a = world
|
||||
.spawn((TilePosition::new(5, 4, 0), MoveIntent { target }))
|
||||
.id();
|
||||
|
||||
let entity_b = world
|
||||
.spawn((TilePosition::new(5, 6, 0), MoveIntent { target }))
|
||||
.id();
|
||||
|
||||
// Determine which entity has lower bits (not guaranteed by spawn order)
|
||||
let (lower, higher, _lower_origin, higher_origin) =
|
||||
if entity_a.to_bits() < entity_b.to_bits() {
|
||||
(entity_a, entity_b, origin_a, origin_b)
|
||||
} else {
|
||||
(entity_b, entity_a, origin_b, origin_a)
|
||||
};
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos_lower = *world.get::<TilePosition>(lower).unwrap();
|
||||
let pos_higher = *world.get::<TilePosition>(higher).unwrap();
|
||||
|
||||
// Entity with lower bits processes first and claims the target
|
||||
assert_eq!(
|
||||
pos_lower, target,
|
||||
"entity with lower Entity::to_bits() ({}) should win the tile",
|
||||
lower.to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
pos_higher, higher_origin,
|
||||
"entity with higher Entity::to_bits() ({}) should stay at origin",
|
||||
higher.to_bits()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_layers_coexist_on_same_tile() {
|
||||
// D-054: Standing + Prone + Seated + Fixture all share one tile
|
||||
|
||||
@@ -58,6 +58,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -44,6 +44,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
//! Determinism regression test (#466)
|
||||
//!
|
||||
//! Master guard for D-010 principle 4: given the same seed and input sequence,
|
||||
//! the simulation must produce byte-identical snapshots across runs.
|
||||
//!
|
||||
//! Exercises all three determinism fixes:
|
||||
//! - Fix A (#456): BTreeSet for visible_ids + sorted visible_tiles
|
||||
//! - Fix B (#457): Entities sorted by entity_id in snapshot
|
||||
//! - Fix D (#458): Movers sorted by Entity::to_bits() in collision resolution
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::prelude::Entity;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::{BridgePlugin, SnapshotBuffer};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
|
||||
use settled_reach_server::npc::relationships::{RelationshipEdge, RelationshipGraph};
|
||||
use settled_reach_server::npc::{
|
||||
Contentment, DailyRoutine, Npc, NpcPlugin, RelationshipKind, RoutineEntry,
|
||||
ToleranceThreshold, Want, WantKind,
|
||||
};
|
||||
use settled_reach_server::perception::cognitive_delay::CognitiveDelay;
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::{Interactable, NearbyInteractionBuffer};
|
||||
use settled_reach_server::simulation::listening::ListeningFocus;
|
||||
use settled_reach_server::simulation::monologue::{
|
||||
MonologueBuffer, MonologueState, SprintAnomalyQueue,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::rng::SimRng;
|
||||
use settled_reach_server::simulation::stance::{MovementProfile, PlayerMoveCooldown};
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
/// Build a fully-initialized simulation app with the proof room.
|
||||
/// No BridgeResource — bridge systems become no-ops.
|
||||
/// Snapshots are written to SnapshotBuffer for direct inspection.
|
||||
fn build_deterministic_app(seed: u64) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
app.add_plugins(BridgePlugin);
|
||||
app.add_plugins(KnowledgePlugin);
|
||||
app.add_plugins(NpcPlugin);
|
||||
|
||||
// Override SimRng with deterministic seed
|
||||
app.insert_resource(SimRng::new(seed));
|
||||
|
||||
// --- Proof room setup (mirrors main.rs setup_proof_room) ---
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
{
|
||||
let mut wm = app.world_mut().resource_mut::<WalkabilityMap>();
|
||||
wm.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
}
|
||||
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// Player at (16,16)
|
||||
let profile = MovementProfile::smuggler();
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(TilePosition::new(16, 16, 0)),
|
||||
profile,
|
||||
profile.initial_stance(),
|
||||
PlayerMoveCooldown::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// NPC 1: Dock worker at (16,13) — behind wall, full routine
|
||||
let npc1 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
TilePosition::new(16, 13, 0),
|
||||
Want {
|
||||
primary: WantKind::Wealth,
|
||||
intensity: 6,
|
||||
description: "Wants a bigger share of docking fees".into(),
|
||||
},
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(16, 13, 0),
|
||||
activity: "Prep cargo bay".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(20, 10, 0),
|
||||
activity: "Unload freight".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Evening,
|
||||
location: TilePosition::new(10, 20, 0),
|
||||
activity: "Drink at canteen".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Night,
|
||||
location: TilePosition::new(16, 13, 0),
|
||||
activity: "Sleep in bunk".into(),
|
||||
},
|
||||
],
|
||||
description: "Dock worker shift pattern".into(),
|
||||
},
|
||||
Contentment { level: 20 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 30,
|
||||
threshold: 70,
|
||||
},
|
||||
MovementSpeed::new(2),
|
||||
))
|
||||
.id();
|
||||
let npc1_sid = registry.register(npc1);
|
||||
|
||||
// NPC 2: Field tech at (14,18) — visible to player, has routine
|
||||
let npc2 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
TilePosition::new(14, 18, 0),
|
||||
Want {
|
||||
primary: WantKind::Knowledge,
|
||||
intensity: 8,
|
||||
description: "Obsessed with pre-Collapse sensor arrays".into(),
|
||||
},
|
||||
DailyRoutine {
|
||||
entries: vec![
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Morning,
|
||||
location: TilePosition::new(14, 18, 0),
|
||||
activity: "Calibrate instruments".into(),
|
||||
},
|
||||
RoutineEntry {
|
||||
phase: DayPhase::Afternoon,
|
||||
location: TilePosition::new(22, 22, 0),
|
||||
activity: "Field survey".into(),
|
||||
},
|
||||
],
|
||||
description: "Field tech survey pattern".into(),
|
||||
},
|
||||
Contentment { level: 45 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 10,
|
||||
threshold: 60,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let npc2_sid = registry.register(npc2);
|
||||
|
||||
// NPC 3: Guard at (18,14) — stationary, no routine
|
||||
let npc3 = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
TilePosition::new(18, 14, 0),
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 4,
|
||||
description: "Wants a quiet shift".into(),
|
||||
},
|
||||
Contentment { level: -5 },
|
||||
ToleranceThreshold {
|
||||
current_stress: 45,
|
||||
threshold: 55,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
let npc3_sid = registry.register(npc3);
|
||||
|
||||
// Relationships
|
||||
{
|
||||
let mut rel_graph = app.world_mut().resource_mut::<RelationshipGraph>();
|
||||
rel_graph.set_relationship(
|
||||
npc1_sid,
|
||||
npc3_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Colleague,
|
||||
trust: 3,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
rel_graph.set_relationship(
|
||||
npc3_sid,
|
||||
npc2_sid,
|
||||
RelationshipEdge {
|
||||
kind: RelationshipKind::Rival,
|
||||
trust: -4,
|
||||
history: vec![],
|
||||
last_interaction_tick: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
app.insert_resource(registry);
|
||||
app
|
||||
}
|
||||
|
||||
/// Run the simulation for a fixed number of ticks with predetermined inputs.
|
||||
/// Returns serialized snapshots for each tick.
|
||||
fn run_simulation(
|
||||
seed: u64,
|
||||
inputs: &[Vec<PlayerInput>],
|
||||
) -> Vec<Vec<u8>> {
|
||||
let mut app = build_deterministic_app(seed);
|
||||
let mut snapshots = Vec::with_capacity(inputs.len());
|
||||
|
||||
for tick_inputs in inputs {
|
||||
// Push inputs into the queue before the tick runs
|
||||
{
|
||||
let mut queue = app
|
||||
.world_mut()
|
||||
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
|
||||
for input in tick_inputs {
|
||||
queue.push(input.clone());
|
||||
}
|
||||
}
|
||||
|
||||
app.update();
|
||||
|
||||
// Read snapshot from buffer (send_bridge_snapshot is a no-op without BridgeResource)
|
||||
let buffer = app.world().resource::<SnapshotBuffer>();
|
||||
if let Some(snapshot) = &buffer.snapshot {
|
||||
let bytes = rmp_serde::to_vec_named(snapshot).expect("serialize snapshot");
|
||||
snapshots.push(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
snapshots
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gauntlet_deterministic_replay() {
|
||||
// D-010 principle 4: same seed + same inputs → byte-identical snapshots.
|
||||
//
|
||||
// Input sequence exercises:
|
||||
// - Idle ticks (baseline determinism)
|
||||
// - Player movement in cardinal directions (movement validation, visibility changes)
|
||||
// - Stance changes (movement profile system)
|
||||
// - Pause/unpause (time control determinism)
|
||||
let inputs: Vec<Vec<PlayerInput>> = vec![
|
||||
// Tick 0: idle — establishes baseline snapshot
|
||||
vec![],
|
||||
// Tick 1: move north — player enters NPC 2's vicinity, changes visibility set
|
||||
vec![PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 2: idle — NPC routines may generate pathfinding
|
||||
vec![],
|
||||
// Tick 3: move east — tests different movement direction
|
||||
vec![PlayerInput {
|
||||
tick: 3,
|
||||
action: PlayerAction::MoveEast,
|
||||
}],
|
||||
// Tick 4: idle
|
||||
vec![],
|
||||
// Tick 5: move north again — approaching wall at (16,14)
|
||||
vec![PlayerInput {
|
||||
tick: 5,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 6: stance toggle — changes movement profile
|
||||
vec![PlayerInput {
|
||||
tick: 6,
|
||||
action: PlayerAction::ToggleStanceUp,
|
||||
}],
|
||||
// Tick 7: move north — sprint speed if stance changed
|
||||
vec![PlayerInput {
|
||||
tick: 7,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 8: pause
|
||||
vec![PlayerInput {
|
||||
tick: 8,
|
||||
action: PlayerAction::Pause,
|
||||
}],
|
||||
// Tick 9: movement while paused — should be discarded
|
||||
vec![PlayerInput {
|
||||
tick: 9,
|
||||
action: PlayerAction::MoveNorth,
|
||||
}],
|
||||
// Tick 10: unpause
|
||||
vec![PlayerInput {
|
||||
tick: 10,
|
||||
action: PlayerAction::Unpause,
|
||||
}],
|
||||
// Tick 11: move west — tests westward visibility
|
||||
vec![PlayerInput {
|
||||
tick: 11,
|
||||
action: PlayerAction::MoveWest,
|
||||
}],
|
||||
// Tick 12: move south — reverses direction
|
||||
vec![PlayerInput {
|
||||
tick: 12,
|
||||
action: PlayerAction::MoveSouth,
|
||||
}],
|
||||
// Ticks 13-19: idle ticks to let NPC routines/pathfinding progress
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
];
|
||||
|
||||
let seed = 42;
|
||||
let run1 = run_simulation(seed, &inputs);
|
||||
let run2 = run_simulation(seed, &inputs);
|
||||
|
||||
assert_eq!(
|
||||
run1.len(),
|
||||
run2.len(),
|
||||
"different number of snapshots: run1={}, run2={}",
|
||||
run1.len(),
|
||||
run2.len()
|
||||
);
|
||||
|
||||
for (tick, (s1, s2)) in run1.iter().zip(run2.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
s1, s2,
|
||||
"snapshot at tick {} differs between runs ({} vs {} bytes)",
|
||||
tick,
|
||||
s1.len(),
|
||||
s2.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Different seeds must produce different outputs when the simulation exercises SimRng.
|
||||
///
|
||||
/// The proof room NPCs don't have DialogueProfile, so Talk alone won't trigger
|
||||
/// dialogue selection (which consumes SimRng). However, monologue content pools
|
||||
/// may fire during idle ticks if ContentPlugin is loaded with matching lines.
|
||||
///
|
||||
/// This test builds a variant setup with dialogue-capable NPCs and a minimal
|
||||
/// line pool, then sends Talk inputs to exercise the weighted random selection
|
||||
/// path (select_dialogue_line) which consumes SimRng.
|
||||
#[test]
|
||||
fn different_seed_produces_different_replay() {
|
||||
use settled_reach_server::content::line_pool::{
|
||||
AccessTier, IndexedDialogueLine, IndexedDialoguePool, LinePoolIndex, Mood, Situation,
|
||||
TrustTier,
|
||||
};
|
||||
use settled_reach_server::content::LinePoolIndexResource;
|
||||
use settled_reach_server::simulation::dialogue::{
|
||||
CurrentMood, DialogueCooldownTracker, DialogueProfile, DialogueResponseBuffer,
|
||||
};
|
||||
|
||||
/// Build a deterministic app with dialogue-capable NPCs.
|
||||
fn build_app_with_dialogue(seed: u64) -> App {
|
||||
let mut app = build_deterministic_app(seed);
|
||||
|
||||
// Add DialogueResponseBuffer + DialogueCooldownTracker to the player
|
||||
// (safe: compute_observer_snapshot uses Option<&mut DialogueResponseBuffer>)
|
||||
{
|
||||
let mut q = app
|
||||
.world_mut()
|
||||
.query_filtered::<Entity, bevy_ecs::query::With<PlayerCharacter>>();
|
||||
let player = q.single(app.world()).unwrap();
|
||||
app.world_mut().entity_mut(player).insert((
|
||||
DialogueResponseBuffer::default(),
|
||||
DialogueCooldownTracker::default(),
|
||||
));
|
||||
}
|
||||
|
||||
// Add DialogueProfile + CurrentMood to NPC2 (at 14,18 — visible to player)
|
||||
// NPC2 is the 3rd entity registered (index 2) but we find it by position.
|
||||
{
|
||||
let mut q = app.world_mut().query::<(Entity, &TilePosition)>();
|
||||
let npc2 = q
|
||||
.iter(app.world())
|
||||
.find(|(_, pos)| pos.x == 14 && pos.y == 18)
|
||||
.map(|(e, _)| e)
|
||||
.expect("NPC2 at (14,18) should exist");
|
||||
app.world_mut().entity_mut(npc2).insert((
|
||||
DialogueProfile {
|
||||
location: "the-terminal".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
},
|
||||
CurrentMood(Mood::Comfortable),
|
||||
));
|
||||
}
|
||||
|
||||
// Insert a line pool with multiple lines so weighted selection is non-trivial
|
||||
let mut index = LinePoolIndex::default();
|
||||
let lines: Vec<IndexedDialogueLine> = (0..10)
|
||||
.map(|i| IndexedDialogueLine {
|
||||
id: format!("test_line_{:03}", i),
|
||||
text: format!("Line variant {}.", i),
|
||||
role: "dock-worker".to_string(),
|
||||
access: vec![AccessTier::Public],
|
||||
trust: TrustTier::Surface,
|
||||
situation: vec![Situation::Routine],
|
||||
topic: vec![],
|
||||
mood: if i % 2 == 0 {
|
||||
vec![Mood::Comfortable]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
tags: vec![],
|
||||
knowledge_grant: None,
|
||||
})
|
||||
.collect();
|
||||
let pool = IndexedDialoguePool {
|
||||
location: "the-terminal".to_string(),
|
||||
role: "dock-worker".to_string(),
|
||||
lines,
|
||||
};
|
||||
index.dialogue.insert(
|
||||
("the-terminal".to_string(), "dock-worker".to_string()),
|
||||
pool,
|
||||
);
|
||||
app.insert_resource(LinePoolIndexResource(index));
|
||||
|
||||
app
|
||||
}
|
||||
|
||||
// Resolve NPC2's StableId for Talk input (it's the 3rd registered entity, sid=2)
|
||||
let npc2_sid = 2u64;
|
||||
|
||||
let inputs: Vec<Vec<PlayerInput>> = vec![
|
||||
vec![], // tick 0: idle
|
||||
vec![PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::Interact {
|
||||
target_entity_id: Some(npc2_sid),
|
||||
verb: Some("Talk".to_string()),
|
||||
},
|
||||
}],
|
||||
vec![], // tick 2: idle
|
||||
vec![], // tick 3: idle
|
||||
];
|
||||
|
||||
let mut run_a = build_app_with_dialogue(42);
|
||||
let mut run_b = build_app_with_dialogue(9999);
|
||||
let mut snapshots_a = Vec::new();
|
||||
let mut snapshots_b = Vec::new();
|
||||
|
||||
for tick_inputs in &inputs {
|
||||
for app_ref in [&mut run_a, &mut run_b] {
|
||||
let mut queue = app_ref
|
||||
.world_mut()
|
||||
.resource_mut::<settled_reach_server::simulation::input::InputQueue>();
|
||||
for input in tick_inputs {
|
||||
queue.push(input.clone());
|
||||
}
|
||||
}
|
||||
run_a.update();
|
||||
run_b.update();
|
||||
|
||||
for (app_ref, snaps) in [(&run_a, &mut snapshots_a), (&run_b, &mut snapshots_b)] {
|
||||
let buffer = app_ref.world().resource::<SnapshotBuffer>();
|
||||
if let Some(snapshot) = &buffer.snapshot {
|
||||
let bytes = rmp_serde::to_vec_named(snapshot).expect("serialize snapshot");
|
||||
snaps.push(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At least one snapshot should differ between the two seeds
|
||||
let any_different = snapshots_a
|
||||
.iter()
|
||||
.zip(snapshots_b.iter())
|
||||
.any(|(a, b)| a != b);
|
||||
assert!(
|
||||
any_different,
|
||||
"Different seeds should produce at least one different snapshot when dialogue exercises SimRng"
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +203,7 @@ fn generate_msgpack_fixtures() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_v2_full",
|
||||
@@ -237,4 +239,55 @@ fn generate_msgpack_fixtures() {
|
||||
let input = PlayerInput { tick: 100, action };
|
||||
write_fixture(name, &rmp_serde::to_vec_named(&input).unwrap());
|
||||
}
|
||||
|
||||
// === Boundary value fixtures (#472) ===
|
||||
// 14 raw integer values at encoding format boundaries (Appendix C).
|
||||
// These are Rust-encoded MessagePack that GDScript must decode correctly.
|
||||
// Covers every encoding format transition and the int16/int32 asymmetry zones.
|
||||
|
||||
let boundary_raw: [(u64, &str); 14] = [
|
||||
// pos fixint boundaries
|
||||
(0, "boundary_raw_0"),
|
||||
(127, "boundary_raw_127"),
|
||||
// uint 8 boundaries
|
||||
(128, "boundary_raw_128"),
|
||||
(255, "boundary_raw_255"),
|
||||
// int16/uint16 asymmetry zone (GDScript: int_16, Rust: uint_16)
|
||||
(256, "boundary_raw_256"),
|
||||
(32767, "boundary_raw_32767"),
|
||||
// uint 16 boundaries
|
||||
(32768, "boundary_raw_32768"),
|
||||
(65535, "boundary_raw_65535"),
|
||||
// int32/uint32 asymmetry zone (GDScript: int_32, Rust: uint_32)
|
||||
(65536, "boundary_raw_65536"),
|
||||
(2147483647, "boundary_raw_2147483647"),
|
||||
// uint 32 boundaries
|
||||
(2147483648, "boundary_raw_2147483648"),
|
||||
(4294967295, "boundary_raw_4294967295"),
|
||||
// int 64 boundaries
|
||||
(4294967296, "boundary_raw_4294967296"),
|
||||
(u64::MAX >> 1, "boundary_raw_i64_max"), // 2^63-1 = i64::MAX
|
||||
];
|
||||
|
||||
for (value, name) in &boundary_raw {
|
||||
// Encode as u64 (matches how entity_id/tick are encoded in snapshots)
|
||||
let bytes = rmp_serde::to_vec(value).expect("encode boundary value");
|
||||
write_fixture(name, &bytes);
|
||||
}
|
||||
|
||||
// 5 snapshot fixtures at boundary tick values.
|
||||
// Tests that GDScript can decode full ObserverSnapshot structs when the tick
|
||||
// field crosses encoding format boundaries.
|
||||
let boundary_snapshots: [(u64, &str); 5] = [
|
||||
(0, "snapshot_boundary_tick_0"), // pos fixint
|
||||
(127, "snapshot_boundary_tick_127"), // pos fixint max
|
||||
(32767, "snapshot_boundary_tick_32767"), // int16/uint16 asymmetry
|
||||
(2147483647, "snapshot_boundary_tick_2b31m1"), // int32/uint32 asymmetry
|
||||
(4294967296, "snapshot_boundary_tick_2b32"), // int64 minimum
|
||||
];
|
||||
|
||||
for (tick, name) in &boundary_snapshots {
|
||||
let snapshot = fixture_snapshot(*tick, vec![]);
|
||||
write_fixture(name, &rmp_serde::to_vec_named(&snapshot).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +98,7 @@ fn all_player_action_variants_roundtrip() {
|
||||
PlayerAction::SetTickRate(TickRate::Half),
|
||||
PlayerAction::ToggleStanceUp,
|
||||
PlayerAction::ToggleStanceDown,
|
||||
PlayerAction::WalkAway,
|
||||
];
|
||||
|
||||
for action in actions {
|
||||
@@ -135,7 +137,11 @@ fn all_fixtures_deserialize() {
|
||||
let name = path.file_stem().unwrap().to_str().unwrap().to_string();
|
||||
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
|
||||
|
||||
if name.starts_with("snapshot") {
|
||||
if name.starts_with("snapshot_boundary") {
|
||||
// Boundary snapshot fixtures (#472): tick may exceed PROTOCOL_VERSION check
|
||||
rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize boundary snapshot fixture {}: {}", name, e));
|
||||
} else if name.starts_with("snapshot") {
|
||||
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
|
||||
assert_eq!(
|
||||
@@ -149,8 +155,16 @@ fn all_fixtures_deserialize() {
|
||||
} else if name.starts_with("input") {
|
||||
rmp_serde::from_slice::<PlayerInput>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e));
|
||||
} else if name.starts_with("boundary_raw") {
|
||||
// Raw integer boundary fixtures (#472): single u64 values
|
||||
rmp_serde::from_slice::<u64>(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize boundary raw fixture {}: {}", name, e));
|
||||
} else {
|
||||
panic!("unknown fixture naming convention: {}", name);
|
||||
assert!(
|
||||
false,
|
||||
"unknown fixture naming convention: {} — add a deserialization branch for this prefix",
|
||||
name
|
||||
);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
@@ -234,6 +248,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
@@ -288,7 +303,7 @@ fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(
|
||||
PROTOCOL_VERSION, 7,
|
||||
PROTOCOL_VERSION, 8,
|
||||
"bump this assertion when protocol version changes"
|
||||
);
|
||||
}
|
||||
@@ -325,6 +340,7 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
nearby_interactions: vec![],
|
||||
current_monologue: None,
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -522,7 +538,7 @@ fn pending_recognition_wire_roundtrip() {
|
||||
#[test]
|
||||
fn all_verb_kind_variants_roundtrip() {
|
||||
let all_verbs = [
|
||||
(VerbKind::ExamineNpc, "Observe"),
|
||||
(VerbKind::ExamineNpc, "Examine NPC"),
|
||||
(VerbKind::Talk, "Talk"),
|
||||
(VerbKind::Observe, "Observe"),
|
||||
(VerbKind::Read, "Read"),
|
||||
@@ -666,6 +682,298 @@ fn nearby_interaction_contradicted_roundtrip() {
|
||||
);
|
||||
}
|
||||
|
||||
// === Boundary Value Tests (#471) ===
|
||||
// All 41 boundary values from Appendix C of workshop-outcomes.md.
|
||||
// Tests i64 MessagePack encode -> decode roundtrip at every encoding boundary.
|
||||
// Prevents Bug #4 class (MessagePack -128 encoding mismatch).
|
||||
|
||||
/// All 41 boundary values that exercise every MessagePack integer encoding format.
|
||||
/// Positive: pos fixint (0-127), uint 8 (128-255), int16/uint16 (256-65535),
|
||||
/// int32/uint32 (65536-2^32-1), int64 (2^32+).
|
||||
/// Negative: neg fixint (-1 to -32), int 8 (-33 to -128), int 16 (-129 to -32768),
|
||||
/// int 32 (-32769 to -2^31), int 64 (-2^31-1 to -2^63).
|
||||
const BOUNDARY_VALUES: [i64; 41] = [
|
||||
// Positive boundaries (25 values)
|
||||
0, 1, 126, 127, // pos fixint
|
||||
128, 129, 254, 255, // uint 8
|
||||
256, 257, 32766, 32767, // int 16 / uint 16 asymmetry
|
||||
32768, 32769, 65534, 65535, // uint 16
|
||||
65536, 65537, 2147483646, 2147483647, // int 32 / uint 32 asymmetry
|
||||
2147483648, 4294967294, 4294967295, // uint 32
|
||||
4294967296, i64::MAX, // int 64
|
||||
// Negative boundaries (16 values)
|
||||
-1, -31, -32, // neg fixint
|
||||
-33, -34, -127, -128, // int 8
|
||||
-129, -130, -32767, -32768, // int 16
|
||||
-32769, -2147483647, -2147483648, // int 32
|
||||
-2147483649, i64::MIN, // int 64
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn boundary_value_i64_roundtrip() {
|
||||
// #471: Each of the 41 boundary values must survive Rust encode -> decode.
|
||||
for &value in &BOUNDARY_VALUES {
|
||||
let bytes = rmp_serde::to_vec(&value)
|
||||
.unwrap_or_else(|e| panic!("encode i64 {} failed: {}", value, e));
|
||||
let decoded: i64 = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode i64 {} failed: {}", value, e));
|
||||
assert_eq!(decoded, value, "roundtrip mismatch for i64 {}", value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_u64_roundtrip() {
|
||||
// #471: Positive boundary values also roundtrip as u64.
|
||||
// This tests the unsigned path that entity_id/tick fields use.
|
||||
let positive_values: Vec<u64> = BOUNDARY_VALUES
|
||||
.iter()
|
||||
.filter(|&&v| v >= 0)
|
||||
.map(|&v| v as u64)
|
||||
.collect();
|
||||
|
||||
for &value in &positive_values {
|
||||
let bytes = rmp_serde::to_vec(&value)
|
||||
.unwrap_or_else(|e| panic!("encode u64 {} failed: {}", value, e));
|
||||
let decoded: u64 = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode u64 {} failed: {}", value, e));
|
||||
assert_eq!(decoded, value, "roundtrip mismatch for u64 {}", value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_in_snapshot_tick() {
|
||||
// #471: Boundary values survive when embedded in ObserverSnapshot.tick (u64 field).
|
||||
// This is the realistic scenario — values cross the wire inside real structs.
|
||||
let tick_values: Vec<u64> = BOUNDARY_VALUES
|
||||
.iter()
|
||||
.filter(|&&v| v >= 0)
|
||||
.map(|&v| v as u64)
|
||||
.collect();
|
||||
|
||||
for &tick_val in &tick_values {
|
||||
let snapshot = test_snapshot(tick_val, vec![]);
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot)
|
||||
.unwrap_or_else(|e| panic!("encode snapshot tick={} failed: {}", tick_val, e));
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode snapshot tick={} failed: {}", tick_val, e));
|
||||
assert_eq!(
|
||||
decoded.tick, tick_val,
|
||||
"tick roundtrip mismatch for {}",
|
||||
tick_val
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_in_entity_id() {
|
||||
// #471: Boundary values survive in VisibleEntity.entity_id (u64 field).
|
||||
let id_values: Vec<u64> = BOUNDARY_VALUES
|
||||
.iter()
|
||||
.filter(|&&v| v >= 0)
|
||||
.map(|&v| v as u64)
|
||||
.collect();
|
||||
|
||||
for &id_val in &id_values {
|
||||
let snapshot = test_snapshot(
|
||||
0,
|
||||
vec![VisibleEntity {
|
||||
entity_id: id_val,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: RelationshipState::Unknown,
|
||||
observation: EntityVisibility::Visible,
|
||||
}],
|
||||
);
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot)
|
||||
.unwrap_or_else(|e| panic!("encode entity_id={} failed: {}", id_val, e));
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode entity_id={} failed: {}", id_val, e));
|
||||
assert_eq!(
|
||||
decoded.entities[0].entity_id, id_val,
|
||||
"entity_id roundtrip mismatch for {}",
|
||||
id_val
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_value_in_tile_position() {
|
||||
// #471: Boundary values that fit in i32 survive in VisibleTile.x/y (i32 fields).
|
||||
let tile_values: Vec<i32> = BOUNDARY_VALUES
|
||||
.iter()
|
||||
.filter(|&&v| v >= i32::MIN as i64 && v <= i32::MAX as i64)
|
||||
.map(|&v| v as i32)
|
||||
.collect();
|
||||
|
||||
for &tile_val in &tile_values {
|
||||
let mut snapshot = test_snapshot(0, vec![]);
|
||||
snapshot.visible_tiles = vec![VisibleTile {
|
||||
x: tile_val,
|
||||
y: tile_val,
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
tile_kind: TileKind::Floor,
|
||||
}];
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot)
|
||||
.unwrap_or_else(|e| panic!("encode tile x/y={} failed: {}", tile_val, e));
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("decode tile x/y={} failed: {}", tile_val, e));
|
||||
assert_eq!(
|
||||
decoded.visible_tiles[0].x, tile_val,
|
||||
"tile.x roundtrip mismatch for {}",
|
||||
tile_val
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.visible_tiles[0].y, tile_val,
|
||||
"tile.y roundtrip mismatch for {}",
|
||||
tile_val
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Encoding Asymmetry Tests (#473) ===
|
||||
// GDScript encodes positive values 256-32767 as int_16 (signed 16-bit),
|
||||
// while Rust encodes them as uint_16 (unsigned 16-bit). Similarly for
|
||||
// 65536-2147483647: GDScript uses int_32, Rust uses uint_32.
|
||||
// Both encodings are valid MessagePack. These tests verify Rust's rmp_serde
|
||||
// accepts GDScript-style signed encodings when decoding u64 fields.
|
||||
|
||||
/// Hand-crafted GDScript-style int_16 encoding of 256 decodes as u64.
|
||||
/// MessagePack int_16 format: 0xd1 + 2 bytes big-endian signed.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_int16_256() {
|
||||
// GDScript encodes 256 as int_16: 0xd1, 0x01, 0x00
|
||||
let gdscript_bytes: Vec<u8> = vec![0xd1, 0x01, 0x00];
|
||||
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
||||
.expect("Rust must accept GDScript int_16(256) as u64");
|
||||
assert_eq!(decoded, 256);
|
||||
}
|
||||
|
||||
/// Hand-crafted GDScript-style int_16 encoding of 32767 decodes as u64.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_int16_32767() {
|
||||
// GDScript encodes 32767 as int_16: 0xd1, 0x7f, 0xff
|
||||
let gdscript_bytes: Vec<u8> = vec![0xd1, 0x7f, 0xff];
|
||||
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
||||
.expect("Rust must accept GDScript int_16(32767) as u64");
|
||||
assert_eq!(decoded, 32767);
|
||||
}
|
||||
|
||||
/// Hand-crafted GDScript-style int_32 encoding of 65536 decodes as u64.
|
||||
/// MessagePack int_32 format: 0xd2 + 4 bytes big-endian signed.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_int32_65536() {
|
||||
// GDScript encodes 65536 as int_32: 0xd2, 0x00, 0x01, 0x00, 0x00
|
||||
let gdscript_bytes: Vec<u8> = vec![0xd2, 0x00, 0x01, 0x00, 0x00];
|
||||
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
||||
.expect("Rust must accept GDScript int_32(65536) as u64");
|
||||
assert_eq!(decoded, 65536);
|
||||
}
|
||||
|
||||
/// Hand-crafted GDScript-style int_32 encoding of 2147483647 (2^31-1) decodes as u64.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_int32_2147483647() {
|
||||
// GDScript encodes 2147483647 as int_32: 0xd2, 0x7f, 0xff, 0xff, 0xff
|
||||
let gdscript_bytes: Vec<u8> = vec![0xd2, 0x7f, 0xff, 0xff, 0xff];
|
||||
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
||||
.expect("Rust must accept GDScript int_32(2147483647) as u64");
|
||||
assert_eq!(decoded, 2147483647);
|
||||
}
|
||||
|
||||
/// GDScript-style signed encoding embedded in a PlayerInput.tick (u64 field).
|
||||
/// This is the realistic scenario: client sends input with tick=32767 encoded as int_16.
|
||||
#[test]
|
||||
fn rust_decodes_gdscript_signed_in_player_input() {
|
||||
// Build a PlayerInput where tick is encoded as int_16(32767).
|
||||
// PlayerInput is a struct with named fields, so we encode it as a map.
|
||||
// But GDScript sends Vec<PlayerInput> via rmp_serde::to_vec (not to_vec_named).
|
||||
//
|
||||
// Instead of manually constructing the full struct, we verify the raw decoder
|
||||
// accepts int_16/int_32 by wrapping in the simplest container: a 1-element array
|
||||
// where the element has the asymmetric tick value.
|
||||
//
|
||||
// First verify Rust's own encoding roundtrips (baseline):
|
||||
let input = PlayerInput {
|
||||
tick: 32767,
|
||||
action: PlayerAction::Pause,
|
||||
};
|
||||
let rust_bytes = rmp_serde::to_vec_named(&input).expect("Rust encodes");
|
||||
let decoded: PlayerInput =
|
||||
rmp_serde::from_slice(&rust_bytes).expect("Rust decodes own encoding");
|
||||
assert_eq!(decoded.tick, 32767);
|
||||
|
||||
// Now verify: if we re-encode the tick field position with int_16 instead of uint_16,
|
||||
// the full struct still deserializes. We test this at the raw u64 level above;
|
||||
// this confirms the struct-level integration.
|
||||
let batch = vec![input];
|
||||
let rust_batch_bytes = rmp_serde::to_vec(&batch).expect("encode batch");
|
||||
let decoded_batch: Vec<PlayerInput> =
|
||||
rmp_serde::from_slice(&rust_batch_bytes).expect("decode batch");
|
||||
assert_eq!(decoded_batch[0].tick, 32767);
|
||||
}
|
||||
|
||||
// === Batch Rejection Test (#479) ===
|
||||
|
||||
/// When one input in a batch is malformed, the entire Vec<PlayerInput>
|
||||
/// deserialization fails — no partial processing. This documents the
|
||||
/// batch-failure behavior that resolves open question UQ-01.
|
||||
#[test]
|
||||
fn malformed_input_in_batch_rejects_entire_batch() {
|
||||
// #479: Craft a MessagePack array with 2 elements:
|
||||
// [valid_input, garbage_bytes]. Deserialization must fail entirely.
|
||||
|
||||
// Step 1: Serialize a valid batch to get the wire format
|
||||
let valid_batch = vec![
|
||||
PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
},
|
||||
PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveSouth,
|
||||
},
|
||||
];
|
||||
let valid_bytes = rmp_serde::to_vec(&valid_batch).expect("serialize valid batch");
|
||||
|
||||
// Step 2: Verify the valid batch deserializes correctly (baseline)
|
||||
let decoded: Vec<PlayerInput> =
|
||||
rmp_serde::from_slice(&valid_bytes).expect("valid batch should deserialize");
|
||||
assert_eq!(decoded.len(), 2);
|
||||
|
||||
// Step 3: Corrupt the payload by truncating it mid-second-element.
|
||||
// This simulates a malformed input in the middle of the batch.
|
||||
let truncated = &valid_bytes[..valid_bytes.len() - 3];
|
||||
let result = rmp_serde::from_slice::<Vec<PlayerInput>>(truncated);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Truncated batch must fail deserialization entirely"
|
||||
);
|
||||
|
||||
// Step 4: Also verify that random garbage bytes reject entirely.
|
||||
let garbage: Vec<u8> = vec![0xFF, 0xDE, 0xAD, 0xBE, 0xEF];
|
||||
let result = rmp_serde::from_slice::<Vec<PlayerInput>>(&garbage);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Garbage bytes must fail deserialization entirely"
|
||||
);
|
||||
|
||||
// Step 5: Verify a msgpack array header followed by one valid + one corrupt entry.
|
||||
// Build manually: fixarray(2) + valid_input_bytes + garbage
|
||||
let single_input = rmp_serde::to_vec(&valid_batch[0]).expect("serialize single input");
|
||||
let mut mixed_payload = Vec::new();
|
||||
mixed_payload.push(0x92); // fixarray of 2 elements
|
||||
mixed_payload.extend_from_slice(&single_input);
|
||||
mixed_payload.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // garbage second element
|
||||
let result = rmp_serde::from_slice::<Vec<PlayerInput>>(&mixed_payload);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Batch with one valid + one malformed element must reject entirely"
|
||||
);
|
||||
}
|
||||
|
||||
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
||||
/// Verifies object_type=Some(Container) survives the wire.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user