feat(simulation): ban HashMap via clippy disallowed_types, fix violations (#343)
Adds server/.clippy.toml with disallowed-types for std::collections::HashMap and std::collections::HashSet. HashMap iteration order is non-deterministic and breaks deterministic simulation replay (D-030). Changes: - server/.clippy.toml: disallow HashMap and HashSet crate-wide - simulation/movement.rs: WalkabilityMap.chunks and occupied map → BTreeMap; add PartialOrd+Ord to ChunkCoord, TilePosition, TilePresence - simulation/monologue.rs: MonologueState.shown_ids → BTreeSet (simulation state) - perception/shadowcast.rs: #![allow] — per-frame FOV scratch, iteration irrelevant - perception/interpretation.rs: #![allow] — per-frame lookup table, key-only access - perception/query.rs: #![allow] — sector_lookup is a per-frame read-only cache Also applies cargo fmt to pre-existing format drift in contraband.rs, dialogue.rs, test_world/mod.rs, and several integration tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
//! Interprets what the observer sees (and doesn't see) against known NPC
|
||||
//! routines and knowledge graph state. Produces high-level observation events
|
||||
//! that drive monologue and investigation triggers.
|
||||
//!
|
||||
//! Note: HashSet is used as a per-frame lookup table (visible tiles/NPCs).
|
||||
//! Only membership checks — iteration order is irrelevant. Not simulation state.
|
||||
#![allow(clippy::disallowed_types)]
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
//! References:
|
||||
//! - Symmetric: https://www.albertford.com/shadowcasting/
|
||||
//! - Traditional: RogueBasin recursive shadowcasting
|
||||
//!
|
||||
//! Note: HashSet is used here as a per-frame scratch accumulator for visible
|
||||
//! tile positions during the FOV sweep. Only `insert` and `contains` are used;
|
||||
//! iteration order never affects the output (results are handed to BTreeSet in
|
||||
//! query.rs). Not simulation state — exempt from the determinism constraint.
|
||||
#![allow(clippy::disallowed_types)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
|
||||
@@ -80,7 +80,10 @@ impl ScanEventBuffer {
|
||||
pub fn check_contraband_scan(
|
||||
time: Res<SimulationTime>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut npc_query: Query<(Entity, &TilePosition, &mut KnowledgeGraph), (With<Npc>, With<ScanAuthority>)>,
|
||||
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>)>,
|
||||
) {
|
||||
@@ -423,7 +426,10 @@ mod tests {
|
||||
|
||||
let mut buffer = world.get_mut::<ScanEventBuffer>(player).unwrap();
|
||||
let events = buffer.take();
|
||||
assert!(events.is_empty(), "NPC without ScanAuthority should not scan");
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"NPC without ScanAuthority should not scan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -461,12 +467,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let npc = world
|
||||
.spawn((
|
||||
Npc,
|
||||
TilePosition::new(5, 6, 0),
|
||||
npc_kg,
|
||||
ScanAuthority,
|
||||
))
|
||||
.spawn((Npc, TilePosition::new(5, 6, 0), npc_kg, ScanAuthority))
|
||||
.id();
|
||||
world.resource_mut::<EntityRegistry>().register(npc);
|
||||
|
||||
@@ -477,12 +478,19 @@ mod tests {
|
||||
// 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");
|
||||
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_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"scan event should emit even for already-known contraband"
|
||||
);
|
||||
assert!(events[0].detected_contraband);
|
||||
}
|
||||
|
||||
@@ -542,7 +550,11 @@ mod tests {
|
||||
// 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_eq!(
|
||||
events.len(),
|
||||
2,
|
||||
"each ScanAuthority NPC should emit a scan event"
|
||||
);
|
||||
assert!(events.iter().all(|e| e.detected_contraband));
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ pub fn available_access_tiers(relationship: RelationshipState) -> Vec<AccessTier
|
||||
/// - 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).
|
||||
///
|
||||
/// Trust tier gates which dialogue lines are available. The layered gate
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// When sprinting past a Contradicted entity, a delayed "double-take" monologue
|
||||
// fires retroactively. Detection in observer pipeline, processing here.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use rand::Rng;
|
||||
@@ -77,7 +77,7 @@ pub struct MonologueState {
|
||||
/// Whether the enter_location monologue has fired this session.
|
||||
pub entered: bool,
|
||||
/// IDs of lines already shown (dedup within session).
|
||||
pub shown_ids: HashSet<String>,
|
||||
pub shown_ids: BTreeSet<String>,
|
||||
/// Character type for pool filtering. v0.1: always "detective".
|
||||
pub character: String,
|
||||
}
|
||||
@@ -89,7 +89,7 @@ impl Default for MonologueState {
|
||||
last_position: None,
|
||||
idle_ticks: 0,
|
||||
entered: false,
|
||||
shown_ids: HashSet::new(),
|
||||
shown_ids: BTreeSet::new(),
|
||||
// v0.1: default to detective; character selection sets this
|
||||
character: "detective".to_string(),
|
||||
}
|
||||
|
||||
@@ -23,7 +23,17 @@ pub struct PlayerCharacter;
|
||||
/// Examples: a Standing character can walk past a Seated NPC at a console,
|
||||
/// a Fixture (terminal) shares a tile with someone Seated at it.
|
||||
#[derive(
|
||||
Component, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize,
|
||||
Component,
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
)]
|
||||
pub enum TilePresence {
|
||||
@@ -42,7 +52,9 @@ pub enum TilePresence {
|
||||
/// Tile position component for grid-based movement.
|
||||
/// Discrete integer coordinates used in simulation; converted to f32
|
||||
/// at the bridge boundary for VisibleEntity wire format.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[derive(
|
||||
Component, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct TilePosition {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
|
||||
@@ -52,13 +52,13 @@ use crate::perception::cognitive_delay::CognitiveDelay;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::perception::vision_cone::Facing;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::contraband::ScanEventBuffer;
|
||||
#[cfg(feature = "gauntlet")]
|
||||
use crate::simulation::interaction::{Interactable, NearbyInteractionBuffer};
|
||||
#[cfg(feature = "gauntlet")]
|
||||
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};
|
||||
|
||||
@@ -21,7 +21,9 @@ 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::{KnowledgeConfidence, KnowledgeGraph, KnowledgePlugin, StableId};
|
||||
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;
|
||||
@@ -462,10 +464,8 @@ fn max_npc_pack_behavioral_regression() {
|
||||
.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");
|
||||
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!(
|
||||
|
||||
@@ -349,10 +349,7 @@ fn t4_knowledge_graph_survives_room_transition() {
|
||||
"T4 post: KG entry must persist after player moves to Hub"
|
||||
);
|
||||
assert_eq!(
|
||||
world
|
||||
.get::<KnowledgeGraph>(player)
|
||||
.unwrap()
|
||||
.entity_count(),
|
||||
world.get::<KnowledgeGraph>(player).unwrap().entity_count(),
|
||||
1,
|
||||
"T4 post: exactly 1 KG entry after room transition"
|
||||
);
|
||||
@@ -542,7 +539,10 @@ fn t7_confrontation_verb_disappears_on_retreat_beyond_mid_range() {
|
||||
"T7 close: NPC at distance 2 must appear in interaction buffer"
|
||||
);
|
||||
assert!(
|
||||
interactions[0].verbs.iter().any(|v| v.kind == VerbKind::Talk),
|
||||
interactions[0]
|
||||
.verbs
|
||||
.iter()
|
||||
.any(|v| v.kind == VerbKind::Talk),
|
||||
"T7 close: Talk must be available at CLOSE_RANGE (confrontation possible)"
|
||||
);
|
||||
|
||||
@@ -599,7 +599,10 @@ fn t8_sprint_blocks_eavesdrop_then_careful_enables_accumulation() {
|
||||
run_listening_system(&mut world);
|
||||
}
|
||||
assert_eq!(
|
||||
world.get::<ListeningFocus>(player).unwrap().stationary_ticks,
|
||||
world
|
||||
.get::<ListeningFocus>(player)
|
||||
.unwrap()
|
||||
.stationary_ticks,
|
||||
0,
|
||||
"T8 sprint: Sprint must block stationary_ticks (50 ticks at sprint, still 0)"
|
||||
);
|
||||
@@ -613,7 +616,10 @@ fn t8_sprint_blocks_eavesdrop_then_careful_enables_accumulation() {
|
||||
// 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,
|
||||
world
|
||||
.get::<ListeningFocus>(player)
|
||||
.unwrap()
|
||||
.stationary_ticks,
|
||||
0,
|
||||
"T8 transition: movement tick must reset stationary_ticks to 0"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user