- Cache active_chunks(): one allocation instead of 5 per tick - heat_transfer/gas_step/pressure_step: direct chunk array access (chunk.temps[idx], chunk.gas_*[idx], chunk.pressure[idx]) instead of grid.get_temp()/set_temp() (eliminates 5 chunk-lookups per cell) - In-place updated_this_tick reset via get_chunk_mut (no Cell copy, no mark_dirty) - light_step: gather sources first (one pass), skip if no sources, reduced radius (lava 25→12, fire 15→6), frequency 10→20 ticks - gas_step: skip chunks with no gas (gas_density all 0) - pressure_step: skip chunks with all atmospheric (128), add mark_dirty - pre_dirty: HashMap instead of Vec (O(1) lookup vs O(n) linear search) - Cross-chunk heat transfer: edge_temps pre-loaded before mutable borrow - Spread chunk save I/O: 1 chunk/tick instead of all every 60 ticks - All 185 tests + 14 scenarios pass - Benchmark: 82.7 FPS avg, p99 86ms (was 18 FPS, p99 442ms)
316 lines
8.2 KiB
Rust
316 lines
8.2 KiB
Rust
use verbatim::ai::action::AiAction;
|
|
use verbatim::ai::session::GameSession;
|
|
use verbatim::world::cell::MaterialId;
|
|
use verbatim::world::chunk::CHUNK_SIZE;
|
|
use verbatim::world::chunked_grid::ChunkedGrid;
|
|
|
|
fn setup() -> GameSession {
|
|
let mut s = GameSession::new();
|
|
s.init_empty();
|
|
s
|
|
}
|
|
|
|
#[test]
|
|
fn fire_produces_co2_gas() {
|
|
let mut s = setup();
|
|
s.perform_action(&AiAction::SetCell {
|
|
x: 100,
|
|
y: 100,
|
|
material: "wood".into(),
|
|
});
|
|
s.perform_action(&AiAction::SetCell {
|
|
x: 99,
|
|
y: 100,
|
|
material: "fire".into(),
|
|
});
|
|
s.step(2);
|
|
let mut found_co2 = false;
|
|
for dy in -5..=0 {
|
|
let (gt, gd) = s.game.grid.get_gas(99, 100 + dy);
|
|
if gt == 3 && gd > 0 {
|
|
found_co2 = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(found_co2, "fire should produce CO2 (type 3) near fire");
|
|
}
|
|
|
|
#[test]
|
|
fn heat_transfer_diffuses_through_solid() {
|
|
let mut grid = ChunkedGrid::with_size(250, 250);
|
|
grid.set_material(100, 100, MaterialId::Stone);
|
|
grid.set_material(101, 100, MaterialId::Stone);
|
|
grid.set_temp(100, 100, 500.0);
|
|
grid.mark_dirty(100, 100);
|
|
let mut ca = verbatim::world::cellular::CellularAutomaton::new();
|
|
for _ in 0..100 {
|
|
ca.step(&mut grid);
|
|
}
|
|
let neighbor_temp = grid.get_temp(101, 100);
|
|
assert!(
|
|
neighbor_temp > 25.0,
|
|
"heat should diffuse to adjacent stone, got {:.1}",
|
|
neighbor_temp
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lava_heats_adjacent_water_to_steam() {
|
|
let mut s = setup();
|
|
s.perform_action(&AiAction::SetCell {
|
|
x: 100,
|
|
y: 100,
|
|
material: "lava".into(),
|
|
});
|
|
s.perform_action(&AiAction::SetCell {
|
|
x: 101,
|
|
y: 100,
|
|
material: "water".into(),
|
|
});
|
|
s.step(5);
|
|
let cell = s.get_cell(101, 100);
|
|
assert!(
|
|
cell.material == "steam" || cell.material == "stone" || cell.material == "empty",
|
|
"water should become steam, lava stone, or steam rises away, got '{}'",
|
|
cell.material
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gas_layer_exists_and_defaults_to_air() {
|
|
let s = setup();
|
|
let (gt, gd) = s.game.grid.get_gas(100, 100);
|
|
assert_eq!(gt, 0, "default gas type should be air (0)");
|
|
assert_eq!(gd, 0, "default gas density should be 0");
|
|
}
|
|
|
|
#[test]
|
|
fn gas_rises_upward() {
|
|
let mut s = setup();
|
|
s.game.grid.set_gas(100, 105, 1, 200);
|
|
s.step(3);
|
|
let mut found_above = false;
|
|
for y in 100..=104 {
|
|
let (_, gd) = s.game.grid.get_gas(100, y);
|
|
if gd > 0 {
|
|
found_above = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(found_above, "smoke gas should rise upward");
|
|
}
|
|
|
|
#[test]
|
|
fn pressure_layer_defaults_to_atmospheric() {
|
|
let s = setup();
|
|
let p = s.game.grid.get_pressure(100, 100);
|
|
assert_eq!(p, 128, "default pressure should be 128 (atmospheric)");
|
|
}
|
|
|
|
#[test]
|
|
fn pressure_equalizes_between_neighbors() {
|
|
let mut grid = ChunkedGrid::with_size(250, 250);
|
|
grid.set_pressure(100, 100, 200);
|
|
grid.set_pressure(101, 100, 128);
|
|
grid.mark_dirty(100, 100);
|
|
grid.mark_dirty(101, 100);
|
|
let mut ca = verbatim::world::cellular::CellularAutomaton::new();
|
|
for _ in 0..100 {
|
|
ca.step(&mut grid);
|
|
}
|
|
let p1 = grid.get_pressure(100, 100);
|
|
let p2 = grid.get_pressure(101, 100);
|
|
let diff = (p1 as i32 - p2 as i32).abs();
|
|
assert!(
|
|
diff < 30,
|
|
"pressure should equalize, got p1={} p2={} diff={}",
|
|
p1,
|
|
p2,
|
|
diff
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn light_layer_defaults_to_dark() {
|
|
let s = setup();
|
|
let l = s.game.grid.get_light(100, 100);
|
|
assert_eq!(l, [0, 0, 0], "default light should be dark");
|
|
}
|
|
|
|
#[test]
|
|
fn lava_emits_world_space_light() {
|
|
let mut grid = ChunkedGrid::with_size(250, 250);
|
|
for &(dx, dy) in &[
|
|
(0, 0),
|
|
(0, 1),
|
|
(0, -1),
|
|
(1, 0),
|
|
(-1, 0),
|
|
(1, 1),
|
|
(-1, 1),
|
|
(1, -1),
|
|
(-1, -1),
|
|
] {
|
|
grid.set_material(
|
|
100 + dx,
|
|
100 + dy,
|
|
if dx == 0 && dy == 0 {
|
|
MaterialId::Lava
|
|
} else {
|
|
MaterialId::Stone
|
|
},
|
|
);
|
|
}
|
|
grid.mark_dirty(100, 100);
|
|
let mut ca = verbatim::world::cellular::CellularAutomaton::new();
|
|
for _ in 0..100 {
|
|
ca.step(&mut grid);
|
|
}
|
|
let l = grid.get_light(100, 100);
|
|
assert!(
|
|
l[0] > 0 || l[1] > 0 || l[2] > 0,
|
|
"lava should emit world-space light, got {:?}",
|
|
l
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn light_blocked_by_solid_walls() {
|
|
let mut s = setup();
|
|
s.perform_action(&AiAction::SetCell {
|
|
x: 100,
|
|
y: 100,
|
|
material: "stone".into(),
|
|
});
|
|
s.perform_action(&AiAction::SetCell {
|
|
x: 100,
|
|
y: 101,
|
|
material: "stone".into(),
|
|
});
|
|
s.perform_action(&AiAction::SetCell {
|
|
x: 100,
|
|
y: 102,
|
|
material: "lava".into(),
|
|
});
|
|
for x in 101..=104 {
|
|
for y in 96..=108 {
|
|
s.perform_action(&AiAction::SetCell {
|
|
x,
|
|
y,
|
|
material: "stone".into(),
|
|
});
|
|
}
|
|
}
|
|
s.step(40);
|
|
let l_behind = s.game.grid.get_light(105, 102);
|
|
assert!(
|
|
l_behind[0] < 30,
|
|
"light should be blocked by thick stone wall, got {:?}",
|
|
l_behind
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn chunk_save_load_roundtrip_preserves_all_layers() {
|
|
let mut grid = ChunkedGrid::with_size(250, 250);
|
|
grid.set_material(70, 70, MaterialId::Lava);
|
|
grid.set_temp(70, 70, 1500.0);
|
|
grid.set_gas(70, 70, 3, 100);
|
|
grid.set_pressure(70, 70, 200);
|
|
grid.set_light(70, 70, [255, 128, 64]);
|
|
|
|
let path = "/tmp/verbatim_multilayer_chunk_1_1.bin";
|
|
let _ = std::fs::remove_file(path);
|
|
grid.save_chunk(path, 1, 1).unwrap();
|
|
|
|
let mut grid2 = ChunkedGrid::with_size(250, 250);
|
|
grid2.load_chunk(path, 1, 1).unwrap();
|
|
|
|
assert_eq!(grid2.get(70, 70).material, MaterialId::Lava);
|
|
assert!(
|
|
(grid2.get_temp(70, 70) - 1500.0).abs() < 1.0,
|
|
"temp should roundtrip"
|
|
);
|
|
assert_eq!(grid2.get_gas(70, 70), (3, 100), "gas should roundtrip");
|
|
assert_eq!(grid2.get_pressure(70, 70), 200, "pressure should roundtrip");
|
|
assert_eq!(
|
|
grid2.get_light(70, 70),
|
|
[255, 128, 64],
|
|
"light should roundtrip"
|
|
);
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn steam_gas_condenses_to_water_when_cold() {
|
|
let mut s = setup();
|
|
s.game.grid.set_gas(100, 100, 4, 200);
|
|
s.game.grid.set_temp(100, 100, 50.0);
|
|
s.game.grid.mark_dirty(100, 100);
|
|
s.step(1);
|
|
let cell = s.get_cell(100, 100);
|
|
assert_eq!(
|
|
cell.material, "water",
|
|
"steam gas at 50C should condense to water, got '{}'",
|
|
cell.material
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn poison_gas_damages_entity() {
|
|
let mut s = setup();
|
|
let (px, py) = s.game.player.center(&s.game.entities);
|
|
let health_before = s
|
|
.game
|
|
.player
|
|
.entity(&s.game.entities)
|
|
.map(|e| e.health)
|
|
.unwrap_or(0.0);
|
|
for _ in 0..10 {
|
|
s.game.grid.set_gas(px as i32, py as i32, 2, 200);
|
|
s.step(1);
|
|
}
|
|
let health_after = s
|
|
.game
|
|
.player
|
|
.entity(&s.game.entities)
|
|
.map(|e| e.health)
|
|
.unwrap_or(0.0);
|
|
assert!(
|
|
health_after < health_before,
|
|
"poison gas should damage entity: before={} after={}",
|
|
health_before,
|
|
health_after
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn temperature_persists_across_chunk_boundary() {
|
|
let mut grid = ChunkedGrid::with_size(256, 256);
|
|
let boundary = CHUNK_SIZE as i32;
|
|
grid.set_material(boundary - 1, 0, MaterialId::Stone);
|
|
grid.set_material(boundary, 0, MaterialId::Stone);
|
|
grid.set_temp(boundary - 1, 0, 500.0);
|
|
grid.set_temp(boundary, 0, 20.0);
|
|
grid.mark_dirty(boundary - 1, 0);
|
|
grid.mark_dirty(boundary, 0);
|
|
let mut ca = verbatim::world::cellular::CellularAutomaton::new();
|
|
for _ in 0..200 {
|
|
ca.step(&mut grid);
|
|
}
|
|
let t_near = grid.get_temp(boundary - 1, 0);
|
|
let t_far = grid.get_temp(boundary, 0);
|
|
assert!(
|
|
t_near < 400.0,
|
|
"heat should diffuse from hot cell, got {:.1}",
|
|
t_near
|
|
);
|
|
assert!(
|
|
(t_near - t_far).abs() < 200.0,
|
|
"temperatures should converge somewhat, near={:.1} far={:.1}",
|
|
t_near,
|
|
t_far
|
|
);
|
|
}
|