Merge remote-tracking branch 'origin/server'
This commit is contained in:
+22
-1
@@ -159,6 +159,27 @@ How narrative, NPCs, and world content are created: content tiers, NPC generatio
|
||||
- **Raised by:** Inigo (insert-tech/organic split), Paula (cognitive architecture framing and trust model connection)
|
||||
- **Dissent:** None
|
||||
|
||||
### D-075: Dialogue filtering — layered confidence gate on trust tier (OQ-18 resolution)
|
||||
- **Date:** 2026-02-19
|
||||
- **Decision:** KnowledgeConfidence ([D-041](architecture.md#d-041-knowledge-graph-data-model)) acts as a co-gate on TrustTier ([D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers) Layer 3), not on AccessTier (Layer 1). Access tier and trust tier remain two separate filtering dimensions with different drivers:
|
||||
1. **AccessTier (Layer 1):** Gated by `RelationshipState` only. Social position determines what *categories* of dialogue are available. No change from current implementation. Character archetype effects are emergent — the detective's investigation naturally creates PersonOfInterest relationships (Authority access), the smuggler's social arc naturally creates Known/Friendly relationships (Peer/Insider access). No archetype tag on the pipeline.
|
||||
2. **TrustTier (Layer 3):** Gated by both `RelationshipState` AND `KnowledgeConfidence`:
|
||||
- **Surface:** any relationship + any confidence — baseline, always available.
|
||||
- **Real:** (Friendly or Known) + KnowsOf+ — requires both rapport and substantive knowledge.
|
||||
- **Secret:** Friendly + KnowsDetails+ — requires both deep rapport and actionable knowledge.
|
||||
3. **KnowledgeConfidence does NOT gate AccessTier.** Access is a social/positional concept ("who are you to me?"), not an information concept ("what do you know about me?"). A stranger can have Authority access (detective flashes badge) with zero knowledge. An insider can have Peer access before they know anything specific about the target.
|
||||
- **Key design choice — no archetype dimension.** The dialogue pipeline does not add a character-archetype tag (detective/smuggler) as a filtering axis. Instead, archetype effects on dialogue emerge from: (a) different starting RelationshipStates driven by gameplay (detective institutions → Authority access, smuggler social network → Insider access), (b) different knowledge accumulation rates (detective's analytical lattice gains KnowsOf faster → Real trust earlier), (c) D-028 Layer 1 access tags on lines already encode "this line is for authority figures" vs "this line is for insiders." This is architecturally consistent with D-010 principle 3 (no baking player identity into the game loop).
|
||||
- **Rationale:** Three reasons for layered-but-not-archetype:
|
||||
1. *Separation of concerns.* Access (social position) and trust (relationship depth x knowledge depth) answer different questions. Collapsing them into one axis would require rewriting D-028's four-layer model and D-035's tag taxonomy — both confirmed and implemented.
|
||||
2. *Minimal code change.* The only implementation change is adding a `KnowledgeConfidence` parameter to `relationship_to_trust()` in `server/src/simulation/dialogue.rs`. The caller already has access to the observer's KnowledgeGraph. No new components, no new tags, no content format changes.
|
||||
3. *Emergent archetype distinction.* Hardcoding archetype tags creates a maintenance burden (new character = new tag = new content variant) and reduces the "two keyholes on the same world" experience. When the detective and smuggler experience different dialogue from the same NPC, it should be because they have different *relationships* and *knowledge*, not because a tag excluded them.
|
||||
- **Implementation change to #305:** `relationship_to_trust()` gains a `confidence` parameter. Mapping: `(Friendly, KnowsDetails+) → Secret`, `(Friendly|Known, KnowsOf+) → Real`, `(_ , _) → Surface`. Caller in `process_talk_interaction` passes `observer_kg.confidence_of(&target_sid)` to the updated function.
|
||||
- **Resolves:** OQ-18
|
||||
- **Amends:** [D-041](architecture.md#d-041-knowledge-graph-data-model) (confirms confidence-to-trust mapping; supersedes the preliminary 1:1 sketch in D-041 "Key design choices" bullet 3 with the layered model above), [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers) (Layer 3 trust now requires both relationship AND confidence)
|
||||
- **Cross-reference:** [D-028](#d-028-dialogue-architecture--tagged-line-pools-with-four-relational-layers), [D-035](#d-035-converged-tag-taxonomy-for-dialogue-and-monologue-line-pools), [D-041](architecture.md#d-041-knowledge-graph-data-model), [D-062](#d-062-invisible-locked-dialogue-options) (confidence progression naturally unlocks new trust tiers, creating the "new options appearing" reward)
|
||||
- **Raised by:** Tyre (technical analysis, architecture synthesis)
|
||||
- **Dissent:** Pending review. Sprint briefing flags Gestalt and Nigel for archetype dimension input.
|
||||
|
||||
---
|
||||
|
||||
*15 decisions. Last updated: 2026-02-16*
|
||||
*16 decisions. Last updated: 2026-02-19*
|
||||
|
||||
Generated
+1
-1
@@ -978,7 +978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "settled-reach-server"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
dependencies = [
|
||||
"bevy_app",
|
||||
"bevy_ecs",
|
||||
|
||||
@@ -161,8 +161,18 @@ pub fn format_snapshot_text(snapshot: &ObserverSnapshot) -> String {
|
||||
|
||||
// Blocked entities (debug, #514)
|
||||
if !snapshot.blocked_entities.is_empty() {
|
||||
let ids: Vec<String> = snapshot.blocked_entities.iter().map(|id| id.to_string()).collect();
|
||||
writeln!(out, "Blocked (LOS): {} [{}]", snapshot.blocked_entities.len(), ids.join(", ")).ok();
|
||||
let ids: Vec<String> = snapshot
|
||||
.blocked_entities
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect();
|
||||
writeln!(
|
||||
out,
|
||||
"Blocked (LOS): {} [{}]",
|
||||
snapshot.blocked_entities.len(),
|
||||
ids.join(", ")
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
writeln!(out, "===").ok();
|
||||
@@ -288,6 +298,7 @@ mod tests {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +421,7 @@ mod tests {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
let text = format_snapshot_text(&snap);
|
||||
assert!(text.contains("Tick 0"));
|
||||
|
||||
@@ -69,6 +69,11 @@ pub struct ObserverSnapshot {
|
||||
/// Client shows speaker name + dialogue text in a dialogue box.
|
||||
#[serde(default)]
|
||||
pub dialogue_response: Option<DialogueResponseEvent>,
|
||||
/// Scan events from NPCs with ScanAuthority this tick (#425, D-065).
|
||||
/// Present when an NPC scanned the player's inventory. Client renders
|
||||
/// scan indicator on the scanning NPC. Empty when no scans occurred.
|
||||
#[serde(default)]
|
||||
pub scan_events: Vec<crate::simulation::contraband::ScanEvent>,
|
||||
/// Debug field: entity IDs on the same z-level that are not visible due to
|
||||
/// LOS obstruction or being outside the vision cone (#514).
|
||||
/// Sorted ascending for deterministic output. Client can safely ignore.
|
||||
|
||||
@@ -77,7 +77,8 @@ impl EntityRegistry {
|
||||
assert!(
|
||||
target >= self.next_id,
|
||||
"cannot reserve backwards: next_id={}, target={}",
|
||||
self.next_id, target
|
||||
self.next_id,
|
||||
target
|
||||
);
|
||||
self.next_id = target;
|
||||
}
|
||||
|
||||
@@ -140,8 +140,8 @@ impl RelationshipState {
|
||||
Self::Friendly => Self::Known,
|
||||
Self::Known => Self::PersonOfInterest,
|
||||
Self::PersonOfInterest => Self::Hostile,
|
||||
Self::Unknown => Self::Unknown, // no-op: can't confront a stranger
|
||||
Self::Hostile => Self::Hostile, // floor: already worst state
|
||||
Self::Unknown => Self::Unknown, // no-op: can't confront a stranger
|
||||
Self::Hostile => Self::Hostile, // floor: already worst state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::knowledge::{EntityRegistry, KnowledgeGraph, StableId};
|
||||
use crate::perception::cognitive_delay::CognitiveDelay;
|
||||
use crate::perception::query::{ActivePerceptionMode, VisibilityGeometry};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use crate::simulation::contraband::ScanEventBuffer;
|
||||
use crate::simulation::dialogue::DialogueResponseBuffer;
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
@@ -68,6 +69,7 @@ pub fn compute_observer_snapshot(
|
||||
Option<&mut SprintAnomalyQueue>,
|
||||
Option<&CognitiveDelay>,
|
||||
Option<&mut DialogueResponseBuffer>,
|
||||
Option<&mut ScanEventBuffer>,
|
||||
),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
@@ -92,6 +94,7 @@ pub fn compute_observer_snapshot(
|
||||
mut anomaly_queue_opt,
|
||||
cognitive_delay_opt,
|
||||
mut dialogue_response_opt,
|
||||
mut scan_event_buffer_opt,
|
||||
)) = observer_query.single_mut()
|
||||
else {
|
||||
tracing::error!("compute_observer_snapshot: PlayerCharacter query failed");
|
||||
@@ -168,6 +171,10 @@ pub fn compute_observer_snapshot(
|
||||
|
||||
let current_monologue = monologue_buffer.take();
|
||||
let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take());
|
||||
let scan_events = scan_event_buffer_opt
|
||||
.as_mut()
|
||||
.map(|buf| buf.take())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build pending recognitions from CognitiveDelay (#423, D-060)
|
||||
let pending_recognitions = cognitive_delay_opt
|
||||
@@ -207,6 +214,7 @@ pub fn compute_observer_snapshot(
|
||||
pending_recognitions,
|
||||
dialogue_response,
|
||||
blocked_entities,
|
||||
scan_events,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2106,10 +2106,7 @@ fn npc_behind_wall_appears_in_blocked_entities() {
|
||||
"NPC behind wall should appear in blocked_entities"
|
||||
);
|
||||
// Not in visible entities
|
||||
let npc_visible = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.any(|e| e.entity_id == npc_sid.0);
|
||||
let npc_visible = snapshot.entities.iter().any(|e| e.entity_id == npc_sid.0);
|
||||
assert!(!npc_visible, "NPC should not be in visible entities");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
// Contraband detection system — NPC scan checks carried items + KG (#425, D-065)
|
||||
//
|
||||
// NPCs with ScanAuthority check the player's inventory for Contraband items
|
||||
// when within interaction range. On detection, the NPC's KnowledgeGraph is
|
||||
// updated with HasContraband fact at KnowsDetails confidence (DirectObservation
|
||||
// source). A ScanEvent is emitted to the player's ScanEventBuffer for
|
||||
// client-side rendering via ObserverSnapshot.
|
||||
//
|
||||
// System ordering: after perception phase, before snapshot phase.
|
||||
// Uses StableId references throughout — no raw bevy Entity handles in KG entries.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::knowledge::types::{
|
||||
FactId, FactKnowledge, KnowledgeConfidence, KnowledgeSource, KnowledgeState,
|
||||
};
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::inventory::CarriedBy;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// Scan range for contraband detection (Manhattan distance, same z-level).
|
||||
/// Matches CLOSE_RANGE from interaction system — NPC must be adjacent.
|
||||
pub const SCAN_RANGE: u32 = 2;
|
||||
|
||||
/// Marker component on item entities that are contraband (D-065).
|
||||
/// Unlicensed lattice components, medical-grade replacements, Severance tech.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Contraband;
|
||||
|
||||
/// Component on NPC entities with scan permissions.
|
||||
/// Only NPCs with this component perform contraband checks.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct ScanAuthority;
|
||||
|
||||
/// Wire-format scan event for ObserverSnapshot inclusion.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ScanEvent {
|
||||
/// StableId of the NPC that performed the scan.
|
||||
pub scanner_entity_id: u64,
|
||||
/// Whether contraband was detected.
|
||||
pub detected_contraband: bool,
|
||||
}
|
||||
|
||||
/// Per-player buffer holding scan events for snapshot inclusion.
|
||||
/// Cleared each tick by the snapshot builder via `take()`.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct ScanEventBuffer {
|
||||
events: Vec<ScanEvent>,
|
||||
}
|
||||
|
||||
impl ScanEventBuffer {
|
||||
/// Push a scan event.
|
||||
pub fn push(&mut self, event: ScanEvent) {
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
/// Drain and return events, leaving the buffer empty.
|
||||
pub fn take(&mut self) -> Vec<ScanEvent> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for contraband in the player's inventory when scanned by NPC.
|
||||
///
|
||||
/// For each NPC with ScanAuthority within SCAN_RANGE of the player:
|
||||
/// 1. Query player's carried items for Contraband marker
|
||||
/// 2. If found and NPC doesn't already know: update NPC's KnowledgeGraph
|
||||
/// with HasContraband fact (KnowsDetails, DirectObservation source)
|
||||
/// 3. Emit ScanEvent to the player's ScanEventBuffer (always, regardless of
|
||||
/// detection result or prior knowledge — client renders the scan animation)
|
||||
///
|
||||
/// Registered in SimulationPlugin (not NpcPlugin) because it operates on
|
||||
/// player inventory and writes to the snapshot pipeline. Consistent with
|
||||
/// process_talk_interaction and other cross-entity systems.
|
||||
///
|
||||
/// System ordering: after validate_movement, before compute_observer_snapshot.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn check_contraband_scan(
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut npc_query: Query<(Entity, &TilePosition, &mut KnowledgeGraph), (With<Npc>, With<ScanAuthority>)>,
|
||||
mut player_query: Query<(Entity, &TilePosition, &mut ScanEventBuffer), With<PlayerCharacter>>,
|
||||
items_query: Query<(&CarriedBy, Option<&Contraband>)>,
|
||||
) {
|
||||
let Ok((player_entity, player_pos, mut scan_buffer)) = player_query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
let player_pos = *player_pos;
|
||||
|
||||
let Some(player_sid) = registry.to_stable(player_entity) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Check if player carries any contraband
|
||||
let has_contraband = items_query
|
||||
.iter()
|
||||
.any(|(carried_by, contraband_opt)| carried_by.0 == player_sid && contraband_opt.is_some());
|
||||
|
||||
for (npc_entity, npc_pos, mut npc_kg) in npc_query.iter_mut() {
|
||||
// Range check: same z-level + within scan range
|
||||
let Some(distance) = npc_pos.manhattan_distance(&player_pos) else {
|
||||
continue; // Different z-level
|
||||
};
|
||||
if distance > SCAN_RANGE {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(npc_sid) = registry.to_stable(npc_entity) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Build the fact ID for this specific player
|
||||
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
|
||||
|
||||
if has_contraband {
|
||||
// Only update KG on first detection (idempotent — don't overwrite existing fact)
|
||||
if !npc_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails) {
|
||||
npc_kg.facts.insert(
|
||||
fact_id,
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsDetails,
|
||||
source: KnowledgeSource::DirectObservation { tick: time.tick },
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: time.tick,
|
||||
},
|
||||
);
|
||||
|
||||
// Also ensure the NPC has entity knowledge of the player
|
||||
npc_kg.observe_entity(player_sid, player_pos, time.tick);
|
||||
|
||||
tracing::info!(
|
||||
npc_id = npc_sid.0,
|
||||
player_id = player_sid.0,
|
||||
tick = time.tick,
|
||||
"Contraband detected: NPC scanned player and found contraband"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit scan event regardless of detection or prior knowledge
|
||||
// (client renders the scan animation itself)
|
||||
scan_buffer.push(ScanEvent {
|
||||
scanner_entity_id: npc_sid.0,
|
||||
detected_contraband: has_contraband,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::graph::KnowledgeGraph;
|
||||
use crate::knowledge::registry::EntityRegistry;
|
||||
use crate::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use crate::simulation::rng::SimRng;
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SimulationTime>();
|
||||
world.insert_resource(SimRng::new(42));
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_detects_contraband_item() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Spawn player
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanEventBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Spawn contraband item carried by player
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Unlicensed Lattice Module".into()),
|
||||
InventorySlot(0),
|
||||
Contraband,
|
||||
));
|
||||
|
||||
// Spawn NPC with ScanAuthority adjacent to player
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanAuthority,
|
||||
))
|
||||
.id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_contraband_scan);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// NPC's KG should now contain HasContraband fact
|
||||
let npc_kg = world.get::<KnowledgeGraph>(npc).unwrap();
|
||||
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
|
||||
assert!(
|
||||
npc_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails),
|
||||
"NPC should know about player's contraband"
|
||||
);
|
||||
|
||||
// NPC should also have entity knowledge of the player
|
||||
assert!(
|
||||
npc_kg.knows_entity(&player_sid),
|
||||
"NPC should have entity knowledge of the player after scan"
|
||||
);
|
||||
|
||||
let _ = npc_sid; // used indirectly
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_emits_event_to_buffer() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanEventBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Contraband item
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Lattice Component".into()),
|
||||
InventorySlot(0),
|
||||
Contraband,
|
||||
));
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanAuthority,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_contraband_scan);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
|
||||
let events = buffer.take();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(events[0].detected_contraband);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_contraband_no_kg_update() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanEventBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Non-contraband item
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Comm Log".into()),
|
||||
InventorySlot(0),
|
||||
));
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanAuthority,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_contraband_scan);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// NPC's KG should NOT have HasContraband fact
|
||||
let npc_kg = world.get::<KnowledgeGraph>(npc).unwrap();
|
||||
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
|
||||
assert!(
|
||||
!npc_kg.knows_fact(&fact_id),
|
||||
"NPC should not know about contraband when player has none"
|
||||
);
|
||||
|
||||
// But scan event should still fire (NPC still scanned)
|
||||
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
|
||||
let events = buffer.take();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(!events[0].detected_contraband);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_no_scan() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanEventBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Lattice Component".into()),
|
||||
InventorySlot(0),
|
||||
Contraband,
|
||||
));
|
||||
|
||||
// NPC far away (distance 5, beyond SCAN_RANGE=2)
|
||||
world.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 10, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanAuthority,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_contraband_scan);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
|
||||
let events = buffer.take();
|
||||
assert!(events.is_empty(), "out-of-range NPC should not scan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_z_level_no_scan() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanEventBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Lattice Component".into()),
|
||||
InventorySlot(0),
|
||||
Contraband,
|
||||
));
|
||||
|
||||
// NPC on different z-level
|
||||
world.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 6, 1),
|
||||
KnowledgeGraph::new(),
|
||||
ScanAuthority,
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_contraband_scan);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
|
||||
let events = buffer.take();
|
||||
assert!(events.is_empty(), "different z-level should prevent scan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_without_scan_authority_does_not_scan() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanEventBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Lattice Component".into()),
|
||||
InventorySlot(0),
|
||||
Contraband,
|
||||
));
|
||||
|
||||
// NPC without ScanAuthority
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0), KnowledgeGraph::new()));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_contraband_scan);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
|
||||
let events = buffer.take();
|
||||
assert!(events.is_empty(), "NPC without ScanAuthority should not scan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_scan_skipped_when_already_known() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanEventBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Lattice Component".into()),
|
||||
InventorySlot(0),
|
||||
Contraband,
|
||||
));
|
||||
|
||||
// Pre-populate NPC's KG with contraband knowledge
|
||||
let mut npc_kg = KnowledgeGraph::new();
|
||||
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
|
||||
npc_kg.facts.insert(
|
||||
fact_id.clone(),
|
||||
FactKnowledge {
|
||||
confidence: KnowledgeConfidence::KnowsDetails,
|
||||
source: KnowledgeSource::DirectObservation { tick: 0 },
|
||||
state: KnowledgeState::Active,
|
||||
acquired_tick: 0,
|
||||
},
|
||||
);
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 6, 0),
|
||||
npc_kg,
|
||||
ScanAuthority,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_contraband_scan);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// NPC already knew — KG should not be re-written (fact tick stays 0)
|
||||
let npc_kg = world.get::<KnowledgeGraph>(npc).unwrap();
|
||||
let fact = npc_kg.facts.get(&fact_id).unwrap();
|
||||
assert_eq!(fact.acquired_tick, 0, "should not overwrite existing knowledge");
|
||||
|
||||
// Scan event should still fire even though NPC already knew
|
||||
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
|
||||
let events = buffer.take();
|
||||
assert_eq!(events.len(), 1, "scan event should emit even for already-known contraband");
|
||||
assert!(events[0].detected_contraband);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_scan_authority_npcs_each_emit_event() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanEventBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
world.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Lattice Component".into()),
|
||||
InventorySlot(0),
|
||||
Contraband,
|
||||
));
|
||||
|
||||
// Two NPCs with ScanAuthority, both in range
|
||||
let npc1 = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 6, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanAuthority,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc1);
|
||||
|
||||
let npc2 = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(6, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
ScanAuthority,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc2);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(check_contraband_scan);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Both NPCs should have KG entries
|
||||
let fact_id = FactId(format!("contraband.detected_{}", player_sid.0));
|
||||
let npc1_kg = world.get::<KnowledgeGraph>(npc1).unwrap();
|
||||
assert!(npc1_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails));
|
||||
let npc2_kg = world.get::<KnowledgeGraph>(npc2).unwrap();
|
||||
assert!(npc2_kg.fact_at_least(&fact_id, KnowledgeConfidence::KnowsDetails));
|
||||
|
||||
// Both should emit separate scan events
|
||||
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
|
||||
let events = buffer.take();
|
||||
assert_eq!(events.len(), 2, "each ScanAuthority NPC should emit a scan event");
|
||||
assert!(events.iter().all(|e| e.detected_contraband));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_event_buffer_take_drains() {
|
||||
let mut buffer = ScanEventBuffer::default();
|
||||
buffer.push(ScanEvent {
|
||||
scanner_entity_id: 1,
|
||||
detected_contraband: true,
|
||||
});
|
||||
buffer.push(ScanEvent {
|
||||
scanner_entity_id: 2,
|
||||
detected_contraband: false,
|
||||
});
|
||||
|
||||
let events = buffer.take();
|
||||
assert_eq!(events.len(), 2);
|
||||
|
||||
let events2 = buffer.take();
|
||||
assert!(events2.is_empty(), "take should drain the buffer");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
//! - Writes DialogueResponseBuffer for snapshot inclusion
|
||||
//! - Uses SimRng for deterministic weighted random selection
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
|
||||
@@ -171,18 +173,40 @@ pub fn available_access_tiers(relationship: RelationshipState) -> Vec<AccessTier
|
||||
}
|
||||
}
|
||||
|
||||
/// Map RelationshipState to the player's effective TrustTier.
|
||||
/// Map RelationshipState + KnowledgeConfidence to the player's effective TrustTier.
|
||||
///
|
||||
/// v0.1 mapping:
|
||||
/// - Friendly → Real (relationship depth unlocks deeper trust)
|
||||
/// - All others → Surface
|
||||
/// D-075 layered gate: trust requires BOTH relationship depth AND knowledge depth.
|
||||
/// - Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge)
|
||||
/// - Real: (Friendly or Known) + KnowsOf+ (rapport + substantive knowledge)
|
||||
/// - Surface: everything else (baseline, always available)
|
||||
/// Map relationship + knowledge confidence to trust tier (D-075).
|
||||
///
|
||||
/// TODO: TrustTier::Secret is currently unreachable. It should gate on
|
||||
/// KG confidence (e.g., KnowsDetails+ for a specific secret topic) rather
|
||||
/// than RelationshipState alone. Tracked for Phase 2 narrative expansion.
|
||||
pub fn relationship_to_trust(relationship: RelationshipState) -> TrustTier {
|
||||
/// Trust tier gates which dialogue lines are available. The layered gate
|
||||
/// requires BOTH sufficient relationship AND sufficient KG confidence:
|
||||
/// Surface: any relationship, any confidence (baseline)
|
||||
/// Real: (Friendly|Known) + KnowsOf+ (rapport + substantive knowledge)
|
||||
/// Secret: Friendly + KnowsDetails+ (deep rapport + actionable knowledge)
|
||||
///
|
||||
/// KnowledgeConfidence ordering is load-bearing here — the >= comparison
|
||||
/// relies on the derive(PartialOrd) order: Suspects < KnowsOf < KnowsDetails < Direct.
|
||||
///
|
||||
/// Unknown NPCs (no KG entry) default to Suspects, yielding Surface tier.
|
||||
/// This is correct: you can't have deep dialogue with someone you know nothing about.
|
||||
pub fn relationship_to_trust(
|
||||
relationship: RelationshipState,
|
||||
confidence: crate::knowledge::types::KnowledgeConfidence,
|
||||
) -> TrustTier {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
|
||||
match relationship {
|
||||
RelationshipState::Friendly => TrustTier::Real,
|
||||
RelationshipState::Friendly if confidence >= KnowledgeConfidence::KnowsDetails => {
|
||||
TrustTier::Secret
|
||||
}
|
||||
RelationshipState::Friendly | RelationshipState::Known
|
||||
if confidence >= KnowledgeConfidence::KnowsOf =>
|
||||
{
|
||||
TrustTier::Real
|
||||
}
|
||||
_ => TrustTier::Surface,
|
||||
}
|
||||
}
|
||||
@@ -373,12 +397,17 @@ pub fn process_talk_interaction(
|
||||
// Layer 2: Derive active situations from game state
|
||||
let situations = derive_situations(time.day_phase(), relationship);
|
||||
|
||||
// Layer 3: Trust tier from relationship
|
||||
let trust = relationship_to_trust(relationship);
|
||||
// Layer 3: Trust tier from relationship + confidence (D-075)
|
||||
// Default to Suspects for unknown NPCs — no KG entry means no basis for
|
||||
// deeper dialogue, which correctly yields Surface trust tier.
|
||||
let confidence = target_stable
|
||||
.and_then(|sid| observer_kg.confidence_of(&sid))
|
||||
.unwrap_or(crate::knowledge::types::KnowledgeConfidence::Suspects);
|
||||
let trust = relationship_to_trust(relationship, confidence);
|
||||
|
||||
// Query Layers 1-3: collect candidates across all available access tiers
|
||||
let mut candidates: Vec<&IndexedDialogueLine> = Vec::new();
|
||||
let mut seen_ids: Vec<&str> = Vec::new();
|
||||
let mut seen_ids: BTreeSet<&str> = BTreeSet::new();
|
||||
|
||||
for access in &access_tiers {
|
||||
let results = line_pool.0.query_dialogue(
|
||||
@@ -389,9 +418,8 @@ pub fn process_talk_interaction(
|
||||
trust,
|
||||
);
|
||||
for line in results {
|
||||
// Deduplicate across access tiers
|
||||
if !seen_ids.contains(&line.id.as_str()) {
|
||||
seen_ids.push(&line.id);
|
||||
// Deduplicate across access tiers (BTreeSet for deterministic iteration)
|
||||
if seen_ids.insert(&line.id) {
|
||||
candidates.push(line);
|
||||
}
|
||||
}
|
||||
@@ -565,10 +593,7 @@ const CONFRONTATION_LINES: &[(&str, &str)] = &[
|
||||
"confront_01",
|
||||
"That changed everything between us. No going back.",
|
||||
),
|
||||
(
|
||||
"confront_02",
|
||||
"The look on their face... they know I know.",
|
||||
),
|
||||
("confront_02", "The look on their face... they know I know."),
|
||||
(
|
||||
"confront_03",
|
||||
"Cards on the table. Let's see what happens next.",
|
||||
@@ -600,13 +625,8 @@ pub fn process_confrontation_response(
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
) {
|
||||
let Ok((
|
||||
player_entity,
|
||||
confrontation,
|
||||
mut observer_kg,
|
||||
mut monologue_buf,
|
||||
mut monologue_state,
|
||||
)) = query.single_mut()
|
||||
let Ok((player_entity, confrontation, mut observer_kg, mut monologue_buf, mut monologue_state)) =
|
||||
query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -714,26 +734,83 @@ mod tests {
|
||||
assert_eq!(tiers, vec![AccessTier::Hostile]);
|
||||
}
|
||||
|
||||
// -- Trust tier tests (D-075: layered confidence gate) --------------------
|
||||
|
||||
#[test]
|
||||
fn trust_friendly_is_real() {
|
||||
fn trust_friendly_knows_details_is_secret() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Friendly),
|
||||
relationship_to_trust(
|
||||
RelationshipState::Friendly,
|
||||
KnowledgeConfidence::KnowsDetails
|
||||
),
|
||||
TrustTier::Secret
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_friendly_direct_is_secret() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Direct),
|
||||
TrustTier::Secret
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_friendly_knows_of_is_real() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::KnowsOf),
|
||||
TrustTier::Real
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_others_are_surface() {
|
||||
fn trust_friendly_suspects_is_surface() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Unknown),
|
||||
relationship_to_trust(RelationshipState::Friendly, KnowledgeConfidence::Suspects),
|
||||
TrustTier::Surface
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_known_knows_of_is_real() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Known),
|
||||
relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::KnowsOf),
|
||||
TrustTier::Real
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_known_suspects_is_surface() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::Known, KnowledgeConfidence::Suspects),
|
||||
TrustTier::Surface
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_unknown_is_always_surface() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
assert_eq!(
|
||||
relationship_to_trust(RelationshipState::PersonOfInterest),
|
||||
relationship_to_trust(RelationshipState::Unknown, KnowledgeConfidence::Direct),
|
||||
TrustTier::Surface
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_poi_is_always_surface() {
|
||||
use crate::knowledge::types::KnowledgeConfidence;
|
||||
// PersonOfInterest uses Authority access, not trust depth
|
||||
assert_eq!(
|
||||
relationship_to_trust(
|
||||
RelationshipState::PersonOfInterest,
|
||||
KnowledgeConfidence::KnowsDetails
|
||||
),
|
||||
TrustTier::Surface
|
||||
);
|
||||
}
|
||||
@@ -1576,7 +1653,10 @@ mod tests {
|
||||
DeviationTrigger::WalkAway,
|
||||
"Deviation trigger should be WalkAway"
|
||||
);
|
||||
assert_eq!(deviation.tick, 42, "Deviation should record the walk-away tick");
|
||||
assert_eq!(
|
||||
deviation.tick, 42,
|
||||
"Deviation should record the walk-away tick"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1659,7 +1739,10 @@ mod tests {
|
||||
|
||||
// RoutineDeviation should be recorded (symmetric with walk-away)
|
||||
let deviation = world.get::<crate::npc::RoutineDeviation>(npc);
|
||||
assert!(deviation.is_some(), "NPC should get RoutineDeviation after confrontation");
|
||||
assert!(
|
||||
deviation.is_some(),
|
||||
"NPC should get RoutineDeviation after confrontation"
|
||||
);
|
||||
assert_eq!(
|
||||
deviation.unwrap().trigger,
|
||||
crate::npc::DeviationTrigger::Confrontation,
|
||||
|
||||
@@ -474,7 +474,10 @@ fn handle_confront(
|
||||
target: target_entity,
|
||||
});
|
||||
|
||||
tracing::debug!(target_id, "Confront: ConfrontationDelivered marker set on player");
|
||||
tracing::debug!(
|
||||
target_id,
|
||||
"Confront: ConfrontationDelivered marker set on player"
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle Place verb: remove an item from inventory and place it on the ground
|
||||
@@ -1850,7 +1853,9 @@ mod tests {
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world.get::<TilePosition>(player).expect("player has position");
|
||||
let pos = world
|
||||
.get::<TilePosition>(player)
|
||||
.expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "player x at hub spawn");
|
||||
assert_eq!(pos.y, hub_spawn.y, "player y at hub spawn");
|
||||
@@ -1979,7 +1984,9 @@ mod tests {
|
||||
schedule.add_systems(process_player_input);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let pos = world.get::<TilePosition>(player).expect("player has position");
|
||||
let pos = world
|
||||
.get::<TilePosition>(player)
|
||||
.expect("player has position");
|
||||
let hub_spawn = crate::test_world::constants::HUB.spawn;
|
||||
assert_eq!(pos.x, hub_spawn.x, "teleport works while paused");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod contraband;
|
||||
pub mod dialogue;
|
||||
pub mod input;
|
||||
pub mod interaction;
|
||||
@@ -38,6 +39,9 @@ impl Plugin for SimulationPlugin {
|
||||
movement::validate_movement.after(path_follow::follow_paths),
|
||||
path_follow::cleanup_path_blocked.after(movement::validate_movement),
|
||||
listening::update_listening_focus.after(movement::validate_movement),
|
||||
contraband::check_contraband_scan
|
||||
.after(movement::validate_movement)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
time::advance_tick.after(path_follow::cleanup_path_blocked),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -122,12 +122,28 @@ pub const PAUSE_CHAMBER: GauntletRoom = GauntletRoom {
|
||||
|
||||
pub const DIALOGUE_ROOM: GauntletRoom = GauntletRoom {
|
||||
name: "dialogue_room",
|
||||
origin: TilePosition { x: 36, y: 104, z: 0 },
|
||||
origin: TilePosition {
|
||||
x: 36,
|
||||
y: 104,
|
||||
z: 0,
|
||||
},
|
||||
size: (28, 20),
|
||||
spawn: TilePosition { x: 50, y: 114, z: 0 },
|
||||
observer: TilePosition { x: 50, y: 114, z: 0 },
|
||||
spawn: TilePosition {
|
||||
x: 50,
|
||||
y: 114,
|
||||
z: 0,
|
||||
},
|
||||
observer: TilePosition {
|
||||
x: 50,
|
||||
y: 114,
|
||||
z: 0,
|
||||
},
|
||||
observer_facing: Facing(FacingDirection::North),
|
||||
reset_plate: Some(TilePosition { x: 50, y: 103, z: 0 }),
|
||||
reset_plate: Some(TilePosition {
|
||||
x: 50,
|
||||
y: 103,
|
||||
z: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
pub const CROWD_PLAZA: GauntletRoom = GauntletRoom {
|
||||
@@ -140,6 +156,50 @@ pub const CROWD_PLAZA: GauntletRoom = GauntletRoom {
|
||||
reset_plate: Some(TilePosition { x: 80, y: 86, z: 0 }),
|
||||
};
|
||||
|
||||
/// Sprint Gauntlet — Room 8 (28x20)
|
||||
/// Tests D-055 sprint suppression. Player sprints past a visible NPC;
|
||||
/// interaction buffer must be empty during sprint, anomaly monologue fires
|
||||
/// retroactively after sprint ends.
|
||||
pub const SPRINT_GAUNTLET: GauntletRoom = GauntletRoom {
|
||||
name: "sprint_gauntlet",
|
||||
origin: TilePosition { x: 0, y: 2, z: 0 },
|
||||
size: (28, 20),
|
||||
spawn: TilePosition { x: 4, y: 10, z: 0 },
|
||||
observer: TilePosition { x: 4, y: 10, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::East),
|
||||
reset_plate: Some(TilePosition { x: 14, y: 22, z: 0 }),
|
||||
};
|
||||
|
||||
/// Eavesdrop Alcove — Room 9 (24x16)
|
||||
/// Tests eavesdrop positioning and ListeningFocus (D-071). Player in Careful
|
||||
/// stance at a corner within audible range of two NPCs in conversation.
|
||||
pub const EAVESDROP_ALCOVE: GauntletRoom = GauntletRoom {
|
||||
name: "eavesdrop_alcove",
|
||||
origin: TilePosition { x: 74, y: 26, z: 0 },
|
||||
size: (24, 16),
|
||||
spawn: TilePosition { x: 78, y: 36, z: 0 },
|
||||
observer: TilePosition { x: 78, y: 36, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::East),
|
||||
reset_plate: Some(TilePosition { x: 86, y: 42, z: 0 }),
|
||||
};
|
||||
|
||||
/// Confrontation Stage — Room 10 (32x24)
|
||||
/// Tests D-070 confrontation vulnerability. Peripheral NPC movement during
|
||||
/// confrontation is suppressed; post-confrontation delayed monologue fires.
|
||||
pub const CONFRONTATION_STAGE: GauntletRoom = GauntletRoom {
|
||||
name: "confrontation_stage",
|
||||
origin: TilePosition { x: 84, y: 2, z: 0 },
|
||||
size: (32, 24),
|
||||
spawn: TilePosition { x: 94, y: 20, z: 0 },
|
||||
observer: TilePosition { x: 94, y: 20, z: 0 },
|
||||
observer_facing: Facing(FacingDirection::North),
|
||||
reset_plate: Some(TilePosition {
|
||||
x: 100,
|
||||
y: 26,
|
||||
z: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
/// All rooms in canonical spawn order.
|
||||
/// THIS ORDER DETERMINES STABLEID ASSIGNMENT.
|
||||
/// Do not reorder existing entries. Append new rooms at the end.
|
||||
@@ -152,6 +212,9 @@ pub const ROOMS: &[GauntletRoom] = &[
|
||||
PAUSE_CHAMBER,
|
||||
DIALOGUE_ROOM,
|
||||
CROWD_PLAZA,
|
||||
SPRINT_GAUNTLET,
|
||||
EAVESDROP_ALCOVE,
|
||||
CONFRONTATION_STAGE,
|
||||
];
|
||||
|
||||
/// Look up which room a position falls in.
|
||||
@@ -190,6 +253,12 @@ pub const PAUSE_CHAMBER_STABLE_IDS: (u64, u64) = (29, 29);
|
||||
pub const DIALOGUE_ROOM_STABLE_IDS: (u64, u64) = (30, 33);
|
||||
pub const CROWD_PLAZA_STABLE_IDS: (u64, u64) = (34, 48);
|
||||
pub const RESET_PLATE_STABLE_IDS: (u64, u64) = (49, 55);
|
||||
// Sprint 11 rooms — appended after RESET_PLATE_STABLE_IDS per additive-only rule.
|
||||
pub const SPRINT_GAUNTLET_STABLE_IDS: (u64, u64) = (56, 57);
|
||||
pub const EAVESDROP_ALCOVE_STABLE_IDS: (u64, u64) = (58, 60);
|
||||
pub const CONFRONTATION_STAGE_STABLE_IDS: (u64, u64) = (61, 62);
|
||||
/// Reset plates for Sprint 11 rooms (sprint_gauntlet, eavesdrop_alcove, confrontation_stage).
|
||||
pub const SPRINT11_RESET_PLATE_STABLE_IDS: (u64, u64) = (63, 65);
|
||||
|
||||
/// Number of actively-spawned entities in the current Gauntlet build.
|
||||
/// Derived from StableId ranges of all rooms + player + reset plates.
|
||||
@@ -202,7 +271,11 @@ pub const EXPECTED_ENTITY_COUNT: usize = 1 // player (StableId 0)
|
||||
+ (PAUSE_CHAMBER_STABLE_IDS.1 - PAUSE_CHAMBER_STABLE_IDS.0 + 1) as usize
|
||||
+ (DIALOGUE_ROOM_STABLE_IDS.1 - DIALOGUE_ROOM_STABLE_IDS.0 + 1) as usize
|
||||
+ (CROWD_PLAZA_STABLE_IDS.1 - CROWD_PLAZA_STABLE_IDS.0 + 1) as usize
|
||||
+ (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize;
|
||||
+ (RESET_PLATE_STABLE_IDS.1 - RESET_PLATE_STABLE_IDS.0 + 1) as usize
|
||||
+ (SPRINT_GAUNTLET_STABLE_IDS.1 - SPRINT_GAUNTLET_STABLE_IDS.0 + 1) as usize
|
||||
+ (EAVESDROP_ALCOVE_STABLE_IDS.1 - EAVESDROP_ALCOVE_STABLE_IDS.0 + 1) as usize
|
||||
+ (CONFRONTATION_STAGE_STABLE_IDS.1 - CONFRONTATION_STAGE_STABLE_IDS.0 + 1) as usize
|
||||
+ (SPRINT11_RESET_PLATE_STABLE_IDS.1 - SPRINT11_RESET_PLATE_STABLE_IDS.0 + 1) as usize;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -252,7 +325,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_dialogue_room() {
|
||||
let pos = TilePosition { x: 50, y: 114, z: 0 };
|
||||
let pos = TilePosition {
|
||||
x: 50,
|
||||
y: 114,
|
||||
z: 0,
|
||||
};
|
||||
let room = room_at(&pos).expect("Dialogue Room observer should be in a room");
|
||||
assert_eq!(room.name, "dialogue_room");
|
||||
}
|
||||
@@ -264,22 +341,50 @@ mod tests {
|
||||
assert_eq!(room.name, "crowd_plaza");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_sprint_gauntlet() {
|
||||
let pos = TilePosition { x: 4, y: 10, z: 0 };
|
||||
let room = room_at(&pos).expect("Sprint Gauntlet observer should be in a room");
|
||||
assert_eq!(room.name, "sprint_gauntlet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_eavesdrop_alcove() {
|
||||
let pos = TilePosition { x: 78, y: 36, z: 0 };
|
||||
let room = room_at(&pos).expect("Eavesdrop Alcove observer should be in a room");
|
||||
assert_eq!(room.name, "eavesdrop_alcove");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_finds_confrontation_stage() {
|
||||
let pos = TilePosition { x: 94, y: 20, z: 0 };
|
||||
let room = room_at(&pos).expect("Confrontation Stage observer should be in a room");
|
||||
assert_eq!(room.name, "confrontation_stage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_returns_none_for_corridor() {
|
||||
// Point inside corridor-E (between Hub and Occlusion)
|
||||
let pos = TilePosition { x: 66, y: 57, z: 0 };
|
||||
assert!(room_at(&pos).is_none(), "Corridor should not be in any room");
|
||||
assert!(
|
||||
room_at(&pos).is_none(),
|
||||
"Corridor should not be in any room"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_at_returns_none_for_outside_map() {
|
||||
let pos = TilePosition { x: 200, y: 200, z: 0 };
|
||||
let pos = TilePosition {
|
||||
x: 200,
|
||||
y: 200,
|
||||
z: 0,
|
||||
};
|
||||
assert!(room_at(&pos).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_rooms_in_correct_order() {
|
||||
assert_eq!(ROOMS.len(), 8);
|
||||
assert_eq!(ROOMS.len(), 11);
|
||||
assert_eq!(ROOMS[0].name, "central_hub");
|
||||
assert_eq!(ROOMS[1].name, "fog_theater");
|
||||
assert_eq!(ROOMS[2].name, "occlusion_corridor");
|
||||
@@ -288,6 +393,9 @@ mod tests {
|
||||
assert_eq!(ROOMS[5].name, "pause_chamber");
|
||||
assert_eq!(ROOMS[6].name, "dialogue_room");
|
||||
assert_eq!(ROOMS[7].name, "crowd_plaza");
|
||||
assert_eq!(ROOMS[8].name, "sprint_gauntlet");
|
||||
assert_eq!(ROOMS[9].name, "eavesdrop_alcove");
|
||||
assert_eq!(ROOMS[10].name, "confrontation_stage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -297,14 +405,15 @@ mod tests {
|
||||
if i >= j {
|
||||
continue;
|
||||
}
|
||||
let overlap_x = a.origin.x < b.origin.x + b.size.0
|
||||
&& a.origin.x + a.size.0 > b.origin.x;
|
||||
let overlap_y = a.origin.y < b.origin.y + b.size.1
|
||||
&& a.origin.y + a.size.1 > b.origin.y;
|
||||
let overlap_x =
|
||||
a.origin.x < b.origin.x + b.size.0 && a.origin.x + a.size.0 > b.origin.x;
|
||||
let overlap_y =
|
||||
a.origin.y < b.origin.y + b.size.1 && a.origin.y + a.size.1 > b.origin.y;
|
||||
assert!(
|
||||
!(overlap_x && overlap_y),
|
||||
"Rooms {} and {} overlap",
|
||||
a.name, b.name
|
||||
a.name,
|
||||
b.name
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -317,12 +426,18 @@ mod tests {
|
||||
assert!(
|
||||
obs.x >= room.origin.x && obs.x < room.origin.x + room.size.0,
|
||||
"Observer x={} outside room {} (origin.x={}, width={})",
|
||||
obs.x, room.name, room.origin.x, room.size.0
|
||||
obs.x,
|
||||
room.name,
|
||||
room.origin.x,
|
||||
room.size.0
|
||||
);
|
||||
assert!(
|
||||
obs.y >= room.origin.y && obs.y < room.origin.y + room.size.1,
|
||||
"Observer y={} outside room {} (origin.y={}, height={})",
|
||||
obs.y, room.name, room.origin.y, room.size.1
|
||||
obs.y,
|
||||
room.name,
|
||||
room.origin.y,
|
||||
room.size.1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -339,6 +454,10 @@ mod tests {
|
||||
DIALOGUE_ROOM_STABLE_IDS,
|
||||
CROWD_PLAZA_STABLE_IDS,
|
||||
RESET_PLATE_STABLE_IDS,
|
||||
SPRINT_GAUNTLET_STABLE_IDS,
|
||||
EAVESDROP_ALCOVE_STABLE_IDS,
|
||||
CONFRONTATION_STAGE_STABLE_IDS,
|
||||
SPRINT11_RESET_PLATE_STABLE_IDS,
|
||||
];
|
||||
for (i, a) in ranges.iter().enumerate() {
|
||||
for (j, b) in ranges.iter().enumerate() {
|
||||
@@ -348,7 +467,10 @@ mod tests {
|
||||
assert!(
|
||||
a.1 < b.0 || b.1 < a.0,
|
||||
"StableId ranges {} and {} overlap: {:?} vs {:?}",
|
||||
i, j, a, b
|
||||
i,
|
||||
j,
|
||||
a,
|
||||
b
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+223
-21
@@ -16,7 +16,7 @@
|
||||
//! - Entities spawned in canonical order → StableId assignment is deterministic
|
||||
//! - Additive-only: existing rooms/entities never reordered
|
||||
//!
|
||||
//! StableId ranges (from gestalt-round3.md):
|
||||
//! StableId ranges (from gestalt-round3.md + Sprint 11):
|
||||
//! Player: 0
|
||||
//! Hub signs: 1-4
|
||||
//! Fog Theater: 5-8
|
||||
@@ -26,7 +26,11 @@
|
||||
//! Pause Chamber: 29
|
||||
//! Dialogue Room: 30-33
|
||||
//! Crowd Plaza: 34-48
|
||||
//! Reset plates: 49-55
|
||||
//! Reset plates (Sprint 1-10 rooms): 49-55
|
||||
//! Sprint Gauntlet: 56-57
|
||||
//! Eavesdrop Alcove: 58-60
|
||||
//! Confrontation Stage: 61-62
|
||||
//! Reset plates (Sprint 11 rooms): 63-65
|
||||
|
||||
#[cfg(feature = "gauntlet")]
|
||||
pub mod constants;
|
||||
@@ -40,10 +44,10 @@ use bevy_app::prelude::*;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::knowledge::KnowledgeGraph;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::knowledge::types::StableId;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::knowledge::KnowledgeGraph;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::perception::cognitive_delay::CognitiveDelay;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::perception::vision_cone::Facing;
|
||||
@@ -54,6 +58,7 @@ use crate::simulation::inventory::ItemName;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::listening::ListeningFocus;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::contraband::ScanEventBuffer;
|
||||
use crate::simulation::monologue::{MonologueBuffer, MonologueState, SprintAnomalyQueue};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
@@ -93,6 +98,9 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
carve_room_interior(&mut walkability, 42, 78, 16, 16); // Pause Chamber
|
||||
carve_room_interior(&mut walkability, 36, 104, 28, 20); // Dialogue Room
|
||||
carve_room_interior(&mut walkability, 80, 78, 32, 32); // Crowd Plaza
|
||||
carve_room_interior(&mut walkability, 0, 2, 28, 20); // Sprint Gauntlet
|
||||
carve_room_interior(&mut walkability, 74, 26, 24, 16); // Eavesdrop Alcove
|
||||
carve_room_interior(&mut walkability, 84, 2, 32, 24); // Confrontation Stage
|
||||
|
||||
// Carve corridors between hub and rooms
|
||||
carve_corridor(&mut walkability, 48, 34, 6, 12); // corridor-N: Hub ↔ Fog Theater
|
||||
@@ -102,6 +110,9 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
carve_corridor(&mut walkability, 12, 68, 6, 14); // corridor-SW: Inventory ↔ Interaction Gallery
|
||||
carve_corridor(&mut walkability, 48, 94, 6, 10); // corridor-S2: Pause ↔ Dialogue Room
|
||||
carve_corridor(&mut walkability, 58, 84, 22, 6); // corridor-E2: Pause ↔ Crowd Plaza
|
||||
// Sprint 11 corridors
|
||||
carve_corridor(&mut walkability, 12, 22, 6, 18); // corridor-NW: Sprint Gauntlet ↔ Inventory south
|
||||
carve_corridor(&mut walkability, 62, 30, 12, 6); // corridor-NE: Eavesdrop Alcove ↔ Occlusion north
|
||||
|
||||
// Set up Occlusion Corridor walls (relative positions converted to absolute)
|
||||
// North wall segment: rel x=[14,22], y=[6,7] — blocks LOS to npc_hidden_wall
|
||||
@@ -138,6 +149,7 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
MonologueState::default(),
|
||||
MonologueBuffer::default(),
|
||||
SprintAnomalyQueue::default(),
|
||||
ScanEventBuffer::default(),
|
||||
CognitiveDelay::default(),
|
||||
ListeningFocus::new(player_pos),
|
||||
profile,
|
||||
@@ -175,13 +187,48 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
// Spawned at corridor entrances per workshop-outcomes.md Section 8.
|
||||
// Each plate triggers reset of its associated room.
|
||||
let reset_plates: &[(&str, TilePosition)] = &[
|
||||
("occlusion_corridor", constants::OCCLUSION_CORRIDOR.reset_plate.expect("occlusion_corridor should have a reset_plate")),
|
||||
("inventory_warehouse", constants::INVENTORY_WAREHOUSE.reset_plate.expect("inventory_warehouse should have a reset_plate")),
|
||||
("pause_chamber", constants::PAUSE_CHAMBER.reset_plate.expect("pause_chamber should have a reset_plate")),
|
||||
("fog_theater", constants::FOG_THEATER.reset_plate.expect("fog_theater should have a reset_plate")),
|
||||
("interaction_gallery", constants::INTERACTION_GALLERY.reset_plate.expect("interaction_gallery should have a reset_plate")),
|
||||
("dialogue_room", constants::DIALOGUE_ROOM.reset_plate.expect("dialogue_room should have a reset_plate")),
|
||||
("crowd_plaza", constants::CROWD_PLAZA.reset_plate.expect("crowd_plaza should have a reset_plate")),
|
||||
(
|
||||
"occlusion_corridor",
|
||||
constants::OCCLUSION_CORRIDOR
|
||||
.reset_plate
|
||||
.expect("occlusion_corridor should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"inventory_warehouse",
|
||||
constants::INVENTORY_WAREHOUSE
|
||||
.reset_plate
|
||||
.expect("inventory_warehouse should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"pause_chamber",
|
||||
constants::PAUSE_CHAMBER
|
||||
.reset_plate
|
||||
.expect("pause_chamber should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"fog_theater",
|
||||
constants::FOG_THEATER
|
||||
.reset_plate
|
||||
.expect("fog_theater should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"interaction_gallery",
|
||||
constants::INTERACTION_GALLERY
|
||||
.reset_plate
|
||||
.expect("interaction_gallery should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"dialogue_room",
|
||||
constants::DIALOGUE_ROOM
|
||||
.reset_plate
|
||||
.expect("dialogue_room should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"crowd_plaza",
|
||||
constants::CROWD_PLAZA
|
||||
.reset_plate
|
||||
.expect("crowd_plaza should have a reset_plate"),
|
||||
),
|
||||
];
|
||||
for &(room_name, pos) in reset_plates {
|
||||
let entity = app
|
||||
@@ -200,6 +247,53 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
// --- Sprint Gauntlet (StableId 56-57) ---
|
||||
rooms::sprint_gauntlet::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Eavesdrop Alcove (StableId 58-60) ---
|
||||
rooms::eavesdrop_alcove::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Confrontation Stage (StableId 61-62) ---
|
||||
rooms::confrontation_stage::spawn_entities(app, &mut registry);
|
||||
|
||||
// --- Sprint 11 reset plates (StableId 63-65) ---
|
||||
let sprint11_reset_plates: &[(&str, TilePosition)] = &[
|
||||
(
|
||||
"sprint_gauntlet",
|
||||
constants::SPRINT_GAUNTLET
|
||||
.reset_plate
|
||||
.expect("sprint_gauntlet should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"eavesdrop_alcove",
|
||||
constants::EAVESDROP_ALCOVE
|
||||
.reset_plate
|
||||
.expect("eavesdrop_alcove should have a reset_plate"),
|
||||
),
|
||||
(
|
||||
"confrontation_stage",
|
||||
constants::CONFRONTATION_STAGE
|
||||
.reset_plate
|
||||
.expect("confrontation_stage should have a reset_plate"),
|
||||
),
|
||||
];
|
||||
for &(room_name, pos) in sprint11_reset_plates {
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Interactable,
|
||||
RoomResetTrigger {
|
||||
room_name: room_name.to_string(),
|
||||
},
|
||||
pos,
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
// --- Populate RoomSnapshots for reset mechanism (#490) ---
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
|
||||
@@ -232,7 +326,9 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
}
|
||||
|
||||
// Interaction Gallery entities (StableId 24-28): objects only, no floor items
|
||||
for id in constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1 {
|
||||
for id in
|
||||
constants::INTERACTION_GALLERY_STABLE_IDS.0..=constants::INTERACTION_GALLERY_STABLE_IDS.1
|
||||
{
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("interaction_gallery", entity, *pos, false);
|
||||
@@ -265,6 +361,35 @@ pub fn setup_gauntlet(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sprint Gauntlet entities (StableId 56-57): sign + NPC, no floor items
|
||||
for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("sprint_gauntlet", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Eavesdrop Alcove entities (StableId 58-60): NPCs + sign, no floor items
|
||||
for id in constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1 {
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("eavesdrop_alcove", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confrontation Stage entities (StableId 61-62): NPCs only, no floor items
|
||||
for id in
|
||||
constants::CONFRONTATION_STAGE_STABLE_IDS.0..=constants::CONFRONTATION_STAGE_STABLE_IDS.1
|
||||
{
|
||||
if let Some(entity) = registry.to_entity(&StableId(id)) {
|
||||
if let Some(pos) = app.world().get::<TilePosition>(entity) {
|
||||
snapshots.record("confrontation_stage", entity, *pos, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.insert_resource(snapshots);
|
||||
app.insert_resource(registry);
|
||||
}
|
||||
@@ -374,44 +499,78 @@ mod tests {
|
||||
|
||||
// Player at StableId 0
|
||||
use crate::knowledge::types::StableId;
|
||||
assert!(registry.to_entity(&StableId(0)).is_some(), "Player at StableId 0");
|
||||
assert!(
|
||||
registry.to_entity(&StableId(0)).is_some(),
|
||||
"Player at StableId 0"
|
||||
);
|
||||
|
||||
// Hub signs at 1-4
|
||||
for id in 1..=4 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Hub sign at StableId {}", id);
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Hub sign at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Fog Theater at 5-8
|
||||
for id in 5..=8 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Fog Theater at StableId {}", id);
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Fog Theater at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Occlusion Corridor at 9-12
|
||||
for id in 9..=12 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Occlusion at StableId {}", id);
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Occlusion at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Inventory Warehouse at 13-23
|
||||
for id in 13..=23 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Inventory at StableId {}", id);
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Inventory at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Interaction Gallery at 24-28
|
||||
for id in 24..=28 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Gallery at StableId {}", id);
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Gallery at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Pause Chamber at 29
|
||||
assert!(registry.to_entity(&StableId(29)).is_some(), "Pause Chamber at StableId 29");
|
||||
assert!(
|
||||
registry.to_entity(&StableId(29)).is_some(),
|
||||
"Pause Chamber at StableId 29"
|
||||
);
|
||||
|
||||
// Dialogue Room at 30-33
|
||||
for id in 30..=33 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Dialogue Room at StableId {}", id);
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Dialogue Room at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Crowd Plaza at 34-48
|
||||
for id in 34..=48 {
|
||||
assert!(registry.to_entity(&StableId(id)).is_some(), "Crowd Plaza at StableId {}", id);
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Crowd Plaza at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Reset plates at 49-55
|
||||
@@ -422,5 +581,48 @@ mod tests {
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Sprint Gauntlet at 56-57
|
||||
for id in constants::SPRINT_GAUNTLET_STABLE_IDS.0..=constants::SPRINT_GAUNTLET_STABLE_IDS.1
|
||||
{
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Sprint Gauntlet at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Eavesdrop Alcove at 58-60
|
||||
for id in
|
||||
constants::EAVESDROP_ALCOVE_STABLE_IDS.0..=constants::EAVESDROP_ALCOVE_STABLE_IDS.1
|
||||
{
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Eavesdrop Alcove at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Confrontation Stage at 61-62
|
||||
for id in constants::CONFRONTATION_STAGE_STABLE_IDS.0
|
||||
..=constants::CONFRONTATION_STAGE_STABLE_IDS.1
|
||||
{
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Confrontation Stage at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
// Sprint 11 reset plates at 63-65
|
||||
for id in constants::SPRINT11_RESET_PLATE_STABLE_IDS.0
|
||||
..=constants::SPRINT11_RESET_PLATE_STABLE_IDS.1
|
||||
{
|
||||
assert!(
|
||||
registry.to_entity(&StableId(id)).is_some(),
|
||||
"Sprint 11 reset plate at StableId {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,13 @@ pub struct RoomSnapshots {
|
||||
|
||||
impl RoomSnapshots {
|
||||
/// Record the initial position of an entity in a room.
|
||||
pub fn record(&mut self, room_name: &str, entity: Entity, position: TilePosition, is_floor_item: bool) {
|
||||
pub fn record(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
entity: Entity,
|
||||
position: TilePosition,
|
||||
is_floor_item: bool,
|
||||
) {
|
||||
self.snapshots
|
||||
.entry(room_name.to_string())
|
||||
.or_default()
|
||||
@@ -196,9 +202,7 @@ mod tests {
|
||||
#[test]
|
||||
fn can_reset_after_debounce() {
|
||||
let mut snapshots = RoomSnapshots::default();
|
||||
snapshots
|
||||
.last_reset_tick
|
||||
.insert("room".to_string(), 100);
|
||||
snapshots.last_reset_tick.insert("room".to_string(), 100);
|
||||
|
||||
assert!(!snapshots.can_reset("room", 105));
|
||||
assert!(snapshots.can_reset("room", 110));
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Confrontation Stage — Room 10 (32x24)
|
||||
//!
|
||||
//! Tests D-070 (confrontation as cognitive vulnerability). Player confronts
|
||||
//! npc_target; peripheral NPC movement occurring during the confrontation
|
||||
//! should be suppressed by the confrontation audio/perception dip and NOT
|
||||
//! trigger an anomaly monologue immediately. A delayed post-confrontation
|
||||
//! monologue fires for the missed peripheral event.
|
||||
//!
|
||||
//! Layout: Open stage area. npc_target is positioned north of the player
|
||||
//! spawn — direct confrontation path. npc_peripheral is placed north-east,
|
||||
//! within potential anomaly detection range, simulating a passer-by during
|
||||
//! the confrontation.
|
||||
//!
|
||||
//! Observer position: (10, 18) relative = (94, 20) absolute, facing North.
|
||||
//!
|
||||
//! Entities (StableId 61-62):
|
||||
//! npc_target (10, 10) rel = (94, 12) abs — Confrontation target NPC
|
||||
//! npc_peripheral (24, 6) rel = (108, 8) abs — Peripheral NPC (passer-by)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 84;
|
||||
const ORIGIN_Y: i32 = 2;
|
||||
|
||||
/// NPC definitions: (rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, description).
|
||||
#[allow(clippy::type_complexity)]
|
||||
const NPCS: &[(i32, i32, WantKind, u8, i16, i16, i16, &str)] = &[
|
||||
(
|
||||
10,
|
||||
10,
|
||||
WantKind::Power,
|
||||
7,
|
||||
-10,
|
||||
30,
|
||||
45,
|
||||
"Confrontation Stage: confrontation target",
|
||||
),
|
||||
(
|
||||
24,
|
||||
6,
|
||||
WantKind::Freedom,
|
||||
4,
|
||||
15,
|
||||
5,
|
||||
70,
|
||||
"Confrontation Stage: peripheral passer-by",
|
||||
),
|
||||
];
|
||||
|
||||
/// Spawn Confrontation Stage entities in canonical order (StableId 61-62).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(rx, ry, want_kind, intensity, contentment, stress, threshold, description) in NPCS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity,
|
||||
description: description.to_string(),
|
||||
},
|
||||
Contentment { level: contentment },
|
||||
ToleranceThreshold {
|
||||
current_stress: stress,
|
||||
threshold,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
}
|
||||
@@ -31,20 +31,52 @@ const ORIGIN_Y: i32 = 104;
|
||||
#[allow(clippy::type_complexity)]
|
||||
const NPCS: &[(&str, i32, i32, WantKind, u8, i16, i16, i16, &str, &str)] = &[
|
||||
(
|
||||
"npc_dialogue_a", 8, 8, WantKind::Connection, 4, 20, 10, 60,
|
||||
"the-terminal", "dock-worker",
|
||||
"npc_dialogue_a",
|
||||
8,
|
||||
8,
|
||||
WantKind::Connection,
|
||||
4,
|
||||
20,
|
||||
10,
|
||||
60,
|
||||
"the-terminal",
|
||||
"dock-worker",
|
||||
),
|
||||
(
|
||||
"npc_dialogue_b", 14, 6, WantKind::Safety, 6, 0, 25, 45,
|
||||
"the-terminal", "technician",
|
||||
"npc_dialogue_b",
|
||||
14,
|
||||
6,
|
||||
WantKind::Safety,
|
||||
6,
|
||||
0,
|
||||
25,
|
||||
45,
|
||||
"the-terminal",
|
||||
"technician",
|
||||
),
|
||||
(
|
||||
"npc_dialogue_c", 20, 8, WantKind::Power, 7, -15, 40, 50,
|
||||
"the-terminal", "supervisor",
|
||||
"npc_dialogue_c",
|
||||
20,
|
||||
8,
|
||||
WantKind::Power,
|
||||
7,
|
||||
-15,
|
||||
40,
|
||||
50,
|
||||
"the-terminal",
|
||||
"supervisor",
|
||||
),
|
||||
(
|
||||
"npc_dialogue_d", 14, 14, WantKind::Knowledge, 3, 10, 5, 70,
|
||||
"the-terminal", "observer",
|
||||
"npc_dialogue_d",
|
||||
14,
|
||||
14,
|
||||
WantKind::Knowledge,
|
||||
3,
|
||||
10,
|
||||
5,
|
||||
70,
|
||||
"the-terminal",
|
||||
"observer",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -65,9 +97,7 @@ pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
intensity,
|
||||
description: format!("Dialogue Room test NPC: {}", name),
|
||||
},
|
||||
Contentment {
|
||||
level: contentment,
|
||||
},
|
||||
Contentment { level: contentment },
|
||||
ToleranceThreshold {
|
||||
current_stress: stress,
|
||||
threshold,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Eavesdrop Alcove — Room 9 (24x16)
|
||||
//!
|
||||
//! Tests eavesdrop positioning and ListeningFocus (D-071). Player stands in
|
||||
//! Careful stance at a corner near two NPCs in conversation. Expected
|
||||
//! outcomes: World SFX boost observable (D-069), conversation murmur event
|
||||
//! emitted (D-072), interaction buffer suppressed at eavesdrop distance.
|
||||
//!
|
||||
//! Layout: Open alcove. Two NPC speakers are placed north-centre. A Readable
|
||||
//! corner-position marker sits in the south-west corner — the eavesdrop
|
||||
//! position. The player observes from the corner with a clear line of sound
|
||||
//! to both speakers. Manhattan distance from observer to npc_speaker_a is 4
|
||||
//! (within EAVESDROP_RANGE = 5).
|
||||
//!
|
||||
//! Observer position: (4, 10) relative = (78, 36) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 58-60):
|
||||
//! npc_speaker_a (6, 6) rel = (80, 32) abs — First conversation NPC
|
||||
//! npc_speaker_b (12, 6) rel = (86, 32) abs — Second conversation NPC
|
||||
//! eavesdrop_corner (4, 10) rel = (78, 36) abs — Readable corner marker
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::bridge::types::ObjectType;
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 74;
|
||||
const ORIGIN_Y: i32 = 26;
|
||||
|
||||
/// NPC definitions: (rel_x, rel_y, want_kind, intensity, contentment, stress, threshold, description).
|
||||
#[allow(clippy::type_complexity)]
|
||||
const SPEAKERS: &[(i32, i32, WantKind, u8, i16, i16, i16, &str)] = &[
|
||||
(
|
||||
6,
|
||||
6,
|
||||
WantKind::Connection,
|
||||
5,
|
||||
10,
|
||||
8,
|
||||
55,
|
||||
"Eavesdrop Alcove: speaker A",
|
||||
),
|
||||
(
|
||||
12,
|
||||
6,
|
||||
WantKind::Knowledge,
|
||||
6,
|
||||
5,
|
||||
12,
|
||||
60,
|
||||
"Eavesdrop Alcove: speaker B",
|
||||
),
|
||||
];
|
||||
|
||||
/// Spawn Eavesdrop Alcove entities in canonical order (StableId 58-60).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
// NPC speakers (StableId 58-59)
|
||||
for &(rx, ry, want_kind, intensity, contentment, stress, threshold, description) in SPEAKERS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
pos,
|
||||
Want {
|
||||
primary: want_kind,
|
||||
intensity,
|
||||
description: description.to_string(),
|
||||
},
|
||||
Contentment { level: contentment },
|
||||
ToleranceThreshold {
|
||||
current_stress: stress,
|
||||
threshold,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
|
||||
// Eavesdrop corner marker (StableId 60)
|
||||
// Readable entity at the corner eavesdrop position. Tests that a Readable
|
||||
// within interaction range is suppressed when the player is at eavesdrop
|
||||
// distance from the speakers (interaction buffer suppression at eavesdrop
|
||||
// distance per D-071 spec).
|
||||
let corner_pos = TilePosition::new(ORIGIN_X + 4, ORIGIN_Y + 10, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((Interactable, ObjectType::Readable, corner_pos))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
@@ -39,10 +39,7 @@ const OBJECTS: &[(&str, i32, i32, ObjectType)] = &[
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
for &(_name, rx, ry, obj_type) in OBJECTS {
|
||||
let pos = TilePosition::new(ORIGIN_X + rx, ORIGIN_Y + ry, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((Interactable, obj_type, pos))
|
||||
.id();
|
||||
let entity = app.world_mut().spawn((Interactable, obj_type, pos)).id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
//! Each room module exports a `spawn_entities()` function that creates
|
||||
//! entities in canonical order for deterministic StableId assignment.
|
||||
|
||||
pub mod confrontation_stage;
|
||||
pub mod crowd_plaza;
|
||||
pub mod dialogue_room;
|
||||
pub mod eavesdrop_alcove;
|
||||
pub mod fog_theater;
|
||||
pub mod hub;
|
||||
pub mod interaction_gallery;
|
||||
pub mod inventory_warehouse;
|
||||
pub mod occlusion_corridor;
|
||||
pub mod pause_chamber;
|
||||
pub mod sprint_gauntlet;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Sprint Gauntlet — Room 8 (28x20)
|
||||
//!
|
||||
//! Tests D-055 (sprint suppresses interaction buffer) and D-016 (anomaly
|
||||
//! monologue fires retroactively after sprint ends).
|
||||
//!
|
||||
//! Layout: Open east-west corridor. Player sprints from the west end past a
|
||||
//! visible NPC at the east end. A Readable zone-marker sign at the sprint
|
||||
//! start position tests that the interaction buffer is empty during sprint.
|
||||
//! After the sprint ends, the delayed anomaly monologue fires.
|
||||
//!
|
||||
//! Observer position: (4, 8) relative = (4, 10) absolute, facing East.
|
||||
//!
|
||||
//! Entities (StableId 56-57):
|
||||
//! sprint_zone_marker (6, 8) rel = (6, 10) abs — Readable zone sign
|
||||
//! npc_sprint_target (22, 8) rel = (22, 10) abs — Visible NPC during sprint
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
use crate::bridge::types::ObjectType;
|
||||
use crate::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use crate::npc::{Contentment, Npc, ToleranceThreshold, Want, WantKind};
|
||||
use crate::simulation::interaction::Interactable;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::path_follow::MovementSpeed;
|
||||
|
||||
/// Room origin (top-left corner including walls).
|
||||
const ORIGIN_X: i32 = 0;
|
||||
const ORIGIN_Y: i32 = 2;
|
||||
|
||||
/// Spawn Sprint Gauntlet entities in canonical order (StableId 56-57).
|
||||
pub fn spawn_entities(app: &mut App, registry: &mut EntityRegistry) {
|
||||
// Sprint zone marker sign (StableId 56)
|
||||
// Placed at the sprint-start position. During sprint the interaction buffer
|
||||
// must be empty — this Readable entity tests that suppression (D-055).
|
||||
let sign_pos = TilePosition::new(ORIGIN_X + 6, ORIGIN_Y + 8, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((Interactable, ObjectType::Readable, sign_pos))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
|
||||
// Sprint target NPC (StableId 57)
|
||||
// Visible at the far (east) end of the gauntlet. The player sprints past
|
||||
// this NPC; if it carries a Contradicted knowledge entry the anomaly
|
||||
// monologue fires after ANOMALY_DELAY_TICKS (D-055, D-016).
|
||||
let npc_pos = TilePosition::new(ORIGIN_X + 22, ORIGIN_Y + 8, 0);
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Npc,
|
||||
Interactable,
|
||||
npc_pos,
|
||||
Want {
|
||||
primary: WantKind::Safety,
|
||||
intensity: 4,
|
||||
description: "Sprint Gauntlet: visible NPC during sprint".to_string(),
|
||||
},
|
||||
Contentment { level: 0 }, // Neutral — test NPC, contentment not load-bearing here
|
||||
ToleranceThreshold {
|
||||
current_stress: 10,
|
||||
threshold: 50,
|
||||
},
|
||||
MovementSpeed::default(),
|
||||
))
|
||||
.id();
|
||||
let sid = registry.register(entity);
|
||||
app.world_mut()
|
||||
.entity_mut(entity)
|
||||
.insert(StableEntityId(sid));
|
||||
}
|
||||
@@ -60,6 +60,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -46,6 +46,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -142,9 +142,9 @@ fn content_runtime_boot_tick_10_snapshot() {
|
||||
barrier.wait();
|
||||
|
||||
// Server thread must not have panicked
|
||||
server_handle
|
||||
.join()
|
||||
.expect("server thread panicked — content triggered a runtime error during tick processing");
|
||||
server_handle.join().expect(
|
||||
"server thread panicked — content triggered a runtime error during tick processing",
|
||||
);
|
||||
|
||||
// Validate final snapshot
|
||||
let snapshot = last_snapshot.expect("should have received at least one snapshot");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Content scaling test (#500, D-026).
|
||||
//! Content scaling test (#513, D-026).
|
||||
//!
|
||||
//! Verifies that adding extra NPCs doesn't degrade tick timing beyond
|
||||
//! acceptable bounds. Runs the Gauntlet baseline, then adds additional
|
||||
@@ -6,15 +6,22 @@
|
||||
//! 1. Tick timing stays within D-026 budget (100ms)
|
||||
//! 2. Baseline entities still behave identically (deterministic)
|
||||
//!
|
||||
//! Sprint 11 adds two new tests (#513 deliverable):
|
||||
//! - max_npc_pack_tick_budget: 80 NPCs (D-026 Active tier ceiling), 100 ticks,
|
||||
//! per-tick budget assertion (every tick < 100ms, not just average).
|
||||
//! - max_npc_pack_behavioral_regression: verifies that adding 46 extra NPCs to
|
||||
//! hit the Active tier ceiling doesn't change original entity behavior at tick 100.
|
||||
//!
|
||||
//! Run with: cargo test --test content_scaling -- --nocapture
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Instant;
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::BridgePlugin;
|
||||
use settled_reach_server::knowledge::registry::{EntityRegistry, StableEntityId};
|
||||
use settled_reach_server::knowledge::KnowledgePlugin;
|
||||
use settled_reach_server::knowledge::{KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId};
|
||||
use settled_reach_server::npc::{Contentment, Npc, NpcPlugin, ToleranceThreshold, Want, WantKind};
|
||||
use settled_reach_server::simulation::interaction::Interactable;
|
||||
use settled_reach_server::simulation::movement::TilePosition;
|
||||
@@ -30,6 +37,24 @@ const MAX_TICK_MS: f64 = 100.0;
|
||||
/// Extra NPC counts for scaling tiers.
|
||||
const EXTRA_NPC_COUNTS: &[usize] = &[0, 15, 50];
|
||||
|
||||
/// D-026 Active tier ceiling: maximum NPCs in full simulation.
|
||||
const ACTIVE_TIER_NPC_CEILING: usize = 80;
|
||||
|
||||
/// Ticks for the full stress test (#513 spec: 100 ticks, 80 NPCs).
|
||||
const STRESS_TICKS: usize = 100;
|
||||
|
||||
/// Known NPC count in the full Gauntlet world (all rooms, Sprint 11 included).
|
||||
/// Fog Theater: 4, Occlusion Corridor: 4, Inventory Warehouse: 1, Pause Chamber: 1,
|
||||
/// Dialogue Room: 4, Crowd Plaza: 15, Sprint Gauntlet: 1, Eavesdrop Alcove: 2,
|
||||
/// Confrontation Stage: 2 = 34 total.
|
||||
///
|
||||
/// Manually maintained — update when rooms are added/changed. Future: derive
|
||||
/// from StableId ranges in constants.rs to avoid manual sync.
|
||||
const GAUNTLET_NPC_COUNT: usize = 34;
|
||||
|
||||
/// Extra NPCs to spawn on top of the Gauntlet baseline to reach Active tier ceiling.
|
||||
const STRESS_EXTRA_NPCS: usize = ACTIVE_TIER_NPC_CEILING - GAUNTLET_NPC_COUNT;
|
||||
|
||||
/// Set up a Gauntlet world and return the app.
|
||||
fn setup_baseline() -> App {
|
||||
let mut app = App::new();
|
||||
@@ -106,6 +131,25 @@ fn count_entities(app: &App) -> usize {
|
||||
registry.len() as usize
|
||||
}
|
||||
|
||||
/// Collect the player's KnowledgeGraph confidence levels for all Gauntlet entities
|
||||
/// (StableIds 0..=max_id). Used to detect KG-level behavioral regression.
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn player_kg_snapshot(app: &App, max_id: u64) -> BTreeMap<u64, KnowledgeConfidence> {
|
||||
let registry = app.world().resource::<EntityRegistry>();
|
||||
let player_entity = registry
|
||||
.to_entity(&StableId(0))
|
||||
.expect("player entity at StableId 0");
|
||||
match app.world().get::<KnowledgeGraph>(player_entity) {
|
||||
Some(kg) => kg
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|(id, _)| id.0 <= max_id)
|
||||
.map(|(id, entry)| (id.0, entry.confidence))
|
||||
.collect(),
|
||||
None => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Baseline tick timing: Gauntlet with default entities stays within D-026 budget.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
@@ -144,7 +188,10 @@ fn scaling_tick_timing_within_budget() {
|
||||
results.push((extra_count, total_entities, avg_ms));
|
||||
}
|
||||
|
||||
eprintln!("\n=== Content Scaling Results (D-026: {}ms budget) ===", MAX_TICK_MS);
|
||||
eprintln!(
|
||||
"\n=== Content Scaling Results (D-026: {}ms budget) ===",
|
||||
MAX_TICK_MS
|
||||
);
|
||||
eprintln!("{:<12} {:<10} {:<15}", "Extra NPCs", "Total", "Avg ms/tick");
|
||||
eprintln!("{:-<37}", "");
|
||||
for &(extra, total, avg_ms) in &results {
|
||||
@@ -215,7 +262,10 @@ fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
let scaled_snap = scaled_buffer.expect("scaled should produce a snapshot");
|
||||
|
||||
// Same tick
|
||||
assert_eq!(baseline_snap.tick, scaled_snap.tick, "tick count should match");
|
||||
assert_eq!(
|
||||
baseline_snap.tick, scaled_snap.tick,
|
||||
"tick count should match"
|
||||
);
|
||||
|
||||
// Same game time
|
||||
assert_eq!(
|
||||
@@ -224,8 +274,14 @@ fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
);
|
||||
|
||||
// Player position should be identical
|
||||
let baseline_player = baseline_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
|
||||
let scaled_player = scaled_snap.entities.iter().find(|e| e.kind == EntityKind::Player);
|
||||
let baseline_player = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| e.kind == EntityKind::Player);
|
||||
let scaled_player = scaled_snap
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| e.kind == EntityKind::Player);
|
||||
assert!(baseline_player.is_some(), "baseline should have player");
|
||||
assert!(scaled_player.is_some(), "scaled should have player");
|
||||
|
||||
@@ -257,3 +313,211 @@ fn extra_npcs_dont_affect_baseline_behavior() {
|
||||
"Original Gauntlet entities (id <= max_baseline_id) should be identical in both runs"
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sprint 11 / #513 — Max-NPC Pack Stress Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Stress test: Active tier ceiling (80 NPCs), 100 ticks, per-tick budget check.
|
||||
///
|
||||
/// Spawns the full Gauntlet baseline ({GAUNTLET_NPC_COUNT} NPCs) plus
|
||||
/// {STRESS_EXTRA_NPCS} extra NPCs to reach the D-026 Active tier ceiling (80).
|
||||
/// Runs {STRESS_TICKS} ticks and asserts that EVERY individual tick (not just
|
||||
/// the average) completes within the 100ms D-026 budget.
|
||||
///
|
||||
/// Outputs a PERF_RESULT JSON line compatible with the perf-baseline tooling
|
||||
/// (same format as tests/perf/baseline.json) so CI can compare against the
|
||||
/// stored baseline.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn max_npc_pack_tick_budget() {
|
||||
let mut app = setup_baseline();
|
||||
spawn_extra_npcs(&mut app, STRESS_EXTRA_NPCS);
|
||||
let total_entities = count_entities(&app);
|
||||
|
||||
// Warm-up: first tick has bevy startup overhead.
|
||||
app.update();
|
||||
|
||||
// Measure STRESS_TICKS, recording each tick individually.
|
||||
let mut per_tick_us: Vec<u64> = Vec::with_capacity(STRESS_TICKS);
|
||||
for _ in 0..STRESS_TICKS {
|
||||
let start = Instant::now();
|
||||
app.update();
|
||||
per_tick_us.push(start.elapsed().as_micros() as u64);
|
||||
}
|
||||
|
||||
// --- Statistics ---
|
||||
let min_us = *per_tick_us.iter().min().unwrap();
|
||||
let max_us = *per_tick_us.iter().max().unwrap();
|
||||
let sum: u64 = per_tick_us.iter().sum();
|
||||
let mean_us = sum / per_tick_us.len() as u64;
|
||||
let mut sorted = per_tick_us.clone();
|
||||
sorted.sort_unstable();
|
||||
let p95_idx = ((sorted.len() - 1) as f64 * 0.95).floor() as usize;
|
||||
let p95_us = sorted[p95_idx.min(sorted.len() - 1)];
|
||||
|
||||
eprintln!(
|
||||
"\n=== Max-NPC Pack Stress Test — D-026 tick budget ({} NPCs, {} ticks) ===",
|
||||
ACTIVE_TIER_NPC_CEILING, STRESS_TICKS
|
||||
);
|
||||
eprintln!(
|
||||
"Entities in world: {} (Gauntlet NPCs: {} extra: {})",
|
||||
total_entities, GAUNTLET_NPC_COUNT, STRESS_EXTRA_NPCS
|
||||
);
|
||||
eprintln!(
|
||||
"Timing: min={:.3}ms mean={:.3}ms p95={:.3}ms max={:.3}ms budget={}ms",
|
||||
min_us as f64 / 1000.0,
|
||||
mean_us as f64 / 1000.0,
|
||||
p95_us as f64 / 1000.0,
|
||||
max_us as f64 / 1000.0,
|
||||
MAX_TICK_MS
|
||||
);
|
||||
|
||||
// Emit PERF_RESULT in the same format as tooling/perf-baseline so output
|
||||
// can be diffed against tests/perf/baseline.json by CI tooling.
|
||||
println!(
|
||||
"PERF_RESULT:{}",
|
||||
serde_json::json!({
|
||||
"test": "max_npc_pack_tick_budget",
|
||||
"spec": "D-026",
|
||||
"tick_timing": {
|
||||
"warmup_ticks": 1,
|
||||
"measured_ticks": STRESS_TICKS,
|
||||
"min_us": min_us,
|
||||
"max_us": max_us,
|
||||
"mean_us": mean_us,
|
||||
"p95_us": p95_us,
|
||||
},
|
||||
"entities": {
|
||||
"total_in_world": total_entities,
|
||||
"active_tier_npcs": ACTIVE_TIER_NPC_CEILING,
|
||||
"gauntlet_npcs": GAUNTLET_NPC_COUNT,
|
||||
"extra_npcs": STRESS_EXTRA_NPCS,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Core assertion: EVERY tick must be within the D-026 100ms budget.
|
||||
// Average-only checks can mask spikes — verify each individual tick.
|
||||
let budget_us = (MAX_TICK_MS * 1000.0) as u64;
|
||||
let over_budget: Vec<(usize, u64)> = per_tick_us
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &us)| us > budget_us)
|
||||
.map(|(i, &us)| (i, us))
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
over_budget.is_empty(),
|
||||
"D-026 tick budget exceeded with {} NPCs: {} of {} ticks over {}ms\n worst: tick {} at {:.3}ms",
|
||||
ACTIVE_TIER_NPC_CEILING,
|
||||
over_budget.len(),
|
||||
STRESS_TICKS,
|
||||
MAX_TICK_MS,
|
||||
over_budget[0].0,
|
||||
over_budget[0].1 as f64 / 1000.0
|
||||
);
|
||||
}
|
||||
|
||||
/// Behavioral regression: 80 NPCs must not disturb original entity state at tick 100.
|
||||
///
|
||||
/// Runs the pure Gauntlet (GAUNTLET_NPC_COUNT NPCs) and the full 80-NPC stress
|
||||
/// pack for STRESS_TICKS ticks. Asserts:
|
||||
/// 1. Snapshot entity IDs for all Gauntlet entities (StableId 0..=65) are identical.
|
||||
/// 2. Player's KnowledgeGraph confidence entries for Gauntlet entity range are identical.
|
||||
///
|
||||
/// This validates D-010 determinism: extra Active-tier NPCs must not affect the
|
||||
/// simulation of original entities via LOS, KG, or ECS phase ordering.
|
||||
/// Spec: #513, D-026, D-010.
|
||||
#[test]
|
||||
#[cfg(feature = "gauntlet")]
|
||||
fn max_npc_pack_behavioral_regression() {
|
||||
use settled_reach_server::test_world::constants::SPRINT11_RESET_PLATE_STABLE_IDS;
|
||||
|
||||
// The highest StableId belonging to a Gauntlet entity (Sprint 11 reset plates).
|
||||
let max_gauntlet_id = SPRINT11_RESET_PLATE_STABLE_IDS.1;
|
||||
|
||||
// --- Baseline run: pure Gauntlet, no extra NPCs ---
|
||||
let mut baseline_app = setup_baseline();
|
||||
for _ in 0..STRESS_TICKS {
|
||||
baseline_app.update();
|
||||
}
|
||||
let baseline_snapshot = baseline_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
let baseline_kg = player_kg_snapshot(&baseline_app, max_gauntlet_id);
|
||||
|
||||
// --- Stress run: Gauntlet + extra NPCs to reach 80 NPC Active tier ceiling ---
|
||||
let mut stress_app = setup_baseline();
|
||||
spawn_extra_npcs(&mut stress_app, STRESS_EXTRA_NPCS);
|
||||
for _ in 0..STRESS_TICKS {
|
||||
stress_app.update();
|
||||
}
|
||||
let stress_snapshot = stress_app
|
||||
.world()
|
||||
.resource::<SnapshotBuffer>()
|
||||
.snapshot
|
||||
.clone();
|
||||
let stress_kg = player_kg_snapshot(&stress_app, max_gauntlet_id);
|
||||
|
||||
let baseline_snap = baseline_snapshot
|
||||
.expect("baseline Gauntlet should produce a snapshot");
|
||||
let stress_snap = stress_snapshot
|
||||
.expect("80-NPC stress run should produce a snapshot");
|
||||
|
||||
// Tick index must match (same number of updates).
|
||||
assert_eq!(
|
||||
baseline_snap.tick, stress_snap.tick,
|
||||
"tick count should match between baseline and stress run"
|
||||
);
|
||||
|
||||
// --- 1. Snapshot entity comparison ---
|
||||
// Collect and sort entity IDs for original Gauntlet entities only.
|
||||
// Extra NPCs (StableId > max_gauntlet_id) are excluded from comparison.
|
||||
let mut baseline_ids: Vec<u64> = baseline_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_gauntlet_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
let mut stress_ids: Vec<u64> = stress_snap
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.entity_id <= max_gauntlet_id)
|
||||
.map(|e| e.entity_id)
|
||||
.collect();
|
||||
baseline_ids.sort_unstable();
|
||||
stress_ids.sort_unstable();
|
||||
|
||||
assert_eq!(
|
||||
baseline_ids, stress_ids,
|
||||
"Gauntlet entity visibility at tick {} must be identical: baseline {} entities vs {} with {} extra NPCs",
|
||||
STRESS_TICKS,
|
||||
baseline_ids.len(),
|
||||
stress_ids.len(),
|
||||
STRESS_EXTRA_NPCS
|
||||
);
|
||||
|
||||
// --- 2. Knowledge graph comparison ---
|
||||
// Player's KG confidence levels for Gauntlet entities (StableId 0..=max_gauntlet_id)
|
||||
// must be identical in both runs. Extra NPCs in the hub may be added to the
|
||||
// player's KG (higher StableIds), but must not affect original entity entries.
|
||||
assert_eq!(
|
||||
baseline_kg, stress_kg,
|
||||
"Player KG confidence entries for Gauntlet entities (id <= {}) differ at tick {}\n baseline: {} entries stress: {} entries",
|
||||
max_gauntlet_id,
|
||||
STRESS_TICKS,
|
||||
baseline_kg.len(),
|
||||
stress_kg.len()
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"Behavioral regression PASS: {} Gauntlet entities identical at tick {} ({} NPCs vs {} NPCs)",
|
||||
baseline_ids.len(),
|
||||
STRESS_TICKS,
|
||||
GAUNTLET_NPC_COUNT,
|
||||
ACTIVE_TIER_NPC_CEILING
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
//! Cross-room transition test scenarios (T1-T8)
|
||||
//!
|
||||
//! Sprint 11 (#506) — system-combination tests at room boundaries.
|
||||
//! Each test exercises a bug class that emerges when two subsystems interact
|
||||
//! across a coordinate boundary (simulated by player position change).
|
||||
//!
|
||||
//! Tests use the ECS world setup pattern with direct system schedule execution
|
||||
//! — no ad-hoc test harness per sprint requirement.
|
||||
//!
|
||||
//! Decision refs:
|
||||
//! D-055 — sprint suppresses interaction buffer (T1, T8)
|
||||
//! D-065 — 9-slot inventory, CarriedBy component (T2)
|
||||
//! D-031 — pause/unpause, TickRate guard (T3)
|
||||
//! D-041 — KnowledgeGraph persistence across transitions (T4, T5)
|
||||
//! D-060 — cognitive delay, entity recognition persistence (T5)
|
||||
//! D-071 — ListeningFocus eavesdrop positioning (T6, T8)
|
||||
//! D-070 — confrontation as cognitive vulnerability, verb range (T7)
|
||||
//! D-057 — verb computation, interaction range transitions (T7)
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_ecs::schedule::Schedule;
|
||||
use settled_reach_server::bridge::types::{MovementStance, VerbKind};
|
||||
use settled_reach_server::knowledge::registry::EntityRegistry;
|
||||
use settled_reach_server::knowledge::KnowledgeGraph;
|
||||
use settled_reach_server::npc::Npc;
|
||||
use settled_reach_server::simulation::interaction::{
|
||||
compute_nearby_interactions, Interactable, NearbyInteractionBuffer, ObjectType,
|
||||
};
|
||||
use settled_reach_server::simulation::inventory::{CarriedBy, InventorySlot, ItemName};
|
||||
use settled_reach_server::simulation::listening::{
|
||||
update_listening_focus, ListeningFocus, EAVESDROP_THRESHOLD, EAVESDROP_THRESHOLD_CAREFUL,
|
||||
};
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::stance::Stance;
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// Minimal world with EntityRegistry (no App — single-system schedule tests).
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
fn run_interaction_system(world: &mut World) {
|
||||
let mut sched = Schedule::default();
|
||||
sched.add_systems(compute_nearby_interactions);
|
||||
sched.run(world);
|
||||
}
|
||||
|
||||
fn run_listening_system(world: &mut World) {
|
||||
let mut sched = Schedule::default();
|
||||
sched.add_systems(update_listening_focus);
|
||||
sched.run(world);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T1: Sprint Exit — buffer clears during sprint, restores on Walk
|
||||
// D-055, Sprint Gauntlet room
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T1 — Sprint Exit.
|
||||
///
|
||||
/// Player at (4, 12 absolute) — standalone scenario position south of the
|
||||
/// Sprint Gauntlet observer (4, 10 per constants.rs). A Readable sign at
|
||||
/// (6, 12) is within CLOSE_RANGE (distance 2).
|
||||
///
|
||||
/// During Walk: sign appears in interaction buffer.
|
||||
/// During Sprint: buffer is empty (D-055 suppression).
|
||||
/// After stance returns to Walk: buffer repopulates within one compute cycle.
|
||||
///
|
||||
/// This covers the cross-room exit behaviour: player sprinting out of the
|
||||
/// Sprint Gauntlet loses all interaction context while in sprint.
|
||||
#[test]
|
||||
fn t1_sprint_suppresses_buffer_and_restores_on_walk() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Player at Sprint Gauntlet observer absolute position (ORIGIN_X=0, ORIGIN_Y=2,
|
||||
// rel observer (4,10) → abs (4,12)), Walk stance.
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(4, 12, 0),
|
||||
NearbyInteractionBuffer::default(),
|
||||
Stance(MovementStance::Walk),
|
||||
))
|
||||
.id();
|
||||
|
||||
// Readable sign at (6, 12) abs — distance 2 from player (CLOSE_RANGE=2).
|
||||
// Mirrors sprint_gauntlet.rs sign entity (StableId 56).
|
||||
let sign = world
|
||||
.spawn((
|
||||
TilePosition::new(6, 12, 0),
|
||||
Interactable,
|
||||
ObjectType::Readable,
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(sign);
|
||||
|
||||
// --- Walk: sign appears in buffer ---
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
!interactions.is_empty(),
|
||||
"T1 Walk: sign at distance 2 must appear in interaction buffer"
|
||||
);
|
||||
|
||||
// --- Sprint: buffer suppressed (D-055) ---
|
||||
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Sprint;
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
interactions.is_empty(),
|
||||
"T1 Sprint: interaction buffer must be empty (D-055 sprint suppression)"
|
||||
);
|
||||
|
||||
// --- Walk again: buffer repopulates within one compute cycle ---
|
||||
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Walk;
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
!interactions.is_empty(),
|
||||
"T1 Walk after Sprint: interaction buffer must repopulate"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T2: Inventory Carry — CarriedBy survives room transition
|
||||
// D-065, Inventory Warehouse → Crowd Plaza
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T2 — Inventory Interact.
|
||||
///
|
||||
/// Player picks up an item (CarriedBy set, TilePosition removed).
|
||||
/// Player moves to a new coordinate region (simulates room transition).
|
||||
///
|
||||
/// Asserts: CarriedBy still references player, item has no TilePosition.
|
||||
/// The information boundary (D-010 principle 2) holds across coordinates:
|
||||
/// a carried item is never "in" the new room until explicitly placed.
|
||||
#[test]
|
||||
fn t2_carried_item_survives_room_transition() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Player at Inventory Warehouse observer position (abs 17, 54).
|
||||
let player = world
|
||||
.spawn((PlayerCharacter, TilePosition::new(17, 54, 0)))
|
||||
.id();
|
||||
let player_sid = world.resource_mut::<EntityRegistry>().register(player);
|
||||
|
||||
// Item already in inventory (no TilePosition — it has been taken).
|
||||
let item = world
|
||||
.spawn((
|
||||
CarriedBy(player_sid),
|
||||
ItemName("Manifest Copy".into()),
|
||||
InventorySlot(0),
|
||||
))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(item);
|
||||
|
||||
// Pre-transition invariants.
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_none(),
|
||||
"T2 pre: carried item must not have TilePosition"
|
||||
);
|
||||
assert_eq!(
|
||||
world.get::<CarriedBy>(item).unwrap().0,
|
||||
player_sid,
|
||||
"T2 pre: CarriedBy must reference player"
|
||||
);
|
||||
|
||||
// Simulate room transition: player moves to Crowd Plaza observer position.
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(96, 94, 0);
|
||||
|
||||
// Post-transition: item state is unchanged by the player's position change.
|
||||
assert!(
|
||||
world.get::<TilePosition>(item).is_none(),
|
||||
"T2 post: item must still have no TilePosition (still carried)"
|
||||
);
|
||||
assert_eq!(
|
||||
world.get::<CarriedBy>(item).unwrap().0,
|
||||
player_sid,
|
||||
"T2 post: CarriedBy must still reference player after movement"
|
||||
);
|
||||
assert_eq!(
|
||||
world.get::<InventorySlot>(item).unwrap().0,
|
||||
0,
|
||||
"T2 post: InventorySlot must be unchanged after room transition"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T3: Pause Anywhere — mid-corridor pause discards movement
|
||||
// D-031
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T3 — Pause Anywhere.
|
||||
///
|
||||
/// Player at a corridor position between two rooms (corridor-N midpoint
|
||||
/// between Hub and Fog Theater, ~abs (50, 39)).
|
||||
/// Pause → movement discarded. Unpause → movement accepted.
|
||||
///
|
||||
/// Tests that pause state is position-agnostic: the pause guard fires
|
||||
/// regardless of whether the player is inside a room or between rooms.
|
||||
#[test]
|
||||
fn t3_pause_mid_corridor_discards_movement_and_resumes() {
|
||||
use bevy_app::prelude::*;
|
||||
use settled_reach_server::bridge::types::{PlayerAction, PlayerInput};
|
||||
use settled_reach_server::simulation::input::InputQueue;
|
||||
use settled_reach_server::simulation::time::{SimulationTime, TickRate};
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
// 200×200 walkability map covers the full gauntlet coordinate space.
|
||||
app.insert_resource(WalkabilityMap::new(200, 200, 1));
|
||||
|
||||
// Player at corridor-N midpoint (between Hub at y≈46 and Fog Theater at y≈2).
|
||||
let player = app
|
||||
.world_mut()
|
||||
.spawn((PlayerCharacter, TilePosition::new(50, 39, 0)))
|
||||
.id();
|
||||
|
||||
// --- Step 1: Pause (tick 0) ---
|
||||
app.world_mut()
|
||||
.resource_mut::<InputQueue>()
|
||||
.push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Pause,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Paused,
|
||||
"T3 step 1: game must be paused"
|
||||
);
|
||||
// Paused — advance_tick does not fire; tick stays at 0.
|
||||
assert_eq!(app.world().resource::<SimulationTime>().tick, 0);
|
||||
|
||||
// --- Step 2: MoveNorth while paused (tick 0) — must be discarded ---
|
||||
app.world_mut()
|
||||
.resource_mut::<InputQueue>()
|
||||
.push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
*app.world().get::<TilePosition>(player).unwrap(),
|
||||
TilePosition::new(50, 39, 0),
|
||||
"T3 step 2: player position must be unchanged while paused"
|
||||
);
|
||||
|
||||
// --- Step 3: Unpause (tick 0) ---
|
||||
app.world_mut()
|
||||
.resource_mut::<InputQueue>()
|
||||
.push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::Unpause,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<SimulationTime>().tick_rate,
|
||||
TickRate::Full,
|
||||
"T3 step 3: game must be running after Unpause"
|
||||
);
|
||||
// advance_tick fires for the first time (Full rate): tick 0 → 1.
|
||||
assert_eq!(app.world().resource::<SimulationTime>().tick, 1);
|
||||
|
||||
// --- Step 4: MoveNorth after unpause (tick 1) — must be accepted ---
|
||||
app.world_mut()
|
||||
.resource_mut::<InputQueue>()
|
||||
.push(PlayerInput {
|
||||
tick: 1,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
*app.world().get::<TilePosition>(player).unwrap(),
|
||||
TilePosition::new(50, 38, 0),
|
||||
"T3 step 4: player must move north (y-1) after unpause"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T4: Knowledge Graph Persistence across room transition
|
||||
// D-041
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T4 — Knowledge State Persistence.
|
||||
///
|
||||
/// Player observes NPC in Dialogue Room (adds KG entry at Direct confidence).
|
||||
/// Player moves to Hub (simulates room transition).
|
||||
///
|
||||
/// Asserts: KG entry persists. The KnowledgeGraph component is not cleared
|
||||
/// or invalidated by a change in player TilePosition.
|
||||
#[test]
|
||||
fn t4_knowledge_graph_survives_room_transition() {
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(50, 114, 0), // Dialogue Room observer position
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
|
||||
// NPC in Dialogue Room (npc_stranger at abs ~(40, 112)).
|
||||
let npc = world.spawn(TilePosition::new(40, 112, 0)).id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Player observes NPC — adds KG entry at Direct confidence.
|
||||
world
|
||||
.get_mut::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.observe_entity(npc_sid, TilePosition::new(40, 112, 0), 0);
|
||||
|
||||
assert!(
|
||||
world
|
||||
.get::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.knows_entity(&npc_sid),
|
||||
"T4 pre: player must know NPC before room transition"
|
||||
);
|
||||
|
||||
// Simulate room transition: player moves to Hub observer position.
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(50, 58, 0);
|
||||
|
||||
assert!(
|
||||
world
|
||||
.get::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.knows_entity(&npc_sid),
|
||||
"T4 post: KG entry must persist after player moves to Hub"
|
||||
);
|
||||
assert_eq!(
|
||||
world
|
||||
.get::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.entity_count(),
|
||||
1,
|
||||
"T4 post: exactly 1 KG entry after room transition"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T5: Entity Knowledge Downgrades on LOS Exit (Fog Carry-Over)
|
||||
// D-041, D-060
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T5 — Fog Carry-Over.
|
||||
///
|
||||
/// Player observes NPC in Fog Theater at Direct confidence.
|
||||
/// Player moves to Hub (NPC now out of LOS). Confidence downgrades
|
||||
/// from Direct to KnowsDetails.
|
||||
///
|
||||
/// Asserts: KG entry persists (entity is remembered, not erased).
|
||||
/// Server-side "fog carry-over" means previously-seen entities remain in
|
||||
/// the KG at reduced confidence so the client can render a "last seen"
|
||||
/// fog state rather than a clean erasure.
|
||||
///
|
||||
/// NOTE: Uses direct KG API calls (observe_entity, observe_entity_leaving_los)
|
||||
/// rather than running the full perception system. This isolates the KG
|
||||
/// persistence contract from perception scheduling.
|
||||
#[test]
|
||||
fn t5_entity_knowledge_downgrades_on_los_exit_not_erased() {
|
||||
use settled_reach_server::knowledge::types::KnowledgeConfidence;
|
||||
|
||||
let mut world = setup_world();
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(56, 18, 0), // Fog Theater observer position
|
||||
KnowledgeGraph::new(),
|
||||
))
|
||||
.id();
|
||||
|
||||
// NPC in Fog Theater (npc_fog_near at abs (38, 16)).
|
||||
let npc = world.spawn(TilePosition::new(38, 16, 0)).id();
|
||||
let npc_sid = world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Player observes NPC — Direct confidence.
|
||||
world
|
||||
.get_mut::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.observe_entity(npc_sid, TilePosition::new(38, 16, 0), 0);
|
||||
|
||||
assert_eq!(
|
||||
world
|
||||
.get::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.confidence_of(&npc_sid),
|
||||
Some(KnowledgeConfidence::Direct),
|
||||
"T5 pre: NPC must be at Direct confidence while player is in Fog Theater"
|
||||
);
|
||||
|
||||
// Player moves to Hub — NPC is now out of LOS.
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(50, 58, 0);
|
||||
|
||||
// Observation system downgrades confidence (entity left LOS).
|
||||
world
|
||||
.get_mut::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.observe_entity_leaving_los(&npc_sid, 1);
|
||||
|
||||
let kg = world.get::<KnowledgeGraph>(player).unwrap();
|
||||
assert!(
|
||||
kg.knows_entity(&npc_sid),
|
||||
"T5 post: KG entry must persist after player leaves the room (fog carry-over)"
|
||||
);
|
||||
assert_eq!(
|
||||
kg.confidence_of(&npc_sid),
|
||||
Some(KnowledgeConfidence::KnowsDetails),
|
||||
"T5 post: confidence must downgrade from Direct to KnowsDetails on LOS exit"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T6: Eavesdrop Cut on Player Movement
|
||||
// D-071, Eavesdrop Alcove
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T6 — Eavesdrop Cut on Transition.
|
||||
///
|
||||
/// Player is stationary at the Eavesdrop Alcove corner position
|
||||
/// (abs 78, 36) with an active eavesdrop_target. Player moves one step
|
||||
/// south (leaving the eavesdrop position). Asserts: stationary_ticks resets
|
||||
/// to 0 and eavesdrop_target clears.
|
||||
///
|
||||
/// This prevents eavesdrop state leaking when the player walks out of the
|
||||
/// Eavesdrop Alcove: the very first movement cuts the focus.
|
||||
#[test]
|
||||
fn t6_eavesdrop_cut_on_player_movement() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC speaker A from Eavesdrop Alcove (StableId 58, abs 80, 32).
|
||||
let speaker_a = world.spawn(TilePosition::new(80, 32, 0)).id();
|
||||
let speaker_a_sid = world.resource_mut::<EntityRegistry>().register(speaker_a);
|
||||
|
||||
// Player at eavesdrop corner position with active eavesdrop focus.
|
||||
let corner_pos = TilePosition::new(78, 36, 0);
|
||||
let mut focus = ListeningFocus::new(corner_pos);
|
||||
focus.stationary_ticks = EAVESDROP_THRESHOLD + 10;
|
||||
focus.eavesdrop_target = Some(speaker_a_sid);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
corner_pos,
|
||||
focus,
|
||||
Stance(MovementStance::Careful), // Careful stance for eavesdrop
|
||||
))
|
||||
.id();
|
||||
|
||||
// Pre-move: eavesdrop is active.
|
||||
{
|
||||
let f = world.get::<ListeningFocus>(player).unwrap();
|
||||
assert!(
|
||||
f.eavesdrop_target.is_some(),
|
||||
"T6 pre: eavesdrop_target must be set before movement"
|
||||
);
|
||||
assert!(
|
||||
f.stationary_ticks > EAVESDROP_THRESHOLD,
|
||||
"T6 pre: stationary_ticks must exceed threshold"
|
||||
);
|
||||
}
|
||||
|
||||
// Player moves one step south (leaving eavesdrop corner).
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(78, 37, 0);
|
||||
run_listening_system(&mut world);
|
||||
|
||||
// Eavesdrop must be cut.
|
||||
let f = world.get::<ListeningFocus>(player).unwrap();
|
||||
assert_eq!(
|
||||
f.stationary_ticks, 0,
|
||||
"T6 post: stationary_ticks must reset to 0 on movement"
|
||||
);
|
||||
assert!(
|
||||
f.eavesdrop_target.is_none(),
|
||||
"T6 post: eavesdrop_target must clear when player leaves eavesdrop position"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T7: Confrontation Interrupt on Room Exit
|
||||
// D-070, D-057, Confrontation Stage
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T7 — Confrontation Interrupt.
|
||||
///
|
||||
/// Player at CLOSE_RANGE (distance 2) from NPC target in Confrontation Stage:
|
||||
/// Talk verb is available (confrontation is possible at this range).
|
||||
/// Player retreats to observer position (94, 20) — distance 8, beyond MID_RANGE=5:
|
||||
/// all NPC verbs disappear from the interaction buffer.
|
||||
///
|
||||
/// This models the confrontation "interrupt" when the player moves away —
|
||||
/// the verb set changes, ending the potential confrontation.
|
||||
#[test]
|
||||
fn t7_confrontation_verb_disappears_on_retreat_beyond_mid_range() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// NPC target at Confrontation Stage absolute position (94, 12).
|
||||
let npc = world
|
||||
.spawn((Npc, TilePosition::new(94, 12, 0), Interactable))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
// Step 1: Player at (94, 14) — distance 2 from NPC (CLOSE_RANGE=2).
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(94, 14, 0),
|
||||
NearbyInteractionBuffer::default(),
|
||||
Stance(MovementStance::Walk),
|
||||
))
|
||||
.id();
|
||||
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert_eq!(
|
||||
interactions.len(),
|
||||
1,
|
||||
"T7 close: NPC at distance 2 must appear in interaction buffer"
|
||||
);
|
||||
assert!(
|
||||
interactions[0].verbs.iter().any(|v| v.kind == VerbKind::Talk),
|
||||
"T7 close: Talk must be available at CLOSE_RANGE (confrontation possible)"
|
||||
);
|
||||
|
||||
// Step 2: Player retreats to observer position (94, 20) — distance 8.
|
||||
// MID_RANGE = 5; distance 8 is fully out of range.
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = TilePosition::new(94, 20, 0);
|
||||
|
||||
run_interaction_system(&mut world);
|
||||
let interactions = world
|
||||
.get_mut::<NearbyInteractionBuffer>(player)
|
||||
.unwrap()
|
||||
.take();
|
||||
assert!(
|
||||
interactions.is_empty(),
|
||||
"T7 retreat: NPC at distance 8 must not appear in buffer (beyond MID_RANGE=5)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// T8: Sprint Blocks Eavesdrop Accumulation (D-055 + D-071)
|
||||
// Sprint Gauntlet → Eavesdrop Alcove transition
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/// T8 — Sprint + Eavesdrop Cross-System Interaction.
|
||||
///
|
||||
/// Sprint stance (D-055 high-alert) prevents stationary_ticks from
|
||||
/// accumulating in ListeningFocus (D-071), so eavesdrop cannot activate
|
||||
/// while the player is sprinting.
|
||||
///
|
||||
/// Scenario: Player sprints through Sprint Gauntlet — 50 ticks stationary
|
||||
/// in Sprint stance → stationary_ticks stays at 0. Player then moves to
|
||||
/// Eavesdrop Alcove and switches to Careful stance. After
|
||||
/// EAVESDROP_THRESHOLD_CAREFUL stationary ticks, the threshold is met.
|
||||
///
|
||||
/// This catches the cross-system bug: stale sprint state leaking into the
|
||||
/// eavesdrop counter if the Sprint check in update_listening_focus is absent.
|
||||
#[test]
|
||||
fn t8_sprint_blocks_eavesdrop_then_careful_enables_accumulation() {
|
||||
let mut world = setup_world();
|
||||
|
||||
// Player starts at Sprint Gauntlet observer position with Sprint stance.
|
||||
let sprint_pos = TilePosition::new(4, 12, 0); // abs (4,12)
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
sprint_pos,
|
||||
ListeningFocus::new(sprint_pos),
|
||||
Stance(MovementStance::Sprint),
|
||||
))
|
||||
.id();
|
||||
|
||||
// 50 stationary ticks at Sprint — counter must not accumulate.
|
||||
for _ in 0..50 {
|
||||
run_listening_system(&mut world);
|
||||
}
|
||||
assert_eq!(
|
||||
world.get::<ListeningFocus>(player).unwrap().stationary_ticks,
|
||||
0,
|
||||
"T8 sprint: Sprint must block stationary_ticks (50 ticks at sprint, still 0)"
|
||||
);
|
||||
|
||||
// Transition: player moves to Eavesdrop Alcove, switches to Careful stance.
|
||||
let alcove_pos = TilePosition::new(78, 36, 0);
|
||||
*world.get_mut::<TilePosition>(player).unwrap() = alcove_pos;
|
||||
world.get_mut::<Stance>(player).unwrap().0 = MovementStance::Careful;
|
||||
|
||||
// One tick to register the movement: system detects position change,
|
||||
// resets stationary_ticks to 0, and updates last_position to alcove_pos.
|
||||
run_listening_system(&mut world);
|
||||
assert_eq!(
|
||||
world.get::<ListeningFocus>(player).unwrap().stationary_ticks,
|
||||
0,
|
||||
"T8 transition: movement tick must reset stationary_ticks to 0"
|
||||
);
|
||||
|
||||
// EAVESDROP_THRESHOLD_CAREFUL stationary ticks in Careful stance.
|
||||
for _ in 0..EAVESDROP_THRESHOLD_CAREFUL {
|
||||
run_listening_system(&mut world);
|
||||
}
|
||||
{
|
||||
let f = world.get::<ListeningFocus>(player).unwrap();
|
||||
assert_eq!(
|
||||
f.stationary_ticks, EAVESDROP_THRESHOLD_CAREFUL,
|
||||
"T8 careful: stationary_ticks must accumulate cleanly after stance change"
|
||||
);
|
||||
assert!(
|
||||
f.stationary_ticks >= EAVESDROP_THRESHOLD_CAREFUL,
|
||||
"T8 careful: stationary_ticks must meet Careful eavesdrop threshold"
|
||||
);
|
||||
}
|
||||
|
||||
// Verify D-071 invariant: Careful threshold is strictly less than normal.
|
||||
assert!(
|
||||
EAVESDROP_THRESHOLD_CAREFUL < EAVESDROP_THRESHOLD,
|
||||
"T8: Careful threshold must be < normal threshold (D-071 invariant)"
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +207,7 @@ fn generate_msgpack_fixtures() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_v2_full",
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"player_facing": "North",
|
||||
"player_inventory": [],
|
||||
"player_stance": "Sprint",
|
||||
"scan_events": [],
|
||||
"tick": 8,
|
||||
"version": 9,
|
||||
"visible_tiles": [
|
||||
|
||||
@@ -308,10 +308,7 @@ fn diff_json(path: &str, expected: &Value, actual: &Value, diffs: &mut Vec<Strin
|
||||
}
|
||||
_ => {
|
||||
if expected != actual {
|
||||
diffs.push(format!(
|
||||
"{}: expected {}, got {}",
|
||||
path, expected, actual
|
||||
));
|
||||
diffs.push(format!("{}: expected {}, got {}", path, expected, actual));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,8 +346,7 @@ fn proof_room_tick_10_matches_golden() {
|
||||
// Serialize to sorted JSON for deterministic comparison
|
||||
let actual_value: Value = serde_json::to_value(&snapshot).expect("serialize to JSON");
|
||||
let actual_sorted = sort_json_keys(&actual_value);
|
||||
let actual_json =
|
||||
serde_json::to_string_pretty(&actual_sorted).expect("format JSON") + "\n";
|
||||
let actual_json = serde_json::to_string_pretty(&actual_sorted).expect("format JSON") + "\n";
|
||||
|
||||
let golden_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN_FILE);
|
||||
|
||||
@@ -382,10 +378,7 @@ fn proof_room_tick_10_matches_golden() {
|
||||
diff_json("", &golden_value, &actual_sorted, &mut diffs);
|
||||
|
||||
if !diffs.is_empty() {
|
||||
let mut msg = format!(
|
||||
"Golden file mismatch ({} differences):\n",
|
||||
diffs.len()
|
||||
);
|
||||
let mut msg = format!("Golden file mismatch ({} differences):\n", diffs.len());
|
||||
for diff in &diffs {
|
||||
msg.push_str(&format!(" {}\n", diff));
|
||||
}
|
||||
|
||||
@@ -215,8 +215,5 @@ fn perf_tick_timing() {
|
||||
},
|
||||
});
|
||||
|
||||
println!(
|
||||
"PERF_RESULT:{}",
|
||||
serde_json::to_string(&result).unwrap()
|
||||
);
|
||||
println!("PERF_RESULT:{}", serde_json::to_string(&result).unwrap());
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +253,7 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
@@ -345,6 +347,7 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
pending_recognitions: vec![],
|
||||
dialogue_response: None,
|
||||
blocked_entities: vec![],
|
||||
scan_events: vec![],
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -1040,18 +1043,16 @@ fn gdscript_generated_fixtures_deserialize() {
|
||||
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
|
||||
|
||||
if name.starts_with("input_batch") {
|
||||
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
panic!("deserialize GDScript batch fixture {}: {}", name, e)
|
||||
});
|
||||
let inputs: Vec<PlayerInput> = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize GDScript batch fixture {}: {}", name, e));
|
||||
assert!(
|
||||
!inputs.is_empty(),
|
||||
"batch fixture {} should not be empty",
|
||||
name
|
||||
);
|
||||
} else if name.starts_with("input_") || name.starts_with("boundary_tick_") {
|
||||
let input: PlayerInput = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
panic!("deserialize GDScript input fixture {}: {}", name, e)
|
||||
});
|
||||
let input: PlayerInput = rmp_serde::from_slice(&bytes)
|
||||
.unwrap_or_else(|e| panic!("deserialize GDScript input fixture {}: {}", name, e));
|
||||
// Verify specific fixtures for extra confidence
|
||||
match name.as_str() {
|
||||
"input_move_north" => {
|
||||
@@ -1078,10 +1079,7 @@ fn gdscript_generated_fixtures_deserialize() {
|
||||
assert_eq!(input.tick, 65536, "int_32 asymmetry: tick=65536");
|
||||
}
|
||||
"boundary_tick_2147483647" => {
|
||||
assert_eq!(
|
||||
input.tick, 2147483647,
|
||||
"int_32 asymmetry: tick=2^31-1"
|
||||
);
|
||||
assert_eq!(input.tick, 2147483647, "int_32 asymmetry: tick=2^31-1");
|
||||
}
|
||||
_ => {} // Other fixtures: deserialization success is sufficient
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user