use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy_symbios_texture::TextureConfig;
use rand::SeedableRng;
use rand::rngs::StdRng;
use symbios_genetics::Genotype;
#[derive(Resource, Default, Debug)]
pub struct TextureConfigStore {
configs: HashMap<String, TextureConfig>,
}
impl TextureConfigStore {
pub fn insert(
&mut self,
material_id: impl Into<String>,
config: TextureConfig,
) -> Option<TextureConfig> {
self.configs.insert(material_id.into(), config)
}
pub fn get(&self, material_id: &str) -> Option<&TextureConfig> {
self.configs.get(material_id)
}
pub fn get_mut(&mut self, material_id: &str) -> Option<&mut TextureConfig> {
self.configs.get_mut(material_id)
}
pub fn remove(&mut self, material_id: &str) -> Option<TextureConfig> {
self.configs.remove(material_id)
}
pub fn len(&self) -> usize {
self.configs.len()
}
pub fn is_empty(&self) -> bool {
self.configs.is_empty()
}
pub fn mutate<R: rand::Rng>(
&mut self,
rng: &mut R,
strength: f32,
) -> Vec<(String, TextureConfig)> {
let mut ids: Vec<&String> = self.configs.keys().collect();
ids.sort();
let ordered_ids: Vec<String> = ids.into_iter().cloned().collect();
let mut out = Vec::with_capacity(ordered_ids.len());
for id in ordered_ids {
if let Some(cfg) = self.configs.get_mut(&id) {
mutate_texture_config(cfg, rng, strength);
out.push((id, cfg.clone()));
}
}
out
}
}
pub fn mutate_texture_config<R: rand::Rng>(cfg: &mut TextureConfig, rng: &mut R, strength: f32) {
cfg.mutate(rng, strength);
}
#[derive(Event, Clone, Copy, Debug)]
pub struct MutateMaterialsRequest {
pub seed: u64,
pub strength: f32,
}
#[derive(Event, Clone, Debug)]
pub struct MaterialTextureMutated {
pub material_id: String,
pub config: TextureConfig,
}
pub fn handle_mutate_request(
request: On<MutateMaterialsRequest>,
mut commands: Commands,
mut store: ResMut<TextureConfigStore>,
) {
let req = request.event();
let mut rng = StdRng::seed_from_u64(req.seed);
let mutated = store.mutate(&mut rng, req.strength);
for (material_id, config) in mutated {
commands.trigger(MaterialTextureMutated {
material_id,
config,
});
}
}
pub struct MaterialMutationPlugin;
impl Plugin for MaterialMutationPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TextureConfigStore>()
.add_observer(handle_mutate_request);
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_symbios_texture::brick::BrickConfig;
use bevy_symbios_texture::stucco::StuccoConfig;
use std::sync::{Arc, Mutex};
fn store_with_two_materials() -> TextureConfigStore {
let mut store = TextureConfigStore::default();
store.insert(
"Brick",
TextureConfig::Brick(BrickConfig {
aspect_ratio: 3.0,
color_brick: [0.45, 0.22, 0.15],
..Default::default()
}),
);
store.insert(
"Stucco",
TextureConfig::Stucco(StuccoConfig {
roughness: 0.35,
color_base: [0.87, 0.83, 0.77],
..Default::default()
}),
);
store
}
#[test]
fn store_round_trips_inserts_and_lookups() {
let store = store_with_two_materials();
assert_eq!(store.len(), 2);
assert!(matches!(store.get("Brick"), Some(TextureConfig::Brick(_))));
assert!(matches!(
store.get("Stucco"),
Some(TextureConfig::Stucco(_))
));
assert!(store.get("Missing").is_none());
}
#[test]
fn mutate_returns_one_entry_per_material_in_sorted_order() {
let mut store = store_with_two_materials();
let mut rng = StdRng::seed_from_u64(7);
let result = store.mutate(&mut rng, 0.5);
assert_eq!(result.len(), 2);
assert_eq!(result[0].0, "Brick", "ordering must be sorted by id");
assert_eq!(result[1].0, "Stucco");
}
#[test]
fn mutate_is_deterministic_for_a_fixed_seed() {
let mut a = store_with_two_materials();
let mut b = store_with_two_materials();
let mut rng_a = StdRng::seed_from_u64(42);
let mut rng_b = StdRng::seed_from_u64(42);
let out_a = a.mutate(&mut rng_a, 0.7);
let out_b = b.mutate(&mut rng_b, 0.7);
let dbg_a = format!("{out_a:?}");
let dbg_b = format!("{out_b:?}");
assert_eq!(dbg_a, dbg_b);
}
#[test]
fn observer_emits_one_event_per_mutated_material() {
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let captured_obs = captured.clone();
let mut app = App::new();
app.add_plugins(MaterialMutationPlugin);
app.world_mut()
.resource_mut::<TextureConfigStore>()
.insert("Brick", TextureConfig::Brick(BrickConfig::default()));
app.world_mut()
.resource_mut::<TextureConfigStore>()
.insert("Stucco", TextureConfig::Stucco(StuccoConfig::default()));
app.add_observer(move |trigger: On<MaterialTextureMutated>| {
captured_obs
.lock()
.unwrap()
.push(trigger.event().material_id.clone());
});
app.world_mut().trigger(MutateMaterialsRequest {
seed: 1,
strength: 0.5,
});
app.update();
let ids = captured.lock().unwrap();
assert_eq!(ids.len(), 2, "expected one event per registered material");
assert!(ids.contains(&"Brick".to_string()));
assert!(ids.contains(&"Stucco".to_string()));
}
#[test]
fn empty_store_no_op_no_events() {
let captured: Arc<Mutex<u32>> = Arc::new(Mutex::new(0));
let captured_obs = captured.clone();
let mut app = App::new();
app.add_plugins(MaterialMutationPlugin);
app.add_observer(move |_: On<MaterialTextureMutated>| {
*captured_obs.lock().unwrap() += 1;
});
app.world_mut().trigger(MutateMaterialsRequest {
seed: 123,
strength: 0.5,
});
app.update();
assert_eq!(
*captured.lock().unwrap(),
0,
"empty store must not emit any events"
);
}
}