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.
This commit is contained in:
@@ -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
|
||||
|
||||
+14
-7
@@ -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};
|
||||
|
||||
+144
-28
@@ -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<usize>,
|
||||
},
|
||||
GetSpectrum {
|
||||
spectrum: String,
|
||||
#[serde(default)]
|
||||
w: Option<usize>,
|
||||
#[serde(default)]
|
||||
h: Option<usize>,
|
||||
},
|
||||
GetAllSpectrums {
|
||||
#[serde(default)]
|
||||
w: Option<usize>,
|
||||
#[serde(default)]
|
||||
h: Option<usize>,
|
||||
},
|
||||
GetViewAt {
|
||||
cam_x: i32,
|
||||
cam_y: i32,
|
||||
@@ -86,6 +99,8 @@ pub struct Response {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub view: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub spectrums: Option<Vec<(String, 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>>,
|
||||
@@ -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<GameSession>) -> 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<GameSession>) -> 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<GameSession>) -> 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<GameSession>) -> 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<GameSession>) -> 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<GameSession>) -> 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<GameSession>) -> 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<GameSession>) -> 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<GameSession>) -> 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<GameSession>) -> 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 => {
|
||||
|
||||
+8
-6
@@ -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<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))?;
|
||||
let recording: ReplayRecording =
|
||||
serde_json::from_str(&data).map_err(|e| std::io::Error::other(e))?;
|
||||
Ok(Self { recording })
|
||||
}
|
||||
|
||||
|
||||
+70
-21
@@ -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 {
|
||||
|
||||
+74
-7
@@ -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<EntityInfo> {
|
||||
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<EntityInfo> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
use crate::game::Game;
|
||||
use crate::render::lighting;
|
||||
use std::io::Write;
|
||||
|
||||
pub struct TapeRecorder {
|
||||
frames: Vec<TapeFrame>,
|
||||
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::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.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),
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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<String>,
|
||||
}
|
||||
|
||||
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.",
|
||||
|
||||
Reference in New Issue
Block a user