Compare commits
5
Commits
c970d2eba7
...
e6bd577a5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6bd577a5a | ||
|
|
0c91f5af21 | ||
|
|
0157d1fa38 | ||
|
|
02fa89b1d7 | ||
|
|
6f9537fff3 |
@@ -7,6 +7,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Content validation tooling — `make validate-content` validates campaign YAML files against JSON schemas, maps files by directory context
|
||||
- Entity::to_bits() roundtrip test — guards against bevy version changes silently breaking wire IDs
|
||||
- TickRate switch mid-accumulation test — verifies Half→Full→Paused→Half transitions preserve accumulator state
|
||||
- Tick rate scaling system (#406, D-052) — Full/Half/Paused rates with fractional accumulation, SetTickRate player action, replaces binary pause flag
|
||||
- Proximity detection and interaction verbs (#404, D-060) — compute_nearby_interactions system with Manhattan distance ranges (close ≤2, mid ≤5), context-sensitive verb computation (Talk, Observe, Examine), PersonOfInterest priority flip, NearbyInteraction in ObserverSnapshot v4
|
||||
- Content directory skeleton (#385, D-057) — district-as-atomic-pack layout with Sova Transit first district, 17 NPC stubs, 3 locations, 5 triangles, dialogue/monologue pools, factions, knowledge catalogs, enum definitions
|
||||
@@ -20,6 +23,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Unregistered entities in observer/interaction now log tracing::error instead of silently falling back to Entity::to_bits()
|
||||
|
||||
### Changed
|
||||
- NearbyInteractionBuffer refactored from global Resource to per-entity Component on PlayerCharacter — multiplayer-ready (D-009)
|
||||
- Observer module split into mod.rs (244 lines) + tests.rs (480 lines) — reduces module complexity
|
||||
- Content directory restructured from flat districts/ to hierarchical campaigns/main/systems/krenn/stations/sova/districts/transit/ — path mirrors canonical IDs, glob-based discovery, multi-campaign/DLC ready
|
||||
- Content manifest (content.yaml) rewritten for glob-based district discovery
|
||||
- District identity fields (system, station, district) now derived from directory path — removed from district.yaml required fields
|
||||
|
||||
@@ -2,7 +2,7 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null)
|
||||
|
||||
.PHONY: help setup build client server test lint ci ci-client ci-server clean \
|
||||
decisions-sync decisions-coverage decisions-active decisions-orphan \
|
||||
db-backup db-install
|
||||
db-backup db-install validate-content
|
||||
|
||||
# --- Configuration ---
|
||||
|
||||
@@ -30,6 +30,7 @@ help:
|
||||
@echo " make decisions-coverage Decision-to-ticket coverage by domain"
|
||||
@echo " make decisions-active List active decisions"
|
||||
@echo " make decisions-orphan Decisions without implementing tickets"
|
||||
@echo " make validate-content Validate content YAML against schemas"
|
||||
@echo ""
|
||||
@echo " GODOT_VERSION=4.6 make setup Override Godot version"
|
||||
|
||||
@@ -127,6 +128,11 @@ decisions-active:
|
||||
decisions-orphan:
|
||||
@db/connectors/sqlite-query "SELECT id, title FROM decisions WHERE type='confirmed' AND status='active' AND id NOT IN (SELECT DISTINCT decision_ref FROM tickets WHERE decision_ref IS NOT NULL)"
|
||||
|
||||
# --- Content Validation ---
|
||||
|
||||
validate-content:
|
||||
@tooling/validate-content
|
||||
|
||||
# --- Clean ---
|
||||
|
||||
clean:
|
||||
|
||||
@@ -14,6 +14,7 @@ use settled_reach_server::npc::{
|
||||
Want, WantKind,
|
||||
};
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::path_follow::MovementSpeed;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
@@ -68,6 +69,7 @@ fn main() {
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
@@ -199,7 +199,6 @@ mod tests {
|
||||
world.init_resource::<crate::knowledge::KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<ObservationEventQueue>();
|
||||
world.init_resource::<crate::simulation::interaction::NearbyInteractionBuffer>();
|
||||
world
|
||||
}
|
||||
|
||||
@@ -235,6 +234,7 @@ mod tests {
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
@@ -285,6 +285,7 @@ mod tests {
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
@@ -355,6 +356,7 @@ mod tests {
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
@@ -387,6 +389,7 @@ mod tests {
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(), // Empty — never seen anyone
|
||||
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
@@ -433,6 +436,7 @@ mod tests {
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
@@ -101,7 +101,6 @@ mod tests {
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<KnowledgeEventQueue>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<crate::simulation::interaction::NearbyInteractionBuffer>();
|
||||
world
|
||||
}
|
||||
|
||||
@@ -116,6 +115,7 @@ mod tests {
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
@@ -159,6 +159,7 @@ mod tests {
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
@@ -195,6 +196,7 @@ mod tests {
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
crate::simulation::interaction::NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
@@ -1,798 +0,0 @@
|
||||
//! Observer visibility query system (#112)
|
||||
//!
|
||||
//! Replaces the unfiltered `generate_snapshot` with a visibility-aware version.
|
||||
//! Combines shadowcasting + vision cone to determine what the observer can see,
|
||||
//! then populates ObserverSnapshot v2 with only visible entities and tiles.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::shadowcast::compute_fov;
|
||||
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// Compute observer snapshot with LOS filtering and vision cone.
|
||||
///
|
||||
/// System ordering: after validate_movement, before advance_tick.
|
||||
/// Replaces bridge::generate_snapshot.
|
||||
pub fn compute_observer_snapshot(
|
||||
time: Res<SimulationTime>,
|
||||
walkability: Res<WalkabilityMap>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut interaction_buffer: ResMut<NearbyInteractionBuffer>,
|
||||
observer_query: Query<(&TilePosition, Option<&Facing>, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
all_entities: Query<(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
)>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
) {
|
||||
let Ok((observer_pos, facing_opt, observer_kg)) = observer_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let facing = facing_opt
|
||||
.map(|f| f.0)
|
||||
.unwrap_or(FacingDirection::default());
|
||||
|
||||
let config = VisionConeConfig::default();
|
||||
let z = observer_pos.z;
|
||||
|
||||
// Step 1: Compute raw FOV using symmetric shadowcasting
|
||||
let fov = compute_fov(
|
||||
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
|
||||
observer_pos.x,
|
||||
observer_pos.y,
|
||||
config.forward_range,
|
||||
z,
|
||||
);
|
||||
|
||||
// Step 2: Apply vision cone to get sector-tagged tiles
|
||||
let cone_tiles =
|
||||
apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config);
|
||||
|
||||
// Step 3: Build visible_tiles for the snapshot
|
||||
let visible_tiles: Vec<VisibleTile> = cone_tiles
|
||||
.iter()
|
||||
.map(|&(x, y, sector)| VisibleTile {
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
visibility: sector,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Step 4: Build lookup set for fast entity visibility check
|
||||
let visible_positions: HashSet<(i32, i32)> =
|
||||
cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect();
|
||||
|
||||
// Build sector lookup (position -> sector)
|
||||
let sector_lookup: std::collections::HashMap<(i32, i32), VisibilitySector> = cone_tiles
|
||||
.iter()
|
||||
.map(|&(x, y, sector)| ((x, y), sector))
|
||||
.collect();
|
||||
|
||||
// Step 5: Filter entities by visibility, overlay knowledge
|
||||
let mut entities = Vec::new();
|
||||
let mut visible_entity_bits: HashSet<u64> = HashSet::new();
|
||||
for (entity, pos, is_player, is_npc) in all_entities.iter() {
|
||||
// Different z-level: not visible
|
||||
if pos.z != z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not in visible tile set: not visible
|
||||
if !visible_positions.contains(&(pos.x, pos.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (rx, ry, rz) = pos.to_render_coords();
|
||||
let kind = if is_player.is_some() {
|
||||
EntityKind::Player
|
||||
} else if is_npc.is_some() {
|
||||
EntityKind::Npc
|
||||
} else {
|
||||
EntityKind::Object
|
||||
};
|
||||
|
||||
let sector = sector_lookup
|
||||
.get(&(pos.x, pos.y))
|
||||
.copied()
|
||||
.unwrap_or(VisibilitySector::Peripheral);
|
||||
|
||||
// Look up relationship from knowledge graph (D-033 entity color)
|
||||
let relationship = if is_player.is_some() {
|
||||
RelationshipState::Known // Self
|
||||
} else if let Some(stable_id) = registry.to_stable(entity) {
|
||||
observer_kg.relationship_with(&stable_id)
|
||||
} else {
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::error!(?entity, "entity visible but not in EntityRegistry");
|
||||
entity.to_bits()
|
||||
});
|
||||
visible_entity_bits.insert(wire_id);
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: wire_id,
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
kind,
|
||||
visibility: sector,
|
||||
relationship,
|
||||
observation: EntityVisibility::Visible,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Add remembered entities from knowledge graph (#366)
|
||||
collect_remembered_entities(
|
||||
observer_kg,
|
||||
&visible_entity_bits,
|
||||
&visible_positions,
|
||||
z,
|
||||
time.tick,
|
||||
&mut entities,
|
||||
);
|
||||
|
||||
// Step 7: Build GameTime from SimulationTime
|
||||
let game_time = GameTime {
|
||||
day: time.day(),
|
||||
time_of_day: time.time_of_day_minutes(),
|
||||
day_phase: time.day_phase(),
|
||||
tick_rate: time.tick_rate,
|
||||
};
|
||||
|
||||
tracing::trace!(
|
||||
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
|
||||
time.tick,
|
||||
visible_entity_bits.len(),
|
||||
entities.len() - visible_entity_bits.len(),
|
||||
visible_tiles.len(),
|
||||
);
|
||||
|
||||
// Step 8: Assemble snapshot (v4: added nearby_interactions)
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: facing,
|
||||
entities,
|
||||
visible_tiles,
|
||||
nearby_interactions: interaction_buffer.take(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Collect remembered entities from the knowledge graph — entities the observer
|
||||
/// knows about but can't currently see. Filters out: already-visible entities,
|
||||
/// entities without known positions, wrong z-level, visible-tile ghosts, and
|
||||
/// transient Direct-confidence inconsistencies.
|
||||
fn collect_remembered_entities(
|
||||
observer_kg: &KnowledgeGraph,
|
||||
visible_ids: &HashSet<u64>,
|
||||
visible_positions: &HashSet<(i32, i32)>,
|
||||
observer_z: i32,
|
||||
current_tick: u64,
|
||||
entities: &mut Vec<VisibleEntity>,
|
||||
) {
|
||||
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
|
||||
if visible_ids.contains(&stable_id.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(position) = knowledge.last_known_position else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if position.z != observer_z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tile is visible but entity isn't there — player knows it moved
|
||||
if visible_positions.contains(&(position.x, position.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Direct confidence = should be in LOS; skip transient inconsistency
|
||||
if knowledge.confidence == KnowledgeConfidence::Direct {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (rx, ry, rz) = position.to_render_coords();
|
||||
debug_assert!(
|
||||
knowledge.last_observed_tick <= current_tick,
|
||||
"last_observed_tick {} > current tick {}",
|
||||
knowledge.last_observed_tick,
|
||||
current_tick,
|
||||
);
|
||||
let age_ticks = current_tick.saturating_sub(knowledge.last_observed_tick);
|
||||
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: stable_id.0,
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: knowledge.relationship,
|
||||
observation: EntityVisibility::Remembered {
|
||||
confidence: knowledge.confidence,
|
||||
age_ticks,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
/// Helper: set up a test world with player, walkability map, and knowledge resources
|
||||
fn setup_world(width: i32, height: i32) -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.insert_resource(WalkabilityMap::new(width, height, 1));
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<NearbyInteractionBuffer>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_always_visible_in_snapshot() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
assert_eq!(snapshot.version, 4);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
|
||||
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_in_los_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// NPC directly north of player (in forward cone)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.entities.len(), 2);
|
||||
let npc = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible");
|
||||
assert_eq!(npc.visibility, VisibilitySector::Forward);
|
||||
assert_eq!(npc.observation, EntityVisibility::Visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_wall_not_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Wall between player and NPC
|
||||
let mut walkability = world.resource_mut::<WalkabilityMap>();
|
||||
walkability.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
// NPC behind the wall
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
// Only player should be visible, not the NPC behind the wall
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert!(npcs.is_empty(), "NPC behind wall should not be visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_player_not_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// NPC far behind player (south, in blind spot)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert!(npcs.is_empty(), "NPC in blind spot should not be visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_z_level_not_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// NPC on different z-level
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert!(npcs.is_empty(), "NPC on different z should not be visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_time_populated() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 7200; // 720 minutes = Evening
|
||||
time.tick_rate = crate::simulation::time::TickRate::Paused;
|
||||
world.insert_resource(time);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.game_time.time_of_day, 720);
|
||||
assert_eq!(
|
||||
snapshot.game_time.day_phase,
|
||||
crate::simulation::time::DayPhase::Evening
|
||||
);
|
||||
assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_tiles_populated() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
!snapshot.visible_tiles.is_empty(),
|
||||
"should have visible tiles"
|
||||
);
|
||||
// Observer's tile should be in the list
|
||||
let has_observer_tile = snapshot
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.any(|t| t.x == 16 && t.y == 16 && t.z == 0);
|
||||
assert!(has_observer_tile, "observer tile should be visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_npc_has_relationship_from_knowledge() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows NPC is hostile
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
));
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible");
|
||||
assert_eq!(npc_entity.relationship, RelationshipState::Hostile);
|
||||
assert_eq!(npc_entity.observation, EntityVisibility::Visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_entity_appears_as_ghost() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC exists far behind the player (not visible)
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 30, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player previously saw NPC at (16, 28) — behind the player (south),
|
||||
// well beyond peripheral range. The tile is NOT in the player's FOV.
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 28, 0), 50);
|
||||
kg.observe_entity_leaving_los(&npc_sid, 60);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
// Should have player (visible) + NPC (remembered)
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert_eq!(remembered.len(), 1, "should have one remembered entity");
|
||||
assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest);
|
||||
|
||||
// Remembered entity at last_known_position (16, 28), not actual (16, 30)
|
||||
assert_eq!(remembered[0].x, 16.5);
|
||||
assert_eq!(remembered[0].y, 28.5);
|
||||
|
||||
if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation {
|
||||
assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails);
|
||||
assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_confidence_not_shown_as_remembered() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC exists but not in LOS
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 10, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Knowledge still shows Direct (transient inconsistency)
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
// Still Direct — don't show as ghost
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// Wall blocks actual NPC position
|
||||
let mut walkability = world.resource_mut::<WalkabilityMap>();
|
||||
walkability.set_walkable(&TilePosition::new(16, 12, 0), false);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
remembered.is_empty(),
|
||||
"Direct-confidence entities should not appear as remembered ghosts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_entity_on_visible_tile_not_shown() {
|
||||
// If the player can see a tile and the entity isn't there,
|
||||
// don't show a ghost — the player knows it moved.
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(30, 30, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player remembers NPC at (16, 15) — a tile the player can currently see
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
|
||||
kg.observe_entity_leaving_los(&npc_sid, 60);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
remembered.is_empty(),
|
||||
"ghost should not appear on a tile the player can currently see"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_entity_different_z_not_shown() {
|
||||
// Remembered entity on a different z-level should not appear
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 1)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player remembers NPC at z=1, but player is at z=0
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 1), 50);
|
||||
kg.observe_entity_leaving_los(&npc_sid, 60);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
remembered.is_empty(),
|
||||
"remembered entity on different z-level should not appear in snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_without_position_not_shown() {
|
||||
// Entity known via gossip (no last_known_position) should not appear
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows about NPC but has never seen it (no position)
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
// Insert knowledge manually without a position
|
||||
kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge {
|
||||
last_known_position: None,
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 50,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: crate::knowledge::KnowledgeSource::Background,
|
||||
state: crate::knowledge::KnowledgeState::Active,
|
||||
relationship: RelationshipState::PersonOfInterest,
|
||||
known_attributes: std::collections::BTreeMap::new(),
|
||||
});
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
remembered.is_empty(),
|
||||
"entity without last_known_position should not appear as ghost"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_npcs_in_los_all_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Three NPCs in front of player, no walls
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
// Player + 3 NPCs = 4 entities
|
||||
assert_eq!(snapshot.entities.len(), 4);
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert_eq!(npcs.len(), 3);
|
||||
assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
|
||||
let mut world = setup_world(32, 32);
|
||||
// Wall at (16,14)
|
||||
world
|
||||
.resource_mut::<WalkabilityMap>()
|
||||
.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// NPC 1: behind wall (should be hidden)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0)));
|
||||
// NPC 2: to the side, no wall (should be visible)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
|
||||
// NPC 3: also visible
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
// Player + 2 visible NPCs = 3 (NPC behind wall excluded)
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Observer visibility query system (#112)
|
||||
//!
|
||||
//! Replaces the unfiltered `generate_snapshot` with a visibility-aware version.
|
||||
//! Combines shadowcasting + vision cone to determine what the observer can see,
|
||||
//! then populates ObserverSnapshot v2 with only visible entities and tiles.
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::bridge::types::*;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::shadowcast::compute_fov;
|
||||
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||||
use crate::simulation::interaction::NearbyInteractionBuffer;
|
||||
use crate::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
|
||||
/// Compute observer snapshot with LOS filtering and vision cone.
|
||||
///
|
||||
/// System ordering: after validate_movement, before advance_tick.
|
||||
/// Replaces bridge::generate_snapshot.
|
||||
pub fn compute_observer_snapshot(
|
||||
time: Res<SimulationTime>,
|
||||
walkability: Res<WalkabilityMap>,
|
||||
registry: Res<EntityRegistry>,
|
||||
mut observer_query: Query<
|
||||
(&TilePosition, Option<&Facing>, &KnowledgeGraph, &mut NearbyInteractionBuffer),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
all_entities: Query<(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
)>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
) {
|
||||
let Ok((observer_pos, facing_opt, observer_kg, mut interaction_buffer)) =
|
||||
observer_query.single_mut()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let facing = facing_opt
|
||||
.map(|f| f.0)
|
||||
.unwrap_or(FacingDirection::default());
|
||||
|
||||
let config = VisionConeConfig::default();
|
||||
let z = observer_pos.z;
|
||||
|
||||
// Step 1: Compute raw FOV using symmetric shadowcasting
|
||||
let fov = compute_fov(
|
||||
|x, y| !walkability.can_move_to(&TilePosition::new(x, y, z)),
|
||||
observer_pos.x,
|
||||
observer_pos.y,
|
||||
config.forward_range,
|
||||
z,
|
||||
);
|
||||
|
||||
// Step 2: Apply vision cone to get sector-tagged tiles
|
||||
let cone_tiles =
|
||||
apply_vision_cone(&fov, observer_pos.x, observer_pos.y, facing, &config);
|
||||
|
||||
// Step 3: Build visible_tiles for the snapshot
|
||||
let visible_tiles: Vec<VisibleTile> = cone_tiles
|
||||
.iter()
|
||||
.map(|&(x, y, sector)| VisibleTile {
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
visibility: sector,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Step 4: Build lookup set for fast entity visibility check
|
||||
let visible_positions: HashSet<(i32, i32)> =
|
||||
cone_tiles.iter().map(|&(x, y, _)| (x, y)).collect();
|
||||
|
||||
// Build sector lookup (position -> sector)
|
||||
let sector_lookup: std::collections::HashMap<(i32, i32), VisibilitySector> = cone_tiles
|
||||
.iter()
|
||||
.map(|&(x, y, sector)| ((x, y), sector))
|
||||
.collect();
|
||||
|
||||
// Step 5: Filter entities by visibility, overlay knowledge
|
||||
let mut entities = Vec::new();
|
||||
let mut visible_entity_bits: HashSet<u64> = HashSet::new();
|
||||
for (entity, pos, is_player, is_npc) in all_entities.iter() {
|
||||
// Different z-level: not visible
|
||||
if pos.z != z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not in visible tile set: not visible
|
||||
if !visible_positions.contains(&(pos.x, pos.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (rx, ry, rz) = pos.to_render_coords();
|
||||
let kind = if is_player.is_some() {
|
||||
EntityKind::Player
|
||||
} else if is_npc.is_some() {
|
||||
EntityKind::Npc
|
||||
} else {
|
||||
EntityKind::Object
|
||||
};
|
||||
|
||||
let sector = sector_lookup
|
||||
.get(&(pos.x, pos.y))
|
||||
.copied()
|
||||
.unwrap_or(VisibilitySector::Peripheral);
|
||||
|
||||
// Look up relationship from knowledge graph (D-033 entity color)
|
||||
let relationship = if is_player.is_some() {
|
||||
RelationshipState::Known // Self
|
||||
} else if let Some(stable_id) = registry.to_stable(entity) {
|
||||
observer_kg.relationship_with(&stable_id)
|
||||
} else {
|
||||
RelationshipState::Unknown
|
||||
};
|
||||
|
||||
// Fallback to Entity::to_bits() is intentional for per-frame systems:
|
||||
// panicking would crash the server every tick. The error log makes this
|
||||
// loud enough to catch in testing while keeping the server alive.
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::error!(?entity, "entity visible but not in EntityRegistry");
|
||||
entity.to_bits()
|
||||
});
|
||||
visible_entity_bits.insert(wire_id);
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: wire_id,
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
kind,
|
||||
visibility: sector,
|
||||
relationship,
|
||||
observation: EntityVisibility::Visible,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Add remembered entities from knowledge graph (#366)
|
||||
collect_remembered_entities(
|
||||
observer_kg,
|
||||
&visible_entity_bits,
|
||||
&visible_positions,
|
||||
z,
|
||||
time.tick,
|
||||
&mut entities,
|
||||
);
|
||||
|
||||
// Step 7: Build GameTime from SimulationTime
|
||||
let game_time = GameTime {
|
||||
day: time.day(),
|
||||
time_of_day: time.time_of_day_minutes(),
|
||||
day_phase: time.day_phase(),
|
||||
tick_rate: time.tick_rate,
|
||||
};
|
||||
|
||||
tracing::trace!(
|
||||
"compute_observer_snapshot: tick={}, visible={}, remembered={}, tiles={}",
|
||||
time.tick,
|
||||
visible_entity_bits.len(),
|
||||
entities.len() - visible_entity_bits.len(),
|
||||
visible_tiles.len(),
|
||||
);
|
||||
|
||||
// Step 8: Assemble snapshot (v4: added nearby_interactions)
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: crate::bridge::types::PROTOCOL_VERSION,
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: facing,
|
||||
entities,
|
||||
visible_tiles,
|
||||
nearby_interactions: interaction_buffer.take(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Collect remembered entities from the knowledge graph — entities the observer
|
||||
/// knows about but can't currently see. Filters out: already-visible entities,
|
||||
/// entities without known positions, wrong z-level, visible-tile ghosts, and
|
||||
/// transient Direct-confidence inconsistencies.
|
||||
fn collect_remembered_entities(
|
||||
observer_kg: &KnowledgeGraph,
|
||||
visible_ids: &HashSet<u64>,
|
||||
visible_positions: &HashSet<(i32, i32)>,
|
||||
observer_z: i32,
|
||||
current_tick: u64,
|
||||
entities: &mut Vec<VisibleEntity>,
|
||||
) {
|
||||
for (stable_id, knowledge) in observer_kg.known_entities_iter() {
|
||||
if visible_ids.contains(&stable_id.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(position) = knowledge.last_known_position else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if position.z != observer_z {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tile is visible but entity isn't there — player knows it moved
|
||||
if visible_positions.contains(&(position.x, position.y)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Direct confidence = should be in LOS; skip transient inconsistency
|
||||
if knowledge.confidence == KnowledgeConfidence::Direct {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (rx, ry, rz) = position.to_render_coords();
|
||||
debug_assert!(
|
||||
knowledge.last_observed_tick <= current_tick,
|
||||
"last_observed_tick {} > current tick {}",
|
||||
knowledge.last_observed_tick,
|
||||
current_tick,
|
||||
);
|
||||
let age_ticks = current_tick.saturating_sub(knowledge.last_observed_tick);
|
||||
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: stable_id.0,
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
relationship: knowledge.relationship,
|
||||
observation: EntityVisibility::Remembered {
|
||||
confidence: knowledge.confidence,
|
||||
age_ticks,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,574 @@
|
||||
use super::*;
|
||||
use crate::knowledge::{EntityRegistry, KnowledgeGraph};
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
/// Helper: set up a test world with player, walkability map, and knowledge resources
|
||||
fn setup_world(width: i32, height: i32) -> World {
|
||||
let mut world = World::new();
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.insert_resource(WalkabilityMap::new(width, height, 1));
|
||||
world.init_resource::<SnapshotBuffer>();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_always_visible_in_snapshot() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().expect("snapshot should exist");
|
||||
assert_eq!(snapshot.version, 4);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
|
||||
assert_eq!(snapshot.entities[0].observation, EntityVisibility::Visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_in_los_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
// NPC directly north of player (in forward cone)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.entities.len(), 2);
|
||||
let npc = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible");
|
||||
assert_eq!(npc.visibility, VisibilitySector::Forward);
|
||||
assert_eq!(npc.observation, EntityVisibility::Visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_wall_not_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
// Wall between player and NPC
|
||||
let mut walkability = world.resource_mut::<WalkabilityMap>();
|
||||
walkability.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
// NPC behind the wall
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 12, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
// Only player should be visible, not the NPC behind the wall
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert!(npcs.is_empty(), "NPC behind wall should not be visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_player_not_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
// NPC far behind player (south, in blind spot)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 26, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert!(npcs.is_empty(), "NPC in blind spot should not be visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_z_level_not_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
// NPC on different z-level
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 1)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert!(npcs.is_empty(), "NPC on different z should not be visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_time_populated() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut time = SimulationTime::default();
|
||||
time.tick = 7200; // 720 minutes = Evening
|
||||
time.tick_rate = crate::simulation::time::TickRate::Paused;
|
||||
world.insert_resource(time);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert_eq!(snapshot.game_time.time_of_day, 720);
|
||||
assert_eq!(
|
||||
snapshot.game_time.day_phase,
|
||||
crate::simulation::time::DayPhase::Evening
|
||||
);
|
||||
assert_eq!(snapshot.game_time.tick_rate, crate::simulation::time::TickRate::Paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_tiles_populated() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::default(),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
assert!(
|
||||
!snapshot.visible_tiles.is_empty(),
|
||||
"should have visible tiles"
|
||||
);
|
||||
// Observer's tile should be in the list
|
||||
let has_observer_tile = snapshot
|
||||
.visible_tiles
|
||||
.iter()
|
||||
.any(|t| t.x == 16 && t.y == 16 && t.z == 0);
|
||||
assert!(has_observer_tile, "observer tile should be visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_npc_has_relationship_from_knowledge() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows NPC is hostile
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::Hostile);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
let npc_entity = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.find(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.expect("NPC should be visible");
|
||||
assert_eq!(npc_entity.relationship, RelationshipState::Hostile);
|
||||
assert_eq!(npc_entity.observation, EntityVisibility::Visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_entity_appears_as_ghost() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC exists far behind the player (not visible)
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 30, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player previously saw NPC at (16, 28) — behind the player (south),
|
||||
// well beyond peripheral range. The tile is NOT in the player's FOV.
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 28, 0), 50);
|
||||
kg.observe_entity_leaving_los(&npc_sid, 60);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
world.insert_resource({ let mut t = SimulationTime::default(); t.tick = 100; t });
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
// Should have player (visible) + NPC (remembered)
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert_eq!(remembered.len(), 1, "should have one remembered entity");
|
||||
assert_eq!(remembered[0].relationship, RelationshipState::PersonOfInterest);
|
||||
|
||||
// Remembered entity at last_known_position (16, 28), not actual (16, 30)
|
||||
assert_eq!(remembered[0].x, 16.5);
|
||||
assert_eq!(remembered[0].y, 28.5);
|
||||
|
||||
if let EntityVisibility::Remembered { confidence, age_ticks } = &remembered[0].observation {
|
||||
assert_eq!(*confidence, KnowledgeConfidence::KnowsDetails);
|
||||
assert_eq!(*age_ticks, 50); // tick 100 - last_observed 50
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_confidence_not_shown_as_remembered() {
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
// NPC exists but not in LOS
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(16, 10, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Knowledge still shows Direct (transient inconsistency)
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 14, 0), 50);
|
||||
// Still Direct — don't show as ghost
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
|
||||
// Wall blocks actual NPC position
|
||||
let mut walkability = world.resource_mut::<WalkabilityMap>();
|
||||
walkability.set_walkable(&TilePosition::new(16, 12, 0), false);
|
||||
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
remembered.is_empty(),
|
||||
"Direct-confidence entities should not appear as remembered ghosts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_entity_on_visible_tile_not_shown() {
|
||||
// If the player can see a tile and the entity isn't there,
|
||||
// don't show a ghost — the player knows it moved.
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(30, 30, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player remembers NPC at (16, 15) — a tile the player can currently see
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(16, 15, 0), 50);
|
||||
kg.observe_entity_leaving_los(&npc_sid, 60);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
remembered.is_empty(),
|
||||
"ghost should not appear on a tile the player can currently see"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembered_entity_different_z_not_shown() {
|
||||
// Remembered entity on a different z-level should not appear
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 1)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player remembers NPC at z=1, but player is at z=0
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 5, 1), 50);
|
||||
kg.observe_entity_leaving_los(&npc_sid, 60);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
remembered.is_empty(),
|
||||
"remembered entity on different z-level should not appear in snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_without_position_not_shown() {
|
||||
// Entity known via gossip (no last_known_position) should not appear
|
||||
let mut world = setup_world(32, 32);
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
let npc = world
|
||||
.spawn((crate::npc::Npc, TilePosition::new(5, 5, 0)))
|
||||
.id();
|
||||
let npc_sid = registry.register(npc);
|
||||
|
||||
// Player knows about NPC but has never seen it (no position)
|
||||
let mut kg = KnowledgeGraph::new();
|
||||
// Insert knowledge manually without a position
|
||||
kg.entities.insert(npc_sid, crate::knowledge::EntityKnowledge {
|
||||
last_known_position: None,
|
||||
last_observed_tick: 0,
|
||||
last_updated_tick: 50,
|
||||
confidence: KnowledgeConfidence::KnowsOf,
|
||||
source: crate::knowledge::KnowledgeSource::Background,
|
||||
state: crate::knowledge::KnowledgeState::Active,
|
||||
relationship: RelationshipState::PersonOfInterest,
|
||||
known_attributes: std::collections::BTreeMap::new(),
|
||||
});
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id();
|
||||
registry.register(player);
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
|
||||
let remembered: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.observation, EntityVisibility::Remembered { .. }))
|
||||
.collect();
|
||||
assert!(
|
||||
remembered.is_empty(),
|
||||
"entity without last_known_position should not appear as ghost"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_npcs_in_los_all_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
// Three NPCs in front of player, no walls
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 14, 0)));
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(18, 14, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
// Player + 3 NPCs = 4 entities
|
||||
assert_eq!(snapshot.entities.len(), 4);
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert_eq!(npcs.len(), 3);
|
||||
assert!(npcs.iter().all(|n| n.observation == EntityVisibility::Visible));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_behind_wall_excluded_from_multi_entity_snapshot() {
|
||||
let mut world = setup_world(32, 32);
|
||||
// Wall at (16,14)
|
||||
world
|
||||
.resource_mut::<WalkabilityMap>()
|
||||
.set_walkable(&TilePosition::new(16, 14, 0), false);
|
||||
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
// NPC 1: behind wall (should be hidden)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(16, 13, 0)));
|
||||
// NPC 2: to the side, no wall (should be visible)
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(14, 14, 0)));
|
||||
// NPC 3: also visible
|
||||
world.spawn((crate::npc::Npc, TilePosition::new(18, 15, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_observer_snapshot);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<SnapshotBuffer>();
|
||||
let snapshot = buffer.snapshot.as_ref().unwrap();
|
||||
// Player + 2 visible NPCs = 3 (NPC behind wall excluded)
|
||||
let npcs: Vec<_> = snapshot
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, EntityKind::Npc))
|
||||
.collect();
|
||||
assert_eq!(npcs.len(), 2, "NPC behind wall should be excluded");
|
||||
}
|
||||
@@ -28,19 +28,20 @@ pub struct Interactable;
|
||||
/// is deferred until the interaction system can read the observer's visible set.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn compute_nearby_interactions(
|
||||
player_query: Query<(&TilePosition, &KnowledgeGraph), With<PlayerCharacter>>,
|
||||
mut player_query: Query<
|
||||
(&TilePosition, &KnowledgeGraph, &mut NearbyInteractionBuffer),
|
||||
With<PlayerCharacter>,
|
||||
>,
|
||||
registry: Res<EntityRegistry>,
|
||||
interactables: Query<
|
||||
(Entity, &TilePosition, Option<&Npc>),
|
||||
(With<Interactable>, Without<PlayerCharacter>),
|
||||
>,
|
||||
mut buffer: ResMut<NearbyInteractionBuffer>,
|
||||
) {
|
||||
buffer.interactions.clear();
|
||||
|
||||
let Ok((player_pos, knowledge)) = player_query.single() else {
|
||||
let Ok((player_pos, knowledge, mut buffer)) = player_query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
buffer.interactions.clear();
|
||||
|
||||
for (entity, pos, is_npc) in interactables.iter() {
|
||||
let Some(distance) = player_pos.manhattan_distance(pos) else {
|
||||
@@ -131,6 +132,9 @@ pub fn compute_nearby_interactions(
|
||||
// Sort by priority (lower = higher), then by kind discriminant for stability
|
||||
verbs.sort_by_key(|v| (v.priority, v.kind as u8));
|
||||
|
||||
// Fallback to Entity::to_bits() is intentional for per-frame systems:
|
||||
// panicking would crash the server every tick. The error log makes this
|
||||
// loud enough to catch in testing while keeping the server alive.
|
||||
let wire_id = registry
|
||||
.to_stable(entity)
|
||||
.map(|sid| sid.0)
|
||||
@@ -156,9 +160,9 @@ pub fn compute_nearby_interactions(
|
||||
/// Buffer for nearby interaction results, consumed by snapshot generation.
|
||||
/// Field is private — use `take()` to drain results into the snapshot.
|
||||
///
|
||||
/// Global Resource — single-observer assumption (v0.1). D-009 multiplayer
|
||||
/// will refactor the entire observer + interaction pipeline to per-entity.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
/// Per-entity Component attached to the PlayerCharacter. Each observer gets
|
||||
/// their own interaction buffer, so D-009 multiplayer works without refactoring.
|
||||
#[derive(Component, Debug, Default)]
|
||||
pub struct NearbyInteractionBuffer {
|
||||
interactions: Vec<NearbyInteraction>,
|
||||
}
|
||||
@@ -180,28 +184,40 @@ mod tests {
|
||||
fn setup_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EntityRegistry>();
|
||||
world.init_resource::<NearbyInteractionBuffer>();
|
||||
world
|
||||
}
|
||||
|
||||
/// Spawn player with standard components + NearbyInteractionBuffer
|
||||
fn spawn_player(world: &mut World, x: i32, y: i32) -> Entity {
|
||||
world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(x, y, 0),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
/// Read the player's NearbyInteractionBuffer component
|
||||
fn read_buffer(world: &mut World) -> &NearbyInteractionBuffer {
|
||||
let mut query = world.query_filtered::<&NearbyInteractionBuffer, With<PlayerCharacter>>();
|
||||
query.single(world).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_in_close_range_gets_talk_and_observe() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
// Talk should be priority 1 (default, not POI)
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
|
||||
}
|
||||
@@ -209,19 +225,14 @@ mod tests {
|
||||
#[test]
|
||||
fn npc_in_mid_range_gets_observe_only() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Distance 4 (mid range, beyond close)
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
|
||||
@@ -230,19 +241,14 @@ mod tests {
|
||||
#[test]
|
||||
fn npc_out_of_range_no_interactions() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Distance 8 (beyond mid range)
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 13, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
@@ -260,16 +266,20 @@ mod tests {
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 6, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg));
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
// Observe should be priority 1 for POI NPC
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::Talk);
|
||||
}
|
||||
@@ -277,19 +287,14 @@ mod tests {
|
||||
#[test]
|
||||
fn object_in_close_range_gets_examine() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Object (no Npc component) at close range
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((TilePosition::new(5, 6, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineObject);
|
||||
@@ -298,39 +303,29 @@ mod tests {
|
||||
#[test]
|
||||
fn different_z_level_no_interactions() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 1), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_entities_sorted_by_distance() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// Farther NPC
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 9, 0), Interactable));
|
||||
// Closer NPC
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 2);
|
||||
assert!(buffer.interactions[0].distance < buffer.interactions[1].distance);
|
||||
}
|
||||
@@ -338,25 +333,19 @@ mod tests {
|
||||
#[test]
|
||||
fn non_interactable_entity_ignored() {
|
||||
let mut world = setup_world();
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
// NPC without Interactable component
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0)));
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert!(buffer.interactions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poi_npc_at_mid_range_gets_observe_only() {
|
||||
// POI priority flip only applies at close range — mid range always Observe-only
|
||||
let mut world = setup_world();
|
||||
let mut registry = EntityRegistry::new(0);
|
||||
|
||||
@@ -369,28 +358,29 @@ mod tests {
|
||||
kg.observe_entity(npc_sid, TilePosition::new(5, 9, 0), 50);
|
||||
kg.set_relationship(&npc_sid, RelationshipState::PersonOfInterest);
|
||||
|
||||
world.spawn((PlayerCharacter, TilePosition::new(5, 5, 0), kg));
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
kg,
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
world.insert_resource(registry);
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::ExamineNpc);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].priority, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equidistant_npcs_sorted_deterministically() {
|
||||
let mut world = setup_world();
|
||||
// Two NPCs at equal distance (1 tile each)
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
KnowledgeGraph::new(),
|
||||
));
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(6, 5, 0), Interactable));
|
||||
world.spawn((Npc, TilePosition::new(4, 5, 0), Interactable));
|
||||
|
||||
@@ -398,9 +388,8 @@ mod tests {
|
||||
schedule.add_systems(compute_nearby_interactions);
|
||||
schedule.run(&mut world);
|
||||
|
||||
let buffer = world.resource::<NearbyInteractionBuffer>();
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 2);
|
||||
// Both at distance 1 — order should be stable across runs
|
||||
assert_eq!(buffer.interactions[0].distance, buffer.interactions[1].distance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ impl Plugin for SimulationPlugin {
|
||||
app.init_resource::<time::SimulationTime>()
|
||||
.insert_resource(rng::SimRng::new(0))
|
||||
.init_resource::<input::InputQueue>()
|
||||
.init_resource::<interaction::NearbyInteractionBuffer>()
|
||||
.init_resource::<crate::knowledge::EntityRegistry>()
|
||||
.add_systems(
|
||||
Update,
|
||||
|
||||
@@ -199,6 +199,37 @@ mod tests {
|
||||
assert_eq!(time.day(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_rate_switch_mid_accumulation() {
|
||||
// Half->Full with 0.5 remainder: Full should tick immediately (0.5 + 1.0 >= 1.0)
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(SimulationTime { tick_rate: TickRate::Half, ..Default::default() });
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(advance_tick);
|
||||
|
||||
// Frame 1: Half rate, accumulate 0.5, no tick
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 0);
|
||||
|
||||
// Switch to Full mid-accumulation (0.5 remainder)
|
||||
world.resource_mut::<SimulationTime>().tick_rate = TickRate::Full;
|
||||
|
||||
// Frame 2: Full rate adds 1.0 to 0.5 remainder → tick fires
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 1);
|
||||
|
||||
// Switch to Paused: no advance regardless of accumulator
|
||||
world.resource_mut::<SimulationTime>().tick_rate = TickRate::Paused;
|
||||
schedule.run(&mut world);
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 1);
|
||||
|
||||
// Switch back to Half: accumulator still has 0.5 from overshoot
|
||||
world.resource_mut::<SimulationTime>().tick_rate = TickRate::Half;
|
||||
schedule.run(&mut world);
|
||||
// 0.5 (leftover) + 0.5 (Half) = 1.0 → tick fires
|
||||
assert_eq!(world.resource::<SimulationTime>().tick, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_rate_no_drift_over_10000_frames() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
|
||||
@@ -7,6 +7,7 @@ use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource};
|
||||
use settled_reach_server::knowledge::{KnowledgeGraph, KnowledgePlugin};
|
||||
use settled_reach_server::simulation::interaction::NearbyInteractionBuffer;
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
use std::io::{BufReader, BufWriter};
|
||||
@@ -34,6 +35,7 @@ fn player_moves_north_through_full_pipeline() {
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
KnowledgeGraph::new(),
|
||||
NearbyInteractionBuffer::default(),
|
||||
));
|
||||
|
||||
// Run one tick: receive input, process, validate movement, generate snapshot, send
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::fs;
|
||||
/// Helper to create a minimal v2 snapshot for tests
|
||||
fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
ObserverSnapshot {
|
||||
version: 4,
|
||||
version: PROTOCOL_VERSION,
|
||||
tick,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
@@ -231,6 +231,35 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
assert_eq!(decoded.entities[0].visibility, VisibilitySector::Forward);
|
||||
}
|
||||
|
||||
/// Entity::to_bits() must roundtrip through from_bits() — guards against
|
||||
/// bevy version changes silently breaking wire IDs (Hoshe #12).
|
||||
#[test]
|
||||
fn entity_to_bits_roundtrip() {
|
||||
use bevy_ecs::entity::Entity;
|
||||
// Create entities via a World so we get valid index+generation pairs
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
let e1 = world.spawn_empty().id();
|
||||
let e2 = world.spawn_empty().id();
|
||||
let e3 = world.spawn_empty().id();
|
||||
// Despawn and respawn to get a higher generation
|
||||
world.despawn(e2);
|
||||
let e4 = world.spawn_empty().id();
|
||||
|
||||
for entity in [e1, e2, e3, e4] {
|
||||
let bits = entity.to_bits();
|
||||
let restored = Entity::from_bits(bits);
|
||||
assert_eq!(entity, restored, "Entity::to_bits() roundtrip failed for {:?}", entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// PROTOCOL_VERSION constant matches snapshot version field
|
||||
#[test]
|
||||
fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(PROTOCOL_VERSION, 4, "bump this assertion when protocol version changes");
|
||||
}
|
||||
|
||||
/// All FacingDirection variants round-trip
|
||||
#[test]
|
||||
fn all_facing_direction_variants_roundtrip() {
|
||||
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate content YAML files against their JSON schemas.
|
||||
|
||||
Schema mapping is by directory context:
|
||||
campaign.yaml → campaign.schema.json
|
||||
system.yaml → system.schema.json
|
||||
station.yaml → station.schema.json
|
||||
district.yaml → district.schema.json
|
||||
npcs/*.yaml → npc-profile.schema.json
|
||||
locations/*.yaml → location.schema.json
|
||||
triangles/*.yaml → triangle.schema.json
|
||||
dialogue/**/*.yaml → dialogue-pool.schema.json
|
||||
monologue/**/*.yaml → monologue-pool.schema.json
|
||||
routines/*.yaml → routine.schema.json
|
||||
|
||||
Files under global/ and content.yaml are skipped (no schema yet).
|
||||
|
||||
Exit code 0 = all valid, 1 = validation errors found.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
import yaml
|
||||
|
||||
CONTENT_DIR = Path(__file__).resolve().parent.parent / "content"
|
||||
SCHEMA_DIR = CONTENT_DIR / "_schema"
|
||||
|
||||
# Map directory parent name (or filename) to schema file
|
||||
FILENAME_SCHEMAS = {
|
||||
"campaign.yaml": "campaign.schema.json",
|
||||
"system.yaml": "system.schema.json",
|
||||
"station.yaml": "station.schema.json",
|
||||
"district.yaml": "district.schema.json",
|
||||
}
|
||||
|
||||
DIR_SCHEMAS = {
|
||||
"npcs": "npc-profile.schema.json",
|
||||
"locations": "location.schema.json",
|
||||
"triangles": "triangle.schema.json",
|
||||
"dialogue": "dialogue-pool.schema.json",
|
||||
"monologue": "monologue-pool.schema.json",
|
||||
"routines": "routine.schema.json",
|
||||
}
|
||||
|
||||
|
||||
def resolve_schema(yaml_path: Path) -> Path | None:
|
||||
"""Determine which schema applies to a content YAML file."""
|
||||
name = yaml_path.name
|
||||
if name in FILENAME_SCHEMAS:
|
||||
return SCHEMA_DIR / FILENAME_SCHEMAS[name]
|
||||
|
||||
# Walk up parents to find a matching directory name
|
||||
rel = yaml_path.relative_to(CONTENT_DIR)
|
||||
for part in reversed(rel.parts[:-1]):
|
||||
if part in DIR_SCHEMAS:
|
||||
return SCHEMA_DIR / DIR_SCHEMAS[part]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors = 0
|
||||
validated = 0
|
||||
skipped = 0
|
||||
|
||||
# Cache loaded schemas
|
||||
schema_cache: dict[str, dict] = {}
|
||||
|
||||
campaigns_dir = CONTENT_DIR / "campaigns"
|
||||
if not campaigns_dir.exists():
|
||||
print(f"No campaigns directory at {campaigns_dir}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
yaml_files = sorted(campaigns_dir.rglob("*.yaml"))
|
||||
if not yaml_files:
|
||||
print("No YAML files found under campaigns/", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for yaml_path in yaml_files:
|
||||
schema_path = resolve_schema(yaml_path)
|
||||
if schema_path is None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if not schema_path.exists():
|
||||
print(f"MISSING SCHEMA: {schema_path.name} for {yaml_path.relative_to(CONTENT_DIR)}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
# Load schema (cached)
|
||||
schema_key = str(schema_path)
|
||||
if schema_key not in schema_cache:
|
||||
with open(schema_path) as f:
|
||||
schema_cache[schema_key] = json.load(f)
|
||||
schema = schema_cache[schema_key]
|
||||
|
||||
# Load YAML
|
||||
try:
|
||||
with open(yaml_path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
print(f"YAML ERROR: {yaml_path.relative_to(CONTENT_DIR)}: {e}")
|
||||
errors += 1
|
||||
continue
|
||||
|
||||
if data is None:
|
||||
# Comment-only or empty placeholder files are valid stubs
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Validate
|
||||
try:
|
||||
jsonschema.validate(instance=data, schema=schema)
|
||||
validated += 1
|
||||
except jsonschema.ValidationError as e:
|
||||
rel = yaml_path.relative_to(CONTENT_DIR)
|
||||
print(f"INVALID: {rel}")
|
||||
print(f" Schema: {schema_path.name}")
|
||||
print(f" Error: {e.message}")
|
||||
if e.absolute_path:
|
||||
print(f" Path: {'.'.join(str(p) for p in e.absolute_path)}")
|
||||
errors += 1
|
||||
|
||||
print(f"\nValidated {validated} files, {skipped} skipped, {errors} errors")
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user