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:
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user