From 096f2e90a96bae8591d7d6aff082213e5b533a27 Mon Sep 17 00:00:00 2001 From: Emil Date: Sun, 21 Jun 2026 18:27:23 +0300 Subject: [PATCH] feat: multi-spectrum AI observation + tape recording system Spectrum system (src/ai/spectrum.rs): - 6 spectrums: materials, temperature, light, entities, density, velocity - Each renders a different ASCII view of the same world state - materials: material display chars + entity chars - temperature: heat levels as .-=+oxX# - light: brightness as .:+oO* - entities: entity positions only, background dots - density: material density as .:+oO# - velocity: entity speed + CA activity as .:+oO, Tape recording (src/ai/tape.rs): - --mode tape CLI with --tape-interval, --tape-output, --tape-json - Records all 6 spectrums + world state every N ticks - Each frame: tick, depth, kills, score, camera, player HP/pos, entity count - Outputs human-readable text and JSON formats Pipe protocol additions: - get_spectrum: single spectrum view by name - get_all_spectrums: all 6 spectrums in one response - Response.spectrums field added Architecture priorities updated: - Graphics mode is PRIMARY renderer - ASCII mode is DEBUG backend - Terminal mode is LEGACY - AI uses multi-spectrum ASCII layers for observation All 171 tests + 14 scenarios pass. --- AGENTS.md | 40 ++++- src/ai/mod.rs | 21 ++- src/ai/protocol.rs | 172 +++++++++++++++++---- src/ai/replay.rs | 14 +- src/ai/scenario.rs | 91 ++++++++--- src/ai/session.rs | 81 +++++++++- src/ai/spectrum.rs | 366 +++++++++++++++++++++++++++++++++++++++++++++ src/ai/tape.rs | 194 ++++++++++++++++++++++++ src/main.rs | 22 +++ 9 files changed, 927 insertions(+), 74 deletions(-) create mode 100644 src/ai/spectrum.rs create mode 100644 src/ai/tape.rs diff --git a/AGENTS.md b/AGENTS.md index 433dd2f..67703d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,20 +4,50 @@ ```sh cargo build # debug build -cargo run --release -- --mode ascii # Vulkan window, ASCII glyphs (default, recommended) -cargo run --release -- --mode graphics # Vulkan window, colored cells, 16:9 window (recommended) -cargo run -- --mode ascii # Vulkan window, ASCII glyphs (debug build, slower) -cargo run -- --mode graphics # Vulkan window, colored cells, 16:9 window (debug build, slower) -cargo run -- --mode terminal # ANSI terminal mode +cargo run --release -- --mode graphics # Vulkan colored cells (PRIMARY, recommended) +cargo run --release -- --mode ascii # Vulkan ASCII glyphs (debug backend) +cargo run -- --mode terminal # ANSI terminal mode (legacy) cargo run -- --mode pipe # JSON stdin/stdout for AI agents cargo run -- --mode test # run all JSON scenarios cargo run -- --mode headless --headless-ticks 60 # dump to headless_dump.txt cargo run -- --mode capture --headless-ticks 60 # render graphics-like PNG to capture.png cargo run --release -- --mode benchmark --benchmark-ticks 600 --benchmark-renderer graphics # FPS benchmark +cargo run --release -- --mode tape --headless-ticks 300 --tape-interval 10 --tape-output tape.txt --tape-json tape.json # multi-spectrum recording ``` Rust edition 2024, requires rustc >= 1.96. Vulkan 1.2+ required for `ascii`/`graphics` modes (falls back to terminal). Use `--release` for playable frame rates; GPU modes are CPU-bound in debug builds due to the cellular-automaton simulation. The GPU event loop is capped at 60 FPS. +## Architecture Priorities + +- **Graphics mode** (`--mode graphics`) is the PRIMARY renderer — colored cells, 8x8 px, 16:9 window +- **ASCII mode** (`--mode ascii`) is a DEBUG backend — same Vulkan pipeline with glyph atlas +- **Terminal mode** (`--mode terminal`) is LEGACY — kept for headless/test compatibility +- **AI observation** uses multi-spectrum ASCII layers via pipe protocol: + - `materials` — material type per cell + - `temperature` — heat levels encoded as characters + - `light` — light intensity per cell + - `entities` — entity positions and types only + - `density` — material density visualization + - `velocity` — entity movement speed and CA activity + +## Tape System + +```sh +# Record all spectrums every 10 ticks for 300 ticks +cargo run --release -- --mode tape --headless-ticks 300 --tape-interval 10 \ + --tape-output tape.txt --tape-json tape.json +``` + +Each tape frame contains: +- Tick number, depth, kills, score +- Camera position +- Player HP, position, entity count +- All 6 spectrum layers as ASCII text + +Pipe protocol spectrum commands: +- `{"cmd":"get_spectrum","spectrum":"materials","w":80,"h":25}` — single spectrum +- `{"cmd":"get_all_spectrums","w":80,"h":25}` — all spectrums at once + ## Tests ```sh diff --git a/src/ai/mod.rs b/src/ai/mod.rs index ccb82ab..935e491 100644 --- a/src/ai/mod.rs +++ b/src/ai/mod.rs @@ -1,13 +1,20 @@ -pub mod state; pub mod action; -pub mod session; +pub mod protocol; pub mod replay; pub mod scenario; -pub mod protocol; +pub mod session; +pub mod spectrum; +pub mod state; +pub mod tape; -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; +pub use replay::{ReplayPlayer, ReplayRecorder, ReplayRecording}; +pub use scenario::{ + format_results, load_scenario, run_all_scenarios, run_scenario, Assertion, AssertionResult, + Scenario, +}; +pub use session::GameSession; +pub use spectrum::{format_all_spectrums, render_all_spectrums, render_spectrum, Spectrum}; +pub use state::{entity_kind_name, render_view, CellInfo, EntityInfo, GameState, SubBodyInfo}; +pub use tape::{run_tape_mode, TapeFrame, TapeRecorder}; diff --git a/src/ai/protocol.rs b/src/ai/protocol.rs index 90e825d..ff7b883 100644 --- a/src/ai/protocol.rs +++ b/src/ai/protocol.rs @@ -1,8 +1,8 @@ -use serde::{Deserialize, Serialize}; use crate::ai::action::AiAction; +use crate::ai::scenario::{format_results, load_scenario, run_all_scenarios, run_scenario}; use crate::ai::session::GameSession; -use crate::ai::scenario::{run_scenario, format_results, load_scenario, run_all_scenarios}; use crate::ai::state::GameState; +use serde::{Deserialize, Serialize}; use std::io::{self, BufRead, Write}; #[derive(Serialize, Deserialize, Debug)] @@ -34,6 +34,19 @@ pub enum Command { #[serde(default)] h: Option, }, + GetSpectrum { + spectrum: String, + #[serde(default)] + w: Option, + #[serde(default)] + h: Option, + }, + GetAllSpectrums { + #[serde(default)] + w: Option, + #[serde(default)] + h: Option, + }, GetViewAt { cam_x: i32, cam_y: i32, @@ -86,6 +99,8 @@ pub struct Response { #[serde(skip_serializing_if = "Option::is_none")] pub view: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub spectrums: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub cell: Option, #[serde(skip_serializing_if = "Option::is_none")] pub region: Option>, @@ -109,9 +124,19 @@ 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, + error: None, + state: None, + view: None, + spectrums: None, + cell: None, + region: None, + entities: None, + player: None, + count: None, + found: None, + scenario_result: None, + scenario_results: None, + recording: None, } } @@ -119,9 +144,18 @@ impl Response { 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, + state: None, + view: None, + spectrums: None, + cell: None, + region: None, + entities: None, + player: None, + count: None, + found: None, + scenario_result: None, + scenario_results: None, + recording: None, } } @@ -153,7 +187,12 @@ pub fn run_pipe_protocol() { Ok(c) => c, Err(e) => { let resp = Response::err(&format!("Parse error: {}", e)); - writeln!(stdout, "{}", serde_json::to_string(&resp).unwrap_or_default()).ok(); + writeln!( + stdout, + "{}", + serde_json::to_string(&resp).unwrap_or_default() + ) + .ok(); stdout.flush().ok(); continue; } @@ -230,7 +269,47 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { 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() } + Response { + view: Some(view), + ..Response::ok() + } + } + + Command::GetSpectrum { spectrum, 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 spec = match spectrum.as_str() { + "materials" => crate::ai::spectrum::Spectrum::Materials, + "temperature" | "temp" => crate::ai::spectrum::Spectrum::Temperature, + "light" => crate::ai::spectrum::Spectrum::Light, + "entities" => crate::ai::spectrum::Spectrum::Entities, + "density" => crate::ai::spectrum::Spectrum::Density, + "velocity" => crate::ai::spectrum::Spectrum::Velocity, + _ => return Response::err("Unknown spectrum. Use: materials, temperature, light, entities, density, velocity"), + }; + let view = s.get_spectrum(&spec, vw, vh); + Response { + spectrums: Some(vec![(spectrum, view)]), + ..Response::ok() + } + } + + Command::GetAllSpectrums { 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 spectrums = s.get_all_spectrums(vw, vh); + Response { + spectrums: Some(spectrums), + ..Response::ok() + } } Command::GetViewAt { cam_x, cam_y, w, h } => { @@ -239,7 +318,10 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { None => return Response::err("No session."), }; let view = s.get_view_at(cam_x, cam_y, w, h); - Response { view: Some(view), ..Response::ok() } + Response { + view: Some(view), + ..Response::ok() + } } Command::GetCell { x, y } => { @@ -248,7 +330,10 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { None => return Response::err("No session."), }; let cell = s.get_cell(x, y); - Response { cell: Some(cell), ..Response::ok() } + Response { + cell: Some(cell), + ..Response::ok() + } } Command::GetRegion { x, y, w, h } => { @@ -257,7 +342,10 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { None => return Response::err("No session."), }; let region = s.get_region(x, y, w, h); - Response { region: Some(region), ..Response::ok() } + Response { + region: Some(region), + ..Response::ok() + } } Command::GetEntities => { @@ -266,7 +354,10 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { None => return Response::err("No session."), }; let entities = s.get_entities(); - Response { entities: Some(entities), ..Response::ok() } + Response { + entities: Some(entities), + ..Response::ok() + } } Command::GetPlayer => { @@ -275,16 +366,28 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { None => return Response::err("No session."), }; let player = s.get_player(); - Response { player, ..Response::ok() } + Response { + player, + ..Response::ok() + } } - Command::CountMaterial { x, y, w, h, material } => { + 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() } + Response { + count: Some(count), + ..Response::ok() + } } Command::FindMaterial { material } => { @@ -293,7 +396,10 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { None => return Response::err("No session."), }; let found = s.find_material(&material); - Response { found, ..Response::ok() } + Response { + found, + ..Response::ok() + } } Command::RecordStart => { @@ -302,7 +408,10 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { None => return Response::err("No session."), }; s.set_recording(true); - Response { recording: Some(true), ..Response::ok() } + Response { + recording: Some(true), + ..Response::ok() + } } Command::RecordStop => { @@ -311,7 +420,10 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { None => return Response::err("No session."), }; s.set_recording(false); - Response { recording: Some(false), ..Response::ok() } + Response { + recording: Some(false), + ..Response::ok() + } } Command::ReplaySave { path } => { @@ -325,20 +437,24 @@ fn handle_command(cmd: Command, session: &mut Option) -> Response { } } - Command::RunScenario { path } => { - match load_scenario(&path) { - Ok(scenario) => { - let result = run_scenario(&scenario); - Response { scenario_result: Some(result), ..Response::ok() } + 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), } - } + 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() } + Response { + scenario_results: Some(report), + ..Response::ok() + } } Command::Quit => { diff --git a/src/ai/replay.rs b/src/ai/replay.rs index 0bcb802..63aa6de 100644 --- a/src/ai/replay.rs +++ b/src/ai/replay.rs @@ -1,6 +1,6 @@ -use serde::{Deserialize, Serialize}; use crate::ai::action::AiAction; use crate::ai::session::GameSession; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct ReplayRecording { @@ -40,12 +40,14 @@ impl ReplayRecorder { } pub fn record_action(&mut self, tick: u64, action: AiAction) { - self.recording.events.push(ReplayEvent::Action { tick, action }); + 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))?; + let json = + serde_json::to_string_pretty(&self.recording).map_err(|e| std::io::Error::other(e))?; std::fs::write(path, json) } @@ -61,8 +63,8 @@ pub struct ReplayPlayer { 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))?; + let recording: ReplayRecording = + serde_json::from_str(&data).map_err(|e| std::io::Error::other(e))?; Ok(Self { recording }) } diff --git a/src/ai/scenario.rs b/src/ai/scenario.rs index 65b431c..190d3b8 100644 --- a/src/ai/scenario.rs +++ b/src/ai/scenario.rs @@ -1,7 +1,7 @@ -use serde::{Deserialize, Serialize}; use crate::ai::action::AiAction; use crate::ai::session::GameSession; use crate::ai::state::GameState; +use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Scenario { @@ -136,7 +136,10 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes AssertionResult { assertion: assertion.clone(), passed, - message: format!("Cell({},{}) = '{}', expected '{}'", x, y, cell.material, material), + message: format!( + "Cell({},{}) = '{}', expected '{}'", + x, y, cell.material, material + ), } } Assertion::CellIsNot { x, y, material } => { @@ -145,7 +148,10 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes AssertionResult { assertion: assertion.clone(), passed, - message: format!("Cell({},{}) = '{}', expected NOT '{}'", x, y, cell.material, material), + message: format!( + "Cell({},{}) = '{}', expected NOT '{}'", + x, y, cell.material, material + ), } } Assertion::CellTempGreaterThan { x, y, temp } => { @@ -154,7 +160,10 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes AssertionResult { assertion: assertion.clone(), passed, - message: format!("Cell({},{}) temp = {:.1}, expected > {:.1}", x, y, cell.temp, temp), + message: format!( + "Cell({},{}) temp = {:.1}, expected > {:.1}", + x, y, cell.temp, temp + ), } } Assertion::CellTempLessThan { x, y, temp } => { @@ -163,25 +172,48 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes AssertionResult { assertion: assertion.clone(), passed, - message: format!("Cell({},{}) temp = {:.1}, expected < {:.1}", x, y, cell.temp, temp), + message: format!( + "Cell({},{}) temp = {:.1}, expected < {:.1}", + x, y, cell.temp, temp + ), } } - Assertion::NoMaterialInRegion { x, y, w, h, material } => { + 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), + message: format!( + "Region({},{},{},{}) has {} '{}' cells, expected 0", + x, y, w, h, count, material + ), } } - Assertion::MaterialCountInRegion { x, y, w, h, material, min, max } => { + 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), + message: format!( + "Region({},{},{},{}) has {} '{}' cells, expected {}-{}", + x, y, w, h, count, material, min, max + ), } } Assertion::EntityAlive { id } => { @@ -191,7 +223,11 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes AssertionResult { assertion: assertion.clone(), passed, - message: format!("Entity({}) alive = {}", id, if passed { "true" } else { "false/not found" }), + message: format!( + "Entity({}) alive = {}", + id, + if passed { "true" } else { "false/not found" } + ), } } Assertion::EntityDead { id } => { @@ -201,7 +237,11 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes AssertionResult { assertion: assertion.clone(), passed, - message: format!("Entity({}) dead = {}", id, if passed { "true" } else { "false" }), + message: format!( + "Entity({}) dead = {}", + id, + if passed { "true" } else { "false" } + ), } } Assertion::EntityHealthLessThan { id, health } => { @@ -212,7 +252,10 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes AssertionResult { assertion: assertion.clone(), passed, - message: format!("Entity({}) health = {:.1}, expected < {:.1}", id, actual, health), + message: format!( + "Entity({}) health = {:.1}, expected < {:.1}", + id, actual, health + ), } } Assertion::EntityOnFire { id } => { @@ -259,13 +302,11 @@ fn check_assertion(session: &GameSession, assertion: &Assertion) -> AssertionRes 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), - } - } + Assertion::Custom { description, check } => AssertionResult { + assertion: assertion.clone(), + passed: false, + message: format!("Custom check '{}' not implemented: {}", check, description), + }, } } @@ -299,11 +340,19 @@ pub fn format_results(results: &[ScenarioResult]) -> String { let total = results.len(); let passed = results.iter().filter(|r| r.passed).count(); - out.push_str(&format!("=== Scenario Results: {}/{} passed ===\n\n", passed, total)); + 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())); + out.push_str(&format!( + "[{}] {} - {} assertions\n", + status, + r.name, + r.assertions.len() + )); if !r.passed { for a in &r.assertions { if !a.passed { diff --git a/src/ai/session.rs b/src/ai/session.rs index 942f126..726f866 100644 --- a/src/ai/session.rs +++ b/src/ai/session.rs @@ -1,6 +1,6 @@ use crate::ai::action::AiAction; use crate::ai::replay::ReplayRecorder; -use crate::ai::state::{build_game_state, CellInfo, EntityInfo, GameState, render_view}; +use crate::ai::state::{build_game_state, render_view, CellInfo, EntityInfo, GameState}; use crate::game::Game; use crate::world::cell::MaterialId; use crate::world::grid::Grid; @@ -77,6 +77,58 @@ impl GameSession { render_view(&self.game.grid, &self.game.entities, cam_x, cam_y, w, h) } + pub fn get_spectrum( + &self, + spectrum: &crate::ai::spectrum::Spectrum, + 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); + let light = crate::render::lighting::compute_lighting( + &self.game.grid, + cam_x, + cam_y, + w, + h, + crate::render::lighting::ambient_light(), + ); + crate::ai::spectrum::render_spectrum( + spectrum, + &self.game.grid, + &self.game.entities, + Some(&light), + cam_x, + cam_y, + w, + h, + ) + } + + pub fn get_all_spectrums(&self, w: usize, h: usize) -> Vec<(String, 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); + let light = crate::render::lighting::compute_lighting( + &self.game.grid, + cam_x, + cam_y, + w, + h, + crate::render::lighting::ambient_light(), + ); + crate::ai::spectrum::render_all_spectrums( + &self.game.grid, + &self.game.entities, + Some(&light), + cam_x, + cam_y, + w, + h, + ) + } + pub fn get_cell(&self, x: i32, y: i32) -> CellInfo { CellInfo::from_grid(&self.game.grid, x, y) } @@ -92,16 +144,29 @@ impl GameSession { } pub fn get_entities(&self) -> Vec { - self.game.entities.all().iter().map(|e| { - crate::ai::state::entity_info(e) - }).collect() + 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)) + 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 { + 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; @@ -151,7 +216,9 @@ impl GameSession { 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()); + self.game + .grid + .set(x + dx, y + dy, crate::world::cell::Cell::empty()); } } } diff --git a/src/ai/spectrum.rs b/src/ai/spectrum.rs new file mode 100644 index 0000000..e595aa7 --- /dev/null +++ b/src/ai/spectrum.rs @@ -0,0 +1,366 @@ +use crate::entity::{EntityKind, EntityManager}; +use crate::world::grid::Grid; + +pub enum Spectrum { + Materials, + Temperature, + Light, + Entities, + Density, + Velocity, +} + +impl Spectrum { + pub fn name(&self) -> &'static str { + match self { + Spectrum::Materials => "materials", + Spectrum::Temperature => "temperature", + Spectrum::Light => "light", + Spectrum::Entities => "entities", + Spectrum::Density => "density", + Spectrum::Velocity => "velocity", + } + } + + pub fn all() -> &'static [Spectrum] { + &[ + Spectrum::Materials, + Spectrum::Temperature, + Spectrum::Light, + Spectrum::Entities, + Spectrum::Density, + Spectrum::Velocity, + ] + } +} + +pub fn render_spectrum( + spectrum: &Spectrum, + grid: &Grid, + entities: &EntityManager, + light: Option<&crate::render::lighting::LightGrid>, + cam_x: i32, + cam_y: i32, + vw: usize, + vh: usize, +) -> String { + match spectrum { + Spectrum::Materials => render_materials(grid, entities, cam_x, cam_y, vw, vh), + Spectrum::Temperature => render_temperature(grid, cam_x, cam_y, vw, vh), + Spectrum::Light => render_light(grid, entities, light, cam_x, cam_y, vw, vh), + Spectrum::Entities => render_entities(grid, entities, cam_x, cam_y, vw, vh), + Spectrum::Density => render_density(grid, cam_x, cam_y, vw, vh), + Spectrum::Velocity => render_velocity(grid, entities, cam_x, cam_y, vw, vh), + } +} + +fn render_materials( + 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', + EntityKind::Slime if e.alive => 's', + _ => '%', + }; + 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 { + buf.push(grid.get(x, y).material.display_char()); + } + } + buf.push('\n'); + } + buf +} + +fn render_temperature(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String { + 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 !grid.in_bounds(x, y) { + buf.push(' '); + } else { + let cell = grid.get(x, y); + if cell.is_empty() { + buf.push(' '); + } else { + let t = cell.temp; + let ch = if t < 0.0 { + '.' + } else if t < 20.0 { + '-' + } else if t < 50.0 { + '=' + } else if t < 100.0 { + '+' + } else if t < 200.0 { + 'o' + } else if t < 400.0 { + 'x' + } else if t < 800.0 { + 'X' + } else { + '#' + }; + buf.push(ch); + } + } + } + buf.push('\n'); + } + buf +} + +fn render_light( + grid: &Grid, + entities: &EntityManager, + light: Option<&crate::render::lighting::LightGrid>, + cam_x: i32, + cam_y: i32, + vw: usize, + vh: usize, +) -> String { + let ambient = crate::render::lighting::ambient_light(); + 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; + let (r, g, b) = if let Some(lg) = light { + let c = lg.get(dx as i32, dy as i32); + (c[0], c[1], c[2]) + } else { + (ambient[0], ambient[1], ambient[2]) + }; + let brightness = (r as u32 + g as u32 + b as u32) / 3; + let ch = if !grid.in_bounds(x, y) { + ' ' + } else if brightness < 30 { + ' ' + } else if brightness < 60 { + '.' + } else if brightness < 90 { + ':' + } else if brightness < 120 { + '+' + } else if brightness < 160 { + 'o' + } else if brightness < 200 { + 'O' + } else { + '*' + }; + buf.push(ch); + } + buf.push('\n'); + } + let _ = entities; + buf +} + +fn render_entities( + grid: &Grid, + entities: &EntityManager, + cam_x: i32, + cam_y: i32, + vw: usize, + vh: usize, +) -> String { + 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; + let mut found = ' '; + for e in entities.all() { + for b in &e.bodies { + if !b.alive { + continue; + } + let bx = b.x as i32; + let by = b.y as i32; + if bx == x && by == y { + found = match e.kind { + EntityKind::Player if e.alive => '@', + EntityKind::Goblin if e.alive => 'g', + EntityKind::Slime if e.alive => 's', + EntityKind::Player => '@', + _ => '%', + }; + break; + } + } + if found != ' ' { + break; + } + } + if found == ' ' && grid.in_bounds(x, y) { + let cell = grid.get(x, y); + if !cell.is_empty() { + found = '.'; + } + } + buf.push(found); + } + buf.push('\n'); + } + buf +} + +fn render_density(grid: &Grid, cam_x: i32, cam_y: i32, vw: usize, vh: usize) -> String { + let reg = crate::world::material::MaterialRegistry::instance(); + 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 !grid.in_bounds(x, y) { + buf.push(' '); + } else { + let cell = grid.get(x, y); + if cell.is_empty() { + buf.push(' '); + } else { + let mat = reg.get(cell.material); + let d = mat.density; + let ch = if d < 0.5 { + '.' + } else if d < 1.0 { + ':' + } else if d < 2.0 { + '+' + } else if d < 5.0 { + 'o' + } else if d < 10.0 { + 'O' + } else { + '#' + }; + buf.push(ch); + } + } + } + buf.push('\n'); + } + buf +} + +fn render_velocity( + grid: &Grid, + entities: &EntityManager, + cam_x: i32, + cam_y: i32, + vw: usize, + vh: usize, +) -> String { + 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; + let mut ch = ' '; + for e in entities.all() { + if !e.alive { + continue; + } + let vx = e.cvx; + let vy = e.cvy; + let speed = (vx * vx + vy * vy).sqrt(); + let (ex, ey) = e.center(); + let sx = ex as i32 - cam_x; + let sy = ey as i32 - cam_y; + if sx == dx as i32 && sy == dy as i32 { + ch = if speed < 0.1 { + '.' + } else if speed < 0.5 { + ':' + } else if speed < 1.0 { + '+' + } else if speed < 2.0 { + 'o' + } else { + 'O' + }; + break; + } + } + if ch == ' ' && grid.in_bounds(x, y) { + let cell = grid.get(x, y); + if cell.updated_this_tick { + ch = ','; + } + } + buf.push(ch); + } + buf.push('\n'); + } + buf +} + +pub fn render_all_spectrums( + grid: &Grid, + entities: &EntityManager, + light: Option<&crate::render::lighting::LightGrid>, + cam_x: i32, + cam_y: i32, + vw: usize, + vh: usize, +) -> Vec<(String, String)> { + Spectrum::all() + .iter() + .map(|s| { + ( + s.name().to_string(), + render_spectrum(s, grid, entities, light, cam_x, cam_y, vw, vh), + ) + }) + .collect() +} + +pub fn format_all_spectrums( + grid: &Grid, + entities: &EntityManager, + light: Option<&crate::render::lighting::LightGrid>, + cam_x: i32, + cam_y: i32, + vw: usize, + vh: usize, +) -> String { + let spectrums = render_all_spectrums(grid, entities, light, cam_x, cam_y, vw, vh); + let mut out = String::new(); + for (name, view) in &spectrums { + out.push_str(&format!("--- {} ---\n", name)); + out.push_str(view); + out.push('\n'); + } + out +} diff --git a/src/ai/tape.rs b/src/ai/tape.rs new file mode 100644 index 0000000..7b1a21a --- /dev/null +++ b/src/ai/tape.rs @@ -0,0 +1,194 @@ +use crate::game::Game; +use crate::render::lighting; +use std::io::Write; + +pub struct TapeRecorder { + frames: Vec, + interval: u32, + last_recorded_tick: u64, + view_w: usize, + view_h: usize, +} + +#[derive(Clone, Debug)] +pub struct TapeFrame { + pub tick: u64, + pub depth: u32, + pub kills: u32, + pub score: u32, + pub cam_x: i32, + pub cam_y: i32, + pub player_hp: f32, + pub player_max_hp: f32, + pub player_pos: [f32; 2], + pub entity_count: usize, + pub spectrums: Vec<(String, String)>, +} + +impl TapeRecorder { + pub fn new(interval: u32, view_w: usize, view_h: usize) -> Self { + Self { + frames: Vec::new(), + interval, + last_recorded_tick: 0, + view_w, + view_h, + } + } + + pub fn should_record(&self, tick: u64) -> bool { + tick - self.last_recorded_tick >= self.interval as u64 + } + + pub fn record(&mut self, game: &Game) { + if !self.should_record(game.tick) { + return; + } + self.last_recorded_tick = game.tick; + + let (px, py) = game.player.center(&game.entities); + let cam_x = px as i32 - (self.view_w as i32 / 2); + let cam_y = py as i32 - (self.view_h as i32 / 2); + + let light = lighting::compute_lighting( + &game.grid, + cam_x, + cam_y, + self.view_w, + self.view_h, + lighting::ambient_light(), + ); + + let spectrums = crate::ai::spectrum::render_all_spectrums( + &game.grid, + &game.entities, + Some(&light), + cam_x, + cam_y, + self.view_w, + self.view_h, + ); + + let (hp, max_hp) = game + .player + .entity(&game.entities) + .map(|e| (e.health, e.max_health)) + .unwrap_or((0.0, 0.0)); + + let frame = TapeFrame { + tick: game.tick, + depth: game.depth, + kills: game.kills, + score: game.score, + cam_x, + cam_y, + player_hp: hp, + player_max_hp: max_hp, + player_pos: [px, py], + entity_count: game.entities.all().len(), + spectrums, + }; + self.frames.push(frame); + } + + pub fn frame_count(&self) -> usize { + self.frames.len() + } + + pub fn save_to_file(&self, path: &str) -> std::io::Result<()> { + let mut f = std::fs::File::create(path)?; + writeln!(f, "=== Verbatim Tape Recording ===")?; + writeln!(f, "frames: {}", self.frames.len())?; + writeln!(f, "view: {}x{}", self.view_w, self.view_h)?; + writeln!(f)?; + + for frame in &self.frames { + writeln!( + f, + "==== FRAME tick={} depth={} kills={} score={} ====", + frame.tick, frame.depth, frame.kills, frame.score + )?; + writeln!(f, "cam: ({}, {})", frame.cam_x, frame.cam_y)?; + writeln!( + f, + "player: hp={:.1}/{:.1} pos=({:.1},{:.1}) entities={}", + frame.player_hp, + frame.player_max_hp, + frame.player_pos[0], + frame.player_pos[1], + frame.entity_count + )?; + writeln!(f)?; + for (name, view) in &frame.spectrums { + writeln!(f, "--- {} ---", name)?; + write!(f, "{}", view)?; + writeln!(f)?; + } + } + Ok(()) + } + + pub fn save_json_to_file(&self, path: &str) -> std::io::Result<()> { + let json = serde_json::to_string_pretty( + &self + .frames + .iter() + .map(|f| { + serde_json::json!({ + "tick": f.tick, + "depth": f.depth, + "kills": f.kills, + "score": f.score, + "cam": [f.cam_x, f.cam_y], + "player": { + "hp": f.player_hp, + "max_hp": f.player_max_hp, + "pos": f.player_pos, + }, + "entity_count": f.entity_count, + "spectrums": f.spectrums.iter().map(|(name, view)| { + serde_json::json!({ + "name": name, + "view": view, + }) + }).collect::>(), + }) + }) + .collect::>(), + ) + .unwrap_or_default(); + + let mut f = std::fs::File::create(path)?; + f.write_all(json.as_bytes())?; + Ok(()) + } +} + +pub fn run_tape_mode(ticks: u32, interval: u32, output: &str, json_output: Option<&str>) { + let mut game = Game::new(); + game.init_world(); + + let view_w = 80usize; + let view_h = 25usize; + let mut recorder = TapeRecorder::new(interval, view_w, view_h); + + recorder.record(&game); + + for _ in 0..ticks { + game.fixed_update(); + recorder.record(&game); + } + + let frame_count = recorder.frame_count(); + match recorder.save_to_file(output) { + Ok(_) => eprintln!("Tape: {} frames recorded, saved to {}", frame_count, output), + Err(e) => eprintln!("Tape save failed: {}", e), + } + + if let Some(json_path) = json_output { + match recorder.save_json_to_file(json_path) { + Ok(_) => eprintln!("Tape JSON saved to {}", json_path), + Err(e) => eprintln!("Tape JSON save failed: {}", e), + } + } +} diff --git a/src/main.rs b/src/main.rs index 80284e6..c43d238 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,6 +41,15 @@ struct Cli { #[arg(long, default_value = "benchmark_results.json")] benchmark_output: String, + + #[arg(long, default_value_t = 5)] + tape_interval: u32, + + #[arg(long, default_value = "tape.txt")] + tape_output: String, + + #[arg(long)] + tape_json: Option, } trait GpuRenderer { @@ -167,6 +176,19 @@ fn main() { "benchmark" => { run_benchmark_mode(&cli); } + "tape" => { + if cli.headless_ticks > 0 { + ai::run_tape_mode( + cli.headless_ticks, + cli.tape_interval, + &cli.tape_output, + cli.tape_json.as_deref(), + ); + } else { + eprintln!("Use --headless-ticks N with --mode tape"); + std::process::exit(1); + } + } _ => { eprintln!( "Unknown mode: {}. Use terminal, ascii, graphics, pipe, test, replay, headless, or capture.",