Compare commits

...
5 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.6 e6bd577a5a chore(meta): update changelog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:53:23 +01:00
jpmschweitzerandClaude Opus 4.6 0c91f5af21 chore(tooling): add make validate-content for schema validation
Python script validates campaign YAML files against JSON schemas in
content/_schema/. Maps files to schemas by directory context (npcs/
→ npc-profile.schema.json, etc.). Skips comment-only placeholder stubs.

Addresses Hoshe #2 review item.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:53:05 +01:00
jpmschweitzerandClaude Opus 4.6 0157d1fa38 test(server): add Entity::to_bits roundtrip and TickRate switch tests
- Entity::to_bits() roundtrip test guards against bevy version changes
  silently breaking wire IDs (Hoshe #1)
- PROTOCOL_VERSION constant used in test helpers instead of hardcoded 4
- TickRate switch mid-accumulation test verifies Half→Full→Paused→Half
  transitions preserve accumulator state correctly (Tyre N3)
- POI mid-range test now asserts priority=1 (Hoshe #4)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:52:59 +01:00
jpmschweitzerandClaude Opus 4.6 02fa89b1d7 refactor(server): extract observer tests to separate file
Split observer/mod.rs (820 lines) into production code (244 lines) and
tests (480 lines). Reduces module size per Hoshe #3 review item.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:52:52 +01:00
jpmschweitzerandClaude Opus 4.6 6f9537fff3 refactor(server): NearbyInteractionBuffer from Resource to Component
Per-entity Component attached to PlayerCharacter instead of global
Resource. Makes the interaction buffer multiplayer-ready (D-009) —
each observer gets their own buffer without pipeline refactoring.

Updated all 8 files touching the buffer: system signatures, player
spawn bundles, and ~30 test spawn sites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:52:47 +01:00
14 changed files with 1096 additions and 875 deletions
+5
View File
@@ -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
+7 -1
View File
@@ -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:
+2
View File
@@ -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);
+5 -1
View File
@@ -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);
+3 -1
View File
@@ -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);
-798
View File
@@ -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");
}
}
+244
View File
@@ -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;
+574
View File
@@ -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");
}
+61 -72
View File
@@ -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);
}
}
-1
View File
@@ -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,
+31
View File
@@ -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();
+2
View File
@@ -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
+30 -1
View File
@@ -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() {
+132
View File
@@ -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())