use image::io::Reader as ImageReader;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::path::Path;
use super::agent::normalize_angle;
use crate::config_defaults::{
agent as agent_consts, environment, environment as env_consts, food as food_img_consts,
population, population as pop_consts, time as time_consts, trail as trail_consts,
};
use crate::render::palette::RgbColor;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DiffusionKernel {
Mean3x3,
Gaussian,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DepositCurve {
#[default]
Linear,
Sqrt,
Log,
Pow,
}
impl DepositCurve {
#[inline]
pub fn apply(self, x: f32, gamma: f32) -> f32 {
match self {
DepositCurve::Linear => x,
DepositCurve::Sqrt => x.sqrt(),
DepositCurve::Log => (1.0 + x).ln(),
DepositCurve::Pow => x.powf(gamma),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Preset {
Network,
Exploratory,
Tendrils,
Organic,
Fire,
River,
#[serde(rename = "petridish")]
PetriDish,
Vortex,
Lightning,
#[serde(rename = "chaosedge")]
ChaosEdge,
Blob,
Slime,
Vines,
Vinescii,
Smoke,
#[serde(rename = "vortex36")]
Vortex36,
#[serde(rename = "dynamictendrils")]
DynamicTendrils,
Mold,
Etching,
Drift,
Constellation,
Mosaic,
Marble,
Prism,
Vellum,
Forge,
Wane,
Gossamer,
Codex,
Tide,
Trademark,
}
pub struct PresetSpec {
pub preset: Preset,
pub name: &'static str,
pub aliases: &'static [&'static str],
pub quick_key: Option<char>,
}
pub const PRESETS: &[PresetSpec] = &[
PresetSpec {
preset: Preset::Network,
name: "Network",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Exploratory,
name: "Exploratory",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Tendrils,
name: "Tendrils",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Organic,
name: "Organic",
aliases: &[],
quick_key: Some('1'),
},
PresetSpec {
preset: Preset::Fire,
name: "Fire",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::River,
name: "River",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::PetriDish,
name: "PetriDish",
aliases: &["petri"],
quick_key: None,
},
PresetSpec {
preset: Preset::Vortex,
name: "Vortex",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Lightning,
name: "Lightning",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::ChaosEdge,
name: "ChaosEdge",
aliases: &["chaos-edge", "chaos_edge"],
quick_key: None,
},
PresetSpec {
preset: Preset::Blob,
name: "Blob",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Slime,
name: "Slime",
aliases: &["pulse"],
quick_key: None,
},
PresetSpec {
preset: Preset::Vines,
name: "Vines",
aliases: &["flocking"],
quick_key: None,
},
PresetSpec {
preset: Preset::Vinescii,
name: "Vinescii",
aliases: &["vines-ascii"],
quick_key: Some('3'),
},
PresetSpec {
preset: Preset::Smoke,
name: "Smoke",
aliases: &["ripple"],
quick_key: None,
},
PresetSpec {
preset: Preset::Vortex36,
name: "Vortex36",
aliases: &["vortex-36", "vortex_36"],
quick_key: None,
},
PresetSpec {
preset: Preset::DynamicTendrils,
name: "DynamicTendrils",
aliases: &["dynamic-tendrils", "dynamic_tendrils"],
quick_key: None,
},
PresetSpec {
preset: Preset::Mold,
name: "Mold",
aliases: &["lumen"],
quick_key: None,
},
PresetSpec {
preset: Preset::Etching,
name: "Etching",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Drift,
name: "Drift",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Constellation,
name: "constellations",
aliases: &["constellation", "atlas"],
quick_key: Some('2'),
},
PresetSpec {
preset: Preset::Mosaic,
name: "Mosaic",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Marble,
name: "Marble",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Prism,
name: "Prism",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Vellum,
name: "Vellum",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Forge,
name: "Forge",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Wane,
name: "Wane",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Gossamer,
name: "Gossamer",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Codex,
name: "Codex",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Tide,
name: "Tide",
aliases: &[],
quick_key: None,
},
PresetSpec {
preset: Preset::Trademark,
name: "Trademark",
aliases: &["logo", "logo-constellation", "logogram", "tslime"],
quick_key: Some('4'),
},
];
#[must_use]
pub fn preset_from_name(name: &str) -> Option<Preset> {
PRESETS
.iter()
.find(|spec| {
spec.name.eq_ignore_ascii_case(name)
|| spec.aliases.iter().any(|a| a.eq_ignore_ascii_case(name))
})
.map(|spec| spec.preset)
}
#[must_use]
pub fn preset_name_list() -> String {
PRESETS
.iter()
.map(|spec| spec.name.to_lowercase())
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn preset_for_set_key(key: char) -> Option<Preset> {
PRESETS
.iter()
.find(|spec| spec.quick_key == Some(key))
.map(|spec| spec.preset)
}
#[must_use]
pub fn preset_for_compare_key(key: char) -> Option<Preset> {
shifted_digit(key).and_then(preset_for_set_key)
}
#[must_use]
pub fn compare_key_digit(key: char) -> Option<char> {
shifted_digit(key)
}
fn shifted_digit(key: char) -> Option<char> {
match key {
'!' => Some('1'),
'@' => Some('2'),
'#' => Some('3'),
'$' => Some('4'),
'%' => Some('5'),
'^' => Some('6'),
'&' => Some('7'),
_ => None,
}
}
impl Preset {
#[must_use]
pub fn name(&self) -> &'static str {
PRESETS
.iter()
.find(|spec| spec.preset == *self)
.map_or("Unknown", |spec| spec.name)
}
pub fn tagline(&self) -> &'static str {
use Preset::*;
match self {
Network => "dense interconnected mesh",
Exploratory => "wide searching tentacles",
Tendrils => "long branching arms",
Organic => "balanced natural growth",
Fire => "aggressive flame-like fronts",
River => "flowing water-like channels",
PetriDish => "slow center-out growth",
Vortex => "spinning vortex currents",
Lightning => "fast dendritic forks",
ChaosEdge => "edge-of-chaos sensitivity",
Blob => "aggregating blob clusters",
Slime => "surface-tension flow",
Vines => "creeping cohesive tendrils",
Vinescii => "ascii cohesive flocking",
Smoke => "drifting smoke columns",
Vortex36 => "trail-modulated vortex",
DynamicTendrils => "trail-sensing tendrils",
Mold => "front-lit growing veins",
Etching => "directional filament linework",
Drift => "color drifts with motion",
Constellation => "a crisp star-map",
Mosaic => "posterized color bands",
Marble => "veined stone",
Prism => "maximum color resolution",
Vellum => "soft parchment density",
Forge => "grainy molten thermal",
Wane => "slow ghosting decay",
Gossamer => "delicate woven threads",
Codex => "typographic engraving",
Tide => "living water, shifting hue",
Trademark => "the living tslime mark",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InitMode {
Random,
CentralBurst,
Circle,
Gradient,
WaveFront,
Spiral,
RandomClusters,
Food,
Petri,
Constellation,
FoodConstellation,
}
impl InitMode {
pub fn random(rng: &mut impl rand::Rng) -> Self {
use InitMode::*;
const ALL: [InitMode; 9] = [
Random,
CentralBurst,
Circle,
Gradient,
WaveFront,
Spiral,
RandomClusters,
Food,
Petri,
];
#[allow(dead_code)] const fn _guard(m: InitMode) {
match m {
InitMode::Random
| InitMode::CentralBurst
| InitMode::Circle
| InitMode::Gradient
| InitMode::WaveFront
| InitMode::Spiral
| InitMode::RandomClusters
| InitMode::Food
| InitMode::Petri
| InitMode::Constellation
| InitMode::FoodConstellation => {}
}
}
ALL[rng.gen_range(0..ALL.len())]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TerrainType {
#[default]
None,
Smooth,
Turbulent,
Mixed,
}
impl std::str::FromStr for TerrainType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"none" | "off" | "disabled" => Ok(TerrainType::None),
"smooth" => Ok(TerrainType::Smooth),
"turbulent" => Ok(TerrainType::Turbulent),
"mixed" => Ok(TerrainType::Mixed),
_ => Err(format!(
"Invalid terrain type: {}. Must be one of: none, smooth, turbulent, mixed",
s
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Wind {
pub dx: f32,
pub dy: f32,
}
impl Wind {
pub fn new(dx: f32, dy: f32) -> Self {
Self { dx, dy }
}
}
impl Default for Wind {
fn default() -> Self {
Self { dx: 0.0, dy: 0.0 }
}
}
impl Validatable for Wind {
fn validate(&self) -> Result<(), ValidationError> {
if self.dx < -1.0 || self.dx > 1.0 {
return Err(ValidationError::out_of_range("wind.dx", -1.0, 1.0, self.dx));
}
if self.dy < -1.0 || self.dy > 1.0 {
return Err(ValidationError::out_of_range("wind.dy", -1.0, 1.0, self.dy));
}
if self.dx.abs() < 0.001 && self.dy.abs() < 0.001 {
return Err(ValidationError::custom("wind cannot be zero vector"));
}
Ok(())
}
}
impl std::str::FromStr for Wind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
use crate::validation::Validatable;
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 2 {
return Err(format!("Wind must be in dx,dy format, got: {}", s));
}
let dx = parts[0]
.parse::<f32>()
.map_err(|e| format!("Invalid dx: {}", e))?;
let dy = parts[1]
.parse::<f32>()
.map_err(|e| format!("Invalid dy: {}", e))?;
let wind = Wind::new(dx, dy);
Validatable::validate(&wind).map_err(|e| e.to_string())?;
Ok(wind)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Attractor {
pub x: f32,
pub y: f32,
pub strength: f32,
}
impl Attractor {
pub fn new(x: f32, y: f32, strength: f32) -> Self {
Self { x, y, strength }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MouseAttractor {
pub x: f32,
pub y: f32,
pub strength: f32,
pub created_at: std::time::Instant,
pub timeout_seconds: f32,
}
impl MouseAttractor {
pub fn new(x: f32, y: f32, strength: f32, timeout_seconds: f32) -> Self {
Self {
x,
y,
strength,
created_at: std::time::Instant::now(),
timeout_seconds,
}
}
pub fn is_expired(&self) -> bool {
self.created_at.elapsed().as_secs_f32() >= self.timeout_seconds
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ObstacleMask {
pub pixels: Vec<f32>,
pub width: usize,
pub height: usize,
}
impl ObstacleMask {
pub fn from_image(
image_path: &str,
target_width: usize,
target_height: usize,
invert: bool,
) -> Result<Self, String> {
let path = Path::new(image_path);
if !path.exists() {
return Err(format!("Image file not found: {}", image_path));
}
let img = ImageReader::open(path)
.map_err(|e| format!("Failed to open image: {}", e))?
.decode()
.map_err(|e| format!("Failed to decode image: {}", e))?;
let resized = img.resize_exact(
target_width as u32,
target_height as u32,
image::imageops::FilterType::Nearest,
);
let pixels: Vec<f32> = resized
.to_luma8()
.pixels()
.map(|p| {
let brightness = p[0] as f32 / 255.0;
if invert {
1.0 - brightness
} else {
brightness
}
})
.collect();
Ok(Self {
pixels,
width: target_width,
height: target_height,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Obstacle {
Circle {
x: f32,
y: f32,
radius: f32,
},
Rect {
x: f32,
y: f32,
width: f32,
height: f32,
},
Image {
path: String,
x: f32,
y: f32,
width: usize,
height: usize,
invert: bool,
threshold: f32,
},
}
impl Obstacle {
pub fn contains(&self, px: f32, py: f32, mask: Option<&ObstacleMask>) -> bool {
match self {
Obstacle::Circle { x, y, radius } => {
let dx = px - x;
let dy = py - y;
dx * dx + dy * dy <= radius * radius
}
Obstacle::Rect {
x,
y,
width,
height,
} => px >= *x && px <= *x + *width && py >= *y && py <= *y + *height,
Obstacle::Image {
path: _,
x,
y,
width,
height,
invert: _,
threshold,
} => {
let lx = px - x;
let ly = py - y;
if lx < 0.0 || lx >= *width as f32 || ly < 0.0 || ly >= *height as f32 {
return false;
}
if let Some(m) = mask {
let ix = lx as usize;
let iy = ly as usize;
let idx = iy * m.width + ix;
if idx >= m.pixels.len() {
return false;
}
m.pixels[idx] >= *threshold
} else {
false
}
}
}
}
pub fn bounce(&self, px: f32, py: f32, heading: f32, _mask: Option<&ObstacleMask>) -> f32 {
match self {
Obstacle::Circle { x, y, radius: _ } => {
let dx = px - x;
let dy = py - y;
let normal_angle = dy.atan2(dx);
let new_heading = 2.0 * normal_angle - heading + std::f32::consts::PI;
normalize_angle(new_heading)
}
Obstacle::Rect {
x,
y,
width,
height,
} => {
let nearest_x = px.clamp(*x, *x + *width);
let nearest_y = py.clamp(*y, *y + *height);
let dx = px - nearest_x;
let dy = py - nearest_y;
if dx.abs() > dy.abs() {
-heading + std::f32::consts::PI
} else {
-heading
}
}
Obstacle::Image {
path: _,
x: _,
y: _,
width: _,
height: _,
invert: _,
threshold: _,
} => -heading,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BoundaryMode {
#[default]
Bounce,
Wrap,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WindowFrame {
None,
Accented,
Glow,
#[default]
Frame,
}
impl WindowFrame {
pub fn reduces_display_area(&self) -> bool {
false
}
pub fn thickness(&self) -> usize {
match self {
WindowFrame::None => 0,
WindowFrame::Frame => 2,
WindowFrame::Accented => 1,
WindowFrame::Glow => 3,
}
}
pub fn is_visible(&self) -> bool {
!matches!(self, WindowFrame::None)
}
}
impl std::str::FromStr for WindowFrame {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"none" => Ok(WindowFrame::None),
"accented" => Ok(WindowFrame::Accented),
"glow" => Ok(WindowFrame::Glow),
"frame" => Ok(WindowFrame::Frame),
_ => Err(format!(
"Invalid window frame: {}. Must be one of: none, accented, glow, frame",
s
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChromeStyle {
#[default]
Minimal,
Expanded,
Fullscreen,
}
impl std::str::FromStr for ChromeStyle {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"minimal" => Ok(ChromeStyle::Minimal),
"expanded" => Ok(ChromeStyle::Expanded),
"fullscreen" => Ok(ChromeStyle::Fullscreen),
_ => Err(format!(
"Invalid chrome style: '{}'. Must be one of: minimal, expanded, fullscreen",
s
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TransitionStyle {
#[default]
Off,
Toast,
Figlet,
Type,
}
impl std::str::FromStr for TransitionStyle {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"off" | "none" => Ok(TransitionStyle::Off),
"toast" => Ok(TransitionStyle::Toast),
"figlet" => Ok(TransitionStyle::Figlet),
"type" => Ok(TransitionStyle::Type),
_ => Err(format!(
"Invalid transition: '{}'. Must be one of: off, toast, figlet, type",
s
)),
}
}
}
impl std::fmt::Display for TransitionStyle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
TransitionStyle::Off => "off",
TransitionStyle::Toast => "toast",
TransitionStyle::Figlet => "figlet",
TransitionStyle::Type => "type",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Aspect {
pub width: u32,
pub height: u32,
}
impl Default for Aspect {
fn default() -> Self {
Self {
width: 3,
height: 2,
}
}
}
impl Aspect {
pub fn cell_ratio(&self) -> f32 {
self.width as f32 / (self.height as f32 / 2.0)
}
}
impl std::str::FromStr for Aspect {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"square" => {
return Ok(Self {
width: 1,
height: 1,
})
}
"4:3" => {
return Ok(Self {
width: 4,
height: 3,
})
}
"3:2" => {
return Ok(Self {
width: 3,
height: 2,
})
}
"16:10" => {
return Ok(Self {
width: 16,
height: 10,
})
}
"16:9" => {
return Ok(Self {
width: 16,
height: 9,
})
}
_ => {}
}
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 2 {
return Err(format!(
"Invalid aspect '{}'. Use W:H or preset (square, 4:3, 3:2, 16:10, 16:9)",
s
));
}
let w = parts[0]
.parse::<u32>()
.map_err(|_| format!("Invalid aspect width in '{}'", s))?;
let h = parts[1]
.parse::<u32>()
.map_err(|_| format!("Invalid aspect height in '{}'", s))?;
if w == 0 || h == 0 {
return Err(format!("Aspect W and H must be non-zero, got '{}'", s));
}
Ok(Self {
width: w,
height: h,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub enum WindowPadding {
#[default]
Auto,
Fixed(usize),
}
impl std::str::FromStr for WindowPadding {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.to_lowercase() == "auto" {
return Ok(Self::Auto);
}
let n = s
.parse::<usize>()
.map_err(|_| format!("Invalid window padding '{}'. Use 'auto' or an integer.", s))?;
Ok(Self::Fixed(n))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct TerminalSizeThreshold {
pub width: usize,
pub height: usize,
}
impl Default for TerminalSizeThreshold {
fn default() -> Self {
Self {
width: 20,
height: 10,
}
}
}
impl std::str::FromStr for TerminalSizeThreshold {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split('x').collect();
if parts.len() != 2 {
return Err(format!(
"Invalid size '{}'. Use WxH format, e.g. '20x10'",
s
));
}
let w = parts[0]
.parse::<usize>()
.map_err(|_| format!("Invalid width in size '{}'", s))?;
let h = parts[1]
.parse::<usize>()
.map_err(|_| format!("Invalid height in size '{}'", s))?;
if w == 0 || h == 0 {
return Err(format!("Size W and H must be non-zero, got '{}'", s));
}
Ok(Self {
width: w,
height: h,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SamplingMode {
#[default]
Nearest,
Bilinear,
}
impl std::str::FromStr for BoundaryMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"bounce" => Ok(BoundaryMode::Bounce),
"wrap" | "toroidal" => Ok(BoundaryMode::Wrap),
_ => Err(format!(
"Invalid boundary mode: {}. Must be one of: bounce, wrap",
s
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct PointConfig {
pub sensor_distance_base: f32,
pub sensor_distance_multiplier: f32,
pub sensor_distance_exponent: f32,
pub sensor_angle_base: f32,
pub sensor_angle_multiplier: f32,
pub sensor_angle_exponent: f32,
pub rotation_angle_base: f32,
pub rotation_angle_multiplier: f32,
pub rotation_angle_exponent: f32,
pub step_size_base: f32,
pub step_size_multiplier: f32,
pub step_size_exponent: f32,
pub vertical_offset: f32,
pub heading_offset: f32,
pub trail_rescale: f32,
}
impl Default for PointConfig {
fn default() -> Self {
Self {
sensor_distance_base: agent_consts::DEFAULT_SENSOR_DISTANCE,
sensor_distance_multiplier: 0.0,
sensor_distance_exponent: 1.0,
sensor_angle_base: agent_consts::DEFAULT_SENSOR_ANGLE,
sensor_angle_multiplier: 0.0,
sensor_angle_exponent: 1.0,
rotation_angle_base: agent_consts::DEFAULT_ROTATION_ANGLE,
rotation_angle_multiplier: 0.0,
rotation_angle_exponent: 1.0,
step_size_base: agent_consts::DEFAULT_STEP_SIZE,
step_size_multiplier: 0.0,
step_size_exponent: 1.0,
vertical_offset: 0.0,
heading_offset: 0.0,
trail_rescale: 1.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModulatedParams {
pub sensor_distance: f32,
pub sensor_angle: f32,
pub rotation_angle: f32,
pub step_size: f32,
}
impl PointConfig {
pub fn compute_params(&self, x: f32) -> ModulatedParams {
let x = (x * self.trail_rescale).clamp(0.0, 1.0);
let compute = |base: f32, multiplier: f32, exponent: f32| -> f32 {
if multiplier == 0.0 || x == 0.0 {
base
} else if exponent == 1.0 {
base + multiplier * x
} else {
base + multiplier * x.powf(exponent)
}
};
ModulatedParams {
sensor_distance: compute(
self.sensor_distance_base,
self.sensor_distance_multiplier,
self.sensor_distance_exponent,
)
.clamp(
agent_consts::MIN_SENSOR_DISTANCE,
agent_consts::MAX_SENSOR_DISTANCE,
),
sensor_angle: compute(
self.sensor_angle_base,
self.sensor_angle_multiplier,
self.sensor_angle_exponent,
)
.clamp(
agent_consts::MIN_SENSOR_ANGLE,
agent_consts::MAX_SENSOR_ANGLE,
),
rotation_angle: compute(
self.rotation_angle_base,
self.rotation_angle_multiplier,
self.rotation_angle_exponent,
)
.clamp(
agent_consts::MIN_ROTATION_ANGLE,
agent_consts::MAX_ROTATION_ANGLE,
),
step_size: compute(
self.step_size_base,
self.step_size_multiplier,
self.step_size_exponent,
)
.clamp(agent_consts::MIN_STEP_SIZE, agent_consts::MAX_STEP_SIZE),
}
}
pub fn has_modulation(&self) -> bool {
self.sensor_distance_multiplier != 0.0
|| self.sensor_angle_multiplier != 0.0
|| self.rotation_angle_multiplier != 0.0
|| self.step_size_multiplier != 0.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct RespawnConfig {
pub interval: u32,
pub base_probability: f32,
pub trail_dependent: bool,
pub max_probability_multiplier: f32,
pub trail_rescale: f32,
}
impl Default for RespawnConfig {
fn default() -> Self {
Self {
interval: 0, base_probability: 0.01,
trail_dependent: false,
max_probability_multiplier: 1.0,
trail_rescale: 1.0,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SpeciesConfig {
pub name: String,
pub count: usize,
pub sensor_angle: f32,
pub rotation_angle: f32,
pub step_size: f32,
pub deposit_amount: f32,
pub color: RgbColor,
pub trail_modulation: Option<PointConfig>,
}
impl Default for SpeciesConfig {
fn default() -> Self {
Self {
name: "default".to_string(),
count: population::DEFAULT_POPULATION,
sensor_angle: agent_consts::DEFAULT_SENSOR_ANGLE,
rotation_angle: agent_consts::DEFAULT_ROTATION_ANGLE,
step_size: agent_consts::DEFAULT_STEP_SIZE,
deposit_amount: agent_consts::DEFAULT_DEPOSIT_AMOUNT,
color: RgbColor::from_hex(0x228b22), trail_modulation: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SimConfig {
pub sensor_angle: f32,
pub sensor_distance: f32,
pub rotation_angle: f32,
pub step_size: f32,
pub decay_factor: f32,
pub deposit_amount: f32,
pub diffusion_kernel: DiffusionKernel,
pub diffusion_sigma: f32,
pub diffuse_weight: f32,
pub decay_gamma: f32,
pub deposit_curve: DepositCurve,
pub deposit_scale: f32,
pub deposit_gamma: f32,
pub deposit_cap: f32,
pub max_brightness: f32,
pub time_scale: f32,
pub attractors: Vec<Attractor>,
pub attractor_strength: f32,
pub mouse_attractors: Vec<MouseAttractor>,
pub mouse_timeout: f32,
pub species_configs: Vec<SpeciesConfig>,
pub separate_species_trails: bool,
pub use_simd: bool,
pub food_image_path: Option<String>,
pub food_image_invert: bool,
pub food_image_scale: f32,
pub obstacles: Vec<Obstacle>,
pub obstacle_masks: Vec<Option<ObstacleMask>>,
pub wind: Option<Wind>,
pub terrain: TerrainType,
pub terrain_strength: f32,
pub background_color: Option<String>,
pub preferred_init_mode: Option<InitMode>,
pub boundary_mode: BoundaryMode,
pub window_frame: WindowFrame,
pub frame_matte_cols: usize,
pub frame_matte_rows: usize,
pub chrome_style: ChromeStyle,
pub transition_style: TransitionStyle,
pub transition_tagline: bool,
pub aspect: Aspect,
pub window_padding: WindowPadding,
pub show_status_bar: bool,
pub min_sim_size: TerminalSizeThreshold,
pub min_frame_size: TerminalSizeThreshold,
pub respawn_config: RespawnConfig,
pub sampling_mode: SamplingMode,
pub constellation_restamp_floor: f32,
}
impl SimConfig {
pub fn total_population(&self) -> usize {
self.species_configs.iter().map(|s| s.count).sum()
}
#[inline]
pub fn deposit_active(&self) -> bool {
self.deposit_curve != DepositCurve::Linear
|| self.deposit_scale != 1.0
|| self.deposit_cap > 0.0
}
pub fn load_obstacle_masks(&mut self) -> Result<(), String> {
self.obstacle_masks.clear();
for obstacle in &self.obstacles {
match obstacle {
Obstacle::Image {
path,
width,
height,
invert,
..
} => {
let mask = ObstacleMask::from_image(path, *width, *height, *invert)?;
self.obstacle_masks.push(Some(mask));
}
_ => {
self.obstacle_masks.push(None);
}
}
}
Ok(())
}
pub fn add_mouse_attractor(&mut self, x: f32, y: f32, strength: f32) {
self.mouse_attractors
.push(MouseAttractor::new(x, y, strength, self.mouse_timeout));
}
pub fn remove_expired_mouse_attractors(&mut self) {
self.mouse_attractors.retain(|ma| !ma.is_expired());
}
pub fn effective_attractors(&self) -> Cow<'_, [Attractor]> {
if self.mouse_attractors.is_empty() {
Cow::Borrowed(&self.attractors)
} else {
let mut result = self.attractors.clone();
for ma in &self.mouse_attractors {
result.push(Attractor::new(ma.x, ma.y, ma.strength));
}
Cow::Owned(result)
}
}
}
impl Default for SimConfig {
fn default() -> Self {
Self {
sensor_angle: agent_consts::DEFAULT_SENSOR_ANGLE,
sensor_distance: agent_consts::DEFAULT_SENSOR_DISTANCE,
rotation_angle: agent_consts::DEFAULT_ROTATION_ANGLE,
step_size: agent_consts::DEFAULT_STEP_SIZE,
decay_factor: trail_consts::DEFAULT_DECAY_FACTOR,
deposit_amount: agent_consts::DEFAULT_DEPOSIT_AMOUNT,
diffusion_kernel: DiffusionKernel::Gaussian,
diffusion_sigma: trail_consts::DEFAULT_DIFFUSION_SIGMA,
diffuse_weight: trail_consts::DEFAULT_DIFFUSE_WEIGHT,
decay_gamma: trail_consts::DEFAULT_DECAY_GAMMA,
deposit_curve: DepositCurve::default(),
deposit_scale: trail_consts::DEFAULT_DEPOSIT_SCALE,
deposit_gamma: trail_consts::DEFAULT_DEPOSIT_GAMMA,
deposit_cap: trail_consts::DEFAULT_DEPOSIT_CAP,
max_brightness: trail_consts::DEFAULT_MAX_BRIGHTNESS,
time_scale: time_consts::DEFAULT_TIME_SCALE,
attractors: Vec::new(),
attractor_strength: env_consts::DEFAULT_ATTRACTOR_STRENGTH,
mouse_attractors: Vec::new(),
mouse_timeout: env_consts::DEFAULT_MOUSE_TIMEOUT,
species_configs: vec![SpeciesConfig::default()],
separate_species_trails: false,
use_simd: true,
food_image_path: Some(food_img_consts::DEFAULT_FOOD_PATH.to_string()),
food_image_invert: food_img_consts::DEFAULT_FOOD_INVERT,
food_image_scale: food_img_consts::DEFAULT_FOOD_SCALE,
obstacles: Vec::new(),
obstacle_masks: Vec::new(),
wind: None,
terrain: TerrainType::None,
terrain_strength: env_consts::DEFAULT_TERRAIN_STRENGTH,
background_color: None,
preferred_init_mode: Some(InitMode::Food),
boundary_mode: BoundaryMode::Bounce,
window_frame: WindowFrame::Frame,
frame_matte_cols: crate::config_defaults::frame_matte::DEFAULT_COLS,
frame_matte_rows: crate::config_defaults::frame_matte::DEFAULT_ROWS,
chrome_style: ChromeStyle::Minimal,
transition_style: TransitionStyle::Off,
transition_tagline: false,
aspect: Aspect::default(),
window_padding: WindowPadding::Auto,
show_status_bar: false,
min_sim_size: TerminalSizeThreshold {
width: 20,
height: 10,
},
min_frame_size: TerminalSizeThreshold {
width: 12,
height: 6,
},
respawn_config: RespawnConfig::default(),
sampling_mode: SamplingMode::Nearest,
constellation_restamp_floor:
crate::config_defaults::DEFAULT_CONSTELLATION_RESTAMP_FLOOR,
}
}
}
use crate::error::ValidationError;
use crate::validation::{rules, Validatable};
impl Validatable for SimConfig {
fn validate(&self) -> Result<(), ValidationError> {
if self.species_configs.is_empty() {
return Err(ValidationError::custom(
"at least one species must be configured",
));
}
let total_pop: usize = self.species_configs.iter().map(|s| s.count).sum();
if !(population::MIN_POPULATION..=population::MAX_POPULATION).contains(&total_pop) {
return Err(ValidationError::custom(format!(
"total population must be between {} and {}, got {}",
population::MIN_POPULATION,
population::MAX_POPULATION,
total_pop
)));
}
rules::SENSOR_ANGLE.validate_f32(self.sensor_angle)?;
rules::SENSOR_DISTANCE.validate_f32(self.sensor_distance)?;
rules::ROTATION_ANGLE.validate_f32(self.rotation_angle)?;
rules::STEP_SIZE.validate_f32(self.step_size)?;
rules::DEPOSIT_AMOUNT.validate_f32(self.deposit_amount)?;
rules::DECAY_FACTOR.validate_f32(self.decay_factor)?;
rules::MAX_BRIGHTNESS.validate_f32(self.max_brightness)?;
rules::DIFFUSION_SIGMA.validate_f32(self.diffusion_sigma)?;
rules::DECAY_GAMMA.validate_f32(self.decay_gamma)?;
rules::DIFFUSE_WEIGHT.validate_f32(self.diffuse_weight)?;
rules::DEPOSIT_SCALE.validate_f32(self.deposit_scale)?;
rules::DEPOSIT_GAMMA.validate_f32(self.deposit_gamma)?;
rules::DEPOSIT_CAP.validate_f32(self.deposit_cap)?;
rules::TIME_SCALE.validate_f32(self.time_scale)?;
rules::ATTRACTOR_STRENGTH.validate_f32(self.attractor_strength)?;
rules::TERRAIN_STRENGTH.validate_f32(self.terrain_strength)?;
for (i, attractor) in self.attractors.iter().enumerate() {
if attractor.strength < environment::MIN_ATTRACTOR_STRENGTH
|| attractor.strength > environment::MAX_ATTRACTOR_STRENGTH
{
return Err(ValidationError::out_of_range(
format!("attractor[{}].strength", i),
environment::MIN_ATTRACTOR_STRENGTH,
environment::MAX_ATTRACTOR_STRENGTH,
attractor.strength,
));
}
}
for species in &self.species_configs {
Validatable::validate(species)?;
}
if let Some(ref wind) = self.wind {
Validatable::validate(wind)?;
}
Ok(())
}
}
impl TryFrom<&crate::cli::Args> for SimConfig {
type Error = crate::error::ValidationError;
fn try_from(args: &crate::cli::Args) -> Result<Self, Self::Error> {
let profile = crate::profile::Profile::resolve_from_args(args)
.map_err(crate::error::ValidationError::custom)?;
Ok(profile.sim)
}
}
impl Validatable for SpeciesConfig {
fn validate(&self) -> Result<(), ValidationError> {
if self.count < pop_consts::MIN_SPECIES_COUNT || self.count > pop_consts::MAX_SPECIES_COUNT
{
return Err(ValidationError::out_of_range(
format!("species '{}' count", self.name),
pop_consts::MIN_SPECIES_COUNT,
pop_consts::MAX_SPECIES_COUNT,
self.count,
));
}
if self.sensor_angle < agent_consts::MIN_SENSOR_ANGLE
|| self.sensor_angle > agent_consts::MAX_SENSOR_ANGLE
{
return Err(ValidationError::out_of_range(
format!("species '{}' sensor_angle", self.name),
agent_consts::MIN_SENSOR_ANGLE,
agent_consts::MAX_SENSOR_ANGLE,
self.sensor_angle,
));
}
if self.rotation_angle < agent_consts::MIN_ROTATION_ANGLE
|| self.rotation_angle > agent_consts::MAX_ROTATION_ANGLE
{
return Err(ValidationError::out_of_range(
format!("species '{}' rotation_angle", self.name),
agent_consts::MIN_ROTATION_ANGLE,
agent_consts::MAX_ROTATION_ANGLE,
self.rotation_angle,
));
}
if self.step_size < agent_consts::MIN_STEP_SIZE
|| self.step_size > agent_consts::MAX_STEP_SIZE
{
return Err(ValidationError::out_of_range(
format!("species '{}' step_size", self.name),
agent_consts::MIN_STEP_SIZE,
agent_consts::MAX_STEP_SIZE,
self.step_size,
));
}
if self.deposit_amount < agent_consts::MIN_DEPOSIT_AMOUNT
|| self.deposit_amount > agent_consts::MAX_DEPOSIT_AMOUNT
{
return Err(ValidationError::out_of_range(
format!("species '{}' deposit_amount", self.name),
agent_consts::MIN_DEPOSIT_AMOUNT,
agent_consts::MAX_DEPOSIT_AMOUNT,
self.deposit_amount,
));
}
Ok(())
}
}
impl From<Preset> for SimConfig {
fn from(preset: Preset) -> Self {
let mut config = Self::default();
crate::preset_sim_defaults::PresetSimDefaults::from(preset).apply_to(&mut config);
config
}
}
impl From<Aspect> for String {
fn from(a: Aspect) -> Self {
format!("{}:{}", a.width, a.height)
}
}
impl TryFrom<String> for Aspect {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl From<WindowPadding> for String {
fn from(p: WindowPadding) -> Self {
match p {
WindowPadding::Auto => "auto".to_string(),
WindowPadding::Fixed(n) => n.to_string(),
}
}
}
impl TryFrom<String> for WindowPadding {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl From<TerminalSizeThreshold> for String {
fn from(t: TerminalSizeThreshold) -> Self {
format!("{}x{}", t.width, t.height)
}
}
impl TryFrom<String> for TerminalSizeThreshold {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
#[test]
fn init_mode_random_covers_all_over_many_draws() {
use rand::SeedableRng;
let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(42);
const ALL: [InitMode; 9] = [
InitMode::Random,
InitMode::CentralBurst,
InitMode::Circle,
InitMode::Gradient,
InitMode::WaveFront,
InitMode::Spiral,
InitMode::RandomClusters,
InitMode::Food,
InitMode::Petri,
];
let mut seen = [false; 9];
for _ in 0..1000 {
let m = InitMode::random(&mut rng);
let i = ALL.iter().position(|x| *x == m).unwrap();
seen[i] = true;
}
assert!(seen.iter().all(|&s| s), "every InitMode should appear");
}
#[test]
fn test_default_config() {
let config = SimConfig::default();
assert_eq!(config.total_population(), 50_000);
assert_eq!(config.sensor_angle, 22.5);
assert_eq!(config.sensor_distance, 9.0);
assert_eq!(config.rotation_angle, 45.0);
assert_eq!(config.step_size, 1.0);
assert_eq!(config.decay_factor, 0.5);
assert_eq!(config.deposit_amount, 5.0);
assert_eq!(config.max_brightness, 100.0);
}
#[test]
fn test_validate_default() {
let config = SimConfig::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_population_too_low() {
let config = SimConfig {
species_configs: vec![SpeciesConfig {
count: 500,
..Default::default()
}],
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_population_too_high() {
let config = SimConfig {
species_configs: vec![SpeciesConfig {
count: 300_000,
..Default::default()
}],
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_sensor_angle() {
let config = SimConfig {
sensor_angle: 100.0,
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_decay_factor() {
let config = SimConfig {
decay_factor: 1.0,
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_max_brightness_too_low() {
let config = SimConfig {
max_brightness: 0.5,
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_max_brightness_too_high() {
let config = SimConfig {
max_brightness: 1500.0,
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_attractor_strength_too_low() {
let config = SimConfig {
attractor_strength: 0.05,
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_attractor_strength_too_high() {
let config = SimConfig {
attractor_strength: 15.0,
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_attractor_strength_valid() {
let config = SimConfig {
attractor_strength: 5.0,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_attractor_creation() {
let attractor = Attractor::new(200.0, 200.0, 1.0);
assert_eq!(attractor.x, 200.0);
assert_eq!(attractor.y, 200.0);
assert_eq!(attractor.strength, 1.0);
}
#[test]
fn test_negative_attractor_strength() {
let attractor = Attractor::new(200.0, 200.0, -1.0);
assert_eq!(attractor.strength, -1.0);
}
#[test]
fn test_species_config_default() {
let species = SpeciesConfig::default();
assert_eq!(species.count, 50_000);
assert_eq!(species.sensor_angle, 22.5);
assert_eq!(species.rotation_angle, 45.0);
assert_eq!(species.step_size, 1.0);
assert_eq!(species.deposit_amount, 5.0);
}
#[test]
fn test_species_config_validate_count_too_low() {
let species = SpeciesConfig {
count: 50,
..Default::default()
};
assert!(species.validate().is_err());
}
#[test]
fn test_species_config_validate_count_too_high() {
let species = SpeciesConfig {
count: 300_000,
..Default::default()
};
assert!(species.validate().is_err());
}
#[test]
fn test_total_population_single_species() {
let config = SimConfig {
species_configs: vec![SpeciesConfig {
count: 10000,
..Default::default()
}],
..Default::default()
};
assert_eq!(config.total_population(), 10000);
}
#[test]
fn test_total_population_multiple_species() {
let config = SimConfig {
species_configs: vec![
SpeciesConfig {
count: 10000,
..Default::default()
},
SpeciesConfig {
count: 20000,
name: "second".to_string(),
color: RgbColor::from_hex(0xff0000),
..Default::default()
},
],
..Default::default()
};
assert_eq!(config.total_population(), 30000);
}
#[test]
fn test_obstacle_circle_contains() {
let circle = Obstacle::Circle {
x: 100.0,
y: 100.0,
radius: 50.0,
};
assert!(circle.contains(100.0, 100.0, None));
assert!(circle.contains(100.0, 150.0, None));
assert!(circle.contains(150.0, 100.0, None));
assert!(!circle.contains(200.0, 100.0, None));
assert!(!circle.contains(100.0, 200.0, None));
}
#[test]
fn test_obstacle_rect_contains() {
let rect = Obstacle::Rect {
x: 100.0,
y: 100.0,
width: 50.0,
height: 50.0,
};
assert!(rect.contains(100.0, 100.0, None));
assert!(rect.contains(150.0, 150.0, None));
assert!(!rect.contains(99.0, 100.0, None));
assert!(!rect.contains(100.0, 99.0, None));
assert!(!rect.contains(151.0, 100.0, None));
assert!(!rect.contains(100.0, 151.0, None));
}
#[test]
fn test_obstacle_circle_bounce() {
let circle = Obstacle::Circle {
x: 100.0,
y: 100.0,
radius: 50.0,
};
let heading = circle.bounce(100.0, 60.0, 0.0, None);
assert!(
heading.is_finite(),
"Bounce should return a valid heading, got {}",
heading
);
}
#[test]
fn test_obstacle_rect_bounce() {
let rect = Obstacle::Rect {
x: 100.0,
y: 100.0,
width: 50.0,
height: 50.0,
};
let heading = rect.bounce(120.0, 100.0, 0.0, None);
assert!(
heading.is_finite(),
"Bounce should return a valid heading, got {}",
heading
);
}
#[test]
fn test_obstacle_mask_from_image_nonexistent() {
let result = ObstacleMask::from_image("nonexistent.png", 100, 100, false);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_sim_config_load_obstacle_masks() {
let mut config = SimConfig {
obstacles: vec![Obstacle::Circle {
x: 100.0,
y: 100.0,
radius: 50.0,
}],
..Default::default()
};
let result = config.load_obstacle_masks();
assert!(result.is_ok());
assert_eq!(config.obstacle_masks.len(), 1);
assert!(config.obstacle_masks[0].is_none());
}
#[test]
fn test_wind_creation() {
let wind = Wind::new(0.5, 0.5);
assert_eq!(wind.dx, 0.5);
assert_eq!(wind.dy, 0.5);
}
#[test]
fn test_wind_validate_valid() {
let wind = Wind::new(1.0, 1.0);
assert!(wind.validate().is_ok());
let wind = Wind::new(-1.0, 0.0);
assert!(wind.validate().is_ok());
let wind = Wind::new(0.0, -1.0);
assert!(wind.validate().is_ok());
}
#[test]
fn test_wind_validate_invalid_dx() {
let wind = Wind::new(1.5, 0.0);
assert!(wind.validate().is_err());
}
#[test]
fn test_wind_validate_invalid_dy() {
let wind = Wind::new(0.0, 1.5);
assert!(wind.validate().is_err());
}
#[test]
fn test_wind_validate_zero() {
let wind = Wind::new(0.0, 0.0);
assert!(wind.validate().is_err());
}
#[test]
fn test_wind_parse() {
let wind: Wind = "0.5,0.5".parse().unwrap();
assert_eq!(wind.dx, 0.5);
assert_eq!(wind.dy, 0.5);
let wind: Wind = "-0.3,0.7".parse().unwrap();
assert_eq!(wind.dx, -0.3);
assert_eq!(wind.dy, 0.7);
}
#[test]
fn test_wind_parse_invalid() {
assert!("0.5".parse::<Wind>().is_err());
assert!("0.5,0.5,extra".parse::<Wind>().is_err());
assert!("abc,def".parse::<Wind>().is_err());
}
#[test]
fn test_terrain_type_parse() {
assert_eq!("none".parse::<TerrainType>().unwrap(), TerrainType::None);
assert_eq!("off".parse::<TerrainType>().unwrap(), TerrainType::None);
assert_eq!(
"smooth".parse::<TerrainType>().unwrap(),
TerrainType::Smooth
);
assert_eq!(
"turbulent".parse::<TerrainType>().unwrap(),
TerrainType::Turbulent
);
assert_eq!("mixed".parse::<TerrainType>().unwrap(), TerrainType::Mixed);
assert_eq!("NONE".parse::<TerrainType>().unwrap(), TerrainType::None);
assert_eq!(
"Smooth".parse::<TerrainType>().unwrap(),
TerrainType::Smooth
);
}
#[test]
fn test_terrain_type_parse_invalid() {
assert!("invalid".parse::<TerrainType>().is_err());
assert!("chaos".parse::<TerrainType>().is_err());
}
#[test]
fn test_sim_config_wind_field() {
let config = SimConfig {
wind: Some(Wind::new(0.5, 0.0)),
..Default::default()
};
assert!(config.wind.is_some());
assert_eq!(config.wind.unwrap().dx, 0.5);
}
#[test]
fn test_sim_config_terrain_field() {
let config = SimConfig {
terrain: TerrainType::Turbulent,
terrain_strength: 2.0,
..Default::default()
};
assert_eq!(config.terrain, TerrainType::Turbulent);
assert_eq!(config.terrain_strength, 2.0);
}
#[test]
fn test_validate_terrain_strength_too_low() {
let config = SimConfig {
terrain_strength: 0.05,
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_terrain_strength_too_high() {
let config = SimConfig {
terrain_strength: 10.0,
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_validate_wind_invalid() {
let config = SimConfig {
wind: Some(Wind::new(1.5, 0.0)),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_effective_attractors() {
let mut config = SimConfig {
attractors: vec![Attractor::new(10.0, 10.0, 1.0)],
..Default::default()
};
config.add_mouse_attractor(20.0, 20.0, 2.0);
let effective = config.effective_attractors();
assert_eq!(effective.len(), 2);
assert_eq!(effective[0].strength, 1.0);
assert_eq!(effective[1].strength, 2.0);
}
#[test]
fn test_mouse_attractor_expiry() {
let ma = MouseAttractor::new(10.0, 10.0, 1.0, 0.01);
assert!(!ma.is_expired());
std::thread::sleep(std::time::Duration::from_millis(20));
assert!(ma.is_expired());
}
#[test]
fn test_remove_expired_mouse_attractors() {
let mut config = SimConfig {
mouse_timeout: 0.01,
..Default::default()
};
config.add_mouse_attractor(10.0, 10.0, 1.0);
assert_eq!(config.mouse_attractors.len(), 1);
std::thread::sleep(std::time::Duration::from_millis(20));
config.remove_expired_mouse_attractors();
assert_eq!(config.mouse_attractors.len(), 0);
}
#[test]
fn test_presets_valid() {
for spec in PRESETS {
let config: SimConfig = spec.preset.into();
assert!(
config.validate().is_ok(),
"Preset {:?} failed validation: {:?}",
spec.preset,
config.validate()
);
}
}
#[test]
fn preset_names_and_aliases_round_trip() {
for spec in PRESETS {
assert_eq!(spec.preset.name(), spec.name);
assert_eq!(preset_from_name(spec.name), Some(spec.preset));
assert_eq!(
preset_from_name(&spec.name.to_lowercase()),
Some(spec.preset)
);
for alias in spec.aliases {
assert_eq!(
preset_from_name(alias),
Some(spec.preset),
"alias {alias} did not resolve to {:?}",
spec.preset
);
}
}
assert_eq!(preset_from_name("definitely-not-a-preset"), None);
}
#[test]
fn preset_quick_keys_are_consistent() {
let mut seen = Vec::new();
for spec in PRESETS {
if let Some(key) = spec.quick_key {
assert!(
key.is_ascii_digit() && key != '0',
"quick_key {key} is not 1-9"
);
assert!(!seen.contains(&key), "duplicate quick_key {key}");
seen.push(key);
assert_eq!(preset_for_set_key(key), Some(spec.preset));
let shifted = match key {
'1' => '!',
'2' => '@',
'3' => '#',
'4' => '$',
'5' => '%',
'6' => '^',
'7' => '&',
_ => continue,
};
assert_eq!(preset_for_compare_key(shifted), Some(spec.preset));
}
}
}
#[test]
fn launch_quick_keys_map_to_launch_presets() {
assert_eq!(preset_for_set_key('1'), Some(Preset::Organic));
assert_eq!(preset_for_set_key('2'), Some(Preset::Constellation));
assert_eq!(preset_for_set_key('3'), Some(Preset::Vinescii));
assert_eq!(preset_for_set_key('4'), Some(Preset::Trademark));
for c in ['5', '6', '7'] {
assert_eq!(
preset_for_set_key(c),
None,
"key {c} must be unbound at launch"
);
}
assert_eq!(preset_for_compare_key('@'), Some(Preset::Constellation));
assert_eq!(preset_for_compare_key('$'), Some(Preset::Trademark));
}
#[test]
fn test_try_from_args_valid() {
use crate::cli::Args;
use clap::Parser;
let args = Args::parse_from(["tslime"]);
let config = SimConfig::try_from(&args);
assert!(
config.is_ok(),
"default args must convert: {:?}",
config.err()
);
}
#[test]
fn test_try_from_args_rejects_out_of_range_sensor_angle() {
use crate::cli::Args;
use clap::Parser;
let args = Args::parse_from(["tslime", "--sensor-angle", "200"]);
let result = SimConfig::try_from(&args);
assert!(result.is_err(), "sensor_angle 200 must be rejected");
}
#[test]
#[cfg(feature = "multi-species")]
fn test_try_from_args_rejects_bad_species_strict() {
use crate::cli::Args;
use clap::Parser;
let args = Args::parse_from(["tslime", "--species", "x:20000@999,45,1.0,5.0:ff0000"]);
let result = SimConfig::try_from(&args);
assert!(
result.is_err(),
"out-of-range species param must now error (was silently accepted)"
);
}
#[test]
#[cfg(not(feature = "multi-species"))]
fn test_species_flag_rejected_without_feature() {
use crate::cli::Args;
use clap::Parser;
assert!(
Args::try_parse_from(["tslime", "--species", "x:1000"]).is_err(),
"--species must be unknown without multi-species feature"
);
}
#[test]
fn test_obstacle_rect_bounce_sides() {
let rect = Obstacle::Rect {
x: 100.0,
y: 100.0,
width: 50.0,
height: 50.0,
};
let h1 = rect.bounce(125.0, 99.9, 0.1, None);
assert!((h1 - (-0.1)).abs() < 0.001);
let h2 = rect.bounce(99.9, 125.0, 0.1, None);
assert!((h2 - (PI - 0.1)).abs() < 0.001);
}
#[test]
fn test_species_config_validate_all() {
let s = SpeciesConfig {
sensor_angle: 1.0,
..Default::default()
};
assert!(s.validate().is_err());
let s = SpeciesConfig {
rotation_angle: 1.0,
..Default::default()
};
assert!(s.validate().is_err());
let s = SpeciesConfig {
step_size: 0.005,
..Default::default()
};
assert!(s.validate().is_err());
let s = SpeciesConfig {
deposit_amount: 0.05,
..Default::default()
};
assert!(s.validate().is_err());
}
#[test]
fn test_validatable_trait() {
use crate::validation::Validatable;
let valid_config = SimConfig::default();
assert!(valid_config.validate().is_ok());
let invalid_config = SimConfig {
sensor_angle: 200.0, ..Default::default()
};
assert!(invalid_config.validate().is_err());
}
#[test]
fn test_species_validatable_trait() {
use crate::validation::Validatable;
let valid_species = SpeciesConfig::default();
assert!(valid_species.validate().is_ok());
let invalid_species = SpeciesConfig {
count: 50, ..Default::default()
};
assert!(invalid_species.validate().is_err());
}
#[test]
fn art_knob_defaults_are_backcompat_neutral() {
let c = SimConfig::default();
assert_eq!(
c.diffuse_weight, 1.0,
"diffuse_weight=1 == full blur == today"
);
assert_eq!(c.decay_gamma, 1.0, "decay_gamma=1 == current decay");
}
#[test]
fn validate_decay_gamma_rejects_out_of_range() {
let config = SimConfig {
decay_gamma: 9999.0,
..SimConfig::default()
};
assert!(
config.validate().is_err(),
"decay_gamma=9999.0 must be rejected"
);
}
#[test]
fn validate_decay_gamma_accepts_valid() {
let config = SimConfig {
decay_gamma: 1.0,
..SimConfig::default()
};
assert!(
config.validate().is_ok(),
"decay_gamma=1.0 must be accepted"
);
}
#[test]
fn deposit_curve_apply_matches_definitions() {
use crate::simulation::config::DepositCurve;
assert_eq!(DepositCurve::Linear.apply(3.0, 0.5), 3.0);
assert!((DepositCurve::Sqrt.apply(9.0, 1.0) - 3.0).abs() < 1e-6);
assert_eq!(DepositCurve::Sqrt.apply(0.0, 1.0), 0.0);
assert_eq!(DepositCurve::Log.apply(0.0, 1.0), 0.0);
assert!((DepositCurve::Log.apply(std::f32::consts::E - 1.0, 1.0) - 1.0).abs() < 1e-6);
assert!((DepositCurve::Pow.apply(4.0, 0.5) - 2.0).abs() < 1e-6);
assert!((DepositCurve::Pow.apply(2.0, 2.0) - 4.0).abs() < 1e-6);
assert_eq!(DepositCurve::default(), DepositCurve::Linear);
}
#[test]
fn deposit_active_off_at_defaults() {
let cfg = SimConfig::default();
assert_eq!(cfg.deposit_curve, DepositCurve::Linear);
assert_eq!(cfg.deposit_scale, 1.0);
assert_eq!(cfg.deposit_gamma, 1.0);
assert_eq!(cfg.deposit_cap, 0.0);
assert!(!cfg.deposit_active(), "defaults must be the off path");
let on = SimConfig {
deposit_curve: DepositCurve::Sqrt,
..Default::default()
};
assert!(on.deposit_active());
let scaled = SimConfig {
deposit_scale: 2.0,
..Default::default()
};
assert!(scaled.deposit_active());
let capped = SimConfig {
deposit_cap: 5.0,
..Default::default()
};
assert!(capped.deposit_active());
}
#[test]
fn deposit_validation_rejects_out_of_range() {
let cfg = SimConfig {
deposit_gamma: 0.0, ..Default::default()
};
assert!(cfg.validate().is_err());
}
}
#[cfg(test)]
mod window_type_tests {
use super::*;
#[test]
fn test_aspect_from_str_presets() {
assert_eq!(
"3:2".parse::<Aspect>().unwrap(),
Aspect {
width: 3,
height: 2
}
);
assert_eq!(
"square".parse::<Aspect>().unwrap(),
Aspect {
width: 1,
height: 1
}
);
assert_eq!(
"4:3".parse::<Aspect>().unwrap(),
Aspect {
width: 4,
height: 3
}
);
assert_eq!(
"16:10".parse::<Aspect>().unwrap(),
Aspect {
width: 16,
height: 10
}
);
assert_eq!(
"16:9".parse::<Aspect>().unwrap(),
Aspect {
width: 16,
height: 9
}
);
}
#[test]
fn test_aspect_from_str_custom() {
assert_eq!(
"5:3".parse::<Aspect>().unwrap(),
Aspect {
width: 5,
height: 3
}
);
}
#[test]
fn test_aspect_from_str_errors() {
assert!("0:1".parse::<Aspect>().is_err());
assert!("bad".parse::<Aspect>().is_err());
assert!("1:2:3".parse::<Aspect>().is_err());
}
#[test]
fn test_aspect_cell_ratio_3_2() {
let aspect = Aspect {
width: 3,
height: 2,
};
assert!((aspect.cell_ratio() - 3.0).abs() < 0.001);
}
#[test]
fn test_chrome_style_from_str() {
assert_eq!(
"minimal".parse::<ChromeStyle>().unwrap(),
ChromeStyle::Minimal
);
assert_eq!(
"expanded".parse::<ChromeStyle>().unwrap(),
ChromeStyle::Expanded
);
assert_eq!(
"fullscreen".parse::<ChromeStyle>().unwrap(),
ChromeStyle::Fullscreen
);
assert!("invalid".parse::<ChromeStyle>().is_err());
}
#[test]
fn test_window_padding_from_str() {
assert_eq!(
"auto".parse::<WindowPadding>().unwrap(),
WindowPadding::Auto
);
assert_eq!(
"4".parse::<WindowPadding>().unwrap(),
WindowPadding::Fixed(4)
);
assert!("bad".parse::<WindowPadding>().is_err());
}
#[test]
fn test_terminal_size_threshold_from_str() {
let t = "20x10".parse::<TerminalSizeThreshold>().unwrap();
assert_eq!(t.width, 20);
assert_eq!(t.height, 10);
assert!("bad".parse::<TerminalSizeThreshold>().is_err());
assert!("0x10".parse::<TerminalSizeThreshold>().is_err());
}
}