diff --git a/Cargo.lock b/Cargo.lock index e0ed09d..3ffdf09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3475,6 +3475,13 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "dominium-core" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "double-ended-peekable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e347c8b..4097ab9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,6 +141,11 @@ members = [ "crates/modules/shuma/shuma-core", "crates/modules/shuma/shuma-intent", + # ============================================================ + # modules/dominium/ — Simulador psicológico de campo medio + # ============================================================ + "crates/modules/dominium/dominium-core", + # ============================================================ # modules/gioser/ — Landing WASM (chacana + 4 elementos) # ============================================================ diff --git a/crates/modules/dominium/dominium-core/Cargo.toml b/crates/modules/dominium/dominium-core/Cargo.toml new file mode 100644 index 0000000..59409ae --- /dev/null +++ b/crates/modules/dominium/dominium-core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "dominium-core" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "dominium — núcleo del simulador de campo medio: grilla SoA de 5 capas + Lemmings vectoriales + las 6 acciones atómicas. Sin deps gráficas." + +[dependencies] +serde = { workspace = true } diff --git a/crates/modules/dominium/dominium-core/src/grid.rs b/crates/modules/dominium/dominium-core/src/grid.rs new file mode 100644 index 0000000..59c1105 --- /dev/null +++ b/crates/modules/dominium/dominium-core/src/grid.rs @@ -0,0 +1,89 @@ +//! El Sustrato Plano — grilla SoA de 5 capas de `f32`. + +use serde::{Deserialize, Serialize}; + +/// Grilla de campos: 5 capas paralelas, cada una `width × height` `f32`, +/// indexadas `y * width + x`. Toda la física opera sobre estos arrays +/// contiguos (cache-friendly). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Grid { + pub width: usize, + pub height: usize, + /// Biomasa / energía / alimento disponible. + pub materia: Vec, + /// Densidad de información / frecuencia dogmática. + pub psique: Vec, + /// Tensión de control / deuda / atractores del Estado Profundo. + pub poder: Vec, + /// Materia prima densa intercambiable. + pub oro: Vec, + /// Contaminación / cicatrices industriales del suelo. + pub degradacion: Vec, +} + +impl Grid { + /// Grilla de `width × height` con todas las capas en cero. + pub fn new(width: usize, height: usize) -> Self { + let n = width * height; + Self { + width, + height, + materia: vec![0.0; n], + psique: vec![0.0; n], + poder: vec![0.0; n], + oro: vec![0.0; n], + degradacion: vec![0.0; n], + } + } + + /// Cantidad de celdas (`width * height`). + pub fn cells(&self) -> usize { + self.width * self.height + } + + /// Índice plano de `(x, y)`. El caller garantiza bounds válidos. + pub fn idx(&self, x: usize, y: usize) -> usize { + y * self.width + x + } + + /// `true` si `(x, y)` cae dentro de la grilla. + pub fn in_bounds(&self, x: i64, y: i64) -> bool { + x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height + } + + /// Clampa una coordenada continua a una celda válida. + pub fn clamp_cell(&self, x: f32, y: f32) -> (usize, usize) { + let cx = (x.floor() as i64).clamp(0, self.width as i64 - 1) as usize; + let cy = (y.floor() as i64).clamp(0, self.height as i64 - 1) as usize; + (cx, cy) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_grid_is_zeroed() { + let g = Grid::new(8, 4); + assert_eq!(g.cells(), 32); + assert!(g.materia.iter().all(|&v| v == 0.0)); + assert_eq!(g.materia.len(), 32); + } + + #[test] + fn idx_and_bounds() { + let g = Grid::new(10, 5); + assert_eq!(g.idx(3, 2), 23); + assert!(g.in_bounds(9, 4)); + assert!(!g.in_bounds(10, 4)); + assert!(!g.in_bounds(-1, 0)); + } + + #[test] + fn clamp_cell_keeps_in_range() { + let g = Grid::new(10, 10); + assert_eq!(g.clamp_cell(-5.0, 3.7), (0, 3)); + assert_eq!(g.clamp_cell(99.0, 99.0), (9, 9)); + } +} diff --git a/crates/modules/dominium/dominium-core/src/lemmings.rs b/crates/modules/dominium/dominium-core/src/lemmings.rs new file mode 100644 index 0000000..b2c14f7 --- /dev/null +++ b/crates/modules/dominium/dominium-core/src/lemmings.rs @@ -0,0 +1,119 @@ +//! Los Agentes Vectoriales — Lemmings en Structure-of-Arrays. +//! +//! Sin objetos ni punteros por agente: vectores paralelos indexados por +//! un `usize` continuo. Datos crudos alineados en caché. + +use serde::{Deserialize, Serialize}; + +/// Índices de las cuatro componentes de `vector_psi`. +pub const PSI_ORDEN: usize = 0; +pub const PSI_MIEDO: usize = 1; +pub const PSI_CURIOSIDAD: usize = 2; +pub const PSI_CORRUPTIBILIDAD: usize = 3; + +/// Población de Lemmings en SoA. Todos los vectores tienen el mismo largo. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Lemmings { + pub pos_x: Vec, + pub pos_y: Vec, + /// Contador incremental de ticks de vida. + pub edad: Vec, + /// Escalar de salud; si llega a 0 el agente muere. + pub energia: Vec, + /// Tensores de sesgo interno `[Orden, Miedo, Curiosidad, Corruptibilidad]`. + pub vector_psi: Vec<[f32; 4]>, + /// Byte discriminador de la máquina de estados (0-5). + pub accion: Vec, +} + +impl Lemmings { + pub fn new() -> Self { + Self::default() + } + + pub fn len(&self) -> usize { + self.pos_x.len() + } + + pub fn is_empty(&self) -> bool { + self.pos_x.is_empty() + } + + /// Instancia un Lemming nuevo (edad 0). Devuelve su índice. + pub fn spawn(&mut self, x: f32, y: f32, energia: f32, psi: [f32; 4]) -> usize { + let i = self.len(); + self.pos_x.push(x); + self.pos_y.push(y); + self.edad.push(0); + self.energia.push(energia); + self.vector_psi.push(psi); + self.accion.push(0); + i + } + + /// Elimina el Lemming `i` por `swap_remove` — O(1), no preserva el + /// orden (el último ocupa el hueco). + pub fn remove(&mut self, i: usize) { + self.pos_x.swap_remove(i); + self.pos_y.swap_remove(i); + self.edad.swap_remove(i); + self.energia.swap_remove(i); + self.vector_psi.swap_remove(i); + self.accion.swap_remove(i); + } + + /// Distancia euclidiana al cuadrado entre dos Lemmings (sin `sqrt` — + /// suficiente para comparar cercanía y bit-exacto). + pub fn dist2(&self, a: usize, b: usize) -> f32 { + let dx = self.pos_x[a] - self.pos_x[b]; + let dy = self.pos_y[a] - self.pos_y[b]; + dx * dx + dy * dy + } + + /// Índice del Lemming vivo más cercano a `i` (distinto de `i`), o + /// `None` si es el único. Determinista: ante empate gana el menor + /// índice. + pub fn nearest(&self, i: usize) -> Option { + let mut best: Option<(usize, f32)> = None; + for j in 0..self.len() { + if j == i { + continue; + } + let d = self.dist2(i, j); + if best.map(|(_, bd)| d < bd).unwrap_or(true) { + best = Some((j, d)); + } + } + best.map(|(j, _)| j) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spawn_and_remove() { + let mut l = Lemmings::new(); + let a = l.spawn(1.0, 1.0, 10.0, [0.0; 4]); + let _b = l.spawn(2.0, 2.0, 20.0, [0.0; 4]); + assert_eq!((a, l.len()), (0, 2)); + l.remove(a); + assert_eq!(l.len(), 1); + // swap_remove: el agente "b" ocupa el índice 0. + assert_eq!(l.energia[0], 20.0); + } + + #[test] + fn nearest_picks_closest_and_breaks_ties_by_index() { + let mut l = Lemmings::new(); + l.spawn(0.0, 0.0, 1.0, [0.0; 4]); // 0 + l.spawn(10.0, 0.0, 1.0, [0.0; 4]); // 1 — lejos + l.spawn(1.0, 0.0, 1.0, [0.0; 4]); // 2 — cerca de 0 + assert_eq!(l.nearest(0), Some(2)); + // Único agente → None. + let mut solo = Lemmings::new(); + solo.spawn(0.0, 0.0, 1.0, [0.0; 4]); + assert_eq!(solo.nearest(0), None); + } +} diff --git a/crates/modules/dominium/dominium-core/src/lib.rs b/crates/modules/dominium/dominium-core/src/lib.rs new file mode 100644 index 0000000..e36f601 --- /dev/null +++ b/crates/modules/dominium/dominium-core/src/lib.rs @@ -0,0 +1,27 @@ +//! `dominium-core` — el núcleo lógico del simulador de campo medio. +//! +//! Laboratorio de complejidad emergente: los agentes (Lemmings) no toman +//! decisiones cognitivas — reaccionan mecánicamente a los campos de una +//! grilla plana ejecutando una de 6 acciones atómicas fijas. Civilización, +//! guerra, fe y poder son patrones emergentes, no algoritmos. +//! +//! - [`grid`] — el Sustrato Plano: 5 capas SoA de `f32`. +//! - [`lemmings`] — los Agentes Vectoriales en Structure-of-Arrays. +//! - [`world`] — el `World` + las 6 acciones atómicas (`Action`). +//! - [`params`] — `SimParams`, las constantes que los sliders ajustan. +//! +//! Cero dependencias gráficas (regla inviolable de la spec): sólo `serde`. +//! La difusión/entropía/cinemática viven en `dominium-physics`; el +//! renderizado isométrico en `dominium-iso` + `dominium-render-plan`. + +#![forbid(unsafe_code)] + +pub mod grid; +pub mod lemmings; +pub mod params; +pub mod world; + +pub use grid::Grid; +pub use lemmings::Lemmings; +pub use params::SimParams; +pub use world::{Action, World}; diff --git a/crates/modules/dominium/dominium-core/src/params.rs b/crates/modules/dominium/dominium-core/src/params.rs new file mode 100644 index 0000000..c8c0e4b --- /dev/null +++ b/crates/modules/dominium/dominium-core/src/params.rs @@ -0,0 +1,54 @@ +//! Constantes globales de la simulación. +//! +//! Son las que los sliders del Panel de Control alimentan en vivo: cada +//! una sintoniza una de las ecuaciones del núcleo. + +use serde::{Deserialize, Serialize}; + +/// Parámetros que gobiernan las 6 acciones y el ciclo de vida. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimParams { + /// Velocidad de desplazamiento de `Mover` (celdas por tick). + pub move_speed: f32, + /// Energía que consume un paso de `Mover`. + pub move_cost: f32, + /// Cantidad extraída de la celda por `Extraer`. + pub extract_rate: f32, + /// Degradación añadida al suelo por cada `Extraer`. + pub degr_per_extract: f32, + /// Tasa de convergencia de `vector_psi` en `Sincronizar` (0-1). + pub sync_rate: f32, + /// Energía transferida por `Intercambiar`. + pub trade_amount: f32, + /// Umbral de energía para que `Replicar` dispare. + pub replicate_threshold: f32, + /// Fracción de la energía del padre que hereda el hijo en `Replicar`. + pub child_energy_frac: f32, + /// Daño de energía que inflige `Degradar`. + pub fight_damage: f32, + /// Fracción del daño que el atacante absorbe como energía. + pub absorb_frac: f32, + /// Umbral de energía bajo el cual el agente se fuerza a `Pelear`. + pub desperation_threshold: f32, + /// Edad máxima; al superarla el agente muere. + pub max_edad: u32, +} + +impl Default for SimParams { + fn default() -> Self { + Self { + move_speed: 1.0, + move_cost: 0.10, + extract_rate: 1.0, + degr_per_extract: 0.05, + sync_rate: 0.10, + trade_amount: 0.50, + replicate_threshold: 50.0, + child_energy_frac: 0.30, + fight_damage: 5.0, + absorb_frac: 0.50, + desperation_threshold: 5.0, + max_edad: 1000, + } + } +} diff --git a/crates/modules/dominium/dominium-core/src/world.rs b/crates/modules/dominium/dominium-core/src/world.rs new file mode 100644 index 0000000..8943d3e --- /dev/null +++ b/crates/modules/dominium/dominium-core/src/world.rs @@ -0,0 +1,232 @@ +//! El mundo: grilla + lemmings, y las 6 acciones atómicas fijas. +//! +//! Cualquier "profesión" o "rol" del macro es sólo un Lemming ejecutando +//! una de estas 6 acciones en un entorno específico. + +use crate::grid::Grid; +use crate::lemmings::{Lemmings, PSI_CORRUPTIBILIDAD, PSI_CURIOSIDAD, PSI_MIEDO, PSI_ORDEN}; +use crate::params::SimParams; +use serde::{Deserialize, Serialize}; + +/// Las 6 acciones atómicas. El byte `accion` del Lemming es uno de estos. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum Action { + /// Lee gradientes vecinos, se mueve hacia el óptimo, gasta energía. + Mover = 0, + /// Resta de la celda actual, suma a su energía, degrada el suelo. + Extraer = 1, + /// Acerca su `vector_psi` a los campos de la celda actual. + Sincronizar = 2, + /// Transfiere energía al vecino más cercano. + Intercambiar = 3, + /// Gasta energía para instanciar un Lemming hijo (edad 0). + Replicar = 4, + /// Resta energía al vecino más cercano y absorbe una fracción. + Degradar = 5, +} + +impl Action { + /// Convierte el byte discriminador. `None` si está fuera de rango. + pub fn from_u8(b: u8) -> Option { + match b { + 0 => Some(Action::Mover), + 1 => Some(Action::Extraer), + 2 => Some(Action::Sincronizar), + 3 => Some(Action::Intercambiar), + 4 => Some(Action::Replicar), + 5 => Some(Action::Degradar), + _ => None, + } + } +} + +/// El estado completo de la simulación. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct World { + pub grid: Grid, + pub lemmings: Lemmings, +} + +impl World { + pub fn new(width: usize, height: usize) -> Self { + Self { grid: Grid::new(width, height), lemmings: Lemmings::new() } + } + + /// Celda que ocupa el Lemming `i`. + fn cell_of(&self, i: usize) -> usize { + let (cx, cy) = self.grid.clamp_cell(self.lemmings.pos_x[i], self.lemmings.pos_y[i]); + self.grid.idx(cx, cy) + } + + /// 0 · Mover — gravedad mental hacia el vecino más afín al `vector_psi`. + pub fn act_mover(&mut self, i: usize, p: &SimParams) { + let (cx, cy) = + self.grid.clamp_cell(self.lemmings.pos_x[i], self.lemmings.pos_y[i]); + let psi = self.lemmings.vector_psi[i]; + let mut best_dir = (0.0f32, 0.0f32); + let mut best_score = f32::MIN; + for (dx, dy) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] { + let (nx, ny) = (cx as i64 + dx, cy as i64 + dy); + if !self.grid.in_bounds(nx, ny) { + continue; + } + let idx = self.grid.idx(nx as usize, ny as usize); + // Orden busca materia, Miedo evita poder, Curiosidad busca + // psique, Corruptibilidad busca oro. + let score = psi[PSI_ORDEN] * self.grid.materia[idx] + - psi[PSI_MIEDO] * self.grid.poder[idx] + + psi[PSI_CURIOSIDAD] * self.grid.psique[idx] + + psi[PSI_CORRUPTIBILIDAD] * self.grid.oro[idx]; + if score > best_score { + best_score = score; + best_dir = (dx as f32, dy as f32); + } + } + let w = self.grid.width as f32 - 1.0; + let h = self.grid.height as f32 - 1.0; + self.lemmings.pos_x[i] = + (self.lemmings.pos_x[i] + best_dir.0 * p.move_speed).clamp(0.0, w); + self.lemmings.pos_y[i] = + (self.lemmings.pos_y[i] + best_dir.1 * p.move_speed).clamp(0.0, h); + self.lemmings.energia[i] -= p.move_cost; + } + + /// 1 · Extraer — vacía materia de la celda hacia la energía del agente. + pub fn act_extraer(&mut self, i: usize, p: &SimParams) { + let idx = self.cell_of(i); + let taken = self.grid.materia[idx].min(p.extract_rate).max(0.0); + self.grid.materia[idx] -= taken; + self.lemmings.energia[i] += taken; + self.grid.degradacion[idx] += p.degr_per_extract; + } + + /// 2 · Sincronizar — el `vector_psi` deriva hacia los campos de la celda. + pub fn act_sincronizar(&mut self, i: usize, p: &SimParams) { + let idx = self.cell_of(i); + let targets = [ + self.grid.psique[idx], + self.grid.poder[idx], + self.grid.psique[idx], + self.grid.poder[idx], + ]; + for k in 0..4 { + let v = self.lemmings.vector_psi[i][k]; + self.lemmings.vector_psi[i][k] = v + (targets[k] - v) * p.sync_rate; + } + } + + /// 3 · Intercambiar — transfiere energía al vecino más cercano. + pub fn act_intercambiar(&mut self, i: usize, p: &SimParams) { + let Some(j) = self.lemmings.nearest(i) else { return }; + let amount = p.trade_amount.min(self.lemmings.energia[i]).max(0.0); + self.lemmings.energia[i] -= amount; + self.lemmings.energia[j] += amount; + } + + /// 4 · Replicar — instancia un hijo con edad 0 en las mismas coordenadas. + pub fn act_replicar(&mut self, i: usize, p: &SimParams) { + if self.lemmings.energia[i] <= p.replicate_threshold { + return; + } + let cost = self.lemmings.energia[i] * p.child_energy_frac; + self.lemmings.energia[i] -= cost; + let (x, y) = (self.lemmings.pos_x[i], self.lemmings.pos_y[i]); + let psi = self.lemmings.vector_psi[i]; + self.lemmings.spawn(x, y, cost, psi); + } + + /// 5 · Degradar (Pelear) — resta energía al vecino y absorbe parte. + pub fn act_degradar(&mut self, i: usize, p: &SimParams) { + let Some(j) = self.lemmings.nearest(i) else { return }; + let dmg = p.fight_damage.min(self.lemmings.energia[j]).max(0.0); + self.lemmings.energia[j] -= dmg; + self.lemmings.energia[i] += dmg * p.absorb_frac; + } + + /// Despacha la acción del Lemming `i` según su byte `accion`. + pub fn step_lemming(&mut self, i: usize, p: &SimParams) { + match Action::from_u8(self.lemmings.accion[i]) { + Some(Action::Mover) => self.act_mover(i, p), + Some(Action::Extraer) => self.act_extraer(i, p), + Some(Action::Sincronizar) => self.act_sincronizar(i, p), + Some(Action::Intercambiar) => self.act_intercambiar(i, p), + Some(Action::Replicar) => self.act_replicar(i, p), + Some(Action::Degradar) => self.act_degradar(i, p), + None => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn world_1x_lemming() -> (World, SimParams) { + let mut w = World::new(16, 16); + w.lemmings.spawn(8.0, 8.0, 100.0, [1.0, 0.0, 0.0, 0.0]); + (w, SimParams::default()) + } + + #[test] + fn action_from_u8_covers_0_to_5() { + for b in 0..=5u8 { + assert!(Action::from_u8(b).is_some()); + } + assert!(Action::from_u8(6).is_none()); + } + + #[test] + fn mover_heads_toward_higher_materia() { + let (mut w, p) = world_1x_lemming(); + // Materia alta a la derecha de (8,8). + let right = w.grid.idx(9, 8); + w.grid.materia[right] = 100.0; + let x0 = w.lemmings.pos_x[0]; + w.act_mover(0, &p); + assert!(w.lemmings.pos_x[0] > x0, "se movió hacia la materia"); + assert!(w.lemmings.energia[0] < 100.0, "Mover cuesta energía"); + } + + #[test] + fn extraer_drains_cell_into_agent_and_degrades() { + let (mut w, p) = world_1x_lemming(); + let idx = w.grid.idx(8, 8); + w.grid.materia[idx] = 10.0; + w.act_extraer(0, &p); + assert!(w.grid.materia[idx] < 10.0); + assert!(w.lemmings.energia[0] > 100.0); + assert!(w.grid.degradacion[idx] > 0.0); + } + + #[test] + fn replicar_spawns_child_and_costs_energy() { + let (mut w, p) = world_1x_lemming(); // energía 100 > umbral 50 + w.act_replicar(0, &p); + assert_eq!(w.lemmings.len(), 2); + assert_eq!(w.lemmings.edad[1], 0); + assert!(w.lemmings.energia[0] < 100.0); + } + + #[test] + fn degradar_drains_nearest_and_absorbs() { + let mut w = World::new(16, 16); + w.lemmings.spawn(8.0, 8.0, 50.0, [0.0; 4]); + w.lemmings.spawn(9.0, 8.0, 50.0, [0.0; 4]); + let p = SimParams::default(); + w.act_degradar(0, &p); + assert!(w.lemmings.energia[1] < 50.0, "la víctima pierde energía"); + assert!(w.lemmings.energia[0] > 50.0, "el atacante absorbe"); + } + + #[test] + fn intercambiar_conserves_total_energy() { + let mut w = World::new(16, 16); + w.lemmings.spawn(8.0, 8.0, 30.0, [0.0; 4]); + w.lemmings.spawn(9.0, 8.0, 30.0, [0.0; 4]); + let p = SimParams::default(); + w.act_intercambiar(0, &p); + let total = w.lemmings.energia[0] + w.lemmings.energia[1]; + assert!((total - 60.0).abs() < 1e-4, "la energía se conserva"); + } +}