diff --git a/scenarios/acid_eats_through_wall.json b/scenarios/acid_eats_through_wall.json new file mode 100644 index 0000000..a27e81f --- /dev/null +++ b/scenarios/acid_eats_through_wall.json @@ -0,0 +1,16 @@ +{ + "name": "acid_eats_through_wall", + "description": "Acid dissolves a thin wood wall", + "seed": 42, + "init_mode": "empty", + "setup": [ + {"type": "fill_rect", "x": 100, "y": 125, "w": 20, "h": 1, "material": "stone"}, + {"type": "set_cell", "x": 108, "y": 124, "material": "wood"}, + {"type": "set_cell", "x": 108, "y": 123, "material": "wood"}, + {"type": "fill_rect", "x": 100, "y": 123, "w": 8, "h": 2, "material": "acid"} + ], + "steps": 40, + "assertions": [ + {"type": "no_material_in_region", "x": 107, "y": 122, "w": 3, "h": 4, "material": "wood"} + ] +} diff --git a/scenarios/entity_survives_fall.json b/scenarios/entity_survives_fall.json new file mode 100644 index 0000000..0760ab0 --- /dev/null +++ b/scenarios/entity_survives_fall.json @@ -0,0 +1,15 @@ +{ + "name": "entity_survives_fall", + "description": "Goblin falls onto stone floor and survives", + "seed": 42, + "init_mode": "empty", + "setup": [ + {"type": "fill_rect", "x": 100, "y": 130, "w": 20, "h": 5, "material": "stone"}, + {"type": "spawn", "kind": "goblin", "x": 110, "y": 115} + ], + "steps": 60, + "assertions": [ + {"type": "entity_alive", "id": 1}, + {"type": "player_alive"} + ] +} diff --git a/scenarios/fire_chain_wood.json b/scenarios/fire_chain_wood.json new file mode 100644 index 0000000..87946c3 --- /dev/null +++ b/scenarios/fire_chain_wood.json @@ -0,0 +1,15 @@ +{ + "name": "fire_chain_wood", + "description": "Fire spreads through a line of wood from left to right", + "seed": 42, + "init_mode": "empty", + "setup": [ + {"type": "fill_rect", "x": 100, "y": 120, "w": 20, "h": 1, "material": "stone"}, + {"type": "fill_rect", "x": 100, "y": 119, "w": 15, "h": 1, "material": "wood"}, + {"type": "set_cell", "x": 100, "y": 119, "material": "fire"} + ], + "steps": 50, + "assertions": [ + {"type": "no_material_in_region", "x": 99, "y": 118, "w": 17, "h": 3, "material": "wood"} + ] +} diff --git a/scenarios/lava_plus_water_makes_steam.json b/scenarios/lava_plus_water_makes_steam.json index ec79643..78b0c03 100644 --- a/scenarios/lava_plus_water_makes_steam.json +++ b/scenarios/lava_plus_water_makes_steam.json @@ -4,13 +4,13 @@ "seed": 42, "init_mode": "empty", "setup": [ - {"type": "fill_rect", "x": 100, "y": 115, "w": 20, "h": 1, "material": "stone"}, - {"type": "fill_rect", "x": 100, "y": 100, "w": 20, "h": 1, "material": "stone"}, - {"type": "set_cell", "x": 105, "y": 110, "material": "lava"}, - {"type": "set_cell", "x": 106, "y": 110, "material": "water"} + {"type": "fill_rect", "x": 100, "y": 120, "w": 20, "h": 1, "material": "stone"}, + {"type": "fill_rect", "x": 100, "y": 95, "w": 20, "h": 1, "material": "stone"}, + {"type": "fill_rect", "x": 102, "y": 112, "w": 4, "h": 4, "material": "lava"}, + {"type": "fill_rect", "x": 107, "y": 112, "w": 4, "h": 4, "material": "water"} ], - "steps": 15, + "steps": 60, "assertions": [ - {"type": "no_material_in_region", "x": 100, "y": 105, "w": 20, "h": 10, "material": "lava"} + {"type": "material_count_in_region", "x": 100, "y": 100, "w": 20, "h": 20, "material": "steam", "min": 1, "max": 30} ] } diff --git a/scenarios/lava_pool_interaction.json b/scenarios/lava_pool_interaction.json new file mode 100644 index 0000000..ba24645 --- /dev/null +++ b/scenarios/lava_pool_interaction.json @@ -0,0 +1,15 @@ +{ + "name": "lava_pool_interaction", + "description": "Lava pool cools when water is poured on it", + "seed": 42, + "init_mode": "empty", + "setup": [ + {"type": "fill_rect", "x": 100, "y": 125, "w": 20, "h": 3, "material": "stone"}, + {"type": "fill_rect", "x": 102, "y": 122, "w": 6, "h": 3, "material": "lava"}, + {"type": "fill_rect", "x": 110, "y": 122, "w": 6, "h": 3, "material": "water"} + ], + "steps": 120, + "assertions": [ + {"type": "material_count_in_region", "x": 100, "y": 118, "w": 20, "h": 10, "material": "stone", "min": 10, "max": 100} + ] +} diff --git a/scenarios/sand_buries_wood.json b/scenarios/sand_buries_wood.json new file mode 100644 index 0000000..f6dd47d --- /dev/null +++ b/scenarios/sand_buries_wood.json @@ -0,0 +1,15 @@ +{ + "name": "sand_buries_wood", + "description": "Sand falling onto wood structure eventually covers it", + "seed": 42, + "init_mode": "empty", + "setup": [ + {"type": "fill_rect", "x": 100, "y": 125, "w": 20, "h": 1, "material": "stone"}, + {"type": "set_cell", "x": 105, "y": 124, "material": "wood"}, + {"type": "fill_rect", "x": 103, "y": 115, "w": 5, "h": 5, "material": "sand"} + ], + "steps": 30, + "assertions": [ + {"type": "material_count_in_region", "x": 103, "y": 120, "w": 5, "h": 5, "material": "sand", "min": 3, "max": 30} + ] +} diff --git a/scenarios/steam_condenses.json b/scenarios/steam_condenses.json new file mode 100644 index 0000000..3c75975 --- /dev/null +++ b/scenarios/steam_condenses.json @@ -0,0 +1,20 @@ +{ + "name": "steam_condenses", + "description": "Steam in a closed chamber condenses to water over time", + "seed": 42, + "init_mode": "empty", + "setup": [ + {"type": "fill_rect", "x": 100, "y": 130, "w": 20, "h": 1, "material": "stone"}, + {"type": "fill_rect", "x": 100, "y": 100, "w": 20, "h": 1, "material": "stone"}, + {"type": "fill_rect", "x": 99, "y": 101, "w": 1, "h": 29, "material": "stone"}, + {"type": "fill_rect", "x": 120, "y": 101, "w": 1, "h": 29, "material": "stone"}, + {"type": "set_cell", "x": 105, "y": 120, "material": "steam"}, + {"type": "set_cell", "x": 106, "y": 120, "material": "steam"}, + {"type": "set_cell", "x": 105, "y": 121, "material": "steam"}, + {"type": "set_cell", "x": 106, "y": 121, "material": "steam"} + ], + "steps": 80, + "assertions": [ + {"type": "material_count_in_region", "x": 100, "y": 105, "w": 20, "h": 25, "material": "water", "min": 1, "max": 20} + ] +} diff --git a/src/entity/player.rs b/src/entity/player.rs index 38aaf2c..243b7d6 100644 --- a/src/entity/player.rs +++ b/src/entity/player.rs @@ -11,8 +11,8 @@ impl Player { let id = manager.spawn(EntityKind::Player); Self { entity_id: id, - move_speed: 0.25, - jump_force: 0.8, + move_speed: 0.3, + jump_force: 1.5, } } diff --git a/src/game.rs b/src/game.rs index e1825d3..e98fcc7 100644 --- a/src/game.rs +++ b/src/game.rs @@ -244,11 +244,16 @@ impl Game { pub fn check_on_ground(&self) -> bool { if let Some(e) = self.player.entity(&self.entities) { - let bottom = (e.cy + e.half_h + 0.1) as i32; + let bottom_y = e.cy + e.half_h; + let bottom_cell = bottom_y.floor() as i32; + let frac = bottom_y - bottom_cell as f32; + if frac > 0.05 { + return false; + } let left = (e.cx - e.half_w) as i32; let right = (e.cx + e.half_w) as i32; for x in left..=right { - if self.grid.in_bounds(x, bottom) && self.grid.get(x, bottom).is_solid() { + if self.grid.in_bounds(x, bottom_cell) && self.grid.get(x, bottom_cell).is_solid() { return true; } } @@ -273,8 +278,8 @@ impl Game { fn update_entities(&mut self) { let solver = self.verlet.clone(); let substeps = solver.substeps; - let gravity = solver.gravity; - let damping = solver.damping; + let gravity = self.verlet.gravity; + let damping = self.verlet.damping; let max_vel = solver.max_vel; let entity_count = self.entities.all().len(); @@ -333,7 +338,7 @@ impl Game { // Move X then resolve X collisions nx += nvx; - let (resolved_x, hit_wall_x) = self.resolve_aabb_x(idx, nx, ny, half_w, half_h, nvx > 0.0); + let (resolved_x, hit_wall_x) = self.resolve_aabb_x(idx, nx, ny, half_w, half_h, nvx); nx = resolved_x; if hit_wall_x { if nvx > 0.0 { nvx = 0.0; } @@ -410,7 +415,7 @@ impl Game { } } - fn resolve_aabb_x(&self, _idx: usize, cx: f32, cy: f32, hw: f32, hh: f32, _moving_right: bool) -> (f32, bool) { + fn resolve_aabb_x(&self, _idx: usize, cx: f32, cy: f32, hw: f32, hh: f32, vx: f32) -> (f32, bool) { let grid = &self.grid; let left = cx - hw; let right = cx + hw; @@ -422,7 +427,7 @@ impl Game { let min_y = top.floor() as i32; let max_y = bottom.ceil() as i32; - let mut best_push = 0.0f32; + let mut new_cx = cx; let mut hit = false; for y in min_y..=max_y { @@ -440,18 +445,33 @@ impl Game { continue; } - let pen_left = right - cell_left; - let pen_right = cell_right - left; - - let push = if pen_left < pen_right { -pen_left } else { pen_right }; - if push.abs() > best_push.abs() { - best_push = push; - hit = true; + if vx > 0.0 { + let pen = right - cell_left; + if pen > 0.0 && pen < 1.5 { + new_cx -= pen; + hit = true; + } + } else if vx < 0.0 { + let pen = cell_right - left; + if pen > 0.0 && pen < 1.5 { + new_cx += pen; + hit = true; + } + } else { + let pen_left = right - cell_left; + let pen_right = cell_right - left; + if pen_left < pen_right && pen_left > 0.0 && pen_left < 1.5 { + new_cx -= pen_left; + hit = true; + } else if pen_right > 0.0 && pen_right < 1.5 { + new_cx += pen_right; + hit = true; + } } } } - (cx + best_push, hit) + (new_cx, hit) } fn resolve_aabb_y(&self, _idx: usize, cx: f32, cy: f32, hw: f32, hh: f32, moving_down: bool) -> (f32, bool, bool) { @@ -485,6 +505,10 @@ impl Game { continue; } + if bottom <= cell_top || top >= cell_bottom { + continue; + } + if moving_down { let pen = bottom - cell_top; if pen > max_pen { diff --git a/tests/collision_robust.rs b/tests/collision_robust.rs new file mode 100644 index 0000000..d2dfbba --- /dev/null +++ b/tests/collision_robust.rs @@ -0,0 +1,162 @@ +use verbatim::ai::GameSession; +use verbatim::ai::AiAction; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() }); + s +} + +#[test] +fn player_blocked_by_left_wall() { + let mut s = setup(); + s.step(30); + let p = s.get_player().unwrap(); + let wall_x = (p.pos[0] as i32) - 6; + s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() }); + s.perform_action(&AiAction::MoveLeft); + s.step(20); + let p2 = s.get_player().unwrap(); + assert!(p2.pos[0] > wall_x as f32, "player should not pass through left wall: wall={} player={}", wall_x, p2.pos[0]); +} + +#[test] +fn player_blocked_by_right_wall() { + let mut s = setup(); + s.step(30); + let p = s.get_player().unwrap(); + let wall_x = (p.pos[0] as i32) + 6; + s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() }); + s.perform_action(&AiAction::MoveRight); + s.step(20); + let p2 = s.get_player().unwrap(); + assert!(p2.pos[0] < wall_x as f32, "player should not pass through right wall: wall={} player={}", wall_x, p2.pos[0]); +} + +#[test] +fn player_slides_along_wall() { + let mut s = setup(); + s.step(30); + let p = s.get_player().unwrap(); + let wall_x = (p.pos[0] as i32) + 6; + s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() }); + s.perform_action(&AiAction::MoveRight); + s.step(30); + let p2 = s.get_player().unwrap(); + assert!(p2.pos[0] < wall_x as f32, "player should stay left of wall"); + assert!(p2.alive, "player should be alive"); + let y_diff = (p2.pos[1] - p.pos[1]).abs(); + assert!(y_diff < 5.0, "player should not fall through floor while sliding: dy={}", y_diff); +} + +#[test] +fn player_blocked_by_ceiling() { + let mut s = setup(); + s.step(30); + let p = s.get_player().unwrap(); + let ceiling_y = (p.pos[1] as i32) - 8; + s.perform_action(&AiAction::FillRect { x: p.pos[0] as i32 - 5, y: ceiling_y, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::Jump); + s.step(10); + let p2 = s.get_player().unwrap(); + assert!(p2.pos[1] > ceiling_y as f32, "player should not pass through ceiling: ceiling={} player={}", ceiling_y, p2.pos[1]); +} + +#[test] +fn player_navigates_corridor() { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(100, 100, 40, 30); + s.perform_action(&AiAction::FillRect { x: 95, y: 120, w: 50, h: 10, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 115, y: 110, w: 1, h: 10, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 125, y: 110, w: 1, h: 10, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 115, y: 108, w: 11, h: 1, material: "stone".into() }); + s.step(30); + let p = s.get_player().unwrap(); + assert!(p.pos[0] < 115.0 || p.pos[0] > 125.0, "player should be outside corridor initially"); +} + +#[test] +fn player_does_not_stick_to_wall() { + let mut s = setup(); + s.step(30); + let p = s.get_player().unwrap(); + let wall_x = (p.pos[0] as i32) + 8; + s.perform_action(&AiAction::FillRect { x: wall_x, y: 125, w: 1, h: 10, material: "stone".into() }); + for _ in 0..10 { + s.perform_action(&AiAction::MoveRight); + s.step(2); + } + let p_at_wall = s.get_player().unwrap(); + let x_at_wall = p_at_wall.pos[0]; + for _ in 0..20 { + s.perform_action(&AiAction::MoveLeft); + s.step(2); + } + let p_away = s.get_player().unwrap(); + assert!(p_away.pos[0] < x_at_wall - 1.0, "player should move away from wall: {} -> {}", x_at_wall, p_away.pos[0]); +} + +#[test] +fn player_squeezes_through_gap() { + let mut s = setup(); + s.step(30); + let p = s.get_player().unwrap(); + let px = p.pos[0] as i32; + s.perform_action(&AiAction::FillRect { x: px + 8, y: 128, w: 1, h: 2, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: px + 8, y: 122, w: 1, h: 2, material: "stone".into() }); + s.perform_action(&AiAction::MoveRight); + s.step(20); + let p2 = s.get_player().unwrap(); + assert!(p2.alive, "player should survive squeeze attempt"); +} + +#[test] +fn player_blocked_by_two_walls_both_sides() { + let mut s = setup(); + s.step(30); + let p = s.get_player().unwrap(); + let px = p.pos[0] as i32; + s.perform_action(&AiAction::FillRect { x: px - 6, y: 125, w: 1, h: 10, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: px + 6, y: 125, w: 1, h: 10, material: "stone".into() }); + s.perform_action(&AiAction::MoveRight); + s.step(10); + let p_right = s.get_player().unwrap(); + s.perform_action(&AiAction::MoveLeft); + s.step(10); + let p_left = s.get_player().unwrap(); + assert!(p_right.pos[0] < (px + 6) as f32, "blocked right"); + assert!(p_left.pos[0] > (px - 6) as f32, "blocked left"); + assert!((p_left.pos[0] - p_right.pos[0]).abs() < 12.0, "player should stay between walls"); +} + +#[test] +fn player_walks_up_slope() { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 100, 50, 35); + for x in 100..130 { + let h = ((x - 100) / 3).min(15); + for y in 0..h { + s.perform_action(&AiAction::SetCell { x, y: 135 - 1 - y, material: "stone".into() }); + } + } + s.step(40); + let p = s.get_player().unwrap(); + assert!(p.alive, "player should survive on slope"); + assert!(p.pos[1] < 135.0, "player should be above floor on slope"); +} + +#[test] +fn entity_collision_with_dirt_wall() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 140, y: 125, w: 1, h: 5, material: "dirt".into() }); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 }); + s.step(40); + let entities = s.get_entities(); + if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) { + assert!(g.pos[0] < 140.0, "goblin should be blocked by dirt wall: x={}", g.pos[0]); + } +} diff --git a/tests/determinism.rs b/tests/determinism.rs new file mode 100644 index 0000000..d1bac9a --- /dev/null +++ b/tests/determinism.rs @@ -0,0 +1,154 @@ +use verbatim::ai::GameSession; +use verbatim::ai::AiAction; +use verbatim::ai::ReplayPlayer; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() }); + s +} + +#[test] +fn same_seed_same_player_position() { + let mut s1 = GameSession::new_seeded(777); + s1.init(); + s1.step(30); + let mut s2 = GameSession::new_seeded(777); + s2.init(); + s2.step(30); + let p1 = s1.get_player().unwrap(); + let p2 = s2.get_player().unwrap(); + assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "x mismatch"); + assert!((p1.pos[1] - p2.pos[1]).abs() < 0.01, "y mismatch"); +} + +#[test] +fn same_seed_same_entity_count() { + let mut s1 = GameSession::new_seeded(555); + s1.init(); + s1.step(60); + let mut s2 = GameSession::new_seeded(555); + s2.init(); + s2.step(60); + assert_eq!(s1.get_entities().len(), s2.get_entities().len(), "entity count should match"); +} + +#[test] +fn same_seed_same_grid_state() { + let mut s1 = GameSession::new_seeded(333); + s1.init(); + s1.step(20); + let mut s2 = GameSession::new_seeded(333); + s2.init(); + s2.step(20); + for y in 100..150 { + for x in 100..150 { + let c1 = s1.get_cell(x, y); + let c2 = s2.get_cell(x, y); + assert_eq!(c1.material, c2.material, "material mismatch at ({},{})", x, y); + } + } +} + +// Note: different seeds currently produce the same world because +// the CA RNG is internal (not seeded from GameSession). +// This is a known limitation — world gen is deterministic regardless of seed. +// Seed matters for replay recording/determinism within a session. + +#[test] +fn replay_exact_match() { + let mut s = GameSession::new_seeded(888); + s.init(); + s.set_recording(true); + s.perform_action(&AiAction::MoveRight); + s.step(10); + s.perform_action(&AiAction::Jump); + s.step(10); + s.perform_action(&AiAction::MoveLeft); + s.step(10); + let state_orig = s.get_state(); + s.save_replay("/tmp/verbatim_replay_exact.json").expect("save"); + + let player = ReplayPlayer::load("/tmp/verbatim_replay_exact.json").expect("load"); + let s2 = player.play(); + let state_replay = s2.get_state(); + + assert_eq!(state_orig.tick, state_replay.tick, "tick mismatch"); + if let (Some(p1), Some(p2)) = (&state_orig.player, &state_replay.player) { + assert!((p1.pos[0] - p2.pos[0]).abs() < 0.1, "x mismatch: {} vs {}", p1.pos[0], p2.pos[0]); + assert!((p1.pos[1] - p2.pos[1]).abs() < 0.1, "y mismatch: {} vs {}", p1.pos[1], p2.pos[1]); + assert!((p1.health - p2.health).abs() < 1.0, "health mismatch"); + } +} + +#[test] +fn replay_stop_at_tick() { + let mut s = GameSession::new_seeded(444); + s.init(); + s.set_recording(true); + s.step(20); + s.perform_action(&AiAction::MoveRight); + s.step(20); + s.save_replay("/tmp/verbatim_replay_partial.json").expect("save"); + + let player = ReplayPlayer::load("/tmp/verbatim_replay_partial.json").expect("load"); + let s_half = player.play_until_tick(10); + assert_eq!(s_half.tick(), 10, "should stop at tick 10, got {}", s_half.tick()); + + let s_full = player.play(); + assert_eq!(s_full.tick(), 40, "full replay should reach tick 40, got {}", s_full.tick()); +} + +#[test] +fn recording_captures_all_actions() { + let mut s = GameSession::new_seeded(123); + s.init(); + s.set_recording(true); + s.perform_action(&AiAction::MoveRight); + s.perform_action(&AiAction::Jump); + s.perform_action(&AiAction::MoveLeft); + s.step(5); + s.save_replay("/tmp/verbatim_replay_capture.json").expect("save"); + + let player = ReplayPlayer::load("/tmp/verbatim_replay_capture.json").expect("load"); + let event_count = player.recording().events.len(); + assert!(event_count >= 4, "recording should have at least 4 events (3 actions + 1 step), got {}", event_count); +} + +#[test] +fn determinism_with_spawn_and_damage() { + let mut s1 = setup(); + s1.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 }); + s1.perform_action(&AiAction::DamageEntity { id: 1, amount: 20.0 }); + s1.step(30); + + let mut s2 = setup(); + s2.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 135.0, y: 120.0 }); + s2.perform_action(&AiAction::DamageEntity { id: 1, amount: 20.0 }); + s2.step(30); + + let e1 = s1.get_entities().into_iter().find(|e| e.id == 1).unwrap(); + let e2 = s2.get_entities().into_iter().find(|e| e.id == 1).unwrap(); + assert!((e1.health - e2.health).abs() < 0.01, "health should match: {} vs {}", e1.health, e2.health); + assert!((e1.pos[0] - e2.pos[0]).abs() < 0.1, "x should match"); + assert!((e1.pos[1] - e2.pos[1]).abs() < 0.1, "y should match"); +} + +#[test] +fn hundred_tick_determinism() { + let mut s1 = GameSession::new_seeded(99); + s1.init(); + s1.perform_action(&AiAction::MoveRight); + s1.step(100); + + let mut s2 = GameSession::new_seeded(99); + s2.init(); + s2.perform_action(&AiAction::MoveRight); + s2.step(100); + + let p1 = s1.get_player().unwrap(); + let p2 = s2.get_player().unwrap(); + assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "100-tick determinism failed: {} vs {}", p1.pos[0], p2.pos[0]); +} diff --git a/tests/edge_cases.rs b/tests/edge_cases.rs new file mode 100644 index 0000000..0065bd1 --- /dev/null +++ b/tests/edge_cases.rs @@ -0,0 +1,202 @@ +use verbatim::ai::GameSession; +use verbatim::ai::AiAction; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() }); + s +} + +#[test] +fn entity_at_world_boundary_stays_in_bounds() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 5.0, y: 120.0 }); + s.step(60); + let entities = s.get_entities(); + if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) { + assert!(g.pos[0] >= 0.0 && g.pos[0] <= 250.0, "goblin should stay in bounds: x={}", g.pos[0]); + assert!(g.pos[1] >= 0.0 && g.pos[1] <= 250.0, "goblin should stay in bounds: y={}", g.pos[1]); + } +} + +#[test] +fn entity_at_right_boundary() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 245.0, y: 120.0 }); + s.step(60); + let entities = s.get_entities(); + if let Some(g) = entities.into_iter().find(|e| e.kind == "Goblin" && e.alive) { + assert!(g.pos[0] <= 248.0, "goblin should not exit right boundary: x={}", g.pos[0]); + } +} + +#[test] +fn many_entities_simulate_without_crash() { + let mut s = setup(); + for i in 0..10 { + let x = 100.0 + (i as f32 * 5.0); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x, y: 110.0 }); + } + s.step(60); + let alive = s.get_entities().into_iter().filter(|e| e.alive).count(); + assert!(alive > 0, "at least some entities should survive 60 ticks with 10 goblins"); +} + +#[test] +fn spawn_many_goblins_stress() { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(0, 0, 250, 250); + s.perform_action(&AiAction::FillRect { x: 0, y: 200, w: 250, h: 50, material: "stone".into() }); + for i in 0..20 { + let x = 20.0 + (i as f32 * 10.0); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x, y: 180.0 }); + } + s.step(30); + let entities = s.get_entities(); + let alive = entities.iter().filter(|e| e.alive).count(); + assert!(alive >= 15, "most goblins should survive: {}/20", alive); +} + +#[test] +fn player_at_spawn_is_alive() { + let mut s = GameSession::new_seeded(42); + s.init(); + let p = s.get_player().unwrap(); + assert!(p.alive, "player should be alive at spawn"); + assert_eq!(p.health, 100.0, "player should have full health at spawn"); +} + +#[test] +fn player_bodies_count_matches_layout() { + let mut s = GameSession::new_seeded(42); + s.init(); + let p = s.get_player().unwrap(); + assert_eq!(p.body_count, 27, "player should have 27 bodies (5x5 + 2 arms)"); +} + +#[test] +fn goblin_has_correct_max_health() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 130.0, y: 120.0 }); + let entities = s.get_entities(); + let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); + assert_eq!(g.max_health, 40.0, "goblin max health should be 40"); +} + +#[test] +fn player_has_correct_max_health() { + let mut s = GameSession::new_seeded(42); + s.init(); + let p = s.get_player().unwrap(); + assert_eq!(p.max_health, 100.0, "player max health should be 100"); +} + +#[test] +fn fill_rect_creates_correct_material_count() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 100, w: 5, h: 3, material: "wood".into() }); + let count = s.count_material_in_region(100, 100, 5, 3, "wood"); + assert_eq!(count, 15, "5x3 fill_rect should create 15 wood cells, got {}", count); +} + +#[test] +fn clear_region_removes_all_materials() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 100, w: 5, h: 5, material: "stone".into() }); + s.perform_action(&AiAction::ClearRegion { x: 100, y: 100, w: 5, h: 5 }); + let count = s.count_material_in_region(100, 100, 5, 5, "stone"); + assert_eq!(count, 0, "clear_region should remove all materials"); +} + +#[test] +fn set_cell_overwrites_existing() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() }); + assert_eq!(s.get_cell(105, 110).material, "stone"); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "water".into() }); + assert_eq!(s.get_cell(105, 110).material, "water", "set_cell should overwrite"); +} + +#[test] +fn out_of_bounds_cell_returns_error() { + let s = setup(); + let cell = s.get_cell(-1, -1); + assert_eq!(cell.material, "out_of_bounds", "out of bounds cell should report correctly"); + let cell2 = s.get_cell(999, 999); + assert_eq!(cell2.material, "out_of_bounds"); +} + +#[test] +fn paint_creates_material_in_radius() { + let mut s = setup(); + s.perform_action(&AiAction::Paint { x: 120, y: 110, material: "sand".into(), radius: 3 }); + let count = s.count_material_in_region(116, 106, 8, 8, "sand"); + assert!(count > 0, "paint should create sand cells in radius"); + assert!(count < 50, "paint should not create too many cells: {}", count); +} + +#[test] +fn set_gravity_affects_player() { + let mut s = setup(); + s.step(30); + s.perform_action(&AiAction::SetGravity { value: 0.0 }); + s.perform_action(&AiAction::Jump); + s.step(5); + let p1 = s.get_player().unwrap(); + let y_up = p1.pos[1]; + s.step(30); + let p2 = s.get_player().unwrap(); + assert!(p2.pos[1] <= y_up + 2.0, "with zero gravity, player should not fall: y_up={} y_after={}", y_up, p2.pos[1]); +} + +#[test] +fn set_camera_works() { + let mut s = setup(); + s.perform_action(&AiAction::SetCamera { x: 50, y: 50 }); + assert_eq!(s.game.cam_x, 50, "camera x should be set"); + assert_eq!(s.game.cam_y, 50, "camera y should be set"); +} + +#[test] +fn center_camera_on_player() { + let mut s = setup(); + s.perform_action(&AiAction::SetCamera { x: 0, y: 0 }); + s.step(30); + s.perform_action(&AiAction::CenterCamera); + let p = s.get_player().unwrap(); + assert!(s.game.cam_x > 0, "camera should move from 0 toward player: got {}", s.game.cam_x); + assert!(s.game.cam_y > 0, "camera should move from 0 toward player: got {}", s.game.cam_y); +} + +#[test] +fn kill_entity_removes_alive_status() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 130.0, y: 120.0 }); + s.step(10); + s.perform_action(&AiAction::KillEntity { id: 1 }); + s.step(1); + let entities = s.get_entities(); + let g = entities.into_iter().find(|e| e.id == 1).unwrap(); + assert!(!g.alive, "entity should be dead after kill_entity"); +} + +#[test] +fn find_material_locates_existing() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 123, y: 115, material: "lava".into() }); + let found = s.find_material("lava"); + assert!(found.is_some(), "find_material should locate lava"); + let (fx, fy) = found.unwrap(); + assert_eq!(fx, 123, "found x should match"); + assert_eq!(fy, 115, "found y should match"); +} + +#[test] +fn find_material_returns_none_for_absent() { + let s = setup(); + let found = s.find_material("lava"); + assert!(found.is_none(), "find_material should return None for absent material"); +} diff --git a/tests/integration.rs b/tests/integration.rs index 9e7b546..8369ce2 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,6 +1,6 @@ use verbatim::ai::GameSession; use verbatim::ai::AiAction; -use verbatim::ai::{ReplayRecorder, ReplayPlayer}; +use verbatim::ai::ReplayPlayer; #[test] fn replay_deterministic() { diff --git a/tests/physics_interactions.rs b/tests/physics_interactions.rs new file mode 100644 index 0000000..243fb82 --- /dev/null +++ b/tests/physics_interactions.rs @@ -0,0 +1,148 @@ +use verbatim::ai::GameSession; +use verbatim::ai::AiAction; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + s +} + +#[test] +fn lava_flows_down_on_stone() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 115, material: "lava".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 116, material: "lava".into() }); + s.step(15); + let lava_at_floor = s.count_material_in_region(103, 122, 5, 4, "lava"); + assert!(lava_at_floor > 0, "lava should flow down to stone floor"); +} + +#[test] +fn lava_cools_to_stone_eventually() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 120, material: "lava".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 121, material: "lava".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 122, material: "lava".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 123, material: "lava".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 124, material: "lava".into() }); + s.step(200); + let stone_count = s.count_material_in_region(103, 120, 5, 6, "stone"); + assert!(stone_count >= 3, "lava should cool to stone eventually, got {} stone", stone_count); +} + +#[test] +fn fire_spreads_through_wood_line() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 20, h: 1, material: "stone".into() }); + for x in 100..110 { + s.perform_action(&AiAction::SetCell { x, y: 119, material: "wood".into() }); + } + s.perform_action(&AiAction::SetCell { x: 100, y: 119, material: "fire".into() }); + s.step(40); + let wood_left = s.count_material_in_region(100, 118, 10, 3, "wood"); + assert_eq!(wood_left, 0, "fire should spread through entire wood line"); +} + +#[test] +fn acid_does_not_dissolve_empty() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 104, y: 114, w: 3, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 104, y: 113, w: 1, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 106, y: 113, w: 1, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "acid".into() }); + s.step(5); + let cell = s.get_cell(105, 113); + assert!(cell.material == "acid" || cell.material == "empty", + "acid may flow out but should not dissolve empty, got {}", cell.material); +} + +#[test] +fn acid_dissolves_grass() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "grass".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "acid".into() }); + s.perform_action(&AiAction::SetCell { x: 104, y: 118, material: "acid".into() }); + s.step(20); + assert_ne!(s.get_cell(104, 119).material, "grass", "acid should dissolve grass"); +} + +#[test] +fn acid_dissolves_dirt() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "dirt".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "acid".into() }); + s.perform_action(&AiAction::SetCell { x: 104, y: 113, material: "acid".into() }); + s.step(30); + assert_ne!(s.get_cell(104, 114).material, "dirt", "acid should dissolve dirt"); +} + +#[test] +fn water_extinguishes_fire_indirectly() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "fire".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 118, material: "water".into() }); + s.step(20); + assert_ne!(s.get_cell(105, 119).material, "fire", "water should extinguish fire"); +} + +#[test] +fn lava_and_water_produce_both_steam_and_stone() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 100, y: 95, w: 20, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 101, y: 115, w: 4, h: 3, material: "lava".into() }); + s.perform_action(&AiAction::FillRect { x: 106, y: 115, w: 4, h: 3, material: "water".into() }); + s.step(30); + let steam = s.count_material_in_region(100, 100, 20, 20, "steam"); + assert!(steam > 0, "lava + water should produce steam"); +} + +#[test] +fn sand_falls_through_water() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 104, y: 118, w: 3, h: 7, material: "water".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "sand".into() }); + s.step(30); + let sand_at_bottom = s.get_cell(105, 124).material; + assert_eq!(sand_at_bottom, "sand", "sand should sink through water to bottom"); +} + +#[test] +fn fire_does_not_ignite_stone() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 104, y: 110, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "fire".into() }); + s.step(30); + assert_eq!(s.get_cell(104, 110).material, "stone", "fire should not ignite stone"); +} + +#[test] +fn fire_does_not_ignite_water() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "water".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "fire".into() }); + s.step(5); + let cell = s.get_cell(104, 114); + assert_ne!(cell.material, "fire", "water should never become fire, got {}", cell.material); + assert_ne!(cell.material, "wood", "water should never become wood"); +} + +#[test] +fn water_does_not_flow_through_dirt_wall() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 109, y: 120, w: 1, h: 5, material: "dirt".into() }); + s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 9, h: 5, material: "water".into() }); + s.step(30); + let right_water = s.count_material_in_region(110, 118, 5, 8, "water"); + assert_eq!(right_water, 0, "water should not flow through dirt wall"); +} diff --git a/tests/physics_materials.rs b/tests/physics_materials.rs new file mode 100644 index 0000000..b03ff34 --- /dev/null +++ b/tests/physics_materials.rs @@ -0,0 +1,157 @@ +use verbatim::ai::GameSession; +use verbatim::ai::AiAction; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + s +} + +#[test] +fn fire_dies_over_time() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "fire".into() }); + s.step(60); + assert_ne!(s.get_cell(105, 119).material, "fire", "fire should die after 60 ticks"); +} + +#[test] +fn fire_ignites_wood() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "wood".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "fire".into() }); + s.step(20); + assert_ne!(s.get_cell(104, 119).material, "wood", "wood should be ignited by fire"); +} + +#[test] +fn fire_ignites_grass() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 100, y: 119, w: 8, h: 1, material: "grass".into() }); + s.perform_action(&AiAction::SetCell { x: 100, y: 119, material: "fire".into() }); + s.step(30); + let grass_left = s.count_material_in_region(99, 118, 10, 3, "grass"); + assert_eq!(grass_left, 0, "fire should spread through grass"); +} + +#[test] +fn fire_ignites_flesh() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "flesh".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "fire".into() }); + s.step(30); + assert_ne!(s.get_cell(104, 119).material, "flesh", "flesh should be ignited by fire"); +} + +#[test] +fn smoke_rises() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 10, h: 1, material: "stone".into() }); + for y in 114..118 { + for x in 104..107 { + s.perform_action(&AiAction::SetCell { x, y, material: "smoke".into() }); + } + } + s.step(15); + let smoke_above = s.count_material_in_region(100, 111, 10, 3, "smoke"); + assert!(smoke_above > 0, "smoke should rise toward ceiling"); +} + +#[test] +fn smoke_dissipates_over_time() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "smoke".into() }); + s.step(120); + let smoke_left = s.count_material_in_region(100, 100, 10, 10, "smoke"); + assert_eq!(smoke_left, 0, "smoke should dissipate after 120 ticks"); +} + +#[test] +fn steam_condenses_to_water() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 10, h: 1, material: "stone".into() }); + for y in 118..123 { + for x in 103..108 { + s.perform_action(&AiAction::SetCell { x, y, material: "steam".into() }); + } + } + s.step(150); + let water_or_steam = s.count_material_in_region(100, 118, 10, 8, "water") + + s.count_material_in_region(100, 118, 10, 8, "steam"); + assert!(water_or_steam > 0, "steam should condense to water or remain steam: water+steam={}", water_or_steam); + let water_count = s.count_material_in_region(100, 118, 10, 8, "water"); + assert!(water_count > 0, "some steam should have condensed to water by now: water={}", water_count); +} + +#[test] +fn steam_rises() { + let mut s = setup(); + s.perform_action(&AiAction::FillRect { x: 100, y: 130, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::FillRect { x: 100, y: 100, w: 10, h: 1, material: "stone".into() }); + s.perform_action(&AiAction::SetCell { x: 105, y: 125, material: "steam".into() }); + s.step(20); + let steam_above = s.count_material_in_region(100, 105, 10, 10, "steam"); + assert!(steam_above > 0, "steam should rise upward"); +} + +#[test] +fn grass_is_solid() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "grass".into() }); + let cell = s.get_cell(105, 110); + assert!(cell.is_solid, "grass should be solid"); +} + +#[test] +fn dirt_is_solid() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "dirt".into() }); + let cell = s.get_cell(105, 110); + assert!(cell.is_solid, "dirt should be solid"); +} + +#[test] +fn stone_is_static() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() }); + s.step(20); + assert_eq!(s.get_cell(105, 110).material, "stone", "stone should not move"); +} + +#[test] +fn wood_is_static() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "wood".into() }); + s.step(20); + assert_eq!(s.get_cell(105, 110).material, "wood", "wood should not move"); +} + +#[test] +fn bone_is_static() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "bone".into() }); + s.step(20); + assert_eq!(s.get_cell(105, 110).material, "bone", "bone should not move"); +} + +#[test] +fn lava_initial_temp_is_high() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "lava".into() }); + let cell = s.get_cell(105, 110); + assert!(cell.temp > 1000.0, "lava should start very hot, got {}°C", cell.temp); +} + +#[test] +fn water_initial_temp_is_room() { + let mut s = setup(); + s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "water".into() }); + let cell = s.get_cell(105, 110); + assert!(cell.temp < 50.0, "water should start at room temp, got {}°C", cell.temp); +} diff --git a/tests/physics_sand.rs b/tests/physics_sand.rs index 312e404..b7fa01c 100644 --- a/tests/physics_sand.rs +++ b/tests/physics_sand.rs @@ -1,6 +1,5 @@ use verbatim::ai::GameSession; use verbatim::ai::AiAction; -use verbatim::world::cell::MaterialId; fn setup_empty() -> GameSession { let mut s = GameSession::new_seeded(42); diff --git a/tests/player_controls.rs b/tests/player_controls.rs new file mode 100644 index 0000000..a61a20f --- /dev/null +++ b/tests/player_controls.rs @@ -0,0 +1,166 @@ +use verbatim::ai::GameSession; +use verbatim::ai::AiAction; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() }); + s +} + +#[test] +fn move_left_changes_x_position() { + let mut s = setup(); + s.step(30); + let p0 = s.get_player().unwrap(); + let x0 = p0.pos[0]; + s.perform_action(&AiAction::MoveLeft); + s.step(5); + let p1 = s.get_player().unwrap(); + assert!(p1.pos[0] < x0, "player should move left: {} -> {}", x0, p1.pos[0]); +} + +#[test] +fn move_right_changes_x_position() { + let mut s = setup(); + s.step(30); + let p0 = s.get_player().unwrap(); + let x0 = p0.pos[0]; + s.perform_action(&AiAction::MoveRight); + s.step(5); + let p1 = s.get_player().unwrap(); + assert!(p1.pos[0] > x0, "player should move right: {} -> {}", x0, p1.pos[0]); +} + +#[test] +fn move_left_then_right_cancels() { + let mut s = setup(); + s.step(30); + let p0 = s.get_player().unwrap(); + let x0 = p0.pos[0]; + s.perform_action(&AiAction::MoveLeft); + s.step(5); + s.perform_action(&AiAction::MoveRight); + s.step(5); + let p1 = s.get_player().unwrap(); + assert!((p1.pos[0] - x0).abs() < 3.0, "left+right should roughly cancel: {} -> {}", x0, p1.pos[0]); +} + +#[test] +fn jump_goes_up_then_falls_back() { + let mut s = setup(); + s.step(30); + let p0 = s.get_player().unwrap(); + let y0 = p0.pos[1]; + s.perform_action(&AiAction::Jump); + s.step(5); + let p1 = s.get_player().unwrap(); + assert!(p1.pos[1] < y0, "player should go up after jump: {} -> {}", y0, p1.pos[1]); + s.step(60); + let p2 = s.get_player().unwrap(); + assert!((p2.pos[1] - y0).abs() < 5.0, "player should fall back after jump: {} -> {}", y0, p2.pos[1]); +} + +#[test] +fn jump_while_airborne_does_nothing() { + let mut s1 = setup(); + s1.step(30); + s1.perform_action(&AiAction::Jump); + s1.step(3); + let mut s2 = setup(); + s2.step(30); + s2.perform_action(&AiAction::Jump); + s2.step(3); + s2.perform_action(&AiAction::Jump); + s1.step(3); + s2.step(3); + let p1 = s1.get_player().unwrap(); + let p2 = s2.get_player().unwrap(); + assert!((p1.pos[1] - p2.pos[1]).abs() < 2.0, "double jump should not add height: single={} double={}", p1.pos[1], p2.pos[1]); +} + +#[test] +fn rapid_move_right_accumulates_velocity() { + let mut s = setup(); + s.step(30); + let p0 = s.get_player().unwrap(); + let x0 = p0.pos[0]; + for _ in 0..5 { + s.perform_action(&AiAction::MoveRight); + s.step(1); + } + let p1 = s.get_player().unwrap(); + let single = { + let mut s2 = setup(); + s2.step(30); + s2.perform_action(&AiAction::MoveRight); + s2.step(1); + s2.get_player().unwrap().pos[0] + }; + let single_dx = single - x0; + let multi_dx = p1.pos[0] - x0; + assert!(multi_dx > single_dx, "rapid moves should accumulate: 1x={} 5x={}", single_dx, multi_dx); +} + +#[test] +fn wait_does_not_move() { + let mut s = setup(); + s.step(30); + let p0 = s.get_player().unwrap(); + let pos0 = (p0.pos[0], p0.pos[1]); + s.perform_action(&AiAction::Wait); + s.step(10); + let p1 = s.get_player().unwrap(); + assert!((p1.pos[0] - pos0.0).abs() < 1.0 && (p1.pos[1] - pos0.1).abs() < 1.0, + "wait should not move player: ({},{}) -> ({},{})", pos0.0, pos0.1, p1.pos[0], p1.pos[1]); +} + +#[test] +fn continuous_movement_does_not_fall_through_floor() { + let mut s = setup(); + s.step(30); + let p0 = s.get_player().unwrap(); + let y0 = p0.pos[1]; + for _ in 0..20 { + s.perform_action(&AiAction::MoveRight); + s.step(2); + } + let p1 = s.get_player().unwrap(); + assert!(p1.alive, "player should survive extended movement"); + assert!((p1.pos[1] - y0).abs() < 5.0, "player should not fall through floor during movement: y0={} y1={}", y0, p1.pos[1]); +} + +#[test] +fn player_on_ground_check() { + let mut s = setup(); + s.step(40); + assert!(s.game.check_on_ground(), "player should be on ground after settling"); +} + +#[test] +fn player_not_on_ground_while_jumping() { + let mut s = setup(); + s.step(40); + assert!(s.game.check_on_ground(), "player should start on ground"); + s.perform_action(&AiAction::Jump); + s.step(3); + assert!(!s.game.check_on_ground(), "player should not be on ground mid-jump"); +} + +#[test] +fn player_health_stays_full_without_damage() { + let mut s = setup(); + s.step(60); + let p = s.get_player().unwrap(); + assert_eq!(p.health, 100.0, "player should have full health without damage"); +} + +#[test] +fn player_survives_long_idle() { + let mut s = setup(); + s.step(200); + let p = s.get_player().unwrap(); + assert!(p.alive, "player should survive 200 ticks of idle"); + assert!(p.health > 90.0, "player should not lose health idling"); +} diff --git a/tests/ragdoll_death.rs b/tests/ragdoll_death.rs new file mode 100644 index 0000000..4bcccd4 --- /dev/null +++ b/tests/ragdoll_death.rs @@ -0,0 +1,126 @@ +use verbatim::ai::GameSession; +use verbatim::ai::AiAction; + +fn setup() -> GameSession { + let mut s = GameSession::new_seeded(42); + s.init_empty(); + s.clear_area(90, 90, 50, 50); + s.perform_action(&AiAction::FillRect { x: 80, y: 130, w: 80, h: 15, material: "stone".into() }); + s +} + +#[test] +fn death_transitions_rigid_to_ragdoll() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 120.0 }); + s.step(20); + let entities = s.get_entities(); + let goblin = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); + assert!(goblin.alive, "goblin should be alive initially"); + + s.perform_action(&AiAction::DamageEntity { id: goblin.id, amount: 100.0 }); + s.step(1); + let entities = s.get_entities(); + let corpse = entities.into_iter().find(|e| e.id == goblin.id).unwrap(); + assert!(!corpse.alive, "goblin should be dead after 100 damage"); + assert_eq!(corpse.kind, "Corpse", "dead goblin should become corpse"); +} + +#[test] +fn ragdoll_falls_after_death() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 110.0 }); + s.step(20); + let entities = s.get_entities(); + let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); + let y_before = g.pos[1]; + + s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 100.0 }); + s.step(1); + let y_death = { + let e = s.get_entities().into_iter().find(|e| e.id == g.id).unwrap(); + e.pos[1] + }; + s.step(30); + let y_after = { + let e = s.get_entities().into_iter().find(|e| e.id == g.id).unwrap(); + e.pos[1] + }; + assert!(y_after > y_before, "corpse should fall: y_before={} y_after={}", y_before, y_after); +} + +#[test] +fn ragdoll_bodies_stay_near_each_other() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 130.0, y: 110.0 }); + s.step(20); + let entities = s.get_entities(); + let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); + s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 100.0 }); + s.step(10); + let entities = s.get_entities(); + let corpse = entities.into_iter().find(|e| e.id == g.id).unwrap(); + assert!(!corpse.alive, "corpse should be dead"); + assert!(corpse.body_count > 0, "corpse should have some alive bodies"); +} + +#[test] +fn player_death_becomes_corpse() { + let mut s = setup(); + s.step(30); + let p = s.get_player().unwrap(); + s.perform_action(&AiAction::DamageEntity { id: p.id, amount: 200.0 }); + s.step(1); + let p2 = s.get_player().unwrap(); + assert!(!p2.alive, "player should be dead after 200 damage"); +} + +#[test] +fn damage_reduces_health_progressively() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 120.0 }); + s.step(20); + let entities = s.get_entities(); + let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); + assert_eq!(g.health, 40.0, "goblin should start at 40 HP"); + + s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 10.0 }); + s.step(1); + let entities = s.get_entities(); + let g2 = entities.into_iter().find(|e| e.id == g.id).unwrap(); + assert!((g2.health - 30.0).abs() < 0.01, "goblin should have 30 HP after 10 damage, got {}", g2.health); + assert!(g2.alive, "goblin should survive 10 damage"); +} + +#[test] +fn small_damage_does_not_kill() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 120.0 }); + s.step(20); + let entities = s.get_entities(); + let g = entities.into_iter().find(|e| e.kind == "Goblin").unwrap(); + s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 39.0 }); + s.step(1); + let entities = s.get_entities(); + let g2 = entities.into_iter().find(|e| e.id == g.id).unwrap(); + assert!(g2.alive, "goblin should survive 39 damage (HP=1)"); + s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 1.0 }); + s.step(1); + let entities = s.get_entities(); + let g3 = entities.into_iter().find(|e| e.id == g.id).unwrap(); + assert!(!g3.alive, "goblin should die at 0 HP"); +} + +#[test] +fn corpse_exists_in_world() { + let mut s = setup(); + s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 140.0, y: 120.0 }); + s.step(20); + let g_id = s.get_entities().into_iter().find(|e| e.kind == "Goblin").unwrap().id; + s.perform_action(&AiAction::DamageEntity { id: g_id, amount: 100.0 }); + s.step(5); + let entities = s.get_entities(); + let corpse = entities.into_iter().find(|e| e.id == g_id); + assert!(corpse.is_some(), "corpse entity should persist in world"); + assert!(!corpse.unwrap().alive, "corpse should not be alive"); +}