diff --git a/Cargo.toml b/Cargo.toml index ff1a06b..8f8695f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/scenarios/acid_dissolves_wood.json b/scenarios/acid_dissolves_wood.json new file mode 100644 index 0000000..31c691a --- /dev/null +++ b/scenarios/acid_dissolves_wood.json @@ -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"} + ] +} diff --git a/scenarios/entity_burns_in_lava.json b/scenarios/entity_burns_in_lava.json new file mode 100644 index 0000000..bfbd18e --- /dev/null +++ b/scenarios/entity_burns_in_lava.json @@ -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} + ] +} diff --git a/scenarios/fire_spreads_through_grass.json b/scenarios/fire_spreads_through_grass.json new file mode 100644 index 0000000..cb2b029 --- /dev/null +++ b/scenarios/fire_spreads_through_grass.json @@ -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"} + ] +} diff --git a/scenarios/lava_ignites_wood.json b/scenarios/lava_ignites_wood.json new file mode 100644 index 0000000..866f9c3 --- /dev/null +++ b/scenarios/lava_ignites_wood.json @@ -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"} + ] +} diff --git a/scenarios/lava_plus_water_makes_steam.json b/scenarios/lava_plus_water_makes_steam.json new file mode 100644 index 0000000..ec79643 --- /dev/null +++ b/scenarios/lava_plus_water_makes_steam.json @@ -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"} + ] +} diff --git a/scenarios/player_falls_and_lands.json b/scenarios/player_falls_and_lands.json new file mode 100644 index 0000000..64b0e36 --- /dev/null +++ b/scenarios/player_falls_and_lands.json @@ -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"} + ] +} diff --git a/scenarios/sand_falls_down.json b/scenarios/sand_falls_down.json new file mode 100644 index 0000000..36f66b2 --- /dev/null +++ b/scenarios/sand_falls_down.json @@ -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"} + ] +} diff --git a/scenarios/water_flows_sideways.json b/scenarios/water_flows_sideways.json new file mode 100644 index 0000000..e137bbb --- /dev/null +++ b/scenarios/water_flows_sideways.json @@ -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} + ] +} diff --git a/src/ai/action.rs b/src/ai/action.rs new file mode 100644 index 0000000..135aee4 --- /dev/null +++ b/src/ai/action.rs @@ -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); + } + } + } +} diff --git a/src/ai/mod.rs b/src/ai/mod.rs new file mode 100644 index 0000000..ccb82ab --- /dev/null +++ b/src/ai/mod.rs @@ -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; diff --git a/src/ai/protocol.rs b/src/ai/protocol.rs new file mode 100644 index 0000000..be07603 --- /dev/null +++ b/src/ai/protocol.rs @@ -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, + #[serde(default)] + mode: Option, + }, + Step { + n: u32, + }, + Action { + action: AiAction, + #[serde(default)] + step: Option, + }, + GetState { + #[serde(default)] + view_w: Option, + #[serde(default)] + view_h: Option, + }, + GetView { + #[serde(default)] + w: Option, + #[serde(default)] + h: Option, + }, + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub view: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cell: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub entities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub player: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub found: Option<(i32, i32)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub scenario_result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scenario_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recording: Option, +} + +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 = 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) -> 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() + } + } +} diff --git a/src/ai/replay.rs b/src/ai/replay.rs new file mode 100644 index 0000000..2933c32 --- /dev/null +++ b/src/ai/replay.rs @@ -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, +} + +#[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 { + 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 + } +} diff --git a/src/ai/scenario.rs b/src/ai/scenario.rs new file mode 100644 index 0000000..379e226 --- /dev/null +++ b/src/ai/scenario.rs @@ -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, + pub steps: u32, + pub assertions: Vec, +} + +#[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, + pub final_state: Option, + pub error: Option, +} + +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 { + 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 { + 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 { + 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 +} diff --git a/src/ai/session.rs b/src/ai/session.rs new file mode 100644 index 0000000..19be000 --- /dev/null +++ b/src/ai/session.rs @@ -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, + 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 { + 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 { + self.game.entities.all().iter().map(|e| { + crate::ai::state::entity_info(e) + }).collect() + } + + pub fn get_player(&self) -> Option { + 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() + } +} diff --git a/src/ai/state.rs b/src/ai/state.rs new file mode 100644 index 0000000..9c5e10d --- /dev/null +++ b/src/ai/state.rs @@ -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, + pub entities: Vec, + 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, +} + +#[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 = 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 = 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 { + 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 { + match name.to_lowercase().as_str() { + "player" => Some(EntityKind::Player), + "goblin" => Some(EntityKind::Goblin), + "corpse" => Some(EntityKind::Corpse), + _ => None, + } +} diff --git a/src/game.rs b/src/game.rs index f355a1f..ac82c14 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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) { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ff5b5fc --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 56831a2..489ee18 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + + #[arg(long)] + replay_file: Option, } 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::>() + .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", - } -} diff --git a/tests/entity_damage.rs b/tests/entity_damage.rs new file mode 100644 index 0000000..5315259 --- /dev/null +++ b/tests/entity_damage.rs @@ -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]); + } +} diff --git a/tests/entity_movement.rs b/tests/entity_movement.rs new file mode 100644 index 0000000..9e19bf5 --- /dev/null +++ b/tests/entity_movement.rs @@ -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); +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..8aff494 --- /dev/null +++ b/tests/integration.rs @@ -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"); +} diff --git a/tests/physics_acid.rs b/tests/physics_acid.rs new file mode 100644 index 0000000..5233ecb --- /dev/null +++ b/tests/physics_acid.rs @@ -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"); +} diff --git a/tests/physics_lava.rs b/tests/physics_lava.rs new file mode 100644 index 0000000..5e4525b --- /dev/null +++ b/tests/physics_lava.rs @@ -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"); +} diff --git a/tests/physics_sand.rs b/tests/physics_sand.rs new file mode 100644 index 0000000..312e404 --- /dev/null +++ b/tests/physics_sand.rs @@ -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"); +} diff --git a/tests/physics_water.rs b/tests/physics_water.rs new file mode 100644 index 0000000..bcda172 --- /dev/null +++ b/tests/physics_water.rs @@ -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"); +}