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:
Emil
2026-06-21 18:27:23 +03:00
parent 586487e61c
commit 096f2e90a9
9 changed files with 927 additions and 74 deletions
+35 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
}
}
}
+366
View File
@@ -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
View File
@@ -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
View File
@@ -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.",