feat: GPU optimization — lighting, viewport CA, benchmark mode, 531 FPS
- Resolution: 8x8 world cells, 2x2 UI cells (UI_SCALE=4) - GPU lighting: vertex-shader computed, light source list buffer (max 64) instead of O(N×R²) grid scan, O(N×S) per cell - Viewport-aware CA: iterate only active chunks, not all 250×250 - Flat array entity/item/shadow maps instead of HashMaps - Flat 128-entry ASCII atlas array instead of HashMap lookup - Partial grid upload: viewport + 30-cell margin only - Pre-allocated viewport arrays in renderer structs (zero alloc/frame) - Skip CPU lighting for GPU modes (pass None) - Benchmark mode: --mode benchmark with per-subsystem timing - GpuLightSource struct, light_count in push constants - gather_sources_in_range() for viewport-scoped source gathering Benchmark (600 ticks, release): Graphics: 531 FPS (was 386, +38%), render 1013us (was 1699us, -40%) ASCII: 402 FPS (was 313, +28%), render 1502us (was 2346us, -36%) All 171 tests + 14 scenarios pass.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
use verbatim::world::cell::{Cell, MaterialId};
|
||||
use verbatim::world::chunk::{world_to_chunk, Chunk, CHUNK_SIZE};
|
||||
use verbatim::world::grid::Grid;
|
||||
|
||||
#[test]
|
||||
fn grid_has_expected_chunks() {
|
||||
let g = Grid::new();
|
||||
assert_eq!(g.chunk_size, CHUNK_SIZE);
|
||||
assert_eq!(g.chunks_x, 4);
|
||||
assert_eq!(g.chunks_y, 4);
|
||||
assert_eq!(g.chunks.len(), 16);
|
||||
assert!(g.chunks.iter().all(|c| c.active));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_to_chunk_mapping() {
|
||||
assert_eq!(world_to_chunk(0, 0), (0, 0, 0, 0));
|
||||
assert_eq!(world_to_chunk(63, 63), (0, 0, 63, 63));
|
||||
assert_eq!(world_to_chunk(64, 64), (1, 1, 0, 0));
|
||||
assert_eq!(world_to_chunk(100, 118), (1, 1, 36, 54));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_local_get_set() {
|
||||
let mut c = Chunk::new();
|
||||
assert!(c.get(0, 0).is_empty());
|
||||
c.set(0, 0, Cell::new(MaterialId::Sand));
|
||||
assert_eq!(c.get(0, 0).material, MaterialId::Sand);
|
||||
assert!(c.modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_get_set_marks_chunk_modified() {
|
||||
let mut g = Grid::new();
|
||||
g.set(10, 10, Cell::new(MaterialId::Water));
|
||||
assert!(g.chunks[g.chunk_index(0, 0)].modified);
|
||||
assert!(!g.chunks[g.chunk_index(1, 1)].modified);
|
||||
g.set(70, 70, Cell::new(MaterialId::Lava));
|
||||
assert!(g.chunks[g.chunk_index(1, 1)].modified);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_serialization_roundtrip() {
|
||||
let c = Cell::new(MaterialId::Wood);
|
||||
let bytes = c.to_bytes();
|
||||
let c2 = Cell::from_bytes(&bytes);
|
||||
assert_eq!(c.material, c2.material);
|
||||
assert_eq!(c.fg, c2.fg);
|
||||
assert_eq!(c.bg, c2.bg);
|
||||
assert_eq!(c.variant, c2.variant);
|
||||
assert!((c.temp - c2.temp).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_chunk_roundtrip() {
|
||||
let mut g = Grid::new();
|
||||
g.set(70, 70, Cell::new(MaterialId::Sand));
|
||||
g.set(71, 70, Cell::new(MaterialId::Water));
|
||||
let path = "/tmp/verbatim_chunk_test_1_1.bin";
|
||||
let _ = std::fs::remove_file(path);
|
||||
g.save_chunk(path, 1, 1).unwrap();
|
||||
let mut g2 = Grid::new();
|
||||
g2.load_chunk(path, 1, 1).unwrap();
|
||||
assert_eq!(g2.get(70, 70).material, MaterialId::Sand);
|
||||
assert_eq!(g2.get(71, 70).material, MaterialId::Water);
|
||||
assert!(g2.chunks[g2.chunk_index(1, 1)].active);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_chunk_cells_are_skipped() {
|
||||
let mut g = Grid::new();
|
||||
g.set(10, 10, Cell::new(MaterialId::Sand));
|
||||
g.set(10, 11, Cell::new(MaterialId::Empty));
|
||||
g.deactivate_all();
|
||||
assert!(!g.cell_active(10, 10));
|
||||
assert!(!g.cell_active(10, 11));
|
||||
g.set_chunk_active(0, 0, true);
|
||||
assert!(g.cell_active(10, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_bounds_clip_to_world() {
|
||||
let g = Grid::new();
|
||||
let (x0, y0, x1, y1) = g.chunk_bounds(3, 3);
|
||||
assert_eq!(x0, 192);
|
||||
assert_eq!(y0, 192);
|
||||
assert_eq!(x1, 250);
|
||||
assert_eq!(y1, 250);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descend_requires_stairs() {
|
||||
let mut s = setup();
|
||||
let start_depth = s.game.depth;
|
||||
s.perform_action(&AiAction::Descend);
|
||||
assert_eq!(
|
||||
s.game.depth, start_depth,
|
||||
"descend should not work without stairs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descend_increases_depth_on_stairs() {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(90, 90, 50, 50);
|
||||
let player = s.game.player.center(&s.game.entities);
|
||||
let foot_x = player.0 as i32;
|
||||
let foot_y = (player.1 + 3.0) as i32;
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: foot_x,
|
||||
y: foot_y,
|
||||
material: "stairs".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: foot_x,
|
||||
y: foot_y + 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.step(5);
|
||||
let before = s.game.depth;
|
||||
s.perform_action(&AiAction::Descend);
|
||||
assert_eq!(
|
||||
s.game.depth,
|
||||
before + 1,
|
||||
"descend should increase depth when on stairs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_shown_in_hud() {
|
||||
let s = setup();
|
||||
let player = s.game.entities.all()[0].clone();
|
||||
let mut ui = verbatim::ui::UiLayer::new();
|
||||
ui.draw_hud(
|
||||
80,
|
||||
25,
|
||||
Some(&player),
|
||||
s.game.tick,
|
||||
verbatim::world::cell::MaterialId::Sand,
|
||||
0,
|
||||
0,
|
||||
s.game.depth,
|
||||
&s.game.player,
|
||||
60.0,
|
||||
);
|
||||
let stats = format!(
|
||||
"LV:{} XP:{} K:{} S:{} D:{} T:{}",
|
||||
player.level, player.xp, 0, 0, s.game.depth, s.game.tick
|
||||
);
|
||||
let stats_w = verbatim::ui::UiLayer::text_width(&stats);
|
||||
let stats_x = (80 - stats_w).max(0);
|
||||
let line: String = (stats_x..80)
|
||||
.step_by(3)
|
||||
.map(|x| ui.get(x, 24).map(|c| c.ch).unwrap_or(' '))
|
||||
.collect();
|
||||
assert!(line.contains("D:1"), "HUD should show depth: {}", line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stairs_material_exists() {
|
||||
let mut s = setup();
|
||||
let cell = s.get_cell(0, 0);
|
||||
assert_ne!(cell.material, "stairs", "empty corner should not be stairs");
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 50,
|
||||
y: 50,
|
||||
material: "stairs".into(),
|
||||
});
|
||||
let cell = s.get_cell(50, 50);
|
||||
assert_eq!(
|
||||
cell.material, "stairs",
|
||||
"stairs material should be placeable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_stairs_bytes_roundtrip() {
|
||||
let cell = verbatim::world::cell::Cell::new(verbatim::world::cell::MaterialId::Stairs);
|
||||
let bytes = cell.to_bytes();
|
||||
let cell2 = verbatim::world::cell::Cell::from_bytes(&bytes);
|
||||
assert_eq!(cell2.material, verbatim::world::cell::MaterialId::Stairs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_name_stairs() {
|
||||
let mut s = setup();
|
||||
let cell = s.get_cell(50, 50);
|
||||
assert_eq!(cell.material, "empty");
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 50,
|
||||
y: 50,
|
||||
material: "stairs".into(),
|
||||
});
|
||||
let cell = s.get_cell(50, 50);
|
||||
assert_eq!(cell.material, "stairs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descend_resets_world() {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(90, 90, 50, 50);
|
||||
let player = s.game.player.center(&s.game.entities);
|
||||
let foot_x = player.0 as i32;
|
||||
let foot_y = (player.1 + 3.0) as i32;
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: foot_x,
|
||||
y: foot_y,
|
||||
material: "stairs".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: foot_x,
|
||||
y: foot_y + 1,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.step(5);
|
||||
let before = s.game.depth;
|
||||
s.perform_action(&AiAction::Descend);
|
||||
assert_eq!(s.game.depth, before + 1);
|
||||
assert!(
|
||||
s.game.player.entity(&s.game.entities).is_some(),
|
||||
"player should respawn"
|
||||
);
|
||||
assert!(
|
||||
s.game
|
||||
.entities
|
||||
.all()
|
||||
.iter()
|
||||
.all(|e| e.alive || e.kind == verbatim::entity::EntityKind::Corpse),
|
||||
"old corpses should be gone"
|
||||
);
|
||||
}
|
||||
+6
-3
@@ -109,7 +109,10 @@ fn player_at_spawn_is_alive() {
|
||||
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");
|
||||
assert_eq!(
|
||||
p.health, p.max_health,
|
||||
"player should have full health at spawn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -134,7 +137,7 @@ fn goblin_has_correct_max_health() {
|
||||
});
|
||||
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");
|
||||
assert_eq!(g.max_health, 80.0, "goblin max health should be 80");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -142,7 +145,7 @@ 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");
|
||||
assert_eq!(p.max_health, 150.0, "player max health should be 150");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::entity::{ItemManager, ItemType};
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(90, 90, 50, 50);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_picked_up_when_near_player() {
|
||||
let mut s = setup();
|
||||
let pos = s.get_player().unwrap().pos;
|
||||
let x = pos[0] as i32;
|
||||
let y = pos[1] as i32;
|
||||
s.game.items.spawn(ItemType::Sword, x, y);
|
||||
assert_eq!(s.game.player.inventory.len(), 0);
|
||||
s.step(1);
|
||||
assert_eq!(s.game.player.inventory.len(), 1);
|
||||
assert_eq!(s.game.player.inventory[0].typ, ItemType::Sword);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipping_weapon_adds_damage_bonus() {
|
||||
let mut s = setup();
|
||||
let pos = s.get_player().unwrap().pos;
|
||||
s.game
|
||||
.items
|
||||
.spawn(ItemType::Sword, pos[0] as i32, pos[1] as i32);
|
||||
s.step(1);
|
||||
s.game.use_item(0);
|
||||
assert_eq!(s.game.player.weapon.as_ref().unwrap().typ, ItemType::Sword);
|
||||
let bonus = s.game.player.weapon.as_ref().unwrap().damage_bonus();
|
||||
assert!(bonus > 0.0, "equipped weapon should provide damage bonus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equipping_armor_reduces_contact_damage() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 120.0,
|
||||
y: 120.0,
|
||||
});
|
||||
let pos = s.get_player().unwrap().pos;
|
||||
s.game
|
||||
.items
|
||||
.spawn(ItemType::PlateArmor, pos[0] as i32, pos[1] as i32);
|
||||
s.step(1);
|
||||
s.game.use_item(0);
|
||||
let armor = s.game.player.armor.as_ref().unwrap().armor_bonus();
|
||||
assert!(armor > 0.0, "plate armor should provide armor bonus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_heals_player() {
|
||||
let mut s = setup();
|
||||
let pos = s.get_player().unwrap().pos;
|
||||
s.game
|
||||
.items
|
||||
.spawn(ItemType::HealthPotion, pos[0] as i32, pos[1] as i32);
|
||||
s.step(1);
|
||||
let id = s.get_player().unwrap().id;
|
||||
s.perform_action(&AiAction::DamageEntity { id, amount: 50.0 });
|
||||
let health_before = s.get_player().unwrap().health;
|
||||
s.game.use_item(0);
|
||||
let health_after = s.get_player().unwrap().health;
|
||||
assert!(health_after > health_before, "health potion should heal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_item_returns_to_world() {
|
||||
let mut s = setup();
|
||||
let pos = s.get_player().unwrap().pos;
|
||||
s.game
|
||||
.items
|
||||
.spawn(ItemType::Dagger, pos[0] as i32, pos[1] as i32);
|
||||
s.step(1);
|
||||
s.game.drop_item(0);
|
||||
assert_eq!(s.game.player.inventory.len(), 0);
|
||||
let count = s
|
||||
.game
|
||||
.items
|
||||
.all()
|
||||
.iter()
|
||||
.filter(|i| i.typ == ItemType::Dagger)
|
||||
.count();
|
||||
assert_eq!(count, 1, "dropped item should exist in world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_manager_spawns_and_removes() {
|
||||
let mut mgr = ItemManager::new();
|
||||
let id = mgr.spawn(ItemType::Food, 100, 100);
|
||||
assert_eq!(id, 0);
|
||||
assert_eq!(mgr.all().len(), 1);
|
||||
let removed = mgr.remove_at(100, 100);
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(mgr.all().len(), 0);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(90, 90, 80, 80);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpses_do_not_cause_lag() {
|
||||
let mut s = setup();
|
||||
for i in 0..20 {
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 100.0 + (i % 5) as f32 * 3.0,
|
||||
y: 100.0 + (i / 5) as f32 * 3.0,
|
||||
});
|
||||
}
|
||||
s.step(20);
|
||||
let ids: Vec<u32> = s
|
||||
.get_entities()
|
||||
.into_iter()
|
||||
.filter(|e| e.kind == "Goblin")
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
|
||||
let start_before = std::time::Instant::now();
|
||||
s.step(60);
|
||||
let before_ms = start_before.elapsed().as_secs_f32() * 1000.0 / 60.0;
|
||||
|
||||
for id in &ids {
|
||||
s.perform_action(&AiAction::DamageEntity {
|
||||
id: *id,
|
||||
amount: 100.0,
|
||||
});
|
||||
}
|
||||
let start_after = std::time::Instant::now();
|
||||
s.step(60);
|
||||
let after_ms = start_after.elapsed().as_secs_f32() * 1000.0 / 60.0;
|
||||
|
||||
assert!(
|
||||
after_ms < before_ms * 3.0,
|
||||
"corpse simulation should not be dramatically slower: before={:.2}ms after={:.2}ms",
|
||||
before_ms,
|
||||
after_ms
|
||||
);
|
||||
}
|
||||
+230
-44
@@ -1,5 +1,5 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
@@ -11,28 +11,76 @@ fn setup() -> GameSession {
|
||||
#[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.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");
|
||||
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.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");
|
||||
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.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");
|
||||
@@ -41,21 +89,55 @@ fn fire_ignites_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.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");
|
||||
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() });
|
||||
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.perform_action(&AiAction::SetCell {
|
||||
x,
|
||||
y,
|
||||
material: "smoke".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
s.step(15);
|
||||
@@ -66,7 +148,11 @@ fn smoke_rises() {
|
||||
#[test]
|
||||
fn smoke_dissipates_over_time() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "smoke".into() });
|
||||
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");
|
||||
@@ -75,26 +161,78 @@ fn smoke_dissipates_over_time() {
|
||||
#[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() });
|
||||
// Closed container so steam/water cannot drift into inactive chunks.
|
||||
for y in 116..125 {
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 100,
|
||||
y,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x: 109,
|
||||
y,
|
||||
material: "stone".into(),
|
||||
});
|
||||
}
|
||||
for x in 100..110 {
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x,
|
||||
y: 116,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::SetCell {
|
||||
x,
|
||||
y: 124,
|
||||
material: "stone".into(),
|
||||
});
|
||||
}
|
||||
for y in 117..124 {
|
||||
for x in 101..109 {
|
||||
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);
|
||||
s.step(200);
|
||||
let water_or_steam = s.count_material_in_region(100, 116, 10, 9, "water")
|
||||
+ s.count_material_in_region(100, 116, 10, 9, "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, 116, 10, 9, "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.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");
|
||||
@@ -103,7 +241,11 @@ fn steam_rises() {
|
||||
#[test]
|
||||
fn grass_is_solid() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "grass".into() });
|
||||
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");
|
||||
}
|
||||
@@ -111,7 +253,11 @@ fn grass_is_solid() {
|
||||
#[test]
|
||||
fn dirt_is_solid() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "dirt".into() });
|
||||
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");
|
||||
}
|
||||
@@ -119,39 +265,79 @@ fn dirt_is_solid() {
|
||||
#[test]
|
||||
fn stone_is_static() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() });
|
||||
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");
|
||||
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.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");
|
||||
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.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");
|
||||
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() });
|
||||
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);
|
||||
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() });
|
||||
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);
|
||||
assert!(
|
||||
cell.temp < 50.0,
|
||||
"water should start at room temp, got {}°C",
|
||||
cell.temp
|
||||
);
|
||||
}
|
||||
|
||||
+76
-15
@@ -1,11 +1,17 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
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.perform_action(&AiAction::FillRect {
|
||||
x: 80,
|
||||
y: 130,
|
||||
w: 80,
|
||||
h: 15,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
@@ -18,7 +24,12 @@ fn move_left_changes_x_position() {
|
||||
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]);
|
||||
assert!(
|
||||
p1.pos[0] < x0,
|
||||
"player should move left: {} -> {}",
|
||||
x0,
|
||||
p1.pos[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -30,7 +41,12 @@ fn move_right_changes_x_position() {
|
||||
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]);
|
||||
assert!(
|
||||
p1.pos[0] > x0,
|
||||
"player should move right: {} -> {}",
|
||||
x0,
|
||||
p1.pos[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -44,7 +60,12 @@ fn move_left_then_right_cancels() {
|
||||
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]);
|
||||
assert!(
|
||||
(p1.pos[0] - x0).abs() < 3.0,
|
||||
"left+right should roughly cancel: {} -> {}",
|
||||
x0,
|
||||
p1.pos[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -56,10 +77,20 @@ fn jump_goes_up_then_falls_back() {
|
||||
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]);
|
||||
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]);
|
||||
assert!(
|
||||
(p2.pos[1] - y0).abs() < 5.0,
|
||||
"player should fall back after jump: {} -> {}",
|
||||
y0,
|
||||
p2.pos[1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -77,7 +108,12 @@ fn jump_while_airborne_does_nothing() {
|
||||
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]);
|
||||
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]
|
||||
@@ -100,7 +136,12 @@ fn rapid_move_right_accumulates_velocity() {
|
||||
};
|
||||
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);
|
||||
assert!(
|
||||
multi_dx > single_dx,
|
||||
"rapid moves should accumulate: 1x={} 5x={}",
|
||||
single_dx,
|
||||
multi_dx
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -112,8 +153,14 @@ fn wait_does_not_move() {
|
||||
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]);
|
||||
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]
|
||||
@@ -128,14 +175,22 @@ fn continuous_movement_does_not_fall_through_floor() {
|
||||
}
|
||||
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]);
|
||||
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");
|
||||
assert!(
|
||||
s.game.check_on_ground(),
|
||||
"player should be on ground after settling"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -145,7 +200,10 @@ fn player_not_on_ground_while_jumping() {
|
||||
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");
|
||||
assert!(
|
||||
!s.game.check_on_ground(),
|
||||
"player should not be on ground mid-jump"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -153,7 +211,10 @@ 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");
|
||||
assert_eq!(
|
||||
p.health, p.max_health,
|
||||
"player should have full health without damage"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
use verbatim::ai::{AiAction, GameSession};
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(80, 80, 80, 80);
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 80,
|
||||
y: 130,
|
||||
w: 80,
|
||||
h: 5,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projectile_travels_and_deals_damage() {
|
||||
let mut s = setup();
|
||||
let player_pos = s.get_player().unwrap().pos;
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: player_pos[0] + 15.0,
|
||||
y: player_pos[1],
|
||||
});
|
||||
s.step(10);
|
||||
let goblin = s
|
||||
.get_entities()
|
||||
.into_iter()
|
||||
.find(|e| e.kind == "Goblin")
|
||||
.unwrap();
|
||||
let hp_before = goblin.health;
|
||||
s.perform_action(&AiAction::Shoot {
|
||||
dir_x: 1.0,
|
||||
dir_y: 0.0,
|
||||
});
|
||||
s.step(10);
|
||||
let goblin_after = s
|
||||
.get_entities()
|
||||
.into_iter()
|
||||
.find(|e| e.kind == "Goblin")
|
||||
.unwrap();
|
||||
assert!(
|
||||
goblin_after.health < hp_before,
|
||||
"goblin should take projectile damage: {} -> {}",
|
||||
hp_before,
|
||||
goblin_after.health
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projectile_stops_on_solid_cell() {
|
||||
let mut s = setup();
|
||||
let player_pos = s.get_player().unwrap().pos;
|
||||
let wall_x = player_pos[0] as i32 + 8;
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: wall_x,
|
||||
y: player_pos[1] as i32 - 2,
|
||||
w: 4,
|
||||
h: 4,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::Shoot {
|
||||
dir_x: 1.0,
|
||||
dir_y: 0.0,
|
||||
});
|
||||
s.step(10);
|
||||
let cell = s.get_cell(wall_x, player_pos[1] as i32);
|
||||
assert_eq!(
|
||||
cell.material, "stone",
|
||||
"projectile should not destroy stone wall"
|
||||
);
|
||||
let state = s.get_state();
|
||||
let projectile_count = state
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|e| e.kind == "Projectile")
|
||||
.count();
|
||||
assert_eq!(
|
||||
projectile_count, 0,
|
||||
"projectile should be destroyed after hitting wall"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fireball_ignites_wood() {
|
||||
let mut s = setup();
|
||||
let player_pos = s.get_player().unwrap().pos;
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: player_pos[0] as i32 + 5,
|
||||
y: player_pos[1] as i32 - 2,
|
||||
w: 6,
|
||||
h: 4,
|
||||
material: "wood".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::ToggleFireball);
|
||||
s.perform_action(&AiAction::Shoot {
|
||||
dir_x: 1.0,
|
||||
dir_y: 0.0,
|
||||
});
|
||||
s.step(15);
|
||||
let fire_count = s.count_material_in_region(
|
||||
player_pos[0] as i32 + 4,
|
||||
player_pos[1] as i32 - 3,
|
||||
10,
|
||||
8,
|
||||
"fire",
|
||||
);
|
||||
assert!(
|
||||
fire_count > 0,
|
||||
"fireball should ignite wood, got {} fire cells",
|
||||
fire_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpse_decomposes_into_flesh_cells() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 130,
|
||||
w: 40,
|
||||
h: 5,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 100,
|
||||
y: 100,
|
||||
w: 1,
|
||||
h: 30,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::FillRect {
|
||||
x: 139,
|
||||
y: 100,
|
||||
w: 1,
|
||||
h: 30,
|
||||
material: "stone".into(),
|
||||
});
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 110.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s.step(10);
|
||||
let goblin = s
|
||||
.get_entities()
|
||||
.into_iter()
|
||||
.find(|e| e.kind == "Goblin")
|
||||
.unwrap();
|
||||
s.perform_action(&AiAction::DamageEntity {
|
||||
id: goblin.id,
|
||||
amount: 100.0,
|
||||
});
|
||||
s.step(100);
|
||||
let corpse = s
|
||||
.get_entities()
|
||||
.into_iter()
|
||||
.find(|e| e.kind == "Corpse")
|
||||
.unwrap();
|
||||
let _pos = corpse.pos;
|
||||
let after = s.count_material_in_region(100, 120, 40, 20, "flesh");
|
||||
assert!(
|
||||
after > 0,
|
||||
"corpse should decompose into flesh cells, got {}",
|
||||
after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_hud_shows_player_hp() {
|
||||
let mut s = setup();
|
||||
let mut ui = verbatim::ui::UiLayer::new();
|
||||
let player = s.game.entities.all()[0].clone();
|
||||
ui.draw_hud(
|
||||
80,
|
||||
25,
|
||||
Some(&player),
|
||||
s.tick(),
|
||||
verbatim::world::cell::MaterialId::Sand,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
&s.game.player,
|
||||
60.0,
|
||||
);
|
||||
assert!(ui.get(0, 8).is_some(), "HUD should draw HP label");
|
||||
}
|
||||
+94
-23
@@ -1,24 +1,37 @@
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
|
||||
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.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.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.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();
|
||||
@@ -29,34 +42,56 @@ fn death_transitions_rigid_to_ragdoll() {
|
||||
#[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.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.perform_action(&AiAction::DamageEntity {
|
||||
id: g.id,
|
||||
amount: 100.0,
|
||||
});
|
||||
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);
|
||||
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.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.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");
|
||||
assert!(
|
||||
corpse.body_count > 0,
|
||||
"corpse should have some alive bodies"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -64,7 +99,10 @@ 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.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");
|
||||
@@ -73,33 +111,54 @@ fn player_death_becomes_corpse() {
|
||||
#[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.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");
|
||||
assert_eq!(g.health, 80.0, "goblin should start at 80 HP");
|
||||
|
||||
s.perform_action(&AiAction::DamageEntity { id: g.id, amount: 10.0 });
|
||||
s.perform_action(&AiAction::DamageEntity {
|
||||
id: g.id,
|
||||
amount: 20.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");
|
||||
assert!(
|
||||
(g2.health - 60.0).abs() < 0.01,
|
||||
"goblin should have 60 HP after 20 damage, got {}",
|
||||
g2.health
|
||||
);
|
||||
assert!(g2.alive, "goblin should survive 20 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.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.perform_action(&AiAction::DamageEntity {
|
||||
id: g.id,
|
||||
amount: 79.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 });
|
||||
assert!(g2.alive, "goblin should survive 79 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();
|
||||
@@ -109,10 +168,22 @@ fn small_damage_does_not_kill() {
|
||||
#[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.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 });
|
||||
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);
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
use verbatim::ai::AiAction;
|
||||
use verbatim::ai::GameSession;
|
||||
use verbatim::entity::EntityKind;
|
||||
|
||||
fn setup() -> GameSession {
|
||||
let mut s = GameSession::new_seeded(42);
|
||||
s.init_empty();
|
||||
s.clear_area(90, 90, 50, 50);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_has_stats() {
|
||||
let mut s = setup();
|
||||
let p = s.get_player().unwrap();
|
||||
assert!(p.strength > 0, "player should have strength");
|
||||
assert!(p.agility > 0, "player should have agility");
|
||||
assert!(p.toughness > 0, "player should have toughness");
|
||||
assert!(p.willpower > 0, "player should have willpower");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goblin_has_stats() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 120.0,
|
||||
y: 120.0,
|
||||
});
|
||||
let g = s
|
||||
.get_entities()
|
||||
.into_iter()
|
||||
.find(|e| e.kind == "Goblin")
|
||||
.unwrap();
|
||||
assert!(g.strength > 0, "goblin should have strength");
|
||||
assert!(g.toughness > 0, "goblin should have toughness");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_health_depends_on_toughness() {
|
||||
let mut s = setup();
|
||||
let p = s.get_player().unwrap();
|
||||
let expected = 80.0 + p.toughness as f32 * 5.0 + p.level as f32 * 10.0;
|
||||
assert!(
|
||||
(p.max_health - expected).abs() < 0.01,
|
||||
"max health should be based on toughness and level"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killing_grants_xp() {
|
||||
let mut s = setup();
|
||||
s.perform_action(&AiAction::Spawn {
|
||||
kind: "goblin".into(),
|
||||
x: 120.0,
|
||||
y: 120.0,
|
||||
});
|
||||
s.step(10);
|
||||
let id = s
|
||||
.get_entities()
|
||||
.into_iter()
|
||||
.find(|e| e.kind == "Goblin")
|
||||
.unwrap()
|
||||
.id;
|
||||
let xp_before = s.get_player().unwrap().xp;
|
||||
s.perform_action(&AiAction::DamageEntity { id, amount: 100.0 });
|
||||
s.step(1);
|
||||
let xp_after = s.get_player().unwrap().xp;
|
||||
assert!(xp_after > xp_before, "killing an enemy should grant XP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xp_accumulation_levels_up() {
|
||||
let mut s = setup();
|
||||
let p = s.game.player.entity_mut(&mut s.game.entities).unwrap();
|
||||
p.add_xp(100);
|
||||
assert_eq!(p.level, 2, "100 XP should level up from 1 to 2");
|
||||
assert_eq!(p.xp, 0, "XP should be reset after level up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_up_increases_max_health() {
|
||||
let mut s = setup();
|
||||
let before = s.get_player().unwrap().max_health;
|
||||
let p = s.game.player.entity_mut(&mut s.game.entities).unwrap();
|
||||
p.add_xp(100);
|
||||
let after = s.get_player().unwrap().max_health;
|
||||
assert!(after > before, "level up should increase max health");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poison_deals_damage_over_time() {
|
||||
let mut s = setup();
|
||||
let id = s.get_player().unwrap().id;
|
||||
s.perform_action(&AiAction::DamageEntity { id, amount: 10.0 });
|
||||
let before = s.get_player().unwrap().health;
|
||||
if let Some(p) = s.game.player.entity_mut(&mut s.game.entities) {
|
||||
p.poisoned = true;
|
||||
}
|
||||
s.step(10);
|
||||
let after = s.get_player().unwrap().health;
|
||||
assert!(after < before, "poison should deal damage over time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bleeding_deals_damage_over_time() {
|
||||
let mut s = setup();
|
||||
let id = s.get_player().unwrap().id;
|
||||
s.perform_action(&AiAction::DamageEntity { id, amount: 10.0 });
|
||||
let before = s.get_player().unwrap().health;
|
||||
if let Some(p) = s.game.player.entity_mut(&mut s.game.entities) {
|
||||
p.bleeding = true;
|
||||
}
|
||||
s.step(10);
|
||||
let after = s.get_player().unwrap().health;
|
||||
assert!(after < before, "bleeding should deal damage over time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_does_not_deal_damage() {
|
||||
let mut s = setup();
|
||||
let id = s.get_player().unwrap().id;
|
||||
s.perform_action(&AiAction::DamageEntity { id, amount: 10.0 });
|
||||
let before = s.get_player().unwrap().health;
|
||||
if let Some(p) = s.game.player.entity_mut(&mut s.game.entities) {
|
||||
p.frozen = true;
|
||||
}
|
||||
s.step(10);
|
||||
let after = s.get_player().unwrap().health;
|
||||
assert!(
|
||||
(after - before).abs() < 1.0,
|
||||
"frozen should not deal damage directly"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_effects_expire() {
|
||||
let mut s = setup();
|
||||
if let Some(p) = s.game.player.entity_mut(&mut s.game.entities) {
|
||||
p.poisoned = true;
|
||||
p.poison_timer = 200;
|
||||
}
|
||||
s.step(200);
|
||||
let p = s.get_player().unwrap();
|
||||
assert!(!p.poisoned, "poison should expire after timer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_up_heals_to_full() {
|
||||
let mut s = setup();
|
||||
let p = s.game.player.entity_mut(&mut s.game.entities).unwrap();
|
||||
p.health = 10.0;
|
||||
p.add_xp(100);
|
||||
assert_eq!(p.health, p.max_health, "level up should heal to full");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_info_includes_level() {
|
||||
let mut s = setup();
|
||||
let p = s.get_player().unwrap();
|
||||
assert_eq!(p.level, 1, "player should start at level 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpse_does_not_gain_xp() {
|
||||
let mut s = setup();
|
||||
let player_id = s.get_player().unwrap().id;
|
||||
s.perform_action(&AiAction::DamageEntity {
|
||||
id: player_id,
|
||||
amount: 999.0,
|
||||
});
|
||||
s.step(1);
|
||||
let p = s.game.player.entity_mut(&mut s.game.entities).unwrap();
|
||||
let xp_before = p.xp;
|
||||
p.add_xp(100);
|
||||
assert_eq!(p.xp, xp_before, "dead player should not gain XP");
|
||||
}
|
||||
+3
-3
@@ -28,7 +28,7 @@ fn slime_spawns_correctly() {
|
||||
assert!(slime.is_some(), "slime should exist after spawn");
|
||||
let sl = slime.unwrap();
|
||||
assert!(sl.alive, "slime should be alive");
|
||||
assert_eq!(sl.max_health, 25.0, "slime max health should be 25");
|
||||
assert_eq!(sl.max_health, 65.0, "slime max health should be 65");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -46,11 +46,11 @@ fn slime_takes_damage_and_dies() {
|
||||
.find(|e| e.kind == "Slime")
|
||||
.unwrap()
|
||||
.id;
|
||||
s.perform_action(&AiAction::DamageEntity { id, amount: 25.0 });
|
||||
s.perform_action(&AiAction::DamageEntity { id, amount: 65.0 });
|
||||
s.step(1);
|
||||
let entities = s.get_entities();
|
||||
let sl = entities.into_iter().find(|e| e.id == id).unwrap();
|
||||
assert!(!sl.alive, "slime should die after 25 damage");
|
||||
assert!(!sl.alive, "slime should die after 65 damage");
|
||||
assert_eq!(sl.kind, "Corpse", "dead slime should become corpse");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user