- 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.
325 lines
8.0 KiB
Rust
325 lines
8.0 KiB
Rust
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
|
|
}
|
|
|
|
#[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, p.max_health,
|
|
"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!(
|
|
p.body_count >= 30,
|
|
"player should have 30+ bodies (detailed humanoid), got {}",
|
|
p.body_count
|
|
);
|
|
}
|
|
|
|
#[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, 80.0, "goblin max health should be 80");
|
|
}
|
|
|
|
#[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, 150.0, "player max health should be 150");
|
|
}
|
|
|
|
#[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);
|
|
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"
|
|
);
|
|
}
|