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
+2
View File
@@ -6,6 +6,8 @@ edition = "2024"
[dependencies]
crossterm = "0.28"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = 3
+18
View File
@@ -0,0 +1,18 @@
{
"name": "acid_dissolves_wood",
"description": "Acid should dissolve wood but not stone",
"seed": 42,
"init_mode": "empty",
"setup": [
{"type": "fill_rect", "x": 100, "y": 115, "w": 20, "h": 3, "material": "stone"},
{"type": "fill_rect", "x": 103, "y": 112, "w": 5, "h": 1, "material": "stone"},
{"type": "set_cell", "x": 105, "y": 111, "material": "wood"},
{"type": "set_cell", "x": 104, "y": 111, "material": "acid"},
{"type": "set_cell", "x": 106, "y": 111, "material": "acid"},
{"type": "set_cell", "x": 105, "y": 110, "material": "acid"}
],
"steps": 30,
"assertions": [
{"type": "no_material_in_region", "x": 103, "y": 109, "w": 5, "h": 4, "material": "wood"}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "entity_burns_in_lava",
"description": "An entity placed on lava should catch fire and take damage",
"seed": 42,
"init_mode": "empty",
"setup": [
{"type": "fill_rect", "x": 100, "y": 130, "w": 20, "h": 1, "material": "stone"},
{"type": "fill_rect", "x": 118, "y": 128, "w": 2, "h": 2, "material": "lava"},
{"type": "spawn", "kind": "goblin", "x": 119, "y": 125}
],
"steps": 60,
"assertions": [
{"type": "entity_health_less_than", "id": 1, "health": 40}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "fire_spreads_through_grass",
"description": "Fire should spread through a line of grass",
"seed": 42,
"init_mode": "empty",
"setup": [
{"type": "fill_rect", "x": 100, "y": 115, "w": 20, "h": 1, "material": "stone"},
{"type": "fill_rect", "x": 100, "y": 114, "w": 10, "h": 1, "material": "grass"},
{"type": "set_cell", "x": 100, "y": 114, "material": "fire"}
],
"steps": 30,
"assertions": [
{"type": "no_material_in_region", "x": 100, "y": 113, "w": 10, "h": 2, "material": "grass"}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "lava_ignites_wood",
"description": "Lava next to wood should set the wood on fire",
"seed": 42,
"init_mode": "empty",
"setup": [
{"type": "fill_rect", "x": 100, "y": 110, "w": 20, "h": 1, "material": "stone"},
{"type": "fill_rect", "x": 105, "y": 108, "w": 3, "h": 2, "material": "wood"},
{"type": "set_cell", "x": 108, "y": 108, "material": "lava"},
{"type": "set_cell", "x": 108, "y": 109, "material": "lava"}
],
"steps": 20,
"assertions": [
{"type": "no_material_in_region", "x": 105, "y": 107, "w": 3, "h": 3, "material": "wood"}
]
}
@@ -0,0 +1,16 @@
{
"name": "lava_plus_water_makes_steam",
"description": "Lava and water adjacent should produce steam and stone",
"seed": 42,
"init_mode": "empty",
"setup": [
{"type": "fill_rect", "x": 100, "y": 115, "w": 20, "h": 1, "material": "stone"},
{"type": "fill_rect", "x": 100, "y": 100, "w": 20, "h": 1, "material": "stone"},
{"type": "set_cell", "x": 105, "y": 110, "material": "lava"},
{"type": "set_cell", "x": 106, "y": 110, "material": "water"}
],
"steps": 15,
"assertions": [
{"type": "no_material_in_region", "x": 100, "y": 105, "w": 20, "h": 10, "material": "lava"}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "player_falls_and_lands",
"description": "Player spawned above ground should fall and land on solid surface",
"seed": 42,
"init_mode": "empty",
"setup": [
{"type": "fill_rect", "x": 120, "y": 130, "w": 10, "h": 1, "material": "stone"},
{"type": "spawn", "kind": "player", "x": 125, "y": 120}
],
"steps": 60,
"assertions": [
{"type": "player_alive"},
{"type": "player_on_ground"}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "sand_falls_down",
"description": "Sand placed in empty space should fall due to gravity",
"seed": 42,
"init_mode": "empty",
"setup": [
{"type": "fill_rect", "x": 100, "y": 110, "w": 10, "h": 1, "material": "stone"},
{"type": "set_cell", "x": 105, "y": 105, "material": "sand"}
],
"steps": 10,
"assertions": [
{"type": "cell_is", "x": 105, "y": 109, "material": "sand"},
{"type": "cell_is_not", "x": 105, "y": 105, "material": "sand"}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "water_flows_sideways",
"description": "Water on a flat surface should spread horizontally",
"seed": 42,
"init_mode": "empty",
"setup": [
{"type": "fill_rect", "x": 100, "y": 110, "w": 20, "h": 1, "material": "stone"},
{"type": "set_cell", "x": 105, "y": 108, "material": "water"},
{"type": "set_cell", "x": 105, "y": 109, "material": "water"}
],
"steps": 30,
"assertions": [
{"type": "material_count_in_region", "x": 110, "y": 108, "w": 5, "h": 3, "material": "water", "min": 1, "max": 10}
]
}
+157
View File
@@ -0,0 +1,157 @@
use serde::{Deserialize, Serialize};
use crate::ai::state::material_from_name;
use crate::ai::state::parse_entity_kind;
use crate::entity::EntityKind;
use crate::game::Game;
use crate::world::cell::{Cell, MaterialId};
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AiAction {
MoveLeft,
MoveRight,
Jump,
Wait,
Paint {
x: i32,
y: i32,
material: String,
radius: i32,
},
SetCell {
x: i32,
y: i32,
material: String,
},
FillRect {
x: i32,
y: i32,
w: i32,
h: i32,
material: String,
},
ClearRegion {
x: i32,
y: i32,
w: i32,
h: i32,
},
Spawn {
kind: String,
x: f32,
y: f32,
},
KillEntity {
id: u32,
},
DamageEntity {
id: u32,
amount: f32,
},
SetGravity {
value: f32,
},
SetCamera {
x: i32,
y: i32,
},
CenterCamera,
}
impl AiAction {
pub fn name(&self) -> &'static str {
match self {
AiAction::MoveLeft => "move_left",
AiAction::MoveRight => "move_right",
AiAction::Jump => "jump",
AiAction::Wait => "wait",
AiAction::Paint { .. } => "paint",
AiAction::SetCell { .. } => "set_cell",
AiAction::FillRect { .. } => "fill_rect",
AiAction::ClearRegion { .. } => "clear_region",
AiAction::Spawn { .. } => "spawn",
AiAction::KillEntity { .. } => "kill_entity",
AiAction::DamageEntity { .. } => "damage_entity",
AiAction::SetGravity { .. } => "set_gravity",
AiAction::SetCamera { .. } => "set_camera",
AiAction::CenterCamera => "center_camera",
}
}
pub fn execute(&self, game: &mut Game) {
match self {
AiAction::MoveLeft => {
game.player.move_left(&mut game.entities);
}
AiAction::MoveRight => {
game.player.move_right(&mut game.entities);
}
AiAction::Jump => {
let on_ground = game.check_on_ground();
game.player.jump(&mut game.entities, on_ground);
}
AiAction::Wait => {}
AiAction::Paint { x, y, material, radius } => {
if let Some(mat) = material_from_name(material) {
for dy in -*radius..=*radius {
for dx in -*radius..=*radius {
if dx * dx + dy * dy <= radius * radius + 1 {
game.grid.set_material(*x + dx, *y + dy, mat);
}
}
}
}
}
AiAction::SetCell { x, y, material } => {
if let Some(mat) = material_from_name(material) {
game.grid.set_material(*x, *y, mat);
}
}
AiAction::FillRect { x, y, w, h, material } => {
if let Some(mat) = material_from_name(material) {
for dy in 0..*h {
for dx in 0..*w {
game.grid.set_material(*x + dx, *y + dy, mat);
}
}
}
}
AiAction::ClearRegion { x, y, w, h } => {
for dy in 0..*h {
for dx in 0..*w {
game.grid.set(*x + dx, *y + dy, Cell::empty());
}
}
}
AiAction::Spawn { kind, x, y } => {
if let Some(k) = parse_entity_kind(kind) {
let id = game.entities.spawn(k);
if let Some(e) = game.entities.get_mut(id) {
e.build_humanoid(*x, *y);
}
}
}
AiAction::KillEntity { id } => {
if let Some(e) = game.entities.get_mut(*id) {
e.kill();
}
}
AiAction::DamageEntity { id, amount } => {
if let Some(e) = game.entities.get_mut(*id) {
e.take_damage(*amount);
}
}
AiAction::SetGravity { value } => {
game.verlet.gravity = *value;
}
AiAction::SetCamera { x, y } => {
game.cam_x = *x;
game.cam_y = *y;
}
AiAction::CenterCamera => {
let (px, py) = game.player.center(&game.entities);
game.center_camera_on(px, py);
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
pub mod state;
pub mod action;
pub mod session;
pub mod replay;
pub mod scenario;
pub mod protocol;
pub use state::{GameState, EntityInfo, SubBodyInfo, CellInfo, render_view, entity_kind_name};
pub use action::AiAction;
pub use session::GameSession;
pub use replay::{ReplayRecorder, ReplayPlayer, ReplayRecording};
pub use scenario::{Scenario, Assertion, AssertionResult, run_scenario, run_all_scenarios, load_scenario, format_results};
pub use protocol::run_pipe_protocol;
+349
View File
@@ -0,0 +1,349 @@
use serde::{Deserialize, Serialize};
use crate::ai::action::AiAction;
use crate::ai::session::GameSession;
use crate::ai::scenario::{run_scenario, Scenario, format_results, load_scenario, run_all_scenarios};
use crate::ai::state::GameState;
use std::io::{self, BufRead, Write};
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Command {
Init {
#[serde(default)]
seed: Option<u64>,
#[serde(default)]
mode: Option<String>,
},
Step {
n: u32,
},
Action {
action: AiAction,
#[serde(default)]
step: Option<u32>,
},
GetState {
#[serde(default)]
view_w: Option<usize>,
#[serde(default)]
view_h: Option<usize>,
},
GetView {
#[serde(default)]
w: Option<usize>,
#[serde(default)]
h: Option<usize>,
},
GetViewAt {
cam_x: i32,
cam_y: i32,
w: usize,
h: usize,
},
GetCell {
x: i32,
y: i32,
},
GetRegion {
x: i32,
y: i32,
w: i32,
h: i32,
},
GetEntities,
GetPlayer,
CountMaterial {
x: i32,
y: i32,
w: i32,
h: i32,
material: String,
},
FindMaterial {
material: String,
},
RecordStart,
RecordStop,
ReplaySave {
path: String,
},
RunScenario {
path: String,
},
RunAllScenarios {
dir: String,
},
Quit,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Response {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<GameState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub view: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cell: Option<crate::ai::state::CellInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<Vec<crate::ai::state::CellInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub entities: Option<Vec<crate::ai::state::EntityInfo>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub player: Option<crate::ai::state::EntityInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub found: Option<(i32, i32)>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scenario_result: Option<crate::ai::scenario::ScenarioResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scenario_results: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recording: Option<bool>,
}
impl Response {
pub fn ok() -> Self {
Self {
ok: true,
error: None, state: None, view: None, cell: None, region: None,
entities: None, player: None, count: None, found: None,
scenario_result: None, scenario_results: None, recording: None,
}
}
pub fn err(msg: &str) -> Self {
Self {
ok: false,
error: Some(msg.to_string()),
state: None, view: None, cell: None, region: None,
entities: None, player: None, count: None, found: None,
scenario_result: None, scenario_results: None, recording: None,
}
}
pub fn with_state(mut self, s: GameState) -> Self {
self.state = Some(s);
self
}
}
pub fn run_pipe_protocol() {
let stdin = io::stdin();
let stdout = io::stdout();
let mut stdout = stdout.lock();
let mut session: Option<GameSession> = None;
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
let line = line.trim();
if line.is_empty() {
continue;
}
let cmd: Command = match serde_json::from_str(line) {
Ok(c) => c,
Err(e) => {
let resp = Response::err(&format!("Parse error: {}", e));
writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap_or_default()).ok();
stdout.flush().ok();
continue;
}
};
let response = handle_command(cmd, &mut session);
let json = serde_json::to_string(&response).unwrap_or_default();
writeln!(stdout, "{}", json).ok();
stdout.flush().ok();
if session.is_none() && response.ok {
break;
}
}
}
fn handle_command(cmd: Command, session: &mut Option<GameSession>) -> Response {
match cmd {
Command::Init { seed, mode } => {
let mut s = match seed {
Some(seed) => GameSession::new_seeded(seed),
None => GameSession::new(),
};
match mode.as_deref().unwrap_or("world") {
"empty" => s.init_empty(),
"world" | _ => s.init(),
}
let state = s.get_state();
*session = Some(s);
Response::ok().with_state(state)
}
Command::Step { n } => {
let s = match session.as_mut() {
Some(s) => s,
None => return Response::err("No session. Send {\"cmd\":\"init\"} first."),
};
s.step(n);
let state = s.get_state();
Response::ok().with_state(state)
}
Command::Action { action, step } => {
let s = match session.as_mut() {
Some(s) => s,
None => return Response::err("No session. Send {\"cmd\":\"init\"} first."),
};
s.perform_action(&action);
if let Some(n) = step {
s.step(n);
}
let state = s.get_state();
Response::ok().with_state(state)
}
Command::GetState { view_w, view_h } => {
let s = match session.as_mut() {
Some(s) => s,
None => return Response::err("No session."),
};
if let (Some(w), Some(h)) = (view_w, view_h) {
s.view_width = w;
s.view_height = h;
}
let state = s.get_state();
Response::ok().with_state(state)
}
Command::GetView { w, h } => {
let s = match session.as_ref() {
Some(s) => s,
None => return Response::err("No session."),
};
let vw = w.unwrap_or(80);
let vh = h.unwrap_or(25);
let view = s.get_view(vw, vh);
Response { view: Some(view), ..Response::ok() }
}
Command::GetViewAt { cam_x, cam_y, w, h } => {
let s = match session.as_ref() {
Some(s) => s,
None => return Response::err("No session."),
};
let view = s.get_view_at(cam_x, cam_y, w, h);
Response { view: Some(view), ..Response::ok() }
}
Command::GetCell { x, y } => {
let s = match session.as_ref() {
Some(s) => s,
None => return Response::err("No session."),
};
let cell = s.get_cell(x, y);
Response { cell: Some(cell), ..Response::ok() }
}
Command::GetRegion { x, y, w, h } => {
let s = match session.as_ref() {
Some(s) => s,
None => return Response::err("No session."),
};
let region = s.get_region(x, y, w, h);
Response { region: Some(region), ..Response::ok() }
}
Command::GetEntities => {
let s = match session.as_ref() {
Some(s) => s,
None => return Response::err("No session."),
};
let entities = s.get_entities();
Response { entities: Some(entities), ..Response::ok() }
}
Command::GetPlayer => {
let s = match session.as_ref() {
Some(s) => s,
None => return Response::err("No session."),
};
let player = s.get_player();
Response { player, ..Response::ok() }
}
Command::CountMaterial { x, y, w, h, material } => {
let s = match session.as_ref() {
Some(s) => s,
None => return Response::err("No session."),
};
let count = s.count_material_in_region(x, y, w, h, &material);
Response { count: Some(count), ..Response::ok() }
}
Command::FindMaterial { material } => {
let s = match session.as_ref() {
Some(s) => s,
None => return Response::err("No session."),
};
let found = s.find_material(&material);
Response { found, ..Response::ok() }
}
Command::RecordStart => {
let s = match session.as_mut() {
Some(s) => s,
None => return Response::err("No session."),
};
s.set_recording(true);
Response { recording: Some(true), ..Response::ok() }
}
Command::RecordStop => {
let s = match session.as_mut() {
Some(s) => s,
None => return Response::err("No session."),
};
s.set_recording(false);
Response { recording: Some(false), ..Response::ok() }
}
Command::ReplaySave { path } => {
let s = match session.as_ref() {
Some(s) => s,
None => return Response::err("No session."),
};
match s.save_replay(&path) {
Ok(()) => Response::ok(),
Err(e) => Response::err(&format!("Save error: {}", e)),
}
}
Command::RunScenario { path } => {
match load_scenario(&path) {
Ok(scenario) => {
let result = run_scenario(&scenario);
Response { scenario_result: Some(result), ..Response::ok() }
}
Err(e) => Response::err(&e),
}
}
Command::RunAllScenarios { dir } => {
let results = run_all_scenarios(&dir);
let report = format_results(&results);
Response { scenario_results: Some(report), ..Response::ok() }
}
Command::Quit => {
*session = None;
Response::ok()
}
}
}
+126
View File
@@ -0,0 +1,126 @@
use serde::{Deserialize, Serialize};
use crate::ai::action::AiAction;
use crate::ai::session::GameSession;
use std::io::Write;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ReplayRecording {
pub seed: u64,
pub init_mode: String,
pub events: Vec<ReplayEvent>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ReplayEvent {
Step { n: u32 },
Action { tick: u64, action: AiAction },
}
pub struct ReplayRecorder {
recording: ReplayRecording,
}
impl ReplayRecorder {
pub fn new(seed: u64) -> Self {
Self {
recording: ReplayRecording {
seed,
init_mode: "world".to_string(),
events: Vec::new(),
},
}
}
pub fn set_seed(&mut self, seed: u64) {
self.recording.seed = seed;
}
pub fn record_step(&mut self, n: u32) {
self.recording.events.push(ReplayEvent::Step { n });
}
pub fn record_action(&mut self, tick: u64, action: AiAction) {
self.recording.events.push(ReplayEvent::Action { tick, action });
}
pub fn save(&self, path: &str) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(&self.recording)
.map_err(|e| std::io::Error::other(e))?;
std::fs::write(path, json)
}
pub fn recording(&self) -> &ReplayRecording {
&self.recording
}
}
pub struct ReplayPlayer {
recording: ReplayRecording,
}
impl ReplayPlayer {
pub fn load(path: &str) -> std::io::Result<Self> {
let data = std::fs::read_to_string(path)?;
let recording: ReplayRecording = serde_json::from_str(&data)
.map_err(|e| std::io::Error::other(e))?;
Ok(Self { recording })
}
pub fn from_recording(recording: ReplayRecording) -> Self {
Self { recording }
}
pub fn play(&self) -> GameSession {
let mut session = GameSession::new_seeded(self.recording.seed);
session.init();
session.set_recording(false);
for event in &self.recording.events {
match event {
ReplayEvent::Step { n } => {
session.step(*n);
}
ReplayEvent::Action { tick: _, action } => {
session.perform_action(action);
}
}
}
session
}
pub fn play_until_tick(&self, target_tick: u64) -> GameSession {
let mut session = GameSession::new_seeded(self.recording.seed);
session.init();
session.set_recording(false);
for event in &self.recording.events {
if session.tick() >= target_tick {
break;
}
match event {
ReplayEvent::Step { n } => {
let remaining = target_tick.saturating_sub(session.tick());
let steps = (*n).min(remaining as u32);
if steps > 0 {
session.step(steps);
}
}
ReplayEvent::Action { tick: _, action } => {
session.perform_action(action);
}
}
}
while session.tick() < target_tick {
session.step(1);
}
session
}
pub fn recording(&self) -> &ReplayRecording {
&self.recording
}
}
+317
View File
@@ -0,0 +1,317 @@
use serde::{Deserialize, Serialize};
use crate::ai::action::AiAction;
use crate::ai::session::GameSession;
use crate::ai::state::{GameState, material_from_name};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Scenario {
pub name: String,
pub description: String,
pub seed: u64,
pub init_mode: String,
pub setup: Vec<AiAction>,
pub steps: u32,
pub assertions: Vec<Assertion>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Assertion {
CellIs {
x: i32,
y: i32,
material: String,
},
CellIsNot {
x: i32,
y: i32,
material: String,
},
CellTempGreaterThan {
x: i32,
y: i32,
temp: f32,
},
CellTempLessThan {
x: i32,
y: i32,
temp: f32,
},
NoMaterialInRegion {
x: i32,
y: i32,
w: i32,
h: i32,
material: String,
},
MaterialCountInRegion {
x: i32,
y: i32,
w: i32,
h: i32,
material: String,
min: usize,
max: usize,
},
EntityAlive {
id: u32,
},
EntityDead {
id: u32,
},
EntityHealthLessThan {
id: u32,
health: f32,
},
EntityOnFire {
id: u32,
},
PlayerOnGround,
PlayerAlive,
PlayerDead,
TickEquals {
tick: u64,
},
Custom {
description: String,
check: String,
},
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AssertionResult {
pub assertion: Assertion,
pub passed: bool,
pub message: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ScenarioResult {
pub name: String,
pub passed: bool,
pub assertions: Vec<AssertionResult>,
pub final_state: Option<GameState>,
pub error: Option<String>,
}
pub fn run_scenario(scenario: &Scenario) -> ScenarioResult {
let mut session = GameSession::new_seeded(scenario.seed);
match scenario.init_mode.as_str() {
"empty" => session.init_empty(),
"world" | _ => session.init(),
}
for action in &scenario.setup {
session.perform_action(action);
}
if scenario.steps > 0 {
session.step(scenario.steps);
}
let mut results = Vec::new();
for assertion in &scenario.assertions {
let result = check_assertion(&session, assertion);
results.push(result);
}
let all_passed = results.iter().all(|r| r.passed);
let final_state = session.get_state();
ScenarioResult {
name: scenario.name.clone(),
passed: all_passed,
assertions: results,
final_state: Some(final_state),
error: None,
}
}
fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionResult {
match assertion {
Assertion::CellIs { x, y, material } => {
let cell = session.get_cell(*x, *y);
let passed = cell.material == *material.to_lowercase();
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Cell({},{}) = '{}', expected '{}'", x, y, cell.material, material),
}
}
Assertion::CellIsNot { x, y, material } => {
let cell = session.get_cell(*x, *y);
let passed = cell.material != *material.to_lowercase();
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Cell({},{}) = '{}', expected NOT '{}'", x, y, cell.material, material),
}
}
Assertion::CellTempGreaterThan { x, y, temp } => {
let cell = session.get_cell(*x, *y);
let passed = cell.temp > *temp;
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Cell({},{}) temp = {:.1}, expected > {:.1}", x, y, cell.temp, temp),
}
}
Assertion::CellTempLessThan { x, y, temp } => {
let cell = session.get_cell(*x, *y);
let passed = cell.temp < *temp;
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Cell({},{}) temp = {:.1}, expected < {:.1}", x, y, cell.temp, temp),
}
}
Assertion::NoMaterialInRegion { x, y, w, h, material } => {
let count = session.count_material_in_region(*x, *y, *w, *h, material);
let passed = count == 0;
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Region({},{},{},{}) has {} '{}' cells, expected 0", x, y, w, h, count, material),
}
}
Assertion::MaterialCountInRegion { x, y, w, h, material, min, max } => {
let count = session.count_material_in_region(*x, *y, *w, *h, material);
let passed = count >= *min && count <= *max;
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Region({},{},{},{}) has {} '{}' cells, expected {}-{}", x, y, w, h, count, material, min, max),
}
}
Assertion::EntityAlive { id } => {
let entities = session.get_entities();
let entity = entities.iter().find(|e| e.id == *id);
let passed = entity.map(|e| e.alive).unwrap_or(false);
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Entity({}) alive = {}", id, if passed { "true" } else { "false/not found" }),
}
}
Assertion::EntityDead { id } => {
let entities = session.get_entities();
let entity = entities.iter().find(|e| e.id == *id);
let passed = entity.map(|e| !e.alive).unwrap_or(true);
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Entity({}) dead = {}", id, if passed { "true" } else { "false" }),
}
}
Assertion::EntityHealthLessThan { id, health } => {
let entities = session.get_entities();
let entity = entities.iter().find(|e| e.id == *id);
let passed = entity.map(|e| e.health < *health).unwrap_or(false);
let actual = entity.map(|e| e.health).unwrap_or(0.0);
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Entity({}) health = {:.1}, expected < {:.1}", id, actual, health),
}
}
Assertion::EntityOnFire { id } => {
let entities = session.get_entities();
let entity = entities.iter().find(|e| e.id == *id);
let passed = entity.map(|e| e.on_fire).unwrap_or(false);
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Entity({}) on_fire = {}", id, passed),
}
}
Assertion::PlayerOnGround => {
let passed = session.game.check_on_ground();
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Player on_ground = {}", passed),
}
}
Assertion::PlayerAlive => {
let player = session.get_player();
let passed = player.map(|p| p.alive).unwrap_or(false);
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Player alive = {}", passed),
}
}
Assertion::PlayerDead => {
let player = session.get_player();
let passed = player.map(|p| !p.alive).unwrap_or(true);
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Player dead = {}", passed),
}
}
Assertion::TickEquals { tick } => {
let passed = session.tick() == *tick;
AssertionResult {
assertion: assertion.clone(),
passed,
message: format!("Tick = {}, expected {}", session.tick(), tick),
}
}
Assertion::Custom { description, check } => {
AssertionResult {
assertion: assertion.clone(),
passed: false,
message: format!("Custom check '{}' not implemented: {}", check, description),
}
}
}
}
pub fn load_scenario(path: &str) -> Result<Scenario, String> {
let data = std::fs::read_to_string(path).map_err(|e| format!("Cannot read {}: {}", path, e))?;
serde_json::from_str(&data).map_err(|e| format!("Cannot parse {}: {}", path, e))
}
pub fn load_scenarios_from_dir(dir: &str) -> Vec<Scenario> {
let mut scenarios = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "json").unwrap_or(false) {
if let Ok(s) = load_scenario(path.to_str().unwrap_or("")) {
scenarios.push(s);
}
}
}
}
scenarios
}
pub fn run_all_scenarios(dir: &str) -> Vec<ScenarioResult> {
let scenarios = load_scenarios_from_dir(dir);
scenarios.iter().map(|s| run_scenario(s)).collect()
}
pub fn format_results(results: &[ScenarioResult]) -> String {
let mut out = String::new();
let total = results.len();
let passed = results.iter().filter(|r| r.passed).count();
out.push_str(&format!("=== Scenario Results: {}/{} passed ===\n\n", passed, total));
for r in results {
let status = if r.passed { "PASS" } else { "FAIL" };
out.push_str(&format!("[{}] {} - {} assertions\n", status, r.name, r.assertions.len()));
if !r.passed {
for a in &r.assertions {
if !a.passed {
out.push_str(&format!(" FAIL: {}\n", a.message));
}
}
}
}
out
}
+185
View File
@@ -0,0 +1,185 @@
use crate::ai::action::AiAction;
use crate::ai::replay::ReplayRecorder;
use crate::ai::state::{build_game_state, CellInfo, EntityInfo, GameState, render_view};
use crate::game::Game;
use crate::world::cell::MaterialId;
use crate::world::grid::Grid;
pub struct GameSession {
pub game: Game,
pub seed: u64,
pub recorder: Option<ReplayRecorder>,
pub view_width: usize,
pub view_height: usize,
}
impl GameSession {
pub fn new() -> Self {
Self {
game: Game::new(),
seed: 42,
recorder: None,
view_width: 80,
view_height: 25,
}
}
pub fn new_seeded(seed: u64) -> Self {
let mut s = Self::new();
s.seed = seed;
s
}
pub fn init(&mut self) {
self.game.init_world();
if let Some(ref mut r) = self.recorder {
r.set_seed(self.seed);
}
}
pub fn init_empty(&mut self) {
self.game.grid.fill_border(MaterialId::Stone);
let cx = (self.game.grid.width / 2) as f32;
let cy = (self.game.grid.height / 2) as f32;
self.game.player.spawn_at(&mut self.game.entities, cx, cy);
let (px, py) = self.game.player.center(&self.game.entities);
self.game.center_camera_on(px, py);
}
pub fn step(&mut self, n: u32) {
for _ in 0..n {
self.game.fixed_update();
}
if let Some(ref mut r) = self.recorder {
r.record_step(n);
}
}
pub fn perform_action(&mut self, action: &AiAction) {
action.execute(&mut self.game);
if let Some(ref mut r) = self.recorder {
r.record_action(self.game.tick, action.clone());
}
}
pub fn perform_action_and_step(&mut self, action: &AiAction, steps: u32) {
self.perform_action(action);
self.step(steps);
}
pub fn get_state(&self) -> GameState {
build_game_state(&self.game, self.view_width, self.view_height)
}
pub fn get_view(&self, w: usize, h: usize) -> String {
let (px, py) = self.game.player.center(&self.game.entities);
let cam_x = px as i32 - (w as i32 / 2);
let cam_y = py as i32 - (h as i32 / 2);
render_view(&self.game.grid, &self.game.entities, cam_x, cam_y, w, h)
}
pub fn get_view_at(&self, cam_x: i32, cam_y: i32, w: usize, h: usize) -> String {
render_view(&self.game.grid, &self.game.entities, cam_x, cam_y, w, h)
}
pub fn get_cell(&self, x: i32, y: i32) -> CellInfo {
CellInfo::from_grid(&self.game.grid, x, y)
}
pub fn get_region(&self, x: i32, y: i32, w: i32, h: i32) -> Vec<CellInfo> {
let mut cells = Vec::with_capacity((w * h) as usize);
for dy in 0..h {
for dx in 0..w {
cells.push(CellInfo::from_grid(&self.game.grid, x + dx, y + dy));
}
}
cells
}
pub fn get_entities(&self) -> Vec<EntityInfo> {
self.game.entities.all().iter().map(|e| {
crate::ai::state::entity_info(e)
}).collect()
}
pub fn get_player(&self) -> Option<EntityInfo> {
self.game.player.entity(&self.game.entities).map(|e| crate::ai::state::entity_info(e))
}
pub fn count_material_in_region(&self, x: i32, y: i32, w: i32, h: i32, material: &str) -> usize {
let target = crate::ai::state::material_from_name(material);
if target.is_none() {
return 0;
}
let target = target.unwrap();
let mut count = 0;
for dy in 0..h {
for dx in 0..w {
let cell = self.game.grid.get(x + dx, y + dy);
if cell.material == target {
count += 1;
}
}
}
count
}
pub fn find_material(&self, material: &str) -> Option<(i32, i32)> {
let target = crate::ai::state::material_from_name(material)?;
for y in 0..self.game.grid.height as i32 {
for x in 0..self.game.grid.width as i32 {
if self.game.grid.get(x, y).material == target {
return Some((x, y));
}
}
}
None
}
pub fn set_recording(&mut self, on: bool) {
if on {
if self.recorder.is_none() {
self.recorder = Some(ReplayRecorder::new(self.seed));
}
} else {
self.recorder = None;
}
}
pub fn is_recording(&self) -> bool {
self.recorder.is_some()
}
pub fn save_replay(&self, path: &str) -> std::io::Result<()> {
if let Some(ref r) = self.recorder {
r.save(path)?;
}
Ok(())
}
pub fn clear_area(&mut self, x: i32, y: i32, w: i32, h: i32) {
for dy in 0..h {
for dx in 0..w {
self.game.grid.set(x + dx, y + dy, crate::world::cell::Cell::empty());
}
}
}
pub fn grid(&self) -> &Grid {
&self.game.grid
}
pub fn grid_mut(&mut self) -> &mut Grid {
&mut self.game.grid
}
pub fn tick(&self) -> u64 {
self.game.tick
}
}
impl Default for GameSession {
fn default() -> Self {
Self::new()
}
}
+204
View File
@@ -0,0 +1,204 @@
use serde::{Deserialize, Serialize};
use crate::entity::{EntityManager, EntityKind};
use crate::world::grid::Grid;
use crate::world::cell::MaterialId;
use crate::world::material::MaterialRegistry;
use crate::game::Game;
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GameState {
pub tick: u64,
pub world_size: [usize; 2],
pub camera: [i32; 2],
pub player: Option<EntityInfo>,
pub entities: Vec<EntityInfo>,
pub view: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct EntityInfo {
pub id: u32,
pub kind: String,
pub alive: bool,
pub health: f32,
pub max_health: f32,
pub pos: [f32; 2],
pub on_fire: bool,
pub body_count: usize,
pub bodies: Vec<SubBodyInfo>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SubBodyInfo {
pub idx: usize,
pub pos: [f32; 2],
pub vel: [f32; 2],
pub health: f32,
pub alive: bool,
pub on_fire: bool,
pub material: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CellInfo {
pub x: i32,
pub y: i32,
pub material: String,
pub temp: f32,
pub is_solid: bool,
pub is_liquid: bool,
pub is_gas: bool,
}
impl CellInfo {
pub fn from_grid(grid: &Grid, x: i32, y: i32) -> Self {
if !grid.in_bounds(x, y) {
return Self {
x, y,
material: "out_of_bounds".to_string(),
temp: 0.0,
is_solid: false,
is_liquid: false,
is_gas: false,
};
}
let cell = grid.get(x, y);
let reg = MaterialRegistry::instance();
let mat = reg.get(cell.material);
Self {
x, y,
material: mat.name.to_string(),
temp: cell.temp,
is_solid: mat.solid,
is_liquid: mat.liquid,
is_gas: mat.gas,
}
}
}
pub fn entity_info(e: &crate::entity::Entity) -> EntityInfo {
let (px, py) = e.center();
let bodies: Vec<SubBodyInfo> = e.bodies.iter().enumerate().map(|(i, b)| {
let reg = MaterialRegistry::instance();
SubBodyInfo {
idx: i,
pos: [b.x, b.y],
vel: [b.vx(), b.vy()],
health: b.health,
alive: b.alive,
on_fire: b.on_fire,
material: reg.get(b.material).name.to_string(),
}
}).collect();
EntityInfo {
id: e.id,
kind: entity_kind_name(e.kind).to_string(),
alive: e.alive,
health: e.health,
max_health: e.max_health,
pos: [px, py],
on_fire: e.on_fire,
body_count: bodies.iter().filter(|b| b.alive).count(),
bodies,
}
}
pub fn build_game_state(game: &Game, view_w: usize, view_h: usize) -> GameState {
let player_info = game.player.entity(&game.entities).map(entity_info);
let entities: Vec<EntityInfo> = game.entities.all()
.iter()
.filter(|e| e.id != game.player.entity_id)
.map(entity_info)
.collect();
let (px, py) = game.player.center(&game.entities);
let cam_x = px as i32 - (view_w as i32 / 2);
let cam_y = py as i32 - (view_h as i32 / 2);
let view = render_view(&game.grid, &game.entities, cam_x, cam_y, view_w, view_h);
GameState {
tick: game.tick,
world_size: [game.grid.width, game.grid.height],
camera: [cam_x, cam_y],
player: player_info,
entities,
view,
}
}
pub fn render_view(grid: &Grid, entities: &EntityManager, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
let mut entity_map = std::collections::HashMap::new();
for e in entities.all() {
for b in &e.bodies {
if !b.alive { continue; }
let sx = b.x as i32 - cam_x;
let sy = b.y as i32 - cam_y;
if sx >= 0 && sx < vw as i32 && sy >= 0 && sy < vh as i32 {
let ch = match e.kind {
EntityKind::Player if e.alive => '@',
EntityKind::Goblin if e.alive => 'g',
_ => '%',
};
entity_map.insert((sx, sy), ch);
}
}
}
let mut buf = String::with_capacity(vw * vh + vh);
for dy in 0..vh {
for dx in 0..vw {
let x = cam_x + dx as i32;
let y = cam_y + dy as i32;
if let Some(&ch) = entity_map.get(&(dx as i32, dy as i32)) {
buf.push(ch);
} else if !grid.in_bounds(x, y) {
buf.push('?');
} else {
let cell = grid.get(x, y);
buf.push(cell.material.display_char());
}
}
buf.push('\n');
}
buf
}
pub fn material_from_name(name: &str) -> Option<MaterialId> {
match name.to_lowercase().as_str() {
"empty" | "air" | " " => Some(MaterialId::Empty),
"sand" => Some(MaterialId::Sand),
"water" => Some(MaterialId::Water),
"stone" => Some(MaterialId::Stone),
"lava" => Some(MaterialId::Lava),
"wood" => Some(MaterialId::Wood),
"flesh" => Some(MaterialId::Flesh),
"bone" => Some(MaterialId::Bone),
"steam" => Some(MaterialId::Steam),
"fire" => Some(MaterialId::Fire),
"acid" => Some(MaterialId::Acid),
"smoke" => Some(MaterialId::Smoke),
"grass" => Some(MaterialId::Grass),
"dirt" => Some(MaterialId::Dirt),
_ => None,
}
}
pub fn entity_kind_name(kind: EntityKind) -> &'static str {
match kind {
EntityKind::Player => "Player",
EntityKind::Goblin => "Goblin",
EntityKind::Corpse => "Corpse",
}
}
pub fn parse_entity_kind(name: &str) -> Option<EntityKind> {
match name.to_lowercase().as_str() {
"player" => Some(EntityKind::Player),
"goblin" => Some(EntityKind::Goblin),
"corpse" => Some(EntityKind::Corpse),
_ => None,
}
}
+10 -3
View File
@@ -86,7 +86,7 @@ impl Game {
self.center_camera_on(px, py);
}
fn center_camera_on(&mut self, px: f32, py: f32) {
pub fn center_camera_on(&mut self, px: f32, py: f32) {
self.cam_x = px as i32 - 40;
self.cam_y = py as i32 - 12;
}
@@ -119,7 +119,7 @@ impl Game {
let _ = renderer.shutdown();
}
fn handle_input(&mut self, vw: usize, vh: usize) {
pub fn handle_input(&mut self, vw: usize, vh: usize) {
let action = self.input.poll();
match action {
Action::Quit => self.running = false,
@@ -154,7 +154,7 @@ impl Game {
}
}
fn check_on_ground(&self) -> bool {
pub fn check_on_ground(&self) -> bool {
if let Some(e) = self.player.entity(&self.entities) {
for b in &e.bodies {
if !b.alive {
@@ -247,6 +247,13 @@ impl Game {
}
solver.solve_constraints(&mut bodies, &constraints, 2);
for b in &mut bodies {
if !b.alive {
continue;
}
resolve_grid_collision(grid, b);
}
}
if let Some(e) = self.entities.all_mut().get_mut(idx) {
+7
View File
@@ -0,0 +1,7 @@
pub mod world;
pub mod physics;
pub mod entity;
pub mod render;
pub mod input;
pub mod game;
pub mod ai;
+100 -80
View File
@@ -1,45 +1,116 @@
mod world;
mod physics;
mod entity;
mod render;
mod input;
mod game;
use clap::Parser;
use game::Game;
use render::terminal::TerminalRenderer;
use verbatim::game::Game;
use verbatim::render::terminal::TerminalRenderer;
use verbatim::ai;
use std::io::Write;
#[derive(Parser, Debug)]
#[command(name = "verbatim", about = "ASCII physics RPG - Noita meets Caves of Qud")]
struct Cli {
#[arg(long, default_value = "terminal")]
render_mode: String,
mode: String,
#[arg(long, default_value_t = 0)]
headless_ticks: u32,
#[arg(long, default_value = "scenarios")]
scenario_dir: String,
#[arg(long)]
scenario: Option<String>,
#[arg(long)]
replay_file: Option<String>,
}
fn main() {
let cli = Cli::parse();
if cli.headless_ticks > 0 {
run_headless(cli.headless_ticks);
return;
}
match cli.render_mode.as_str() {
match cli.mode.as_str() {
"terminal" => {
let mut renderer = TerminalRenderer::new();
let mut game = Game::new();
game.run(&mut renderer);
}
"vulkan" => {
eprintln!("Vulkan renderer not yet implemented. Use --render-mode terminal.");
std::process::exit(1);
"pipe" => {
ai::run_pipe_protocol();
}
"test" => {
run_test_mode(&cli);
}
"replay" => {
run_replay_mode(&cli);
}
"headless" => {
if cli.headless_ticks > 0 {
run_headless(cli.headless_ticks);
} else {
eprintln!("Use --headless-ticks N with --mode headless");
std::process::exit(1);
}
}
_ => {
eprintln!("Unknown render mode: {}. Use 'terminal' or 'vulkan'.", cli.render_mode);
eprintln!("Unknown mode: {}. Use terminal, pipe, test, replay, or headless.", cli.mode);
std::process::exit(1);
}
}
}
fn run_test_mode(cli: &Cli) {
if let Some(path) = &cli.scenario {
match ai::load_scenario(path) {
Ok(scenario) => {
let result = ai::run_scenario(&scenario);
let report = ai::format_results(&[result.clone()]);
println!("{}", report);
if !result.passed {
std::process::exit(1);
}
}
Err(e) => {
eprintln!("Error loading scenario: {}", e);
std::process::exit(1);
}
}
} else {
let results = ai::run_all_scenarios(&cli.scenario_dir);
if results.is_empty() {
eprintln!("No scenarios found in {}", cli.scenario_dir);
std::process::exit(1);
}
let report = ai::format_results(&results);
println!("{}", report);
let any_failed = results.iter().any(|r| !r.passed);
if any_failed {
std::process::exit(1);
}
}
}
fn run_replay_mode(cli: &Cli) {
let path = match &cli.replay_file {
Some(p) => p,
None => {
eprintln!("Use --replay-file PATH with --mode replay");
std::process::exit(1);
}
};
match ai::ReplayPlayer::load(path) {
Ok(player) => {
let session = player.play();
let state = session.get_state();
println!("=== Replay: {} events ===", player.recording().events.len());
println!("Final tick: {}", state.tick);
if let Some(ref p) = state.player {
println!("Player: {} hp={:.1}/{:.1} pos=({:.1},{:.1}) alive={}",
p.kind, p.health, p.max_health, p.pos[0], p.pos[1], p.alive);
}
println!("Entities: {}", state.entities.len());
println!("\nFinal view:\n{}", state.view);
}
Err(e) => {
eprintln!("Error loading replay: {}", e);
std::process::exit(1);
}
}
@@ -81,7 +152,7 @@ fn run_headless(ticks: u32) {
let alive: Vec<_> = game.entities.all().iter()
.filter(|e| e.alive)
.map(|e| format!("{}(hp={:.0}, pos={:?})", entity_kind_name(e.kind), e.health, e.center()))
.map(|e| format!("{}(hp={:.0}, pos={:?})", ai::entity_kind_name(e.kind), e.health, e.center()))
.collect();
log.push_str(&format!("Alive entities: {}\n\n", alive.join(", ")));
}
@@ -92,56 +163,13 @@ fn run_headless(ticks: u32) {
eprintln!("Headless run complete: {} ticks, dump written to headless_dump.txt", ticks);
}
fn dump_view(grid: &world::grid::Grid, entities: &entity::EntityManager, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
let mut buf = String::with_capacity(vw * vh + vh + 100);
buf.push_str(&format!(" Camera ({}, {}):\n", cam_x, cam_y));
let mut entity_map = std::collections::HashMap::new();
for e in entities.all() {
for b in &e.bodies {
if !b.alive { continue; }
let sx = b.x as i32 - cam_x;
let sy = b.y as i32 - cam_y;
if sx >= 0 && sx < vw as i32 && sy >= 0 && sy < vh as i32 {
let ch = match e.kind {
entity::EntityKind::Player if e.alive => '@',
entity::EntityKind::Goblin if e.alive => 'g',
_ => '%',
};
entity_map.insert((sx, sy), ch);
}
}
}
for dy in 0..vh {
let y = cam_y + dy as i32;
if dy == 0 {
buf.push_str(" ");
for dx in 0..vw {
let x = cam_x + dx as i32;
if x % 10 == 0 {
buf.push_str(&format!("{}", (x / 10) % 10));
} else {
buf.push(' ');
}
}
buf.push('\n');
}
buf.push_str(&format!("{:2}", y % 100));
for dx in 0..vw {
let x = cam_x + dx as i32;
if let Some(&ch) = entity_map.get(&(dx as i32, dy as i32)) {
buf.push(ch);
} else if !grid.in_bounds(x, y) {
buf.push('?');
} else {
let cell = grid.get(x, y);
buf.push(cell.material.display_char());
}
}
buf.push('\n');
}
buf
fn dump_view(grid: &verbatim::world::grid::Grid, entities: &verbatim::entity::EntityManager, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String {
ai::render_view(grid, entities, cam_x, cam_y, vw, vh)
.lines()
.enumerate()
.map(|(i, line)| format!("{:2}{}", (cam_y + i as i32) % 100, line))
.collect::<Vec<_>>()
.join("\n") + "\n"
}
fn player_info(game: &Game) -> String {
@@ -149,17 +177,9 @@ fn player_info(game: &Game) -> String {
let (cx, cy) = e.center();
let body_count = e.bodies.iter().filter(|b| b.alive).count();
let on_fire = e.on_fire;
let kind = entity_kind_name(e.kind);
let kind = ai::entity_kind_name(e.kind);
format!("{} hp={:.1}/{:.1} pos=({:.1},{:.1}) bodies={}/{} on_fire={}", kind, e.health, e.max_health, cx, cy, body_count, e.bodies.len(), on_fire)
} else {
"None".to_string()
}
}
fn entity_kind_name(kind: entity::EntityKind) -> &'static str {
match kind {
entity::EntityKind::Player => "Player",
entity::EntityKind::Goblin => "Goblin",
entity::EntityKind::Corpse => "Corpse",
}
}
+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");
}