use neurodoom::engine::ClassicEngine;
use neurodoom::game_data::MobjFlag;
use neurodoom::map::MapData;
use neurodoom::math::*;
use neurodoom::physics;
use neurodoom::rules::PlayerAction;
use neurodoom::texture::TextureData;
use neurodoom::wad::Wad;
use neurodoom::world::PeerId;
fn load_wad() -> Vec<u8> {
let path = std::env::var("DOOM_WAD")
.unwrap_or_else(|_| "doom1.wad".to_string());
std::fs::read(&path).unwrap_or_else(|e| panic!("failed to read WAD at {path}: {e}"))
}
fn setup_map() -> MapData {
let data = load_wad();
let wad = Wad::parse(&data).unwrap();
let mut map = MapData::load(&wad, "E1M1").unwrap();
let textures = TextureData::load(&wad).unwrap();
map.resolve_textures(&textures);
map
}
#[test]
fn sight_same_point_is_visible() {
let map = setup_map();
let p1 = map.things.iter().find(|t| t.type_num == 1).unwrap();
let x = (p1.x as Fixed) << FRACBITS;
let y = (p1.y as Fixed) << FRACBITS;
let (fz, _) = physics::find_sector_heights(&map, x, y);
let eye_z = fz + 41 * FRACUNIT;
assert!(
physics::check_sight(&map, x, y, eye_z, x, y, fz, 56 * FRACUNIT),
"same position should always be visible"
);
}
#[test]
fn sight_nearby_open_area() {
let map = setup_map();
let p1 = map.things.iter().find(|t| t.type_num == 1).unwrap();
let x1 = (p1.x as Fixed) << FRACBITS;
let y1 = (p1.y as Fixed) << FRACBITS;
let (fz1, _) = physics::find_sector_heights(&map, x1, y1);
let eye_z = fz1 + 41 * FRACUNIT;
let x2 = x1 + 64 * FRACUNIT;
let y2 = y1;
let (fz2, _) = physics::find_sector_heights(&map, x2, y2);
assert!(
physics::check_sight(&map, x1, y1, eye_z, x2, y2, fz2, 56 * FRACUNIT),
"nearby point in same room should be visible"
);
}
#[test]
fn sight_blocked_by_wall() {
let map = setup_map();
let x1 = 1056 * FRACUNIT;
let y1 = -3616 * FRACUNIT;
let (fz1, _) = physics::find_sector_heights(&map, x1, y1);
let eye_z = fz1 + 41 * FRACUNIT;
let x2 = 3200 * FRACUNIT;
let y2 = -2400 * FRACUNIT;
let (fz2, _) = physics::find_sector_heights(&map, x2, y2);
assert!(
!physics::check_sight(&map, x1, y1, eye_z, x2, y2, fz2, 56 * FRACUNIT),
"sight across map through walls should be blocked"
);
}
#[test]
fn monsters_dont_activate_through_walls() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
for _ in 0..35 {
engine.tick_single(PeerId(0), PlayerAction::default());
}
let player_pos = engine.world().iter()
.find(|e| engine.world().is_controlled(e.id))
.map(|e| (e.x, e.y))
.unwrap();
let mut far_monsters_with_target = 0;
let mut far_monsters_total = 0;
for e in engine.world().iter() {
if !e.flags.contains(MobjFlag::Shootable) || !e.flags.contains(MobjFlag::CountKill) {
continue;
}
let dist = ((e.x - player_pos.0).abs() as i64 + (e.y - player_pos.1).abs() as i64)
>> FRACBITS;
if dist > 1500 {
far_monsters_total += 1;
if e.target.is_some() {
far_monsters_with_target += 1;
}
}
}
assert!(
far_monsters_total > 0,
"E1M1 should have distant monsters"
);
assert_eq!(
far_monsters_with_target, 0,
"far-away monsters in other rooms should not have acquired a target \
({far_monsters_with_target}/{far_monsters_total} did)"
);
}
#[test]
fn hitscan_does_not_damage_through_walls() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
engine.tick_single(PeerId(0), PlayerAction::default());
let initial_health: Vec<_> = engine.world().iter()
.filter(|e| e.flags.contains(MobjFlag::Shootable) && e.flags.contains(MobjFlag::CountKill))
.map(|e| (e.id, e.health))
.collect();
let player_pos = engine.world().iter()
.find(|e| engine.world().is_controlled(e.id))
.map(|e| (e.x, e.y))
.unwrap();
for angle_step in 0..8 {
let angle_turn = (angle_step * 8192) as i16;
engine.tick_single(PeerId(0), PlayerAction {
angle_turn,
..PlayerAction::default()
});
engine.tick_single(PeerId(0), PlayerAction {
buttons: neurodoom::types::Button::Attack.into(),
..PlayerAction::default()
});
}
for e in engine.world().iter() {
if !e.flags.contains(MobjFlag::Shootable) || !e.flags.contains(MobjFlag::CountKill) {
continue;
}
let dist = ((e.x - player_pos.0).abs() as i64 + (e.y - player_pos.1).abs() as i64)
>> FRACBITS;
if dist > 1500 {
if let Some((_, orig_hp)) = initial_health.iter().find(|(id, _)| *id == e.id) {
assert_eq!(
e.health, *orig_hp,
"monster at distance {} should not take hitscan damage through walls",
dist
);
}
}
}
}
#[test]
fn monster_movement_speed_is_nonzero() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let initial_positions: Vec<_> = engine.world().iter()
.filter(|e| e.flags.contains(MobjFlag::Shootable) && e.flags.contains(MobjFlag::CountKill))
.map(|e| (e.id, e.x, e.y))
.collect();
for _ in 0..105 {
engine.tick_single(PeerId(0), PlayerAction {
forward_move: 25,
..PlayerAction::default()
});
}
let mut any_moved = false;
for e in engine.world().iter() {
if e.target.is_none() { continue; }
if let Some((_, ox, oy)) = initial_positions.iter().find(|(id, _, _)| *id == e.id) {
if e.x != *ox || e.y != *oy {
any_moved = true;
break;
}
}
}
let targeted = engine.world().iter().filter(|e| e.target.is_some()
&& e.flags.contains(MobjFlag::CountKill)).count();
if targeted > 0 {
assert!(any_moved, "monsters with targets should move (speed bug regression)");
}
}
#[test]
fn weapon_state_machine_fires_and_returns_to_ready() {
use neurodoom::game_data::{S_PISTOL, S_PISTOL1};
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let pid = engine.world().controlled_entities().next().unwrap();
let psp = engine.world().player_state(pid).unwrap().psp_state;
assert_eq!(psp, S_PISTOL, "weapon should start in ready state");
let attack = PlayerAction {
buttons: neurodoom::types::Button::Attack.into(),
..PlayerAction::default()
};
engine.tick_single(PeerId(0), attack.clone());
let psp = engine.world().player_state(pid).unwrap().psp_state;
assert_eq!(psp, S_PISTOL1, "weapon should enter fire state on attack");
let idle = PlayerAction::default();
for _ in 0..30 {
engine.tick_single(PeerId(0), idle.clone());
}
let psp = engine.world().player_state(pid).unwrap().psp_state;
assert_eq!(psp, S_PISTOL, "weapon should return to ready state after firing");
}
#[test]
fn pistol_damage_in_expected_range() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let mut damages = Vec::new();
for _ in 0..100 {
let r = engine.world_mut().p_random();
let damage = 5 * (r % 3 + 1);
damages.push(damage);
}
assert!(damages.iter().all(|&d| d >= 5 && d <= 15),
"pistol damage should be in range 5-15, got {:?}", damages);
assert!(damages.iter().any(|&d| d == 5), "should sometimes roll minimum damage");
assert!(damages.iter().any(|&d| d == 15), "should sometimes roll maximum damage");
}
#[test]
fn monster_hitscan_damage_in_expected_range() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let mut damages = Vec::new();
for _ in 0..100 {
let r = engine.world_mut().p_random();
let damage = (r % 5 + 1) * 3;
damages.push(damage);
}
assert!(damages.iter().all(|&d| d >= 3 && d <= 15),
"monster damage should be in range 3-15, got {:?}", damages);
assert!(damages.iter().any(|&d| d == 3), "should sometimes roll minimum damage");
assert!(damages.iter().any(|&d| d == 15), "should sometimes roll maximum damage");
}
#[test]
fn player_health_clamps_at_one() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let attack_forward = PlayerAction {
forward_move: 25,
..PlayerAction::default()
};
for _ in 0..350 {
engine.tick_single(PeerId(0), attack_forward.clone());
}
let pid = engine.world().controlled_entities().next().unwrap();
let player = engine.world().get(pid).unwrap();
assert!(player.health >= 1, "player health should clamp at 1, got {}", player.health);
assert!(player.flags.contains(MobjFlag::Shootable),
"player should remain shootable after reaching low HP");
}
#[test]
fn weapon_bob_changes_when_moving() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let pid = engine.world().controlled_entities().next().unwrap();
engine.tick_single(PeerId(0), PlayerAction::default());
let bob_still = engine.world().player_state(pid).unwrap().bob;
assert_eq!(bob_still, 0, "bob should be 0 when standing still");
for _ in 0..10 {
engine.tick_single(PeerId(0), PlayerAction {
forward_move: 25,
..PlayerAction::default()
});
}
let bob_moving = engine.world().player_state(pid).unwrap().bob;
assert!(bob_moving > 0, "bob should be positive when moving, got {bob_moving}");
let ps = engine.world().player_state(pid).unwrap();
let sx = ps.psp_sx;
let _sy = ps.psp_sy;
assert!(sx != 0, "weapon sx should be non-zero");
}
#[test]
fn imp_fireball_has_correct_properties() {
let info = neurodoom::game_data::EntityType(31).info().unwrap();
assert!(info.speed > 0, "imp fireball should have non-zero speed, got {}", info.speed);
assert!(info.flags().contains(MobjFlag::Missile), "imp fireball should have Missile flag");
assert!(!info.spawnstate.is_null(), "imp fireball should have a spawn state");
let st = info.spawnstate.get().unwrap();
assert_eq!(st.sprite.0, 18, "imp fireball should use BAL1 sprite (18), got {}", st.sprite.0);
assert_eq!(info.damage, 3, "imp fireball should do 3 damage");
}
#[test]
fn imp_fires_projectile_when_in_range() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
for _ in 0..700 {
engine.tick_single(PeerId(0), PlayerAction {
forward_move: 25,
..PlayerAction::default()
});
}
for _ in 0..200 {
engine.tick_single(PeerId(0), PlayerAction::default());
}
let fireballs: Vec<_> = engine.world().iter()
.filter(|e| e.entity_type == neurodoom::game_data::EntityType(31))
.collect();
let missiles: Vec<_> = engine.world().iter()
.filter(|e| e.flags.contains(MobjFlag::Missile))
.collect();
let imps: Vec<_> = engine.world().iter()
.filter(|e| e.entity_type == neurodoom::game_data::EntityType(13))
.collect();
let imps_with_target = imps.iter().filter(|e| e.target.is_some()).count();
let imp_states: Vec<_> = imps.iter().map(|e| e.state.0).collect();
eprintln!("Fireballs (ET33): {}, Total missiles: {}", fireballs.len(), missiles.len());
eprintln!("Imps: {}, with target: {}, states: {:?}", imps.len(), imps_with_target, imp_states);
}
#[test]
fn puff_spawns_on_hitscan_hit() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let _initial_count = engine.world().entity_count();
for _ in 0..70 {
engine.tick_single(PeerId(0), PlayerAction {
forward_move: 25,
..PlayerAction::default()
});
}
let attack = PlayerAction {
buttons: neurodoom::types::Button::Attack.into(),
..PlayerAction::default()
};
for _ in 0..20 {
engine.tick_single(PeerId(0), attack.clone());
}
let puff_count = engine.world().iter()
.filter(|e| e.entity_type == neurodoom::game_data::EntityType(37))
.count();
let _ = puff_count;
}
#[test]
fn rocket_launcher_spawns_projectile() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let pid = engine.world().controlled_entities().next().unwrap();
if let Some(ps) = engine.world_mut().player_state_mut(pid) {
ps.weapon_owned[neurodoom::types::WeaponType::RocketLauncher as usize] = true;
ps.ammo = [999, 99, 999, 99];
ps.ready_weapon = neurodoom::types::WeaponType::RocketLauncher;
ps.psp_state = neurodoom::game_data::StateNum(57); ps.psp_tics = 1;
}
let attack = PlayerAction {
buttons: neurodoom::types::Button::Attack.into(),
..PlayerAction::default()
};
for _ in 0..20 {
engine.tick_single(PeerId(0), attack.clone());
}
let missiles: Vec<_> = engine.world().iter()
.filter(|e| e.flags.contains(MobjFlag::Missile))
.collect();
let rockets: Vec<_> = engine.world().iter()
.filter(|e| e.entity_type == neurodoom::game_data::EntityType(33))
.collect();
eprintln!("Missiles: {}, Rockets (ET33): {}", missiles.len(), rockets.len());
let total = engine.world().entity_count();
eprintln!("Total entities: {}", total);
assert!(missiles.len() > 0 || rockets.len() > 0,
"rocket launcher should spawn at least one projectile");
}
#[test]
fn e1m1_step_lines_have_lower_textures() {
let wad_data = load_wad();
let wad = neurodoom::wad::Wad::parse(&wad_data).unwrap();
let mut map = neurodoom::map::MapData::load(&wad, "E1M1").unwrap();
let textures = neurodoom::texture::TextureData::load(&wad).unwrap();
map.resolve_textures(&textures);
for li in 73..83 {
let line = &map.lines[li];
let s0 = line.sidenum[0].unwrap() as usize;
let side = &map.sides[s0];
eprintln!("Line {li}: top={} bot={} mid={} bot_name={:?}",
side.top_texture, side.bottom_texture, side.mid_texture,
core::str::from_utf8(&side.bottom_texture_name).unwrap_or("?"));
}
let has_bot = (73..83).any(|li| {
let s0 = map.lines[li].sidenum[0].unwrap() as usize;
map.sides[s0].bottom_texture > 0
});
assert!(has_bot, "E1M1 step lines should have lower textures (SLADWALL)");
}
#[test]
fn e1m1_step_walls_visible() {
let wad_data = load_wad();
let mut engine = ClassicEngine::new(&wad_data, "E1M1").unwrap();
engine.tick_single(PeerId(0), PlayerAction::default());
let wall_count = engine.semantic_buffer().iter()
.filter(|&&c| c == neurodoom::render::SemanticClass::Wall as u8).count();
assert!(wall_count > 1000, "should have many wall pixels");
}
#[test]
fn e1m1_diagonal_stripe_midtextures_resolved() {
let wad_data = load_wad();
let wad = neurodoom::wad::Wad::parse(&wad_data).unwrap();
let mut map = neurodoom::map::MapData::load(&wad, "E1M1").unwrap();
let textures = neurodoom::texture::TextureData::load(&wad).unwrap();
map.resolve_textures(&textures);
for li in 298..=303 {
let line = &map.lines[li];
for si in 0..2 {
if let Some(sid) = line.sidenum[si] {
let side = &map.sides[sid as usize];
eprintln!("Line {li} side[{si}]={sid}: mid_tex={} mid_name={:?}",
side.mid_texture,
core::str::from_utf8(&side.mid_texture_name).unwrap_or("?"));
}
}
}
let line298 = &map.lines[298];
let s0 = line298.sidenum[0].unwrap() as usize;
assert!(map.sides[s0].mid_texture > 0,
"BRNBIGL should resolve to valid texture index, got {}", map.sides[s0].mid_texture);
}
#[test]
fn brnbigl_texture_has_content() {
let wad_data = load_wad();
let wad = neurodoom::wad::Wad::parse(&wad_data).unwrap();
let textures = neurodoom::texture::TextureData::load(&wad).unwrap();
let tex = &textures.textures[5];
eprintln!("BRNBIGL: {}x{}, data_len={}", tex.width, tex.height, tex.data.len());
let nonzero = tex.data.iter().filter(|&&b| b != 0).count();
let total = tex.data.len();
eprintln!("Non-zero pixels: {}/{} ({:.1}%)", nonzero, total, 100.0 * nonzero as f64 / total as f64);
let col0 = tex.column(0);
let col0_nz = col0.iter().filter(|&&b| b != 0).count();
eprintln!("Column 0: {} non-zero out of {}", col0_nz, col0.len());
assert!(nonzero > 0, "BRNBIGL should have non-zero pixels (diagonal stripes)");
}
#[test]
fn prng_is_deterministic() {
let wad_data = load_wad();
let mut e1 = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let mut e2 = ClassicEngine::new(&wad_data, "E1M1").unwrap();
let seq1: Vec<_> = (0..50).map(|_| e1.world_mut().p_random()).collect();
let seq2: Vec<_> = (0..50).map(|_| e2.world_mut().p_random()).collect();
assert_eq!(seq1, seq2, "PRNG should be deterministic across instances");
}