test: 109 Rust tests + 14 JSON scenarios, polish physics
Tests added: - physics_materials.rs: 15 tests (fire, smoke, steam, grass, dirt, bone, wood, stone, lava temp) - physics_interactions.rs: 12 tests (lava+water, fire spread, acid interactions, sand+water) - player_controls.rs: 12 tests (move L/R, jump, double jump, rapid input, wait, continuous) - collision_robust.rs: 10 tests (walls, ceiling, sliding, corridor, slope, dirt wall, unstick) - ragdoll_death.rs: 7 tests (death transition, ragdoll fall, progressive damage, corpse) - determinism.rs: 8 tests (same seed, replay exact, replay partial, recording, 100-tick) - edge_cases.rs: 19 tests (boundary, stress 20 goblins, fill/clear/paint, gravity, camera) - 6 new JSON scenarios (fire chain, lava pool, sand bury, entity fall, steam, acid wall) Fixes: - AABB resolve_aabb_y: add vertical overlap check (was pushing player down when jumping) - AABB resolve_aabb_x: velocity-aware resolution (player was stuck against walls) - check_on_ground: use fractional position check (was always true) - jump_force 1.5 (was 0.8) for 27-body entity - Gravity read from self.verlet (was from clone, SetGravity didn't work) - u8 overflow in lava color (saturating_add) 109 Rust tests, 14 JSON scenarios, 0 warnings, 0 failures
This commit is contained in:
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-15
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user