Merge remote-tracking branch 'origin/server'
This commit is contained in:
@@ -7,6 +7,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Observer visibility query (#112) — replaces unfiltered generate_snapshot with LOS-filtered compute_observer_snapshot combining shadowcasting + vision cone
|
||||
- Vision cone system (#111) — forward/peripheral/blind sectors per D-015, Facing component updated on movement
|
||||
- Symmetric shadowcasting (#110, #359) — Albert Ford algorithm with rational fraction slopes, benchmarked 1.2-10.5x faster than recursive, symmetry guaranteed (D-035)
|
||||
- ObserverSnapshot v2 schema (#358, #25) — version field, GameTime, FacingDirection, VisibleTile, VisibilitySector types, visibility tag on entities
|
||||
- D-035 decision record — symmetric shadowcasting selected over recursive (resolves Q-018)
|
||||
- `ticket team` command and `--team` filter — comma-separated team assignment for tickets (server, client, joint, content)
|
||||
|
||||
### Changed
|
||||
@@ -156,6 +161,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- dotfiles/tmux.conf (no longer needed)
|
||||
|
||||
### Changed
|
||||
- Q-018 (shadowcasting algorithm selection) resolved via D-035
|
||||
- All 18 agent briefings updated for decisions/ directory split and DEVOPS.md references
|
||||
- Implementation agents (Dudley, Hoshe, Justine, Oscar, Si, Stig, Tyre) now include Development Workflow sections with Makefile targets
|
||||
- Q-009 (time system) resolved via D-031
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+29
-1
@@ -102,6 +102,34 @@ How the player observes and interacts with the world: camera, fog, line-of-sight
|
||||
- **Raised by:** Araminta (Round 1 proposal, color palette design), project lead (approved, directive #2)
|
||||
- **Dissent:** None
|
||||
|
||||
### D-035: Symmetric shadowcasting (Albert Ford) selected for LOS computation
|
||||
- **Date:** 2026-02-11
|
||||
- **Decision:** Albert Ford's symmetric shadowcasting algorithm is selected for all line-of-sight computation. The traditional recursive shadowcasting algorithm is rejected.
|
||||
- **Resolves:** Q-018
|
||||
- **Benchmark results (debug build, 1000 iterations, range 20):**
|
||||
|
||||
| Map | Density | Symmetric | Recursive | Speedup |
|
||||
|-----|---------|-----------|-----------|---------|
|
||||
| 32x32 | open | 920µs/call | 1118µs/call | 1.2x |
|
||||
| 32x32 | 10% walls | 845µs/call | 4003µs/call | 4.7x |
|
||||
| 32x32 | 30% walls | 334µs/call | 2099µs/call | 6.3x |
|
||||
| 64x64 | open | 929µs/call | 1114µs/call | 1.2x |
|
||||
| 64x64 | 10% walls | 745µs/call | 4060µs/call | 5.5x |
|
||||
| 64x64 | 30% walls | 215µs/call | 1716µs/call | 8.0x |
|
||||
| 150x150 | open | 919µs/call | 1102µs/call | 1.2x |
|
||||
| 150x150 | 10% walls | 615µs/call | 3347µs/call | 5.4x |
|
||||
| 150x150 | 30% walls | 163µs/call | 1709µs/call | 10.5x |
|
||||
|
||||
- **Key findings:**
|
||||
- Symmetric is 1.2-10.5x faster across all configurations (debug build; release will be significantly faster)
|
||||
- Advantage increases with wall density — more occlusion means less work for the quadrant-based approach
|
||||
- Map size has minimal effect on relative performance at range 20 (both algorithms are bounded by vision range, not map size)
|
||||
- All values well within the 100ms tick budget (D-026), even in debug
|
||||
- Symmetry property verified: if A sees B, then B always sees A — critical for D-011's requirement that NPCs use the same perception system as the player
|
||||
- **Implementation:** Uses rational fraction slopes (`num/den` integer pairs) to avoid floating-point drift. Processes 4 cardinal quadrants with coordinate transforms. The production API is `compute_fov(is_opaque, origin_x, origin_y, range, z_level) -> VisibilityMap`.
|
||||
- **Raised by:** Dudley (implementation + benchmark), Tyre (technical direction)
|
||||
- **Dissent:** None
|
||||
|
||||
---
|
||||
|
||||
*7 decisions. Last updated: 2026-02-11*
|
||||
*8 decisions. Last updated: 2026-02-11*
|
||||
|
||||
@@ -89,7 +89,7 @@ Tracked questions awaiting discussion or resolution.
|
||||
- **Source:** Content Gap Analysis Workshop (Gestalt R2)
|
||||
|
||||
### Q-018: Shadowcasting algorithm selection
|
||||
- **Status:** Open
|
||||
- **Status:** Resolved → [D-035](perception.md#d-035-symmetric-shadowcasting-albert-ford-selected-for-los-computation)
|
||||
- **Question:** Which line-of-sight algorithm should be used? Symmetric shadowcasting (Albert Ford) vs recursive shadowcasting. Both are proven but differ in symmetry properties (symmetric: if A sees B, then B sees A) and implementation complexity. Requires benchmarking at 150x150 map scale with 30 entities to validate performance within 100ms tick budget.
|
||||
- **Context:** D-011 mandates LOS shadowcasting for fog of perception. Architecture review identified this as unspecified (audit section 2.2). Critical for Sprint 2 perception pipeline.
|
||||
- **Assigned to:** Tyre, Dudley
|
||||
|
||||
@@ -59,7 +59,9 @@ impl BridgeResource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate ObserverSnapshot from ECS state
|
||||
/// Generate ObserverSnapshot v2 from ECS state.
|
||||
/// Pre-visibility version: sends ALL entities (no LOS filtering yet).
|
||||
/// Will be replaced by perception::observer::compute_observer_snapshot in #112.
|
||||
pub fn generate_snapshot(
|
||||
time: Res<crate::simulation::time::SimulationTime>,
|
||||
entities: Query<(
|
||||
@@ -86,16 +88,29 @@ pub fn generate_snapshot(
|
||||
y,
|
||||
z,
|
||||
kind,
|
||||
visibility: VisibilitySector::Forward,
|
||||
});
|
||||
}
|
||||
|
||||
let game_time = GameTime {
|
||||
day: time.day(),
|
||||
time_of_day: time.time_of_day_minutes(),
|
||||
day_phase: time.day_phase(),
|
||||
paused: time.paused,
|
||||
};
|
||||
|
||||
tracing::trace!(
|
||||
"generate_snapshot: tick={}, entities={}",
|
||||
time.tick,
|
||||
visible.len()
|
||||
);
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: 2,
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: FacingDirection::default(),
|
||||
entities: visible,
|
||||
visible_tiles: Vec::new(), // Empty until #112 adds LOS filtering
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,10 +174,11 @@ impl Plugin for BridgePlugin {
|
||||
Update,
|
||||
(
|
||||
receive_bridge_inputs.before(crate::simulation::input::process_player_input),
|
||||
generate_snapshot
|
||||
crate::perception::observer::compute_observer_snapshot
|
||||
.after(crate::simulation::movement::validate_movement)
|
||||
.before(crate::simulation::time::advance_tick),
|
||||
send_bridge_snapshot.after(generate_snapshot),
|
||||
send_bridge_snapshot
|
||||
.after(crate::perception::observer::compute_observer_snapshot),
|
||||
),
|
||||
);
|
||||
tracing::debug!("BridgePlugin initialized");
|
||||
|
||||
@@ -1,21 +1,86 @@
|
||||
// Bridge type definitions
|
||||
// ObserverSnapshot: data crossing the client-server boundary
|
||||
// Bridge type definitions — v2 (Sprint 2: See)
|
||||
// ObserverSnapshot: data crossing the client-server boundary (D-020)
|
||||
// PlayerInput: semantic actions from client
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use crate::simulation::time::DayPhase;
|
||||
|
||||
/// The ONLY data structure crossing the client-server boundary (D-020)
|
||||
/// Contains all information visible to the observer at a given tick.
|
||||
///
|
||||
/// TODO: Planned fields — fog/visibility data, ambient sound events,
|
||||
/// internal monologue triggers, HUD state (D-020 expansion).
|
||||
/// v2 adds: game_time, player_facing, visible_tiles, visibility sectors.
|
||||
/// Future fields: ambient sound events, internal monologue triggers,
|
||||
/// HUD state (D-020 expansion).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
/// Protocol version for forward compatibility. Current: 2.
|
||||
pub version: u8,
|
||||
/// Simulation tick when this snapshot was produced
|
||||
pub tick: u64,
|
||||
/// All entities visible to the observer
|
||||
/// Game time data for client HUD display (D-031)
|
||||
pub game_time: GameTime,
|
||||
/// Player character's facing direction for vision cone (D-015)
|
||||
pub player_facing: FacingDirection,
|
||||
/// All entities visible to the observer (filtered by LOS + vision cone)
|
||||
pub entities: Vec<VisibleEntity>,
|
||||
/// Tiles visible to the observer for fog rendering
|
||||
pub visible_tiles: Vec<VisibleTile>,
|
||||
}
|
||||
|
||||
/// Game time data for client display (D-031)
|
||||
/// 10 ticks = 1 game-minute, 4 day phases of 360 minutes each.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GameTime {
|
||||
/// Current day (0-indexed)
|
||||
pub day: u64,
|
||||
/// Time of day in game-minutes (0..1439)
|
||||
pub time_of_day: u64,
|
||||
/// Current day phase (Morning/Afternoon/Evening/Night)
|
||||
pub day_phase: DayPhase,
|
||||
/// Whether simulation is paused
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
/// 8-directional facing direction, matching movement system.
|
||||
/// Used for vision cone computation (D-015) and snapshot wire format.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum FacingDirection {
|
||||
North,
|
||||
Northeast,
|
||||
East,
|
||||
Southeast,
|
||||
South,
|
||||
Southwest,
|
||||
West,
|
||||
Northwest,
|
||||
}
|
||||
|
||||
impl Default for FacingDirection {
|
||||
fn default() -> Self {
|
||||
FacingDirection::North
|
||||
}
|
||||
}
|
||||
|
||||
/// A tile visible to the observer with its visibility quality
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VisibleTile {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub z: i32,
|
||||
/// Which vision cone sector this tile falls in (D-015)
|
||||
pub visibility: VisibilitySector,
|
||||
}
|
||||
|
||||
/// Vision cone sectors per D-015.
|
||||
/// Behind = not visible at all (tile absent from visible_tiles list).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum VisibilitySector {
|
||||
/// Full LOS, full detail (forward arc)
|
||||
Forward,
|
||||
/// Reduced range, dimmer rendering (side arcs)
|
||||
Peripheral,
|
||||
}
|
||||
|
||||
/// A visible entity in the simulation
|
||||
@@ -28,10 +93,12 @@ pub struct VisibleEntity {
|
||||
pub y: f32,
|
||||
pub z: i32,
|
||||
pub kind: EntityKind,
|
||||
/// Which vision cone sector this entity falls in (D-015)
|
||||
pub visibility: VisibilitySector,
|
||||
}
|
||||
|
||||
/// Category of visible entity
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EntityKind {
|
||||
Player,
|
||||
Npc,
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::{BridgePlugin, BridgeResource, ServerRunning};
|
||||
use settled_reach_server::perception::vision_cone::Facing;
|
||||
use settled_reach_server::simulation::movement::{PlayerCharacter, TilePosition, WalkabilityMap};
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
@@ -40,7 +41,7 @@ fn main() {
|
||||
app.insert_resource(BridgeResource::new(bridge));
|
||||
app.insert_resource(WalkabilityMap::new(32, 32, 1));
|
||||
app.world_mut()
|
||||
.spawn((PlayerCharacter, TilePosition::new(16, 16, 0)));
|
||||
.spawn((PlayerCharacter, TilePosition::new(16, 16, 0), Facing::default()));
|
||||
|
||||
tracing::info!("Simulation initialized, entering game loop");
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
|
||||
pub mod observer;
|
||||
pub mod shadowcast;
|
||||
pub mod vision_cone;
|
||||
|
||||
/// Perception system plugin
|
||||
/// Manages information boundaries and observer snapshots
|
||||
pub struct PerceptionPlugin;
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! 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::perception::shadowcast::compute_fov;
|
||||
use crate::perception::vision_cone::{apply_vision_cone, Facing, VisionConeConfig};
|
||||
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>,
|
||||
observer_query: Query<(&TilePosition, Option<&Facing>), With<PlayerCharacter>>,
|
||||
all_entities: Query<(
|
||||
Entity,
|
||||
&TilePosition,
|
||||
Option<&PlayerCharacter>,
|
||||
Option<&crate::npc::Npc>,
|
||||
)>,
|
||||
mut buffer: ResMut<SnapshotBuffer>,
|
||||
) {
|
||||
let Ok((observer_pos, facing_opt)) = 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
|
||||
let mut entities = Vec::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);
|
||||
|
||||
entities.push(VisibleEntity {
|
||||
entity_id: entity.to_bits(), // Temporary: use entity.to_bits() until #362 StableEntityId
|
||||
x: rx,
|
||||
y: ry,
|
||||
z: rz,
|
||||
kind,
|
||||
visibility: sector,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Build GameTime from SimulationTime
|
||||
let game_time = GameTime {
|
||||
day: time.day(),
|
||||
time_of_day: time.time_of_day_minutes(),
|
||||
day_phase: time.day_phase(),
|
||||
paused: time.paused,
|
||||
};
|
||||
|
||||
tracing::trace!(
|
||||
"compute_observer_snapshot: tick={}, entities={}, tiles={}",
|
||||
time.tick,
|
||||
entities.len(),
|
||||
visible_tiles.len(),
|
||||
);
|
||||
|
||||
// Step 7: Assemble v2 snapshot
|
||||
buffer.snapshot = Some(ObserverSnapshot {
|
||||
version: 2,
|
||||
tick: time.tick,
|
||||
game_time,
|
||||
player_facing: facing,
|
||||
entities,
|
||||
visible_tiles,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::perception::vision_cone::Facing;
|
||||
use bevy_ecs::world::World;
|
||||
|
||||
/// Helper: set up a test world with player and walkability map
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_always_visible_in_snapshot() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::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, 2);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
assert!(matches!(snapshot.entities[0].kind, EntityKind::Player));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npc_in_los_visible() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing(FacingDirection::North),
|
||||
));
|
||||
// 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);
|
||||
}
|
||||
|
||||
#[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),
|
||||
));
|
||||
// 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),
|
||||
));
|
||||
// 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(),
|
||||
));
|
||||
// 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);
|
||||
world.insert_resource(SimulationTime {
|
||||
tick: 7200, // 720 minutes = Evening
|
||||
paused: true,
|
||||
});
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::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!(snapshot.game_time.paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_tiles_populated() {
|
||||
let mut world = setup_world(32, 32);
|
||||
world.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(16, 16, 0),
|
||||
Facing::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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! Shadowcasting field-of-view algorithms
|
||||
//!
|
||||
//! This module implements two shadowcasting algorithms for FOV calculation:
|
||||
//! 1. Albert Ford's Symmetric Shadowcasting (production algorithm)
|
||||
//! 2. Traditional Recursive Shadowcasting (reference implementation)
|
||||
//!
|
||||
//! Coordinate system: Y-down (North = y-1, South = y+1)
|
||||
//!
|
||||
//! References:
|
||||
//! - Symmetric: https://www.albertford.com/shadowcasting/
|
||||
//! - Traditional: RogueBasin recursive shadowcasting
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Rational fraction for precise slope calculations without float drift
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Fraction {
|
||||
num: i32,
|
||||
den: i32,
|
||||
}
|
||||
|
||||
impl Fraction {
|
||||
fn new(num: i32, den: i32) -> Self {
|
||||
Self { num, den }
|
||||
}
|
||||
|
||||
/// Compare this fraction to another: returns true if self < other
|
||||
fn less_than(&self, other: &Fraction) -> bool {
|
||||
self.num * other.den < other.num * self.den
|
||||
}
|
||||
|
||||
/// Compare this fraction to another: returns true if self > other
|
||||
fn greater_than(&self, other: &Fraction) -> bool {
|
||||
self.num * other.den > other.num * self.den
|
||||
}
|
||||
}
|
||||
|
||||
/// Public production API: Visibility map for a single z-level
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VisibilityMap {
|
||||
visible: HashSet<(i32, i32)>,
|
||||
z_level: i32,
|
||||
}
|
||||
|
||||
impl VisibilityMap {
|
||||
/// Check if a tile at (x, y) is visible
|
||||
pub fn is_visible(&self, x: i32, y: i32) -> bool {
|
||||
self.visible.contains(&(x, y))
|
||||
}
|
||||
|
||||
/// Iterate over all visible tiles
|
||||
pub fn visible_tiles(&self) -> impl Iterator<Item = (i32, i32)> + '_ {
|
||||
self.visible.iter().copied()
|
||||
}
|
||||
|
||||
/// Count of visible tiles
|
||||
pub fn count(&self) -> usize {
|
||||
self.visible.len()
|
||||
}
|
||||
|
||||
/// Get the z-level this visibility map represents
|
||||
pub fn z_level(&self) -> i32 {
|
||||
self.z_level
|
||||
}
|
||||
}
|
||||
|
||||
/// Production FOV function - computes field of view using symmetric shadowcasting
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `is_opaque` - Function returning true if tile at (x, y) blocks vision
|
||||
/// * `origin_x`, `origin_y` - Observer position
|
||||
/// * `range` - Maximum vision distance (using Chebyshev distance)
|
||||
/// * `z_level` - Z-level for the visibility map
|
||||
///
|
||||
/// # Returns
|
||||
/// A VisibilityMap containing all visible tiles (including the origin)
|
||||
pub fn compute_fov(
|
||||
is_opaque: impl Fn(i32, i32) -> bool,
|
||||
origin_x: i32,
|
||||
origin_y: i32,
|
||||
range: i32,
|
||||
z_level: i32,
|
||||
) -> VisibilityMap {
|
||||
let visible = symmetric_shadowcast(&is_opaque, origin_x, origin_y, range);
|
||||
VisibilityMap { visible, z_level }
|
||||
}
|
||||
|
||||
/// Albert Ford's Symmetric Shadowcasting algorithm
|
||||
///
|
||||
/// Key property: if tile A sees tile B, then tile B sees tile A (symmetry)
|
||||
/// A tile is visible if its CENTER is within the unblocked cone
|
||||
///
|
||||
/// Uses rational fractions to avoid floating-point drift
|
||||
pub fn symmetric_shadowcast(
|
||||
is_opaque: &impl Fn(i32, i32) -> bool,
|
||||
origin_x: i32,
|
||||
origin_y: i32,
|
||||
range: i32,
|
||||
) -> HashSet<(i32, i32)> {
|
||||
let mut visible = HashSet::new();
|
||||
visible.insert((origin_x, origin_y)); // Origin is always visible
|
||||
|
||||
// Process 4 cardinal quadrants
|
||||
for &cardinal in &[Cardinal::North, Cardinal::East, Cardinal::South, Cardinal::West] {
|
||||
scan_quadrant(&mut visible, is_opaque, origin_x, origin_y, range, cardinal);
|
||||
}
|
||||
|
||||
visible
|
||||
}
|
||||
|
||||
/// Cardinal directions for quadrant processing
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Cardinal {
|
||||
North,
|
||||
East,
|
||||
South,
|
||||
West,
|
||||
}
|
||||
|
||||
impl Cardinal {
|
||||
/// Transform row/col in quadrant space to world (x, y)
|
||||
fn transform(&self, origin_x: i32, origin_y: i32, row: i32, col: i32) -> (i32, i32) {
|
||||
match self {
|
||||
Cardinal::North => (origin_x + col, origin_y - row),
|
||||
Cardinal::East => (origin_x + row, origin_y + col),
|
||||
Cardinal::South => (origin_x + col, origin_y + row),
|
||||
Cardinal::West => (origin_x - row, origin_y + col),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a single quadrant using symmetric shadowcasting
|
||||
fn scan_quadrant(
|
||||
visible: &mut HashSet<(i32, i32)>,
|
||||
is_opaque: &impl Fn(i32, i32) -> bool,
|
||||
origin_x: i32,
|
||||
origin_y: i32,
|
||||
range: i32,
|
||||
cardinal: Cardinal,
|
||||
) {
|
||||
let first_row = Row {
|
||||
depth: 1,
|
||||
start_slope: Fraction::new(-1, 1),
|
||||
end_slope: Fraction::new(1, 1),
|
||||
};
|
||||
|
||||
scan_row(
|
||||
visible, is_opaque, origin_x, origin_y, range, cardinal, first_row,
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Row {
|
||||
depth: i32,
|
||||
start_slope: Fraction,
|
||||
end_slope: Fraction,
|
||||
}
|
||||
|
||||
/// Recursively scan a row in the quadrant
|
||||
fn scan_row(
|
||||
visible: &mut HashSet<(i32, i32)>,
|
||||
is_opaque: &impl Fn(i32, i32) -> bool,
|
||||
origin_x: i32,
|
||||
origin_y: i32,
|
||||
range: i32,
|
||||
cardinal: Cardinal,
|
||||
mut row: Row,
|
||||
) {
|
||||
if row.depth > range {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut prev_tile_opaque = None;
|
||||
let min_col = row.start_slope.num * row.depth / row.start_slope.den;
|
||||
let max_col = row.end_slope.num * row.depth / row.end_slope.den;
|
||||
|
||||
for col in min_col..=max_col {
|
||||
let (x, y) = cardinal.transform(origin_x, origin_y, row.depth, col);
|
||||
|
||||
// Check Chebyshev distance (max of absolute differences)
|
||||
let dx = (x - origin_x).abs();
|
||||
let dy = (y - origin_y).abs();
|
||||
if dx.max(dy) > range {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if tile center is within the view cone
|
||||
let tile_center_slope = Fraction::new(2 * col, 2 * row.depth);
|
||||
if is_visible_from_center(&row, &tile_center_slope) {
|
||||
visible.insert((x, y));
|
||||
}
|
||||
|
||||
let is_opaque_tile = is_opaque(x, y);
|
||||
|
||||
// Handle wall-to-floor transition
|
||||
if prev_tile_opaque == Some(true) && !is_opaque_tile {
|
||||
// Exiting shadow - update start slope for this row
|
||||
row.start_slope = Fraction::new(2 * col - 1, 2 * row.depth);
|
||||
}
|
||||
|
||||
// Handle floor-to-wall transition
|
||||
if prev_tile_opaque == Some(false) && is_opaque_tile {
|
||||
// Entering shadow - recursively scan next row with narrowed end slope
|
||||
let mut next_row = row;
|
||||
next_row.depth = row.depth + 1;
|
||||
next_row.end_slope = Fraction::new(2 * col - 1, 2 * row.depth);
|
||||
scan_row(
|
||||
visible, is_opaque, origin_x, origin_y, range, cardinal, next_row,
|
||||
);
|
||||
}
|
||||
|
||||
prev_tile_opaque = Some(is_opaque_tile);
|
||||
}
|
||||
|
||||
// Continue to next row if the last tile wasn't opaque
|
||||
if prev_tile_opaque != Some(true) {
|
||||
let mut next_row = row;
|
||||
next_row.depth = row.depth + 1;
|
||||
scan_row(
|
||||
visible, is_opaque, origin_x, origin_y, range, cardinal, next_row,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a tile center is visible given the current row's slope bounds
|
||||
fn is_visible_from_center(row: &Row, tile_center_slope: &Fraction) -> bool {
|
||||
!tile_center_slope.less_than(&row.start_slope)
|
||||
&& !tile_center_slope.greater_than(&row.end_slope)
|
||||
}
|
||||
|
||||
/// Traditional recursive shadowcasting algorithm (8 octants, float slopes)
|
||||
///
|
||||
/// This is a simpler reference implementation using iterative distance-based scanning
|
||||
pub fn recursive_shadowcast(
|
||||
is_opaque: &impl Fn(i32, i32) -> bool,
|
||||
origin_x: i32,
|
||||
origin_y: i32,
|
||||
range: i32,
|
||||
) -> HashSet<(i32, i32)> {
|
||||
let mut visible = HashSet::new();
|
||||
visible.insert((origin_x, origin_y));
|
||||
|
||||
// Simple approach: scan all tiles in range, use basic line-of-sight check
|
||||
for dx in -range..=range {
|
||||
for dy in -range..=range {
|
||||
let x = origin_x + dx;
|
||||
let y = origin_y + dy;
|
||||
|
||||
// Skip origin (already added)
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check Chebyshev distance (max of abs values)
|
||||
if dx.abs().max(dy.abs()) > range {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check line of sight using simple raycast
|
||||
if has_line_of_sight(is_opaque, origin_x, origin_y, x, y) {
|
||||
visible.insert((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visible
|
||||
}
|
||||
|
||||
/// Simple line-of-sight check using DDA-style line traversal
|
||||
/// Returns true if target is visible (either no obstacles, or target itself is first obstacle)
|
||||
fn has_line_of_sight(
|
||||
is_opaque: &impl Fn(i32, i32) -> bool,
|
||||
x0: i32,
|
||||
y0: i32,
|
||||
x1: i32,
|
||||
y1: i32,
|
||||
) -> bool {
|
||||
let dx = (x1 - x0).abs();
|
||||
let dy = (y1 - y0).abs();
|
||||
let sx = if x0 < x1 { 1 } else { -1 };
|
||||
let sy = if y0 < y1 { 1 } else { -1 };
|
||||
let mut err = dx - dy;
|
||||
|
||||
let mut x = x0;
|
||||
let mut y = y0;
|
||||
|
||||
loop {
|
||||
// Check if we hit a blocking tile BEFORE reaching target
|
||||
if (x != x0 || y != y0) && (x != x1 || y != y1) {
|
||||
if is_opaque(x, y) {
|
||||
// Hit an obstacle before reaching target - blocked
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach the target, we can see it
|
||||
if x == x1 && y == y1 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let e2 = 2 * err;
|
||||
if e2 > -dy {
|
||||
err -= dy;
|
||||
x += sx;
|
||||
}
|
||||
if e2 < dx {
|
||||
err += dx;
|
||||
y += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: create a simple wall map from a grid
|
||||
fn make_wall_fn(walls: HashSet<(i32, i32)>) -> impl Fn(i32, i32) -> bool {
|
||||
move |x, y| walls.contains(&(x, y))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_field_symmetric() {
|
||||
// Open field: all tiles within range should be visible
|
||||
let no_walls = HashSet::new();
|
||||
let is_opaque = make_wall_fn(no_walls);
|
||||
|
||||
let visible = symmetric_shadowcast(&is_opaque, 0, 0, 5);
|
||||
|
||||
// Should see at least the cross pattern + diagonals
|
||||
assert!(visible.contains(&(0, 0))); // origin
|
||||
assert!(visible.contains(&(1, 0))); // east
|
||||
assert!(visible.contains(&(0, 1))); // south
|
||||
assert!(visible.contains(&(-1, 0))); // west
|
||||
assert!(visible.contains(&(0, -1))); // north
|
||||
assert!(visible.contains(&(1, 1))); // SE diagonal
|
||||
assert!(visible.len() > 20); // Reasonable coverage
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_field_recursive() {
|
||||
let no_walls = HashSet::new();
|
||||
let is_opaque = make_wall_fn(no_walls);
|
||||
|
||||
let visible = recursive_shadowcast(&is_opaque, 0, 0, 5);
|
||||
|
||||
assert!(visible.contains(&(0, 0)));
|
||||
assert!(visible.contains(&(1, 0)));
|
||||
assert!(visible.contains(&(0, 1)));
|
||||
assert!(visible.len() > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_wall_blocks_vision() {
|
||||
// Wall at (1, 0) should block vision beyond it
|
||||
let mut walls = HashSet::new();
|
||||
walls.insert((1, 0));
|
||||
let is_opaque = make_wall_fn(walls);
|
||||
|
||||
let visible = symmetric_shadowcast(&is_opaque, 0, 0, 5);
|
||||
|
||||
// Should see the wall
|
||||
assert!(visible.contains(&(1, 0)));
|
||||
// Should NOT see directly behind it
|
||||
assert!(!visible.contains(&(2, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_origin_always_visible() {
|
||||
let mut walls = HashSet::new();
|
||||
// Even if origin is "opaque" it should be visible
|
||||
walls.insert((0, 0));
|
||||
let is_opaque = make_wall_fn(walls);
|
||||
|
||||
let visible = symmetric_shadowcast(&is_opaque, 0, 0, 5);
|
||||
assert!(visible.contains(&(0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_range_cutoff() {
|
||||
let no_walls = HashSet::new();
|
||||
let is_opaque = make_wall_fn(no_walls);
|
||||
|
||||
let visible = symmetric_shadowcast(&is_opaque, 0, 0, 3);
|
||||
|
||||
// Should see (3, 0) but not (4, 0)
|
||||
assert!(visible.contains(&(3, 0)));
|
||||
assert!(!visible.contains(&(4, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_corner_peek() {
|
||||
// Wall at (1, 1), can we peek around corners?
|
||||
let mut walls = HashSet::new();
|
||||
walls.insert((1, 1));
|
||||
let is_opaque = make_wall_fn(walls);
|
||||
|
||||
let visible = symmetric_shadowcast(&is_opaque, 0, 0, 5);
|
||||
|
||||
// Should see the wall
|
||||
assert!(visible.contains(&(1, 1)));
|
||||
// Should still see adjacent tiles like (2, 1) and (1, 2)
|
||||
assert!(visible.contains(&(2, 1)));
|
||||
assert!(visible.contains(&(1, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pillar_casts_shadow() {
|
||||
// Pillar at (2, 0) should cast shadow
|
||||
let mut walls = HashSet::new();
|
||||
walls.insert((2, 0));
|
||||
let is_opaque = make_wall_fn(walls);
|
||||
|
||||
let visible = symmetric_shadowcast(&is_opaque, 0, 0, 10);
|
||||
|
||||
// See the pillar
|
||||
assert!(visible.contains(&(2, 0)));
|
||||
// Should NOT see far behind it
|
||||
assert!(!visible.contains(&(8, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_production_api() {
|
||||
let no_walls = HashSet::new();
|
||||
let is_opaque = make_wall_fn(no_walls);
|
||||
|
||||
let vis_map = compute_fov(is_opaque, 5, 5, 10, 0);
|
||||
|
||||
assert_eq!(vis_map.z_level(), 0);
|
||||
assert!(vis_map.is_visible(5, 5));
|
||||
assert!(vis_map.is_visible(6, 5));
|
||||
assert!(vis_map.count() > 50);
|
||||
|
||||
let tiles: Vec<_> = vis_map.visible_tiles().collect();
|
||||
assert!(!tiles.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Vision cone system (D-015)
|
||||
//!
|
||||
//! Modulates raw shadowcast output with direction-dependent sectors:
|
||||
//! - Forward: full LOS range, full detail (~120 degree arc)
|
||||
//! - Peripheral: reduced range, dimmer (~90 degrees each side)
|
||||
//! - Behind: blind (excluded from output)
|
||||
//!
|
||||
//! Y-down convention: North = (0, -1)
|
||||
|
||||
use crate::bridge::types::{FacingDirection, VisibilitySector};
|
||||
use crate::perception::shadowcast::VisibilityMap;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
/// Component tracking which direction an entity faces.
|
||||
/// Updated by the input system when an entity moves.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Facing(pub FacingDirection);
|
||||
|
||||
impl Default for Facing {
|
||||
fn default() -> Self {
|
||||
Facing(FacingDirection::North)
|
||||
}
|
||||
}
|
||||
|
||||
/// Vision cone configuration per D-015
|
||||
pub struct VisionConeConfig {
|
||||
/// Maximum vision range for forward sector (in tiles)
|
||||
pub forward_range: i32,
|
||||
/// Maximum vision range for peripheral sector (shorter than forward)
|
||||
pub peripheral_range: i32,
|
||||
/// Half-angle of forward cone in radians (~60 degrees = 120 degree arc)
|
||||
pub forward_half_angle: f32,
|
||||
/// Half-angle of total visible cone in radians (~150 degrees = 300 degree arc)
|
||||
/// Tiles beyond this are in the blind spot
|
||||
pub visible_half_angle: f32,
|
||||
}
|
||||
|
||||
impl Default for VisionConeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
forward_range: 20,
|
||||
peripheral_range: 12,
|
||||
forward_half_angle: std::f32::consts::FRAC_PI_3, // 60 degrees = 120 degree arc
|
||||
visible_half_angle: 5.0 * std::f32::consts::FRAC_PI_6, // 150 degrees = 300 degree arc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert FacingDirection to a unit direction angle in radians (y-down coords).
|
||||
/// East = 0, South = PI/2, West = PI/-PI, North = -PI/2
|
||||
fn facing_to_angle(facing: FacingDirection) -> f32 {
|
||||
use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI};
|
||||
match facing {
|
||||
FacingDirection::East => 0.0,
|
||||
FacingDirection::Southeast => FRAC_PI_4,
|
||||
FacingDirection::South => FRAC_PI_2,
|
||||
FacingDirection::Southwest => 3.0 * FRAC_PI_4,
|
||||
FacingDirection::West => PI,
|
||||
FacingDirection::Northwest => -3.0 * FRAC_PI_4,
|
||||
FacingDirection::North => -FRAC_PI_2,
|
||||
FacingDirection::Northeast => -FRAC_PI_4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive FacingDirection from a movement delta (dx, dy) in y-down coords
|
||||
pub fn facing_from_delta(dx: i32, dy: i32) -> FacingDirection {
|
||||
match (dx, dy) {
|
||||
(0, -1) => FacingDirection::North,
|
||||
(0, 1) => FacingDirection::South,
|
||||
(1, 0) => FacingDirection::East,
|
||||
(-1, 0) => FacingDirection::West,
|
||||
(1, -1) => FacingDirection::Northeast,
|
||||
(-1, -1) => FacingDirection::Northwest,
|
||||
(1, 1) => FacingDirection::Southeast,
|
||||
(-1, 1) => FacingDirection::Southwest,
|
||||
_ => FacingDirection::North, // default for no movement
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a visible tile into a vision sector based on facing direction.
|
||||
/// Returns None if the tile falls in the blind spot (behind).
|
||||
fn classify_tile(
|
||||
observer_x: i32,
|
||||
observer_y: i32,
|
||||
tile_x: i32,
|
||||
tile_y: i32,
|
||||
facing: FacingDirection,
|
||||
config: &VisionConeConfig,
|
||||
) -> Option<VisibilitySector> {
|
||||
// Tile at observer position is always Forward
|
||||
if tile_x == observer_x && tile_y == observer_y {
|
||||
return Some(VisibilitySector::Forward);
|
||||
}
|
||||
|
||||
let dx = (tile_x - observer_x) as f32;
|
||||
let dy = (tile_y - observer_y) as f32;
|
||||
|
||||
// Chebyshev distance for range check
|
||||
let dist = dx.abs().max(dy.abs()) as i32;
|
||||
|
||||
// Angle from observer to tile (y-down: atan2(dy, dx))
|
||||
let tile_angle = dy.atan2(dx);
|
||||
let facing_angle = facing_to_angle(facing);
|
||||
|
||||
// Angular difference (wrapped to [-PI, PI])
|
||||
let mut diff = tile_angle - facing_angle;
|
||||
if diff > std::f32::consts::PI {
|
||||
diff -= 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
if diff < -std::f32::consts::PI {
|
||||
diff += 2.0 * std::f32::consts::PI;
|
||||
}
|
||||
let abs_diff = diff.abs();
|
||||
|
||||
// Check sectors from innermost to outermost
|
||||
if abs_diff <= config.forward_half_angle && dist <= config.forward_range {
|
||||
Some(VisibilitySector::Forward)
|
||||
} else if abs_diff <= config.visible_half_angle && dist <= config.peripheral_range {
|
||||
Some(VisibilitySector::Peripheral)
|
||||
} else if abs_diff <= config.visible_half_angle && dist <= config.forward_range {
|
||||
// Beyond peripheral range but within visible angle and forward range:
|
||||
// still visible at reduced quality
|
||||
Some(VisibilitySector::Peripheral)
|
||||
} else {
|
||||
None // Blind spot
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply vision cone to a raw shadowcast VisibilityMap.
|
||||
/// Returns only tiles in Forward or Peripheral sectors, with sector tags.
|
||||
/// Tiles in the blind spot (behind) are excluded.
|
||||
pub fn apply_vision_cone(
|
||||
fov: &VisibilityMap,
|
||||
observer_x: i32,
|
||||
observer_y: i32,
|
||||
facing: FacingDirection,
|
||||
config: &VisionConeConfig,
|
||||
) -> Vec<(i32, i32, VisibilitySector)> {
|
||||
fov.visible_tiles()
|
||||
.filter_map(|(x, y)| {
|
||||
classify_tile(observer_x, observer_y, x, y, facing, config)
|
||||
.map(|sector| (x, y, sector))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn default_config() -> VisionConeConfig {
|
||||
VisionConeConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observer_position_always_forward() {
|
||||
let config = default_config();
|
||||
for dir in [
|
||||
FacingDirection::North,
|
||||
FacingDirection::South,
|
||||
FacingDirection::East,
|
||||
FacingDirection::West,
|
||||
] {
|
||||
let result = classify_tile(5, 5, 5, 5, dir, &config);
|
||||
assert_eq!(result, Some(VisibilitySector::Forward));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_sector_north() {
|
||||
let config = default_config();
|
||||
// Facing north, tile directly north should be Forward
|
||||
let result = classify_tile(5, 5, 5, 3, FacingDirection::North, &config);
|
||||
assert_eq!(result, Some(VisibilitySector::Forward));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripheral_sector_sides() {
|
||||
let config = default_config();
|
||||
// Facing north, tile to the east should be Peripheral
|
||||
let result = classify_tile(5, 5, 8, 5, FacingDirection::North, &config);
|
||||
assert_eq!(result, Some(VisibilitySector::Peripheral));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn behind_is_blind() {
|
||||
let config = default_config();
|
||||
// Facing north, tile directly south should be blind (None)
|
||||
let result = classify_tile(5, 5, 5, 10, FacingDirection::North, &config);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_range_limit() {
|
||||
let config = default_config();
|
||||
// Tile at forward range should be visible
|
||||
let result = classify_tile(0, 0, 0, -20, FacingDirection::North, &config);
|
||||
assert_eq!(result, Some(VisibilitySector::Forward));
|
||||
// Tile beyond forward range should not be (but this would not be in FOV anyway)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripheral_range_limit() {
|
||||
let config = default_config();
|
||||
// Tile at distance > peripheral_range but in peripheral angle:
|
||||
// should be Peripheral (within forward_range)
|
||||
let result = classify_tile(0, 0, 15, 0, FacingDirection::North, &config);
|
||||
assert_eq!(result, Some(VisibilitySector::Peripheral));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_facing_directions_produce_forward() {
|
||||
let config = default_config();
|
||||
// For each facing direction, the tile directly ahead should be Forward
|
||||
let cases = [
|
||||
(FacingDirection::North, (0, -3)),
|
||||
(FacingDirection::South, (0, 3)),
|
||||
(FacingDirection::East, (3, 0)),
|
||||
(FacingDirection::West, (-3, 0)),
|
||||
(FacingDirection::Northeast, (3, -3)),
|
||||
(FacingDirection::Southeast, (3, 3)),
|
||||
(FacingDirection::Southwest, (-3, 3)),
|
||||
(FacingDirection::Northwest, (-3, -3)),
|
||||
];
|
||||
for (dir, (dx, dy)) in cases {
|
||||
let result = classify_tile(5, 5, 5 + dx, 5 + dy, dir, &config);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(VisibilitySector::Forward),
|
||||
"Facing {:?}, tile ({}, {}) should be Forward",
|
||||
dir,
|
||||
5 + dx,
|
||||
5 + dy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facing_from_delta_all_directions() {
|
||||
assert_eq!(facing_from_delta(0, -1), FacingDirection::North);
|
||||
assert_eq!(facing_from_delta(0, 1), FacingDirection::South);
|
||||
assert_eq!(facing_from_delta(1, 0), FacingDirection::East);
|
||||
assert_eq!(facing_from_delta(-1, 0), FacingDirection::West);
|
||||
assert_eq!(facing_from_delta(1, -1), FacingDirection::Northeast);
|
||||
assert_eq!(facing_from_delta(-1, -1), FacingDirection::Northwest);
|
||||
assert_eq!(facing_from_delta(1, 1), FacingDirection::Southeast);
|
||||
assert_eq!(facing_from_delta(-1, 1), FacingDirection::Southwest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_vision_cone_filters_behind() {
|
||||
use crate::perception::shadowcast::compute_fov;
|
||||
|
||||
let fov = compute_fov(|_, _| false, 5, 5, 10, 0);
|
||||
let config = default_config();
|
||||
let cone = apply_vision_cone(&fov, 5, 5, FacingDirection::North, &config);
|
||||
|
||||
// Should have some tiles
|
||||
assert!(!cone.is_empty());
|
||||
|
||||
// Tile directly south (same x, far behind) should be in blind spot
|
||||
// The blind spot is the 60 degrees directly behind
|
||||
let has_direct_south_far = cone.iter().any(|&(x, y, _)| x == 5 && y >= 10);
|
||||
assert!(
|
||||
!has_direct_south_far,
|
||||
"tiles directly behind (same column, far south) should be blind"
|
||||
);
|
||||
|
||||
// Origin should be present
|
||||
let has_origin = cone.iter().any(|&(x, y, _)| x == 5 && y == 5);
|
||||
assert!(has_origin, "observer position should be in cone");
|
||||
|
||||
// Tiles directly north should be Forward
|
||||
let north_tiles: Vec<_> = cone
|
||||
.iter()
|
||||
.filter(|&&(x, _, _)| x == 5)
|
||||
.filter(|&&(_, y, _)| y < 5)
|
||||
.collect();
|
||||
assert!(!north_tiles.is_empty());
|
||||
for &&(_, _, sector) in &north_tiles {
|
||||
assert_eq!(sector, VisibilitySector::Forward);
|
||||
}
|
||||
|
||||
// Fewer tiles behind than in front (asymmetric cone)
|
||||
let tiles_north = cone.iter().filter(|&&(_, y, _)| y < 5).count();
|
||||
let tiles_south = cone.iter().filter(|&&(_, y, _)| y > 5).count();
|
||||
assert!(
|
||||
tiles_north > tiles_south,
|
||||
"should see more tiles forward (north={}) than behind (south={})",
|
||||
tiles_north,
|
||||
tiles_south
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// PlayerInput: semantic actions (MoveNorth, Interact, UsePerceptionMode)
|
||||
|
||||
use crate::bridge::types::{PlayerAction, PlayerInput};
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
|
||||
use crate::simulation::time::SimulationTime;
|
||||
use bevy_ecs::prelude::*;
|
||||
@@ -101,6 +102,10 @@ fn apply_move(
|
||||
commands.entity(entity).insert(MoveIntent {
|
||||
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
|
||||
});
|
||||
// Update facing direction based on movement (D-015 vision cone)
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(Facing(facing_from_delta(dx, dy)));
|
||||
} else {
|
||||
tracing::warn!("No player entity found for movement input");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::local::LocalBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::SimBridge;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
@@ -32,14 +33,24 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
let bridge = LocalBridge::accept(&server_path).expect("failed to accept");
|
||||
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: 2,
|
||||
tick: 42,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 100,
|
||||
x: 10.5,
|
||||
y: 20.3,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
}],
|
||||
visible_tiles: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -4,6 +4,7 @@ use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
||||
use settled_reach_server::bridge::tcp::TcpBridge;
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::bridge::SimBridge;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::thread;
|
||||
|
||||
@@ -18,14 +19,24 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
let bridge = TcpBridge::accept_on(listener).expect("failed to accept");
|
||||
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: 2,
|
||||
tick: 42,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 100,
|
||||
x: 10.5,
|
||||
y: 20.3,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
}],
|
||||
visible_tiles: vec![],
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -56,9 +56,15 @@ fn player_moves_north_through_full_pipeline() {
|
||||
rmp_serde::from_slice(&response).expect("deserialize snapshot");
|
||||
|
||||
// Snapshot captures state at end of tick 0 (before advance_tick increments to 1)
|
||||
assert_eq!(snapshot.version, 2);
|
||||
assert_eq!(snapshot.tick, 0);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
|
||||
// v2 fields populated
|
||||
assert_eq!(snapshot.game_time.day, 0);
|
||||
assert_eq!(snapshot.game_time.day_phase, settled_reach_server::simulation::time::DayPhase::Morning);
|
||||
assert!(!snapshot.game_time.paused);
|
||||
|
||||
let player_entity = &snapshot.entities[0];
|
||||
// Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0)
|
||||
// Render coords: (16.5, 15.5, 0)
|
||||
@@ -66,6 +72,7 @@ fn player_moves_north_through_full_pipeline() {
|
||||
assert_eq!(player_entity.y, 15.5);
|
||||
assert_eq!(player_entity.z, 0);
|
||||
assert!(matches!(player_entity.kind, EntityKind::Player));
|
||||
assert!(matches!(player_entity.visibility, VisibilitySector::Forward));
|
||||
|
||||
// Clean up
|
||||
drop(reader);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Run with: cargo test --test gen_fixtures -- --ignored
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -14,30 +15,45 @@ fn write_fixture(name: &str, bytes: &[u8]) {
|
||||
eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len());
|
||||
}
|
||||
|
||||
/// Helper to create a minimal v2 snapshot for fixtures
|
||||
fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
ObserverSnapshot {
|
||||
version: 2,
|
||||
tick,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
entities,
|
||||
visible_tiles: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // Run manually: cargo test --test gen_fixtures -- --ignored
|
||||
fn generate_msgpack_fixtures() {
|
||||
// Snapshot with one NPC entity
|
||||
let snapshot = ObserverSnapshot {
|
||||
tick: 42,
|
||||
entities: vec![VisibleEntity {
|
||||
let snapshot = fixture_snapshot(
|
||||
42,
|
||||
vec![VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
}],
|
||||
};
|
||||
);
|
||||
write_fixture(
|
||||
"snapshot_one_npc",
|
||||
&rmp_serde::to_vec_named(&snapshot).unwrap(),
|
||||
);
|
||||
|
||||
// Empty snapshot
|
||||
let empty = ObserverSnapshot {
|
||||
tick: 0,
|
||||
entities: vec![],
|
||||
};
|
||||
let empty = fixture_snapshot(0, vec![]);
|
||||
write_fixture("snapshot_empty", &rmp_serde::to_vec_named(&empty).unwrap());
|
||||
|
||||
// PlayerInput: MoveNorth
|
||||
@@ -60,32 +76,34 @@ fn generate_msgpack_fixtures() {
|
||||
&rmp_serde::to_vec_named(&input_perception).unwrap(),
|
||||
);
|
||||
|
||||
// Snapshot with Player entity (EntityKind::Player added by server team)
|
||||
let snapshot_player = ObserverSnapshot {
|
||||
tick: 1,
|
||||
entities: vec![VisibleEntity {
|
||||
// Snapshot with Player entity
|
||||
let snapshot_player = fixture_snapshot(
|
||||
1,
|
||||
vec![VisibleEntity {
|
||||
entity_id: 100,
|
||||
x: 16.5,
|
||||
y: 16.5,
|
||||
z: 0,
|
||||
kind: EntityKind::Player,
|
||||
visibility: VisibilitySector::Forward,
|
||||
}],
|
||||
};
|
||||
);
|
||||
write_fixture(
|
||||
"snapshot_player",
|
||||
&rmp_serde::to_vec_named(&snapshot_player).unwrap(),
|
||||
);
|
||||
|
||||
// Snapshot with multiple entities and all EntityKind variants
|
||||
let snapshot_multi = ObserverSnapshot {
|
||||
tick: 999,
|
||||
entities: vec![
|
||||
let snapshot_multi = fixture_snapshot(
|
||||
999,
|
||||
vec![
|
||||
VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 16.5,
|
||||
y: 16.5,
|
||||
z: 0,
|
||||
kind: EntityKind::Player,
|
||||
visibility: VisibilitySector::Forward,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 2,
|
||||
@@ -93,6 +111,7 @@ fn generate_msgpack_fixtures() {
|
||||
y: 10.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Peripheral,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 3,
|
||||
@@ -100,6 +119,7 @@ fn generate_msgpack_fixtures() {
|
||||
y: 3.0,
|
||||
z: 1,
|
||||
kind: EntityKind::Object,
|
||||
visibility: VisibilitySector::Forward,
|
||||
},
|
||||
VisibleEntity {
|
||||
entity_id: 4,
|
||||
@@ -107,12 +127,58 @@ fn generate_msgpack_fixtures() {
|
||||
y: 0.0,
|
||||
z: -1,
|
||||
kind: EntityKind::Terrain,
|
||||
visibility: VisibilitySector::Forward,
|
||||
},
|
||||
],
|
||||
);
|
||||
write_fixture(
|
||||
"snapshot_multi_entity",
|
||||
&rmp_serde::to_vec_named(&snapshot_multi).unwrap(),
|
||||
);
|
||||
|
||||
// v2 snapshot with visible_tiles and game_time populated
|
||||
let snapshot_v2_full = ObserverSnapshot {
|
||||
version: 2,
|
||||
tick: 500,
|
||||
game_time: GameTime {
|
||||
day: 1,
|
||||
time_of_day: 720,
|
||||
day_phase: DayPhase::Evening,
|
||||
paused: false,
|
||||
},
|
||||
player_facing: FacingDirection::Southeast,
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 10.5,
|
||||
y: 10.5,
|
||||
z: 0,
|
||||
kind: EntityKind::Player,
|
||||
visibility: VisibilitySector::Forward,
|
||||
}],
|
||||
visible_tiles: vec![
|
||||
VisibleTile {
|
||||
x: 10,
|
||||
y: 10,
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
},
|
||||
VisibleTile {
|
||||
x: 11,
|
||||
y: 10,
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Peripheral,
|
||||
},
|
||||
VisibleTile {
|
||||
x: 10,
|
||||
y: 9,
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
},
|
||||
],
|
||||
};
|
||||
write_fixture(
|
||||
"snapshot_multi_entity",
|
||||
&rmp_serde::to_vec_named(&snapshot_multi).unwrap(),
|
||||
"snapshot_v2_full",
|
||||
&rmp_serde::to_vec_named(&snapshot_v2_full).unwrap(),
|
||||
);
|
||||
|
||||
// Batch input: Vec<PlayerInput> with two actions (D-030 Layer 1 bidirectional symmetry)
|
||||
|
||||
+113
-12
@@ -1,24 +1,44 @@
|
||||
//! IPC serialization round-trip tests (D-030 Layer 1: fixture-based).
|
||||
|
||||
use settled_reach_server::bridge::types::*;
|
||||
use settled_reach_server::simulation::time::DayPhase;
|
||||
use std::fs;
|
||||
|
||||
/// Helper to create a minimal v2 snapshot for tests
|
||||
fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
ObserverSnapshot {
|
||||
version: 2,
|
||||
tick,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
entities,
|
||||
visible_tiles: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observer_snapshot_roundtrip() {
|
||||
let snapshot = ObserverSnapshot {
|
||||
tick: 42,
|
||||
entities: vec![VisibleEntity {
|
||||
let snapshot = test_snapshot(
|
||||
42,
|
||||
vec![VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 10.0,
|
||||
y: 20.0,
|
||||
z: 0,
|
||||
kind: EntityKind::Npc,
|
||||
visibility: VisibilitySector::Forward,
|
||||
}],
|
||||
};
|
||||
);
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded.version, 2);
|
||||
assert_eq!(decoded.tick, 42);
|
||||
assert_eq!(decoded.entities.len(), 1);
|
||||
assert_eq!(decoded.entities[0].entity_id, 1);
|
||||
@@ -39,10 +59,7 @@ fn player_input_roundtrip() {
|
||||
|
||||
#[test]
|
||||
fn empty_snapshot_roundtrip() {
|
||||
let snapshot = ObserverSnapshot {
|
||||
tick: 0,
|
||||
entities: vec![],
|
||||
};
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
@@ -140,11 +157,9 @@ fn all_entity_kind_variants_roundtrip() {
|
||||
y: 0.0,
|
||||
z: 0,
|
||||
kind,
|
||||
visibility: VisibilitySector::Forward,
|
||||
};
|
||||
let snapshot = ObserverSnapshot {
|
||||
tick: 0,
|
||||
entities: vec![entity],
|
||||
};
|
||||
let snapshot = test_snapshot(0, vec![entity]);
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
let re_bytes = rmp_serde::to_vec_named(&decoded).expect("re-serialize");
|
||||
@@ -154,3 +169,89 @@ fn all_entity_kind_variants_roundtrip() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// v2 snapshot fields round-trip correctly
|
||||
#[test]
|
||||
fn snapshot_v2_fields_roundtrip() {
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: 2,
|
||||
tick: 100,
|
||||
game_time: GameTime {
|
||||
day: 3,
|
||||
time_of_day: 720,
|
||||
day_phase: DayPhase::Evening,
|
||||
paused: true,
|
||||
},
|
||||
player_facing: FacingDirection::Southeast,
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 5.5,
|
||||
y: 10.5,
|
||||
z: 0,
|
||||
kind: EntityKind::Player,
|
||||
visibility: VisibilitySector::Forward,
|
||||
}],
|
||||
visible_tiles: vec![
|
||||
VisibleTile {
|
||||
x: 5,
|
||||
y: 10,
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Forward,
|
||||
},
|
||||
VisibleTile {
|
||||
x: 6,
|
||||
y: 10,
|
||||
z: 0,
|
||||
visibility: VisibilitySector::Peripheral,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
|
||||
assert_eq!(decoded.version, 2);
|
||||
assert_eq!(decoded.game_time.day, 3);
|
||||
assert_eq!(decoded.game_time.time_of_day, 720);
|
||||
assert_eq!(decoded.game_time.day_phase, DayPhase::Evening);
|
||||
assert!(decoded.game_time.paused);
|
||||
assert_eq!(decoded.player_facing, FacingDirection::Southeast);
|
||||
assert_eq!(decoded.visible_tiles.len(), 2);
|
||||
assert_eq!(decoded.visible_tiles[0].visibility, VisibilitySector::Forward);
|
||||
assert_eq!(decoded.visible_tiles[1].visibility, VisibilitySector::Peripheral);
|
||||
assert_eq!(decoded.entities[0].visibility, VisibilitySector::Forward);
|
||||
}
|
||||
|
||||
/// All FacingDirection variants round-trip
|
||||
#[test]
|
||||
fn all_facing_direction_variants_roundtrip() {
|
||||
let directions = [
|
||||
FacingDirection::North,
|
||||
FacingDirection::Northeast,
|
||||
FacingDirection::East,
|
||||
FacingDirection::Southeast,
|
||||
FacingDirection::South,
|
||||
FacingDirection::Southwest,
|
||||
FacingDirection::West,
|
||||
FacingDirection::Northwest,
|
||||
];
|
||||
|
||||
for dir in directions {
|
||||
let snapshot = ObserverSnapshot {
|
||||
version: 2,
|
||||
tick: 0,
|
||||
game_time: GameTime {
|
||||
day: 0,
|
||||
time_of_day: 0,
|
||||
day_phase: DayPhase::Morning,
|
||||
paused: false,
|
||||
},
|
||||
player_facing: dir,
|
||||
entities: vec![],
|
||||
visible_tiles: vec![],
|
||||
};
|
||||
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
||||
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
||||
assert_eq!(decoded.player_facing, dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Shadowcasting algorithm benchmarks
|
||||
//!
|
||||
//! Compares performance of symmetric vs recursive shadowcasting
|
||||
//! Run with: cargo test --test shadowcast_bench -- --ignored --nocapture
|
||||
|
||||
use rand::Rng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
use rand::SeedableRng;
|
||||
use settled_reach_server::perception::shadowcast::{symmetric_shadowcast, recursive_shadowcast};
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Configuration for a benchmark run
|
||||
struct BenchConfig {
|
||||
map_size: i32,
|
||||
wall_density: f64, // 0.0 to 1.0
|
||||
vision_range: i32,
|
||||
iterations: usize,
|
||||
seed: u64,
|
||||
}
|
||||
|
||||
/// Generate a random wall map with specified density
|
||||
fn generate_wall_map(size: i32, density: f64, seed: u64) -> HashSet<(i32, i32)> {
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(seed);
|
||||
let mut walls = HashSet::new();
|
||||
|
||||
for x in 0..size {
|
||||
for y in 0..size {
|
||||
if rng.random::<f64>() < density {
|
||||
walls.insert((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walls
|
||||
}
|
||||
|
||||
/// Run benchmark for a single configuration
|
||||
fn bench_config(config: &BenchConfig) -> BenchResults {
|
||||
let walls = generate_wall_map(config.map_size, config.wall_density, config.seed);
|
||||
let is_opaque = |x: i32, y: i32| walls.contains(&(x, y));
|
||||
|
||||
// Pick random origin points (deterministic from same seed)
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(config.seed + 1000);
|
||||
let origins: Vec<(i32, i32)> = (0..config.iterations)
|
||||
.map(|_| {
|
||||
let x = rng.random_range(0..config.map_size);
|
||||
let y = rng.random_range(0..config.map_size);
|
||||
(x, y)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Benchmark symmetric shadowcasting
|
||||
let start = Instant::now();
|
||||
let mut symmetric_total_tiles = 0;
|
||||
for &(x, y) in &origins {
|
||||
let visible = symmetric_shadowcast(&is_opaque, x, y, config.vision_range);
|
||||
symmetric_total_tiles += visible.len();
|
||||
}
|
||||
let symmetric_duration = start.elapsed();
|
||||
|
||||
// Benchmark recursive shadowcasting
|
||||
let start = Instant::now();
|
||||
let mut recursive_total_tiles = 0;
|
||||
for &(x, y) in &origins {
|
||||
let visible = recursive_shadowcast(&is_opaque, x, y, config.vision_range);
|
||||
recursive_total_tiles += visible.len();
|
||||
}
|
||||
let recursive_duration = start.elapsed();
|
||||
|
||||
BenchResults {
|
||||
symmetric_ms: symmetric_duration.as_secs_f64() * 1000.0,
|
||||
recursive_ms: recursive_duration.as_secs_f64() * 1000.0,
|
||||
symmetric_avg_tiles: symmetric_total_tiles as f64 / config.iterations as f64,
|
||||
recursive_avg_tiles: recursive_total_tiles as f64 / config.iterations as f64,
|
||||
}
|
||||
}
|
||||
|
||||
struct BenchResults {
|
||||
symmetric_ms: f64,
|
||||
recursive_ms: f64,
|
||||
symmetric_avg_tiles: f64,
|
||||
recursive_avg_tiles: f64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn benchmark_symmetric_vs_recursive() {
|
||||
println!("\n=== Shadowcasting Algorithm Benchmark ===\n");
|
||||
println!("Comparing Symmetric (Albert Ford) vs Traditional Recursive\n");
|
||||
|
||||
let configs = vec![
|
||||
// 32x32 maps
|
||||
BenchConfig {
|
||||
map_size: 32,
|
||||
wall_density: 0.0,
|
||||
vision_range: 20,
|
||||
iterations: 1000,
|
||||
seed: 42,
|
||||
},
|
||||
BenchConfig {
|
||||
map_size: 32,
|
||||
wall_density: 0.1,
|
||||
vision_range: 20,
|
||||
iterations: 1000,
|
||||
seed: 42,
|
||||
},
|
||||
BenchConfig {
|
||||
map_size: 32,
|
||||
wall_density: 0.3,
|
||||
vision_range: 20,
|
||||
iterations: 1000,
|
||||
seed: 42,
|
||||
},
|
||||
// 64x64 maps
|
||||
BenchConfig {
|
||||
map_size: 64,
|
||||
wall_density: 0.0,
|
||||
vision_range: 20,
|
||||
iterations: 1000,
|
||||
seed: 42,
|
||||
},
|
||||
BenchConfig {
|
||||
map_size: 64,
|
||||
wall_density: 0.1,
|
||||
vision_range: 20,
|
||||
iterations: 1000,
|
||||
seed: 42,
|
||||
},
|
||||
BenchConfig {
|
||||
map_size: 64,
|
||||
wall_density: 0.3,
|
||||
vision_range: 20,
|
||||
iterations: 1000,
|
||||
seed: 42,
|
||||
},
|
||||
// 150x150 maps
|
||||
BenchConfig {
|
||||
map_size: 150,
|
||||
wall_density: 0.0,
|
||||
vision_range: 20,
|
||||
iterations: 1000,
|
||||
seed: 42,
|
||||
},
|
||||
BenchConfig {
|
||||
map_size: 150,
|
||||
wall_density: 0.1,
|
||||
vision_range: 20,
|
||||
iterations: 1000,
|
||||
seed: 42,
|
||||
},
|
||||
BenchConfig {
|
||||
map_size: 150,
|
||||
wall_density: 0.3,
|
||||
vision_range: 20,
|
||||
iterations: 1000,
|
||||
seed: 42,
|
||||
},
|
||||
];
|
||||
|
||||
for config in configs {
|
||||
let density_str = match (config.wall_density * 100.0) as i32 {
|
||||
0 => "open field",
|
||||
10 => "moderate corridors",
|
||||
30 => "dense rooms",
|
||||
d => &format!("{}% walls", d),
|
||||
};
|
||||
|
||||
println!(
|
||||
"Map: {}x{}, Density: {}, Range: {}, Iterations: {}",
|
||||
config.map_size, config.map_size, density_str, config.vision_range, config.iterations
|
||||
);
|
||||
|
||||
let results = bench_config(&config);
|
||||
|
||||
println!(" Symmetric: {:.2}ms total, {:.2}µs/call, {:.1} tiles avg",
|
||||
results.symmetric_ms,
|
||||
results.symmetric_ms * 1000.0 / config.iterations as f64,
|
||||
results.symmetric_avg_tiles
|
||||
);
|
||||
println!(" Recursive: {:.2}ms total, {:.2}µs/call, {:.1} tiles avg",
|
||||
results.recursive_ms,
|
||||
results.recursive_ms * 1000.0 / config.iterations as f64,
|
||||
results.recursive_avg_tiles
|
||||
);
|
||||
|
||||
let speedup = results.recursive_ms / results.symmetric_ms;
|
||||
let comparison = if speedup > 1.0 {
|
||||
format!("Symmetric is {:.2}x faster", speedup)
|
||||
} else {
|
||||
format!("Recursive is {:.2}x faster", 1.0 / speedup)
|
||||
};
|
||||
println!(" → {}\n", comparison);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_algorithm_is_symmetric() {
|
||||
// Verify that if A sees B, then B sees A (symmetric property)
|
||||
// NOTE: Testing a subset of cases due to edge-case complexity in full grid testing
|
||||
println!("\n=== Testing Symmetric Property (simplified) ===\n");
|
||||
|
||||
// Simple open field test - perfect symmetry should hold here
|
||||
let no_walls: HashSet<(i32, i32)> = HashSet::new();
|
||||
let is_opaque = |x: i32, y: i32| no_walls.contains(&(x, y));
|
||||
|
||||
let test_positions = vec![(0, 0), (3, 3), (5, 2), (1, 7)];
|
||||
let range = 8;
|
||||
let mut failures = 0;
|
||||
|
||||
for &(ax, ay) in &test_positions {
|
||||
let a_visible = symmetric_shadowcast(&is_opaque, ax, ay, range);
|
||||
|
||||
for &(bx, by) in &test_positions {
|
||||
if ax == bx && ay == by {
|
||||
continue; // Skip self
|
||||
}
|
||||
|
||||
let b_visible = symmetric_shadowcast(&is_opaque, bx, by, range);
|
||||
|
||||
// If A sees B, then B should see A
|
||||
if a_visible.contains(&(bx, by)) && !b_visible.contains(&(ax, ay)) {
|
||||
println!(
|
||||
"SYMMETRY VIOLATION: ({}, {}) sees ({}, {}) but not vice versa",
|
||||
ax, ay, bx, by
|
||||
);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failures == 0 {
|
||||
println!("✓ Symmetry verified for test cases\n");
|
||||
} else {
|
||||
println!("✗ Found {} symmetry violations\n", failures);
|
||||
}
|
||||
|
||||
assert_eq!(failures, 0, "Symmetry property violated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_algorithms_agree_on_basic_cases() {
|
||||
// Verify both algorithms produce similar results on basic scenarios
|
||||
println!("\n=== Comparing Algorithm Results ===\n");
|
||||
|
||||
let test_cases = vec![
|
||||
("Open field", HashSet::new()),
|
||||
("Single wall at (2,0)", {
|
||||
let mut w = HashSet::new();
|
||||
w.insert((2, 0));
|
||||
w
|
||||
}),
|
||||
("L-shaped corridor", {
|
||||
let mut w = HashSet::new();
|
||||
for i in 0..5 {
|
||||
w.insert((i, 2));
|
||||
w.insert((2, i));
|
||||
}
|
||||
w
|
||||
}),
|
||||
];
|
||||
|
||||
for (name, walls) in test_cases {
|
||||
let is_opaque = |x: i32, y: i32| walls.contains(&(x, y));
|
||||
let origin = (0, 0);
|
||||
let range = 10;
|
||||
|
||||
let symmetric = symmetric_shadowcast(&is_opaque, origin.0, origin.1, range);
|
||||
let recursive = recursive_shadowcast(&is_opaque, origin.0, origin.1, range);
|
||||
|
||||
println!("Test case: {}", name);
|
||||
println!(" Symmetric: {} tiles visible", symmetric.len());
|
||||
println!(" Recursive: {} tiles visible", recursive.len());
|
||||
|
||||
// They may not match exactly due to algorithmic differences, but should be close
|
||||
let diff = (symmetric.len() as i32 - recursive.len() as i32).abs();
|
||||
let max_allowed_diff = (symmetric.len() as f64 * 0.1).ceil() as i32; // 10% tolerance
|
||||
|
||||
if diff <= max_allowed_diff {
|
||||
println!(" ✓ Results within tolerance (diff: {})\n", diff);
|
||||
} else {
|
||||
println!(" ⚠ Large difference (diff: {})\n", diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user