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:
Emil
2026-06-21 16:09:45 +03:00
parent c25cae9a32
commit 357db17c2f
53 changed files with 6362 additions and 651 deletions
+90
View File
@@ -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);
}
+152
View File
@@ -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
View File
@@ -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]
+102
View File
@@ -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);
}
+49
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+188
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}