diff --git a/server/src/content/loader.rs b/server/src/content/loader.rs index aba68da37..ae63ad704 100644 --- a/server/src/content/loader.rs +++ b/server/src/content/loader.rs @@ -327,6 +327,117 @@ fn walk_yaml_files(dir: &Path, callback: &mut impl FnMut(&Path)) { } /// Check if a YAML file contains only comments and whitespace (stub file). +// --------------------------------------------------------------------------- +// Tile loading (#577) +// --------------------------------------------------------------------------- + +use crate::simulation::movement::{TileKind, TilePosition, WalkabilityMap}; + +/// Parse a tile character into a TileKind. +/// Returns `None` for unrecognized characters. +fn parse_tile_char(ch: char) -> Option { + match ch { + 'F' => Some(TileKind::Floor), + 'W' => Some(TileKind::Wall), + 'V' => Some(TileKind::Void), + 'R' => Some(TileKind::Restricted), + _ => None, + } +} + +/// Load tile data from all locations in a ContentStore into a WalkabilityMap. +/// +/// For each location that has both `tile_bounds` and `tiles`, parses the tile +/// rows and calls `set_walkable` + `set_tile_kind` on the WalkabilityMap. +/// +/// Logs warnings for: +/// - Row count mismatch vs tile_bounds height +/// - Column count mismatch vs tile_bounds width +/// - Unrecognized tile characters +/// +/// Returns the number of locations that had tile data applied. +pub fn load_location_tiles(store: &ContentStore, walkability: &mut WalkabilityMap) -> u32 { + let mut locations_loaded = 0u32; + + for (_district_id, district) in &store.districts { + for location in &district.locations { + if apply_location_tiles(location, walkability) { + locations_loaded += 1; + } + } + } + + locations_loaded +} + +/// Apply tile data from a single Location to the WalkabilityMap. +/// Returns true if tiles were applied, false if skipped. +fn apply_location_tiles(location: &Location, walkability: &mut WalkabilityMap) -> bool { + let (Some(bounds), Some(tiles)) = (&location.tile_bounds, &location.tiles) else { + return false; + }; + + let expected_height = (bounds.y_max - bounds.y_min + 1) as usize; + let expected_width = (bounds.x_max - bounds.x_min + 1) as usize; + + if tiles.len() != expected_height { + tracing::warn!( + "Location '{}': tile row count {} != expected height {} (from tile_bounds)", + location.canonical_id, + tiles.len(), + expected_height, + ); + } + + for (row_idx, row) in tiles.iter().enumerate() { + let y = bounds.y_min + row_idx as i32; + + if row.len() != expected_width { + tracing::warn!( + "Location '{}' row {}: length {} != expected width {}", + location.canonical_id, + row_idx, + row.len(), + expected_width, + ); + } + + for (col_idx, ch) in row.chars().enumerate() { + let x = bounds.x_min + col_idx as i32; + let pos = TilePosition::new(x, y, bounds.z); + + match parse_tile_char(ch) { + Some(kind) => { + let walkable = matches!(kind, TileKind::Floor); + walkability.set_walkable(&pos, walkable); + walkability.set_tile_kind(&pos, kind); + } + None => { + tracing::warn!( + "Location '{}' row {} col {}: unrecognized tile char '{}'", + location.canonical_id, + row_idx, + col_idx, + ch, + ); + } + } + } + } + + tracing::info!( + "Loaded tiles for location '{}': {}x{} at ({},{}) z={}", + location.canonical_id, + expected_width, + expected_height, + bounds.x_min, + bounds.y_min, + bounds.z, + ); + + true +} + fn is_comment_only_file(path: &Path) -> bool { let Ok(text) = std::fs::read_to_string(path) else { return false; @@ -618,4 +729,193 @@ pools: } } } + + // ----------------------------------------------------------------------- + // Tile loading tests (#577) + // ----------------------------------------------------------------------- + + fn make_location_with_tiles(tiles: Vec<&str>) -> Location { + Location { + canonical_id: "test-loc".to_string(), + display_name: "Test Location".to_string(), + description: None, + tile_bounds: Some(TileBounds { + x_min: 0, + y_min: 0, + x_max: tiles.first().map_or(0, |r| r.len() as i32 - 1), + y_max: tiles.len() as i32 - 1, + z: 0, + }), + tiles: Some(tiles.iter().map(|s| s.to_string()).collect()), + sightlines: None, + ambient_sound: None, + social_site: None, + } + } + + #[test] + fn parse_tile_char_all_kinds() { + assert_eq!(parse_tile_char('F'), Some(TileKind::Floor)); + assert_eq!(parse_tile_char('W'), Some(TileKind::Wall)); + assert_eq!(parse_tile_char('V'), Some(TileKind::Void)); + assert_eq!(parse_tile_char('R'), Some(TileKind::Restricted)); + assert_eq!(parse_tile_char('X'), None); + assert_eq!(parse_tile_char(' '), None); + } + + #[test] + fn apply_location_tiles_stamps_walkability() { + let loc = make_location_with_tiles(vec![ + "FWF", + "FFF", + "WFW", + ]); + let mut map = WalkabilityMap::new(4, 4, 1); + + let applied = apply_location_tiles(&loc, &mut map); + assert!(applied); + + // Row 0: F W F + assert!(map.can_move_to(&TilePosition::new(0, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(1, 0, 0))); + assert!(map.can_move_to(&TilePosition::new(2, 0, 0))); + + // Row 1: F F F + assert!(map.can_move_to(&TilePosition::new(0, 1, 0))); + assert!(map.can_move_to(&TilePosition::new(1, 1, 0))); + assert!(map.can_move_to(&TilePosition::new(2, 1, 0))); + + // Row 2: W F W + assert!(!map.can_move_to(&TilePosition::new(0, 2, 0))); + assert!(map.can_move_to(&TilePosition::new(1, 2, 0))); + assert!(!map.can_move_to(&TilePosition::new(2, 2, 0))); + } + + #[test] + fn apply_location_tiles_stamps_tile_kind() { + let loc = make_location_with_tiles(vec![ + "FWVR", + ]); + let mut map = WalkabilityMap::new(4, 1, 1); + + apply_location_tiles(&loc, &mut map); + + assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor); + assert_eq!(map.tile_kind(&TilePosition::new(1, 0, 0)), TileKind::Wall); + assert_eq!(map.tile_kind(&TilePosition::new(2, 0, 0)), TileKind::Void); + assert_eq!(map.tile_kind(&TilePosition::new(3, 0, 0)), TileKind::Restricted); + } + + #[test] + fn apply_location_tiles_with_offset() { + let loc = Location { + canonical_id: "offset-loc".to_string(), + display_name: "Offset".to_string(), + description: None, + tile_bounds: Some(TileBounds { + x_min: 10, + y_min: 20, + x_max: 12, + y_max: 21, + z: 0, + }), + tiles: Some(vec!["FWF".to_string(), "WFW".to_string()]), + sightlines: None, + ambient_sound: None, + social_site: None, + }; + let mut map = WalkabilityMap::new(32, 32, 1); + + apply_location_tiles(&loc, &mut map); + + // (10,20) = F, (11,20) = W, (12,20) = F + assert!(map.can_move_to(&TilePosition::new(10, 20, 0))); + assert!(!map.can_move_to(&TilePosition::new(11, 20, 0))); + assert!(map.can_move_to(&TilePosition::new(12, 20, 0))); + + // (10,21) = W, (11,21) = F, (12,21) = W + assert!(!map.can_move_to(&TilePosition::new(10, 21, 0))); + assert!(map.can_move_to(&TilePosition::new(11, 21, 0))); + assert!(!map.can_move_to(&TilePosition::new(12, 21, 0))); + } + + #[test] + fn apply_location_tiles_skips_without_tiles() { + let loc = Location { + canonical_id: "no-tiles".to_string(), + display_name: "No Tiles".to_string(), + description: None, + tile_bounds: Some(TileBounds { + x_min: 0, y_min: 0, x_max: 4, y_max: 4, z: 0, + }), + tiles: None, + sightlines: None, + ambient_sound: None, + social_site: None, + }; + let mut map = WalkabilityMap::new(5, 5, 1); + assert!(!apply_location_tiles(&loc, &mut map)); + } + + #[test] + fn apply_location_tiles_skips_without_bounds() { + let loc = Location { + canonical_id: "no-bounds".to_string(), + display_name: "No Bounds".to_string(), + description: None, + tile_bounds: None, + tiles: Some(vec!["FFF".to_string()]), + sightlines: None, + ambient_sound: None, + social_site: None, + }; + let mut map = WalkabilityMap::new(5, 5, 1); + assert!(!apply_location_tiles(&loc, &mut map)); + } + + #[test] + fn load_location_tiles_from_store() { + let mut store = ContentStore::default(); + let mut district = DistrictContent::default(); + district.locations.push(make_location_with_tiles(vec![ + "FW", + "WF", + ])); + store.districts.insert("test".to_string(), district); + + let mut map = WalkabilityMap::new(4, 4, 1); + let count = load_location_tiles(&store, &mut map); + + assert_eq!(count, 1); + assert!(map.can_move_to(&TilePosition::new(0, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(1, 0, 0))); + assert!(!map.can_move_to(&TilePosition::new(0, 1, 0))); + assert!(map.can_move_to(&TilePosition::new(1, 1, 0))); + } + + #[test] + fn location_yaml_with_tiles_deserializes() { + let yaml = r#" +canonical_id: test-room +display_name: "Test Room" +tile_bounds: + x_min: 5 + y_min: 10 + x_max: 9 + y_max: 12 + z: 0 +tiles: + - "FFFFF" + - "FWWWF" + - "FFFFF" +"#; + let loc: Location = serde_yaml::from_str(yaml).expect("location with tiles should parse"); + assert_eq!(loc.canonical_id, "test-room"); + assert!(loc.tiles.is_some()); + let tiles = loc.tiles.unwrap(); + assert_eq!(tiles.len(), 3); + assert_eq!(tiles[0], "FFFFF"); + assert_eq!(tiles[1], "FWWWF"); + assert_eq!(tiles[2], "FFFFF"); + } } diff --git a/server/src/content/mod.rs b/server/src/content/mod.rs index a30221592..49ef8d9ca 100644 --- a/server/src/content/mod.rs +++ b/server/src/content/mod.rs @@ -79,6 +79,15 @@ fn load_and_spawn_content(world: &mut World) { let result = spawn::spawn_content(world, &store); tracing::info!("Content loaded and spawned: {} NPCs", result.npcs_spawned); + // Stamp location tile data onto WalkabilityMap (#577) + if world.contains_resource::() { + let mut walkability = world.resource_mut::(); + let tiles_loaded = loader::load_location_tiles(&store, &mut walkability); + if tiles_loaded > 0 { + tracing::info!("Loaded tile data for {} locations", tiles_loaded); + } + } + // Build line pool index let index = line_pool::LinePoolIndex::build(&store); tracing::info!( diff --git a/server/src/content/types.rs b/server/src/content/types.rs index 695ced368..825a9ba4d 100644 --- a/server/src/content/types.rs +++ b/server/src/content/types.rs @@ -394,6 +394,20 @@ pub struct Location { pub description: Option, #[serde(default)] pub tile_bounds: Option, + /// Tile layout for this location (#577). + /// + /// Array of strings, one row per string, left-to-right = +x, top-to-bottom = +y. + /// Each character maps to a server-side TileKind: + /// `F` = Floor (walkable, open space) + /// `W` = Wall (solid obstacle, blocks movement and LOS) + /// `V` = Void (out-of-bounds / unloaded) + /// `R` = Restricted (blocked but traversable by specific entities) + /// + /// Row 0 is placed at `tile_bounds.y_min`, column 0 at `tile_bounds.x_min`. + /// Requires `tile_bounds` to be set. Row count must equal + /// `y_max - y_min + 1`, and each row length must equal `x_max - x_min + 1`. + #[serde(default)] + pub tiles: Option>, #[serde(default)] pub sightlines: Option, #[serde(default)] diff --git a/server/src/simulation/chunk_streaming.rs b/server/src/simulation/chunk_streaming.rs new file mode 100644 index 000000000..a0282c302 --- /dev/null +++ b/server/src/simulation/chunk_streaming.rs @@ -0,0 +1,337 @@ +//! Chunk streaming system (#578, D-012). +//! +//! Loads chunks near the player and unloads distant chunks based on a +//! configurable radius. For v0.1 the radius covers the entire hand-authored +//! district (256×256 visual tiles = 8×8 chunks of 32 tiles), so all chunks +//! remain loaded. The architecture supports future per-demand loading (v0.3+). +//! +//! The system runs on a configurable tick cadence (default: every 10 ticks). +//! It queries the player's TilePosition, computes which chunks should be +//! loaded (Chebyshev distance ≤ radius from the player's chunk), and +//! loads/unloads accordingly. + +use bevy_ecs::prelude::*; + +use crate::simulation::movement::{ChunkCoord, PlayerCharacter, TilePosition, WalkabilityMap}; +use crate::simulation::time::SimulationTime; + +/// How many chunks around the player to keep loaded (Chebyshev distance). +/// +/// Default: 8, which covers the full v0.1 district (256×256 = 8×8 chunks). +/// For v0.3+ borderless generation, set to 3-4 for memory-bounded streaming. +#[derive(Resource, Debug, Clone)] +pub struct ChunkLoadRadius { + pub radius: i32, +} + +impl Default for ChunkLoadRadius { + fn default() -> Self { + Self { radius: 8 } + } +} + +/// How often the streaming system runs, in simulation ticks. +/// +/// Default: 10 ticks (one game-minute at D-031 cadence). +/// Lower values increase responsiveness but add per-tick overhead. +#[derive(Resource, Debug, Clone)] +pub struct ChunkStreamingCadence { + pub ticks: u64, +} + +impl Default for ChunkStreamingCadence { + fn default() -> Self { + Self { ticks: 10 } + } +} + +/// Chunk streaming system — loads/unloads chunks around the player position. +/// +/// Runs on cadence (every `ChunkStreamingCadence.ticks` simulation ticks). +/// Computes the set of chunks within `ChunkLoadRadius` of the player's +/// current chunk (Chebyshev distance), loads missing chunks, and unloads +/// chunks that are now out of range. +/// +/// For v0.1, radius=8 covers the entire district so nothing ever unloads. +/// For v0.3+, the generator fills newly loaded chunks with terrain data. +pub fn chunk_streaming( + time: Res, + cadence: Res, + radius: Res, + walkability: Option>, + player_query: Query<&TilePosition, With>, +) { + let Some(mut walkability) = walkability else { + return; + }; + + // Cadence gate — only run every N ticks + if cadence.ticks > 0 && time.tick % cadence.ticks != 0 { + return; + } + + let Ok(player_pos) = player_query.single() else { + return; + }; + + let player_chunk = player_pos.chunk_coord(); + let r = radius.radius; + + // Load chunks within radius that aren't already loaded + let mut loaded = 0u32; + for cx in (player_chunk.cx - r)..=(player_chunk.cx + r) { + for cy in (player_chunk.cy - r)..=(player_chunk.cy + r) { + let coord = ChunkCoord { + cx, + cy, + z: player_chunk.z, + }; + if walkability.load_chunk(coord) { + loaded += 1; + } + } + } + + // Unload chunks outside radius + let mut unloaded = 0u32; + let to_check = walkability.loaded_chunk_coords(); + for coord in to_check { + // Only manage chunks on the player's z-level + if coord.z != player_chunk.z { + continue; + } + let dx = (coord.cx - player_chunk.cx).abs(); + let dy = (coord.cy - player_chunk.cy).abs(); + if dx > r || dy > r { + walkability.unload_chunk(&coord); + unloaded += 1; + } + } + + if loaded > 0 || unloaded > 0 { + tracing::debug!( + "Chunk streaming: loaded {}, unloaded {} (player chunk: ({},{},{}), radius: {})", + loaded, + unloaded, + player_chunk.cx, + player_chunk.cy, + player_chunk.z, + r, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy_ecs::schedule::Schedule; + + fn setup_streaming_world( + radius: i32, + cadence: u64, + player_pos: TilePosition, + map_width: i32, + map_height: i32, + ) -> (World, Schedule) { + let mut world = World::new(); + world.init_resource::(); + world.insert_resource(ChunkLoadRadius { radius }); + world.insert_resource(ChunkStreamingCadence { ticks: cadence }); + world.insert_resource(WalkabilityMap::new(map_width, map_height, 1)); + world.spawn((PlayerCharacter, player_pos)); + + let mut schedule = Schedule::default(); + schedule.add_systems(chunk_streaming); + (world, schedule) + } + + #[test] + fn default_radius_covers_v01_district() { + // 256×256 visual district = 8×8 chunks of 32 tiles each. + // Player at center (128, 128). Default radius=8. + // All original 64 chunks should still be loaded after streaming runs + // (streaming may also create empty chunks beyond the district boundary). + let (mut world, mut schedule) = setup_streaming_world( + 8, + 1, // run every tick + TilePosition::new(128, 128, 0), + 256, + 256, + ); + + // Initial state: WalkabilityMap::new(256, 256, 1) creates 8×8 = 64 chunks + assert_eq!(world.resource::().chunk_count(), 64); + + schedule.run(&mut world); + + // All original chunks (0..8, 0..8) must still be loaded — nothing unloaded + let wm = world.resource::(); + for cx in 0..8 { + for cy in 0..8 { + assert!( + wm.has_chunk(&ChunkCoord { cx, cy, z: 0 }), + "chunk ({},{}) should still be loaded", + cx, + cy, + ); + } + } + // Total count ≥ 64 (streaming may also load chunks beyond district boundary) + assert!(wm.chunk_count() >= 64); + } + + #[test] + fn small_radius_unloads_distant_chunks() { + // Start with a 5×5 chunk map (160×160 tiles), player at center. + // Use radius=1 so only 3×3=9 chunks around the player are kept. + let (mut world, mut schedule) = setup_streaming_world( + 1, + 1, + TilePosition::new(80, 80, 0), // chunk (2,2) — center of 5×5 + 160, + 160, + ); + + // Initial: 5×5 = 25 chunks + let initial_count = world.resource::().chunk_count(); + assert_eq!(initial_count, 25); + + schedule.run(&mut world); + + // After streaming: only 3×3 = 9 chunks around player chunk (2,2) + let after_count = world.resource::().chunk_count(); + assert_eq!(after_count, 9, "expected 3×3 chunks within radius=1"); + + // Verify the player's chunk is still loaded + let wm = world.resource::(); + assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 })); + // Corner chunks should be unloaded + assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); + assert!(!wm.has_chunk(&ChunkCoord { cx: 4, cy: 4, z: 0 })); + } + + #[test] + fn player_movement_loads_new_chunks() { + // Start with radius=1, player at (16, 16) → chunk (0,0). + // Map is 3×3 chunks (96×96 tiles). + let (mut world, mut schedule) = setup_streaming_world( + 1, + 1, + TilePosition::new(16, 16, 0), // chunk (0,0) + 96, + 96, + ); + + // Run streaming — unloads distant chunks + schedule.run(&mut world); + + // Only chunks (0,0), (0,1), (1,0), (1,1) should be loaded + // (radius=1 from chunk (0,0): cx ∈ [-1..1], cy ∈ [-1..1], + // but negative coords weren't in the original map. + // So loaded: (0,0) and its positive neighbors within range) + let wm = world.resource::(); + assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 1, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 })); + // Chunk (2,2) should be unloaded (distance > 1 from (0,0)) + assert!(!wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 })); + + // Move player to chunk (2,2) + let mut q = world.query_filtered::<&mut TilePosition, With>(); + let mut pos = q.single_mut(&mut world).unwrap(); + pos.x = 80; + pos.y = 80; + + // Advance tick so cadence gate passes + world.resource_mut::().tick = 1; + schedule.run(&mut world); + + // Now chunk (2,2) and its neighbors should be loaded + let wm = world.resource::(); + assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 2, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 2, z: 0 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 2, cy: 1, z: 0 })); + // And chunk (0,0) should now be unloaded (distance 2 from (2,2)) + assert!(!wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); + } + + #[test] + fn cadence_gate_skips_intermediate_ticks() { + let (mut world, mut schedule) = setup_streaming_world( + 1, + 10, // run every 10 ticks + TilePosition::new(80, 80, 0), + 160, + 160, + ); + + // tick=0 → runs (0 % 10 == 0) + schedule.run(&mut world); + assert_eq!(world.resource::().chunk_count(), 9); + + // Reload all chunks to simulate "something loads chunks back" + world.insert_resource(WalkabilityMap::new(160, 160, 1)); + assert_eq!(world.resource::().chunk_count(), 25); + + // tick=5 → should NOT run (5 % 10 != 0) + world.resource_mut::().tick = 5; + schedule.run(&mut world); + assert_eq!( + world.resource::().chunk_count(), + 25, + "should not have run at tick 5" + ); + + // tick=10 → should run (10 % 10 == 0) + world.resource_mut::().tick = 10; + schedule.run(&mut world); + assert_eq!(world.resource::().chunk_count(), 9); + } + + #[test] + fn zero_cadence_runs_every_tick() { + let (mut world, mut schedule) = setup_streaming_world( + 1, + 0, // cadence=0 means run every tick + TilePosition::new(80, 80, 0), + 160, + 160, + ); + + // tick=0, cadence=0: condition is `0 > 0 && ...` which is false → runs + schedule.run(&mut world); + assert_eq!(world.resource::().chunk_count(), 9); + } + + #[test] + fn other_z_levels_untouched() { + // Create a map with 2 z-levels. Player on z=0 with radius=0 (only own chunk). + let mut world = World::new(); + world.init_resource::(); + world.insert_resource(ChunkLoadRadius { radius: 0 }); + world.insert_resource(ChunkStreamingCadence { ticks: 1 }); + + // 2×2 chunks on 2 z-levels = 8 chunks total + world.insert_resource(WalkabilityMap::new(64, 64, 2)); + world.spawn((PlayerCharacter, TilePosition::new(16, 16, 0))); + + let mut schedule = Schedule::default(); + schedule.add_systems(chunk_streaming); + + // Before: 8 chunks (2×2×2) + assert_eq!(world.resource::().chunk_count(), 8); + + schedule.run(&mut world); + + // After: z=0 should have only 1 chunk (player's own), z=1 untouched (2×2=4) + // Total: 1 + 4 = 5 + let wm = world.resource::(); + assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 0 })); + assert!(!wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 0 })); + // z=1 chunks all still there + assert!(wm.has_chunk(&ChunkCoord { cx: 0, cy: 0, z: 1 })); + assert!(wm.has_chunk(&ChunkCoord { cx: 1, cy: 1, z: 1 })); + assert_eq!(wm.chunk_count(), 5); + } +} diff --git a/server/src/simulation/mod.rs b/server/src/simulation/mod.rs index 048b4a655..b3497debe 100644 --- a/server/src/simulation/mod.rs +++ b/server/src/simulation/mod.rs @@ -4,6 +4,7 @@ use bevy_app::prelude::*; use bevy_ecs::schedule::IntoScheduleConfigs; +pub mod chunk_streaming; pub mod contraband; pub mod conversation; pub mod dialogue; @@ -50,6 +51,8 @@ impl Plugin for SimulationPlugin { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -127,6 +130,12 @@ impl Plugin for SimulationPlugin { .add_systems( Update, zone::detect_zone_crossings.after(movement::validate_movement), + ) + // Chunk streaming (#578, D-012) — loads/unloads chunks around the player. + // Runs before input processing so chunks are available for the current tick. + .add_systems( + Update, + chunk_streaming::chunk_streaming.before(input::process_player_input), ); tracing::debug!("SimulationPlugin initialized"); diff --git a/server/src/simulation/movement.rs b/server/src/simulation/movement.rs index 1ca9111eb..671677117 100644 --- a/server/src/simulation/movement.rs +++ b/server/src/simulation/movement.rs @@ -1,6 +1,7 @@ // Tile-based movement and collision system // Implements Sprint 1 ticket #236: walkability map and movement validation // Extended by #420: TilePresence posture layers for same-tile occupancy (D-054) +// Extended by #576: TileKind layer per tile (server-authoritative tile classification) // Chunk-based storage per D-012: supports chunk load/unload for future borderless generation // Y-down convention: North = y-1, South = y+1 @@ -16,6 +17,30 @@ use crate::simulation::stance::Stance; /// Chunk size in tiles (32x32 per chunk) pub const CHUNK_SIZE: i32 = 32; +/// Server-side authoritative tile classification (#576, D-012). +/// +/// Mirrors the bridge `TileKind` (Floor/Wall/Door/Object used for client rendering) +/// but serves a different purpose: simulation logic and tile authoring. +/// +/// Tile format for location YAML authoring (#577): +/// - `F` = Floor (walkable, open space) +/// - `W` = Wall (solid obstacle, blocks movement and LOS) +/// - `V` = Void (out-of-bounds / unloaded; treated as blocked) +/// - `R` = Restricted (blocked but traversable by specific entities, e.g. airlocks) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TileKind { + /// Walkable floor tile. Default for populated chunks. + #[default] + Floor, + /// Solid wall tile. Blocks movement and line-of-sight. + Wall, + /// Void / unloaded tile. Out-of-bounds or ungenerated space. + Void, + /// Restricted tile. Blocked for standard movement but accessible + /// to authorised entities (e.g. locked zones, maintenance airlocks). + Restricted, +} + /// Marker component identifying the player-controlled entity. #[derive(Component, Debug)] pub struct PlayerCharacter; @@ -121,7 +146,7 @@ impl TilePosition { } /// Get the chunk coordinate this tile belongs to. - fn chunk_coord(&self) -> ChunkCoord { + pub fn chunk_coord(&self) -> ChunkCoord { ChunkCoord { cx: self.x.div_euclid(CHUNK_SIZE), cy: self.y.div_euclid(CHUNK_SIZE), @@ -143,22 +168,43 @@ pub struct ChunkCoord { pub z: i32, } -/// Walkability data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles). +/// Per-tile storage cell: walkability flag and tile classification. +/// Private — exposed only through WalkabilityMap's public API. +#[derive(Debug, Clone, Copy)] +struct TileCell { + walkable: bool, + kind: TileKind, +} + +/// Walkability and tile-kind data for a single chunk (CHUNK_SIZE x CHUNK_SIZE tiles). +/// Extended by #576 to carry TileKind alongside the walkability bool. #[derive(Debug, Clone)] struct ChunkData { - tiles: Vec, // CHUNK_SIZE * CHUNK_SIZE, true = walkable + tiles: Vec, } impl ChunkData { fn new_walkable() -> Self { Self { - tiles: vec![true; (CHUNK_SIZE * CHUNK_SIZE) as usize], + tiles: vec![ + TileCell { + walkable: true, + kind: TileKind::Floor + }; + (CHUNK_SIZE * CHUNK_SIZE) as usize + ], } } fn new_blocked() -> Self { Self { - tiles: vec![false; (CHUNK_SIZE * CHUNK_SIZE) as usize], + tiles: vec![ + TileCell { + walkable: false, + kind: TileKind::Wall + }; + (CHUNK_SIZE * CHUNK_SIZE) as usize + ], } } @@ -166,12 +212,24 @@ impl ChunkData { (ly * CHUNK_SIZE + lx) as usize } + /// Returns the walkability flag for a tile (backward-compatible internal accessor). fn get(&self, lx: i32, ly: i32) -> bool { - self.tiles[Self::index(lx, ly)] + self.tiles[Self::index(lx, ly)].walkable } + /// Sets only the walkability flag; tile kind is unchanged. fn set(&mut self, lx: i32, ly: i32, walkable: bool) { - self.tiles[Self::index(lx, ly)] = walkable; + self.tiles[Self::index(lx, ly)].walkable = walkable; + } + + /// Returns the tile kind for a cell. + fn get_kind(&self, lx: i32, ly: i32) -> TileKind { + self.tiles[Self::index(lx, ly)].kind + } + + /// Sets the tile kind for a cell; walkability is unchanged. + fn set_kind(&mut self, lx: i32, ly: i32, kind: TileKind) { + self.tiles[Self::index(lx, ly)].kind = kind; } } @@ -226,6 +284,7 @@ impl WalkabilityMap { } /// Set walkability of a tile. Creates the chunk if it doesn't exist. + /// Does not change the tile's TileKind. pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) { let coord = pos.chunk_coord(); let (lx, ly) = pos.local_offset(); @@ -236,6 +295,27 @@ impl WalkabilityMap { chunk.set(lx, ly, walkable); } + /// Get the tile kind at a position. Returns `TileKind::Void` for unloaded chunks. + pub fn tile_kind(&self, pos: &TilePosition) -> TileKind { + let coord = pos.chunk_coord(); + let (lx, ly) = pos.local_offset(); + self.chunks + .get(&coord) + .map_or(TileKind::Void, |chunk| chunk.get_kind(lx, ly)) + } + + /// Set the tile kind at a position. Creates the chunk if it doesn't exist. + /// Does not change the tile's walkability. + pub fn set_tile_kind(&mut self, pos: &TilePosition, kind: TileKind) { + let coord = pos.chunk_coord(); + let (lx, ly) = pos.local_offset(); + let chunk = self + .chunks + .entry(coord) + .or_insert_with(ChunkData::new_blocked); + chunk.set_kind(lx, ly, kind); + } + /// Check if a chunk is loaded. pub fn has_chunk(&self, coord: &ChunkCoord) -> bool { self.chunks.contains_key(coord) @@ -259,6 +339,12 @@ impl WalkabilityMap { pub fn chunk_count(&self) -> usize { self.chunks.len() } + + /// Returns the coordinates of all loaded chunks (#578). + /// Used by the chunk streaming system to determine which chunks to unload. + pub fn loaded_chunk_coords(&self) -> Vec { + self.chunks.keys().copied().collect() + } } /// Component representing an intent to move to a target tile. @@ -366,6 +452,89 @@ pub fn validate_movement( mod tests { use super::*; + // ----------------------------------------------------------------------- + // TileKind layer tests (#576) + // ----------------------------------------------------------------------- + + #[test] + fn tile_kind_default_is_floor_for_walkable_chunk() { + let map = WalkabilityMap::new(10, 10, 1); + assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Floor); + assert_eq!(map.tile_kind(&TilePosition::new(5, 5, 0)), TileKind::Floor); + assert_eq!(map.tile_kind(&TilePosition::new(9, 9, 0)), TileKind::Floor); + } + + #[test] + fn tile_kind_unloaded_chunk_returns_void() { + let map = WalkabilityMap::new(10, 10, 1); + // Negative coords → unloaded chunk → Void + assert_eq!(map.tile_kind(&TilePosition::new(-1, 0, 0)), TileKind::Void); + assert_eq!(map.tile_kind(&TilePosition::new(0, -1, 0)), TileKind::Void); + // z-level not loaded → Void + assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 1)), TileKind::Void); + } + + #[test] + fn tile_kind_round_trip() { + let mut map = WalkabilityMap::new(10, 10, 1); + let pos = TilePosition::new(5, 5, 0); + + map.set_tile_kind(&pos, TileKind::Wall); + assert_eq!(map.tile_kind(&pos), TileKind::Wall); + + map.set_tile_kind(&pos, TileKind::Restricted); + assert_eq!(map.tile_kind(&pos), TileKind::Restricted); + + map.set_tile_kind(&pos, TileKind::Void); + assert_eq!(map.tile_kind(&pos), TileKind::Void); + + map.set_tile_kind(&pos, TileKind::Floor); + assert_eq!(map.tile_kind(&pos), TileKind::Floor); + } + + #[test] + fn tile_kind_independent_of_walkability() { + let mut map = WalkabilityMap::new(10, 10, 1); + let pos = TilePosition::new(3, 3, 0); + + // Start: Floor + walkable + assert_eq!(map.tile_kind(&pos), TileKind::Floor); + assert!(map.can_move_to(&pos)); + + // Set walkable = false; kind should remain Floor + map.set_walkable(&pos, false); + assert!(!map.can_move_to(&pos)); + assert_eq!(map.tile_kind(&pos), TileKind::Floor); + + // Set kind = Wall; walkability should remain false + map.set_tile_kind(&pos, TileKind::Wall); + assert_eq!(map.tile_kind(&pos), TileKind::Wall); + assert!(!map.can_move_to(&pos)); + + // Restore walkable = true; kind should stay Wall + map.set_walkable(&pos, true); + assert!(map.can_move_to(&pos)); + assert_eq!(map.tile_kind(&pos), TileKind::Wall); + } + + #[test] + fn tile_kind_set_creates_chunk_on_demand() { + let mut map = WalkabilityMap::new(1, 1, 1); + let new_chunk_pos = TilePosition::new(32, 0, 0); // new chunk + + assert!(!map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 })); + map.set_tile_kind(&new_chunk_pos, TileKind::Restricted); + assert!(map.has_chunk(&ChunkCoord { cx: 1, cy: 0, z: 0 })); + assert_eq!(map.tile_kind(&new_chunk_pos), TileKind::Restricted); + } + + #[test] + fn blocked_chunk_default_kind_is_wall() { + let map = WalkabilityMap::new_blocked(32, 32, 1); + assert_eq!(map.tile_kind(&TilePosition::new(0, 0, 0)), TileKind::Wall); + assert_eq!(map.tile_kind(&TilePosition::new(15, 15, 0)), TileKind::Wall); + } + #[test] fn tile_position_equality() { let pos1 = TilePosition::new(5, 10, 0);