feat(dominium): dominium-core — núcleo del simulador de campo medio
- grid — el Sustrato Plano: grilla SoA de 5 capas f32 (materia, psique,
poder, oro, degradación), indexada y*width+x.
- lemmings — Agentes Vectoriales en SoA: pos_x/y, edad, energia,
vector_psi [Orden,Miedo,Curiosidad,Corruptibilidad], accion u8.
spawn / swap_remove / nearest (determinista, empate por menor índice).
- world — World + las 6 acciones atómicas fijas: Mover (gravedad mental
hacia el vecino más afín al psi), Extraer, Sincronizar, Intercambiar,
Replicar, Degradar. step_lemming despacha por el byte accion.
- params — SimParams (las constantes que los sliders del panel ajustan).
Cero deps gráficas — sólo serde (regla inviolable de la spec).
11 tests verdes (acciones verificadas: Mover sigue la materia, Extraer
degrada, Replicar engendra, Intercambiar conserva energía, etc.).
cargo check --workspace verde.
Pendiente dominium: physics (difusión/entropía/cinemática), iso,
render-plan, canvas/panel GPUI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
d1727b1374fb0b48355a338f751bf943a41f3816 · by sergio · (5 months ago) · parent 191e6b06e · browse the tree at this commit
Semantic delta · 51
crates/modules/dominium/dominium-core/src/grid.rs
crates/modules/dominium/dominium-core/src/lemmings.rs
crates/modules/dominium/dominium-core/src/lib.rs
crates/modules/dominium/dominium-core/src/params.rs
crates/modules/dominium/dominium-core/src/world.rs
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<f32>,
+ /// Densidad de información / frecuencia dogmática.
+ pub psique: Vec<f32>,
+ /// Tensión de control / deuda / atractores del Estado Profundo.
+ pub poder: Vec<f32>,
+ /// Materia prima densa intercambiable.
+ pub oro: Vec<f32>,
+ /// Contaminación / cicatrices industriales del suelo.
+ pub degradacion: Vec<f32>,
+}
+
+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<f32>,
+ pub pos_y: Vec<f32>,
+ /// Contador incremental de ticks de vida.
+ pub edad: Vec<u32>,
+ /// Escalar de salud; si llega a 0 el agente muere.
+ pub energia: Vec<f32>,
+ /// 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<u8>,
+}
+
+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<usize> {
+ 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<Action> {
+ 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");
+ }
+}