feat: AI integration - pipe protocol, test framework, replay system

AI Session API (src/ai/session.rs):
- GameSession wraps Game with seeded determinism
- init/init_empty, step(n), perform_action, get_state
- get_cell, get_region, get_entities, get_player, count_material

JSON Pipe Protocol (src/ai/protocol.rs):
- --mode pipe: stdin/stdout JSON line protocol
- Commands: init, step, action, get_state, get_view, get_cell,
  get_region, get_entities, get_player, count_material, find_material,
  record_start/stop, replay_save, run_scenario, quit

Test Framework:
- 27 Rust integration tests (tests/*.rs) covering sand, water, lava,
  acid, fire physics + entity movement, damage, ragdoll
- 8 JSON scenarios (scenarios/*.json) runnable via --mode test
- Scenario assertions: cell_is, cell_is_not, no_material_in_region,
  material_count_in_region, entity_alive/dead, player_on_ground, etc.

Replay System (src/ai/replay.rs):
- Record all actions + seed for deterministic playback
- ReplayPlayer::play() and play_until_tick() for debugging
- --mode replay --replay-file PATH

Other changes:
- src/lib.rs: library target for integration tests
- Collision: post-constraint collision pass to reduce tunneling
- serde + serde_json dependencies
- All enums use rename_all = snake_case for JSON compatibility
This commit is contained in:
Emil
2026-06-20 18:53:21 +03:00
parent 3cd94a386a
commit 7f4b1dde5e
26 changed files with 2027 additions and 83 deletions
+73
View File
@@ -0,0 +1,73 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(95, 95, 40, 40);
s
}
#[test]
fn entity_takes_lava_damage() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 116, y: 123, w: 4, h: 2, material: "lava".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 120.0 });
s.step(60);
let entities = s.get_entities();
let goblin = entities.into_iter().find(|e| e.kind == "Goblin");
assert!(goblin.is_some(), "goblin should exist");
let g = goblin.unwrap();
assert!(g.health < 40.0, "goblin should have taken damage from lava, hp={}", g.health);
}
#[test]
fn entity_dies_becomes_corpse() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 110.0, y: 120.0 });
s.step(30);
s.perform_action(&AiAction::DamageEntity { id: 1, amount: 100.0 });
s.step(1);
let entities = s.get_entities();
let goblin = entities.into_iter().find(|e| e.id == 1);
assert!(goblin.is_some(), "entity should still exist");
let g = goblin.unwrap();
assert!(!g.alive, "entity should be dead after 100 damage");
assert_eq!(g.kind, "Corpse", "dead entity should be a corpse, got {}", g.kind);
}
#[test]
fn entity_on_fire_takes_damage_over_time() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 125, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 116, y: 123, w: 4, h: 2, material: "lava".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 118.0, y: 120.0 });
s.step(20);
let entities = s.get_entities();
let goblin = entities.into_iter().find(|e| e.id == 1);
if let Some(g) = goblin {
if g.on_fire {
let hp_after_fire = g.health;
s.step(30);
let entities2 = s.get_entities();
if let Some(g2) = entities2.into_iter().find(|e| e.id == 1) {
assert!(g2.health < hp_after_fire, "entity on fire should lose more health over time");
}
}
}
}
#[test]
fn entity_blocked_by_stone() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 130, w: 30, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 110, y: 126, w: 1, h: 4, material: "stone".into() });
s.perform_action(&AiAction::Spawn { kind: "goblin".into(), x: 105.0, y: 125.0 });
s.step(30);
let entities = s.get_entities();
if let Some(g) = entities.into_iter().find(|e| e.id == 1) {
assert!(g.pos[0] < 110.0, "goblin should be blocked by stone wall, got x={}", g.pos[0]);
}
}
+57
View File
@@ -0,0 +1,57 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(95, 95, 40, 35);
s.perform_action(&AiAction::FillRect { x: 80, y: 135, w: 80, h: 15, material: "stone".into() });
s
}
#[test]
fn player_falls_and_lands() {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(120, 128, 10, 5);
s.perform_action(&AiAction::FillRect { x: 118, y: 132, w: 14, h: 10, material: "stone".into() });
s.step(30);
let player = s.get_player().expect("player should exist");
assert!(player.alive, "player should be alive");
assert!(player.pos[1] < 132.0, "player should land on stone at y=132, got y={}", player.pos[1]);
assert!(player.pos[1] > 125.0, "player should have fallen from center");
}
#[test]
fn player_blocked_by_stone_wall() {
let mut s = setup_empty();
s.step(30);
let player = s.get_player().expect("player should exist");
let x = player.pos[0] as i32;
s.perform_action(&AiAction::FillRect { x: x + 5, y: 125, w: 1, h: 10, material: "stone".into() });
s.perform_action(&AiAction::MoveRight);
s.step(10);
let player = s.get_player().expect("player should exist");
assert!(player.pos[0] < (x + 5) as f32, "player should be blocked by wall");
}
#[test]
fn player_can_move_right() {
let mut s = setup_empty();
s.step(30);
let player = s.get_player().expect("player should exist");
let initial_x = player.pos[0];
s.perform_action(&AiAction::MoveRight);
s.step(10);
let player = s.get_player().expect("player should exist");
assert!(player.pos[0] > initial_x, "player should have moved right: {} -> {}", initial_x, player.pos[0]);
}
#[test]
fn player_survives_fall() {
let mut s = setup_empty();
s.step(60);
let player = s.get_player().expect("player should exist");
assert!(player.alive, "player should survive a fall onto stone");
assert!(player.health > 50.0, "player should not take significant damage from landing, hp={}", player.health);
}
+108
View File
@@ -0,0 +1,108 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::ai::{ReplayRecorder, ReplayPlayer};
#[test]
fn replay_deterministic() {
let mut s1 = GameSession::new_seeded(123);
s1.init();
s1.set_recording(true);
s1.perform_action(&AiAction::MoveRight);
s1.step(10);
s1.perform_action(&AiAction::Jump);
s1.step(10);
let state1 = s1.get_state();
s1.save_replay("/tmp/verbatim_test_replay.json").expect("save replay");
let player = ReplayPlayer::load("/tmp/verbatim_test_replay.json").expect("load replay");
let s2 = player.play();
let state2 = s2.get_state();
assert_eq!(state1.tick, state2.tick, "ticks should match");
if let (Some(p1), Some(p2)) = (&state1.player, &state2.player) {
assert_eq!(p1.health, p2.health, "player health should match");
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "player x should match: {} vs {}", p1.pos[0], p2.pos[0]);
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.01, "player y should match: {} vs {}", p1.pos[1], p2.pos[1]);
}
}
#[test]
fn replay_play_until_tick() {
let mut s = GameSession::new_seeded(999);
s.init();
s.set_recording(true);
s.perform_action(&AiAction::MoveRight);
s.step(5);
s.perform_action(&AiAction::MoveLeft);
s.step(5);
s.perform_action(&AiAction::Jump);
s.step(10);
s.save_replay("/tmp/verbatim_test_replay2.json").expect("save");
let player = ReplayPlayer::load("/tmp/verbatim_test_replay2.json").expect("load");
let s_half = player.play_until_tick(5);
assert_eq!(s_half.tick(), 5, "should stop at tick 5, got {}", s_half.tick());
let s_full = player.play();
assert_eq!(s_full.tick(), 20, "full replay should reach tick 20, got {}", s_full.tick());
}
#[test]
fn same_seed_same_state() {
let mut s1 = GameSession::new_seeded(42);
s1.init();
s1.step(30);
let state1 = s1.get_state();
let mut s2 = GameSession::new_seeded(42);
s2.init();
s2.step(30);
let state2 = s2.get_state();
assert_eq!(state1.tick, state2.tick);
if let (Some(p1), Some(p2)) = (&state1.player, &state2.player) {
assert!((p1.pos[0] - p2.pos[0]).abs() < 0.01, "x mismatch: {} vs {}", p1.pos[0], p2.pos[0]);
assert!((p1.pos[1] - p2.pos[1]).abs() < 0.01, "y mismatch: {} vs {}", p1.pos[1], p2.pos[1]);
}
}
#[test]
fn pipe_protocol_init_and_step() {
use std::io::Write;
use std::process::{Command, Stdio};
let mut child = Command::new(env!("CARGO_BIN_EXE_verbatim"))
.arg("--mode").arg("pipe")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("failed to start process");
let stdin = child.stdin.as_mut().expect("failed to open stdin");
writeln!(stdin, "{{\"cmd\":\"init\",\"seed\":42}}").expect("write init");
stdin.flush().expect("flush");
let mut output = String::new();
let stdout = child.stdout.as_mut().expect("failed to open stdout");
use std::io::Read;
let mut buf = [0u8; 4096];
let n = stdout.read(&mut buf).expect("read");
output.push_str(&String::from_utf8_lossy(&buf[..n]));
let json: serde_json::Value = serde_json::from_str(output.trim()).expect("parse response");
assert_eq!(json["ok"], true, "init should succeed: {}", output);
writeln!(stdin, "{{\"cmd\":\"step\",\"n\":10}}").expect("write step");
stdin.flush().expect("flush");
let n = stdout.read(&mut buf).expect("read");
let output2 = String::from_utf8_lossy(&buf[..n]).to_string();
let json2: serde_json::Value = serde_json::from_str(output2.trim()).expect("parse step response");
assert_eq!(json2["ok"], true);
assert_eq!(json2["state"]["tick"], 10, "tick should be 10 after stepping 10");
writeln!(stdin, "{{\"cmd\":\"quit\"}}").expect("write quit");
stdin.flush().expect("flush");
child.wait().expect("wait");
}
+38
View File
@@ -0,0 +1,38 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(95, 95, 40, 40);
s
}
#[test]
fn acid_dissolves_wood() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 118, material: "wood".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 119, material: "acid".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 119, material: "acid".into() });
s.step(30);
assert_ne!(s.get_cell(104, 118).material, "wood", "acid should have dissolved the wood");
}
#[test]
fn acid_does_not_dissolve_stone() {
let mut s = setup_empty();
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 111, material: "acid".into() });
s.step(20);
assert_eq!(s.get_cell(105, 110).material, "stone", "acid should not dissolve stone");
}
#[test]
fn acid_flows_down() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "acid".into() });
s.step(20);
assert_ne!(s.get_cell(105, 105).material, "acid", "acid should have flowed down from y=105");
}
+61
View File
@@ -0,0 +1,61 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(95, 95, 40, 40);
s
}
#[test]
fn lava_flows_down() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 3, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 111, material: "lava".into() });
s.step(10);
let lava_count = s.count_material_in_region(103, 110, 5, 6, "lava");
let stone_count = s.count_material_in_region(103, 110, 5, 6, "stone");
assert!(lava_count > 0 || stone_count >= 4,
"lava should have flowed down or cooled to stone, lava={} stone={}", lava_count, stone_count);
}
#[test]
fn lava_plus_water_makes_steam() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 120, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 99, w: 20, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 110, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 106, y: 110, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 109, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 106, y: 109, material: "water".into() });
s.step(20);
let lava_remaining = s.count_material_in_region(100, 105, 20, 15, "lava");
assert_eq!(lava_remaining, 0, "all lava should have been converted by water");
}
#[test]
fn lava_ignites_wood() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 104, y: 114, material: "wood".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "wood".into() });
s.perform_action(&AiAction::SetCell { x: 106, y: 114, material: "wood".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "lava".into() });
s.step(15);
let wood_remaining = s.count_material_in_region(103, 113, 5, 3, "wood");
assert_eq!(wood_remaining, 0, "all wood should have been ignited by lava, got {} wood cells", wood_remaining);
}
#[test]
fn lava_ignites_grass() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 114, w: 5, h: 1, material: "grass".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 113, material: "lava".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 114, material: "lava".into() });
s.step(15);
let grass_remaining = s.count_material_in_region(99, 113, 7, 3, "grass");
assert_eq!(grass_remaining, 0, "grass should have been ignited by lava");
}
+52
View File
@@ -0,0 +1,52 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
use verbatim::world::cell::MaterialId;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(95, 95, 30, 40);
s
}
#[test]
fn sand_falls_down() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
s.step(10);
assert_eq!(s.get_cell(105, 105).material, "empty", "sand should have fallen from y=105");
assert_eq!(s.get_cell(105, 114).material, "sand", "sand should be resting on stone at y=114");
}
#[test]
fn sand_displaces_water() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 10, h: 5, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
s.step(20);
let sand_at_bottom = s.get_cell(105, 114).material == "sand";
assert!(sand_at_bottom, "sand should sink to bottom through water");
}
#[test]
fn sand_piles_on_stone() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 115, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 104, material: "sand".into() });
s.step(15);
let count = s.count_material_in_region(104, 112, 3, 4, "sand");
assert!(count >= 2, "both sand cells should have piled up, got {} sand cells", count);
}
#[test]
fn sand_does_not_fall_through_stone() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 10, h: 1, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 105, y: 105, material: "sand".into() });
s.step(10);
assert_eq!(s.get_cell(105, 109).material, "sand", "sand should rest on top of stone");
assert_eq!(s.get_cell(105, 110).material, "stone", "stone should remain");
}
+43
View File
@@ -0,0 +1,43 @@
use verbatim::ai::GameSession;
use verbatim::ai::AiAction;
fn setup_empty() -> GameSession {
let mut s = GameSession::new_seeded(42);
s.init_empty();
s.clear_area(95, 95, 40, 40);
s
}
#[test]
fn water_flows_down() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 95, y: 115, w: 40, h: 3, material: "stone".into() });
s.perform_action(&AiAction::SetCell { x: 110, y: 105, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 110, y: 106, material: "water".into() });
s.perform_action(&AiAction::SetCell { x: 110, y: 107, material: "water".into() });
s.step(20);
let water_near_bottom = s.count_material_in_region(105, 112, 10, 4, "water");
assert!(water_near_bottom > 0, "water should have flowed down to near the stone floor, found {} water cells near bottom", water_near_bottom);
}
#[test]
fn water_spreads_sideways() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 95, y: 115, w: 50, h: 3, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 110, y: 111, w: 1, h: 4, material: "water".into() });
s.step(50);
let left_count = s.count_material_in_region(100, 110, 10, 5, "water");
let right_count = s.count_material_in_region(111, 110, 10, 5, "water");
assert!(left_count > 0 || right_count > 0, "water should spread sideways: left={} right={}", left_count, right_count);
}
#[test]
fn water_does_not_pass_through_stone_wall() {
let mut s = setup_empty();
s.perform_action(&AiAction::FillRect { x: 95, y: 115, w: 50, h: 3, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 115, y: 110, w: 1, h: 5, material: "stone".into() });
s.perform_action(&AiAction::FillRect { x: 100, y: 110, w: 15, h: 5, material: "water".into() });
s.step(30);
let right_water = s.count_material_in_region(116, 108, 10, 10, "water");
assert_eq!(right_water, 0, "water should not pass through stone wall");
}