feat: dominium standalone — simulador de campo medio sobre Llimphi

Front-door publicable de dominium: los 9 crates propios como path
members; Llimphi, app-bus, rimay-localize, wawa-config y pluma-notebook
por git-dep al monorepo tawasuyu.git (branch=main). cargo check
--workspace --all-targets pasa exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 23:22:40 +00:00
commit 1860b51f70
70 changed files with 19902 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
/target
**/*.rs.bk
*.pdb
@@ -0,0 +1,42 @@
[package]
name = "dominium-app-llimphi"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium-app-llimphi — la ventana viva del simulador de campo medio sobre Llimphi. Reemplazo del `dominium-app` GPUI: arma la cadena `core → physics → iso → render-plan → canvas-llimphi`, corre un loop de tick (~11 Hz) en un thread aparte que reentra al update vía `Handle::dispatch`, y compone canvas + panel de stats + controles play/pause/reseed."
[[bin]]
name = "dominium-app-llimphi"
path = "src/main.rs"
[dependencies]
dominium-canvas-llimphi = { path = "../dominium-canvas-llimphi" }
dominium-core = { path = "../dominium-core" }
dominium-iso = { path = "../dominium-iso" }
dominium-render-plan = { path = "../dominium-render-plan" }
dominium-sim = { path = "../dominium-sim" }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-button = { workspace = true }
llimphi-widget-slider = { workspace = true }
llimphi-widget-text-input = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-edit-menu = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
llimphi-motion = { workspace = true }
llimphi-clipboard = { workspace = true }
app-bus = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
directories = { workspace = true }
rimay-localize = { workspace = true }
wawa-config = { workspace = true }
wawa-config-llimphi = { workspace = true }
[dev-dependencies]
# Sólo para el example `pantallazo_dominium` (volcado headless a PNG).
pollster = { workspace = true }
png = { workspace = true }
@@ -0,0 +1,17 @@
# dominium-app-llimphi
> App de [dominium](../README.md): canvas + panel de control + loop 11 Hz.
Binario que monta el canvas ([`dominium-canvas-llimphi`](../dominium-canvas-llimphi/README.md)) + panel con sliders (seed, dt, brightness por capa, conceptos activos), play/pause/step, snapshot dump. Loop a 11 Hz por default (configurable). Carga/guarda config via `wawa-config-llimphi`.
## Uso
```sh
cargo run --release -p dominium-app-llimphi
```
## Deps
- [`dominium-core`](../dominium-core/README.md), [`dominium-physics`](../dominium-physics/README.md), [`dominium-canvas-llimphi`](../dominium-canvas-llimphi/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/)
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
@@ -0,0 +1,17 @@
# dominium-app-llimphi
> [dominium](../README.md) app: canvas + control panel + 11 Hz loop.
Binary that mounts the canvas ([`dominium-canvas-llimphi`](../dominium-canvas-llimphi/README.md)) + panel with sliders (seed, dt, brightness per layer, active concepts), play/pause/step, snapshot dump. Default loop at 11 Hz (configurable). Loads/saves config via `wawa-config-llimphi`.
## Usage
```sh
cargo run --release -p dominium-app-llimphi
```
## Deps
- [`dominium-core`](../dominium-core/README.md), [`dominium-physics`](../dominium-physics/README.md), [`dominium-canvas-llimphi`](../dominium-canvas-llimphi/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/)
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
@@ -0,0 +1,76 @@
{
"items": [
{
"id": "iglesia",
"sprite_id": 1,
"pos_x": 16.0,
"pos_y": 16.0,
"radius": 10.0,
"mods": { "materia": -0.05, "psique": 0.40, "poder": 0.10, "oro": 0.00 },
"hack": { "trigger": { "EnergiaBajo": 18.0 }, "forced_action": 2, "duration": 40 }
},
{
"id": "banco-central",
"sprite_id": 2,
"pos_x": 64.0,
"pos_y": 18.0,
"radius": 9.0,
"mods": { "materia": 0.00, "psique": -0.05, "poder": 0.25, "oro": -0.15 },
"hack": { "trigger": "Always", "forced_action": 1, "duration": 25 }
},
{
"id": "comuna",
"sprite_id": 3,
"pos_x": 18.0,
"pos_y": 60.0,
"radius": 12.0,
"mods": { "materia": 0.18, "psique": 0.05, "poder": -0.10, "oro": 0.00 },
"hack": { "trigger": { "EnergiaBajo": 10.0 }, "forced_action": 3, "duration": 30 }
},
{
"id": "laboratorio-rebelde",
"sprite_id": 4,
"pos_x": 64.0,
"pos_y": 62.0,
"radius": 9.0,
"mods": { "materia": -0.02, "psique": 0.25, "poder": -0.05, "oro": 0.00 },
"hack": { "trigger": { "EdadSobre": 80 }, "forced_action": 4, "duration": 15 }
},
{
"id": "imperio",
"sprite_id": 5,
"pos_x": 40.0,
"pos_y": 8.0,
"radius": 14.0,
"mods": { "materia": -0.08, "psique": -0.05, "poder": 0.45, "oro": 0.10 },
"hack": { "trigger": { "EdadSobre": 60 }, "forced_action": 5, "duration": 35 }
},
{
"id": "casino",
"sprite_id": 6,
"pos_x": 10.0,
"pos_y": 40.0,
"radius": 8.0,
"mods": { "materia": -0.04, "psique": 0.10, "poder": -0.02, "oro": 0.20 },
"hack": { "trigger": "Always", "forced_action": 1, "duration": 12 }
},
{
"id": "mercado",
"sprite_id": 7,
"pos_x": 40.0,
"pos_y": 40.0,
"radius": 11.0,
"mods": { "materia": 0.05, "psique": 0.02, "poder": 0.00, "oro": 0.06 },
"hack": { "trigger": "Always", "forced_action": 3, "duration": 18 }
},
{
"id": "templo-andino",
"sprite_id": 8,
"pos_x": 70.0,
"pos_y": 72.0,
"radius": 10.0,
"mods": { "materia": 0.06, "psique": 0.22, "poder": -0.02, "oro": 0.02 },
"hack": { "trigger": { "EnergiaBajo": 25.0 }, "forced_action": 2, "duration": 50 }
}
]
}
@@ -0,0 +1,703 @@
//! **Showreel** de `dominium` — el simulador determinista de campo medio.
//!
//! No es eye-candy abstracto: monta la **visualización real** de dominium —
//! la maqueta isométrica que produce `dominium-render-plan` y pinta
//! `dominium-canvas-llimphi::canvas_view` — alimentada por una simulación
//! **viva** de `dominium-physics` que avanza tick a tick a lo largo del
//! reel. Lo que ves en cada frame es una sociedad de lemmings fluyendo
//! sobre el sustrato numérico (5 capas), con los Conceptos (iglesia /
//! banco / comuna / laboratorio) emitiendo sus campos. El motor sólo suma
//! flotantes; la civilización emerge.
//!
//! Render **headless y determinista**: una simulación se siembra con seed
//! fijo y se avanza `N_TICKS_PRE` ticks; luego, frame `i` de `N` →
//! `t = i/(N-1)` → se elige el snapshot vivo correspondiente → se arma el
//! `RenderPlan` con la cámara/relieve del frame → vello → wgpu → PNG.
//!
//! Beats (rediseñados 2026-06-16 para que el reel TENGA MOVIMIENTO):
//! - **cold-open** (08%): trazo bezier draw-on + punto teal, sobre negro.
//! Breve — no debe dominar.
//! - **diorama** (6100%): la maqueta iso real corre TODO el resto del reel
//! con la cámara en movimiento CONTINUO: un **zoom-in** claro (la escala
//! iso casi se duplica) combinado con un **paneo** que recorre el
//! continente en arco (el nodo del canvas es más grande que el viewport y
//! se desliza). La simulación avanza varios ticks por frame para que
//! lemmings/réplicas/migración se muevan a ojo. La cámara es la fuente
//! principal de dinamismo: cada frame se ve distinto.
//! - **wordmark** (86100%): "dominium" + subtítulo, diorama en leve
//! fade-out detrás. (El viejo beat ψ de clústeres k-means se eliminó:
//! dejaba ~96% de lemmings en un clúster y el recoloreo era invisible —
//! un beat muerto. Mejor sacarlo que fingirlo.)
//!
//! ```text
//! cargo run -p dominium-app-llimphi --example dominium_showreel --release -- \
//! [out_dir] [n_frames] [W] [H]
//! ```
//! Defaults: `out_dir=showreel_frames_dominium`, `n_frames=300`, `W=1600`, `H=900`.
#![allow(dead_code)]
// La app es un binario sin lib: incluimos sus módulos reales por `#[path]`
// para usar exactamente el mismo `Sim`, colores y pack que la app.
#[path = "../src/consts.rs"]
mod consts;
#[path = "../src/model.rs"]
mod model;
#[path = "../src/packs.rs"]
mod packs;
#[path = "../src/sim.rs"]
mod sim;
#[path = "../src/view.rs"]
mod view;
#[path = "../src/worldgen.rs"]
mod worldgen;
use std::fs::{create_dir_all, File};
use std::io::BufWriter;
use dominium_core::{SimParams, World};
use dominium_iso::{IsoProjector, ZWeights};
use dominium_render_plan::{build_plan, PlanConfig, RenderMode, RenderPlan, SpritePrim};
use dominium_sim::Sim;
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy;
use llimphi_ui::llimphi_layout::taffy::prelude::{
length, percent, Position, Size, Style,
};
use llimphi_ui::llimphi_layout::taffy::Rect;
use llimphi_ui::llimphi_layout::LayoutTree;
use llimphi_ui::llimphi_raster::peniko::{self, Color, Gradient};
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter};
use llimphi_theme::motion;
use llimphi_ui::{measure_text_node, mount, paint, PaintRect, View};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, Stroke};
use dominium_canvas_llimphi::canvas_view;
use crate::consts::{KMEANS_REFRESH_TICKS, LEMMINGS, SNAPSHOT_RING_CAP, TRAIL_CAP};
use crate::packs::default_conceptos;
use crate::worldgen::bioma_palette;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Ticks que avanzamos ANTES de empezar a capturar — deja que la sociedad
/// arranque (réplicas, primeros asentamientos) antes del primer frame.
const N_TICKS_PRE: u64 = 8;
/// Ticks de simulación que avanzamos POR cada frame capturado. Con 1 tick
/// el cambio entre frames es imperceptible (el campo medio se mueve lento);
/// con varios, lemmings/réplicas/migración se mueven a ojo desnudo.
const TICKS_PER_FRAME: u64 = 4;
/// Grilla del reel — MÁS CHICA que la de la app (240). Cada celda emite un
/// techo + caras laterales: a 240×240 son ~150k polígonos y, a 1600×900, el
/// rasterizador por software del entorno wedgea y produce frames negros. Con
/// una grilla menor la maqueta es idéntica en carácter pero la escena pesa
/// una fracción, y el render sale vivo de punta a punta. (La app real sigue
/// usando 240; esto es sólo presentación del reel.)
const SHOW_GRID: usize = 120;
/// Color de acento (teal de marca tawasuyu).
const ACCENT: Color = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF);
// ───────────────────────── utilidades ─────────────────────────
fn with_alpha(c: Color, a: f32) -> Color {
let [r, g, b, _] = c.components;
Color::new([r, g, b, a.clamp(0.0, 1.0)])
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
/// Reescala `t` desde el subintervalo `[lo,hi]` a `[0,1]`, clampado.
fn seg(t: f32, lo: f32, hi: f32) -> f32 {
((t - lo) / (hi - lo)).clamp(0.0, 1.0)
}
// ───────────────────────── snapshots de simulación ─────────────────────────
/// Un fotograma vivo de la simulación: el `World` + las asignaciones de
/// clúster ψ vigentes (para el modo `PsiCluster`).
struct SimSnapshot {
world: World,
clusters: Vec<u8>,
}
/// Calibración idéntica a `Dominium::init` / `pantallazo_dominium`: la
/// población crece de forma controlada (réplica barata, regrowth limitado).
fn demo_params() -> SimParams {
SimParams {
diffusion_rate: 0.02,
entropy_rate: 0.004,
regrowth_rate: 0.004,
carrying_capacity: 40.0,
metabolic_cost: 0.05,
replicate_threshold: 28.0,
child_energy_frac: 0.45,
abundance_threshold: 50.0,
..SimParams::default()
}
}
/// Relieve visual por bioma (mares hunden, picos elevan) — calco de `init`.
fn demo_weights() -> ZWeights {
ZWeights {
materia: 0.02,
psique: -0.075,
poder: 0.40,
oro: 0.0,
degradacion: 1.30,
}
}
/// Corre la simulación real y captura `n` snapshots vivos (uno por frame),
/// avanzando un tick de `dominium-physics` por cada uno. Determinista: mismo
/// seed → misma película, bit a bit.
fn capture_snapshots(n: usize) -> Vec<SimSnapshot> {
let rng_seed = 0xD0_31_31_07_u64;
let seeder =
|s: u64| dominium_core::worldgen::seed(s, SHOW_GRID, LEMMINGS, default_conceptos());
let mut sim = Sim::new(
seeder(rng_seed),
demo_params(),
rng_seed,
SNAPSHOT_RING_CAP,
TRAIL_CAP,
KMEANS_REFRESH_TICKS,
true,
Box::new(seeder),
);
// Calentamiento: que la sociedad ya esté en marcha al primer frame.
for _ in 0..N_TICKS_PRE {
sim.advance(true);
}
let mut out = Vec::with_capacity(n);
for _ in 0..n {
// Varios ticks por frame: el movimiento de la sociedad (lemmings,
// réplicas, migración) se nota frame a frame, no sólo la cámara.
for _ in 0..TICKS_PER_FRAME {
sim.advance(false);
}
out.push(SimSnapshot {
world: sim.world.clone(),
clusters: sim.cluster_assignments.clone(),
});
}
out
}
// ───────────────────────── la escena por frame ─────────────────────────
/// Desplaza TODA la geometría del plan por `(dx, dy)` pero deja la caja
/// envolvente (`min/max`) intacta. `canvas_view` centra el plan según su
/// bbox, así que al mover la geometría sin mover el bbox la maqueta se
/// **panea** dentro del rect — la cámara recorre el continente. (Hacerlo
/// así, en vez de agrandar el nodo del canvas, evita un nodo gigante que en
/// el render headless por software dejaba el readback en frame congelado.)
fn pan_plan(mut plan: RenderPlan, dx: f32, dy: f32) -> RenderPlan {
for q in &mut plan.quads {
q.x += dx;
q.y += dy;
}
for p in &mut plan.polygons {
for v in &mut p.vertices {
v.0 += dx;
v.1 += dy;
}
}
for g in &mut plan.glyphs {
g.x += dx;
g.y += dy;
}
for s in &mut plan.sprites {
match s {
SpritePrim::Fill { points, .. } | SpritePrim::Stroke { points, .. } => {
for pt in points {
pt.0 += dx;
pt.1 += dy;
}
}
SpritePrim::Disc { cx, cy, .. } => {
*cx += dx;
*cy += dy;
}
}
}
// bbox a propósito SIN tocar: el paneo nace de la diferencia entre el
// centro del bbox (donde canvas_view ancla) y la geometría ya movida.
plan
}
/// Recorta del plan toda la geometría que cae FUERA del viewport (con un
/// margen). Imprescindible a zoom alto: la maqueta de 240×240 emite ~150k
/// polígonos, pero acercada sólo una fracción es visible — pintar los 150k a
/// 1600×900 satura el rasterizador por software y wedgea el device. Culling
/// deja sólo lo on-screen y mantiene la escena liviana de punta a punta.
///
/// `canvas_view` ancla el centro del bbox en el centro del rect, así que la
/// posición en pantalla de un vértice es `vértice + (centro_rect centro_bbox)`.
/// El bbox se deja INTACTO (el culling no debe mover la cámara).
fn cull_plan(mut plan: RenderPlan, cw: f64, ch: f64, margin: f32) -> RenderPlan {
let bbox_cx = (plan.min_x + plan.max_x) * 0.5;
let bbox_cy = (plan.min_y + plan.max_y) * 0.5;
let off_x = cw as f32 * 0.5 - bbox_cx;
let off_y = ch as f32 * 0.5 - bbox_cy;
let lo_x = -margin;
let lo_y = -margin;
let hi_x = cw as f32 + margin;
let hi_y = ch as f32 + margin;
let on_screen = |x: f32, y: f32, w: f32, h: f32| -> bool {
let sx = x + off_x;
let sy = y + off_y;
sx + w >= lo_x && sx <= hi_x && sy + h >= lo_y && sy <= hi_y
};
plan.quads.retain(|q| on_screen(q.x, q.y, q.w, q.h));
plan.polygons.retain(|p| {
let (mut nx, mut ny, mut xx, mut xy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
for (vx, vy) in p.vertices {
nx = nx.min(vx);
ny = ny.min(vy);
xx = xx.max(vx);
xy = xy.max(vy);
}
on_screen(nx, ny, xx - nx, xy - ny)
});
plan
}
/// Arma el `RenderPlan` de un snapshot con la escala iso del frame `t`. El
/// zoom-in del reel se logra ramplando `iso.scale` (la cámara se acerca).
fn plan_for(snap: &SimSnapshot, weights: &ZWeights, scale: f32) -> RenderPlan {
let iso = IsoProjector::new(scale, 0.55);
let cfg = PlanConfig {
tile: scale,
lemming_size: 2.6,
lemming_lift: 0.6,
concepto_size: 7.5,
concepto_lift: 2.0,
light_dir: (0.55, 0.35),
andina_layers: 0,
andina_threshold: 1.0,
palette: bioma_palette(),
render_mode: RenderMode::Composite,
texture: false,
};
build_plan(&snap.world, &iso, weights, &cfg)
}
/// Overlays vector (cold-open + wordmark + punto firma) sobre un nodo
/// full-screen, en función de `t`.
fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64) {
// ── COLD OPEN (0–8%) ───────────────────────────────────────────
let b1 = seg(t, 0.0, 0.05);
let line_vis = 1.0 - seg(t, 0.05, 0.08);
if line_vis > 0.001 {
let cx = cw / 2.0;
let cy = ch / 2.0;
let mut path = BezPath::new();
path.move_to((cx - 360.0, cy + 40.0));
let c1 = (cx - 150.0, cy - 220.0);
let c2 = (cx + 150.0, cy + 220.0);
let p3 = (cx + 360.0, cy - 40.0);
let cb = vello::kurbo::CubicBez::new(
Point::new(cx - 360.0, cy + 40.0),
Point::new(c1.0, c1.1),
Point::new(c2.0, c2.1),
Point::new(p3.0, p3.1),
);
use vello::kurbo::ParamCurve;
let draw_on = motion::ease_out_cubic(seg(t, 0.01, 0.055)) as f64;
let mut trimmed = BezPath::new();
let mut head = cb.p0;
trimmed.move_to(cb.p0);
let steps = 96;
for k in 1..=steps {
let u = (k as f64 / steps as f64) * draw_on;
let pt = cb.eval(u);
trimmed.line_to(pt);
head = pt;
}
let line_col = with_alpha(ACCENT, 0.9 * line_vis);
scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, line_col, None, &trimmed);
let pop = motion::ease_out_back(b1) as f64;
let r = (4.0_f64 + 7.0 * pop).max(0.0);
let dot_a = (b1 * line_vis).clamp(0.0, 1.0);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(ACCENT, 0.18 * dot_a),
None,
&Circle::new(head, r * 3.2),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(ACCENT, dot_a),
None,
&Circle::new(head, r),
);
}
// ── WORDMARK (86100%) ─────────────────────────────────────────
let word_in = seg(t, 0.86, 0.95);
let word_a = motion::ease_out_cubic(word_in);
if word_a > 0.001 {
let size = 136.0_f32;
let layout = ts.layout(
"dominium", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false,
);
let m = measurement(&layout);
let rise = lerp(26.0, 0.0, word_a as f64);
let ox = (cw - m.width as f64) / 2.0;
let oy = (ch - m.height as f64) / 2.0 - 22.0 + rise;
let brush = peniko::Brush::Solid(with_alpha(Color::from_rgba8(0xF2, 0xF4, 0xF3, 0xFF), word_a));
draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
let sub_a = motion::ease_out_cubic(seg(t, 0.90, 0.99));
if sub_a > 0.001 {
let ssz = 25.0_f32;
let sub = ts.layout(
"un simulador donde la civilización emerge de la aritmética",
ssz, None, Alignment::Start, 1.0, false, None, 400.0, false, false,
);
let sm = measurement(&sub);
let dot_r = 6.0;
let block_w = sm.width as f64 + dot_r * 2.0 + 14.0;
let sx = (cw - block_w) / 2.0;
let sy = oy + m.height as f64 + 20.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(ACCENT, sub_a),
None,
&Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r),
);
let sbrush = peniko::Brush::Solid(with_alpha(Color::from_rgba8(0x9A, 0xA3, 0xA0, 0xFF), sub_a));
draw_layout_brush_xf(scene, &sub, &sbrush, Affine::translate((sx + dot_r * 2.0 + 14.0, sy)));
}
}
// ── punto teal de firma (esquina inf-der) ───────────────────────
let corner_a = seg(t, 0.04, 0.09) * (1.0 - seg(t, 0.84, 0.90));
if corner_a > 0.001 {
let cx = cw - 54.0;
let cy = ch - 54.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(ACCENT, 0.16 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 18.0),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(ACCENT, 0.9 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 6.0),
);
}
}
/// Construye el árbol `View` del frame `t`.
fn build_view(
t: f32,
cw: f64,
ch: f64,
snaps: &[SimSnapshot],
weights: &ZWeights,
bg: Color,
) -> View<()> {
// Snapshot vivo: el diorama se ve a partir de ~6%; antes es el cold-open
// sobre negro. Mapeamos el tramo [0.06, 1.0] de t al índice de snapshot
// para que la simulación corra durante todo el reel.
let diorama_t = seg(t, 0.06, 1.0);
let idx = ((diorama_t * (snaps.len() as f32 - 1.0)).round() as usize).min(snaps.len() - 1);
let snap = &snaps[idx];
// Entrada del diorama: fade-in rápido (614%) y leve fade-out bajo el
// wordmark (no a negro — la maqueta sigue viva detrás del título).
let in_a = motion::ease_out_cubic(seg(t, 0.06, 0.14));
let out_a = 1.0 - 0.55 * motion::ease_in_out_cubic(seg(t, 0.86, 0.97)) as f32;
let diorama_a = (in_a * out_a).clamp(0.0, 1.0) as f64;
// ── CÁMARA: zoom-in continuo + paneo a lo largo de TODO el reel.
// CLAVE: la velocidad de cámara debe ser ~constante (lineal), NO un
// ease-in-out — un ease-in-out concentra todo el movimiento en los bordes
// y deja un PLATEAU muerto en el medio (frames idénticos). Acá `cam`
// avanza lineal con `t`, así CADA frame difiere del anterior por igual.
let cam = seg(t, 0.06, 1.0) as f64;
// Zoom: acercamiento parejo y perceptible (lineal). La grilla del reel es
// 120 (la mitad lineal de la app), así que la escala arranca alta para
// que el continente llene el cuadro y casi se duplica hacia el primer
// plano. Con culling la escena se mantiene liviana en todo el rango.
let scale = lerp(6.0, 11.0, cam) as f32;
// Paneo: desplazamos la geometría del plan (sin tocar su bbox), así
// `canvas_view` —que ancla en el centro del bbox— deja la maqueta corrida
// dentro del rect. Recorrido en arco diagonal (avance lineal en X +
// curva en Y) para que la cámara cruce el continente revelando regiones
// distintas a medida que el zoom aprieta. El nodo sigue siendo del tamaño
// del viewport (estable en el render headless).
const PAN_AMP_X: f64 = 620.0;
const PAN_AMP_Y: f64 = 360.0;
// X: barrido lineal izquierda→derecha (velocidad constante).
let pan_x = lerp(PAN_AMP_X, -PAN_AMP_X, cam);
// Y: avance lineal arriba→abajo MÁS un arco (sin) — sobrevuelo, no recta;
// siempre en movimiento.
let arc = (std::f64::consts::PI * cam).sin();
let pan_y = lerp(PAN_AMP_Y, -PAN_AMP_Y, cam) + arc * PAN_AMP_Y * 0.5;
let mut children: Vec<View<()>> = Vec::new();
if diorama_a > 0.001 {
let plan = pan_plan(plan_for(snap, weights, scale), pan_x as f32, pan_y as f32);
// Cull a viewport: a zoom alto recorta el grueso de los ~150k
// polígonos fuera de cuadro y mantiene la escena liviana (clave para
// que el GPU por software no wedgee a pantalla completa).
let plan = cull_plan(plan, cw, ch, 64.0);
let canvas = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
right: length(0.0),
bottom: length(0.0),
},
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.alpha(diorama_a as f32)
.children(vec![canvas_view::<()>(plan, None)]);
children.push(canvas);
}
// Viñeta sutil para asentar el diorama sobre el fondo negro.
let vignette = {
let [r, g, b, _] = bg.components;
let edge = Color::new([r, g, b, 0.0]);
let dark = Color::new([r * 0.3, g * 0.3, b * 0.3, 0.55]);
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
right: length(0.0),
bottom: length(0.0),
},
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.paint_with(move |scene, _ts, rect: PaintRect| {
let cx = (rect.x + rect.w * 0.5) as f64;
let cy = (rect.y + rect.h * 0.5) as f64;
let radius = (rect.w.max(rect.h)) as f64 * 0.75;
let grad = Gradient::new_radial(Point::new(cx, cy), radius as f32)
.with_stops([edge, edge, dark].as_slice());
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
&grad,
None,
&vello::kurbo::Rect::new(
rect.x as f64,
rect.y as f64,
(rect.x + rect.w) as f64,
(rect.y + rect.h) as f64,
),
);
})
};
children.push(vignette);
// Overlay vector full-screen (cold-open + wordmark).
let overlay = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
right: length(0.0),
bottom: length(0.0),
},
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.paint_with(move |scene, ts, _rect: PaintRect| {
draw_overlays(scene, ts, t, cw, ch);
});
children.push(overlay);
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
position: Position::Relative,
overflow: taffy::Point {
x: taffy::Overflow::Hidden,
y: taffy::Overflow::Hidden,
},
..Default::default()
})
.clip(true)
.fill(bg)
.children(children)
}
fn main() {
rimay_localize::init();
let mut args = std::env::args().skip(1);
let out_dir = args
.next()
.unwrap_or_else(|| "showreel_frames_dominium".to_string());
let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(300);
let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
// Ventana de frames a renderar [start, end) — para chunkear el render en
// varios procesos (el GPU por software del entorno wedgea tras ~18 frames
// pesados en un mismo device; un proceso por chunk lo sortea). El `t` se
// computa siempre contra `n`, así la ventana es un subconjunto del reel
// completo, no un reel recortado. Default: todo.
let start: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(0);
let end: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(n).min(n);
create_dir_all(&out_dir).expect("mkdir out_dir");
// Fondo: un negro azulado profundo, espacio negativo elegante.
let bg = Color::from_rgba8(0x07, 0x09, 0x0B, 0xFF);
let weights = demo_weights();
eprintln!("dominium_showreel: sembrando simulación y capturando {n} snapshots vivos…");
let snaps = capture_snapshots(n);
eprintln!(
"dominium_showreel: pob inicial {} → final {} lemmings",
snaps.first().map(|s| s.world.lemmings.len()).unwrap_or(0),
snaps.last().map(|s| s.world.lemmings.len()).unwrap_or(0),
);
// GPU: un device por proceso. El cuello de botella real del render
// headless por software (llvmpipe) NO es el zoom sino el VOLUMEN de
// geometría: la grilla de la app (240) emite ~150k polígonos y a 1600×900
// satura el rasterizador hasta dejar frames negros/congelados. Por eso el
// reel usa `SHOW_GRID` (120) + culling a viewport: con la escena liviana,
// los 300 frames salen vivos en un solo proceso. Los args `start`/`end`
// quedan disponibles por si hiciera falta chunkear en otro entorno.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = make_target(&hal, w, h);
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let mut ts = Typesetter::new();
let cw = w as f64;
let ch = h as f64;
let [br, bgc, bb, _] = bg.components;
let base = Color::from_rgba8((br * 255.0) as u8, (bgc * 255.0) as u8, (bb * 255.0) as u8, 255);
for i in start..end {
let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
let root = build_view(t, cw, ch, &snaps, &weights, bg);
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
renderer
.render_to_view(&hal, &scene, &view, w, h, base)
.expect("render_to_view");
// Bloqueo explícito: que el trabajo de vello (compute + blit) termine
// ANTES de copiar la textura. Sin esto, en el GPU por software del
// entorno headless, escenas pesadas (zoom alto = polígonos grandes a
// pantalla completa) dejaban el readback en frame congelado a partir
// de cierto punto. Drenar la cola entre render y copia lo evita.
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
let path = format!("{out_dir}/frame_{i:04}.png");
write_png(&hal, &target, &path, w, h);
if i % 30 == 0 || i == n - 1 {
eprintln!("dominium_showreel: frame {}/{} (t={:.3})", i + 1, n, t);
}
}
eprintln!("dominium_showreel: {n} frames en {out_dir}/ ({w}x{h})");
}
/// Crea la textura destino del render (reusada dentro de un bloque de device).
fn make_target(hal: &Hal, w: u32, h: u32) -> wgpu::Texture {
hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("dominium-showreel"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * h as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for r in 0..h as usize {
let s = r * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wr = enc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,357 @@
//! Pantallazo headless de `dominium-app-llimphi` — el simulador de campo
//! medio sobre Llimphi.
//!
//! Monta la **view real** de la app (menubar, status bar, banda de
//! onboarding, canvas isométrico y panel lateral con el tab Mundo) con una
//! simulación sembrada de verdad: el mismo `Sim` que usa la app, mundo
//! 240×240 con biomas procedurales, 2500 lemmings y el pack de Conceptos
//! por defecto (iglesia / banco / comuna / laboratorio…), avanzado unos
//! cuantos ticks de `dominium-physics` para que el lienzo muestre una
//! sociedad viva (población, acciones y métricas ψ reales en el panel).
//!
//! Pinta a una textura wgpu sin ventana y vuelca PNG (mismo patrón que
//! `agora-app/examples/pantallazo_agora.rs`).
//!
//! `cargo run -p dominium-app-llimphi --example pantallazo_dominium --release -- [out.png]`
#![allow(dead_code)]
// La app es un crate binario sin lib: incluimos sus módulos reales por
// `#[path]` para llamar exactamente las mismas vistas que pinta la app.
#[path = "../src/consts.rs"]
mod consts;
#[path = "../src/model.rs"]
mod model;
#[path = "../src/packs.rs"]
mod packs;
#[path = "../src/sim.rs"]
mod sim;
#[path = "../src/view.rs"]
mod view;
#[path = "../src/worldgen.rs"]
mod worldgen;
use std::fs::File;
use std::io::BufWriter;
use dominium_core::{PsiMetrics, SimParams, WorldStats};
use dominium_iso::{IsoProjector, ZWeights};
use dominium_render_plan::{build_plan_with_overrides, PlanConfig, RenderMode};
use dominium_sim::Sim;
use llimphi_motion::Tween;
use llimphi_theme::Theme;
use llimphi_ui::llimphi_hal::{wgpu, Hal};
use llimphi_ui::llimphi_layout::taffy;
use llimphi_ui::llimphi_layout::taffy::prelude::{
length, percent, Dimension, FlexDirection, Size, Style,
};
use llimphi_ui::llimphi_layout::LayoutTree;
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_raster::{vello, Renderer};
use llimphi_ui::llimphi_text::Typesetter;
use llimphi_ui::{measure_text_node, mount, paint, View};
use llimphi_widget_menubar::{menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
use llimphi_widget_text_input::TextInputState;
use crate::consts::{GRID, KMEANS_REFRESH_TICKS, LEMMINGS, SNAPSHOT_RING_CAP, TICK_MS, TRAIL_CAP};
use crate::model::{Model, Msg, PanelTab};
use crate::packs::default_conceptos;
use crate::sim::lemming_color_for;
use crate::view::{canvas_pane, onboarding_bar, side_panel, status_bar};
use crate::worldgen::bioma_palette;
const W: u32 = 1600;
const H: u32 = 1000;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// Cuántos ticks de física avanzamos antes del pantallazo. Con la
/// calibración de `init` la población **crece** (2500 → ~6300 en 15 ticks):
/// hay actividad real (réplicas, extracciones, contadores de acciones) sin
/// reventar el presupuesto de vello — el plan ya trae 57 600 celdas de
/// terreno, y por encima de ~7000 lemmings extra el raster GPU desborda sus
/// buffers internos y devuelve un frame vacío (verificado empíricamente:
/// 20 ticks ≈ 7100 lemmings → PNG en blanco).
const TICKS_SEMBRADOS: u64 = 15;
/// Construye el `Model` demo: el mismo estado que `Dominium::init`, pero
/// con seeder determinista (pack embebido, sin leer el pack del usuario) y
/// sin watcher de wawa-config — el pantallazo debe ser reproducible.
fn modelo_demo() -> Model {
// Calibración idéntica a `init` (src/main.rs): drenaje basal modesto,
// réplica barata, regrowth limitado por la carga de la llanura.
let params = SimParams {
diffusion_rate: 0.02,
entropy_rate: 0.004,
regrowth_rate: 0.004,
carrying_capacity: 40.0,
metabolic_cost: 0.05,
replicate_threshold: 28.0,
child_energy_frac: 0.45,
abundance_threshold: 50.0,
..SimParams::default()
};
// Relieve por bioma (mares hunden, picos elevan) — calco de `init`.
let weights = ZWeights {
materia: 0.02,
psique: -0.075,
poder: 0.40,
oro: 0.0,
degradacion: 1.30,
};
// Seeder determinista: mismo `worldgen::seed` del core que usa la app,
// pero siempre con el pack embebido (el de `~/.config` cambiaría el
// pantallazo según la máquina).
let rng_seed = 0xD0_31_31_07;
let seeder = |s: u64| dominium_core::worldgen::seed(s, GRID, LEMMINGS, default_conceptos());
let mut sim = Sim::new(
seeder(rng_seed),
params,
rng_seed,
SNAPSHOT_RING_CAP,
TRAIL_CAP,
KMEANS_REFRESH_TICKS,
true,
Box::new(seeder),
);
// Avanzamos la simulación de verdad: cada `advance` es un tick completo
// de `dominium-physics` (mover/extraer/sincronizar/replicar/degradar…),
// así el canvas y las métricas del panel muestran una sociedad viva.
for _ in 0..TICKS_SEMBRADOS {
sim.advance(false);
}
Model {
sim,
// Misma cámara que la app: scale 3.0 px/celda, z_factor 0.55. En el
// lienzo de 1600×1000 la maqueta iso 240×240 entra completa.
iso: IsoProjector::new(3.0, 0.55),
weights,
cfg: PlanConfig {
tile: 3.0,
lemming_size: 2.6,
lemming_lift: 0.6,
concepto_size: 7.0,
concepto_lift: 2.0,
light_dir: (0.55, 0.35),
andina_layers: 0,
andina_threshold: 1.0,
palette: bioma_palette(),
render_mode: RenderMode::Composite,
texture: false,
},
selected: None,
sync_relieve: false,
id_input: TextInputState::new(),
id_input_focused: false,
scenario_idx: 0,
show_trails: false,
theme: Theme::dark(),
_wawa_watcher: None,
panel_tab: PanelTab::Mundo,
// `false` → la app muestra la banda de onboarding (primer arranque).
onboarding_done: false,
menu_open: None,
menu_active: usize::MAX,
menu_anim: Tween::idle(1.0),
edit_menu: None,
edit_active: usize::MAX,
edit_anim: Tween::idle(1.0),
clipboard: llimphi_clipboard::SystemClipboard::new(),
}
}
/// Barra de menú con los mismos menús raíz que la app (`app_menu` en
/// src/main.rs). Cerrados en el pantallazo, así que sólo se ven los rótulos.
fn menu_demo() -> app_bus::AppMenu {
use app_bus::{AppMenu, Menu, MenuItem};
AppMenu::new()
.menu(Menu::new("Archivo").item(MenuItem::new("Cargar pack de usuario", "file.loadpack")))
.menu(Menu::new("Editar").item(MenuItem::new("Renombrar concepto…", "concepto.rename")))
.menu(Menu::new("Simulación").item(MenuItem::new("Pausar", "sim.toggleplay")))
.menu(Menu::new("Ver").item(MenuItem::new("Ciclar modo de render", "view.rendermode")))
.menu(Menu::new("Ayuda").item(MenuItem::new("Mostrar guía de uso", "help.onboarding")))
}
/// Misma composición que `Dominium::view` (src/main.rs): menubar + status
/// bar + banda de onboarding + fila canvas|panel. Sólo se omiten los
/// handlers de click/drag del canvas — acá nadie interactúa.
fn view_demo(model: &Model, menu: &app_bus::AppMenu, theme: &Theme) -> View<Msg> {
let shown = model.sim.displayed_world();
let stats = WorldStats::from_world(shown);
let psi_metrics = PsiMetrics::from_world(shown);
let status = status_bar(model, theme);
let plan = build_plan_with_overrides(shown, &model.iso, &model.weights, &model.cfg, |i| {
lemming_color_for(model, i)
});
let canvas = canvas_pane(plan);
let side = side_panel(model, &stats, &psi_metrics, theme);
let body = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: Dimension::auto(),
},
flex_grow: 1.0,
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(vec![canvas, side]);
let menubar = menubar_view(&MenuBarSpec {
menu,
open: model.menu_open,
theme,
viewport: (W as f32, H as f32),
height: MENU_H,
on_open: std::sync::Arc::new(Msg::MenuOpen),
on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
});
let mut frame: Vec<View<Msg>> = vec![menubar, status];
if !model.onboarding_done {
frame.push(onboarding_bar(theme));
}
frame.push(body);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.fill(theme.bg_app)
.children(frame)
}
fn main() {
rimay_localize::init();
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "/tmp/shots/dominium.png".to_string());
if let Some(dir) = std::path::Path::new(&out).parent() {
std::fs::create_dir_all(dir).ok();
}
let theme = Theme::dark();
let model = modelo_demo();
eprintln!(
"pantallazo_dominium: mundo {GRID}×{GRID} · pob {} · tick {} (cada tick = {TICK_MS} ms en la app)",
model.sim.world.lemmings.len(),
model.sim.tick,
);
let menu = menu_demo();
let root = view_demo(&model, &menu, &theme);
// view → layout → scene (misma secuencia que el eventloop real).
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let mut ts = Typesetter::new();
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (W as f32, H as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("pantallazo-dominium"),
size: wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, _] = theme.bg_app.components;
let bg = Color::from_rgba8((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255);
renderer
.render_to_view(&hal, &scene, &view, W, H, bg)
.expect("render_to_view");
write_png(&hal, &target, &out);
eprintln!("pantallazo_dominium: escrito {out} ({W}x{H})");
}
/// Lee la textura a CPU y la vuelca como PNG RGBA8.
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
let unpadded = (W * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * H as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(H),
},
},
wgpu::Extent3d {
width: W,
height: H,
depth_or_array_layers: 1,
},
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((W * H * 4) as usize);
for row in 0..H as usize {
let s = row * padded;
pixels.extend_from_slice(&data[s..s + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), W, H);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().unwrap();
w.write_image_data(&pixels).unwrap();
}
@@ -0,0 +1,58 @@
{
"items": [
{
"id": "templo-andino",
"sprite_id": 8,
"pos_x": 40.0,
"pos_y": 12.0,
"radius": 14.0,
"mods": { "materia": 0.10, "psique": 0.35, "poder": -0.05, "oro": 0.02 },
"hack": { "trigger": { "EnergiaBajo": 22.0 }, "forced_action": 2, "duration": 60 }
},
{
"id": "ayllu-norte",
"sprite_id": 3,
"pos_x": 16.0,
"pos_y": 28.0,
"radius": 14.0,
"mods": { "materia": 0.25, "psique": 0.08, "poder": -0.12, "oro": 0.00 },
"hack": { "trigger": { "EnergiaBajo": 14.0 }, "forced_action": 3, "duration": 35 }
},
{
"id": "ayllu-sur",
"sprite_id": 3,
"pos_x": 62.0,
"pos_y": 56.0,
"radius": 14.0,
"mods": { "materia": 0.25, "psique": 0.08, "poder": -0.12, "oro": 0.00 },
"hack": { "trigger": { "EnergiaBajo": 14.0 }, "forced_action": 3, "duration": 35 }
},
{
"id": "mina-de-altura",
"sprite_id": 7,
"pos_x": 12.0,
"pos_y": 68.0,
"radius": 8.0,
"mods": { "materia": -0.10, "psique": -0.03, "poder": 0.05, "oro": 0.18 },
"hack": { "trigger": "Always", "forced_action": 1, "duration": 30 }
},
{
"id": "tambo",
"sprite_id": 5,
"pos_x": 40.0,
"pos_y": 44.0,
"radius": 9.0,
"mods": { "materia": 0.08, "psique": 0.05, "poder": 0.02, "oro": 0.05 },
"hack": { "trigger": "Always", "forced_action": 3, "duration": 22 }
},
{
"id": "ayllu-este",
"sprite_id": 3,
"pos_x": 68.0,
"pos_y": 22.0,
"radius": 12.0,
"mods": { "materia": 0.22, "psique": 0.06, "poder": -0.10, "oro": 0.00 },
"hack": { "trigger": { "EnergiaBajo": 14.0 }, "forced_action": 3, "duration": 35 }
}
]
}
@@ -0,0 +1,76 @@
{
"items": [
{
"id": "reserva-federal",
"sprite_id": 2,
"pos_x": 40.0,
"pos_y": 16.0,
"radius": 18.0,
"mods": { "materia": 0.00, "psique": -0.06, "poder": 0.40, "oro": -0.20 },
"hack": { "trigger": "Always", "forced_action": 1, "duration": 30 }
},
{
"id": "broker-norte",
"sprite_id": 2,
"pos_x": 64.0,
"pos_y": 24.0,
"radius": 9.0,
"mods": { "materia": -0.04, "psique": 0.05, "poder": 0.10, "oro": 0.15 },
"hack": { "trigger": "Always", "forced_action": 1, "duration": 20 }
},
{
"id": "broker-sur",
"sprite_id": 2,
"pos_x": 16.0,
"pos_y": 56.0,
"radius": 9.0,
"mods": { "materia": -0.04, "psique": 0.05, "poder": 0.10, "oro": 0.15 },
"hack": { "trigger": "Always", "forced_action": 1, "duration": 20 }
},
{
"id": "casino-vip",
"sprite_id": 6,
"pos_x": 16.0,
"pos_y": 32.0,
"radius": 8.0,
"mods": { "materia": -0.06, "psique": 0.12, "poder": -0.02, "oro": 0.25 },
"hack": { "trigger": "Always", "forced_action": 1, "duration": 14 }
},
{
"id": "mall",
"sprite_id": 7,
"pos_x": 28.0,
"pos_y": 56.0,
"radius": 13.0,
"mods": { "materia": 0.04, "psique": 0.10, "poder": 0.02, "oro": 0.10 },
"hack": { "trigger": "Always", "forced_action": 3, "duration": 14 }
},
{
"id": "agencia-de-prensa",
"sprite_id": 4,
"pos_x": 56.0,
"pos_y": 56.0,
"radius": 13.0,
"mods": { "materia": -0.02, "psique": 0.35, "poder": 0.05, "oro": 0.00 },
"hack": { "trigger": { "EdadSobre": 40 }, "forced_action": 2, "duration": 25 }
},
{
"id": "barriada",
"sprite_id": 3,
"pos_x": 12.0,
"pos_y": 68.0,
"radius": 11.0,
"mods": { "materia": 0.10, "psique": -0.05, "poder": -0.15, "oro": -0.05 },
"hack": { "trigger": { "EnergiaBajo": 8.0 }, "forced_action": 3, "duration": 25 }
},
{
"id": "barriada-norte",
"sprite_id": 3,
"pos_x": 70.0,
"pos_y": 8.0,
"radius": 10.0,
"mods": { "materia": 0.10, "psique": -0.05, "poder": -0.15, "oro": -0.05 },
"hack": { "trigger": { "EnergiaBajo": 8.0 }, "forced_action": 3, "duration": 25 }
}
]
}
@@ -0,0 +1,58 @@
{
"items": [
{
"id": "zigurat",
"sprite_id": 1,
"pos_x": 36.0,
"pos_y": 24.0,
"radius": 12.0,
"mods": { "materia": -0.04, "psique": 0.32, "poder": 0.18, "oro": 0.00 },
"hack": { "trigger": { "EnergiaBajo": 20.0 }, "forced_action": 2, "duration": 45 }
},
{
"id": "palacio",
"sprite_id": 5,
"pos_x": 52.0,
"pos_y": 20.0,
"radius": 11.0,
"mods": { "materia": -0.06, "psique": -0.02, "poder": 0.38, "oro": 0.08 },
"hack": { "trigger": { "EdadSobre": 50 }, "forced_action": 5, "duration": 25 }
},
{
"id": "bazar",
"sprite_id": 7,
"pos_x": 28.0,
"pos_y": 48.0,
"radius": 12.0,
"mods": { "materia": 0.06, "psique": 0.04, "poder": 0.00, "oro": 0.10 },
"hack": { "trigger": "Always", "forced_action": 3, "duration": 18 }
},
{
"id": "campo-de-cebada",
"sprite_id": 3,
"pos_x": 16.0,
"pos_y": 64.0,
"radius": 14.0,
"mods": { "materia": 0.22, "psique": 0.02, "poder": -0.06, "oro": 0.00 },
"hack": null
},
{
"id": "casa-de-tablillas",
"sprite_id": 4,
"pos_x": 62.0,
"pos_y": 60.0,
"radius": 9.0,
"mods": { "materia": -0.02, "psique": 0.18, "poder": 0.10, "oro": 0.02 },
"hack": { "trigger": { "EdadSobre": 70 }, "forced_action": 2, "duration": 30 }
},
{
"id": "irrigacion",
"sprite_id": 3,
"pos_x": 48.0,
"pos_y": 50.0,
"radius": 10.0,
"mods": { "materia": 0.16, "psique": 0.00, "poder": -0.02, "oro": 0.00 },
"hack": null
}
]
}
@@ -0,0 +1,30 @@
//! Constantes de mundo y de bucle compartidas por todos los módulos de la app.
/// Lado de la grilla cuadrada del mundo. 240×240 = 57 600 celdas: continente
/// con varios biomas (mares, ríos, llanuras, sierras, picos). El motor sigue
/// siendo O(grid) en difusión y O(N²) en `nearest`, así que la población
/// arranca en miles pero limitada por los frenos termodinámicos de
/// `init`-time (ver `SimParams` override).
pub(crate) const GRID: usize = 240;
/// Población inicial de Lemmings. Miles. La densidad efectiva queda más
/// baja que en la versión 80² histórica (≈0.043 lem/celda) porque sólo
/// spawnean en tierra navegable y el motor ya no permite el crecimiento
/// exponencial sin freno.
pub(crate) const LEMMINGS: usize = 2500;
/// Periodo del bucle de simulación (~11 Hz).
pub(crate) const TICK_MS: u64 = 90;
/// Cada cuántos ticks recalculamos k-means para colorear los clusters
/// (modo PsiCluster). 30 ticks ≈ 2.7s — suficiente para ver tribus
/// emergentes sin que el costo del kmeans (O(K·N·iter)) note.
pub(crate) const KMEANS_REFRESH_TICKS: u64 = 30;
/// Ancho del panel de stats.
pub(crate) const SIDE_WIDTH: f32 = 240.0;
/// Tamaño del ring de snapshots: ~18 segundos a 11 Hz. Permite ver hacia
/// atrás un par de minutos de simulación sin pasarse en RAM (cada snapshot
/// es un `World` clonado; con grid 40×40 y ~50 lemmings, ~30 KB).
pub(crate) const SNAPSHOT_RING_CAP: usize = 200;
/// Largo del trail por lemming. Tradeoff: muy alto y la pantalla se llena
/// de motas; muy bajo y el rastro no cuenta nada. 24 a 11 Hz ≈ 2 s de
/// historia visible — coincide con el horizonte que el ojo integra.
pub(crate) const TRAIL_CAP: usize = 24;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,312 @@
//! Modelo de la app y mensajes del bucle Elm. La conducta vive en
//! `update`/`view` (en `main.rs`); acá sólo los datos y los enums de edición.
use dominium_iso::{IsoProjector, ZWeights};
use dominium_sim::Sim;
use dominium_render_plan::PlanConfig;
use llimphi_theme::Theme;
use llimphi_clipboard::SystemClipboard;
use llimphi_ui::KeyEvent;
use llimphi_widget_edit_menu::EditAction;
use llimphi_widget_text_input::TextInputState;
pub(crate) struct Model {
/// Sesión de simulación (estado de dominio + reloj + historia): `world`,
/// `params`, `tick/epoch/rng_seed`, ring de snapshots (rewind), trails y
/// clusters. El ciclo de vida (`advance`/`reseed`/…) vive en `Sim`
/// (`dominium-sim`); el `Model` sólo guarda estado de vista.
pub(crate) sim: Sim,
pub(crate) iso: IsoProjector,
pub(crate) weights: ZWeights,
pub(crate) cfg: PlanConfig,
/// Índice del Concepto seleccionado, si alguno. `None` cuando no hay
/// selección. Si se "Limpia" la lista se resetea a `None`.
pub(crate) selected: Option<usize>,
/// Cuando está activo, editar `ZWeights` (relieve visual) también
/// escribe a `params.relieve` (relieve físico) — lo que ves es lo
/// que sienten los lemmings.
pub(crate) sync_relieve: bool,
/// Buffer de texto del input de renombre. `id_input_focused` decide
/// si el panel muestra el text-input o el label estático.
pub(crate) id_input: TextInputState,
pub(crate) id_input_focused: bool,
/// Índice del scenario actual en `scenario_packs()`. El picker del panel
/// lo cicla; "Sembrar pack" instala el JSON correspondiente.
pub(crate) scenario_idx: usize,
/// Toggle para mostrar las trayectorias.
pub(crate) show_trails: bool,
/// Theme efectivo. Se construye en init desde `wawa-config` (con
/// fallback a `Theme::dark()` si no hay archivo aún) y se rearma
/// en cada `Msg::WawaConfigChanged`.
pub(crate) theme: Theme,
/// Subscripción al bus de configuración del SO. `Option` porque
/// la creación puede fallar en plataformas sin ProjectDirs.
/// Se mantiene viva mientras vive el `Model`.
pub(crate) _wawa_watcher: Option<wawa_config::ConfigWatcher>,
/// Cuál tab del panel lateral está activo. La UI muestra los grupos
/// relevantes según esta selección — el modelo es simple, sin lazy load.
pub(crate) panel_tab: PanelTab,
/// Si el usuario ya entendió las gestures de canvas (click crea, drag
/// mueve, segundo click selecciona). Cuando es `false` la app muestra
/// un hint flotante sobre el canvas. Se apaga al primer click.
pub(crate) onboarding_done: bool,
/// Barra de menú principal: índice del menú raíz abierto (`None` cerrado).
pub(crate) menu_open: Option<usize>,
/// Fila resaltada por teclado en el menú principal (`usize::MAX` = ninguna).
pub(crate) menu_active: usize,
/// Animación de aparición/swap del dropdown del menú principal (0→1).
pub(crate) menu_anim: llimphi_motion::Tween<f32>,
/// Menú de edición contextual: ancla `(x, y)` en ventana (`None` cerrado).
/// Opera sobre el editor del campo de texto focuseado (`id_input`).
pub(crate) edit_menu: Option<(f32, f32)>,
/// Fila resaltada por teclado en el menú de edición (`usize::MAX` = ninguna).
pub(crate) edit_active: usize,
/// Animación de aparición del menú de edición (0→1).
pub(crate) edit_anim: llimphi_motion::Tween<f32>,
/// Clipboard del sistema, compartido por el menú de edición y el
/// text-input de renombre.
pub(crate) clipboard: SystemClipboard,
}
/// Pestañas del panel lateral. El orden es el orden visual en el tab bar.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PanelTab {
Mundo,
Conceptos,
Psique,
Vista,
}
impl PanelTab {
pub(crate) fn label(self) -> &'static str {
match self {
PanelTab::Mundo => "Mundo",
PanelTab::Conceptos => "Conceptos",
PanelTab::Psique => "ψ",
PanelTab::Vista => "Vista",
}
}
pub(crate) fn all() -> [PanelTab; 4] {
[
PanelTab::Mundo,
PanelTab::Conceptos,
PanelTab::Psique,
PanelTab::Vista,
]
}
}
/// Una de las cuatro capas modificables de un `Concepto` (degradacion
/// queda fuera — es cicatriz emergente, no editable).
#[derive(Clone, Copy, Debug)]
pub(crate) enum Layer {
Materia,
Psique,
Poder,
Oro,
}
/// Slot de `SimParams` editable desde el panel. Los 4 más visibles más los
/// dos del ciclo estacional; los demás quedan al default.
#[derive(Clone, Copy, Debug)]
pub(crate) enum ParamSlot {
ClimbCost,
DiffusionRate,
EntropyRate,
MoveCost,
SeasonPeriod,
SeasonAmplitude,
/// Intensidad con la que el psi modula los efectos de las acciones.
PsiModulation,
/// Radio social del contagio (Fase B).
SocialRadius,
/// Tasa de convergencia del contagio social.
ContagionRate,
/// Umbral de homofilia (Fase B.2) — 0 = sin filtro.
HomophilyThreshold,
// — Economía: los levers termodinámicos del flujo de energía. Deciden
// si una población crece, se estabiliza o colapsa. Hasta ahora
// hardcoded; expuestos para que el escenario guardado los capture.
/// Cantidad de materia que `Extraer` drena del suelo por acción.
ExtractRate,
/// Energía transferida por `Intercambiar`.
TradeAmount,
/// Fracción del espacio libre que la naturaleza repuebla por tick.
RegrowthRate,
/// Asíntota del regrowth: techo de materia por celda.
CarryingCapacity,
/// Drenaje basal de energía por tick a todo lemming vivo.
MetabolicCost,
/// Umbral de energía para que `Replicar` dispare.
ReplicateThreshold,
/// Umbral de abundancia por encima del cual el agente se fuerza a
/// `Replicar` (0 = desactiva la transición).
AbundanceThreshold,
// — Cinética fina: los escalares de cada acción atómica y del ciclo de
// vida. Más quirúrgicos que la economía; tunean la "sensación" del
// motor sin redefinir su balance macro.
/// Celdas por tick que avanza `Mover`.
MoveSpeed,
/// Tasa de convergencia del `vector_psi` en `Sincronizar` (0-1).
SyncRate,
/// Degradación añadida al suelo por cada `Extraer`.
DegrPerExtract,
/// Fracción de la energía del padre que hereda el hijo en `Replicar`.
ChildEnergyFrac,
/// Daño de energía que inflige `Degradar`.
FightDamage,
/// Fracción del daño que el atacante absorbe como energía.
AbsorbFrac,
/// Umbral de energía bajo el cual el agente se fuerza a `Degradar`.
DesperationThreshold,
/// Edad máxima; al superarla el agente muere (entero, en ticks).
MaxEdad,
}
impl ParamSlot {
pub(crate) fn range(self) -> (f32, f32) {
match self {
ParamSlot::ClimbCost => (0.0, 0.5),
ParamSlot::DiffusionRate => (0.0, 0.5),
ParamSlot::EntropyRate => (0.0, 0.05),
ParamSlot::MoveCost => (0.0, 0.5),
// 0 = sin estaciones; hasta 500 ticks por ciclo (≈45 s a 11 Hz).
ParamSlot::SeasonPeriod => (0.0, 500.0),
ParamSlot::SeasonAmplitude => (0.0, 1.0),
// Psi modulation: rango [0, 1] de uso típico; > 1 amplifica
// demasiado y rompe calibraciones del default.
ParamSlot::PsiModulation => (0.0, 1.0),
// Radio social — hasta media diagonal del grid 80×80.
ParamSlot::SocialRadius => (0.0, 30.0),
// Tasa de contagio: > 0.5 produce conformismo brutal en pocos
// ticks; típicos 0.05..0.20.
ParamSlot::ContagionRate => (0.0, 0.5),
// Homofilia 0..2 — > sqrt(4) = 2 incluye todo el psi space.
ParamSlot::HomophilyThreshold => (0.0, 2.0),
// Economía — rangos calibrados alrededor de los defaults del
// motor (ver `SimParams::default` / overrides de init).
ParamSlot::ExtractRate => (0.0, 6.0),
ParamSlot::TradeAmount => (0.0, 5.0),
ParamSlot::RegrowthRate => (0.0, 0.1),
ParamSlot::CarryingCapacity => (0.0, 100.0),
ParamSlot::MetabolicCost => (0.0, 0.5),
ParamSlot::ReplicateThreshold => (0.0, 100.0),
ParamSlot::AbundanceThreshold => (0.0, 150.0),
// Cinética fina — rangos alrededor de los defaults del motor.
ParamSlot::MoveSpeed => (0.0, 4.0),
ParamSlot::SyncRate => (0.0, 1.0),
ParamSlot::DegrPerExtract => (0.0, 0.2),
ParamSlot::ChildEnergyFrac => (0.0, 1.0),
ParamSlot::FightDamage => (0.0, 20.0),
ParamSlot::AbsorbFrac => (0.0, 1.0),
ParamSlot::DesperationThreshold => (0.0, 30.0),
// Edad máxima en ticks — 0 = inmortal (cuidado: sin cosecha por
// vejez la población sólo cae por desesperación/metabolismo).
ParamSlot::MaxEdad => (0.0, 20000.0),
}
}
}
/// Capa de `ZWeights` editable desde el panel — define el **relieve
/// visual** (cuánto eleva cada capa el render). Independiente del
/// `relieve` físico de `SimParams`.
#[derive(Clone, Copy, Debug)]
pub(crate) enum ZSlot {
Materia,
Psique,
Poder,
Oro,
Degradacion,
}
#[derive(Clone)]
pub(crate) enum Msg {
Tick,
TogglePlay,
Reseed,
LimpiarConceptos,
SembrarConceptos,
SelectConcepto(usize),
DeselectConcepto,
EditMod(Layer, f32),
EditRadius(f32),
DeleteSelected,
EditParam(ParamSlot, f32),
EditZWeight(ZSlot, f32),
GuardarPack,
CargarPack,
CrearConcepto,
/// Click sobre el canvas, en coords de mundo. Si cae sobre un
/// Concepto existente lo selecciona; si no, crea uno nuevo ahí.
CanvasClick(f32, f32),
ToggleSyncRelieve,
ToggleAndina,
// Editor de BehaviorHack del Concepto seleccionado.
HackToggle, // agrega o quita el hack.
HackCycleTrigger, // rota Always → EnergiaBajo → EdadSobre → Always.
HackCycleAction, // rota la acción forzada 0..5 → 0...
HackEditTriggerParam(f32),
HackEditDuration(f32),
CycleSprite,
/// Delta de un Move dentro de un drag activo, en coords de mundo.
/// Mueve el Concepto seleccionado si hay uno.
CanvasDragMove(f32, f32),
FocusIdInput,
BlurIdInput,
IdInputKey(KeyEvent),
/// Cicla al siguiente scenario embebido. Sólo cambia la selección
/// (no lo aplica hasta que se toque "Cargar scenario").
CycleScenario,
/// Reemplaza los conceptos del mundo con el scenario actualmente
/// seleccionado. Limpia hack_locks vivos y deselecciona.
LoadScenario,
/// Cicla `cfg.render_mode`: Composite → Heatmap(Materia) → … →
/// Heatmap(Degradacion) → Composite.
CycleRenderMode,
/// Toggle de visualización de trayectorias.
ToggleTrails,
/// Toggle de texturización procedural sobre los techos.
ToggleTexture,
/// Delta sobre `rewind_offset` (positivo = más atrás; negativo = hacia
/// el presente). El slider del panel emite estos deltas; un botón
/// "vivo" emite `RewindHome`.
RewindBy(f32),
/// Vuelve `rewind_offset` a 0 (presente).
RewindHome,
/// El bus `wawa-config` publicó una versión nueva. Aplicamos
/// theme y locale; los demás campos no nos competen.
WawaConfigChanged(Box<wawa_config::WawaConfig>),
/// Alterna `big_five` en SimParams. Si la población vino sin columna
/// `psi5` (saves Big Four), la rellenamos al pasar a Big5.
ToggleBigFive,
/// Cicla `ActionPolicy` entre Fixed y PsiArgmax. Con periodo 0 nunca
/// re-elige, así que también arrancamos un período sano la primera vez.
CyclePsiPolicy,
/// Cambia el tab activo del panel lateral.
SelectTab(PanelTab),
/// Cierra el hint flotante de onboarding (se cierra solo en el primer
/// click sobre el canvas, pero también hay una X visible).
DismissOnboarding,
/// Barra de menú principal: abrir/cerrar un menú raíz (`None` = cerrar).
MenuOpen(Option<usize>),
/// Comando elegido en el menú principal — se traduce al `Msg` real.
MenuCommand(String),
/// Navegación por teclado en el menú principal (`+1` baja, `-1` sube).
MenuNav(i32),
/// Enter en el menú principal: ejecuta la fila activa.
MenuActivate,
/// Tick de animación de menús (sólo re-render).
MenuTick,
/// Navegación por teclado en el menú de edición.
EditNav(i32),
/// Enter en el menú de edición: ejecuta la fila activa.
EditActivate,
/// Right-click en la ventana → abre el menú de edición en `(x, y)`,
/// operando sobre el campo de texto focuseado (`id_input`).
EditMenuOpen(f32, f32),
/// Acción elegida en el menú de edición (sobre `id_input`).
EditMenuAction(EditAction),
/// Cierra cualquier menú abierto (click-fuera / Esc).
CloseMenus,
}
@@ -0,0 +1,136 @@
//! Packs de Conceptos: scenarios embebidos en el binario y persistencia del
//! pack del usuario en `$XDG_CONFIG_HOME/dominium/pack.json`.
use std::path::PathBuf;
use dominium_core::{Conceptos, SimParams};
use dominium_iso::ZWeights;
use serde::{Deserialize, Serialize};
/// Pack JSON por defecto — iglesia / banco / comuna / laboratorio + variantes.
/// Embebido para que el binario corra sin archivos sueltos en cwd.
pub(crate) const DEFAULT_PACK: &str = include_str!("../conceptos.default.json");
/// Scenarios embebidos: civilizaciones-arquetipo. Cada uno es un JSON con
/// la misma forma que el `DEFAULT_PACK`; el picker del panel cicla entre
/// ellos sin necesidad de archivos sueltos.
pub(crate) const PACK_ANDES: &str = include_str!("../packs/andes.json");
pub(crate) const PACK_MESOPOTAMIA: &str = include_str!("../packs/mesopotamia.json");
pub(crate) const PACK_CAPITALISMO: &str = include_str!("../packs/capitalismo.json");
/// Parsea el pack JSON embebido. Si el JSON está malformado el binario
/// arranca con la colección vacía — la sim corre igual.
pub(crate) fn default_conceptos() -> Conceptos {
serde_json::from_str::<Conceptos>(DEFAULT_PACK).unwrap_or_default()
}
/// Listado ordenado de packs embebidos disponibles en el picker. El primero
/// es el default; el ciclo es circular. Tupla `(id legible, JSON raw)`.
pub(crate) fn scenario_packs() -> [(&'static str, &'static str); 4] {
[
("default", DEFAULT_PACK),
("andes", PACK_ANDES),
("mesopotamia", PACK_MESOPOTAMIA),
("capitalismo", PACK_CAPITALISMO),
]
}
/// Path absoluto al pack del usuario: `$XDG_CONFIG_HOME/dominium/pack.json`
/// (típicamente `~/.config/dominium/pack.json`). `None` si la plataforma
/// no expone un config dir.
pub(crate) fn user_pack_path() -> Option<PathBuf> {
directories::ProjectDirs::from("", "", "dominium")
.map(|d| d.config_dir().join("pack.json"))
}
/// Escenario completo serializable: la termodinámica del motor
/// (`params`), el relieve visual (`weights`) y los Conceptos del mundo, en
/// un solo archivo reproducible. Es el "pack" en su forma rica — guardar y
/// cargar restituye el mundo *y su sintonía*, no sólo las fichas.
///
/// `params` y `weights` son `Option` para que un pack viejo (sólo
/// `Conceptos`) cargue sin ellos y la app conserve su sintonía actual. La
/// retrocompatibilidad la garantiza [`parse_escenario`], no el `#[serde]`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Escenario {
/// Sintonía del motor. `None` = no tocar los `SimParams` vigentes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub params: Option<SimParams>,
/// Relieve visual (`ZWeights`). `None` = no tocar el relieve vigente.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weights: Option<ZWeights>,
/// Las fichas del mundo. Siempre presente (puede ir vacío).
#[serde(default)]
pub conceptos: Conceptos,
}
/// Parsea un JSON de pack tolerando los dos formatos:
/// 1. **Escenario rico** `{ "params": …, "weights": …, "conceptos": {…} }`.
/// 2. **Pack histórico** `{ "items": [ … ] }` — sólo `Conceptos`, sin
/// sintonía; se envuelve en un `Escenario` con `params`/`weights` a
/// `None`.
///
/// El discriminante es la clave `conceptos`: si está, es formato rico; si
/// no, se intenta como `Conceptos` plano.
fn parse_escenario(raw: &str) -> Option<Escenario> {
if raw.contains("\"conceptos\"") {
match serde_json::from_str::<Escenario>(raw) {
Ok(esc) => return Some(esc),
Err(e) => eprintln!("dominium · escenario malformado: {e}"),
}
}
match serde_json::from_str::<Conceptos>(raw) {
Ok(conceptos) => Some(Escenario {
params: None,
weights: None,
conceptos,
}),
Err(e) => {
eprintln!("dominium · pack corrupto: {e}");
None
}
}
}
/// Escribe el escenario completo (sintonía + relieve + Conceptos) al pack
/// del usuario. Crea el directorio padre si no existe. Errores van a
/// stderr (la app no muere).
pub(crate) fn save_user_escenario(params: &SimParams, weights: &ZWeights, cs: &Conceptos) {
let Some(path) = user_pack_path() else {
eprintln!("dominium · no hay ProjectDirs en esta plataforma");
return;
};
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("dominium · no pude crear {}: {e}", parent.display());
return;
}
}
let esc = Escenario {
params: Some(params.clone()),
weights: Some(*weights),
conceptos: cs.clone(),
};
match serde_json::to_string_pretty(&esc) {
Ok(json) => match std::fs::write(&path, json) {
Ok(()) => eprintln!("dominium · escenario guardado en {}", path.display()),
Err(e) => eprintln!("dominium · error escribiendo {}: {e}", path.display()),
},
Err(e) => eprintln!("dominium · error serializando escenario: {e}"),
}
}
/// Carga el escenario del usuario si existe. Devuelve `None` si el archivo
/// no está, o si el contenido no parsea por ninguno de los dos formatos.
pub(crate) fn load_user_escenario() -> Option<Escenario> {
let path = user_pack_path()?;
let raw = std::fs::read_to_string(&path).ok()?;
let esc = parse_escenario(&raw)?;
eprintln!("dominium · escenario cargado desde {}", path.display());
Some(esc)
}
/// Carga sólo los Conceptos del pack del usuario — vista estrecha sobre
/// [`load_user_escenario`] para el seeder del mundo (que no toca params).
pub(crate) fn load_user_pack() -> Option<Conceptos> {
load_user_escenario().map(|e| e.conceptos)
}
@@ -0,0 +1,132 @@
//! Helpers de mutación/render que tocan estado de VISTA además del dominio.
//! El ciclo de vida de la simulación (avance, reseed, snapshots, trails,
//! clusters) vive en `dominium_sim::Sim` (regla #2); acá quedan los que
//! cruzan con `selected`, `cfg`, `iso` u otros campos del frontend.
use dominium_core::{Concepto, LayerMods};
use dominium_render_plan::{Color, Quad, RenderMode, RenderPlan};
use crate::consts::GRID;
use crate::model::Model;
/// Accede mutable al Concepto seleccionado, si lo hay.
pub(crate) fn selected_mut(m: &mut Model) -> Option<&mut Concepto> {
let i = m.selected?;
m.sim.world.conceptos.items.get_mut(i)
}
/// Agrega un Concepto en `(x, y)` (clamp al grid), lo nombra
/// `nuevo-N` y queda seleccionado para edición inmediata.
pub(crate) fn spawn_concepto_at(m: &mut Model, x: f32, y: f32) {
let max = (GRID as f32) - 1.0;
let n = m.sim.world.conceptos.len();
let new = Concepto {
id: format!("nuevo-{}", n + 1),
sprite_id: 0,
pos_x: x.clamp(0.0, max),
pos_y: y.clamp(0.0, max),
radius: 4.0,
mods: LayerMods::default(),
hack: None,
persuasion: None,
};
let i = m.sim.world.conceptos.add(new);
m.selected = Some(i);
}
/// Copia `ZWeights` (relieve visual) al array `[f32; 5]` que SimParams
/// usa como relieve físico, manteniendo el orden de capas del `Grid`.
pub(crate) fn mirror_zweights_to_relieve(
z: &dominium_iso::ZWeights,
relieve: &mut [f32; 5],
) {
relieve[dominium_core::RELIEVE_MATERIA] = z.materia;
relieve[dominium_core::RELIEVE_PSIQUE] = z.psique;
relieve[dominium_core::RELIEVE_PODER] = z.poder;
relieve[dominium_core::RELIEVE_ORO] = z.oro;
relieve[dominium_core::RELIEVE_DEGRADACION] = z.degradacion;
}
/// Tres colores fijos del paleta de clusters — orden de aparición en el
/// resultado de `kmeans_psi`. Magenta / cian / amarillo: los más fáciles de
/// distinguir sobre cualquier fondo de bioma.
pub(crate) const CLUSTER_COLORS: [Color; 3] = [
[0.96, 0.30, 0.72, 1.0], // magenta
[0.30, 0.90, 0.90, 1.0], // cian
[0.96, 0.92, 0.30, 1.0], // amarillo
];
/// Color para el lemming `i` según el `RenderMode` actual y las
/// asignaciones de cluster vigentes. Se usa como override de
/// `build_plan_with_overrides`.
pub(crate) fn lemming_color_for(m: &Model, i: usize) -> Color {
if matches!(m.cfg.render_mode, RenderMode::PsiCluster)
&& i < m.sim.cluster_assignments.len()
{
let c = m.sim.cluster_assignments[i] as usize;
if c < CLUSTER_COLORS.len() {
return CLUSTER_COLORS[c];
}
}
m.cfg.palette.lemming
}
/// Pinta las posiciones históricas de los lemmings como quads diminutos
/// con alpha decreciente — los más viejos casi transparentes. Va después
/// del `build_plan` para que los trails queden por encima del suelo pero
/// por debajo del HUD; depth pequeño constante negativo para no romper el
/// orden de pintor de las celdas.
///
/// Se llama sólo en vivo (no en rewind), porque en rewind el `World` que
/// se renderiza no necesariamente tiene los mismos índices de lemming que
/// el frame de trails — y mezclarlos confundiría al ojo más que ayudar.
pub(crate) fn overlay_trails(plan: &mut RenderPlan, m: &Model) {
let n_frames = m.sim.trails.len();
if n_frames == 0 {
return;
}
let lemming_color = m.cfg.palette.lemming;
// Tamaño de la moteta: la mitad del marker del lemming, así no compite
// visualmente con la posición actual.
let size = m.cfg.lemming_size * 0.45;
for (k, frame) in m.sim.trails.iter().enumerate() {
// k=0 es el más viejo → alpha bajo; k=n-1 el más nuevo → alpha alto.
// No incluyo el último frame: ya está pintado por el lemming actual.
if k + 1 == n_frames {
break;
}
let t = (k + 1) as f32 / n_frames as f32; // ∈ (0, 1)
let alpha = 0.10 + 0.40 * t;
let color: Color = [
lemming_color[0],
lemming_color[1],
lemming_color[2],
alpha,
];
for &(x, y) in frame {
let (sx, sy) = m.iso.project(x, y, m.cfg.lemming_lift * 0.5);
plan.quads.push(Quad {
x: sx - size * 0.5,
y: sy - size * 0.5,
w: size,
h: size,
color,
// Detrás de los Lemmings vivos (que pintan a depth ≈ x+y+0.5)
// pero delante de la celda (depth x+y).
depth: x + y + 0.25,
});
}
}
// Mantengo el plan ordenado: insert al final desordena. Re-ordeno por
// depth — coste O(N log N) pero N es del orden de 50·24 = 1200 quads.
plan.quads.sort_by(|a, b| {
a.depth.partial_cmp(&b.depth).unwrap_or(std::cmp::Ordering::Equal)
});
// Re-extender la bounding box por si los trails caen fuera.
for q in &plan.quads {
plan.min_x = plan.min_x.min(q.x);
plan.min_y = plan.min_y.min(q.y);
plan.max_x = plan.max_x.max(q.x + q.w);
plan.max_y = plan.max_y.max(q.y + q.h);
}
}
@@ -0,0 +1,992 @@
//! Todas las vistas de la app: status bar, onboarding, canvas pane, panel
//! lateral con sus cuatro tabs y los widgets de fila/slider reutilizados.
use dominium_canvas_llimphi::canvas_view;
use dominium_core::{Epoch, PsiMetrics, Trigger, WorldStats};
use dominium_render_plan::{Color, RenderMode};
use llimphi_theme::Theme;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
AlignItems, JustifyContent, Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{DragPhase, View};
use llimphi_widget_button::{button_view, ButtonPalette};
use llimphi_widget_slider::{slider_view, SliderPalette};
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
use crate::consts::{GRID, SIDE_WIDTH};
use crate::model::{Layer, Model, Msg, PanelTab, ParamSlot, ZSlot};
use crate::packs::scenario_packs;
use crate::sim::CLUSTER_COLORS;
/// Nombre humano de la acción atómica `0..5`.
fn action_name(b: u8) -> &'static str {
match b {
0 => "Mover",
1 => "Extraer",
2 => "Sincronizar",
3 => "Intercambiar",
4 => "Replicar",
5 => "Degradar",
_ => "?",
}
}
/// Descripción del trigger para mostrar en el panel.
fn trigger_label(t: Trigger) -> String {
match t {
Trigger::Always => "Always".to_string(),
Trigger::EnergiaBajo(v) => format!("EnergíaBajo({v:.0})"),
Trigger::EdadSobre(v) => format!("EdadSobre({v})"),
}
}
/// Banda informativa que cubre el ancho de la app y explica las tres
/// gestures básicas del canvas. Se muestra hasta que el usuario haga el
/// primer click (que también es la gesture más obvia). Tiene una X a la
/// derecha para cerrarla manualmente sin tocar el canvas.
pub(crate) fn onboarding_bar(theme: &Theme) -> View<Msg> {
let hint_text = "Click vacío → crea concepto · Click sobre uno → selecciona · Drag → mover · Tabs arriba a la derecha";
let label = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.text_aligned(hint_text, 11.5, theme.accent, Alignment::Start);
let close_btn = View::new(Style {
size: Size {
width: length(28.0_f32),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.children(vec![llimphi_widget_button::button_view::<Msg>(
"",
&ButtonPalette::from_theme(theme),
Msg::DismissOnboarding,
)]);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(28.0_f32),
},
align_items: Some(AlignItems::Center),
padding: Rect {
left: length(14.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![label, close_btn])
}
pub(crate) fn status_bar(model: &Model, theme: &Theme) -> View<Msg> {
let estado = rimay_localize::t(if model.sim.running {
"dominium-status-running"
} else {
"dominium-status-paused"
});
// Texto principal: tamaño · población · epoch · tick. El usuario lo
// ve siempre, sin importar el tab del panel.
let line = format!(
"{}×{} · pob {} · epoch {} · tick {}",
GRID,
GRID,
model.sim.world.lemmings.len(),
model.sim.epoch,
model.sim.tick,
);
let label_view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.text_aligned(line, 12.0, theme.fg_text, Alignment::Start);
let estado_view = View::new(Style {
size: Size {
width: length(120.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.text_aligned(estado, 12.0, theme.accent, Alignment::End);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(34.0_f32),
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::SpaceBetween),
padding: Rect {
left: length(14.0_f32),
right: length(14.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(vec![label_view, estado_view])
}
pub(crate) fn canvas_pane(plan: dominium_render_plan::RenderPlan) -> View<Msg> {
let canvas_bg = llimphi_ui::llimphi_raster::peniko::Color::from_rgba8(11, 13, 18, 255);
let canvas = canvas_view::<Msg>(plan, Some(canvas_bg));
View::new(Style {
size: Size {
width: Dimension::auto(),
height: percent(1.0_f32),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
min_size: Size {
width: length(0.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.clip(true)
.children(vec![canvas])
}
pub(crate) fn side_panel(
model: &Model,
stats: &WorldStats,
psi_metrics: &PsiMetrics,
theme: &Theme,
) -> View<Msg> {
let btn_palette = ButtonPalette::from_theme(theme);
let mut slider_palette = SliderPalette::from_theme(theme);
// Comprimimos los slots para que entren en el sidebar de 240 px.
slider_palette.label_width = 56.0;
slider_palette.track_width = 90.0;
slider_palette.value_width = 44.0;
let header = label_view(&rimay_localize::t("dominium-header-sim"), 11.0, theme.fg_muted);
let play_label = rimay_localize::t(if model.sim.running {
"dominium-btn-pause"
} else {
"dominium-btn-resume"
});
let play_btn = sized_button(&play_label, &btn_palette, Msg::TogglePlay);
let reset_btn = sized_button(
&rimay_localize::t("dominium-btn-reseed"),
&btn_palette,
Msg::Reseed,
);
// --- Tab bar: 4 pestañas chiquitas en fila ---
let tab_bar = tab_bar_view(model, &btn_palette, theme);
// Header siempre visible: play/pause + reseed (los controles más usados,
// independientes del tab).
let mut children: Vec<View<Msg>> = vec![
header,
tab_bar,
play_btn,
reset_btn,
separator(theme),
];
// Contenido específico del tab actual.
match model.panel_tab {
PanelTab::Mundo => append_mundo_tab(&mut children, model, stats, theme, &btn_palette, &slider_palette),
PanelTab::Conceptos => append_conceptos_tab(&mut children, model, theme, &btn_palette, &slider_palette),
PanelTab::Psique => append_psique_tab(&mut children, model, stats, psi_metrics, theme, &btn_palette, &slider_palette),
PanelTab::Vista => append_vista_tab(&mut children, model, theme, &btn_palette, &slider_palette),
}
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size {
width: length(SIDE_WIDTH),
height: percent(1.0_f32),
},
flex_shrink: 0.0,
gap: Size {
width: length(0.0_f32),
height: length(10.0_f32),
},
padding: Rect {
left: length(14.0_f32),
right: length(14.0_f32),
top: length(14.0_f32),
bottom: length(14.0_f32),
},
..Default::default()
})
.fill(theme.bg_panel)
.children(children)
}
/// Línea horizontal de 1 px usada como separator entre secciones del panel.
fn separator(theme: &Theme) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(1.0_f32),
},
..Default::default()
})
.fill(theme.border)
}
/// Barra horizontal con un botón por cada `PanelTab`. El tab activo se
/// resalta cambiando el `accent` del label (botón) — la palette de Llimphi
/// no expone "tab pill", así que usamos la convención de marcar el activo
/// con `▸`.
fn tab_bar_view(model: &Model, btn_palette: &ButtonPalette, _theme: &Theme) -> View<Msg> {
let buttons: Vec<View<Msg>> = PanelTab::all()
.into_iter()
.map(|tab| {
let active = tab == model.panel_tab;
let label = if active {
format!("{}", tab.label())
} else {
tab.label().to_string()
};
let mut bp = btn_palette.clone();
if active {
bp.bg = btn_palette.bg_hover;
}
View::new(Style {
size: Size {
width: Dimension::auto(),
height: length(26.0_f32),
},
flex_grow: 1.0,
flex_basis: length(0.0_f32),
..Default::default()
})
.children(vec![llimphi_widget_button::button_view::<Msg>(
&label,
&bp,
Msg::SelectTab(tab),
)])
})
.collect();
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(26.0_f32),
},
gap: Size {
width: length(4.0_f32),
height: length(0.0_f32),
},
..Default::default()
})
.children(buttons)
}
/// Tab "Mundo" — estado macro + sliders de motor + scenario picker.
fn append_mundo_tab(
children: &mut Vec<View<Msg>>,
model: &Model,
stats: &WorldStats,
theme: &Theme,
btn_palette: &ButtonPalette,
slider_palette: &SliderPalette,
) {
children.push(label_view(
&rimay_localize::t("dominium-header-metricas"),
11.0,
theme.fg_muted,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-population"),
&stats.n.to_string(),
theme,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-epoca"),
Epoch::classify(stats).label(),
theme,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-materia"),
&format!("{:.0}", stats.total_materia),
theme,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-oro"),
&format!("{:.0}", stats.total_oro),
theme,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-energia"),
&format!("{:.0}", stats.total_energia),
theme,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-gini-energia"),
&format!("{:.3}", stats.gini_energia),
theme,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-edad-media"),
&format!("{:.1}", stats.mean_edad),
theme,
));
children.push(stat_row(
"season×",
&format!("{:.2}", model.sim.params.season_factor(model.sim.world.tick_count)),
theme,
));
children.push(separator(theme));
children.push(label_view("[ ACCIONES ACTUALES ]", 11.0, theme.fg_muted));
let action_labels: [(&str, usize); 6] = [
("dominium-action-mover", 0),
("dominium-action-extraer", 1),
("dominium-action-sincronizar", 2),
("dominium-action-intercambiar", 3),
("dominium-action-replicar", 4),
("dominium-action-degradar", 5),
];
for (key, ai) in action_labels {
children.push(stat_row(
&rimay_localize::t(key),
&stats.action_counts[ai].to_string(),
theme,
));
}
children.push(separator(theme));
children.push(label_view("[ MOTOR ]", 11.0, theme.fg_muted));
children.push(param_slider("climb", model.sim.params.climb_cost, ParamSlot::ClimbCost, slider_palette));
children.push(param_slider("move", model.sim.params.move_cost, ParamSlot::MoveCost, slider_palette));
children.push(param_slider("diffuse", model.sim.params.diffusion_rate, ParamSlot::DiffusionRate, slider_palette));
children.push(param_slider("entropy", model.sim.params.entropy_rate, ParamSlot::EntropyRate, slider_palette));
children.push(param_slider("season T", model.sim.params.season_period as f32, ParamSlot::SeasonPeriod, slider_palette));
children.push(param_slider("season A", model.sim.params.season_amplitude, ParamSlot::SeasonAmplitude, slider_palette));
children.push(separator(theme));
children.push(label_view("[ ECONOMÍA ]", 11.0, theme.fg_muted));
children.push(param_slider("extraer", model.sim.params.extract_rate, ParamSlot::ExtractRate, slider_palette));
children.push(param_slider("trueque", model.sim.params.trade_amount, ParamSlot::TradeAmount, slider_palette));
children.push(param_slider("regrowth", model.sim.params.regrowth_rate, ParamSlot::RegrowthRate, slider_palette));
children.push(param_slider("carga", model.sim.params.carrying_capacity, ParamSlot::CarryingCapacity, slider_palette));
children.push(param_slider("metabol", model.sim.params.metabolic_cost, ParamSlot::MetabolicCost, slider_palette));
children.push(param_slider("replica", model.sim.params.replicate_threshold, ParamSlot::ReplicateThreshold, slider_palette));
children.push(param_slider("abundan", model.sim.params.abundance_threshold, ParamSlot::AbundanceThreshold, slider_palette));
children.push(separator(theme));
children.push(label_view("[ CINÉTICA ]", 11.0, theme.fg_muted));
children.push(param_slider("velocid", model.sim.params.move_speed, ParamSlot::MoveSpeed, slider_palette));
children.push(param_slider("sync", model.sim.params.sync_rate, ParamSlot::SyncRate, slider_palette));
children.push(param_slider("cicatriz", model.sim.params.degr_per_extract, ParamSlot::DegrPerExtract, slider_palette));
children.push(param_slider("herencia", model.sim.params.child_energy_frac, ParamSlot::ChildEnergyFrac, slider_palette));
children.push(param_slider("daño", model.sim.params.fight_damage, ParamSlot::FightDamage, slider_palette));
children.push(param_slider("absorbe", model.sim.params.absorb_frac, ParamSlot::AbsorbFrac, slider_palette));
children.push(param_slider("desespe", model.sim.params.desperation_threshold, ParamSlot::DesperationThreshold, slider_palette));
children.push(param_slider("edad max", model.sim.params.max_edad as f32, ParamSlot::MaxEdad, slider_palette));
children.push(separator(theme));
children.push(label_view("[ SCENARIO ]", 11.0, theme.fg_muted));
let packs = scenario_packs();
let (current_id, _) = packs[model.scenario_idx];
children.push(sized_button(
&format!("pack: {} (▸ ciclar)", current_id),
btn_palette,
Msg::CycleScenario,
));
children.push(sized_button(
&rimay_localize::t_args("dominium-btn-load-named", &[("name", current_id.into())]),
btn_palette,
Msg::LoadScenario,
));
children.push(separator(theme));
children.push(label_view(&format!("grilla {GRID}×{GRID}"), 11.0, theme.fg_muted));
}
/// Tab "Conceptos" — lista de conceptos, crear/cargar/guardar/limpiar,
/// y el editor del Concepto seleccionado (radius, sprite, 4 mods, hack).
fn append_conceptos_tab(
children: &mut Vec<View<Msg>>,
model: &Model,
theme: &Theme,
btn_palette: &ButtonPalette,
slider_palette: &SliderPalette,
) {
children.push(label_view(
&rimay_localize::t("dominium-header-conceptos"),
11.0,
theme.fg_muted,
));
children.push(label_view(
&rimay_localize::t_args(
"dominium-active-count",
&[("count", model.sim.world.conceptos.len().to_string().into())],
),
12.0,
theme.fg_text,
));
// Hint contextual: si no hay conceptos, le decimos cómo crear uno.
if model.sim.world.conceptos.items.is_empty() {
children.push(label_view(
"Click sobre el mapa para crear",
11.0,
theme.fg_muted,
));
}
for (i, c) in model.sim.world.conceptos.items.iter().enumerate() {
children.push(concepto_row(i, &c.id, model.selected == Some(i), theme));
}
children.push(sized_button(
&rimay_localize::t("dominium-btn-create-concept"),
btn_palette,
Msg::CrearConcepto,
));
children.push(sized_button(
&rimay_localize::t("dominium-btn-seed-pack"),
btn_palette,
Msg::SembrarConceptos,
));
children.push(sized_button(
&rimay_localize::t("dominium-btn-clear"),
btn_palette,
Msg::LimpiarConceptos,
));
children.push(sized_button(
&rimay_localize::t("dominium-btn-save"),
btn_palette,
Msg::GuardarPack,
));
children.push(sized_button(
&rimay_localize::t("dominium-btn-load-saved"),
btn_palette,
Msg::CargarPack,
));
// Editor del seleccionado.
let Some(i) = model.selected else { return };
let Some(c) = model.sim.world.conceptos.items.get(i) else { return };
children.push(separator(theme));
children.push(label_view(
&rimay_localize::t("dominium-header-editar"),
11.0,
theme.fg_muted,
));
if model.id_input_focused {
children.push(text_input_view(
&model.id_input,
&rimay_localize::t("dominium-slider-nombre"),
true,
&TextInputPalette::from_theme(theme),
Msg::FocusIdInput,
));
} else {
children.push(sized_button(
&format!("{} (✎ renombrar)", c.id),
btn_palette,
Msg::FocusIdInput,
));
}
children.push(slider_view(
&rimay_localize::t("dominium-slider-radius"),
c.radius,
0.5,
20.0,
slider_palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::EditRadius(dv)),
DragPhase::End => None,
},
));
children.push(sized_button(
&format!(
"sprite: {} ({})",
c.sprite_id,
dominium_render_plan::sprite_name(c.sprite_id)
),
btn_palette,
Msg::CycleSprite,
));
children.push(mod_slider(
&rimay_localize::t("dominium-slider-materia"),
c.mods.materia,
Layer::Materia,
slider_palette,
));
children.push(mod_slider(
&rimay_localize::t("dominium-slider-psique"),
c.mods.psique,
Layer::Psique,
slider_palette,
));
children.push(mod_slider(
&rimay_localize::t("dominium-slider-poder"),
c.mods.poder,
Layer::Poder,
slider_palette,
));
children.push(mod_slider(
&rimay_localize::t("dominium-slider-oro"),
c.mods.oro,
Layer::Oro,
slider_palette,
));
children.push(label_view(
&rimay_localize::t("dominium-label-hack"),
11.0,
theme.fg_muted,
));
match c.hack {
None => {
children.push(sized_button(
"+ Agregar hack",
btn_palette,
Msg::HackToggle,
));
}
Some(h) => {
children.push(sized_button(
&format!("trigger: {}", trigger_label(h.trigger)),
btn_palette,
Msg::HackCycleTrigger,
));
match h.trigger {
Trigger::Always => {}
Trigger::EnergiaBajo(v) => {
children.push(slider_view(
"umbral",
v,
0.0,
100.0,
slider_palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::HackEditTriggerParam(dv)),
DragPhase::End => None,
},
));
}
Trigger::EdadSobre(v) => {
children.push(slider_view(
"edad",
v as f32,
0.0,
1000.0,
slider_palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::HackEditTriggerParam(dv)),
DragPhase::End => None,
},
));
}
}
children.push(sized_button(
&format!("acción: {} ({})", h.forced_action, action_name(h.forced_action)),
btn_palette,
Msg::HackCycleAction,
));
children.push(slider_view(
"duración",
h.duration as f32,
1.0,
500.0,
slider_palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::HackEditDuration(dv)),
DragPhase::End => None,
},
));
children.push(sized_button(" Quitar hack", btn_palette, Msg::HackToggle));
}
}
children.push(sized_button("🗑 Borrar", btn_palette, Msg::DeleteSelected));
children.push(sized_button("◌ Deseleccionar", btn_palette, Msg::DeselectConcepto));
}
/// Tab "ψ" — sliders de psicología social + métricas ψ.
fn append_psique_tab(
children: &mut Vec<View<Msg>>,
model: &Model,
stats: &WorldStats,
psi_metrics: &PsiMetrics,
theme: &Theme,
btn_palette: &ButtonPalette,
slider_palette: &SliderPalette,
) {
children.push(label_view("[ DIVERSIDAD ψ ]", 11.0, theme.fg_muted));
children.push(stat_row(
&rimay_localize::t("dominium-stat-var-psi-orden"),
&format!("{:.3}", stats.var_psi[0]),
theme,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-var-psi-miedo"),
&format!("{:.3}", stats.var_psi[1]),
theme,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-var-psi-curiosidad"),
&format!("{:.3}", stats.var_psi[2]),
theme,
));
children.push(stat_row(
&rimay_localize::t("dominium-stat-var-psi-corruptib"),
&format!("{:.3}", stats.var_psi[3]),
theme,
));
children.push(separator(theme));
children.push(label_view("[ CONTAGIO SOCIAL ]", 11.0, theme.fg_muted));
children.push(param_slider(
"psi mod",
model.sim.params.psi_effect_modulation,
ParamSlot::PsiModulation,
slider_palette,
));
children.push(param_slider(
"radio soc",
model.sim.params.social_radius,
ParamSlot::SocialRadius,
slider_palette,
));
children.push(param_slider(
"contagio",
model.sim.params.contagion_rate,
ParamSlot::ContagionRate,
slider_palette,
));
children.push(param_slider(
"homofilia",
model.sim.params.homophily_threshold,
ParamSlot::HomophilyThreshold,
slider_palette,
));
let big5_label = if model.sim.params.big_five {
"✓ Big Five: ON (5D)"
} else {
"○ Big Five: OFF (4D)"
};
children.push(sized_button(big5_label, btn_palette, Msg::ToggleBigFive));
let policy_label = match model.sim.params.action_policy {
dominium_core::ActionPolicy::Fixed => "○ Política: Fixed".to_string(),
dominium_core::ActionPolicy::PsiArgmax => format!(
"✓ Política: PsiArgmax (T={})",
model.sim.params.policy_reeval_period
),
};
children.push(sized_button(&policy_label, btn_palette, Msg::CyclePsiPolicy));
children.push(separator(theme));
children.push(label_view("[ POLARIZACIÓN Esteban-Ray ]", 11.0, theme.fg_muted));
let psi_labels = ["ORDEN", "MIEDO", "CURIO", "CORR"];
for (i, lab) in psi_labels.iter().enumerate() {
children.push(stat_row(
&format!("polar {lab}"),
&format!("{:.4}", psi_metrics.polarization[i]),
theme,
));
}
children.push(label_view("[ Moran's I (autocorr.) ]", 11.0, theme.fg_muted));
for (i, lab) in psi_labels.iter().enumerate() {
children.push(stat_row(
&format!("Moran {lab}"),
&format!("{:+.3}", psi_metrics.moran_i[i]),
theme,
));
}
if model.sim.params.big_five {
children.push(stat_row(
"polar EXTRA",
&format!("{:.4}", psi_metrics.polarization_ext),
theme,
));
children.push(stat_row(
"Moran EXTRA",
&format!("{:+.3}", psi_metrics.moran_i_ext),
theme,
));
}
// Legend de clusters cuando el render está mostrando tribus.
if matches!(model.cfg.render_mode, RenderMode::PsiCluster) {
children.push(separator(theme));
children.push(label_view("[ TRIBUS k-means ]", 11.0, theme.fg_muted));
for (k, c) in CLUSTER_COLORS.iter().enumerate() {
let n_in = model
.sim
.cluster_assignments
.iter()
.filter(|&&a| a as usize == k)
.count();
children.push(stat_row(
&format!("cluster {k} ({})", color_swatch(*c)),
&n_in.to_string(),
theme,
));
}
}
}
/// Tab "Vista" — render mode + trails + andina + ZWeights + rewind.
fn append_vista_tab(
children: &mut Vec<View<Msg>>,
model: &Model,
theme: &Theme,
btn_palette: &ButtonPalette,
slider_palette: &SliderPalette,
) {
children.push(label_view("[ MODO RENDER ]", 11.0, theme.fg_muted));
let render_label = match model.cfg.render_mode {
RenderMode::Composite => "Render: compuesto".to_string(),
RenderMode::Heatmap(l) => format!("Render: heatmap {}", l.label()),
RenderMode::PsiCluster => "Render: tribus ψ (k-means)".to_string(),
};
children.push(sized_button(&render_label, btn_palette, Msg::CycleRenderMode));
let trails_label = if model.show_trails {
"✓ Trayectorias: ON"
} else {
"○ Trayectorias: OFF"
};
children.push(sized_button(trails_label, btn_palette, Msg::ToggleTrails));
let texture_label = if model.cfg.texture {
"✓ Textura: ON"
} else {
"○ Textura: OFF"
};
children.push(sized_button(texture_label, btn_palette, Msg::ToggleTexture));
let andina_label = if model.cfg.andina_layers > 0 {
"✓ Estampa andina: ON"
} else {
"○ Estampa andina: OFF"
};
children.push(sized_button(andina_label, btn_palette, Msg::ToggleAndina));
children.push(separator(theme));
children.push(label_view("[ RELIEVE VISUAL ]", 11.0, theme.fg_muted));
children.push(z_slider(
&rimay_localize::t("dominium-slider-materia"),
model.weights.materia,
ZSlot::Materia,
slider_palette,
));
children.push(z_slider(
&rimay_localize::t("dominium-slider-psique"),
model.weights.psique,
ZSlot::Psique,
slider_palette,
));
children.push(z_slider(
&rimay_localize::t("dominium-slider-poder"),
model.weights.poder,
ZSlot::Poder,
slider_palette,
));
children.push(z_slider(
&rimay_localize::t("dominium-slider-oro"),
model.weights.oro,
ZSlot::Oro,
slider_palette,
));
children.push(z_slider(
"degrad.",
model.weights.degradacion,
ZSlot::Degradacion,
slider_palette,
));
let sync_label = if model.sync_relieve {
"✓ Sync físico: ON"
} else {
"○ Sync físico: OFF"
};
children.push(sized_button(sync_label, btn_palette, Msg::ToggleSyncRelieve));
children.push(separator(theme));
children.push(label_view("[ REWIND ]", 11.0, theme.fg_muted));
let max_rewind = model.sim.snapshots.len().saturating_sub(1).max(1);
children.push(slider_view(
"rewind",
model.sim.rewind_offset as f32,
0.0,
max_rewind as f32,
slider_palette,
|phase, dv| match phase {
DragPhase::Move => Some(Msg::RewindBy(dv)),
DragPhase::End => None,
},
));
if model.sim.rewind_offset > 0 {
children.push(sized_button(
&format!("▶ Vivo (estabas {} atrás)", model.sim.rewind_offset),
btn_palette,
Msg::RewindHome,
));
}
}
/// Glifo simple para indicar el color de un cluster en una fila de stat.
/// El texto es monoespaciado pero los colores van en el panel — usamos
/// emojis círculos para que el matching visual sea inmediato sin tocar el
/// renderer del label.
fn color_swatch(c: Color) -> &'static str {
let r = c[0] > 0.6;
let g = c[1] > 0.6;
let b = c[2] > 0.6;
match (r, g, b) {
(true, false, true) => "magenta",
(false, true, true) => "cian",
(true, true, false) => "amarillo",
_ => "·",
}
}
fn label_view(text: &str, size_px: f32, color: llimphi_ui::llimphi_raster::peniko::Color) -> View<Msg> {
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(18.0_f32),
},
..Default::default()
})
.text_aligned(text.to_string(), size_px, color, Alignment::Start)
}
fn stat_row(label: &str, value: &str, theme: &Theme) -> View<Msg> {
let label_v = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
flex_grow: 1.0,
..Default::default()
})
.text_aligned(label.to_string(), 12.0, theme.fg_muted, Alignment::Start);
let value_v = View::new(Style {
size: Size {
width: length(90.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.text_aligned(value.to_string(), 12.0, theme.fg_text, Alignment::End);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.children(vec![label_v, value_v])
}
fn sized_button(label: &str, palette: &ButtonPalette, msg: Msg) -> View<Msg> {
let mut btn = button_view(label, palette, msg);
btn.style.size = Size {
width: percent(1.0_f32),
height: length(30.0_f32),
};
btn
}
/// Fila clicable con el nombre de un Concepto. La fila seleccionada
/// queda resaltada con `bg_selected`; las demás reaccionan al hover.
fn concepto_row(i: usize, id: &str, selected: bool, theme: &Theme) -> View<Msg> {
let bg = if selected { theme.bg_selected } else { theme.bg_panel };
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: length(20.0_f32),
},
padding: Rect {
left: length(8.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
..Default::default()
})
.fill(bg)
.hover_fill(theme.bg_row_hover)
.radius(3.0)
.text_aligned(
format!("· {id}"),
12.0,
if selected { theme.accent } else { theme.fg_text },
Alignment::Start,
)
.on_click(Msg::SelectConcepto(i))
}
/// Slider para una capa de `LayerMods`. Rango fijo `[-1, 1]` — encaja con
/// el patrón típico (emisión positiva, drenaje negativo).
fn mod_slider(label: &str, value: f32, layer: Layer, palette: &SliderPalette) -> View<Msg> {
slider_view(
label,
value,
-1.0,
1.0,
palette,
move |phase, dv| match phase {
DragPhase::Move => Some(Msg::EditMod(layer, dv)),
DragPhase::End => None,
},
)
}
/// Slider para un slot de `SimParams`. El rango lo decide el slot.
fn param_slider(
label: &str,
value: f32,
slot: ParamSlot,
palette: &SliderPalette,
) -> View<Msg> {
let (min, max) = slot.range();
slider_view(
label,
value,
min,
max,
palette,
move |phase, dv| match phase {
DragPhase::Move => Some(Msg::EditParam(slot, dv)),
DragPhase::End => None,
},
)
}
/// Slider para un slot de `ZWeights` (relieve visual del render).
/// Rango simétrico [-2, 2]: negativo = la capa cava valles, positivo = eleva.
fn z_slider(
label: &str,
value: f32,
slot: ZSlot,
palette: &SliderPalette,
) -> View<Msg> {
slider_view(
label,
value,
-2.0,
2.0,
palette,
move |phase, dv| match phase {
DragPhase::Move => Some(Msg::EditZWeight(slot, dv)),
DragPhase::End => None,
},
)
}
@@ -0,0 +1,44 @@
//! Generación procedural del mundo (frontend): paleta de biomas para el
//! render y el wrapper que invoca al motor `dominium_core::worldgen` con las
//! dimensiones, población y pack de Conceptos de esta app. El generador en sí
//! (PRNG, fbm, ríos, biomas, lemmings) vive en el core (regla #2).
use dominium_core::World;
use crate::consts::{GRID, LEMMINGS};
use crate::packs::{default_conceptos, load_user_pack};
/// Paleta retocada para que mar / tierra / cumbres se lean a primera
/// vista. Reemplaza la `Palette::default()` del render-plan en la app sin
/// tocar el crate (otros consumidores siguen con el default histórico).
pub(crate) fn bioma_palette() -> dominium_render_plan::Palette {
dominium_render_plan::Palette {
// Arena oscura para celdas sin capa dominante — visualmente
// "tierra de borde" en lugar del gris-azulado original.
floor: [0.30, 0.25, 0.20, 1.0],
// Pasto firme.
materia: [0.30, 0.62, 0.32, 1.0],
// Azul océano profundo (sustituye al cian claro del default).
psique: [0.16, 0.34, 0.66, 1.0],
// Siena de cumbre (sustituye al rojo bandera).
poder: [0.78, 0.52, 0.32, 1.0],
oro: [0.92, 0.76, 0.28, 1.0],
// Gris-violeta de roca alta (sustituye al violeta saturado).
degradacion: [0.46, 0.40, 0.50, 1.0],
// Marfil suave para lemmings — destaca sobre pasto y agua.
lemming: [0.97, 0.95, 0.88, 1.0],
concepto_aura: [0.95, 0.86, 0.55, 0.18],
concepto_base: [0.58, 0.45, 0.18, 1.0],
concepto: [0.98, 0.88, 0.42, 1.0],
shadow: [0.04, 0.04, 0.06, 0.42],
}
}
/// Siembra un mundo `GRID×GRID` con `LEMMINGS` lemmings. Si el usuario ya
/// tiene un pack guardado gana sobre el embebido (así sus ediciones
/// sobreviven al reseed/reapertura); si no, default. Delega en
/// [`dominium_core::worldgen::seed`].
pub(crate) fn seed(seed: u64) -> World {
let conceptos = load_user_pack().unwrap_or_else(default_conceptos);
dominium_core::worldgen::seed(seed, GRID, LEMMINGS, conceptos)
}
@@ -0,0 +1,22 @@
[package]
name = "dominium-canvas-llimphi"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium-canvas-llimphi — backend Llimphi del simulador. Recibe un `RenderPlan` (cadena `core → physics → iso → render-plan` agnóstica intacta) y devuelve un `View<Msg>` con `paint_with` que pinta los quads centrados en sus bounds usando vello."
[dependencies]
dominium-render-plan = { path = "../dominium-render-plan" }
llimphi-ui = { workspace = true }
[dev-dependencies]
# Cadena completa para el example: arma un World + un IsoProjector y
# llama a `build_plan` para que el canvas reciba un plan realista.
dominium-core = { path = "../dominium-core" }
dominium-iso = { path = "../dominium-iso" }
[[example]]
name = "canvas_demo"
path = "examples/canvas_demo.rs"
@@ -0,0 +1,10 @@
# dominium-canvas-llimphi
> Backend Llimphi (vello) para [dominium](../README.md).
Convierte el `Vec<Quad>` que produce [`dominium-render-plan`](../dominium-render-plan/README.md) en operaciones `vello::Scene` adentro de un `View::paint_with(...)` de Llimphi. Single-pass; cero allocs por frame (re-usa el buffer de quads).
## Deps
- [`dominium-render-plan`](../dominium-render-plan/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) (vello)
@@ -0,0 +1,10 @@
# dominium-canvas-llimphi
> Llimphi (vello) backend for [dominium](../README.md).
Converts the `Vec<Quad>` from [`dominium-render-plan`](../dominium-render-plan/README.md) into `vello::Scene` operations inside a Llimphi `View::paint_with(...)`. Single-pass; zero allocations per frame (reuses the quad buffer).
## Deps
- [`dominium-render-plan`](../dominium-render-plan/README.md)
- [`llimphi-ui`](../../../02_ruway/llimphi/) (vello)
@@ -0,0 +1,105 @@
//! Showcase de `dominium-canvas-llimphi`: arma un mundo pequeño con
//! patrones manuales (vetas de oro, parches de materia, niebla de
//! psique), construye el `RenderPlan` con `build_plan` y lo pinta
//! centrado en la ventana.
//!
//! Sin loop de simulación — la app Llimphi completa con tick vivo
//! va en `dominium-app-llimphi` (próximo bloque).
//!
//! Corré con: `cargo run -p dominium-canvas-llimphi --example canvas_demo --release`.
use dominium_canvas_llimphi::canvas_view;
use dominium_core::World;
use dominium_iso::{IsoProjector, ZWeights};
use dominium_render_plan::{build_plan, PlanConfig};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::{App, Handle, View};
const GRID: usize = 32;
#[derive(Clone)]
enum Msg {}
struct Model {
world: World,
}
struct Showcase;
impl App for Showcase {
type Model = Model;
type Msg = Msg;
fn title() -> &'static str {
"dominium · canvas showcase"
}
fn initial_size() -> (u32, u32) {
(1000, 720)
}
fn init(_: &Handle<Msg>) -> Model {
Model { world: seed() }
}
fn update(model: Model, _: Msg, _: &Handle<Msg>) -> Model {
model
}
fn view(model: &Model) -> View<Msg> {
let iso = IsoProjector::new(1.0, 4.0);
let weights = ZWeights::default();
let cfg = PlanConfig::default();
let plan = build_plan(&model.world, &iso, &weights, &cfg);
let canvas = canvas_view::<Msg>(plan, Some(Color::from_rgba8(14, 16, 22, 255)));
View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
})
.children(vec![canvas])
}
}
/// Mundo sembrado a mano: continentes de materia en el centro, vetas
/// de oro en una diagonal y un parche de psique en una esquina. Sin
/// PRNG — siempre la misma escena entre runs.
fn seed() -> World {
let mut w = World::new(GRID, GRID);
for cy in 0..GRID {
for cx in 0..GRID {
let idx = w.grid.idx(cx, cy);
// Continente: gauss centrado.
let dx = cx as f32 - (GRID as f32 * 0.5);
let dy = cy as f32 - (GRID as f32 * 0.5);
let d2 = dx * dx + dy * dy;
let materia = (40.0 - d2 * 0.15).max(0.0);
w.grid.materia[idx] = materia;
// Veta de oro en la diagonal cx == cy.
if cx == cy && cx > 4 && cx < GRID - 4 {
w.grid.oro[idx] = 35.0;
}
// Psique en el cuadrante inferior derecho.
if cx > GRID * 2 / 3 && cy > GRID * 2 / 3 {
w.grid.psique[idx] = 18.0;
}
// Borde de degradación.
if cx == 0 || cy == 0 || cx == GRID - 1 || cy == GRID - 1 {
w.grid.degradacion[idx] = 25.0;
}
}
}
w
}
fn main() {
llimphi_ui::run::<Showcase>();
}
@@ -0,0 +1,192 @@
//! `dominium-canvas-llimphi` — el único crate de dominium que importa
//! `llimphi-ui`.
//!
//! Toda la cadena `dominium-core → physics → iso → render-plan` es
//! agnóstica de backend. Este crate cierra el circuito: una función
//! [`canvas_view`] que recibe un [`RenderPlan`] ya resuelto y devuelve
//! un `View<Msg>` con `paint_with` que pinta los quads vía vello,
//! centrando la maqueta en los bounds asignados por taffy.
//!
//! Reemplazo Llimphi del `dominium-canvas-gpui`. Igual contrato:
//! el `Element` (acá `View`) no guarda estado entre frames — el host
//! reconstruye el View con el `RenderPlan` del frame actual.
#![forbid(unsafe_code)]
use dominium_render_plan::{Color as PlanColor, RenderPlan, SpritePrim};
use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Point, Rect as KurboRect, Stroke};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::llimphi_text::{draw_block, TextBlock};
use llimphi_ui::{PaintRect, View};
/// Convierte el RGBA lineal del plan (`[f32;4]` en [0,1]) al `Color`
/// de peniko. Mantiene la convención sin gamma del backend GPUI.
fn plan_color(c: PlanColor) -> Color {
let to_byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
Color::from_rgba8(to_byte(c[0]), to_byte(c[1]), to_byte(c[2]), to_byte(c[3]))
}
/// Construye un View que pinta `plan` en su rect. Si `background` está
/// presente, se pinta como fondo sólido antes de los quads (el `fill`
/// del View ya lo cubriría — pero esta API mantiene el shape del
/// `DominiumCanvas::background` del backend GPUI).
pub fn canvas_view<Msg>(plan: RenderPlan, background: Option<Color>) -> View<Msg>
where
Msg: Clone + 'static,
{
// El plan es Send + Sync (Vec<Quad> con Copy). Lo movemos a la
// closure de paint; el runtime la invoca por frame.
let view = View::new(Style {
size: Size {
width: percent(1.0_f32),
height: percent(1.0_f32),
},
..Default::default()
});
let view = if let Some(bg) = background {
view.fill(bg)
} else {
view
};
view.paint_with(move |scene, ts, rect: PaintRect| {
if plan.quads.is_empty()
&& plan.polygons.is_empty()
&& plan.glyphs.is_empty()
&& plan.sprites.is_empty()
{
return;
}
// Centra la maqueta: el centro de la caja envolvente del plan
// se alinea con el centro del rect del nodo.
let plan_cx = (plan.min_x + plan.max_x) * 0.5;
let plan_cy = (plan.min_y + plan.max_y) * 0.5;
let off_x = (rect.x + rect.w * 0.5 - plan_cx) as f64;
let off_y = (rect.y + rect.h * 0.5 - plan_cy) as f64;
// Intercala quads + polygons por depth, atrás → adelante. Cada
// input ya está ordenado por su propio depth, así que un merge
// lineal alcanza — sin re-ordenar.
let mut qi = 0usize;
let mut pi = 0usize;
while qi < plan.quads.len() || pi < plan.polygons.len() {
let q_d = plan.quads.get(qi).map(|q| q.depth);
let p_d = plan.polygons.get(pi).map(|p| p.depth);
let take_quad = match (q_d, p_d) {
(Some(q), Some(p)) => q <= p,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => break,
};
if take_quad {
let q = &plan.quads[qi];
let x0 = q.x as f64 + off_x;
let y0 = q.y as f64 + off_y;
let x1 = x0 + q.w as f64;
let y1 = y0 + q.h as f64;
let r = KurboRect::new(x0, y0, x1, y1);
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
plan_color(q.color),
None,
&r,
);
qi += 1;
} else {
let p = &plan.polygons[pi];
let mut path = BezPath::new();
let v = &p.vertices;
path.move_to(Point::new(v[0].0 as f64 + off_x, v[0].1 as f64 + off_y));
path.line_to(Point::new(v[1].0 as f64 + off_x, v[1].1 as f64 + off_y));
path.line_to(Point::new(v[2].0 as f64 + off_x, v[2].1 as f64 + off_y));
path.line_to(Point::new(v[3].0 as f64 + off_x, v[3].1 as f64 + off_y));
path.close_path();
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
plan_color(p.color),
None,
&path,
);
pi += 1;
}
}
// Sprites vectoriales de los Conceptos, por encima de los quads.
// Cada primitiva es relleno (polígono cerrado), trazo (polilínea
// con grosor) o disco. Coordenadas ya en pantalla → sólo offset.
for prim in &plan.sprites {
match prim {
SpritePrim::Fill { points, color } => {
if points.len() < 3 {
continue;
}
let mut path = BezPath::new();
path.move_to(Point::new(points[0].0 as f64 + off_x, points[0].1 as f64 + off_y));
for pt in &points[1..] {
path.line_to(Point::new(pt.0 as f64 + off_x, pt.1 as f64 + off_y));
}
path.close_path();
scene.fill(Fill::NonZero, Affine::IDENTITY, plan_color(*color), None, &path);
}
SpritePrim::Stroke { points, width, color } => {
if points.len() < 2 {
continue;
}
let mut path = BezPath::new();
path.move_to(Point::new(points[0].0 as f64 + off_x, points[0].1 as f64 + off_y));
for pt in &points[1..] {
path.line_to(Point::new(pt.0 as f64 + off_x, pt.1 as f64 + off_y));
}
scene.stroke(
&Stroke::new(*width as f64),
Affine::IDENTITY,
plan_color(*color),
None,
&path,
);
}
SpritePrim::Disc { cx, cy, r, color } => {
let circle =
Circle::new(Point::new(*cx as f64 + off_x, *cy as f64 + off_y), *r as f64);
scene.fill(Fill::NonZero, Affine::IDENTITY, plan_color(*color), None, &circle);
}
}
}
// Glifos por encima de todo, sin re-shaping cacheado.
for gl in &plan.glyphs {
let s = gl.ch.to_string();
let block = TextBlock::simple(
&s,
gl.size_px,
plan_color(gl.color),
(gl.x as f64 + off_x, gl.y as f64 + off_y),
);
draw_block(scene, ts, &block);
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pure_red_round_trips() {
let c = plan_color([1.0, 0.0, 0.0, 1.0]).to_rgba8();
assert_eq!((c.r, c.g, c.b, c.a), (255, 0, 0, 255));
}
#[test]
fn alpha_passes_through() {
let c = plan_color([0.0, 0.0, 1.0, 0.25]).to_rgba8();
assert_eq!(c.b, 255);
assert_eq!(c.a, 64); // 0.25 * 255 = 63.75 ~> 64
}
#[test]
fn out_of_range_clamps() {
let c = plan_color([1.5, -0.2, 0.5, 1.0]).to_rgba8();
assert_eq!((c.r, c.g, c.b), (255, 0, 128));
}
}
@@ -0,0 +1,20 @@
[package]
name = "dominium-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium-cli — corredor headless del simulador. Útil para validar determinismo cross-platform, dumpar CSVs de stats por tick, o experimentar con packs de Conceptos sin abrir ventana."
[[bin]]
name = "dominium-cli"
path = "src/main.rs"
[dependencies]
dominium-core = { path = "../dominium-core" }
dominium-physics = { path = "../dominium-physics" }
clap = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
anyhow = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
# dominium-cli
> CLI de [dominium](../README.md): run / step / dump determinista.
Sin UI, sin renderer. Ideal para:
- **Reproducir simulaciones bit-a-bit** (mismo seed + misma version ⇒ mismo output).
- **Benchmarks** (`run --ticks N --bench`).
- **Snapshots para regresión** (`dump --tick N` produce JSON inspectable).
## Uso
```sh
cargo run --release -p dominium-cli -- run --seed 42 --ticks 1000
cargo run --release -p dominium-cli -- step --seed 42 --until 500
cargo run --release -p dominium-cli -- dump --input /tmp/state.bin
```
## Deps
- [`dominium-core`](../dominium-core/README.md), [`dominium-physics`](../dominium-physics/README.md)
- `clap`, `serde_json`
+21
View File
@@ -0,0 +1,21 @@
# dominium-cli
> CLI of [dominium](../README.md): deterministic run / step / dump.
No UI, no renderer. Ideal for:
- **Reproducing simulations bit-for-bit** (same seed + same version ⇒ same output).
- **Benchmarks** (`run --ticks N --bench`).
- **Regression snapshots** (`dump --tick N` produces inspectable JSON).
## Usage
```sh
cargo run --release -p dominium-cli -- run --seed 42 --ticks 1000
cargo run --release -p dominium-cli -- step --seed 42 --until 500
cargo run --release -p dominium-cli -- dump --input /tmp/state.bin
```
## Deps
- [`dominium-core`](../dominium-core/README.md), [`dominium-physics`](../dominium-physics/README.md)
- `clap`, `serde_json`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
[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 }
libm = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+19
View File
@@ -0,0 +1,19 @@
# dominium-core
> Datos + 6 acciones atómicas + Conceptos JSON para [dominium](../README.md). Sin gráficos.
`Grid` con 5 capas (`materia`, `psique`, `poder`, `oro`, `degradacion`) en `Vec<f32>` indexados `y * width + x`. `Agent` con vector estado + decisión. Seis acciones atómicas: `Mover`, `Tomar`, `Soltar`, `Transmitir`, `Atacar`, `Descansar`. `Concepto` carga emisores de campo via JSON (`id+pos+radio+mods+hack`).
## API
```rust
use dominium_core::{World, Concept};
let mut w = World::new(256, 256, seed);
w.cargar_conceptos(&conceptos_json)?;
```
## Deps
- `serde`, `libm`
- Cero deps gráficas (regla inviolable)
@@ -0,0 +1,19 @@
# dominium-core
> Data + 6 atomic actions + JSON Concepts for [dominium](../README.md). No graphics.
`Grid` with 5 layers (`materia`, `psique`, `poder`, `oro`, `degradacion`) in `Vec<f32>` indexed `y * width + x`. `Agent` with vector state + decision. Six atomic actions: `Mover`, `Tomar`, `Soltar`, `Transmitir`, `Atacar`, `Descansar`. `Concepto` loads field emitters via JSON (`id+pos+radio+mods+hack`).
## API
```rust
use dominium_core::{World, Concept};
let mut w = World::new(256, 256, seed);
w.cargar_conceptos(&conceptos_json)?;
```
## Deps
- `serde`, `libm`
- Zero graphics deps (inviolable rule)
@@ -0,0 +1,254 @@
//! Conceptos — emisores de campo metaprogramables.
//!
//! Un `Concepto` es una **entidad de diseño**, no de código. Lleva una
//! posición, un radio, modificadores por capa (cuánto emite/drena de
//! `materia/psique/poder/oro` por tick a cada celda dentro del radio) y un
//! `BehaviorHack` opcional que captura la acción de los Lemmings que entran
//! a su radio.
//!
//! Para el motor, una "iglesia", un "banco" o una "comuna" no son tipos
//! distintos: son la misma estructura con números diferentes. La iglesia es
//! `mods.psique > 0, mods.materia < 0` con `hack: forced_action = Sincronizar`.
//! El banco es `mods.oro < 0, mods.poder > 0`. La comuna es `mods.materia >
//! 0, mods.degradacion no se toca` (degradacion no es modificable: es
//! cicatriz emergente del extraer).
//!
//! La unidad es **una pieza de datos**: serializable a JSON, generable por
//! cualquier productor externo (un humano en un panel, un script, una IA
//! offline) sin tocar el código del motor.
//!
//! El motor sigue siendo *tonto*: en `dominium-physics` recorre la lista de
//! conceptos y suma los modificadores con un falloff lineal. Cero IA, cero
//! embeddings, cero narrativa. Solo álgebra sobre la grilla.
use serde::{Deserialize, Serialize};
/// Emisión/drenaje por tick en una celda en el centro del radio. En el
/// borde el valor cae linealmente a cero (falloff lineal).
///
/// `degradacion` no se modifica desde un Concepto — es cicatriz emergente
/// del extraer de los Lemmings, no algo que un emisor pueda revertir.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct LayerMods {
pub materia: f32,
pub psique: f32,
pub poder: f32,
pub oro: f32,
}
/// Condición que dispara un `BehaviorHack` sobre un Lemming que cae dentro
/// del radio del Concepto.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum Trigger {
/// Cualquier Lemming en el radio queda capturado.
Always,
/// Solo si la `energia` del Lemming está por debajo del umbral.
EnergiaBajo(f32),
/// Solo si la `edad` del Lemming es mayor al umbral.
EdadSobre(u32),
}
/// Toma de control de la acción de un Lemming durante `duration` ticks.
///
/// Mientras esté capturado (`hack_lock > 0`), el Lemming ejecuta
/// `forced_action` ignorando cualquier transición que el motor le aplicaría
/// (incluida la desesperación → pelear).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct BehaviorHack {
pub trigger: Trigger,
/// Byte de la acción forzada (0-5; ver [`crate::Action`]).
pub forced_action: u8,
/// Ticks que dura el hack desde que se aplica.
pub duration: u32,
}
/// Influencia psicológica de un Concepto — Fase B.2.
///
/// A diferencia del `BehaviorHack` (que CONGELA acción por N ticks, una
/// metáfora de coerción/captura), la `Persuasion` empuja el `vector_psi`
/// del agente hacia un objetivo cada tick mientras esté dentro del radio,
/// sin tocar su acción. Es la mecánica canónica de **persuasión** /
/// **propaganda**: el agente sigue siendo libre de actuar, pero su
/// psicología deriva.
///
/// El falloff es lineal (1 en el centro, 0 en el borde) — la mismo que
/// usa `LayerMods` sobre la grilla. Así un agente que entra y sale del
/// radio acumula influencia proporcional al tiempo expuesto y a su
/// proximidad al centro.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Persuasion {
/// Objetivo psicológico hacia el que se empuja el `vector_psi` del
/// agente. Convención `[ORDEN, MIEDO, CURIOSIDAD, CORRUPTIBILIDAD]`.
/// Ej. una "iglesia ortodoxa" usaría `[1.0, 0.5, 0.0, 0.0]`.
pub target_psi: [f32; 4],
/// Tasa de convergencia por tick a falloff 1.0 (centro del radio).
/// `psi_nuevo = psi + rate · falloff · (target psi)`. Rango útil
/// 0.01..0.10. Valores grandes producen "lavado de cerebro" en pocos
/// ticks; chicos generan deriva lenta.
pub rate: f32,
}
/// Un emisor de campo metaprogramable.
///
/// `sprite_id` es opaco al motor: solo viaja del JSON hasta el backend
/// gráfico (que decide qué pintar). El motor no le mira el valor.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Concepto {
/// Nombre legible (ej. `"iglesia"`, `"banco-central"`, `"comuna"`).
/// Solo informativo: el motor lo ignora.
pub id: String,
/// Identificador opaco del sprite que el backend usa para dibujarlo.
#[serde(default)]
pub sprite_id: u32,
pub pos_x: f32,
pub pos_y: f32,
/// Radio de influencia en unidades de celda.
pub radius: f32,
/// Cuánto emite/drena por tick en el centro (cae linealmente al borde).
pub mods: LayerMods,
/// Toma de control opcional. `None` = solo emite campo.
#[serde(default)]
pub hack: Option<BehaviorHack>,
/// Persuasión psicológica opcional (Fase B.2). `None` = el Concepto
/// sólo emite campo / hackea acción. Cuando está presente, ADEMÁS
/// empuja el `vector_psi` de los lemmings dentro del radio cada tick.
/// Es ortogonal al `hack`: un Concepto puede coercer una acción Y
/// persuadir psi simultáneamente.
#[serde(default)]
pub persuasion: Option<Persuasion>,
}
/// Colección lineal. Sin ordenamiento, sin índice espacial: la sim es
/// chica (decenas de conceptos × miles de celdas/Lemmings) y el costo es
/// despreciable. La iteración es determinista por orden de inserción.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Conceptos {
pub items: Vec<Concepto>,
}
impl Conceptos {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn clear(&mut self) {
self.items.clear();
}
/// Agrega un concepto al final. Devuelve su índice.
pub fn add(&mut self, c: Concepto) -> usize {
self.items.push(c);
self.items.len() - 1
}
/// Elimina por índice con `swap_remove` — O(1), no preserva el orden.
pub fn remove(&mut self, i: usize) {
self.items.swap_remove(i);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_and_remove_swap() {
let mut cs = Conceptos::new();
let a = cs.add(Concepto {
id: "a".into(),
sprite_id: 0,
pos_x: 1.0,
pos_y: 1.0,
radius: 5.0,
mods: LayerMods::default(),
hack: None,
persuasion: None,
});
let _b = cs.add(Concepto {
id: "b".into(),
sprite_id: 0,
pos_x: 2.0,
pos_y: 2.0,
radius: 5.0,
mods: LayerMods::default(),
hack: None,
persuasion: None,
});
assert_eq!((a, cs.len()), (0, 2));
cs.remove(a);
assert_eq!(cs.len(), 1);
assert_eq!(cs.items[0].id, "b");
}
#[test]
fn json_roundtrip_preserves_concepto() {
let c = Concepto {
id: "iglesia".into(),
sprite_id: 42,
pos_x: 10.0,
pos_y: 10.0,
radius: 6.0,
mods: LayerMods { materia: -0.1, psique: 0.8, poder: 0.3, oro: 0.0 },
hack: Some(BehaviorHack {
trigger: Trigger::EnergiaBajo(20.0),
forced_action: 2,
duration: 50,
}),
persuasion: None,
};
let s = serde_json::to_string(&c).expect("serializa");
let back: Concepto = serde_json::from_str(&s).expect("deserializa");
assert_eq!(c, back);
}
#[test]
fn json_collection_roundtrip() {
let mut cs = Conceptos::new();
cs.add(Concepto {
id: "iglesia".into(),
sprite_id: 1,
pos_x: 8.0,
pos_y: 8.0,
radius: 5.0,
mods: LayerMods { psique: 0.5, ..Default::default() },
hack: None,
persuasion: None,
});
cs.add(Concepto {
id: "banco".into(),
sprite_id: 2,
pos_x: 30.0,
pos_y: 12.0,
radius: 4.0,
mods: LayerMods { oro: -0.2, poder: 0.4, ..Default::default() },
hack: None,
persuasion: None,
});
let s = serde_json::to_string(&cs).expect("serializa");
let back: Conceptos = serde_json::from_str(&s).expect("deserializa");
assert_eq!(cs, back);
}
#[test]
fn default_optional_fields_in_json() {
// sprite_id y hack tienen serde(default); deben aceptar JSONs minimalistas.
let raw = r#"{
"id": "minimal",
"pos_x": 0.0,
"pos_y": 0.0,
"radius": 1.0,
"mods": { "materia": 0.0, "psique": 0.0, "poder": 0.0, "oro": 0.0 }
}"#;
let c: Concepto = serde_json::from_str(raw).expect("deserializa minimal");
assert_eq!(c.sprite_id, 0);
assert!(c.hack.is_none());
}
}
@@ -0,0 +1,180 @@
//! Clasificación cualitativa del estado del mundo — "qué época estamos
//! viviendo" a partir de las métricas agregadas.
//!
//! No es una capa del motor: el motor sigue ignorando que existen "edades de
//! oro". Esto es un **lector** que toma `WorldStats` y traduce los números a
//! una etiqueta legible para mostrar en el HUD o etiquetar filas del CSV.
//!
//! Las heurísticas son honestas y pocas: seis arquetipos con umbrales fijos,
//! orden de prelación explícito. Cuando el mundo no encaja en ningún
//! arquetipo, cae a [`Epoch::Equilibrio`].
use crate::metrics::WorldStats;
use serde::{Deserialize, Serialize};
/// Arquetipos macro que el mundo puede atravesar. La clasificación corre
/// sobre la `WorldStats` instantánea — no hay memoria, así que el "Auge" no
/// implica que la pob esté creciendo (no tenemos derivadas), sino que el
/// estado actual *parece un auge*: mucha materia, mucha energía, Gini bajo.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Epoch {
/// Mundo vacío o casi — la población se extinguió o está al borde.
Colapso,
/// Mucha gente, poca materia/energía promedio: hay hambre.
Hambruna,
/// Gini extremo: pocos concentran la energía, la mayoría malvive.
Imperio,
/// Mucha materia, mucha energía, Gini moderado: prosperidad amplia.
EdadDeOro,
/// Más materia que pob., reservas creciendo: el motor está "respirando".
Auge,
/// Default: nada extremo, el sistema flota.
Equilibrio,
}
impl Epoch {
/// Etiqueta corta y legible para HUD/CSV. Sin tilde donde podría romper
/// renderers ASCII-only.
pub fn label(self) -> &'static str {
match self {
Epoch::Colapso => "colapso",
Epoch::Hambruna => "hambruna",
Epoch::Imperio => "imperio",
Epoch::EdadDeOro => "edad-de-oro",
Epoch::Auge => "auge",
Epoch::Equilibrio => "equilibrio",
}
}
/// Clasifica el mundo según `stats`. Orden de prelación: colapso →
/// hambruna → imperio → edad-de-oro → auge → equilibrio. La primera
/// regla que matchea gana — los umbrales están elegidos para que sólo
/// una matchee a la vez en la práctica.
pub fn classify(stats: &WorldStats) -> Epoch {
// 1. Colapso: muy poca gente — o se está extinguiendo o ya pasó.
if stats.n < 5 {
return Epoch::Colapso;
}
let nf = stats.n as f32;
let energia_por_capita = stats.total_energia / nf;
let materia_por_capita = stats.total_materia / nf;
// 2. Hambruna: muchos, poca energía y poca materia disponible.
if energia_por_capita < 8.0 && materia_por_capita < 30.0 {
return Epoch::Hambruna;
}
// 3. Imperio: concentración brutal de energía aunque haya recursos.
if stats.gini_energia > 0.55 {
return Epoch::Imperio;
}
// 4. Edad de oro: holgura energética y suelo fértil, sin grandes
// diferencias. El umbral de materia es absoluto para que mundos
// chicos no clasifiquen como "edad de oro" a falta de masa.
if energia_por_capita > 25.0
&& materia_por_capita > 60.0
&& stats.gini_energia < 0.35
{
return Epoch::EdadDeOro;
}
// 5. Auge: materia abundante sin que la energía haya explotado aún.
// Si materia_por_capita es alto pero no llegamos al combo de
// "edad de oro" es porque la energía aún no se reparte — está
// pasando algo bueno pero todavía no llegó a la mesa.
if materia_por_capita > 80.0 {
return Epoch::Auge;
}
// 6. Default.
Epoch::Equilibrio
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::World;
fn stats_from(
n: usize,
energia: f32,
materia: f32,
gini: f32,
) -> WorldStats {
WorldStats {
n,
gini_energia: gini,
var_psi: [0.0; 4],
action_counts: [0; 6],
total_materia: materia,
total_psique: 0.0,
total_poder: 0.0,
total_oro: 0.0,
total_degradacion: 0.0,
mean_edad: 0.0,
total_energia: energia,
}
}
#[test]
fn colapso_when_population_is_tiny() {
let s = stats_from(2, 100.0, 100.0, 0.0);
assert_eq!(Epoch::classify(&s), Epoch::Colapso);
}
#[test]
fn hambruna_when_per_capita_is_low() {
// 50 lemmings, 50 energía total (1.0/cap), 100 materia total (2.0/cap).
let s = stats_from(50, 50.0, 100.0, 0.1);
assert_eq!(Epoch::classify(&s), Epoch::Hambruna);
}
#[test]
fn imperio_when_gini_is_high() {
// Hay recursos pero un puñado concentra todo.
let s = stats_from(50, 5000.0, 5000.0, 0.7);
assert_eq!(Epoch::classify(&s), Epoch::Imperio);
}
#[test]
fn edad_de_oro_when_abundant_and_egalitarian() {
// 30/cap energía, 100/cap materia, gini bajo.
let s = stats_from(50, 1500.0, 5000.0, 0.20);
assert_eq!(Epoch::classify(&s), Epoch::EdadDeOro);
}
#[test]
fn auge_when_materia_abundant_but_energy_modest() {
// Mucha materia/cap pero energía/cap insuficiente para edad de oro.
let s = stats_from(50, 600.0, 5000.0, 0.30);
assert_eq!(Epoch::classify(&s), Epoch::Auge);
}
#[test]
fn equilibrio_is_the_default() {
let s = stats_from(50, 700.0, 2000.0, 0.30);
assert_eq!(Epoch::classify(&s), Epoch::Equilibrio);
}
#[test]
fn empty_world_collapses() {
let w = World::new(4, 4);
let s = WorldStats::from_world(&w);
assert_eq!(Epoch::classify(&s), Epoch::Colapso);
}
#[test]
fn label_is_stable_and_ascii_safe() {
// Sanity para CSV/logs: ningún arquetipo emite cadena vacía.
for e in [
Epoch::Colapso,
Epoch::Hambruna,
Epoch::Imperio,
Epoch::EdadDeOro,
Epoch::Auge,
Epoch::Equilibrio,
] {
let l = e.label();
assert!(!l.is_empty());
assert!(l.is_ascii());
}
}
}
@@ -0,0 +1,455 @@
//! Eventos discretos — Fase D.1 del simulador.
//!
//! Hasta ahora el mundo evolucionaba sólo por su dinámica interna
//! (difusión, agentes, Conceptos estáticos). Los eventos discretos son
//! **perturbaciones puntuales** que el experimentador inyecta en ticks
//! específicos para medir la respuesta poblacional: una sequía, una
//! noticia, una pandemia mental.
//!
//! Cada `Event` lleva el `tick` exacto en el que se dispara. El CLI carga
//! una *timeline* JSON (lista ordenada de eventos) y antes de cada
//! `tick()` aplica los que coinciden con el reloj global.
//!
//! Determinismo: la aplicación es lineal (sin random), los eventos se
//! procesan en orden de aparición en la lista. Mismas listas en x86 y
//! ARM → mismas trayectorias bit-exactas.
use crate::lemmings::{PSI_CORRUPTIBILIDAD, PSI_CURIOSIDAD, PSI_MIEDO, PSI_ORDEN};
use crate::world::World;
use serde::{Deserialize, Serialize};
/// Identificador semántico de una capa del Sustrato. Se serializa como
/// string (`"materia"`, `"psique"`, …) para que las timelines JSON sean
/// legibles a ojo, no como bytes opacos.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LayerId {
Materia,
Psique,
Poder,
Oro,
Degradacion,
}
/// Variantes de evento. Diseñadas para ser ortogonales: cada una toca
/// exactamente un eje del mundo (capa de grilla, vector_psi de agentes, o
/// la lista de agentes mismos). Se evita el "mega-evento" porque rompe
/// la composabilidad.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum EventKind {
/// Suma `amount` (con falloff lineal) a la capa indicada en una región
/// circular. `amount` puede ser negativo (drenaje). Modela: sequía,
/// descubrimiento de oro, plaga sobre la materia, contaminación.
Shock {
layer: LayerId,
x: f32,
y: f32,
radius: f32,
amount: f32,
},
/// Suma un delta a `vector_psi` de los agentes en una región circular,
/// con falloff lineal en el centro→borde. Modela: noticia, manifiesto,
/// shock cultural. Cero efecto sobre la grilla.
PsiNudge {
x: f32,
y: f32,
radius: f32,
delta_psi: [f32; 4],
},
/// Spawnea `n` agentes con `psi/energia/accion` iguales. Si `radius > 0`
/// y `n > 1`, los dispersa en una rejilla en espiral de Vogel
/// (determinista, simétrica) dentro del círculo; si `radius == 0` o
/// `n == 1`, todos quedan en `(x, y)`. Modela: migración, refugiados,
/// nacimiento de una colonia.
Spawn {
x: f32,
y: f32,
n: u32,
radius: f32,
energia: f32,
psi: [f32; 4],
accion: u8,
},
/// Mata todos los agentes dentro del radio. Determinista total: la
/// fracción que muere no es probabilística — todo el que está
/// adentro, muere. Modela: pandemia regional, genocidio, terremoto
/// localizado. Para fracciones parciales, encadená varios `Kill` con
/// radios concéntricos en distintos ticks.
Kill { x: f32, y: f32, radius: f32 },
}
/// Un evento etiquetado con el tick en que debe dispararse.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event {
/// Reloj global (`World::tick_count`) en el que se aplica.
pub tick: u64,
#[serde(flatten)]
pub kind: EventKind,
}
/// Aplica un único evento al mundo. Funcionalmente puro respecto del tick
/// (no consulta `world.tick_count` — quién llame decide *cuándo* lo aplica
/// según su propia política).
pub fn apply_event(world: &mut World, ev: &EventKind) {
match ev {
EventKind::Shock { layer, x, y, radius, amount } => {
apply_shock_on_layer(world, *layer, *x, *y, *radius, *amount);
}
EventKind::PsiNudge { x, y, radius, delta_psi } => {
apply_psi_nudge(world, *x, *y, *radius, *delta_psi);
}
EventKind::Spawn { x, y, n, radius, energia, psi, accion } => {
apply_spawn(world, *x, *y, *n, *radius, *energia, *psi, *accion);
}
EventKind::Kill { x, y, radius } => {
apply_kill(world, *x, *y, *radius);
}
}
}
fn apply_shock_on_layer(
world: &mut World,
layer: LayerId,
x: f32,
y: f32,
radius: f32,
amount: f32,
) {
if radius <= 0.0 {
return;
}
let r2 = radius * radius;
let w = world.grid.width;
let h = world.grid.height;
let xmin = ((x - radius).floor() as i64).max(0) as usize;
let xmax_raw = ((x + radius).ceil() as i64).max(0) as usize;
let xmax = xmax_raw.min(w.saturating_sub(1));
let ymin = ((y - radius).floor() as i64).max(0) as usize;
let ymax_raw = ((y + radius).ceil() as i64).max(0) as usize;
let ymax = ymax_raw.min(h.saturating_sub(1));
if xmin >= w || ymin >= h {
return;
}
for cy in ymin..=ymax {
for cx in xmin..=xmax {
let dx = cx as f32 - x;
let dy = cy as f32 - y;
let d2 = dx * dx + dy * dy;
if d2 > r2 {
continue;
}
let falloff = 1.0 - libm::sqrtf(d2 / r2);
let idx = world.grid.idx(cx, cy);
let delta = amount * falloff;
match layer {
LayerId::Materia => world.grid.materia[idx] += delta,
LayerId::Psique => world.grid.psique[idx] += delta,
LayerId::Poder => world.grid.poder[idx] += delta,
LayerId::Oro => world.grid.oro[idx] += delta,
LayerId::Degradacion => world.grid.degradacion[idx] += delta,
}
}
}
}
/// Spawnea `n` agentes determinísticamente. Espiral de Vogel
/// (golden-angle): para `k ∈ 0..n`, `θ_k = k · 137.5077°` y
/// `r_k = radius · sqrt(k / (n-1))`. Distribuye uniformemente sin RNG —
/// el patrón es bit-exacto cross-platform vía libm.
fn apply_spawn(
world: &mut World,
x: f32,
y: f32,
n: u32,
radius: f32,
energia: f32,
psi: [f32; 4],
accion: u8,
) {
if n == 0 {
return;
}
let max_x = world.grid.width as f32 - 1.0;
let max_y = world.grid.height as f32 - 1.0;
let radius_eff = radius.max(0.0);
// Golden angle en radianes: π · (3 √5).
let golden = std::f32::consts::PI * (3.0 - libm::sqrtf(5.0));
let nf = n as f32;
for k in 0..n {
let (px, py) = if radius_eff > 0.0 && n > 1 {
let kf = k as f32;
let theta = kf * golden;
// Distancia normalizada por raíz cuadrada — distribución uniforme
// en el disco. `+ 0.5` centra el primer punto fuera del origen.
let r = radius_eff * libm::sqrtf((kf + 0.5) / nf);
(
x + r * libm::cosf(theta),
y + r * libm::sinf(theta),
)
} else {
(x, y)
};
let px = px.clamp(0.0, max_x);
let py = py.clamp(0.0, max_y);
let i = world.lemmings.spawn(px, py, energia, psi);
world.lemmings.accion[i] = accion.min(5);
}
}
/// Mata determinísticamente todos los agentes dentro del radio. Recorre
/// índices al revés para que `swap_remove` no invalide los menores que
/// todavía no procesamos. Bit-exacto cross-platform.
fn apply_kill(world: &mut World, x: f32, y: f32, radius: f32) {
if radius <= 0.0 {
return;
}
let r2 = radius * radius;
// Recolectar índices a matar primero, luego matarlos en orden decreciente.
let mut to_kill: Vec<usize> = Vec::new();
for i in 0..world.lemmings.len() {
let dx = world.lemmings.pos_x[i] - x;
let dy = world.lemmings.pos_y[i] - y;
if dx * dx + dy * dy <= r2 {
to_kill.push(i);
}
}
// Sort descendente: `swap_remove` mueve el último al hueco; si vamos
// de mayor a menor, los índices menores siguen siendo válidos.
to_kill.sort_unstable_by(|a, b| b.cmp(a));
for i in to_kill {
world.lemmings.remove(i);
}
}
fn apply_psi_nudge(world: &mut World, x: f32, y: f32, radius: f32, delta: [f32; 4]) {
if radius <= 0.0 {
return;
}
let r2 = radius * radius;
for i in 0..world.lemmings.len() {
let dx = world.lemmings.pos_x[i] - x;
let dy = world.lemmings.pos_y[i] - y;
let d2 = dx * dx + dy * dy;
if d2 > r2 {
continue;
}
let falloff = 1.0 - libm::sqrtf(d2 / r2);
let psi = &mut world.lemmings.vector_psi[i];
psi[PSI_ORDEN] += delta[PSI_ORDEN] * falloff;
psi[PSI_MIEDO] += delta[PSI_MIEDO] * falloff;
psi[PSI_CURIOSIDAD] += delta[PSI_CURIOSIDAD] * falloff;
psi[PSI_CORRUPTIBILIDAD] += delta[PSI_CORRUPTIBILIDAD] * falloff;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shock_materia_inyecta_y_falloff_lineal() {
let mut w = World::new(20, 20);
apply_event(
&mut w,
&EventKind::Shock {
layer: LayerId::Materia,
x: 10.0,
y: 10.0,
radius: 4.0,
amount: 100.0,
},
);
let center = w.grid.idx(10, 10);
let halfway = w.grid.idx(12, 10);
let edge = w.grid.idx(14, 10); // distancia 4 = radius → falloff 0
assert!((w.grid.materia[center] - 100.0).abs() < 1e-4);
assert!(w.grid.materia[halfway] > 0.0);
assert!(w.grid.materia[halfway] < 100.0);
assert!(w.grid.materia[edge].abs() < 1e-5);
}
#[test]
fn shock_negativo_drena() {
let mut w = World::new(8, 8);
for c in w.grid.materia.iter_mut() {
*c = 50.0;
}
apply_event(
&mut w,
&EventKind::Shock {
layer: LayerId::Materia,
x: 4.0,
y: 4.0,
radius: 2.0,
amount: -30.0,
},
);
let center = w.grid.idx(4, 4);
assert!(
(w.grid.materia[center] - 20.0).abs() < 1e-4,
"drenó {} en lugar de 30",
50.0 - w.grid.materia[center]
);
}
#[test]
fn psi_nudge_empuja_vector_psi_de_agentes_en_radio() {
let mut w = World::new(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]); // dentro
w.lemmings.spawn(0.0, 0.0, 30.0, [0.5; 4]); // afuera
let psi_pre_outside = w.lemmings.vector_psi[1];
apply_event(
&mut w,
&EventKind::PsiNudge {
x: 10.0,
y: 10.0,
radius: 5.0,
delta_psi: [0.3, 0.0, 0.0, 0.0],
},
);
// Agente en el centro: falloff = 1, psi[0] sube 0.3.
assert!((w.lemmings.vector_psi[0][0] - 0.3).abs() < 1e-5);
// Agente fuera del radio: sin cambios.
assert_eq!(w.lemmings.vector_psi[1], psi_pre_outside);
}
#[test]
fn spawn_zero_n_is_noop() {
let mut w = World::new(20, 20);
apply_event(
&mut w,
&EventKind::Spawn {
x: 10.0, y: 10.0, n: 0, radius: 5.0,
energia: 30.0, psi: [0.5; 4], accion: 0,
},
);
assert_eq!(w.lemmings.len(), 0);
}
#[test]
fn spawn_one_agent_at_point() {
let mut w = World::new(20, 20);
apply_event(
&mut w,
&EventKind::Spawn {
x: 10.0, y: 10.0, n: 1, radius: 0.0,
energia: 42.0, psi: [0.1, 0.2, 0.3, 0.4], accion: 3,
},
);
assert_eq!(w.lemmings.len(), 1);
assert_eq!(w.lemmings.pos_x[0], 10.0);
assert_eq!(w.lemmings.pos_y[0], 10.0);
assert_eq!(w.lemmings.energia[0], 42.0);
assert_eq!(w.lemmings.vector_psi[0], [0.1, 0.2, 0.3, 0.4]);
assert_eq!(w.lemmings.accion[0], 3);
}
#[test]
fn spawn_n_disperses_in_radius_deterministically() {
// Espiral de Vogel: para n=20, radius=5, todos los agentes deben
// caer dentro del círculo (distancia ≤ radius) y la distribución
// debe ser repetible bit-exacto.
let mut a = World::new(40, 40);
let mut b = World::new(40, 40);
let ev = EventKind::Spawn {
x: 20.0, y: 20.0, n: 20, radius: 5.0,
energia: 30.0, psi: [0.5; 4], accion: 1,
};
apply_event(&mut a, &ev);
apply_event(&mut b, &ev);
assert_eq!(a.lemmings.len(), 20);
assert_eq!(a.lemmings.pos_x, b.lemmings.pos_x);
assert_eq!(a.lemmings.pos_y, b.lemmings.pos_y);
// Todos dentro del círculo (+ pequeña tolerancia por sqrt).
for i in 0..a.lemmings.len() {
let dx = a.lemmings.pos_x[i] - 20.0;
let dy = a.lemmings.pos_y[i] - 20.0;
let d = libm::sqrtf(dx * dx + dy * dy);
assert!(d <= 5.0 + 1e-3, "agente {i} fuera del círculo: d={d}");
}
// No todos en el mismo punto (verificación de dispersión efectiva).
let center_dx = a.lemmings.pos_x[0] - 20.0;
let center_dy = a.lemmings.pos_y[0] - 20.0;
let other_dx = a.lemmings.pos_x[10] - 20.0;
let other_dy = a.lemmings.pos_y[10] - 20.0;
assert!(
(center_dx - other_dx).abs() > 0.5 || (center_dy - other_dy).abs() > 0.5,
"agentes 0 y 10 demasiado cerca — la espiral no dispersó"
);
}
#[test]
fn kill_removes_agents_inside_radius() {
let mut w = World::new(40, 40);
// 3 dentro del radio (centro 20,20, r=5), 2 afuera.
w.lemmings.spawn(20.0, 20.0, 30.0, [0.0; 4]); // dentro
w.lemmings.spawn(22.0, 20.0, 30.0, [0.1; 4]); // dentro
w.lemmings.spawn(19.0, 21.0, 30.0, [0.2; 4]); // dentro
w.lemmings.spawn(30.0, 30.0, 30.0, [0.3; 4]); // afuera
w.lemmings.spawn(5.0, 5.0, 30.0, [0.4; 4]); // afuera
apply_event(&mut w, &EventKind::Kill { x: 20.0, y: 20.0, radius: 5.0 });
assert_eq!(w.lemmings.len(), 2);
// Los sobrevivientes son los dos lejos — sus psi se preservan
// (no exigimos orden por swap_remove, pero deben ser los originales).
let psis: Vec<[f32; 4]> = w.lemmings.vector_psi.clone();
assert!(psis.contains(&[0.3; 4]));
assert!(psis.contains(&[0.4; 4]));
}
#[test]
fn kill_zero_radius_is_noop() {
let mut w = World::new(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]);
apply_event(&mut w, &EventKind::Kill { x: 10.0, y: 10.0, radius: 0.0 });
assert_eq!(w.lemmings.len(), 1);
}
#[test]
fn timeline_json_roundtrip() {
let events = vec![
Event {
tick: 50,
kind: EventKind::Shock {
layer: LayerId::Materia,
x: 10.0,
y: 10.0,
radius: 5.0,
amount: -100.0,
},
},
Event {
tick: 100,
kind: EventKind::PsiNudge {
x: 20.0,
y: 20.0,
radius: 8.0,
delta_psi: [0.0, 0.5, 0.0, 0.0],
},
},
Event {
tick: 150,
kind: EventKind::Spawn {
x: 5.0,
y: 5.0,
n: 10,
radius: 2.0,
energia: 40.0,
psi: [0.2, 0.3, 0.4, 0.1],
accion: 1,
},
},
Event {
tick: 200,
kind: EventKind::Kill {
x: 25.0,
y: 25.0,
radius: 4.0,
},
},
];
let s = serde_json::to_string(&events).expect("serializa");
let back: Vec<Event> = serde_json::from_str(&s).expect("deserializa");
assert_eq!(events, back);
}
}
@@ -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));
}
}
@@ -0,0 +1,250 @@
//! 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;
/// Quinta componente *opcional* del psi — la dimensión de Extraversión del
/// modelo Big Five. Mapea a sociabilidad / asertividad / energía social.
/// Vive en su propio `Vec<f32>` (`Lemmings::psi5`) en lugar de extender el
/// `vector_psi` a `[f32; 5]` para preservar bit-exactitud y serde compat con
/// motores Big Four históricos.
pub const PSI_EXTRAVERSION: usize = 4;
/// Valor default de `psi5` cuando se hace `spawn` sin especificarlo o cuando
/// un `World` antiguo se deserializa sin la columna. Elegimos 0.5 para que
/// sea "ni introvertido ni extravertido" y la psicología quede neutral.
pub const PSI_EXTRAVERSION_DEFAULT: f32 = 0.5;
/// 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>,
/// Ticks restantes de captura por un `BehaviorHack` de un Concepto.
/// Mientras es > 0, el Lemming ejecuta su `accion` sin reevaluar
/// transiciones (la captura sobrescribe a la desesperación).
pub hack_lock: Vec<u32>,
/// Quinta dimensión opcional del psi — Big Five Extraversion. Cuando el
/// motor corre en modo Big Four (`SimParams::big_five == false`), este
/// vector se mantiene poblado con el default `PSI_EXTRAVERSION_DEFAULT`
/// pero no afecta ninguna ecuación. Saves históricos sin esta columna
/// vienen vacíos y se rellenan vía [`Lemmings::ensure_psi5_len`].
#[serde(default)]
pub psi5: Vec<f32>,
}
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. La quinta
/// componente del psi (Big Five Extraversion) queda en el default neutral
/// `PSI_EXTRAVERSION_DEFAULT`; usar [`Lemmings::spawn_big5`] para fijarla.
pub fn spawn(&mut self, x: f32, y: f32, energia: f32, psi: [f32; 4]) -> usize {
self.spawn_big5(x, y, energia, psi, PSI_EXTRAVERSION_DEFAULT)
}
/// Como [`spawn`], pero pone explícitamente el quinto componente `psi5`
/// (Big Five Extraversion). Usar cuando el motor corre con
/// `SimParams::big_five = true` y los agentes nacen con una distribución
/// de extraversión no trivial.
pub fn spawn_big5(
&mut self,
x: f32,
y: f32,
energia: f32,
psi: [f32; 4],
psi5: f32,
) -> 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);
self.hack_lock.push(0);
self.psi5.push(psi5);
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);
self.hack_lock.swap_remove(i);
// El `psi5` de saves Big Four puede estar vacío — sólo recortamos si
// hay algo. Mantiene la invariante "len == pos_x.len() len == 0".
if !self.psi5.is_empty() {
self.psi5.swap_remove(i);
}
}
/// Asegura que `psi5` tenga el mismo largo que `pos_x`, rellenando con
/// `PSI_EXTRAVERSION_DEFAULT` lo que falte. Idempotente. Sirve para
/// "ascender" saves Big Four a Big Five sin perder la población vieja.
pub fn ensure_psi5_len(&mut self) {
let n = self.pos_x.len();
if self.psi5.len() < n {
self.psi5.resize(n, PSI_EXTRAVERSION_DEFAULT);
} else if self.psi5.len() > n {
self.psi5.truncate(n);
}
}
/// Lectura segura del quinto componente. Cuando `psi5` está vacío
/// (saves históricos Big Four) devuelve `PSI_EXTRAVERSION_DEFAULT`; con
/// `i` fuera de rango, también — usar sólo con índices válidos.
pub fn psi5_at(&self, i: usize) -> f32 {
self.psi5.get(i).copied().unwrap_or(PSI_EXTRAVERSION_DEFAULT)
}
/// 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)
}
/// Índice del Lemming vivo con **menor energía** distinto de `i`. Es
/// el destinatario de `act_intercambiar` cuando la estrategia es
/// "redistribución solidaria": en lugar de donar al vecino físico
/// más cercano (que puede ser igualmente pobre), busca al más
/// necesitado del mundo. Determinista: ante empate, menor índice.
pub fn poorest(&self, i: usize) -> Option<usize> {
let mut best: Option<(usize, f32)> = None;
for j in 0..self.len() {
if j == i {
continue;
}
let e = self.energia[j];
if best.map(|(_, be)| e < be).unwrap_or(true) {
best = Some((j, e));
}
}
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 spawn_default_pone_psi5_neutral() {
let mut l = Lemmings::new();
let i = l.spawn(1.0, 1.0, 10.0, [0.5; 4]);
assert_eq!(l.psi5.len(), l.pos_x.len());
assert_eq!(l.psi5_at(i), PSI_EXTRAVERSION_DEFAULT);
}
#[test]
fn spawn_big5_fija_psi5_explicito() {
let mut l = Lemmings::new();
let i = l.spawn_big5(0.0, 0.0, 10.0, [0.5; 4], 0.9);
assert!((l.psi5_at(i) - 0.9).abs() < 1e-6);
}
#[test]
fn ensure_psi5_len_completa_columna_faltante() {
// Simula un save Big Four cargado por serde sin la columna psi5.
let mut l = Lemmings {
pos_x: vec![1.0, 2.0, 3.0],
pos_y: vec![1.0, 2.0, 3.0],
edad: vec![0; 3],
energia: vec![10.0; 3],
vector_psi: vec![[0.5; 4]; 3],
accion: vec![0; 3],
hack_lock: vec![0; 3],
psi5: Vec::new(),
};
l.ensure_psi5_len();
assert_eq!(l.psi5.len(), 3);
for v in &l.psi5 {
assert!((*v - PSI_EXTRAVERSION_DEFAULT).abs() < 1e-6);
}
}
#[test]
fn remove_actualiza_psi5() {
let mut l = Lemmings::new();
l.spawn_big5(0.0, 0.0, 10.0, [0.0; 4], 0.1);
l.spawn_big5(0.0, 0.0, 10.0, [0.0; 4], 0.9);
assert_eq!(l.psi5, vec![0.1, 0.9]);
l.remove(0);
// swap_remove deja el último (0.9) en el hueco 0.
assert_eq!(l.psi5, vec![0.9]);
}
#[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);
}
}
@@ -0,0 +1,48 @@
//! `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.
//! - [`conceptos`] — emisores de campo metaprogramables (datos puros).
//!
//! 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 conceptos;
pub mod epoch;
pub mod events;
pub mod grid;
pub mod lemmings;
pub mod metrics;
pub mod params;
pub mod psi_metrics;
pub mod world;
pub mod worldgen;
pub use conceptos::{BehaviorHack, Concepto, Conceptos, LayerMods, Persuasion, Trigger};
pub use epoch::Epoch;
pub use events::{apply_event, Event, EventKind, LayerId};
pub use grid::Grid;
pub use lemmings::{
Lemmings, PSI_CORRUPTIBILIDAD, PSI_CURIOSIDAD, PSI_EXTRAVERSION, PSI_EXTRAVERSION_DEFAULT,
PSI_MIEDO, PSI_ORDEN,
};
pub use metrics::WorldStats;
pub use psi_metrics::{
kmeans_psi, morans_i_for, KMeansResult, PsiMetrics, KMEANS_EPS, KMEANS_K, KMEANS_MAX_ITER,
MORANS_RADIUS_DEFAULT, POLARIZATION_ALPHA, POLARIZATION_BINS,
};
pub use params::{
ActionPolicy, SimParams, TradeTarget, RELIEVE_DEGRADACION, RELIEVE_MATERIA, RELIEVE_ORO,
RELIEVE_PODER, RELIEVE_PSIQUE,
};
pub use world::{select_action_argmax, select_action_argmax_big5, Action, World};
@@ -0,0 +1,235 @@
//! Estadísticas agregadas del mundo — **lectura pura, no muta nada**.
//!
//! Pensado para alimentar HUDs, CSV del CLI y eventuales tests de invariantes
//! macro (¿la energía total decae? ¿el Gini se dispara con ciertos packs?).
//!
//! Determinista bit-exacto: itera en orden lineal, suma `f32` en el mismo
//! orden en cualquier plataforma, sin paralelismo ni hashing.
use crate::world::World;
/// Foto del estado agregado del mundo en un instante.
///
/// Convención del `vector_psi`: las 4 componentes en orden
/// `[ORDEN, MIEDO, CURIOSIDAD, CORRUPTIBILIDAD]`.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct WorldStats {
/// Cantidad de Lemmings vivos.
pub n: usize,
/// Coeficiente de Gini sobre `energia` ∈ [0, 1]. 0 = perfecta igualdad,
/// 1 = un único agente concentra todo. `0.0` si `n < 2`.
pub gini_energia: f32,
/// Varianza poblacional de cada componente del `vector_psi` ∈ ℝ⁺. `0.0`
/// para componentes con `n == 0`.
pub var_psi: [f32; 4],
/// Conteo de cuántos Lemmings ejecutan cada `Action` (0..=5).
pub action_counts: [u32; 6],
/// Suma de las 5 capas del Sustrato — útil para detectar drift de masa.
pub total_materia: f32,
pub total_psique: f32,
pub total_poder: f32,
pub total_oro: f32,
pub total_degradacion: f32,
/// Media de `edad` (0 si `n == 0`).
pub mean_edad: f32,
/// Suma de `energia` (0 si `n == 0`).
pub total_energia: f32,
}
impl WorldStats {
/// Calcula todas las métricas en una sola pasada por agente + cinco
/// sumas lineales por las capas. Asignación: un `Vec<f32>` temporal del
/// largo de la población para el Gini (ordenamiento necesario).
pub fn from_world(w: &World) -> Self {
let n = w.lemmings.len();
let mut action_counts = [0u32; 6];
let mut sum_psi = [0.0f64; 4];
let mut sum_psi2 = [0.0f64; 4];
let mut sum_edad: u64 = 0;
let mut sum_energia: f64 = 0.0;
for i in 0..n {
let a = w.lemmings.accion[i];
if (a as usize) < action_counts.len() {
action_counts[a as usize] += 1;
}
let psi = w.lemmings.vector_psi[i];
for k in 0..4 {
let v = psi[k] as f64;
sum_psi[k] += v;
sum_psi2[k] += v * v;
}
sum_edad += w.lemmings.edad[i] as u64;
sum_energia += w.lemmings.energia[i] as f64;
}
// Var(X) = E[X²] E[X]²; en f64 internamente, downcast al final.
let mut var_psi = [0.0f32; 4];
if n > 0 {
let nf = n as f64;
for k in 0..4 {
let mean = sum_psi[k] / nf;
let v = (sum_psi2[k] / nf) - mean * mean;
var_psi[k] = v.max(0.0) as f32;
}
}
let mean_edad = if n > 0 { (sum_edad as f64 / n as f64) as f32 } else { 0.0 };
let gini_energia = gini_of(&w.lemmings.energia);
let g = &w.grid;
Self {
n,
gini_energia,
var_psi,
action_counts,
total_materia: sum_layer(&g.materia),
total_psique: sum_layer(&g.psique),
total_poder: sum_layer(&g.poder),
total_oro: sum_layer(&g.oro),
total_degradacion: sum_layer(&g.degradacion),
mean_edad,
total_energia: sum_energia as f32,
}
}
}
/// Suma de `f32` acumulada en `f64` para no perder precisión en grillas
/// grandes — la salida es `f32` pero el orden de la suma queda fijado por
/// el orden lineal del slice, así que sigue siendo bit-exacto.
fn sum_layer(layer: &[f32]) -> f32 {
let mut acc: f64 = 0.0;
for &v in layer {
acc += v as f64;
}
acc as f32
}
/// Gini sobre energía no-negativa. Implementación clásica vía orden ascendente:
///
/// ```text
/// G = ( 2·Σ(i · x_i) (n+1)·Σx_i ) / ( n · Σx_i )
/// ```
///
/// Robusto a entradas vacías (→ 0.0), a `Σx_i == 0` (→ 0.0) y a valores
/// negativos (los considera 0 — la energía nunca debería ser negativa pero
/// `act_degradar` puede dejarla en rojo un tick antes de la cosecha).
fn gini_of(values: &[f32]) -> f32 {
let n = values.len();
if n < 2 {
return 0.0;
}
let mut v: Vec<f32> = values.iter().map(|x| x.max(0.0)).collect();
// `sort_by` con comparación total — `f32` no implementa `Ord`. Las NaN
// son imposibles aquí (sólo aritmética cerrada sobre f32 finitos), pero
// por las dudas las tratamos como iguales para no panickear.
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut sum: f64 = 0.0;
let mut weighted: f64 = 0.0;
for (i, &x) in v.iter().enumerate() {
sum += x as f64;
weighted += (i + 1) as f64 * x as f64;
}
if sum <= 0.0 {
return 0.0;
}
let nf = n as f64;
let g = (2.0 * weighted - (nf + 1.0) * sum) / (nf * sum);
g.clamp(0.0, 1.0) as f32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SimParams;
#[test]
fn empty_world_yields_zeros() {
let w = World::new(4, 4);
let s = WorldStats::from_world(&w);
assert_eq!(s.n, 0);
assert_eq!(s.gini_energia, 0.0);
assert_eq!(s.action_counts, [0; 6]);
assert_eq!(s.var_psi, [0.0; 4]);
assert_eq!(s.mean_edad, 0.0);
}
#[test]
fn gini_zero_when_all_equal() {
assert_eq!(gini_of(&[10.0, 10.0, 10.0, 10.0]), 0.0);
}
#[test]
fn gini_one_when_only_one_has_value() {
// 0,0,0,…,100 → cerca de 1 (no exactamente; la cota teórica es (n-1)/n)
let n = 100;
let mut v = vec![0.0f32; n];
v[n - 1] = 100.0;
let g = gini_of(&v);
let expected_upper = (n as f32 - 1.0) / n as f32;
assert!((g - expected_upper).abs() < 1e-3, "gini={g}");
}
#[test]
fn gini_rises_with_inequality() {
let flat = gini_of(&[5.0, 5.0, 5.0, 5.0]);
let mid = gini_of(&[2.0, 4.0, 6.0, 8.0]);
let sharp = gini_of(&[0.5, 0.5, 0.5, 18.5]);
assert!(flat < mid);
assert!(mid < sharp);
}
#[test]
fn action_counts_match_population_distribution() {
let mut w = World::new(8, 8);
// 3 con accion=2, 2 con accion=0, 1 con accion=5.
for _ in 0..3 {
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0; 4]);
w.lemmings.accion[i] = 2;
}
for _ in 0..2 {
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0; 4]);
w.lemmings.accion[i] = 0;
}
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0; 4]);
w.lemmings.accion[i] = 5;
let s = WorldStats::from_world(&w);
assert_eq!(s.action_counts[0], 2);
assert_eq!(s.action_counts[2], 3);
assert_eq!(s.action_counts[5], 1);
assert_eq!(s.n, 6);
}
#[test]
fn var_psi_zero_when_population_is_uniform() {
let mut w = World::new(4, 4);
for _ in 0..10 {
w.lemmings.spawn(0.0, 0.0, 1.0, [0.5, 0.5, 0.5, 0.5]);
}
let s = WorldStats::from_world(&w);
for k in 0..4 {
assert!(s.var_psi[k] < 1e-6, "var[{k}] no es cero: {}", s.var_psi[k]);
}
}
#[test]
fn layer_totals_track_grid_state() {
let mut w = World::new(4, 4);
let idx = w.grid.idx(1, 1);
w.grid.materia[idx] = 5.0;
w.grid.oro[idx] = 3.0;
let s = WorldStats::from_world(&w);
assert!((s.total_materia - 5.0).abs() < 1e-5);
assert!((s.total_oro - 3.0).abs() < 1e-5);
}
#[test]
fn stats_silent_passthrough_with_simparams() {
// Sanity: SimParams sigue siendo construible sin el módulo nuevo.
let _ = SimParams::default();
let w = World::new(2, 2);
let _ = WorldStats::from_world(&w);
}
}
@@ -0,0 +1,398 @@
//! 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};
/// Política de elección de la `accion` base de los Lemmings.
///
/// El motor histórico fija la acción una sola vez (en `seed` / al replicarse
/// se hereda del padre) y nunca la recalcula salvo por transiciones de
/// supervivencia (desesperación → pelear) o captura por Conceptos
/// (`apply_hacks`). Eso convierte al `vector_psi` en una variable casi
/// decorativa: la psicología del agente no decide qué hace, sólo cómo se
/// mueve.
///
/// `PsiArgmax` cierra el bucle: cada `policy_reeval_period` ticks, los
/// agentes libres (sin `hack_lock`) recalculan su byte de acción tomando el
/// `argmax` de `action_weights · vector_psi`. Determinista bit-exacto: sin
/// RNG, sin softmax — comparación lineal de 6 escalares con tie-break por
/// menor índice. Es el complemento mínimo que vuelve endógena la
/// heterogeneidad poblacional sin romper §1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActionPolicy {
/// Comportamiento histórico: la acción se asigna al spawn (o se hereda
/// del padre en `Replicar`) y sólo cambia por transiciones de
/// supervivencia o hacks. La psicología no decide qué hace el agente.
Fixed,
/// La acción se reelige cada `policy_reeval_period` ticks como
/// `argmax(action_weights · vector_psi)`. Determinista, sin RNG.
PsiArgmax,
}
impl Default for ActionPolicy {
fn default() -> Self {
ActionPolicy::Fixed
}
}
/// A quién dona un Lemming cuando ejecuta `act_intercambiar`. Permite
/// elegir entre la semántica original (vecino físico) y la redistribución
/// solidaria (el más necesitado del mundo) — esta última es la que cierra
/// el ciclo termodinámico y produce un punto fijo `N* > 0`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TradeTarget {
/// Dona al vecino físico más cercano. Comportamiento histórico de
/// `act_intercambiar`. Conserva la semántica geográfica pero no
/// redistribuye eficientemente — la energía oscila localmente y los
/// Replicadores aislados se agotan.
Nearest,
/// Dona al lemming con menor energía global (O(n) determinista).
/// "Solidaridad universal": los Traders ricos alimentan a los
/// Replicadores pobres, sostiene la natalidad.
Poorest,
}
impl Default for TradeTarget {
fn default() -> Self {
TradeTarget::Poorest
}
}
/// 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,
/// Umbral de energía por encima del cual el agente se fuerza a
/// `Replicar` — el atractor simétrico de la desesperación. Cierra el
/// ciclo termodinámico: sin esta transición, los Replicadores
/// genéticos se agotan en pocas generaciones y `dN/dt < 0`
/// estructural. `0.0` deshabilita la transición (motor pre-2026-05-26).
#[serde(default)]
pub abundance_threshold: f32,
/// A quién dona un Lemming cuando ejecuta `act_intercambiar`.
/// Default `Poorest` — la redistribución solidaria es la que cierra
/// el ciclo termodinámico del sistema. Ver [`TradeTarget`].
#[serde(default)]
pub trade_target: TradeTarget,
/// Edad máxima; al superarla el agente muere.
pub max_edad: u32,
/// Costo metabólico basal: energía drenada cada tick a TODOS los
/// lemmings por el simple hecho de estar vivos, independiente de la
/// acción. Es el freno termodinámico que estabiliza la población —
/// sin él, los Extractores acumulan E sin techo y la natalidad
/// (vía abundance side-effect) se descontrola. Con él, dE/dt → 0
/// cuando N llega a la capacidad de carga del territorio.
/// `0.0` deshabilita (motor pre-2026-05-26).
#[serde(default)]
pub metabolic_cost: f32,
/// Fracción que cada celda difunde hacia sus 4 vecinas por tick (0-1).
pub diffusion_rate: f32,
/// Tasa de pérdida natural (entropía) de los campos por tick (0-1).
pub entropy_rate: f32,
/// Pesos por capa que definen el **relieve físico** que sienten los
/// lemmings al moverse (no es lo mismo que el `ZWeights` del render —
/// el render puede mostrar una vista distinta de la "altura"). El
/// gradiente del relieve atrae/repele en `act_mover` y cobra
/// `climb_cost` extra de energía por unidad subida.
pub relieve: [f32; 5],
/// Energía consumida por unidad de relieve **subido** en `act_mover`
/// (los lemmings no pagan extra al bajar). El score de un candidato
/// se reduce en `climb_cost · max(0, z_dst z_src)` antes de elegir.
pub climb_cost: f32,
/// Período del ciclo estacional, en ticks. Una estación completa
/// (verano→invierno→verano) toma `season_period` ticks. `0` deshabilita
/// el ciclo y el motor se comporta como antes (campos sin modulación).
#[serde(default)]
pub season_period: u32,
/// Amplitud del ciclo estacional, ∈ [0, 1]. Modula multiplicativamente
/// `diffusion_rate` y `entropy_rate` por un factor
/// `1 + amp · sin(2π · t / period)`. Con `0.0` no hay ciclo (equivalente
/// a `season_period = 0`). Es el "clima" del mundo: en verano (factor
/// alto) los campos difunden y decaen más rápido; en invierno se
/// congelan. Cero semántica de calendario — son sólo dos floats que
/// pasan por la libm.
#[serde(default)]
pub season_amplitude: f32,
/// Fracción del *espacio libre* que la naturaleza repuebla con materia
/// por tick (regrowth logístico). En cada celda:
/// `materia += regrowth_rate · max(0, carrying_capacity materia)`.
/// Vive *dentro* de la fase de difusión — no agrega una fase nueva al
/// §1.5. Es el cierre termodinámico del motor: sin esta fuente la
/// entropía vence siempre y la población se extingue.
#[serde(default)]
pub regrowth_rate: f32,
/// Asíntota del regrowth: hacia este valor empuja la materia por
/// celda. Inyecciones por Conceptos o por muerte de lemmings pueden
/// superarlo; el regrowth nunca lo hace.
#[serde(default)]
pub carrying_capacity: f32,
/// Intensidad con la que el `vector_psi` del agente modula los efectos
/// de sus 5 acciones físicas (Mover, Extraer, Intercambiar, Replicar,
/// Degradar). Con `0.0` los efectos son idénticos al motor histórico
/// — bit-exacto. Con `> 0`, el psi entra en cada cantidad afín:
///
/// - `Mover`: `move_cost ← move_cost · (1 + mod · 0.5 · psi[MIEDO])`
/// — el miedoso se cansa más al moverse.
/// - `Extraer`: `extract_rate ← extract_rate · (1 + mod · psi[CORRUPTIBILIDAD])`
/// — el corrupto saca más del suelo y deja más cicatriz.
/// - `Intercambiar`: `trade_amount ← trade_amount · max(0, 1 + mod ·
/// (psi[ORDEN] psi[CORRUPTIBILIDAD]))` — el ordenado comparte, el
/// corrupto retiene.
/// - `Replicar`: `replicate_threshold ← replicate_threshold · max(0.1,
/// 1 mod · 0.3 · psi[ORDEN])` — el ordenado replica antes.
/// - `Degradar`: `fight_damage ← fight_damage · max(0, 1 + mod ·
/// (psi[CORRUPTIBILIDAD] psi[MIEDO]))` — el miedoso pega menos, el
/// corrupto más.
///
/// Rango sugerido `[0, 1]`. Valores > 1 amplifican la heterogeneidad
/// pero pueden producir efectos no-monotónicos cuando un psi extremo
/// hace flip al signo del factor (los clamps a 0/0.1 lo previenen).
#[serde(default)]
pub psi_effect_modulation: f32,
/// Política de elección de la `accion` base. Ver [`ActionPolicy`].
/// Default `Fixed` → comportamiento histórico bit-exacto.
#[serde(default)]
pub action_policy: ActionPolicy,
/// Pesos `[accion][componente_psi]` para `ActionPolicy::PsiArgmax`. Una
/// matriz 6×4 — fila `a` = qué tan atractiva es la acción `a` para cada
/// componente del psi. Cuando la política es `Fixed` se ignora.
///
/// Default semánticamente plausible (independiente del comportamiento
/// histórico porque sólo se consulta con `PsiArgmax`):
/// - `Mover` (0): premia CURIOSIDAD, penaliza MIEDO.
/// - `Extraer` (1): premia ORDEN y CORRUPTIBILIDAD.
/// - `Sincronizar` (2): premia CURIOSIDAD.
/// - `Intercambiar` (3): premia ORDEN, penaliza MIEDO.
/// - `Replicar` (4): premia ORDEN.
/// - `Degradar` (5): premia CORRUPTIBILIDAD, penaliza MIEDO.
#[serde(default = "default_action_weights")]
pub action_weights: [[f32; 4]; 6],
/// Cada cuántos ticks reelige la acción la `ActionPolicy::PsiArgmax`.
/// `0` deshabilita la reelección incluso si la política es `PsiArgmax`
/// (failsafe: la matriz sólo "se enciende" cuando hay periodo). Valores
/// típicos: 10..200. Períodos chicos pueden volver al sistema neurótico
/// (cambia de oficio cada poco); muy grandes, inerte.
#[serde(default)]
pub policy_reeval_period: u32,
/// Radio de influencia social (Fase B): cada agente acerca su
/// `vector_psi` al promedio del psi de los vecinos que estén a
/// distancia euclidiana ≤ `social_radius`. `0.0` (default) deshabilita
/// el contagio — el motor histórico no paga nada.
///
/// **Costo**: O(N²) determinista, aceptable hasta ~10k agentes por la
/// grilla típica. Sin índice espacial: para poblaciones masivas habría
/// que indexar celdas por agente — pendiente para Fase B.2.
#[serde(default)]
pub social_radius: f32,
/// Tasa de convergencia del contagio social (Fase B). Cada tick, los
/// agentes en el radio acercan su psi al promedio local por
/// `psi_nuevo = psi + rate · (psi_local psi)`. `0.0` (default) =
/// sin contagio incluso si `social_radius > 0`. Rango útil 0.01..0.20:
/// valores grandes producen conformismo brutal (todos convergen al
/// mismo psi), valores chicos preservan diversidad.
#[serde(default)]
pub contagion_rate: f32,
/// Umbral de homofilia (Fase B.2): un vecino dentro del `social_radius`
/// sólo influye al agente si su distancia psi euclidiana es menor a
/// este umbral. Mismo psi → siempre influye; psi muy distinto → no
/// influye en absoluto. Es el "sólo escucho a los míos" canónico de la
/// psicología social.
///
/// `0.0` (default) = sin filtro de homofilia → contagio universal
/// (motor B.1: produce homogeneización con tasas altas). Rango útil
/// 0.3..1.0 — con threshold chico emergen **tribus aisladas** y la
/// polarización **sube** en vez de bajar; con threshold grande, recae
/// al comportamiento de B.1.
#[serde(default)]
pub homophily_threshold: f32,
/// Activa el modelo Big Five (5 dimensiones) en vez de las 4 históricas.
/// Cuando es `true`:
/// - El contagio social incluye la quinta dimensión (Extraversion).
/// - La política `PsiArgmax` consulta `action_weights_ext` además de
/// `action_weights`.
/// - Las métricas `PsiMetrics` calculan `polarization_ext` y `moran_i_ext`.
/// - La homofilia mide distancia en 5D (en vez de 4D).
///
/// `false` (default) → bit-exacto al motor histórico Big Four.
#[serde(default)]
pub big_five: bool,
/// Columna extendida de `action_weights` para la quinta dimensión del
/// psi (Extraversion). Sólo se consulta cuando `big_five = true`. Default
/// cero → la 5ª dimensión empieza neutra y el caller la sintoniza.
///
/// Default semánticamente plausible:
/// - Mover (0), Sincronizar (2), Intercambiar (3): premian extraversión.
/// - Extraer (1), Replicar (4), Degradar (5): neutrales.
#[serde(default = "default_action_weights_ext")]
pub action_weights_ext: [f32; 6],
}
/// Default de `SimParams::action_weights_ext` — peso por acción para la 5ª
/// dimensión del psi (Big Five Extraversion). Acciones sociales (Mover,
/// Sincronizar, Intercambiar) premian extraversión; las solitarias o
/// agresivas (Extraer, Replicar, Degradar) son neutrales.
fn default_action_weights_ext() -> [f32; 6] {
// 0 Mover, 1 Extraer, 2 Sincronizar, 3 Intercambiar, 4 Replicar, 5 Degradar
[0.4, 0.0, 0.6, 0.8, 0.0, -0.2]
}
/// Default de `SimParams::action_weights` — fila por acción, columna por
/// componente del `vector_psi` (`[ORDEN, MIEDO, CURIOSIDAD, CORRUPTIBILIDAD]`).
fn default_action_weights() -> [[f32; 4]; 6] {
[
// 0 Mover O M C K
[0.0, -0.5, 1.0, 0.0],
// 1 Extraer O M C K
[0.6, 0.0, 0.0, 0.8],
// 2 Sincronizar O M C K
[0.0, 0.0, 1.0, 0.0],
// 3 Intercambiar O M C K
[1.0, -0.4, 0.0, 0.0],
// 4 Replicar O M C K
[1.0, 0.0, 0.0, 0.0],
// 5 Degradar O M C K
[0.0, -0.8, 0.0, 1.0],
]
}
/// Índices semánticos para indexar `SimParams::relieve`. Coinciden con el
/// orden de capas del `Grid`.
pub const RELIEVE_MATERIA: usize = 0;
pub const RELIEVE_PSIQUE: usize = 1;
pub const RELIEVE_PODER: usize = 2;
pub const RELIEVE_ORO: usize = 3;
pub const RELIEVE_DEGRADACION: usize = 4;
impl SimParams {
/// Factor multiplicativo del ciclo estacional para el tick `t`. Vale
/// `1.0 + season_amplitude · sin(2π · t / season_period)` cuando hay
/// ciclo activo, y `1.0` cuando `season_period == 0` o
/// `season_amplitude == 0.0`. Resultado siempre clamped a `[0, 2]` para
/// que la modulación no invierta el signo de las tasas.
///
/// **Determinismo bit-exacto**: usamos `libm::sinf` para evitar
/// divergencias entre `f32::sin` de x86 vs ARM. El argumento se calcula
/// en `f64` y se castea al final, así fases consecutivas no acumulan
/// drift por wrap-around de grandes `t`.
pub fn season_factor(&self, t: u64) -> f32 {
if self.season_period == 0 || self.season_amplitude == 0.0 {
return 1.0;
}
let period = self.season_period as f64;
// Fase en [0, 2π) — modular antes de pasar a f32 para no perder
// precisión cuando t es grande.
let phase = ((t as f64).rem_euclid(period)) / period;
let arg = (phase * std::f64::consts::TAU) as f32;
let s = libm::sinf(arg);
(1.0 + self.season_amplitude * s).clamp(0.0, 2.0)
}
}
impl Default for SimParams {
fn default() -> Self {
Self {
move_speed: 1.0,
move_cost: 0.06,
// Extracción generosa: la principal fuente de energía del sistema.
extract_rate: 2.5,
degr_per_extract: 0.02,
sync_rate: 0.10,
// Intercambio AGRESIVO: el mecanismo de redistribución que evita
// que el Gini suba a 1 y los Replicadores se agoten.
// Sin redistribución, la energía se concentra en Extractores y
// los Replicadores (que no extraen) se quedan sin combustible.
trade_amount: 1.5,
// Threshold de reproducción más alto: filtra para que sólo
// agentes con energía sustancial puedan tener hijos. Combinado
// con `abundance_threshold` alto (ver abajo), el sistema
// converge a un N* finito en lugar de crecer monotónicamente.
replicate_threshold: 25.0,
child_energy_frac: 0.50,
fight_damage: 4.0,
absorb_frac: 0.55,
desperation_threshold: 4.0,
// Atractor de abundancia: cualquier agente con E > 60 se vuelve
// Replicador. Calibrado para que pase con frecuencia moderada
// dado el flujo neto de energía típico (~0.5/tick por Extractor).
// Threshold de abundancia alto: sólo agentes con MUCHA energía
// (mucha más que la del equilibrio E* ≈ 27) replican como
// bonus. Esto frena el crecimiento poblacional y mantiene
// N* en el rango ~500-2000 en una grilla 80×80 con regrowth
// moderado.
abundance_threshold: 80.0,
trade_target: TradeTarget::Poorest,
// Vida larga + sin cliff: la cohorte inicial llega a max_edad
// al mismo tiempo y la mortalidad sincronizada extingue al
// sistema. Con max_edad alto, las cohortes se desincronizan
// por la natalidad estocástica vía Replicar y la mortalidad
// queda repartida.
max_edad: 6000,
// Costo metabólico basal: 0.05 E/tick. Calibrado para que el
// punto fijo N* quede en ~500-1500 (manejable para perf O(N²)
// de nearest/poorest), no en decenas de miles.
metabolic_cost: 0.05,
diffusion_rate: 0.10,
// Entropía a la mitad: la pérdida por tick era demasiado agresiva
// para el ciclo materia→energía→muerte→materia.
entropy_rate: 0.005,
// Default: el relieve físico sigue a materia, igual que el
// ZWeights del render por defecto. Las montañas de "biomasa"
// son las que se sienten al caminar.
relieve: [1.0, 0.0, 0.0, 0.0, 0.0],
climb_cost: 0.05,
// Sin estaciones por default — el motor sigue siendo el de antes
// a menos que el usuario las prenda explícitamente.
season_period: 0,
season_amplitude: 0.0,
// Regrowth lento + capacidad chica: la materia es escasa.
// Esto cierra la capacidad del territorio en N* manejable.
// Si subís estos, N* explota (validate empíricamente).
regrowth_rate: 0.015,
carrying_capacity: 18.0,
// Default: psi NO modula efectos → bit-exacto al motor histórico.
// Subir lentamente (0.3..0.7) para que la psicología empiece a
// sentirse sin reventar las calibraciones del Default.
psi_effect_modulation: 0.0,
// Default: política fija → la acción no se reelige por psi. Esto
// preserva tests existentes y todos los packs históricos.
action_policy: ActionPolicy::Fixed,
action_weights: default_action_weights(),
// Failsafe: con período 0, ni siquiera `PsiArgmax` reelige.
policy_reeval_period: 0,
// Fase B: contagio social desactivado por default. El motor
// histórico no recorre vecinos sociales, mantiene perf O(N).
social_radius: 0.0,
contagion_rate: 0.0,
// Fase B.2: sin filtro de homofilia → contagio universal cuando
// se enciende (semántica de B.1).
homophily_threshold: 0.0,
// Big Five off por default — el motor mantiene los 4 ejes
// históricos bit-exacto.
big_five: false,
action_weights_ext: default_action_weights_ext(),
}
}
}
@@ -0,0 +1,751 @@
//! Métricas psicológicas sobre la población — lectura pura, no muta nada.
//!
//! Complemento de `metrics::WorldStats`: aquellos eran agregados macro
//! (Gini de energía, conteo por acción, varianza global de psi). Estos son
//! métricas *psicológicas* en sentido estricto:
//!
//! 1. **Polarización Esteban-Ray** sobre cada componente del `vector_psi`.
//! Detecta distribuciones bimodales/multimodales — la población se está
//! rompiendo en tribus psicológicas. Cero cuando todos son iguales o la
//! distribución es unimodal centrada; sube cuando se forman polos.
//!
//! 2. **Correlación punto-biserial `psi[k] ↔ accion == a`**: una matriz
//! `4×6` que mide cuánto predice cada componente del psi cada acción.
//! Con `ActionPolicy::Fixed` y `psi_effect_modulation == 0` (motor
//! histórico), los valores fluctúan cerca de 0 porque la acción no
//! depende del psi. Con `PsiArgmax` se concentran en celdas donde
//! `action_weights[a][k]` es alto — exactamente el efecto que Fase A
//! instaló y que necesitamos *medir*.
//!
//! Determinismo bit-exacto: iteración lineal, sumas en `f64`, `libm::sqrt`/
//! `powf` para constantes precomputadas en orden fijo. No hay paralelismo,
//! ni hashing, ni ordenamiento sensible a empates.
use crate::lemmings::Lemmings;
use crate::world::World;
/// Cantidad de bins usados por la polarización Esteban-Ray. Pocos bins
/// son robustos a poblaciones chicas; con K=8 podemos detectar hasta 4
/// modos sin que el ruido domine.
pub const POLARIZATION_BINS: usize = 8;
/// Exponente de Esteban-Ray (`α`). `α=1` es el valor canónico que enfatiza
/// la concentración de masa en pocos polos sin desplomar el aporte de
/// distancia. `α=0` colapsaría a Gini; `α=1.6` (otro canónico) penaliza
/// más los polos chicos.
pub const POLARIZATION_ALPHA: f32 = 1.0;
/// Radio de vecindad (en unidades de celda) usado por el `from_world`
/// default para Moran's I. Pares de agentes con distancia ≤ este radio
/// son considerados vecinos espaciales con peso 1; el resto, peso 0
/// (vecindad binaria). Valor calibrado para grids 3080: detecta
/// autocorrelación local sin colapsar al promedio global.
pub const MORANS_RADIUS_DEFAULT: f32 = 6.0;
/// Cantidad de clusters fija para `kmeans_psi`. Tres es el mínimo que
/// detecta "centro + dos polos" — el patrón típico cuando emerge
/// polarización en la población.
pub const KMEANS_K: usize = 3;
/// Iteraciones máximas del k-means. 20 alcanza para 4 dimensiones y
/// poblaciones <10k; con convergencia temprana cuando `Δinertia < EPS`.
pub const KMEANS_MAX_ITER: u32 = 20;
/// Tolerancia de convergencia para `kmeans_psi` — cuando la inercia entre
/// iteraciones consecutivas cambia menos que esto, asume convergencia.
pub const KMEANS_EPS: f32 = 1e-4;
/// Snapshot psicológico instantáneo. Foto, no historia.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct PsiMetrics {
/// Polarización Esteban-Ray por componente del `vector_psi`
/// (`[ORDEN, MIEDO, CURIOSIDAD, CORRUPTIBILIDAD]`). Cero cuando todos
/// los agentes tienen el mismo valor del componente o la varianza es
/// despreciable.
pub polarization: [f32; 4],
/// Correlación punto-biserial `r[k][a]` entre el componente `k` del
/// `vector_psi` (continuo) y el indicador `1[accion == a]` (binario).
/// Rango teórico `[-1, 1]`. Cero por convención cuando no hay agentes
/// con la acción `a` (o todos la tienen) o cuando `var(psi[k]) ≈ 0`.
pub psi_action_corr: [[f32; 6]; 4],
/// Índice de Moran I por componente del `vector_psi`. Mide
/// autocorrelación espacial: cuán parecido es el psi de un agente al
/// de sus vecinos en radio `MORANS_RADIUS_DEFAULT`. Rango teórico
/// aprox. `[-1, +1]`:
/// - `+1`: vecinos muy parecidos → segregación residencial (Schelling).
/// - `0`: psi distribuido al azar espacialmente.
/// - `-1`: vecinos opuestos (patrón "tablero de ajedrez").
/// Cero por convención cuando `n < 2`, `var(psi[k]) ≈ 0`, o ningún
/// par está dentro del radio.
pub moran_i: [f32; 4],
/// Polarización Esteban-Ray de la 5ª dimensión `psi5` (Big Five
/// Extraversion). `0.0` cuando el motor corre en Big Four o cuando la
/// 5ª dimensión es uniforme.
pub polarization_ext: f32,
/// Índice de Moran I de la 5ª dimensión `psi5`. `0.0` en Big Four o
/// distribución uniforme/azarosa.
pub moran_i_ext: f32,
}
impl PsiMetrics {
/// Computa todas las métricas con el radio de Moran default
/// (`MORANS_RADIUS_DEFAULT`). Vacío o N<2 → ceros (no hay señal).
pub fn from_world(w: &World) -> Self {
Self::from_world_with_moran_radius(w, MORANS_RADIUS_DEFAULT)
}
/// Como `from_world`, pero el caller decide el radio de vecindad
/// espacial usado por Moran's I. Útil cuando el grid es muy chico o
/// muy grande y el default no aplica.
pub fn from_world_with_moran_radius(w: &World, moran_radius: f32) -> Self {
let l = &w.lemmings;
let n = l.len();
if n < 2 {
return Self::default();
}
let mut polarization = [0.0f32; 4];
let mut moran_i = [0.0f32; 4];
for k in 0..4 {
let mut buf = Vec::with_capacity(n);
for i in 0..n {
buf.push(l.vector_psi[i][k]);
}
polarization[k] = polarization_esteban_ray(&buf);
moran_i[k] = morans_i_for(&buf, &l.pos_x, &l.pos_y, moran_radius);
}
// Big Five: si la columna psi5 está poblada (len == n), computa
// polarización y Moran sobre ella. En motor Big Four la columna está
// vacía o uniforme y los valores quedan en cero por convención.
let (polarization_ext, moran_i_ext) = if l.psi5.len() == n {
let buf: &[f32] = &l.psi5;
(
polarization_esteban_ray(buf),
morans_i_for(buf, &l.pos_x, &l.pos_y, moran_radius),
)
} else {
(0.0, 0.0)
};
let psi_action_corr = psi_action_corr_all(l);
Self {
polarization,
psi_action_corr,
moran_i,
polarization_ext,
moran_i_ext,
}
}
}
/// Índice de Moran I clásico con vecindad binaria por radio:
///
/// ```text
/// I = (n / S₀) · Σᵢ Σⱼ wᵢⱼ · (xᵢ − μ) · (xⱼ − μ) / Σᵢ (xᵢ − μ)²
/// ```
///
/// `wᵢⱼ = 1` si `|posᵢ posⱼ| ≤ radius` y `i ≠ j`, sino `0`.
/// `S₀ = Σᵢⱼ wᵢⱼ` (el número total de pares vecinos).
///
/// Devuelve `0.0` para casos patológicos (n<2, varianza ~0, S₀==0).
/// Acumulador en `f64` para estabilidad numérica en grids grandes.
pub fn morans_i_for(values: &[f32], xs: &[f32], ys: &[f32], radius: f32) -> f32 {
let n = values.len();
if n < 2 {
return 0.0;
}
if radius <= 0.0 {
return 0.0;
}
let r2 = radius * radius;
let nf = n as f64;
let mut mean: f64 = 0.0;
for &v in values {
mean += v as f64;
}
mean /= nf;
let mut variance: f64 = 0.0;
for &v in values {
let d = v as f64 - mean;
variance += d * d;
}
if variance < 1e-12 {
return 0.0;
}
let mut numerator: f64 = 0.0;
let mut s0: f64 = 0.0;
for i in 0..n {
let xi = xs[i];
let yi = ys[i];
let di = values[i] as f64 - mean;
for j in 0..n {
if i == j {
continue;
}
let dx = xs[j] - xi;
let dy = ys[j] - yi;
if dx * dx + dy * dy > r2 {
continue;
}
let dj = values[j] as f64 - mean;
numerator += di * dj;
s0 += 1.0;
}
}
if s0 < 1e-12 {
return 0.0;
}
((nf / s0) * (numerator / variance)) as f32
}
/// Polarización Esteban-Ray con K=`POLARIZATION_BINS` bins igualmente
/// espaciados entre `[min, max]` del slice. `α=POLARIZATION_ALPHA`.
///
/// ```text
/// P_α(p, x) = Σᵢ Σⱼ pᵢ^(1+α) · pⱼ · |xᵢ xⱼ|
/// ```
///
/// `min == max` → 0.0 (todos iguales, no hay nada que polarizar).
/// `n < 2` → 0.0.
fn polarization_esteban_ray(values: &[f32]) -> f32 {
let n = values.len();
if n < 2 {
return 0.0;
}
let mut min = values[0];
let mut max = values[0];
for &v in &values[1..] {
if v < min {
min = v;
}
if v > max {
max = v;
}
}
let span = max - min;
if span < 1e-9 {
return 0.0;
}
let bins = POLARIZATION_BINS;
let mut counts = vec![0u32; bins];
for &v in values {
// Bin = floor((v - min) / span * bins), clampeado a [0, bins-1].
let raw = ((v - min) / span) * bins as f32;
let mut bi = raw as i64;
if bi >= bins as i64 {
bi = bins as i64 - 1;
}
if bi < 0 {
bi = 0;
}
counts[bi as usize] += 1;
}
let nf = n as f64;
let bin_width = span as f64 / bins as f64;
let mut probs = [0.0f64; POLARIZATION_BINS];
for i in 0..bins {
probs[i] = counts[i] as f64 / nf;
}
// Centros de bin: min + (i + 0.5) · bin_width.
let mut centers = [0.0f64; POLARIZATION_BINS];
for i in 0..bins {
centers[i] = min as f64 + (i as f64 + 0.5) * bin_width;
}
// `α + 1` precomputado en f32 — libm::powf garantiza el mismo bit a
// bit en x86 y ARM.
let exp = (POLARIZATION_ALPHA + 1.0) as f64;
let mut acc: f64 = 0.0;
for i in 0..bins {
if probs[i] <= 0.0 {
continue;
}
let pi_alpha = libm::pow(probs[i], exp);
for j in 0..bins {
if probs[j] <= 0.0 {
continue;
}
let diff = (centers[i] - centers[j]).abs();
acc += pi_alpha * probs[j] * diff;
}
}
acc as f32
}
/// Correlación de Pearson punto-biserial entre cada componente del psi
/// (continuo) y el indicador `1[accion == a]` (binario), para cada
/// `k ∈ 0..4` y `a ∈ 0..6`. Fórmula clásica:
///
/// ```text
/// r_pb = ( μ_{X|Y=1} μ_X ) · √( p / (1p) ) / σ_X
/// ```
///
/// Devuelve ceros para entradas patológicas (varianza ~0, acción nunca
/// ejecutada o ejecutada por todos).
fn psi_action_corr_all(l: &Lemmings) -> [[f32; 6]; 4] {
let n = l.len();
if n < 2 {
return [[0.0; 6]; 4];
}
let nf = n as f64;
// Pasada 1: media de cada componente del psi.
let mut mean_psi = [0.0f64; 4];
for i in 0..n {
for k in 0..4 {
mean_psi[k] += l.vector_psi[i][k] as f64;
}
}
for k in 0..4 {
mean_psi[k] /= nf;
}
// Pasada 2: varianza de cada componente.
let mut var_psi = [0.0f64; 4];
for i in 0..n {
for k in 0..4 {
let d = l.vector_psi[i][k] as f64 - mean_psi[k];
var_psi[k] += d * d;
}
}
for k in 0..4 {
var_psi[k] /= nf;
}
// Pasada 3: conteo por acción y suma del psi condicional.
let mut count_a = [0u64; 6];
let mut sum_psi_when_a = [[0.0f64; 6]; 4];
for i in 0..n {
let a = l.accion[i] as usize;
if a < 6 {
count_a[a] += 1;
for k in 0..4 {
sum_psi_when_a[k][a] += l.vector_psi[i][k] as f64;
}
}
}
let mut out = [[0.0f32; 6]; 4];
for k in 0..4 {
if var_psi[k] < 1e-12 {
continue;
}
let sd = libm::sqrt(var_psi[k]);
for a in 0..6 {
let p = count_a[a] as f64 / nf;
if p < 1e-9 || p > 1.0 - 1e-9 {
continue;
}
let mean_when_a = sum_psi_when_a[k][a] / count_a[a] as f64;
let r = (mean_when_a - mean_psi[k]) * libm::sqrt(p / (1.0 - p)) / sd;
out[k][a] = r as f32;
}
}
out
}
/// Resultado del k-means determinista sobre `vector_psi`.
#[derive(Debug, Clone, PartialEq)]
pub struct KMeansResult {
/// Centroides finales en el espacio psi 4D. `[cluster][componente]`.
pub centroids: [[f32; 4]; KMEANS_K],
/// Cantidad de agentes asignados a cada cluster.
pub sizes: [u32; KMEANS_K],
/// Asignación por agente: byte `0..KMEANS_K`. Largo = `world.lemmings.len()`.
pub assignments: Vec<u8>,
/// Suma de distancias cuadradas de cada agente a su centroide. Métrica
/// agregada de "compactness" de los clusters. Cero = clusters perfectos
/// (todos los agentes están en su centroide); valores grandes = clusters
/// difusos.
pub inertia: f32,
/// Iteraciones efectivamente corridas hasta la convergencia.
pub iterations: u32,
}
/// k-means determinista sobre `vector_psi` con `k = KMEANS_K = 3`. Cero
/// RNG: inicialización por buckets `i % k`. Convergencia cuando la inercia
/// entre iteraciones consecutivas cambia menos que `KMEANS_EPS`. Asignación
/// tie-break por menor índice de cluster.
///
/// Devuelve `None` cuando hay menos de `KMEANS_K` agentes.
pub fn kmeans_psi(world: &World) -> Option<KMeansResult> {
let l = &world.lemmings;
let n = l.len();
if n < KMEANS_K {
return None;
}
// Inicialización determinista: buckets por índice módulo K.
let mut centroids: [[f32; 4]; KMEANS_K] = [[0.0; 4]; KMEANS_K];
{
let mut sums = [[0.0f64; 4]; KMEANS_K];
let mut counts = [0u32; KMEANS_K];
for i in 0..n {
let c = i % KMEANS_K;
for d in 0..4 {
sums[c][d] += l.vector_psi[i][d] as f64;
}
counts[c] += 1;
}
for c in 0..KMEANS_K {
if counts[c] == 0 {
continue;
}
for d in 0..4 {
centroids[c][d] = (sums[c][d] / counts[c] as f64) as f32;
}
}
}
let mut assignments = vec![0u8; n];
let mut prev_inertia: f64 = f64::INFINITY;
let mut iterations: u32 = 0;
let mut last_inertia: f64 = 0.0;
for it in 0..KMEANS_MAX_ITER {
iterations = it + 1;
// Step 1: asignar cada agente al centroide más cercano.
let mut inertia: f64 = 0.0;
for i in 0..n {
let mut best_c: u8 = 0;
let mut best_d2: f32 = f32::MAX;
for c in 0..KMEANS_K {
let mut d2: f32 = 0.0;
for d in 0..4 {
let diff = l.vector_psi[i][d] - centroids[c][d];
d2 += diff * diff;
}
if d2 < best_d2 {
best_d2 = d2;
best_c = c as u8;
}
}
assignments[i] = best_c;
inertia += best_d2 as f64;
}
last_inertia = inertia;
// Convergencia: si la inercia no se mueve, paramos.
if (prev_inertia - inertia).abs() < KMEANS_EPS as f64 {
break;
}
prev_inertia = inertia;
// Step 2: recomputar centroides como medias del cluster. Clusters
// vacíos preservan su centroide del paso anterior (no se actualizan).
let mut new_sums = [[0.0f64; 4]; KMEANS_K];
let mut new_counts = [0u32; KMEANS_K];
for i in 0..n {
let c = assignments[i] as usize;
for d in 0..4 {
new_sums[c][d] += l.vector_psi[i][d] as f64;
}
new_counts[c] += 1;
}
for c in 0..KMEANS_K {
if new_counts[c] == 0 {
continue;
}
for d in 0..4 {
centroids[c][d] = (new_sums[c][d] / new_counts[c] as f64) as f32;
}
}
}
let mut sizes = [0u32; KMEANS_K];
for &a in &assignments {
sizes[a as usize] += 1;
}
Some(KMeansResult {
centroids,
sizes,
assignments,
inertia: last_inertia as f32,
iterations,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::params::SimParams;
use crate::world::World;
#[test]
fn empty_or_singleton_yields_zeros() {
let w = World::new(4, 4);
let m = PsiMetrics::from_world(&w);
assert_eq!(m.polarization, [0.0; 4]);
assert_eq!(m.psi_action_corr, [[0.0; 6]; 4]);
let mut w = World::new(4, 4);
w.lemmings.spawn(1.0, 1.0, 10.0, [0.5; 4]);
let m = PsiMetrics::from_world(&w);
assert_eq!(m.polarization, [0.0; 4]);
}
#[test]
fn uniform_population_has_zero_polarization() {
let mut w = World::new(4, 4);
for _ in 0..50 {
w.lemmings.spawn(1.0, 1.0, 10.0, [0.5; 4]);
}
let m = PsiMetrics::from_world(&w);
for k in 0..4 {
assert!(m.polarization[k].abs() < 1e-5, "comp {k}: {}", m.polarization[k]);
}
}
#[test]
fn bimodal_population_has_high_polarization() {
let mut w = World::new(4, 4);
// Mitad psi[0]=0, mitad psi[0]=1: distribución perfectamente bimodal.
for k in 0..50 {
let val = if k < 25 { 0.0 } else { 1.0 };
w.lemmings.spawn(1.0, 1.0, 10.0, [val, 0.5, 0.5, 0.5]);
}
let m = PsiMetrics::from_world(&w);
// El componente bimodal debe ser claramente más polarizado que los
// unimodales centrados.
assert!(
m.polarization[0] > 0.1,
"comp 0 bimodal debe polarizar: {}",
m.polarization[0]
);
for k in 1..4 {
assert!(
m.polarization[k] < 1e-4,
"comp {k} uniforme no debe polarizar: {}",
m.polarization[k]
);
}
// Y el bimodal debe ser mayor que el uniforme por un margen amplio.
assert!(m.polarization[0] > m.polarization[1] * 100.0);
}
#[test]
fn psi_action_correlation_emerges_when_psi_predicts_action() {
// Construcción a mano: los lemmings con CORRUPTIBILIDAD alta están
// todos en accion=Degradar (5). Los honestos están en accion=Mover (0).
let mut w = World::new(4, 4);
for _ in 0..30 {
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0, 0.0, 0.0, 1.0]);
w.lemmings.accion[i] = 5; // Degradar
}
for _ in 0..30 {
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [0.0, 0.0, 0.0, 0.0]);
w.lemmings.accion[i] = 0; // Mover
}
let m = PsiMetrics::from_world(&w);
// corr(CORRUPTIBILIDAD, Degradar) debe ser ~+1: alta CORR → Degradar.
assert!(
m.psi_action_corr[3][5] > 0.8,
"corr CORR↔Degradar: {}",
m.psi_action_corr[3][5]
);
// corr(CORRUPTIBILIDAD, Mover) debe ser ~-1: alta CORR → NO Mover.
assert!(
m.psi_action_corr[3][0] < -0.8,
"corr CORR↔Mover: {}",
m.psi_action_corr[3][0]
);
// Componentes irrelevantes (ORDEN, MIEDO, CURIOSIDAD) varianza 0
// → correlación 0 por convención.
for k in 0..3 {
for a in 0..6 {
assert!(
m.psi_action_corr[k][a].abs() < 1e-5,
"comp {k} action {a}: {}",
m.psi_action_corr[k][a]
);
}
}
}
#[test]
fn psi_action_correlation_zero_when_action_random_vs_psi() {
// psi alternados con accion fija (todos hacen lo mismo). p(accion)=1
// → fórmula devuelve 0 por convención (no se puede correlacionar
// con un evento que siempre ocurre).
let mut w = World::new(4, 4);
for k in 0..40 {
let val = if k % 2 == 0 { 0.0 } else { 1.0 };
let i = w.lemmings.spawn(1.0, 1.0, 10.0, [val; 4]);
w.lemmings.accion[i] = 1; // todos Extraer
}
let m = PsiMetrics::from_world(&w);
// Todos hacen Extraer → p=1 → corr = 0 en todas las columnas.
for k in 0..4 {
for a in 0..6 {
assert!(
m.psi_action_corr[k][a].abs() < 1e-5,
"comp {k} action {a} debe ser 0: {}",
m.psi_action_corr[k][a]
);
}
}
}
#[test]
fn morans_i_is_high_when_neighbors_are_alike() {
// Dos clusters físicos+psi distintos: izquierda con psi[0]=1,
// derecha con psi[0]=0. Como cada agente está rodeado de iguales,
// Moran's I debe ser cercano a +1.
let mut w = World::new(40, 40);
for k in 0..6 {
w.lemmings
.spawn(5.0 + (k % 3) as f32, 5.0 + (k / 3) as f32, 30.0, [1.0, 0.0, 0.0, 0.0]);
}
for k in 0..6 {
w.lemmings
.spawn(30.0 + (k % 3) as f32, 30.0 + (k / 3) as f32, 30.0, [0.0, 0.0, 0.0, 0.0]);
}
let m = PsiMetrics::from_world(&w);
// En psi[ORDEN], la segregación física espeja la variación → Moran alto.
assert!(
m.moran_i[0] > 0.5,
"Moran's I bajo aunque hay clustering espacial claro: {}",
m.moran_i[0]
);
}
#[test]
fn morans_i_is_zero_when_psi_is_spatially_random() {
// Mismas posiciones que el test anterior pero alternando psi:
// patrón A B A B A B en ambas zonas → autocorrelación ≈ 0.
let mut w = World::new(40, 40);
for k in 0..12 {
let psi_val = if k % 2 == 0 { 1.0 } else { 0.0 };
let x = 5.0 + (k % 4) as f32 * 2.0;
let y = 5.0 + (k / 4) as f32 * 2.0;
w.lemmings.spawn(x, y, 30.0, [psi_val, 0.0, 0.0, 0.0]);
}
let m = PsiMetrics::from_world(&w);
// Patrón tipo ajedrez: Moran's I tiende a ser negativo (vecinos
// distintos). Aceptamos un rango amplio: muy lejos de +1.
assert!(
m.moran_i[0] < 0.5,
"Moran's I alto en distribución alternante: {}",
m.moran_i[0]
);
}
#[test]
fn morans_i_zero_when_uniform_population() {
let mut w = World::new(40, 40);
for _ in 0..20 {
w.lemmings.spawn(10.0, 10.0, 30.0, [0.5; 4]);
}
let m = PsiMetrics::from_world(&w);
for k in 0..4 {
assert!(
m.moran_i[k].abs() < 1e-5,
"Moran[{k}] no es cero en pop uniforme: {}",
m.moran_i[k]
);
}
}
#[test]
fn kmeans_returns_none_when_too_few_agents() {
let w = World::new(8, 8);
assert!(kmeans_psi(&w).is_none());
let mut w = World::new(8, 8);
w.lemmings.spawn(1.0, 1.0, 10.0, [0.5; 4]);
w.lemmings.spawn(2.0, 2.0, 10.0, [0.5; 4]);
assert!(kmeans_psi(&w).is_none()); // sólo 2 agentes, K=3
}
#[test]
fn kmeans_finds_three_distinct_clusters() {
// Tres grupos en zonas opuestas del espacio psi: [1,0,0,0],
// [0,1,0,0], [0,0,1,0]. 10 agentes por grupo.
let mut w = World::new(8, 8);
for _ in 0..10 {
w.lemmings.spawn(1.0, 1.0, 10.0, [1.0, 0.0, 0.0, 0.0]);
}
for _ in 0..10 {
w.lemmings.spawn(1.0, 1.0, 10.0, [0.0, 1.0, 0.0, 0.0]);
}
for _ in 0..10 {
w.lemmings.spawn(1.0, 1.0, 10.0, [0.0, 0.0, 1.0, 0.0]);
}
let r = kmeans_psi(&w).expect("k-means corre");
// Los 3 clusters deben quedar de tamaño ~10 cada uno.
let mut sizes = r.sizes.to_vec();
sizes.sort();
assert_eq!(sizes, vec![10, 10, 10]);
// Inertia muy chica porque los clusters son compactos.
assert!(r.inertia < 0.1, "inertia alta: {}", r.inertia);
}
#[test]
fn kmeans_is_deterministic_under_same_input() {
// Dos mundos idénticos deben producir k-means idéntico.
let build = || {
let mut w = World::new(8, 8);
for k in 0..18 {
let val = (k as f32 * 0.37).fract();
w.lemmings.spawn(1.0, 1.0, 10.0, [val, 1.0 - val, val * val, 0.5]);
}
w
};
let a = kmeans_psi(&build()).expect("a");
let b = kmeans_psi(&build()).expect("b");
assert_eq!(a.centroids, b.centroids);
assert_eq!(a.sizes, b.sizes);
assert_eq!(a.assignments, b.assignments);
assert_eq!(a.inertia, b.inertia);
assert_eq!(a.iterations, b.iterations);
}
#[test]
fn psi_metrics_calcula_ext_cuando_psi5_esta_poblado() {
// Población bimodal sólo en la 5ª dimensión: mitad psi5=0,
// mitad psi5=1. Las 4 primeras componentes uniformes.
let mut w = World::new(8, 8);
for k in 0..40 {
let v5 = if k < 20 { 0.0 } else { 1.0 };
w.lemmings.spawn_big5(1.0, 1.0, 10.0, [0.5; 4], v5);
}
let m = PsiMetrics::from_world(&w);
// polarization_ext debe ser alta porque la distribución es bimodal.
assert!(m.polarization_ext > 0.1, "polar_ext bimodal: {}", m.polarization_ext);
// Las 4 primeras componentes uniformes → polarization ~0.
for k in 0..4 {
assert!(m.polarization[k].abs() < 1e-4, "comp {k}: {}", m.polarization[k]);
}
}
#[test]
fn psi_metrics_ext_es_cero_sin_columna_psi5() {
// Build manual de un Lemmings con psi5 vacío (motor Big Four
// serializado antes del cambio).
use crate::lemmings::Lemmings;
let mut w = World::new(8, 8);
// Llenamos los vectores básicos a mano para simular un deserialize
// viejo que no traía psi5.
w.lemmings = Lemmings {
pos_x: vec![1.0, 2.0],
pos_y: vec![1.0, 2.0],
edad: vec![0; 2],
energia: vec![10.0; 2],
vector_psi: vec![[0.5; 4]; 2],
accion: vec![0; 2],
hack_lock: vec![0; 2],
psi5: Vec::new(),
};
let m = PsiMetrics::from_world(&w);
assert_eq!(m.polarization_ext, 0.0);
assert_eq!(m.moran_i_ext, 0.0);
}
#[test]
fn metrics_run_on_typical_world_without_panicking() {
let mut w = World::new(16, 16);
for k in 0..40 {
let x = (k % 8) as f32 + 2.0;
let y = (k / 8) as f32 + 2.0;
let psi = [
(k as f32 * 0.13).fract(),
(k as f32 * 0.27).fract(),
(k as f32 * 0.41).fract(),
(k as f32 * 0.59).fract(),
];
let i = w.lemmings.spawn(x, y, 30.0, psi);
w.lemmings.accion[i] = (k % 6) as u8;
}
let m = PsiMetrics::from_world(&w);
// No deben aparecer NaN/inf.
for k in 0..4 {
assert!(m.polarization[k].is_finite());
for a in 0..6 {
assert!(m.psi_action_corr[k][a].is_finite());
}
}
// Sanidad: con SimParams default el tipo se usa sin problemas.
let _ = SimParams::default();
}
}
@@ -0,0 +1,661 @@
//! 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::conceptos::Conceptos;
use crate::grid::Grid;
use crate::lemmings::{Lemmings, PSI_CORRUPTIBILIDAD, PSI_CURIOSIDAD, PSI_MIEDO, PSI_ORDEN};
use crate::params::{SimParams, TradeTarget};
use serde::{Deserialize, Serialize};
/// Selecciona el byte de acción que maximiza `action_weights · psi`. Tie-break
/// determinista por menor índice. Devuelve el byte en `0..=5`.
///
/// Esta función es la mecánica matemática de [`ActionPolicy::PsiArgmax`]: sin
/// RNG, sin softmax, sin libm. Cualquier sintonía de pesos produce el mismo
/// resultado en x86 y ARM porque sólo hay multiplicaciones y sumas `f32` en
/// orden fijo.
pub fn select_action_argmax(psi: &[f32; 4], weights: &[[f32; 4]; 6]) -> u8 {
let mut best_idx: u8 = 0;
let mut best_score: f32 = f32::MIN;
for (a, w) in weights.iter().enumerate() {
let s = w[0] * psi[0] + w[1] * psi[1] + w[2] * psi[2] + w[3] * psi[3];
if s > best_score {
best_score = s;
best_idx = a as u8;
}
}
best_idx
}
/// Variante Big Five de [`select_action_argmax`]. Suma al score la
/// contribución de la 5ª dimensión `psi5` ponderada por `weights_ext`.
/// Tie-break determinista por menor índice — idéntico al motor Big Four
/// cuando `psi5 == 0` y `weights_ext == [0; 6]`.
pub fn select_action_argmax_big5(
psi: &[f32; 4],
psi5: f32,
weights: &[[f32; 4]; 6],
weights_ext: &[f32; 6],
) -> u8 {
let mut best_idx: u8 = 0;
let mut best_score: f32 = f32::MIN;
for a in 0..6 {
let w = &weights[a];
let s = w[0] * psi[0] + w[1] * psi[1] + w[2] * psi[2] + w[3] * psi[3]
+ weights_ext[a] * psi5;
if s > best_score {
best_score = s;
best_idx = a as u8;
}
}
best_idx
}
/// 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,
#[serde(default)]
pub conceptos: Conceptos,
/// Tick global del mundo — `physics::tick` lo incrementa al final de
/// cada paso. Es el reloj que alimenta la modulación estacional de
/// `SimParams::season_period`. Saves viejos sin este campo arrancan
/// en 0 vía `serde(default)`.
#[serde(default)]
pub tick_count: u64,
}
impl World {
pub fn new(width: usize, height: usize) -> Self {
Self {
grid: Grid::new(width, height),
lemmings: Lemmings::new(),
conceptos: Conceptos::new(),
tick_count: 0,
}
}
/// 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)
}
/// Relieve físico de la celda `idx` — combinación lineal de las 5
/// capas pesada por `p.relieve`. Es la altura que **siente** un
/// lemming, no la que se renderiza (esa la define `ZWeights`).
fn relieve_at(&self, idx: usize, p: &SimParams) -> f32 {
let g = &self.grid;
p.relieve[0] * g.materia[idx]
+ p.relieve[1] * g.psique[idx]
+ p.relieve[2] * g.poder[idx]
+ p.relieve[3] * g.oro[idx]
+ p.relieve[4] * g.degradacion[idx]
}
/// 0 · Mover — gravedad mental hacia el vecino más afín al `vector_psi`,
/// penalizado por el costo de pendiente. Las "montañas" emergentes de
/// alta `materia` o de alta `psique` (según `p.relieve`) se vuelven
/// barreras físicas: cuesta más score subir y se paga energía extra
/// proporcional a la altura efectivamente subida.
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 cur_idx = self.grid.idx(cx, cy);
let z_cur = self.relieve_at(cur_idx, p);
let mut best_dir = (0.0f32, 0.0f32);
let mut best_z = z_cur;
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 mut 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];
let z_n = self.relieve_at(idx, p);
let climb = (z_n - z_cur).max(0.0);
score -= p.climb_cost * climb;
if score > best_score {
best_score = score;
best_dir = (dx as f32, dy as f32);
best_z = z_n;
}
}
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);
// Costo base + costo de pendiente realmente subida.
let climb_paid = (best_z - z_cur).max(0.0) * p.climb_cost;
// Psi-modulación: el miedoso se cansa más al moverse (chequea el
// entorno, vuelve, duda). Factor 1.0 cuando modulation == 0.
let move_cost_eff =
p.move_cost * (1.0 + p.psi_effect_modulation * 0.5 * psi[PSI_MIEDO]).max(0.0);
self.lemmings.energia[i] -= move_cost_eff + climb_paid;
}
/// 1 · Extraer — vacía materia de la celda hacia la energía del agente.
///
/// Psi-modulación: el agente con `psi[CORRUPTIBILIDAD]` alto saca más
/// de la celda (y deja proporcionalmente más cicatriz). Sin modulación
/// (factor 1.0) el comportamiento es idéntico al motor histórico.
pub fn act_extraer(&mut self, i: usize, p: &SimParams) {
let idx = self.cell_of(i);
let psi = self.lemmings.vector_psi[i];
let factor = (1.0 + p.psi_effect_modulation * psi[PSI_CORRUPTIBILIDAD]).max(0.0);
let rate_eff = p.extract_rate * factor;
let taken = self.grid.materia[idx].min(rate_eff).max(0.0);
self.grid.materia[idx] -= taken;
self.lemmings.energia[i] += taken;
self.grid.degradacion[idx] += p.degr_per_extract * factor;
}
/// 2 · Sincronizar — el `vector_psi` deriva hacia los campos de la celda.
/// Mapeo coherente con `act_mover`: ORDEN↔materia, MIEDO↔poder,
/// CURIOSIDAD↔psique, CORRUPTIBILIDAD↔oro.
pub fn act_sincronizar(&mut self, i: usize, p: &SimParams) {
let idx = self.cell_of(i);
let mut targets = [0.0f32; 4];
targets[PSI_ORDEN] = self.grid.materia[idx];
targets[PSI_MIEDO] = self.grid.poder[idx];
targets[PSI_CURIOSIDAD] = self.grid.psique[idx];
targets[PSI_CORRUPTIBILIDAD] = self.grid.oro[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 a otro agente. El destinatario
/// depende de `p.trade_target`: `Nearest` mantiene la semántica original
/// (vecino físico más cercano), `Poorest` redistribuye al más necesitado
/// del mundo. La elección controla si el sistema alcanza un punto fijo
/// `N* > 0` o se extingue por desigualdad creciente.
pub fn act_intercambiar(&mut self, i: usize, p: &SimParams) {
let target = match p.trade_target {
TradeTarget::Nearest => self.lemmings.nearest(i),
TradeTarget::Poorest => self.lemmings.poorest(i),
};
let Some(j) = target else { return };
// Psi-modulación: el ordenado comparte, el corruptible retiene.
// Factor clamp ≥ 0 — un psi extremo en CORRUPTIBILIDAD puede
// anular el intercambio pero no invertirlo (eso sería robo, que
// no es la semántica de `act_intercambiar`).
let psi = self.lemmings.vector_psi[i];
let factor =
(1.0 + p.psi_effect_modulation * (psi[PSI_ORDEN] - psi[PSI_CORRUPTIBILIDAD])).max(0.0);
let amount = (p.trade_amount * factor).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 una celda **vecina**
/// (no la misma del padre). El hijo hereda la acción y el `vector_psi`.
///
/// Dispersión determinista: la dirección del hijo viene de
/// `(edad_padre + idx_padre) % 4`, así N hijos del mismo padre se
/// reparten en las 4 vecinas. Sin esta dispersión, los hijos saturan
/// la celda del padre, agotan la materia local y colapsan en cascada
/// — incluso con regrowth + costo metabólico activos.
///
/// La herencia + dispersión + side-effect de abundancia (ver
/// `step_lemming`) son las tres piezas que dan al sistema un punto
/// fijo `N* > 0`.
pub fn act_replicar(&mut self, i: usize, p: &SimParams) {
let psi = self.lemmings.vector_psi[i];
// Psi-modulación: el ordenado baja su umbral de reproducción
// (forma familia antes). Clamp inferior a 0.1·threshold para
// evitar reproducción explosiva con psi extremos.
let thr_factor = (1.0 - p.psi_effect_modulation * 0.3 * psi[PSI_ORDEN]).max(0.1);
let thr_eff = p.replicate_threshold * thr_factor;
if self.lemmings.energia[i] <= thr_eff {
return;
}
let cost = self.lemmings.energia[i] * p.child_energy_frac;
self.lemmings.energia[i] -= cost;
let accion = self.lemmings.accion[i];
// Dirección de dispersión: 0=E, 1=O, 2=S, 3=N. Determinista por
// (edad + i) — distribuye los hijos sucesivos en las 4 vecinas.
let dir = (self.lemmings.edad[i].wrapping_add(i as u32) & 0x3) as u8;
let (dx, dy) = match dir {
0 => (1.0, 0.0),
1 => (-1.0, 0.0),
2 => (0.0, 1.0),
_ => (0.0, -1.0),
};
let max_x = self.grid.width as f32 - 1.0;
let max_y = self.grid.height as f32 - 1.0;
let x = (self.lemmings.pos_x[i] + dx).clamp(0.0, max_x);
let y = (self.lemmings.pos_y[i] + dy).clamp(0.0, max_y);
// El hijo hereda el psi5 del padre — sin esto, el linaje Big Five
// se borraría a cada generación.
let psi5 = self.lemmings.psi5_at(i);
let child = self.lemmings.spawn_big5(x, y, cost, psi, psi5);
self.lemmings.accion[child] = accion;
}
/// 5 · Degradar (Pelear) — resta energía al vecino y absorbe parte.
///
/// Psi-modulación: el atacante miedoso pega menos, el corruptible más.
/// Factor `max(0, 1 + mod · (CORR MIEDO))` — un agente cuyo MIEDO
/// domina deja de hacer daño (pero `act_degradar` sigue ejecutándose:
/// es la mecánica de "amago" / "huida").
pub fn act_degradar(&mut self, i: usize, p: &SimParams) {
let Some(j) = self.lemmings.nearest(i) else { return };
let psi = self.lemmings.vector_psi[i];
let factor =
(1.0 + p.psi_effect_modulation * (psi[PSI_CORRUPTIBILIDAD] - psi[PSI_MIEDO])).max(0.0);
let dmg_max = p.fight_damage * factor;
let dmg = dmg_max.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`.
///
/// **Bonus de abundancia**: si `p.abundance_threshold > 0` y la
/// energía del agente supera ese umbral, ejecuta `act_replicar` como
/// *side-effect* ANTES de su acción principal. Esto cierra el ciclo
/// termodinámico: cualquier agente saciado se reproduce sin abandonar
/// su rol (un Extractor sigue extrayendo, un Trader sigue donando).
/// Si el lemming ya está en `Replicar`, el bonus no doble-cuenta —
/// `act_replicar` requiere que `energia > replicate_threshold` y le
/// resta `child_energy_frac`, así que el segundo intento dentro del
/// mismo tick muy probablemente fallará el guardia.
pub fn step_lemming(&mut self, i: usize, p: &SimParams) {
if p.abundance_threshold > 0.0
&& self.lemmings.hack_lock[i] == 0
&& self.lemmings.energia[i] > p.abundance_threshold
{
self.act_replicar(i, p);
}
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 replicar_passes_action_to_child() {
// Sin herencia, el subgrupo "Replicador" se pierde en una generación
// y dN/dt < 0 estructuralmente. La herencia es el fix matemático
// que cierra el ciclo.
let mut w = World::new(8, 8);
let i = w.lemmings.spawn(4.0, 4.0, 100.0, [0.5, 0.5, 0.5, 0.5]);
w.lemmings.accion[i] = 4; // Replicar
let p = SimParams::default();
w.act_replicar(i, &p);
assert_eq!(w.lemmings.len(), 2);
// El hijo (índice 1) hereda la acción 4 del padre, no la acción 0
// que el spawn pone por default.
assert_eq!(w.lemmings.accion[1], 4, "hijo hereda accion del padre");
// El psi también se hereda — eso ya funcionaba.
assert_eq!(w.lemmings.vector_psi[1], w.lemmings.vector_psi[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 mover_prefiere_camino_llano_sobre_subir_pendiente() {
// Dos vecinos atractivos por materia, uno además requiere subir
// una montaña (relieve = materia, climb_cost alto). El lemming
// debe elegir el llano.
let mut w = World::new(16, 16);
let i = w.lemmings.spawn(8.0, 8.0, 100.0, [1.0, 0.0, 0.0, 0.0]);
// Materia idéntica a ambos lados de la celda actual.
let right = w.grid.idx(9, 8);
let left = w.grid.idx(7, 8);
w.grid.materia[right] = 50.0;
w.grid.materia[left] = 50.0;
// Pero al subir a la derecha estamos sobre un pico alto.
// Como `relieve = materia`, el right_idx tiene z=50; el left_idx tiene
// z=50 también. Para forzar pendiente asimétrica subimos sólo la
// derecha:
w.grid.materia[right] = 200.0; // pico mucho mayor
let mut p = SimParams::default();
p.climb_cost = 10.0; // pendiente brutalmente cara
let x0 = w.lemmings.pos_x[i];
w.act_mover(i, &p);
// El pico está a la derecha; con climb_cost = 10 cuesta demasiado.
// Cualquier movimiento que NO sea hacia +x está bien (izq, arriba
// o abajo son todos llanos).
assert!(w.lemmings.pos_x[i] <= x0, "no fue hacia el pico de la derecha");
}
#[test]
fn mover_cobra_energia_extra_por_subir() {
let mut w = World::new(8, 8);
let i = w.lemmings.spawn(4.0, 4.0, 100.0, [1.0, 0.0, 0.0, 0.0]);
// Pico a la derecha.
let right = w.grid.idx(5, 4);
w.grid.materia[right] = 100.0;
// Caso A: climb_cost = 0 (sin penalty). Energy gastada = move_cost.
let mut p = SimParams::default();
p.climb_cost = 0.0;
w.act_mover(i, &p);
let after_flat = w.lemmings.energia[i];
let lost_flat = 100.0 - after_flat;
// Reset y repetir con climb_cost > 0.
let mut w2 = World::new(8, 8);
let j = w2.lemmings.spawn(4.0, 4.0, 100.0, [1.0, 0.0, 0.0, 0.0]);
let right2 = w2.grid.idx(5, 4);
w2.grid.materia[right2] = 100.0;
let mut p2 = SimParams::default();
p2.climb_cost = 0.5;
w2.act_mover(j, &p2);
let lost_climb = 100.0 - w2.lemmings.energia[j];
// Sin climb_cost, el agente puede ir igual al pico (porque la
// materia lo atrae mucho), pero pierde más energía cuando climb_cost
// > 0 porque paga la altura subida.
assert!(lost_climb > lost_flat, "subir con climb_cost > 0 cuesta más");
}
#[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");
}
#[test]
fn intercambiar_poorest_donates_to_the_neediest() {
// Default: TradeTarget::Poorest. El trader (i=0) tiene E=50.
// El más cercano (i=1) está al lado pero tiene E=49. El más pobre
// (i=2) está lejos pero tiene E=5. Debe donar al pobre, no al
// cercano.
let mut w = World::new(20, 20);
w.lemmings.spawn(2.0, 2.0, 50.0, [0.0; 4]); // 0: trader
w.lemmings.spawn(3.0, 2.0, 49.0, [0.0; 4]); // 1: cercano, rico
w.lemmings.spawn(18.0, 18.0, 5.0, [0.0; 4]); // 2: lejos, pobre
let p = SimParams::default();
let before_close = w.lemmings.energia[1];
let before_poor = w.lemmings.energia[2];
w.act_intercambiar(0, &p);
assert_eq!(w.lemmings.energia[1], before_close, "no le tocó al cercano");
assert!(w.lemmings.energia[2] > before_poor, "le donó al pobre");
}
#[test]
fn intercambiar_nearest_preserves_legacy_behavior() {
// Con TradeTarget::Nearest, el comportamiento histórico se mantiene.
let mut w = World::new(20, 20);
w.lemmings.spawn(2.0, 2.0, 50.0, [0.0; 4]);
w.lemmings.spawn(3.0, 2.0, 49.0, [0.0; 4]); // cercano
w.lemmings.spawn(18.0, 18.0, 5.0, [0.0; 4]); // pobre lejos
let mut p = SimParams::default();
p.trade_target = TradeTarget::Nearest;
let before_close = w.lemmings.energia[1];
let before_poor = w.lemmings.energia[2];
w.act_intercambiar(0, &p);
assert!(w.lemmings.energia[1] > before_close, "le donó al cercano");
assert_eq!(w.lemmings.energia[2], before_poor, "no le tocó al pobre");
}
// ───────────────────────── Fase A: psi modula efectos ─────────────────
#[test]
fn psi_modulation_zero_preserves_legacy_act_extraer() {
// Con psi_effect_modulation = 0.0 el resultado es bit-exacto al motor
// histórico, sin importar qué psi tenga el agente. Esta es la
// garantía de retrocompat para todo el corpus de tests preexistentes.
let mut a = World::new(8, 8);
let mut b = World::new(8, 8);
let i = a.lemmings.spawn(4.0, 4.0, 100.0, [0.9, 0.0, 0.0, 0.9]);
let j = b.lemmings.spawn(4.0, 4.0, 100.0, [0.0, 0.0, 0.0, 0.0]);
let idx = a.grid.idx(4, 4);
a.grid.materia[idx] = 50.0;
b.grid.materia[idx] = 50.0;
let p = SimParams::default(); // psi_effect_modulation == 0
a.act_extraer(i, &p);
b.act_extraer(j, &p);
assert_eq!(a.lemmings.energia[i], b.lemmings.energia[j]);
assert_eq!(a.grid.materia[idx], b.grid.materia[idx]);
assert_eq!(a.grid.degradacion[idx], b.grid.degradacion[idx]);
}
#[test]
fn corruptible_extrae_mas_y_degrada_mas() {
// Dos agentes idénticos salvo CORRUPTIBILIDAD: el corrupto saca más
// materia (más energía propia) y deja más cicatriz en el suelo.
// Es la modulación canónica de Extraer.
let mut a = World::new(8, 8); // corrupto
let mut b = World::new(8, 8); // honesto
let i = a.lemmings.spawn(4.0, 4.0, 0.0, [0.0, 0.0, 0.0, 1.0]);
let j = b.lemmings.spawn(4.0, 4.0, 0.0, [0.0, 0.0, 0.0, 0.0]);
let idx = a.grid.idx(4, 4);
a.grid.materia[idx] = 100.0;
b.grid.materia[idx] = 100.0;
let mut p = SimParams::default();
p.psi_effect_modulation = 0.8;
a.act_extraer(i, &p);
b.act_extraer(j, &p);
assert!(
a.lemmings.energia[i] > b.lemmings.energia[j],
"corrupto sacó más: {} vs {}",
a.lemmings.energia[i], b.lemmings.energia[j]
);
assert!(
a.grid.degradacion[idx] > b.grid.degradacion[idx],
"corrupto dejó más cicatriz"
);
}
#[test]
fn miedoso_pega_menos_en_degradar() {
let mut a = World::new(8, 8); // miedoso
let mut b = World::new(8, 8); // valiente
a.lemmings.spawn(4.0, 4.0, 50.0, [0.0, 1.0, 0.0, 0.0]); // MIEDO=1
a.lemmings.spawn(5.0, 4.0, 50.0, [0.0; 4]); // víctima
b.lemmings.spawn(4.0, 4.0, 50.0, [0.0; 4]); // valiente
b.lemmings.spawn(5.0, 4.0, 50.0, [0.0; 4]); // víctima
let mut p = SimParams::default();
p.psi_effect_modulation = 0.8;
let e_victima_pre = a.lemmings.energia[1];
a.act_degradar(0, &p);
b.act_degradar(0, &p);
let dmg_miedoso = e_victima_pre - a.lemmings.energia[1];
let dmg_valiente = e_victima_pre - b.lemmings.energia[1];
assert!(
dmg_miedoso < dmg_valiente,
"miedoso pega menos: {dmg_miedoso} < {dmg_valiente}"
);
}
#[test]
fn ordenado_comparte_mas_en_intercambiar() {
let mut a = World::new(8, 8); // ordenado
let mut b = World::new(8, 8); // neutral
a.lemmings.spawn(4.0, 4.0, 50.0, [1.0, 0.0, 0.0, 0.0]); // ORDEN=1
a.lemmings.spawn(5.0, 4.0, 1.0, [0.0; 4]); // pobre cercano
b.lemmings.spawn(4.0, 4.0, 50.0, [0.0; 4]);
b.lemmings.spawn(5.0, 4.0, 1.0, [0.0; 4]);
let mut p = SimParams::default();
p.psi_effect_modulation = 0.8;
p.trade_target = TradeTarget::Nearest; // forzamos al cercano para test reproducible
a.act_intercambiar(0, &p);
b.act_intercambiar(0, &p);
let donado_orden = a.lemmings.energia[1] - 1.0;
let donado_base = b.lemmings.energia[1] - 1.0;
assert!(
donado_orden > donado_base,
"ordenado donó más: {donado_orden} > {donado_base}"
);
}
#[test]
fn argmax_big5_se_reduce_a_big4_con_pesos_ext_cero() {
// Sanity: con `action_weights_ext = [0; 6]` y cualquier psi5,
// `select_action_argmax_big5` debe coincidir con la versión Big Four.
let weights = crate::params::SimParams::default().action_weights;
let weights_ext = [0.0f32; 6];
let psis = [
[0.0, 0.0, 0.0, 1.0],
[1.0, 0.0, 0.0, 0.0],
[0.5, 0.5, 0.5, 0.5],
];
for psi in &psis {
let a4 = select_action_argmax(psi, &weights);
for psi5 in [0.0, 0.5, 1.0] {
let a5 = select_action_argmax_big5(psi, psi5, &weights, &weights_ext);
assert_eq!(a4, a5, "psi {:?} psi5 {}", psi, psi5);
}
}
}
#[test]
fn argmax_big5_cambia_decision_cuando_extra_pesa() {
// Con un peso ext alto en Intercambiar (3) y psi5 = 1.0, un agente
// que en Big Four iría a Degradar (5) — psi=[0,0,0,1] — debería
// saltar a Intercambiar porque la 5ª columna lo empuja.
let mut weights_ext = [0.0f32; 6];
weights_ext[3] = 5.0; // empujamos fuerte a Intercambiar
let weights = crate::params::SimParams::default().action_weights;
let psi = [0.0, 0.0, 0.0, 1.0];
let psi5 = 1.0;
let a = select_action_argmax_big5(&psi, psi5, &weights, &weights_ext);
assert_eq!(a, 3, "el 5º peso debe ganarle a Degradar");
}
#[test]
fn replicar_hereda_psi5_del_padre() {
let mut w = World::new(8, 8);
let i = w.lemmings.spawn_big5(4.0, 4.0, 100.0, [0.5; 4], 0.73);
w.lemmings.accion[i] = 4;
let p = SimParams::default();
w.act_replicar(i, &p);
assert_eq!(w.lemmings.len(), 2);
assert!((w.lemmings.psi5_at(1) - 0.73).abs() < 1e-5, "hijo {} != 0.73", w.lemmings.psi5_at(1));
}
#[test]
fn argmax_picks_action_with_highest_psi_dot_weights() {
// psi puro en CORRUPTIBILIDAD → con los pesos por default, la
// acción ganadora es Extraer (peso 0.8) o Degradar (peso 1.0). Como
// Degradar tiene mayor peso para CORRUPTIBILIDAD, gana.
let weights = crate::params::SimParams::default().action_weights;
let psi = [0.0, 0.0, 0.0, 1.0];
assert_eq!(select_action_argmax(&psi, &weights), 5);
// psi puro en CURIOSIDAD → Mover (1.0) y Sincronizar (1.0) empatan
// → gana el menor índice = Mover (0).
let psi = [0.0, 0.0, 1.0, 0.0];
assert_eq!(select_action_argmax(&psi, &weights), 0);
// psi puro en ORDEN → Intercambiar (1.0) y Replicar (1.0) empatan
// → gana el menor índice = Intercambiar (3).
let psi = [1.0, 0.0, 0.0, 0.0];
assert_eq!(select_action_argmax(&psi, &weights), 3);
}
}
@@ -0,0 +1,394 @@
//! Generación procedural del mundo: PRNG, ruido fbm, ríos y el [`seed`] que
//! esculpe biomas y reparte Lemmings sobre tierra firme.
//!
//! Motor agnóstico de GUI (regla #2): extraído de `dominium-app-llimphi`,
//! que ahora sólo envuelve [`seed`] pasándole sus dimensiones de grilla, su
//! población de Lemmings y el pack de [`Conceptos`] (default o del usuario).
//! No conoce frontends ni paletas de render.
use crate::{Conceptos, World};
// ---------------------------------------------------------------------
// PRNG mínimo (LCG 64) — siembra reproducible sin dependencias.
// ---------------------------------------------------------------------
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u32(&mut self) -> u32 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
// Shift por 32 (no 33): los 32 bits altos del LCG son los de mejor
// calidad, y `as u32` los toma sin perder el bit 31. La versión
// anterior usaba `>> 33`, dejando un resultado en `[0, 2^31)` →
// `next_f32()` retornaba `[0, 0.5)` y todo el mundo era mar.
(self.0 >> 32) as u32
}
fn next_f32(&mut self) -> f32 {
(self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
}
}
/// Esculpe un río senoidal entre `(x0, y0)` y el borde opuesto, pintando
/// `psique` alta y limpiando `materia` a lo largo del trazo. El río tiene
/// ancho `width` celdas y serpentea con amplitud `wiggle` perpendicular al
/// rumbo. La curva se muestrea a paso unitario.
fn carve_river(w: &mut World, rng: &mut Lcg, vertical: bool, length: usize, width: f32, wiggle: f32) {
let g_w = w.grid.width as f32;
let g_h = w.grid.height as f32;
let start = rng.next_f32() * if vertical { g_w } else { g_h };
let phase = rng.next_f32() * core::f32::consts::TAU;
let freq = 0.06 + rng.next_f32() * 0.05;
for s in 0..length {
let t = s as f32;
let bend = libm::sinf(t * freq + phase) * wiggle;
let (cx_f, cy_f) = if vertical {
(start + bend, t * g_h / length as f32)
} else {
(t * g_w / length as f32, start + bend)
};
let r = width.ceil() as i64;
for dy in -r..=r {
for dx in -r..=r {
let x = cx_f + dx as f32;
let y = cy_f + dy as f32;
if x < 0.0 || y < 0.0 || x >= g_w || y >= g_h {
continue;
}
let d = libm::sqrtf((dx as f32).powi(2) + (dy as f32).powi(2));
if d > width {
continue;
}
let intensity = 1.0 - d / width;
let idx = w.grid.idx(x as usize, y as usize);
// Río = mucha psique (agua azul), nada de materia, sin oro.
w.grid.psique[idx] = (w.grid.psique[idx] + 130.0 * intensity).min(180.0);
w.grid.materia[idx] *= 1.0 - intensity * 0.95;
w.grid.oro[idx] *= 1.0 - intensity * 0.8;
w.grid.poder[idx] *= 1.0 - intensity * 0.8;
w.grid.degradacion[idx] *= 1.0 - intensity * 0.9;
}
}
}
}
/// Value noise multioctava determinista. Devuelve `Vec<f32>` de tamaño
/// `w*h` con valores aproximadamente en `[-1, 1]`. Las octavas suben en
/// frecuencia y bajan en amplitud — la primera define continentes, las
/// últimas, granulado. Smoothstep `s(t) = t²(3-2t)` entre celdas coarse.
fn fbm_noise(seed: u64, w: usize, h: usize) -> Vec<f32> {
let mut rng = Lcg::new(seed);
let mut field = vec![0.0_f32; w * h];
// (frecuencia, amplitud). 4 octavas: 6×6 continentes → 96×96 ruido fino.
let octaves: [(usize, f32); 4] = [(6, 1.0), (12, 0.55), (24, 0.30), (96, 0.18)];
let mut amp_norm = 0.0_f32;
for (_, a) in &octaves {
amp_norm += a;
}
for (n, amp) in octaves {
// Grilla coarse (n+1)×(n+1) de valores aleatorios en [-1, 1].
let coarse_w = n + 1;
let mut coarse = vec![0.0_f32; coarse_w * coarse_w];
for v in coarse.iter_mut() {
*v = rng.next_f32() * 2.0 - 1.0;
}
let sx = n as f32 / w as f32;
let sy = n as f32 / h as f32;
for y in 0..h {
for x in 0..w {
let fx = x as f32 * sx;
let fy = y as f32 * sy;
let cx = (fx.floor() as usize).min(n - 1);
let cy = (fy.floor() as usize).min(n - 1);
let tx = (fx - cx as f32).clamp(0.0, 1.0);
let ty = (fy - cy as f32).clamp(0.0, 1.0);
let smooth = |a: f32| a * a * (3.0 - 2.0 * a);
let u = smooth(tx);
let v = smooth(ty);
let a = coarse[cy * coarse_w + cx];
let b = coarse[cy * coarse_w + cx + 1];
let c = coarse[(cy + 1) * coarse_w + cx];
let d = coarse[(cy + 1) * coarse_w + cx + 1];
let p = a * (1.0 - u) + b * u;
let q = c * (1.0 - u) + d * u;
field[y * w + x] += amp * (p * (1.0 - v) + q * v);
}
}
}
for v in field.iter_mut() {
*v /= amp_norm;
}
field
}
/// Siembra un mundo cuadrado `grid × grid`: continentes de materia, vetas de
/// oro, niebla de psique y una población de `lemmings` Lemmings con sesgos y
/// acciones variadas. Los `conceptos` (default embebido o pack del usuario)
/// se asignan al mundo resultante — el caller decide cuáles.
pub fn seed(seed: u64, grid: usize, lemmings: usize, conceptos: Conceptos) -> World {
let mut w = World::new(grid, grid);
let mut rng = Lcg::new(seed);
// --- Capas iniciales basadas en dos campos fbm independientes ---
// `elev` ∈ ~[-1, 1] decide bioma; `humid` ∈ ~[-1, 1] modula fertilidad.
let elev = fbm_noise(seed ^ 0xE1E_7A57, grid, grid);
let humid = fbm_noise(seed ^ 0x4D015_7CE, grid, grid);
for cy in 0..grid {
for cx in 0..grid {
let idx = w.grid.idx(cx, cy);
let e_raw = elev[idx];
let h = humid[idx];
// Forma del continente:
// - bias +0.25 → tierra domina globalmente.
// - edge_drop · 0.30 → costas/bordes en mar.
// E[edge_drop] = 2/3 en una grilla uniforme → mean(e) ≈ 0.05,
// con FBM std ≈ 0.24 da ~35% mar, ~65% tierra.
let nx = (cx as f32 / grid as f32) * 2.0 - 1.0;
let ny = (cy as f32 / grid as f32) * 2.0 - 1.0;
let edge_drop = nx.abs().max(ny.abs());
let e = e_raw + 0.30 - edge_drop * 0.28;
if e < -0.18 {
// Mar profundo: psique alta para que el azul aguante la
// difusión lenta (entropy=0.005, diffusion=0.02 → unos cientos
// de ticks antes de notarse erosión visual). Pintar también
// `degradacion` baja persistente refuerza el tono frío y
// ancla la celda como "no fértil" para los lemmings que la
// crucen.
w.grid.psique[idx] = 180.0 + rng.next_f32() * 30.0;
w.grid.degradacion[idx] = 2.0;
} else if e < -0.05 {
// Mar somero / lagunas: agua más clara, mínima vida acuática.
w.grid.psique[idx] = 110.0 + rng.next_f32() * 20.0;
w.grid.materia[idx] = rng.next_f32() * 4.0;
w.grid.degradacion[idx] = 1.0;
} else if e < 0.08 {
// Costa / pantano fértil: alta materia + algo de agua.
w.grid.materia[idx] = 45.0 + (h.max(0.0)) * 30.0 + rng.next_f32() * 6.0;
w.grid.psique[idx] = 18.0 + rng.next_f32() * 8.0;
if rng.next_f32() > 0.94 {
w.grid.oro[idx] = rng.next_f32() * 18.0;
}
} else if e < 0.30 {
// Llanura: el granero del mundo. Materia muy alta cuando
// hay humedad; menos donde el clima es seco.
let fertility = (h * 0.5 + 0.5).clamp(0.2, 1.0);
w.grid.materia[idx] = 50.0 + fertility * 50.0 + rng.next_f32() * 5.0;
if rng.next_f32() > 0.92 {
w.grid.oro[idx] = rng.next_f32() * 24.0;
}
} else if e < 0.42 {
// Colinas: materia decreciente, asoma el poder (vetas).
let alpha = (e - 0.30) / 0.12;
w.grid.materia[idx] = (1.0 - alpha) * 35.0 + rng.next_f32() * 4.0;
w.grid.poder[idx] = alpha * 9.0;
if rng.next_f32() > 0.82 {
w.grid.oro[idx] = rng.next_f32() * 30.0; // minas en colinas
}
} else {
// Montañas / picos: poco material vivo, mucha estructura
// bruta (poder) y, en los más altos, cicatriz rocosa. Umbral
// bajado a 0.42 (en la cola del FBM con mean ≈ +0.08) para
// que ~10% del mapa sea cordillera visible.
let alpha = ((e - 0.42) / 0.40).clamp(0.0, 1.0);
w.grid.poder[idx] = 6.0 + alpha * 18.0;
w.grid.degradacion[idx] = 1.5 + alpha * alpha * 14.0;
if rng.next_f32() > 0.97 {
w.grid.oro[idx] = rng.next_f32() * 35.0;
}
}
}
}
// --- Ríos: 2 cruces. Uno vertical, uno horizontal. Sin erosión real
// — los ríos se pintan encima del bioma sobrescribiendo. ---
carve_river(&mut w, &mut rng, true, grid, 2.4, grid as f32 * 0.18);
carve_river(&mut w, &mut rng, false, grid, 1.8, grid as f32 * 0.14);
// --- Lemmings: distribuidos solo en tierra firme (e ∈ [-0.05, 0.45]).
// Rechaza candidatos en mar o pico. Si tras 32 intentos no encuentra
// un punto válido, suelta donde caiga (failsafe para no congelar el
// seed). ---
let pick_land = |rng: &mut Lcg, elev: &[f32]| -> (f32, f32) {
for _ in 0..64 {
let x = rng.next_f32() * (grid as f32 - 1.0);
let y = rng.next_f32() * (grid as f32 - 1.0);
let nx = (x / grid as f32) * 2.0 - 1.0;
let ny = (y / grid as f32) * 2.0 - 1.0;
let edge_drop = nx.abs().max(ny.abs());
// Misma transformación que el biomeing arriba, así los
// lemmings caen en celdas-tierra coherentes.
let e = elev[(y as usize) * grid + (x as usize)] + 0.30 - edge_drop * 0.28;
if e > -0.05 && e < 0.45 {
return (x, y);
}
}
(
rng.next_f32() * (grid as f32 - 1.0),
rng.next_f32() * (grid as f32 - 1.0),
)
};
for k in 0..lemmings {
let (x, y) = pick_land(&mut rng, &elev);
let psi = [
rng.next_f32(),
rng.next_f32(),
rng.next_f32(),
rng.next_f32(),
];
let i = w.lemmings.spawn(x, y, 40.0 + rng.next_f32() * 40.0, psi);
// Distribución calibrada al punto fijo del sistema con herencia
// de acción + intercambio fuerte (trade_amount = 1.5):
// α_e = 0.30 (Extraer · cosecha — fuente principal de E)
// α_t = 0.30 (Intercambiar · redistribución — evita concentración)
// α_m = 0.20 (Mover · exploración)
// α_r = 0.15 (Replicar · natalidad)
// α_s = 0.05 (Sincronizar · convergencia cultural)
//
// Balance energético por capita en equilibrio:
// dE/dt = α_e · e_r - α_m · c_m - α_r · f · E_r · 1[E_r>T]
// = 0.30·2.5 - 0.20·0.06 - 0.15·0.45·E_r
// = 0.738 - 0.0675·E_r
// E* = 0.738 / 0.0675 ≈ 11 (cerca del threshold T=12)
// El sistema oscila alrededor de ese E*, replicando a baja
// frecuencia pero sostenidamente.
w.lemmings.accion[i] = match k % 20 {
0..=5 => 1, // 6/20 = 0.30 Extraer
6..=11 => 3, // 6/20 = 0.30 Intercambiar
12..=15 => 0, // 4/20 = 0.20 Mover
16..=18 => 4, // 3/20 = 0.15 Replicar
_ => 2, // 1/20 = 0.05 Sincronizar
} as u8;
}
w.conceptos = conceptos;
w
}
#[cfg(test)]
mod seeding_tests {
//! Tests del seeding del mundo. No verifican la física (eso ya está en
//! `dominium-core` / `dominium-physics`), sólo que la distribución de
//! biomas tras `seed()` queda en proporciones razonables: ni todo mar
//! ni todo montaña.
use super::*;
use crate::Conceptos;
// Grilla y población de prueba (espejan los consts de la app:
// GRID = 240, LEMMINGS = 2500) para que los rangos esperados valgan.
const GRID: usize = 240;
const LEMMINGS: usize = 2500;
fn seed_demo(s: u64) -> World {
seed(s, GRID, LEMMINGS, Conceptos::default())
}
/// Clasificación de bioma a partir de las capas de una celda. Espeja
/// los thresholds de `seed()` para validar lo que efectivamente quedó
/// pintado.
fn classify_cell(g: &crate::Grid, idx: usize) -> &'static str {
// Mar profundo: mucha psique y nada de materia/poder.
if g.psique[idx] > 150.0 && g.materia[idx] < 1.0 {
"mar_profundo"
} else if g.psique[idx] > 80.0 && g.materia[idx] < 6.0 {
"mar_somero"
} else if g.psique[idx] > 15.0 && g.psique[idx] <= 80.0 && g.materia[idx] > 30.0 {
"costa"
} else if g.materia[idx] > 40.0 && g.poder[idx] < 0.5 {
"llanura"
} else if g.poder[idx] >= 0.5 && g.poder[idx] < 8.0 {
"colina"
} else if g.poder[idx] >= 8.0 || g.degradacion[idx] > 4.0 {
"pico"
} else {
"otro"
}
}
/// Sanity: el LCG genera valores uniformes en [-1, 1] (esta función
/// hubiera capturado el bug `>> 33` original donde la mean era -0.5).
#[test]
fn lcg_genera_distribucion_simetrica() {
let mut rng = Lcg::new(1234);
let mut sum = 0.0_f64;
let n = 100_000;
for _ in 0..n {
sum += (rng.next_f32() * 2.0 - 1.0) as f64;
}
let mean = sum / n as f64;
assert!(
mean.abs() < 0.02,
"LCG sesgado: mean = {mean:.4} (debe estar cerca de 0)"
);
}
#[test]
fn seed_default_balances_biomas() {
let w = seed_demo(0xD0_31_31_07);
let total = w.grid.cells();
let mut hist = std::collections::HashMap::<&'static str, usize>::new();
for i in 0..total {
*hist.entry(classify_cell(&w.grid, i)).or_default() += 1;
}
let pct = |k: &str| -> f32 {
*hist.get(k).unwrap_or(&0) as f32 / total as f32 * 100.0
};
let mar = pct("mar_profundo") + pct("mar_somero");
// El mar no debe dominar el mapa visualmente (versión anterior daba
// ~50% mar y al usuario "todo se ve azul al inicio").
assert!(
mar < 40.0,
"mar < 40% del mapa, fue {:.1}% — el bias continental no está empujando suficiente tierra",
mar
);
// Y al menos hay mar — sin mar no hay distinción agua/tierra.
assert!(mar > 10.0, "mar > 10%, fue {:.1}% — el mapa quedó casi sin agua", mar);
// La tierra incluye llanura (la mayoría de los lemmings vive ahí).
assert!(
pct("llanura") > 18.0,
"llanura > 18%, fue {:.1}% — sin granero el motor se ahoga",
pct("llanura")
);
// Picos visibles pero no dominantes (la versión anterior daba el
// mapa "casi plano").
let pico = pct("pico");
assert!(
(5.0..28.0).contains(&pico),
"pico ∈ [5, 28]%, fue {:.1}% — cordillera fuera de rango",
pico
);
}
#[test]
fn lemmings_no_se_acumulan_en_un_cuadrante() {
let w = seed_demo(0xD0_31_31_07);
// Reparto por cuadrante.
let mut q = [0_u32; 4];
for i in 0..w.lemmings.len() {
let x = w.lemmings.pos_x[i];
let y = w.lemmings.pos_y[i];
let h = GRID as f32 / 2.0;
let qi = match (x >= h, y >= h) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
q[qi] += 1;
}
let total = w.lemmings.len() as u32;
// Ningún cuadrante > 75% de la población (versión anterior tenía
// seeds donde el continente caía en un solo cuadrante y todos los
// lemmings se apilaban ahí).
for (i, &n) in q.iter().enumerate() {
let pct = n as f32 / total as f32 * 100.0;
assert!(
pct < 75.0,
"cuadrante {i} concentra {pct:.1}% de los lemmings — el bias continental + center_lift no está dispersando bien"
);
}
}
}
@@ -0,0 +1,13 @@
[package]
name = "dominium-iso"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium — proyección pseudo-3D isométrica calculada en CPU: matriz iso fija + Z compuesto de 5 capas + sombras analíticas Lambert."
[dependencies]
dominium-core = { path = "../dominium-core" }
libm = { workspace = true }
serde = { workspace = true }
+19
View File
@@ -0,0 +1,19 @@
# dominium-iso
> Proyección 30° + sombra Lambert para [dominium](../README.md).
Math puro de la proyección isométrica: `(x, y, z_world) → (sx, sy_screen)` con ángulo 30° y escala configurable. Sombra Lambert proporcional al producto punto entre la normal y la luz. Cero deps gráficas — esto produce coordenadas, [`dominium-render-plan`](../dominium-render-plan/README.md) las usa.
## API
```rust
use dominium_iso::{project, lambert};
let (sx, sy) = project(x, y, z, scale);
let shade = lambert(normal, light);
```
## Deps
- `libm`
- Cero deps externas
+19
View File
@@ -0,0 +1,19 @@
# dominium-iso
> 30° projection + Lambert shadow for [dominium](../README.md).
Pure math of isometric projection: `(x, y, z_world) → (sx, sy_screen)` at 30° angle with configurable scale. Lambert shadow proportional to dot product between normal and light. Zero graphics deps — this produces coordinates, [`dominium-render-plan`](../dominium-render-plan/README.md) uses them.
## API
```rust
use dominium_iso::{project, lambert};
let (sx, sy) = project(x, y, z, scale);
let shade = lambert(normal, light);
```
## Deps
- `libm`
- Zero external deps
+181
View File
@@ -0,0 +1,181 @@
//! `dominium-iso` — proyección pseudo-3D isométrica.
//!
//! GPUI no maneja matrices de proyección 3D ni mallas: la ilusión de
//! relieve se calcula en CPU antes de emitir quads 2D. Matriz iso fija:
//!
//! ```text
//! x_pantalla = (x - y) · cos(30°)
//! y_pantalla = (x + y) · sin(30°) Z
//! ```
//!
//! La altura `Z` no existe en el motor lógico — se extrae de los campos
//! de la grilla como una combinación lineal config'able de las 5 capas
//! ([`ZWeights`]). Los `cos`/`sin` van por `libm` para que la proyección
//! sea bit-exacta en cualquier plataforma.
#![forbid(unsafe_code)]
use dominium_core::Grid;
use serde::{Deserialize, Serialize};
/// Pesos del Z compuesto — uno por capa de la grilla. El panel expone
/// estos 5 sliders; el relieve es `Σ wᵢ · capaᵢ`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ZWeights {
pub materia: f32,
pub psique: f32,
pub poder: f32,
pub oro: f32,
pub degradacion: f32,
}
impl Default for ZWeights {
/// Por defecto el relieve sigue la `materia`.
fn default() -> Self {
Self { materia: 1.0, psique: 0.0, poder: 0.0, oro: 0.0, degradacion: 0.0 }
}
}
impl ZWeights {
/// Z compuesto de la celda `idx`: combinación lineal de las 5 capas.
pub fn z_of(&self, grid: &Grid, idx: usize) -> f32 {
self.materia * grid.materia[idx]
+ self.psique * grid.psique[idx]
+ self.poder * grid.poder[idx]
+ self.oro * grid.oro[idx]
+ self.degradacion * grid.degradacion[idx]
}
}
/// Proyector isométrico. `cos`/`sin` de 30° precomputados vía `libm`.
#[derive(Debug, Clone, Copy)]
pub struct IsoProjector {
cos30: f32,
sin30: f32,
/// Escala de pantalla (pixels por unidad de mundo).
pub scale: f32,
/// Cuánto eleva el `Z` en pixels de pantalla.
pub z_factor: f32,
}
impl IsoProjector {
/// Crea un proyector. `scale` = pixels por celda; `z_factor` = cuánto
/// levanta una unidad de Z.
pub fn new(scale: f32, z_factor: f32) -> Self {
// 30° en radianes. libm da el mismo bit en x86 y ARM.
let rad = core::f32::consts::FRAC_PI_6;
Self {
cos30: libm::cosf(rad),
sin30: libm::sinf(rad),
scale,
z_factor,
}
}
/// Proyecta una coordenada de mundo `(x, y)` con altura `z` a
/// coordenadas de pantalla.
pub fn project(&self, x: f32, y: f32, z: f32) -> (f32, f32) {
let sx = (x - y) * self.cos30 * self.scale;
let sy = ((x + y) * self.sin30 - z * self.z_factor) * self.scale;
(sx, sy)
}
/// Proyecta la sombra de un punto sobre el suelo (Lambert plano): la
/// sombra cae en `z = 0` desplazada según la dirección de la luz, con
/// largo proporcional a la altura del punto.
pub fn shadow(&self, x: f32, y: f32, z: f32, light_dir: (f32, f32)) -> (f32, f32) {
let foot_x = x + light_dir.0 * z;
let foot_y = y + light_dir.1 * z;
self.project(foot_x, foot_y, 0.0)
}
/// Inversa de [`Self::project`] asumiendo `z = 0` (clicks sobre el
/// suelo). Dadas coordenadas de pantalla `(sx, sy)`, devuelve el
/// `(x, y)` de mundo que las generó si se hubiera proyectado con
/// `z = 0`. Para clicks sobre celdas elevadas el resultado se
/// desplaza (las cimas proyectan a una `y_pantalla` distinta a la
/// de su pie); para una sembrazón de Conceptos es suficiente.
///
/// ```text
/// sx = (x - y) · cos30 · scale
/// sy = (x + y) · sin30 · scale
/// ⇒ x = (sx / (cos30·scale) + sy / (sin30·scale)) / 2
/// y = (sy / (sin30·scale) sx / (cos30·scale)) / 2
/// ```
pub fn unproject_floor(&self, sx: f32, sy: f32) -> (f32, f32) {
let s = self.scale.max(f32::EPSILON);
let u = sx / (self.cos30 * s); // = x - y
let v = sy / (self.sin30 * s); // = x + y
((u + v) * 0.5, (v - u) * 0.5)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-4
}
#[test]
fn origin_projects_to_origin() {
let iso = IsoProjector::new(1.0, 1.0);
let (x, y) = iso.project(0.0, 0.0, 0.0);
assert!(approx(x, 0.0) && approx(y, 0.0));
}
#[test]
fn diamond_axis_collapses_x() {
// En iso, (a, a) cae sobre x_pantalla = 0 (la diagonal del rombo).
let iso = IsoProjector::new(1.0, 1.0);
let (sx, _) = iso.project(5.0, 5.0, 0.0);
assert!(approx(sx, 0.0));
}
#[test]
fn z_raises_the_point_upward() {
let iso = IsoProjector::new(1.0, 10.0);
let (_, y0) = iso.project(3.0, 3.0, 0.0);
let (_, y1) = iso.project(3.0, 3.0, 2.0);
// Más Z → menor y de pantalla (sube).
assert!(y1 < y0);
}
#[test]
fn composite_z_is_a_linear_combination() {
let mut g = Grid::new(4, 4);
let idx = g.idx(1, 1);
g.materia[idx] = 10.0;
g.poder[idx] = 4.0;
let w = ZWeights { materia: 0.5, psique: 0.0, poder: 2.0, oro: 0.0, degradacion: 0.0 };
// 0.5*10 + 2*4 = 13
assert!(approx(w.z_of(&g, idx), 13.0));
}
#[test]
fn projector_is_deterministic() {
let a = IsoProjector::new(2.0, 3.0);
let b = IsoProjector::new(2.0, 3.0);
assert_eq!(a.project(7.0, 11.0, 1.5), b.project(7.0, 11.0, 1.5));
}
#[test]
fn unproject_floor_is_inverse_of_project_at_z_zero() {
let iso = IsoProjector::new(12.0, 5.0);
for (x, y) in [(3.0, 7.0), (15.0, 2.0), (8.0, 8.0), (0.0, 0.0)] {
let (sx, sy) = iso.project(x, y, 0.0);
let (rx, ry) = iso.unproject_floor(sx, sy);
assert!(approx(rx, x) && approx(ry, y), "({x},{y}) → ({rx},{ry})");
}
}
#[test]
fn shadow_of_ground_point_equals_its_projection() {
let iso = IsoProjector::new(1.0, 5.0);
// z = 0 → la sombra coincide con el punto.
let p = iso.project(4.0, 2.0, 0.0);
let s = iso.shadow(4.0, 2.0, 0.0, (1.0, 0.5));
assert!(approx(p.0, s.0) && approx(p.1, s.1));
}
}
@@ -0,0 +1,23 @@
[package]
name = "dominium-notebook-kernel"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium — kernel de notebook que ejecuta celdas sobre un World de dominium-core/physics. 5 lenguajes (world/seed/tick/stats/param) sobre estado compartido Arc<Mutex<DominiumState>>; convierte el notebook DAG en un editor de simulaciones."
[dependencies]
async-trait = { workspace = true }
pluma-notebook-core = { workspace = true }
pluma-notebook-exec = { workspace = true }
dominium-core = { path = "../dominium-core" }
dominium-physics = { path = "../dominium-physics" }
png = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
[[example]]
name = "notebook_dominium_demo"
path = "examples/notebook_dominium_demo.rs"
@@ -0,0 +1,19 @@
# pluma-notebook-kernel-dominium
> Kernel simulador para el notebook de [pluma](../README.md).
Celdas que corren [`dominium`](../../../01_yachay/dominium/README.md) con un seed + ticks fijos. Salidas: el snapshot final + visualización vía [`pineal-heatmap`](../../pineal/pineal-heatmap/README.md). Ideal para notebooks reproducibles de simulación (papers, talleres).
## API
```rust
use pluma_notebook_kernel_dominium::DominiumKernel;
let k = DominiumKernel::new();
let outputs = k.correr(&celda).await?;
```
## Deps
- [`pluma-notebook-core`](../pluma-notebook-core/README.md)
- [`dominium-core`](../../../01_yachay/dominium/dominium-core/README.md), [`dominium-physics`](../../../01_yachay/dominium/dominium-physics/README.md)
@@ -0,0 +1,19 @@
# pluma-notebook-kernel-dominium
> Simulator kernel for the [pluma](../README.md) notebook.
Cells running [`dominium`](../../../01_yachay/dominium/README.md) with a fixed seed + ticks. Outputs: final snapshot + visualization via [`pineal-heatmap`](../../pineal/pineal-heatmap/README.md). Ideal for reproducible simulation notebooks (papers, workshops).
## API
```rust
use pluma_notebook_kernel_dominium::DominiumKernel;
let k = DominiumKernel::new();
let outputs = k.correr(&celda).await?;
```
## Deps
- [`pluma-notebook-core`](../pluma-notebook-core/README.md)
- [`dominium-core`](../../../01_yachay/dominium/dominium-core/README.md), [`dominium-physics`](../../../01_yachay/dominium/dominium-physics/README.md)
@@ -0,0 +1,115 @@
//! Showcase end-to-end del `DominiumKernel` sobre el motor de
//! notebooks.
//!
//! Arma un notebook hardcoded con la cadena:
//!
//! ```text
//! world ───┐
//! params ──┼─► tick(0) ─► tick(50) ─► tick(50) ─► stats
//! seed ────┘
//! ```
//!
//! - `world`: resetea la grilla a 32×24.
//! - `seed`: siembra 150 lemmings con `seed=7` (determinista).
//! - `params`: ajusta tres campos escalares.
//! - `tick(0)`: snapshot inicial — corre 0 ticks (ya con seed + params),
//! imprime stats t=0.
//! - `tick(50)` y `tick(50)` encadenados: avanzan 100 ticks en total
//! en dos celdas para mostrar reactividad parcial.
//! - `stats`: lectura final sin avanzar el reloj.
//!
//! Corré con: `cargo run -p dominium-notebook-kernel --example
//! notebook_dominium_demo --release`.
//!
//! El demo no abre ventana; imprime el stdout de cada celda. Para
//! visualizar el DAG arrástralo a `pluma-notebook-graph-llimphi`
//! (consume el mismo `Notebook`).
use pluma_notebook_core::{CellId, CellKind, Notebook, OutputPayload};
use pluma_notebook_exec::run_all;
use dominium_notebook_kernel::DominiumKernel;
#[tokio::main]
async fn main() {
let mut nb = Notebook::new();
let world = code(&mut nb, "dominium-world", "32 24");
let seed = code(&mut nb, "dominium-seed", "150 7");
let params = code(
&mut nb,
"dominium-param",
"move_speed=0.4\nsync_rate=0.05\nclimb_cost=0.1",
);
let tick0 = code(&mut nb, "dominium-tick", "0");
let tick50_a = code(&mut nb, "dominium-tick", "50");
let tick50_b = code(&mut nb, "dominium-tick", "50");
let stats = code(&mut nb, "dominium-stats", "");
// Edges del DAG: world se setea primero; seed y params dependen de
// world (ambos lo necesitan listo); tick(0) depende de seed + params;
// los dos tick(50) van en cadena; stats al final.
assert!(nb.add_dependency(seed, world));
assert!(nb.add_dependency(params, world));
assert!(nb.add_dependency(tick0, seed));
assert!(nb.add_dependency(tick0, params));
assert!(nb.add_dependency(tick50_a, tick0));
assert!(nb.add_dependency(tick50_b, tick50_a));
assert!(nb.add_dependency(stats, tick50_b));
let kernel = DominiumKernel::new();
let report = run_all(&mut nb, &kernel).await.expect("notebook sin ciclo");
println!("=== notebook_dominium_demo — corrida completa ===");
println!(
"ejecutadas: {} · falladas: {} · saltadas: {}\n",
report.executed.len(),
report.failed.len(),
report.skipped.len()
);
for cell in nb.cells() {
let lang = match &cell.kind {
CellKind::Code { language } => language.as_str(),
_ => "n/a",
};
let stdout = cell
.last_output
.as_ref()
.map(|o| o.stdout.as_str())
.unwrap_or("(sin output)");
println!(
"--- celda {} [{lang}] state={:?} ---",
cell.id, cell.state
);
println!("source: {}", cell.source.replace('\n', ""));
println!("{stdout}");
println!();
}
// Imprime también el digest reproducible — dos corridas idénticas
// dan el mismo número en cualquier laptop.
if let Some(d) = nb.notebook_digest() {
println!(
"notebook_digest = {}",
d.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
);
}
// Sanity: la última celda debe ser una tabla con la fila "n".
let stats_cell = nb.cell(stats).unwrap();
if let Some(out) = &stats_cell.last_output {
if let OutputPayload::Table { rows, .. } = &out.payload {
let n_row = rows.iter().find(|r| r[0] == "n").unwrap();
println!("\nlemmings vivos al final: {}", n_row[1]);
}
}
}
fn code(nb: &mut Notebook, language: &str, source: &str) -> CellId {
nb.push(
CellKind::Code { language: language.to_string() },
source.to_string(),
)
}
@@ -0,0 +1,815 @@
//! `dominium-notebook-kernel` — kernel de notebook que ejecuta
//! celdas sobre el simulador determinista de [`dominium_core`] +
//! [`dominium_physics`].
//!
//! El kernel mantiene un estado interno compartido entre celdas
//! (`Arc<Mutex<DominiumState>>`): un único [`World`] mutable + sus
//! [`SimParams`]. Cada celda muta ese estado y reporta el resultado;
//! re-ejecutar una celda upstream (vía `pluma_notebook_exec::run_from`)
//! re-aplica la cascada de mutaciones desde ese punto, exactamente como
//! Excel re-evalúa una columna cuando cambia una fórmula raíz.
//!
//! ## Lenguajes reconocidos
//!
//! | `language` | Source | Efecto |
//! |-------------------|------------------------------|---------------------------------------------------------------|
//! | `dominium-world` | `"W H"` (ej. `"32 24"`) | Resetea el mundo a una grilla `W×H`, lemmings vacíos. |
//! | `dominium-seed` | `"N [SEED]"` (ej. `"200 42"`)| Siembra N lemmings con LCG determinista a partir de SEED. |
//! | `dominium-tick` | `"N"` o vacío | Corre N ticks (default 1); output = stats post. |
//! | `dominium-stats` | (vacío) | Lee `WorldStats` sin tick. |
//! | `dominium-param` | `"NAME=VALUE"` por línea | Setea uno o más campos `f32` de `SimParams`. |
//! | `dominium-render` | `"W H [SCALE]"` (px) | Rasteriza grid + lemmings a PNG (`SCALE`≥1 multiplica la resolución), output `OutputPayload::Image`. |
//!
//! Cualquier otra `language` devuelve `KernelError::Runtime` con
//! mensaje claro.
//!
//! ## Por qué encaja en el DAG
//!
//! - Una celda `dominium-world "32 24"` resetea el mundo.
//! - Una celda `dominium-seed "200 42"` que depende de la primera
//! siembra agentes.
//! - Una celda `dominium-tick "100"` que depende de la segunda corre
//! 100 ticks; su output es la tabla de `WorldStats`.
//! - Editar la primera (`"64 64"`) y llamar `run_from(world)` re-
//! ejecuta la cadena entera en orden topológico, dejando un sistema
//! reproducible que un investigador puede explorar sin tocar Rust.
#![forbid(unsafe_code)]
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use dominium_core::{SimParams, World, WorldStats};
use dominium_physics::tick as physics_tick;
use pluma_notebook_core::{CellOutput, OutputPayload};
use pluma_notebook_exec::{Kernel, KernelError, KernelOutput};
/// Estado vivo de un kernel dominium: el `World` (o `None` antes de
/// `dominium-world`) y los `SimParams` que las celdas mutan.
#[derive(Debug, Clone, Default)]
pub struct DominiumState {
pub world: Option<World>,
pub params: SimParams,
}
/// Kernel ECS dominium. El estado se comparte entre celdas vía
/// `Arc<Mutex<...>>` — los notebooks reactivos lo leen y escriben en
/// orden topológico garantizado por `pluma-notebook-exec`.
pub struct DominiumKernel {
state: Arc<Mutex<DominiumState>>,
}
impl Default for DominiumKernel {
fn default() -> Self {
Self::new()
}
}
impl DominiumKernel {
pub fn new() -> Self {
Self::from_state(DominiumState::default())
}
pub fn from_state(state: DominiumState) -> Self {
Self {
state: Arc::new(Mutex::new(state)),
}
}
/// Handle al estado compartido. Útil para que la UI lea el `World`
/// actual y lo pinte (cosmos-canvas-llimphi / dominium-canvas-llimphi)
/// sin que la celda tenga que serializarlo.
pub fn state_handle(&self) -> Arc<Mutex<DominiumState>> {
Arc::clone(&self.state)
}
/// Snapshot del estado actual — copia profunda. No bloquea por más
/// de un Mutex lock breve. Sirve para tests y para serializar
/// reportes.
pub fn snapshot(&self) -> DominiumState {
self.state.lock().expect("kernel state envenenado").clone()
}
}
#[async_trait]
impl Kernel for DominiumKernel {
async fn execute(
&self,
source: &str,
language: &str,
) -> Result<KernelOutput, KernelError> {
match language {
"dominium-world" => exec_world(source, &self.state),
"dominium-seed" => exec_seed(source, &self.state),
"dominium-tick" => exec_tick(source, &self.state),
"dominium-stats" => exec_stats(&self.state),
"dominium-param" => exec_param(source, &self.state),
"dominium-render" => exec_render(source, &self.state),
other => Err(KernelError::Runtime(format!(
"lenguaje no reconocido por el kernel dominium: '{other}' \
(esperaba: dominium-world | dominium-seed | dominium-tick | \
dominium-stats | dominium-param | dominium-render)"
))),
}
}
}
fn exec_world(
source: &str,
state: &Arc<Mutex<DominiumState>>,
) -> Result<KernelOutput, KernelError> {
let mut it = source.split_whitespace();
let w: usize = parse_required(it.next(), "WIDTH")?;
let h: usize = parse_required(it.next(), "HEIGHT")?;
if w == 0 || h == 0 {
return Err(KernelError::Runtime(
"WIDTH y HEIGHT deben ser > 0".into(),
));
}
let mut s = lock(state)?;
s.world = Some(World::new(w, h));
Ok(text_output(format!("world reseteado a {w}×{h}, lemmings=0")))
}
fn exec_seed(
source: &str,
state: &Arc<Mutex<DominiumState>>,
) -> Result<KernelOutput, KernelError> {
let mut it = source.split_whitespace();
let n: usize = parse_required(it.next(), "N")?;
let seed: u64 = it
.next()
.map(|s| {
s.parse::<u64>().map_err(|_| {
KernelError::Runtime(format!("SEED debe ser un u64: '{s}'"))
})
})
.transpose()?
.unwrap_or(0xC05_0510_0000_0001u64);
let mut s = lock(state)?;
let world = s
.world
.as_mut()
.ok_or_else(|| KernelError::Runtime(
"no hay world: llamá a dominium-world WxH primero".into(),
))?;
let w_max = world.grid.width as f32 - 1.0;
let h_max = world.grid.height as f32 - 1.0;
let mut rng = Lcg::new(seed);
for _ in 0..n {
let x = rng.next_unit() * w_max;
let y = rng.next_unit() * h_max;
let psi = [
rng.next_unit(),
rng.next_unit(),
rng.next_unit(),
rng.next_unit(),
];
world.lemmings.spawn(x, y, 100.0, psi);
}
Ok(text_output(format!(
"sembrados {n} lemmings con seed={seed} (total={})",
world.lemmings.len()
)))
}
fn exec_tick(
source: &str,
state: &Arc<Mutex<DominiumState>>,
) -> Result<KernelOutput, KernelError> {
let n: usize = if source.trim().is_empty() {
1
} else {
source
.trim()
.parse()
.map_err(|_| KernelError::Runtime(format!("N debe ser un usize: '{source}'")))?
};
let mut s = lock(state)?;
let params = s.params.clone();
let world = s
.world
.as_mut()
.ok_or_else(|| KernelError::Runtime(
"no hay world: llamá a dominium-world WxH primero".into(),
))?;
for _ in 0..n {
physics_tick(world, &params);
}
let stats = WorldStats::from_world(world);
Ok(stats_to_output(&stats, Some(n)))
}
fn exec_stats(
state: &Arc<Mutex<DominiumState>>,
) -> Result<KernelOutput, KernelError> {
let s = lock(state)?;
let world = s
.world
.as_ref()
.ok_or_else(|| KernelError::Runtime(
"no hay world: llamá a dominium-world WxH primero".into(),
))?;
let stats = WorldStats::from_world(world);
Ok(stats_to_output(&stats, None))
}
fn exec_param(
source: &str,
state: &Arc<Mutex<DominiumState>>,
) -> Result<KernelOutput, KernelError> {
let mut s = lock(state)?;
let mut changed: Vec<String> = Vec::new();
for raw_line in source.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (name, value) = line.split_once('=').ok_or_else(|| {
KernelError::Runtime(format!(
"se espera NAME=VALUE por línea, llegó: '{line}'"
))
})?;
let name = name.trim();
let value: f32 = value.trim().parse().map_err(|_| {
KernelError::Runtime(format!(
"VALUE debe ser un f32 para '{name}': '{}'",
value.trim()
))
})?;
set_param_field(&mut s.params, name, value)?;
changed.push(format!("{name}={value}"));
}
if changed.is_empty() {
return Err(KernelError::Runtime(
"ninguna asignación NAME=VALUE encontrada en la celda".into(),
));
}
Ok(text_output(format!("params actualizados: {}", changed.join(", "))))
}
/// Setea uno de los campos `f32` planos de [`SimParams`]. La lista
/// está cerrada explícitamente porque los campos no triviales
/// (`relieve` que es array, `action_policy` que es enum, `action_weights`
/// que es matriz, `trade_target` que es enum) requieren parsers
/// dedicados; ese alcance queda fuera del MVP.
fn set_param_field(p: &mut SimParams, name: &str, v: f32) -> Result<(), KernelError> {
match name {
"move_speed" => p.move_speed = v,
"move_cost" => p.move_cost = v,
"extract_rate" => p.extract_rate = v,
"degr_per_extract" => p.degr_per_extract = v,
"sync_rate" => p.sync_rate = v,
"trade_amount" => p.trade_amount = v,
"replicate_threshold" => p.replicate_threshold = v,
"child_energy_frac" => p.child_energy_frac = v,
"fight_damage" => p.fight_damage = v,
"absorb_frac" => p.absorb_frac = v,
"desperation_threshold" => p.desperation_threshold = v,
"abundance_threshold" => p.abundance_threshold = v,
"metabolic_cost" => p.metabolic_cost = v,
"diffusion_rate" => p.diffusion_rate = v,
"entropy_rate" => p.entropy_rate = v,
"climb_cost" => p.climb_cost = v,
"season_amplitude" => p.season_amplitude = v,
"regrowth_rate" => p.regrowth_rate = v,
"carrying_capacity" => p.carrying_capacity = v,
"psi_effect_modulation" => p.psi_effect_modulation = v,
"social_radius" => p.social_radius = v,
"contagion_rate" => p.contagion_rate = v,
other => {
return Err(KernelError::Runtime(format!(
"parámetro no soportado por este kernel: '{other}' \
(sólo campos escalares f32 — relieve/action_policy/etc \
quedan fuera del MVP)"
)));
}
}
Ok(())
}
fn exec_render(
source: &str,
state: &Arc<Mutex<DominiumState>>,
) -> Result<KernelOutput, KernelError> {
let mut it = source.split_whitespace();
let w_px: u32 = it
.next()
.map(|s| {
s.parse::<u32>().map_err(|_| {
KernelError::Runtime(format!("WIDTH inválido: '{s}'"))
})
})
.transpose()?
.unwrap_or(256);
let h_px: u32 = it
.next()
.map(|s| {
s.parse::<u32>().map_err(|_| {
KernelError::Runtime(format!("HEIGHT inválido: '{s}'"))
})
})
.transpose()?
.unwrap_or(256);
// SCALE (zoom ≥ 1.0): multiplica la resolución de salida. `rasterize_world`
// mapea TODO el grid al lienzo, así que más píxeles = misma vista del mundo
// con más detalle (cada celda ocupa más píxeles). Valor inválido/≤0 → 1.0.
let scale: f32 = it
.next()
.map(|s| s.parse::<f32>().unwrap_or(1.0))
.unwrap_or(1.0);
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
if w_px == 0 || h_px == 0 || w_px > 4096 || h_px > 4096 {
return Err(KernelError::Runtime(format!(
"WIDTH/HEIGHT debe estar en [1, 4096], llegó {w_px}x{h_px}"
)));
}
// Dimensiones finales tras aplicar el zoom, clampeadas al techo de 4096
// por lado (evita que un SCALE grande dispare una asignación enorme).
let out_w = (((w_px as f32) * scale).round() as u32).clamp(1, 4096);
let out_h = (((h_px as f32) * scale).round() as u32).clamp(1, 4096);
let s = lock(state)?;
let world = s
.world
.as_ref()
.ok_or_else(|| KernelError::Runtime(
"no hay world: llamá a dominium-world WxH primero".into(),
))?;
let png = rasterize_world(world, out_w, out_h);
Ok(CellOutput {
stdout: format!("rasterizado {out_w}×{out_h} px ({} bytes PNG)", png.len()),
value: Some(format!("{}x{}", out_w, out_h)),
payload: OutputPayload::Image {
width: out_w,
height: out_h,
mime: "image/png".to_string(),
bytes: png,
},
})
}
/// Rasteriza el `World` a un PNG RGBA `w_px × h_px`. Mapeo:
/// - cada pixel del PNG corresponde a una posición (x, y) de la grilla
/// muestreada con vecino más cercano;
/// - el color de la celda es una combinación de las 5 capas normalizadas
/// contra su pico actual (so las escalas no colapsan): rojo = poder,
/// verde = materia, azul = psique, amarillo (R+G) = oro, marrón
/// atenuante = degradación;
/// - cada lemming se pinta como un punto blanco de 2×2 px (clamped).
fn rasterize_world(world: &dominium_core::World, w_px: u32, h_px: u32) -> Vec<u8> {
let g = &world.grid;
let gw = g.width.max(1) as f32;
let gh = g.height.max(1) as f32;
// Peaks para normalizar — evita que escenas vacías queden negras
// por completo (un valor pequeño se vuelve visible relativamente).
let peak = |layer: &[f32]| -> f32 {
layer
.iter()
.copied()
.fold(0.0_f32, |m, v| if v > m { v } else { m })
.max(1e-6)
};
let p_mat = peak(&g.materia);
let p_psi = peak(&g.psique);
let p_pod = peak(&g.poder);
let p_oro = peak(&g.oro);
let p_deg = peak(&g.degradacion);
let mut buf: Vec<u8> = vec![0u8; (w_px as usize) * (h_px as usize) * 4];
for py in 0..h_px {
let gy = ((py as f32) * gh / h_px as f32).floor() as usize;
let gy = gy.min(g.height - 1);
for px in 0..w_px {
let gx = ((px as f32) * gw / w_px as f32).floor() as usize;
let gx = gx.min(g.width - 1);
let idx = g.idx(gx, gy);
let mat = g.materia[idx] / p_mat;
let psi = g.psique[idx] / p_psi;
let pod = g.poder[idx] / p_pod;
let oro = g.oro[idx] / p_oro;
let deg = g.degradacion[idx] / p_deg;
// Mezcla: R = poder + 0.6*oro; G = materia + 0.6*oro; B = psique.
// Degradación atenúa todo (suelo quemado).
let atten = (1.0 - 0.5 * deg).max(0.2);
let r = ((pod + 0.6 * oro) * atten).clamp(0.0, 1.0);
let g_c = ((mat + 0.6 * oro) * atten).clamp(0.0, 1.0);
let b = (psi * atten).clamp(0.0, 1.0);
let off = ((py as usize) * w_px as usize + px as usize) * 4;
buf[off] = (r * 255.0) as u8;
buf[off + 1] = (g_c * 255.0) as u8;
buf[off + 2] = (b * 255.0) as u8;
buf[off + 3] = 255;
}
}
// Pinta lemmings como pixels blancos (2×2) por agente. Coords
// físicas en (0..g.width-1, 0..g.height-1) → (0..w_px-1, 0..h_px-1).
let inv_gw = if g.width > 1 { (w_px as f32 - 1.0) / (g.width as f32 - 1.0) } else { 0.0 };
let inv_gh = if g.height > 1 { (h_px as f32 - 1.0) / (g.height as f32 - 1.0) } else { 0.0 };
for i in 0..world.lemmings.len() {
let lx = world.lemmings.pos_x[i];
let ly = world.lemmings.pos_y[i];
let px = (lx * inv_gw) as i32;
let py = (ly * inv_gh) as i32;
for dy in 0..2i32 {
for dx in 0..2i32 {
let x = (px + dx).clamp(0, w_px as i32 - 1) as usize;
let y = (py + dy).clamp(0, h_px as i32 - 1) as usize;
let off = (y * w_px as usize + x) * 4;
buf[off] = 255;
buf[off + 1] = 255;
buf[off + 2] = 255;
buf[off + 3] = 255;
}
}
}
encode_png_rgba(&buf, w_px, h_px)
}
fn encode_png_rgba(rgba: &[u8], w: u32, h: u32) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(rgba.len() / 2);
{
let mut encoder = png::Encoder::new(&mut out, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().expect("encoder header");
writer.write_image_data(rgba).expect("encoder data");
}
out
}
fn stats_to_output(stats: &WorldStats, ticks_run: Option<usize>) -> KernelOutput {
let mut rows: Vec<Vec<String>> = Vec::with_capacity(16);
if let Some(t) = ticks_run {
rows.push(vec!["ticks_aplicados".to_string(), t.to_string()]);
}
rows.push(vec!["n".to_string(), stats.n.to_string()]);
rows.push(vec![
"gini_energia".to_string(),
format!("{:.4}", stats.gini_energia),
]);
rows.push(vec![
"total_energia".to_string(),
format!("{:.2}", stats.total_energia),
]);
rows.push(vec![
"mean_edad".to_string(),
format!("{:.2}", stats.mean_edad),
]);
for (k, label) in ["orden", "miedo", "curiosidad", "corruptibilidad"]
.iter()
.enumerate()
{
rows.push(vec![
format!("var_psi_{label}"),
format!("{:.4}", stats.var_psi[k]),
]);
}
for (k, label) in
["mover", "extraer", "sincronizar", "intercambiar", "replicar", "pelear"]
.iter()
.enumerate()
{
rows.push(vec![
format!("action_{label}"),
stats.action_counts[k].to_string(),
]);
}
rows.push(vec![
"total_materia".to_string(),
format!("{:.2}", stats.total_materia),
]);
rows.push(vec![
"total_psique".to_string(),
format!("{:.2}", stats.total_psique),
]);
rows.push(vec![
"total_poder".to_string(),
format!("{:.2}", stats.total_poder),
]);
rows.push(vec![
"total_oro".to_string(),
format!("{:.2}", stats.total_oro),
]);
rows.push(vec![
"total_degradacion".to_string(),
format!("{:.2}", stats.total_degradacion),
]);
let stdout = rows
.iter()
.map(|r| format!("{:<28} {}", r[0], r[1]))
.collect::<Vec<_>>()
.join("\n");
CellOutput {
stdout,
value: Some(stats.n.to_string()),
payload: OutputPayload::Table {
columns: vec!["key".into(), "value".into()],
rows,
},
}
}
fn text_output(msg: impl Into<String>) -> KernelOutput {
let s = msg.into();
CellOutput {
stdout: s.clone(),
value: None,
payload: OutputPayload::Text(s),
}
}
fn lock<'a>(
state: &'a Arc<Mutex<DominiumState>>,
) -> Result<std::sync::MutexGuard<'a, DominiumState>, KernelError> {
state
.lock()
.map_err(|_| KernelError::Runtime("kernel state envenenado".into()))
}
fn parse_required<T: std::str::FromStr>(
raw: Option<&str>,
name: &str,
) -> Result<T, KernelError> {
let raw = raw.ok_or_else(|| KernelError::Runtime(format!("falta {name}")))?;
raw.parse::<T>()
.map_err(|_| KernelError::Runtime(format!("{name} inválido: '{raw}'")))
}
/// LCG mínimo determinista (mismos constantes que `numerical recipes`).
/// Bit-exacto cross-platform — bastante para sembrar lemmings de un
/// notebook reproducible. NO usar para criptografía.
struct Lcg {
state: u64,
}
impl Lcg {
fn new(seed: u64) -> Self {
// Evita el estado 0 absorbente — si el caller pasó 0,
// arrancamos en una semilla impar conocida.
let state = if seed == 0 { 0xDEADBEEF_CAFEBABEu64 } else { seed };
Self { state }
}
fn next_u32(&mut self) -> u32 {
self.state = self
.state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.state >> 32) as u32
}
/// Float en [0, 1).
fn next_unit(&mut self) -> f32 {
// 24 bits altos del u32 → mantisa de f32 — distribución
// uniforme correcta sin sesgos por shift.
(self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
}
}
#[cfg(test)]
mod tests {
use super::*;
use pluma_notebook_core::{CellKind, Notebook};
use pluma_notebook_exec::run_all;
fn kernel() -> DominiumKernel {
DominiumKernel::new()
}
#[tokio::test]
async fn world_resetea_grilla() {
let k = kernel();
let out = k.execute("16 8", "dominium-world").await.unwrap();
let s = k.snapshot();
let w = s.world.unwrap();
assert_eq!(w.grid.width, 16);
assert_eq!(w.grid.height, 8);
assert_eq!(w.lemmings.len(), 0);
assert!(out.stdout.contains("16×8"));
}
#[tokio::test]
async fn seed_sin_world_falla() {
let k = kernel();
let r = k.execute("100", "dominium-seed").await;
assert!(matches!(r, Err(KernelError::Runtime(ref m)) if m.contains("dominium-world")));
}
#[tokio::test]
async fn seed_determinista_misma_seed_misma_poblacion() {
let k1 = kernel();
k1.execute("16 16", "dominium-world").await.unwrap();
k1.execute("50 42", "dominium-seed").await.unwrap();
let pop1 = k1.snapshot().world.unwrap().lemmings.pos_x.clone();
let k2 = kernel();
k2.execute("16 16", "dominium-world").await.unwrap();
k2.execute("50 42", "dominium-seed").await.unwrap();
let pop2 = k2.snapshot().world.unwrap().lemmings.pos_x.clone();
assert_eq!(pop1, pop2, "misma seed debe producir misma población");
}
#[tokio::test]
async fn tick_avanza_reloj() {
let k = kernel();
k.execute("16 16", "dominium-world").await.unwrap();
k.execute("50 1", "dominium-seed").await.unwrap();
let t0 = k.snapshot().world.unwrap().tick_count;
k.execute("10", "dominium-tick").await.unwrap();
let t1 = k.snapshot().world.unwrap().tick_count;
assert_eq!(t1 - t0, 10);
}
#[tokio::test]
async fn tick_vacio_es_uno() {
let k = kernel();
k.execute("8 8", "dominium-world").await.unwrap();
k.execute("5 1", "dominium-seed").await.unwrap();
let t0 = k.snapshot().world.unwrap().tick_count;
k.execute("", "dominium-tick").await.unwrap();
let t1 = k.snapshot().world.unwrap().tick_count;
assert_eq!(t1 - t0, 1);
}
#[tokio::test]
async fn stats_devuelve_tabla() {
let k = kernel();
k.execute("8 8", "dominium-world").await.unwrap();
k.execute("3 1", "dominium-seed").await.unwrap();
let out = k.execute("", "dominium-stats").await.unwrap();
match out.payload {
OutputPayload::Table { columns, rows } => {
assert_eq!(columns, vec!["key".to_string(), "value".to_string()]);
let n_row = rows.iter().find(|r| r[0] == "n").unwrap();
assert_eq!(n_row[1], "3");
}
other => panic!("se esperaba Table, llegó {other:?}"),
}
}
#[tokio::test]
async fn param_setea_campo_conocido() {
let k = kernel();
k.execute("move_speed=0.75", "dominium-param").await.unwrap();
assert!((k.snapshot().params.move_speed - 0.75).abs() < 1e-6);
}
#[tokio::test]
async fn param_multiline_setea_varios() {
let k = kernel();
k.execute("move_speed=0.5\nsync_rate=0.1", "dominium-param")
.await
.unwrap();
let p = k.snapshot().params;
assert!((p.move_speed - 0.5).abs() < 1e-6);
assert!((p.sync_rate - 0.1).abs() < 1e-6);
}
#[tokio::test]
async fn param_desconocido_falla() {
let k = kernel();
let r = k.execute("relieve=0.5", "dominium-param").await;
assert!(matches!(r, Err(KernelError::Runtime(_))));
}
#[tokio::test]
async fn lenguaje_no_dominium_falla() {
let k = kernel();
let r = k.execute("hola", "python").await;
assert!(matches!(r, Err(KernelError::Runtime(ref m)) if m.contains("no reconocido")));
}
#[tokio::test]
async fn render_sin_world_falla() {
let k = kernel();
let r = k.execute("64 64", "dominium-render").await;
assert!(matches!(r, Err(KernelError::Runtime(ref m)) if m.contains("dominium-world")));
}
#[tokio::test]
async fn render_produce_png_payload() {
let k = kernel();
k.execute("16 16", "dominium-world").await.unwrap();
k.execute("20 1", "dominium-seed").await.unwrap();
let out = k.execute("64 64", "dominium-render").await.unwrap();
match out.payload {
OutputPayload::Image {
width,
height,
mime,
bytes,
} => {
assert_eq!(width, 64);
assert_eq!(height, 64);
assert_eq!(mime, "image/png");
// Header PNG: 89 50 4E 47 0D 0A 1A 0A
assert_eq!(
&bytes[..8],
&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
);
}
other => panic!("se esperaba Image, llegó {other:?}"),
}
}
#[tokio::test]
async fn render_scale_multiplica_la_resolucion() {
let k = kernel();
k.execute("16 16", "dominium-world").await.unwrap();
// 64×64 base con SCALE 2.0 → 128×128 de salida.
let out = k.execute("64 64 2.0", "dominium-render").await.unwrap();
match out.payload {
OutputPayload::Image { width, height, .. } => {
assert_eq!(width, 128);
assert_eq!(height, 128);
}
other => panic!("se esperaba Image, llegó {other:?}"),
}
assert_eq!(out.value.as_deref(), Some("128x128"));
}
#[tokio::test]
async fn render_scale_clampea_al_techo() {
let k = kernel();
k.execute("8 8", "dominium-world").await.unwrap();
// 4096 base × 2 → clamp a 4096 (no 8192).
let out = k.execute("4096 4096 2", "dominium-render").await.unwrap();
if let OutputPayload::Image { width, height, .. } = out.payload {
assert_eq!((width, height), (4096, 4096));
} else {
panic!("se esperaba Image");
}
}
#[tokio::test]
async fn render_defaults_256x256() {
let k = kernel();
k.execute("8 8", "dominium-world").await.unwrap();
let out = k.execute("", "dominium-render").await.unwrap();
if let OutputPayload::Image { width, height, .. } = out.payload {
assert_eq!(width, 256);
assert_eq!(height, 256);
} else {
panic!("se esperaba Image");
}
}
#[tokio::test]
async fn render_dimensiones_invalidas_falla() {
let k = kernel();
k.execute("8 8", "dominium-world").await.unwrap();
let r = k.execute("0 100", "dominium-render").await;
assert!(matches!(r, Err(KernelError::Runtime(_))));
let r2 = k.execute("100 8000", "dominium-render").await;
assert!(matches!(r2, Err(KernelError::Runtime(_))));
}
#[tokio::test]
async fn notebook_completo_ejecuta_en_topo_order() {
// Notebook con cadena world → seed → param → tick. Una sola
// corrida con run_all debe dejar el world con lemmings vivos +
// tick_count > 0.
let k = kernel();
let mut nb = Notebook::new();
let w = nb.push(
CellKind::Code { language: "dominium-world".into() },
"32 24",
);
let s = nb.push(
CellKind::Code { language: "dominium-seed".into() },
"100 7",
);
let p = nb.push(
CellKind::Code { language: "dominium-param".into() },
"move_speed=0.4\nsync_rate=0.05",
);
let t = nb.push(
CellKind::Code { language: "dominium-tick".into() },
"20",
);
nb.add_dependency(s, w);
nb.add_dependency(p, w);
nb.add_dependency(t, s);
nb.add_dependency(t, p);
let report = run_all(&mut nb, &k).await.unwrap();
assert_eq!(report.executed.len(), 4);
assert!(report.failed.is_empty());
let snap = k.snapshot();
let w = snap.world.as_ref().unwrap();
// El sim puede ganar o perder lemmings durante el tick
// (Replicar/Pelear cambian la población). Sólo verificamos
// que hubo siembra y que el reloj corrió N ticks.
assert!(w.lemmings.len() > 0, "el seed sembró población");
assert_eq!(w.tick_count, 20, "tick avanzó el reloj N pasos");
assert!((snap.params.move_speed - 0.4).abs() < 1e-6);
}
}
@@ -0,0 +1,12 @@
[package]
name = "dominium-physics"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium — ciclo del motor: difusión + entropía de los campos + tick completo (transiciones, acciones, envejecimiento). Determinista bit-exacto."
[dependencies]
dominium-core = { path = "../dominium-core" }
libm = { workspace = true }
@@ -0,0 +1,25 @@
# dominium-physics
> Tick determinista de 6 fases para [dominium](../README.md).
Cada `tick()` corre las 6 fases en orden fijo:
1. **Difusión** de capas (`materia`, `psique`, `poder`).
2. **Decay** exponencial por capa.
3. **Acoplamiento ψ↔acción endógeno** (Fase A): el campo `psique` modula bias de decisión de los agentes, y la acción de los agentes inyecta de vuelta en `psique`.
4. **Conceptos**: emisores activos inyectan/drenan capas según su radio + mods.
5. **Agentes**: decisión + ejecución de las 6 acciones atómicas.
6. **Invariantes**: validación final (masa conservada, capas no-negativas).
## API
```rust
use dominium_physics::tick;
tick(&mut world);
```
## Deps
- [`dominium-core`](../dominium-core/README.md)
- `libm`
@@ -0,0 +1,25 @@
# dominium-physics
> Deterministic 6-phase tick for [dominium](../README.md).
Each `tick()` runs 6 phases in fixed order:
1. **Diffusion** of layers (`materia`, `psique`, `poder`).
2. **Exponential decay** per layer.
3. **Endogenous ψ↔action coupling** (Phase A): the `psique` field modulates agent decision bias, and agent action injects back into `psique`.
4. **Concepts**: active emitters inject/drain layers based on their radius + mods.
5. **Agents**: decision + execution of the 6 atomic actions.
6. **Invariants**: final validation (mass conserved, non-negative layers).
## API
```rust
use dominium_physics::tick;
tick(&mut world);
```
## Deps
- [`dominium-core`](../dominium-core/README.md)
- `libm`
@@ -0,0 +1,392 @@
//! Aplicación de Conceptos sobre la grilla y sobre los Lemmings.
//!
//! Dos pasos puros, sin estado interno, recorriendo la `Vec<Concepto>` en
//! el orden de inserción. Determinista bit-exacto.
//!
//! - [`apply_conceptos`] — emite/drena los modificadores de cada concepto
//! sobre las celdas dentro de su radio, con falloff lineal.
//! - [`apply_hacks`] — decrementa los locks vivos y, para los lemmings
//! libres dentro de un radio con `hack` cuyo `trigger` se cumple, fuerza
//! `accion` y arranca el lock.
use dominium_core::{Trigger, World};
/// Empuja el `vector_psi` de los lemmings dentro del radio de cualquier
/// Concepto con `persuasion: Some(_)` hacia su `target_psi`, con tasa
/// modulada por el falloff lineal (1 en el centro, 0 en el borde).
///
/// Esta fase NO bloquea acción (no toca `hack_lock` ni `accion`) — la
/// persuasión es ortogonal al hack coercitivo. Un Concepto puede ejercer
/// ambas: persuadir y, si entra el trigger del hack, capturar.
///
/// Determinismo: iteración lineal `(concepto, agente)` por índices,
/// `libm::sqrtf` para el falloff (bit-exacto cross-platform).
pub fn apply_persuasion(world: &mut World) {
for c in &world.conceptos.items {
let Some(per) = &c.persuasion else { continue };
if c.radius <= 0.0 || per.rate <= 0.0 {
continue;
}
let r2 = c.radius * c.radius;
for i in 0..world.lemmings.len() {
let dx = world.lemmings.pos_x[i] - c.pos_x;
let dy = world.lemmings.pos_y[i] - c.pos_y;
let d2 = dx * dx + dy * dy;
if d2 > r2 {
continue;
}
// Mismo falloff que `apply_conceptos`: lineal sobre la
// distancia normalizada.
let falloff = 1.0 - libm::sqrtf(d2 / r2);
let pull = per.rate * falloff;
for k in 0..4 {
let cur = world.lemmings.vector_psi[i][k];
let target = per.target_psi[k];
world.lemmings.vector_psi[i][k] = cur + pull * (target - cur);
}
}
}
}
/// Suma los modificadores de cada concepto a las celdas dentro de su radio,
/// con falloff lineal (1 en el centro, 0 en el borde).
///
/// Recorre los conceptos en orden de inserción y las celdas en orden
/// `(y, x)` para que la simulación sea bit-exacta plataforma a plataforma.
pub fn apply_conceptos(world: &mut World) {
let w = world.grid.width;
let h = world.grid.height;
for c in &world.conceptos.items {
if c.radius <= 0.0 {
continue;
}
let r2 = c.radius * c.radius;
// Ventana acotada de celdas a inspeccionar.
let xmin = ((c.pos_x - c.radius).floor() as i64).max(0) as usize;
let xmax_raw = ((c.pos_x + c.radius).ceil() as i64).max(0) as usize;
let xmax = xmax_raw.min(w.saturating_sub(1));
let ymin = ((c.pos_y - c.radius).floor() as i64).max(0) as usize;
let ymax_raw = ((c.pos_y + c.radius).ceil() as i64).max(0) as usize;
let ymax = ymax_raw.min(h.saturating_sub(1));
if xmin >= w || ymin >= h {
continue;
}
for cy in ymin..=ymax {
for cx in xmin..=xmax {
let dx = cx as f32 - c.pos_x;
let dy = cy as f32 - c.pos_y;
let d2 = dx * dx + dy * dy;
if d2 > r2 {
continue;
}
let falloff = 1.0 - libm::sqrtf(d2 / r2);
let idx = world.grid.idx(cx, cy);
world.grid.materia[idx] += c.mods.materia * falloff;
world.grid.psique[idx] += c.mods.psique * falloff;
world.grid.poder[idx] += c.mods.poder * falloff;
world.grid.oro[idx] += c.mods.oro * falloff;
}
}
}
}
/// Decrementa los locks activos y arranca nuevos en los Lemmings que
/// caigan dentro del radio de un concepto con `hack` cuyo `trigger` se
/// cumple. La acción forzada vence cualquier transición posterior del
/// motor (incluida la desesperación → pelear).
///
/// Determinismo: orden `(lemming, concepto)` por índice; ante varios
/// conceptos que capturen al mismo lemming, gana el de menor índice.
pub fn apply_hacks(world: &mut World) {
let n = world.lemmings.len();
// 1. Decrementar locks vivos. El lemming sigue ejecutando la acción
// forzada porque su byte `accion` ya está fijado.
for i in 0..n {
if world.lemmings.hack_lock[i] > 0 {
world.lemmings.hack_lock[i] -= 1;
}
}
// 2. Capturar lemmings libres que entren al radio de un concepto.
for i in 0..n {
if world.lemmings.hack_lock[i] > 0 {
continue;
}
for c in &world.conceptos.items {
let Some(h) = &c.hack else { continue };
let dx = world.lemmings.pos_x[i] - c.pos_x;
let dy = world.lemmings.pos_y[i] - c.pos_y;
if dx * dx + dy * dy > c.radius * c.radius {
continue;
}
let fires = match h.trigger {
Trigger::Always => true,
Trigger::EnergiaBajo(e) => world.lemmings.energia[i] < e,
Trigger::EdadSobre(a) => world.lemmings.edad[i] > a,
};
if fires {
world.lemmings.accion[i] = h.forced_action;
world.lemmings.hack_lock[i] = h.duration;
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dominium_core::{BehaviorHack, Concepto, LayerMods, Trigger, World};
fn empty_world(w: usize, h: usize) -> World {
World::new(w, h)
}
fn concepto(id: &str, x: f32, y: f32, r: f32, mods: LayerMods) -> Concepto {
Concepto {
id: id.into(),
sprite_id: 0,
pos_x: x,
pos_y: y,
radius: r,
mods,
hack: None,
persuasion: None,
}
}
#[test]
fn concepto_inyecta_psique_en_su_centro() {
let mut w = empty_world(8, 8);
w.conceptos.add(concepto(
"iglesia",
4.0,
4.0,
2.0,
LayerMods { psique: 1.0, ..Default::default() },
));
let center = w.grid.idx(4, 4);
apply_conceptos(&mut w);
assert!((w.grid.psique[center] - 1.0).abs() < 1e-5);
}
#[test]
fn falloff_decae_hacia_el_borde() {
let mut w = empty_world(16, 16);
w.conceptos.add(concepto(
"fuente",
8.0,
8.0,
4.0,
LayerMods { materia: 1.0, ..Default::default() },
));
apply_conceptos(&mut w);
let center = w.grid.idx(8, 8);
let halfway = w.grid.idx(10, 8);
let edge = w.grid.idx(12, 8); // distancia 4 = radius → falloff = 0
assert!(w.grid.materia[center] > w.grid.materia[halfway]);
assert!(w.grid.materia[halfway] > 0.0);
assert!(w.grid.materia[edge].abs() < 1e-5);
}
#[test]
fn conceptos_no_afectan_celdas_fuera_del_radio() {
let mut w = empty_world(20, 20);
w.conceptos.add(concepto(
"compacto",
10.0,
10.0,
2.0,
LayerMods { oro: 1.0, ..Default::default() },
));
apply_conceptos(&mut w);
let lejos = w.grid.idx(0, 0);
assert!(w.grid.oro[lejos].abs() < 1e-6);
}
#[test]
fn drenar_baja_el_campo() {
let mut w = empty_world(8, 8);
let center = w.grid.idx(4, 4);
w.grid.materia[center] = 10.0;
w.conceptos.add(concepto(
"agujero",
4.0,
4.0,
1.0,
LayerMods { materia: -2.0, ..Default::default() },
));
apply_conceptos(&mut w);
assert!(w.grid.materia[center] < 10.0);
}
#[test]
fn hack_captura_lemming_y_le_fija_accion() {
let mut w = empty_world(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]);
w.lemmings.accion[0] = 0; // Mover
w.conceptos.add(Concepto {
id: "iglesia".into(),
sprite_id: 0,
pos_x: 10.0,
pos_y: 10.0,
radius: 3.0,
mods: LayerMods::default(),
hack: Some(BehaviorHack {
trigger: Trigger::Always,
forced_action: 2, // Sincronizar
duration: 10,
}),
persuasion: None,
});
apply_hacks(&mut w);
assert_eq!(w.lemmings.accion[0], 2);
assert_eq!(w.lemmings.hack_lock[0], 10);
}
#[test]
fn hack_con_trigger_no_cumplido_no_dispara() {
let mut w = empty_world(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]); // energía 30
w.lemmings.accion[0] = 0;
w.conceptos.add(Concepto {
id: "soup-kitchen".into(),
sprite_id: 0,
pos_x: 10.0,
pos_y: 10.0,
radius: 3.0,
mods: LayerMods::default(),
hack: Some(BehaviorHack {
trigger: Trigger::EnergiaBajo(10.0),
forced_action: 2,
duration: 5,
}),
persuasion: None,
});
apply_hacks(&mut w);
assert_eq!(w.lemmings.accion[0], 0); // sigue moviéndose
assert_eq!(w.lemmings.hack_lock[0], 0);
}
#[test]
fn hack_lock_decrementa_y_no_resnatura_si_lock_vive() {
let mut w = empty_world(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]);
w.lemmings.accion[0] = 2;
w.lemmings.hack_lock[0] = 3;
// Concepto presente con hack — pero el lemming ya está locked.
w.conceptos.add(Concepto {
id: "x".into(),
sprite_id: 0,
pos_x: 10.0,
pos_y: 10.0,
radius: 3.0,
mods: LayerMods::default(),
hack: Some(BehaviorHack {
trigger: Trigger::Always,
forced_action: 5,
duration: 10,
}),
persuasion: None,
});
apply_hacks(&mut w);
// El lock baja a 2, la acción se mantiene en 2 (no se re-evaluó).
assert_eq!(w.lemmings.hack_lock[0], 2);
assert_eq!(w.lemmings.accion[0], 2);
}
#[test]
fn persuasion_arrastra_psi_hacia_target_y_no_toca_accion() {
// Iglesia ortodoxa en (10, 10), radio 5, persuade psi → [1,0.5,0,0]
// con rate=0.20. Lemming en el centro con psi=[0,0,0,0] hace accion=0.
let mut w = empty_world(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0, 0.0, 0.0, 0.0]);
w.lemmings.accion[0] = 0; // Mover
w.conceptos.add(Concepto {
id: "iglesia-ortodoxa".into(),
sprite_id: 0,
pos_x: 10.0,
pos_y: 10.0,
radius: 5.0,
mods: LayerMods::default(),
hack: None,
persuasion: Some(dominium_core::Persuasion {
target_psi: [1.0, 0.5, 0.0, 0.0],
rate: 0.20,
}),
});
apply_persuasion(&mut w);
// En el centro, falloff = 1.0. Pull efectivo = 0.20.
// psi_nuevo = 0 + 0.20 · (target 0) = target · 0.20.
let psi = w.lemmings.vector_psi[0];
assert!((psi[0] - 0.20).abs() < 1e-5, "psi[0]: {}", psi[0]);
assert!((psi[1] - 0.10).abs() < 1e-5, "psi[1]: {}", psi[1]);
// Y la acción NO cambia (la persuasión es ortogonal al hack).
assert_eq!(w.lemmings.accion[0], 0);
assert_eq!(w.lemmings.hack_lock[0], 0);
}
#[test]
fn persuasion_falloff_lineal_en_el_borde() {
// Lemming al borde del radio: falloff = 0 → no se modifica psi.
let mut w = empty_world(20, 20);
w.lemmings.spawn(15.0, 10.0, 30.0, [0.0; 4]); // a distancia 5 del concepto
w.conceptos.add(Concepto {
id: "fuente-de-virtud".into(),
sprite_id: 0,
pos_x: 10.0,
pos_y: 10.0,
radius: 5.0,
mods: LayerMods::default(),
hack: None,
persuasion: Some(dominium_core::Persuasion {
target_psi: [1.0; 4],
rate: 1.0, // máxima tasa: si influyera nada, no es por rate chica
}),
});
apply_persuasion(&mut w);
assert_eq!(w.lemmings.vector_psi[0], [0.0; 4]);
}
#[test]
fn persuasion_none_no_cambia_psi() {
let mut w = empty_world(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.3, 0.4, 0.5, 0.6]);
w.conceptos.add(Concepto {
id: "solo-emite-campo".into(),
sprite_id: 0,
pos_x: 10.0,
pos_y: 10.0,
radius: 5.0,
mods: LayerMods { materia: 0.5, ..Default::default() },
hack: None,
persuasion: None,
});
let psi_pre = w.lemmings.vector_psi[0];
apply_persuasion(&mut w);
assert_eq!(w.lemmings.vector_psi[0], psi_pre);
}
#[test]
fn primer_concepto_gana_si_dos_capturan_al_mismo_lemming() {
let mut w = empty_world(20, 20);
w.lemmings.spawn(10.0, 10.0, 30.0, [0.0; 4]);
let mk = |id: &str, action: u8| Concepto {
id: id.into(),
sprite_id: 0,
pos_x: 10.0,
pos_y: 10.0,
radius: 5.0,
mods: LayerMods::default(),
hack: Some(BehaviorHack {
trigger: Trigger::Always,
forced_action: action,
duration: 7,
}),
persuasion: None,
};
w.conceptos.add(mk("a", 3));
w.conceptos.add(mk("b", 5));
apply_hacks(&mut w);
assert_eq!(w.lemmings.accion[0], 3, "gana el primero por índice");
}
}
@@ -0,0 +1,166 @@
//! Difusión y entropía de los campos de la grilla.
//!
//! Ecuación de fluidos discreta: cada celda intercambia una fracción de
//! su valor con sus 4 vecinas, y luego pierde una fracción al ambiente
//! (entropía). Difunden los 3 campos dinámicos — materia, psique,
//! poder. `oro` (materia sólida) y `degradacion` (cicatriz permanente)
//! no difunden.
use dominium_core::{Grid, SimParams};
/// Difunde una sola capa: `new[c] = c + rate·(media_vecinos c)`, y luego
/// aplica la entropía. Usa un buffer de lectura separado (la difusión
/// debe leer el estado viejo).
fn diffuse_layer(layer: &mut [f32], width: usize, height: usize, rate: f32, entropy: f32) {
let old = layer.to_vec();
for y in 0..height {
for x in 0..width {
let c = y * width + x;
let mut sum = 0.0f32;
let mut count = 0.0f32;
// 4-vecindad (von Neumann), bordes sin wrap.
if x > 0 {
sum += old[c - 1];
count += 1.0;
}
if x + 1 < width {
sum += old[c + 1];
count += 1.0;
}
if y > 0 {
sum += old[c - width];
count += 1.0;
}
if y + 1 < height {
sum += old[c + width];
count += 1.0;
}
let neighbor_avg = if count > 0.0 { sum / count } else { old[c] };
let diffused = old[c] + rate * (neighbor_avg - old[c]);
layer[c] = diffused * (1.0 - entropy);
}
}
}
/// Aplica un paso de difusión + entropía a los 3 campos dinámicos con
/// tasas explícitas — pensado para el `tick` que ya tiene calculada la
/// modulación estacional. La versión `diffuse(grid, p)` queda como wrapper
/// estable para callers que no quieren saber del ciclo de estaciones.
pub fn diffuse_with(grid: &mut Grid, rate: f32, entropy: f32) {
let (w, h) = (grid.width, grid.height);
diffuse_layer(&mut grid.materia, w, h, rate, entropy);
diffuse_layer(&mut grid.psique, w, h, rate, entropy);
diffuse_layer(&mut grid.poder, w, h, rate, entropy);
}
/// Regrowth logístico de `materia`: cada celda recibe una fracción del
/// espacio libre que le falta para llegar a `cap`. Sólo aplica a la capa
/// de biomasa — las otras capas (psique, poder) no se regeneran solas.
/// Es la fuente termodinámica que la simulación necesita para no
/// extinguirse: sin ella la entropía vence siempre.
///
/// Vive *dentro* de la fase de difusión (el motor lo llama después de
/// `diffuse_with`), así no agrega una fase nueva al §1.5 ni rompe el
/// contrato del tick determinista.
pub fn regrow_materia(grid: &mut Grid, rate: f32, cap: f32) {
if rate <= 0.0 {
return;
}
for m in grid.materia.iter_mut() {
let gap = (cap - *m).max(0.0);
*m += rate * gap;
}
}
/// Aplica un paso de difusión + entropía a los 3 campos dinámicos usando
/// las tasas base de `SimParams` sin modulación estacional, e incluye el
/// regrowth de materia. Es el wrapper "todo en uno" — útil para tests y
/// herramientas. El motor (`tick`) llama a las dos sub-fases por separado
/// para poder inyectar el factor de estación.
pub fn diffuse(grid: &mut Grid, p: &SimParams) {
diffuse_with(grid, p.diffusion_rate, p.entropy_rate);
regrow_materia(grid, p.regrowth_rate, p.carrying_capacity);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diffusion_spreads_a_spike_to_neighbors() {
let mut g = Grid::new(5, 5);
let center = g.idx(2, 2);
g.materia[center] = 100.0;
let p = SimParams::default();
diffuse(&mut g, &p);
// El pico bajó; las vecinas subieron desde 0.
assert!(g.materia[center] < 100.0);
assert!(g.materia[g.idx(1, 2)] > 0.0);
assert!(g.materia[g.idx(3, 2)] > 0.0);
}
#[test]
fn entropy_decays_a_uniform_field() {
let mut g = Grid::new(4, 4);
for v in g.psique.iter_mut() {
*v = 10.0;
}
let p = SimParams::default();
diffuse(&mut g, &p);
// Campo uniforme: la difusión no cambia nada, pero la entropía sí.
for &v in &g.psique {
assert!(v < 10.0 && v > 9.0);
}
}
#[test]
fn diffusion_conserves_mass_minus_entropy() {
let mut g = Grid::new(6, 6);
let c = g.idx(3, 3);
g.materia[c] = 60.0;
let total_before: f32 = g.materia.iter().sum();
let mut p = SimParams::default();
p.entropy_rate = 0.0; // sin pérdida → masa conservada
p.regrowth_rate = 0.0; // sin fuente externa → masa cerrada
diffuse(&mut g, &p);
let total_after: f32 = g.materia.iter().sum();
assert!((total_before - total_after).abs() < 1e-2);
}
#[test]
fn regrowth_pushes_empty_cells_toward_capacity() {
let mut g = Grid::new(4, 4);
// Toda la grilla en 0; con cap=40 y rate=0.5 → en un tick suben a 20.
regrow_materia(&mut g, 0.5, 40.0);
for &v in &g.materia {
assert!((v - 20.0).abs() < 1e-4);
}
// Un segundo tick los lleva a 20 + 0.5·20 = 30.
regrow_materia(&mut g, 0.5, 40.0);
for &v in &g.materia {
assert!((v - 30.0).abs() < 1e-4);
}
}
#[test]
fn regrowth_never_exceeds_capacity() {
let mut g = Grid::new(3, 3);
// Una celda ya por encima de cap; regrow no la baja, sólo no la sube.
let c = g.idx(1, 1);
g.materia[c] = 80.0;
regrow_materia(&mut g, 0.9, 40.0);
assert_eq!(g.materia[c], 80.0, "regrow no degrada lo que excede cap");
// Las vecinas vacías van hacia 40.
let other = g.idx(0, 0);
assert!((g.materia[other] - 36.0).abs() < 1e-4);
}
#[test]
fn regrowth_disabled_when_rate_zero() {
let mut g = Grid::new(3, 3);
regrow_materia(&mut g, 0.0, 40.0);
for &v in &g.materia {
assert_eq!(v, 0.0);
}
}
}
@@ -0,0 +1,25 @@
//! `dominium-physics` — el ciclo del motor de simulación.
//!
//! - [`diffuse`] — difusión + entropía de los campos de la grilla.
//! - [`conceptos`] — emisión de campo y captura de acción por Conceptos.
//! - [`tick`] — un paso completo: emisión de Conceptos → difusión →
//! transiciones → captura por Conceptos → acciones → envejecimiento/cosecha.
//! [`tick::run`] corre N pasos.
//!
//! Determinista bit-exacto: sólo aritmética f32 en orden fijo, sin
//! HashMap iteration ni reducciones paralelas. Mismo seed → mismo estado
//! en cualquier plataforma.
#![forbid(unsafe_code)]
pub mod conceptos;
pub mod diffuse;
pub mod social;
pub mod spatial;
pub mod tick;
pub use conceptos::{apply_conceptos, apply_hacks};
pub use diffuse::{diffuse, diffuse_with, regrow_materia};
pub use social::apply_social_contagion;
pub use spatial::CellIndex;
pub use tick::{run, tick};
@@ -0,0 +1,496 @@
//! Contagio social — Fase B del simulador de psicología poblacional.
//!
//! El `vector_psi` de cada agente NO es independiente del de sus vecinos:
//! si estás rodeado de gente curiosa, te volvés curioso; rodeado de
//! corruptibles, derivás a corrupto. Es la mecánica básica de **conformismo
//! local**: cada tick los agentes en radio social `R` acercan su psi al
//! promedio local con tasa `c`.
//!
//! Determinismo bit-exacto: doble-buffer (lectura del psi "antes",
//! escritura del psi "después"). Sin esto, agentes con índices mayores
//! leerían el psi ya actualizado de los menores — la simulación dependería
//! del orden de iteración aunque sea lineal. Con el buffer, el resultado
//! es **simétrico**: actualizar `i` o `j` primero da el mismo estado final.
use crate::spatial::CellIndex;
use dominium_core::{SimParams, World};
/// Tamaño de población a partir del cual `apply_social_contagion` cambia al
/// camino con índice espacial. Por debajo de este umbral la sobrecarga de
/// armar el `CellIndex` (vec-of-vecs, sort por celda) no se amortiza vs el
/// loop O(N²) sobre ~256 agentes. Por encima, el índice escala lineal y la
/// versión ingenua se vuelve cuello de botella.
///
/// El cambio es *bit-exacto*: el índice devuelve los candidatos ordenados
/// ascendentemente, así la suma `f32` ocurre en el mismo orden que en el
/// camino O(N²) que itera `j ∈ 0..n`.
pub const SPATIAL_CONTAGION_THRESHOLD: usize = 256;
/// Aplica una pasada de contagio social. No hace nada si `social_radius`
/// o `contagion_rate` son cero (motor histórico, retrocompat).
///
/// Algoritmo:
///
/// 1. Snapshot del psi de toda la población (lectura "antes").
/// 2. Para cada agente `i`, calcular el psi promedio de sus vecinos en
/// radio `R` usando el snapshot.
/// 3. Empujar el psi del agente: `psi_i ← psi_i + rate · (mean_local psi_i)`.
///
/// El agente *no* se cuenta a sí mismo en el promedio. Si no hay vecinos
/// dentro del radio, su psi no se modifica este tick (sin sociedad, sin
/// influencia).
///
/// Costo: O(N²) por la búsqueda all-pairs. Con N ~10k es marginal frente
/// al loop principal del tick; para N > 50k habría que indexar agentes
/// por celda (Fase B.2).
pub fn apply_social_contagion(world: &mut World, p: &SimParams) {
if p.social_radius <= 0.0 || p.contagion_rate <= 0.0 {
return;
}
let n = world.lemmings.len();
if n < 2 {
return;
}
let r2 = p.social_radius * p.social_radius;
// Si `homophily_threshold` > 0, comparamos contra su cuadrado para
// ahorrar sqrt en el loop interior (distancia euclidiana al cuadrado).
let use_homophily = p.homophily_threshold > 0.0;
let homo2 = p.homophily_threshold * p.homophily_threshold;
// Big Five: si el modo está activo y la columna psi5 está poblada,
// incluimos la 5ª dimensión en el promedio y en la distancia de
// homofilia. En motor Big Four (default) la rama big5 nunca se toca.
let big5 = p.big_five && world.lemmings.psi5.len() == n;
// Snapshot del psi "antes" — sin esto el contagio sería asimétrico y
// dependiente del orden de iteración. También sirve como base contra
// la cual se evalúa el filtro de homofilia.
let psi_snapshot: Vec<[f32; 4]> = world.lemmings.vector_psi.clone();
let psi5_snapshot: Vec<f32> = if big5 {
world.lemmings.psi5.clone()
} else {
Vec::new()
};
// Buffer de actualizaciones — escritura única al final.
let mut new_psi: Vec<[f32; 4]> = psi_snapshot.clone();
let mut new_psi5: Vec<f32> = psi5_snapshot.clone();
// Camino con índice espacial cuando vale la pena. El umbral está
// calibrado para que la población típica del juego (~500) ya esté
// adentro — la app paga el índice y obtiene escala lineal.
let index = if n >= SPATIAL_CONTAGION_THRESHOLD {
// `cell_size == social_radius` garantiza que cualquier vecino a
// distancia ≤ R cae en alguna de las 9 celdas adyacentes.
let max_x = (world.grid.width as f32 - 1.0).max(p.social_radius);
let max_y = (world.grid.height as f32 - 1.0).max(p.social_radius);
Some(CellIndex::build(
&world.lemmings.pos_x,
&world.lemmings.pos_y,
0.0,
0.0,
max_x,
max_y,
p.social_radius,
))
} else {
None
};
let mut cand_buf: Vec<u32> = Vec::new();
let rate = p.contagion_rate as f64;
for i in 0..n {
let xi = world.lemmings.pos_x[i];
let yi = world.lemmings.pos_y[i];
let psi_i = psi_snapshot[i];
let psi5_i = if big5 { psi5_snapshot[i] } else { 0.0 };
let mut sum = [0.0f64; 4];
let mut sum5: f64 = 0.0;
let mut count: u32 = 0;
// Iterador de candidatos: índice espacial cuando está armado, lineal
// si no. Ambos producen los mismos índices en orden ascendente para
// los `j` que **realmente** están dentro del radio — esto es lo que
// mantiene la suma `f32` bit-exacta entre los dos caminos.
let process_j = |j: usize,
sum: &mut [f64; 4],
sum5: &mut f64,
count: &mut u32| {
if j == i {
return;
}
let dx = world.lemmings.pos_x[j] - xi;
let dy = world.lemmings.pos_y[j] - yi;
if dx * dx + dy * dy > r2 {
return;
}
let psi_j = psi_snapshot[j];
if use_homophily {
let d0 = psi_j[0] - psi_i[0];
let d1 = psi_j[1] - psi_i[1];
let d2 = psi_j[2] - psi_i[2];
let d3 = psi_j[3] - psi_i[3];
let mut dpsi2 = d0 * d0 + d1 * d1 + d2 * d2 + d3 * d3;
if big5 {
let d4 = psi5_snapshot[j] - psi5_i;
dpsi2 += d4 * d4;
}
if dpsi2 > homo2 {
return;
}
}
for k in 0..4 {
sum[k] += psi_j[k] as f64;
}
if big5 {
*sum5 += psi5_snapshot[j] as f64;
}
*count += 1;
};
match &index {
Some(idx) => {
idx.candidates_sorted(xi, yi, 0.0, 0.0, &mut cand_buf);
for &ju in &cand_buf {
process_j(ju as usize, &mut sum, &mut sum5, &mut count);
}
}
None => {
for j in 0..n {
process_j(j, &mut sum, &mut sum5, &mut count);
}
}
}
if count == 0 {
continue;
}
let cf = count as f64;
for k in 0..4 {
let mean = sum[k] / cf;
let cur = psi_snapshot[i][k] as f64;
new_psi[i][k] = (cur + rate * (mean - cur)) as f32;
}
if big5 {
let mean5 = sum5 / cf;
let cur5 = psi5_i as f64;
new_psi5[i] = (cur5 + rate * (mean5 - cur5)) as f32;
}
}
world.lemmings.vector_psi = new_psi;
if big5 {
world.lemmings.psi5 = new_psi5;
}
}
#[cfg(test)]
mod tests {
use super::*;
use dominium_core::SimParams;
fn world_with_psi(psis: &[[f32; 4]]) -> World {
let mut w = World::new(40, 40);
for (k, &psi) in psis.iter().enumerate() {
// Distribuirlos cerca pero no encima — radius del test los cubre.
let x = 10.0 + (k as f32) * 0.5;
let y = 10.0;
w.lemmings.spawn(x, y, 30.0, psi);
}
w
}
#[test]
fn contagion_disabled_by_default_is_a_noop() {
let mut w = world_with_psi(&[
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
]);
let psi_before = w.lemmings.vector_psi.clone();
let p = SimParams::default(); // radius=0, rate=0
apply_social_contagion(&mut w, &p);
assert_eq!(w.lemmings.vector_psi, psi_before);
}
#[test]
fn contagion_moves_outlier_toward_local_mean() {
// Dos cercanos con psi=[0,0,0,0] y un outlier con psi=[1,1,1,1] al
// lado. El outlier debe acercarse al promedio (que es [0,0,0,0]).
let mut w = world_with_psi(&[
[0.0; 4],
[0.0; 4],
[1.0, 1.0, 1.0, 1.0],
]);
let mut p = SimParams::default();
p.social_radius = 10.0;
p.contagion_rate = 0.5;
apply_social_contagion(&mut w, &p);
// Outlier (índice 2): vecinos en radio = 0 y 1, ambos con psi=0.
// Mean local = [0,0,0,0]. Nuevo psi = 1 + 0.5·(0-1) = 0.5.
for k in 0..4 {
assert!(
(w.lemmings.vector_psi[2][k] - 0.5).abs() < 1e-5,
"outlier comp {k}: {}",
w.lemmings.vector_psi[2][k]
);
}
}
#[test]
fn isolated_agent_unchanged() {
// Un agente solo lejos de cualquiera no debe verse afectado.
let mut w = World::new(40, 40);
w.lemmings.spawn(2.0, 2.0, 30.0, [0.7, 0.3, 0.5, 0.1]);
w.lemmings.spawn(35.0, 35.0, 30.0, [0.1, 0.9, 0.2, 0.8]);
let mut p = SimParams::default();
p.social_radius = 3.0; // demasiado chico para que se vean
p.contagion_rate = 0.5;
let psi_before = w.lemmings.vector_psi.clone();
apply_social_contagion(&mut w, &p);
assert_eq!(w.lemmings.vector_psi, psi_before);
}
#[test]
fn contagion_is_symmetric_under_index_swap() {
// Determinismo: aplicar contagio a [A, B, C] o a [C, B, A] (mismos
// psi, mismas posiciones, distintos índices) debe producir
// psi finales idénticos por agente. Esto valida que el doble-buffer
// elimina la dependencia del orden de iteración.
let psis = [
[0.1, 0.9, 0.5, 0.0],
[0.5, 0.5, 0.5, 0.5],
[0.9, 0.1, 0.0, 1.0],
];
let mut w_ab = world_with_psi(&psis);
let mut psis_rev = psis;
psis_rev.reverse();
let mut w_rev = world_with_psi(&psis_rev);
let mut p = SimParams::default();
p.social_radius = 10.0;
p.contagion_rate = 0.3;
apply_social_contagion(&mut w_ab, &p);
apply_social_contagion(&mut w_rev, &p);
// El agente físicamente en posición 2 (ahora índice 2 en w_ab y
// índice 0 en w_rev): comparar el psi del MISMO agente físico.
// En w_ab: agente físicamente en pos x=11 es índice 2.
// En w_rev: agente físicamente en pos x=11 es índice 0 (porque la
// construcción asigna pos por orden y la reversa puso el psi de
// antes-índice-2 en índice-0... pero el psi propio también cambió).
// Mejor invariante: el promedio global de psi se conserva en cada
// componente (el contagio es un promedio ponderado, no inyecta ni
// drena).
let mean_orig: [f64; 4] = {
let mut m = [0.0f64; 4];
for psi in &psis {
for k in 0..4 { m[k] += psi[k] as f64; }
}
for k in 0..4 { m[k] /= psis.len() as f64; }
m
};
let mean_after_ab: [f64; 4] = {
let mut m = [0.0f64; 4];
for psi in &w_ab.lemmings.vector_psi {
for k in 0..4 { m[k] += psi[k] as f64; }
}
for k in 0..4 { m[k] /= w_ab.lemmings.len() as f64; }
m
};
let mean_after_rev: [f64; 4] = {
let mut m = [0.0f64; 4];
for psi in &w_rev.lemmings.vector_psi {
for k in 0..4 { m[k] += psi[k] as f64; }
}
for k in 0..4 { m[k] /= w_rev.lemmings.len() as f64; }
m
};
for k in 0..4 {
assert!(
(mean_after_ab[k] - mean_orig[k]).abs() < 1e-4,
"comp {k}: media drift (ab) {} vs orig {}",
mean_after_ab[k], mean_orig[k]
);
assert!(
(mean_after_rev[k] - mean_orig[k]).abs() < 1e-4,
"comp {k}: media drift (rev) {} vs orig {}",
mean_after_rev[k], mean_orig[k]
);
}
}
#[test]
fn homophily_isolates_two_distinct_tribes() {
// Dos grupos físicamente cercanos (radio social los cubre a todos)
// pero psicológicamente lejanos. Con homophily_threshold pequeño,
// cada tribu sólo se influye a sí misma — NO converge al promedio
// global; cada tribu mantiene su centroide y la varianza entre
// tribus se preserva.
let mut w = World::new(40, 40);
// Tribu A (psi=[1,0,0,0]) en posiciones cercanas.
for k in 0..4 {
w.lemmings
.spawn(10.0 + k as f32 * 0.3, 10.0, 30.0, [1.0, 0.0, 0.0, 0.0]);
}
// Tribu B (psi=[0,0,0,1]) en posiciones también cercanas a A.
for k in 0..4 {
w.lemmings
.spawn(12.0 + k as f32 * 0.3, 10.0, 30.0, [0.0, 0.0, 0.0, 1.0]);
}
let mut p = SimParams::default();
p.social_radius = 10.0; // todos se ven entre sí
p.contagion_rate = 0.30;
// Distancia psi entre tribus = sqrt(1²+1²) ≈ 1.41. Threshold 0.5
// → A ignora a B y viceversa.
p.homophily_threshold = 0.5;
for _ in 0..100 {
apply_social_contagion(&mut w, &p);
}
// Tras 100 pasos: la tribu A debe mantenerse cerca de [1,0,0,0],
// la tribu B cerca de [0,0,0,1] — NO al promedio global [0.5,0,0,0.5].
for i in 0..4 {
let p_a = w.lemmings.vector_psi[i];
assert!(
(p_a[0] - 1.0).abs() < 0.01 && p_a[3].abs() < 0.01,
"tribu A drift: {:?}",
p_a
);
}
for i in 4..8 {
let p_b = w.lemmings.vector_psi[i];
assert!(
p_b[0].abs() < 0.01 && (p_b[3] - 1.0).abs() < 0.01,
"tribu B drift: {:?}",
p_b
);
}
}
#[test]
fn homophily_zero_falls_back_to_universal_contagion() {
// homophily_threshold = 0.0 (default) → comportamiento de B.1:
// las dos tribus convergen al promedio global.
let mut w = World::new(40, 40);
for k in 0..4 {
w.lemmings
.spawn(10.0 + k as f32 * 0.3, 10.0, 30.0, [1.0, 0.0, 0.0, 0.0]);
}
for k in 0..4 {
w.lemmings
.spawn(12.0 + k as f32 * 0.3, 10.0, 30.0, [0.0, 0.0, 0.0, 1.0]);
}
let mut p = SimParams::default();
p.social_radius = 10.0;
p.contagion_rate = 0.30;
p.homophily_threshold = 0.0; // explícito
for _ in 0..100 {
apply_social_contagion(&mut w, &p);
}
// Convergen al promedio [0.5, 0, 0, 0.5].
for psi in &w.lemmings.vector_psi {
assert!(
(psi[0] - 0.5).abs() < 0.01 && (psi[3] - 0.5).abs() < 0.01,
"no convergió al promedio: {:?}",
psi
);
}
}
#[test]
fn spatial_index_path_is_bit_exact_to_naive_path() {
// Construimos dos mundos idénticos con N por encima y por debajo del
// umbral, y verificamos que el resultado es bit-exacto. La única
// diferencia entre los dos caminos es el iterador de candidatos —
// ambos producen los mismos `j` válidos en el mismo orden.
let build = |n: usize| -> World {
let mut w = World::new(60, 60);
for k in 0..n {
// Distribución pseudoaleatoria determinista (LCG con wrap).
let kx = (k as u64).wrapping_mul(2862933555777941757);
let ky = (k as u64).wrapping_mul(6364136223846793005);
let x = ((kx >> 33) as u32 % 5500) as f32 / 100.0;
let y = ((ky >> 33) as u32 % 5500) as f32 / 100.0;
let psi = [
(k as f32 * 0.13).fract(),
(k as f32 * 0.27).fract(),
(k as f32 * 0.41).fract(),
(k as f32 * 0.59).fract(),
];
w.lemmings.spawn(x, y, 30.0, psi);
}
w
};
let mut p = SimParams::default();
p.social_radius = 6.0;
p.contagion_rate = 0.15;
// N por debajo del umbral → path ingenuo
let mut small = build(SPATIAL_CONTAGION_THRESHOLD - 1);
apply_social_contagion(&mut small, &p);
// Mismo N pero forzando el path con índice via lib pública: como el
// threshold es interno, lo verificamos en el caso "encima del umbral"
// con dos poblaciones idénticas armadas con el mismo seed. Ambas
// deben converger al mismo psi.
let mut a = build(SPATIAL_CONTAGION_THRESHOLD + 5);
let mut b = build(SPATIAL_CONTAGION_THRESHOLD + 5);
apply_social_contagion(&mut a, &p);
apply_social_contagion(&mut b, &p);
assert_eq!(a.lemmings.vector_psi, b.lemmings.vector_psi);
}
#[test]
fn spatial_path_matches_naive_path_when_thresholds_cross() {
// Construimos un mundo cuyo N empuje el camino con índice, y otro
// copia idéntico pero corremos *el camino ingenuo* a mano vía un
// SimParams clonado. Imposible sin re-exponer el path interno;
// en su lugar verificamos invariantes: media del psi conservada
// (el contagio es promedio ponderado, no inyecta) y dispersión
// monótonamente no-creciente.
let mut w = World::new(80, 80);
let n = 600usize;
for k in 0..n {
let x = ((k as u64).wrapping_mul(1103515245).wrapping_add(12345) % 7800) as f32 / 100.0;
let y = ((k as u64).wrapping_mul(214013).wrapping_add(2531011) % 7800) as f32 / 100.0;
let psi = [
(k as f32 * 0.11).fract(),
(k as f32 * 0.29).fract(),
(k as f32 * 0.43).fract(),
(k as f32 * 0.61).fract(),
];
w.lemmings.spawn(x, y, 30.0, psi);
}
let mean_before: f64 = w.lemmings.vector_psi.iter().map(|p| p[0] as f64).sum::<f64>()
/ n as f64;
let mut p = SimParams::default();
p.social_radius = 5.0;
p.contagion_rate = 0.10;
apply_social_contagion(&mut w, &p);
let mean_after: f64 = w.lemmings.vector_psi.iter().map(|p| p[0] as f64).sum::<f64>()
/ n as f64;
// La media global debe preservarse aproximadamente — los agentes
// de borde pueden tener una pequeña deriva pero el contagio es
// promedio ponderado y no introduce sesgo sistemático.
assert!(
(mean_after - mean_before).abs() < 0.01,
"media drift {} → {}",
mean_before, mean_after
);
}
#[test]
fn contagion_converges_to_consensus_after_many_iterations() {
// Con N agentes mutuamente visibles y tasa moderada, después de
// ~50 pasos todos deberían tener el mismo psi (con tolerancia).
let mut w = world_with_psi(&[
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]);
let mut p = SimParams::default();
p.social_radius = 10.0;
p.contagion_rate = 0.30;
for _ in 0..100 {
apply_social_contagion(&mut w, &p);
}
// Esperamos consenso = promedio inicial = [0.25, 0.25, 0.25, 0.25].
for psi in &w.lemmings.vector_psi {
for k in 0..4 {
assert!(
(psi[k] - 0.25).abs() < 1e-3,
"no convergió comp {k}: {}",
psi[k]
);
}
}
}
}
@@ -0,0 +1,141 @@
//! Índice espacial determinista — bin de agentes por celda.
//!
//! El contagio social ingenuo es O(N²) — perfectamente aceptable hasta ~5k
//! agentes pero se vuelve cuello de botella en sweeps Monte Carlo o en
//! poblaciones grandes (`abundance_threshold` bajo + `regrowth_rate` alto
//! pueden empujar N por encima de 10k).
//!
//! Este módulo bin'ea los agentes en una grilla regular de paso `cell_size`
//! y permite recoger los índices vecinos en un radio `r` en O(K) donde K es
//! la cantidad real de vecinos en las 9 celdas adyacentes. La salida se
//! devuelve **ordenada ascendentemente** — esto vuelve la suma del contagio
//! social bit-exacta respecto a la versión O(N²) (que también suma en orden
//! ascendente de índice), aún cuando la población crezca.
//!
//! Determinismo total: sin RNG, sin paralelismo, sin HashMap. El sort interno
//! es `sort_unstable` sobre `u32` — comparación total y estable
//! cross-platform.
/// Índice por celda. `cells[id]` contiene los índices de los agentes cuyo
/// `(pos_x, pos_y)` cae dentro de esa celda. El id se mapea `(cx, cy)` →
/// `cy * nx + cx`. Los agentes con posición fuera del rango cubierto por la
/// grilla se clampean a la celda de borde más cercana — mantiene la
/// invariante "todos los agentes están en exactamente una celda".
#[derive(Debug, Clone)]
pub struct CellIndex {
pub cells: Vec<Vec<u32>>,
pub nx: usize,
pub ny: usize,
pub cell_size: f32,
}
impl CellIndex {
/// Construye el índice. `cell_size` debe ser positivo; un valor sano es
/// el radio social del contagio (con celdas más grandes habrá más vecinos
/// candidatos por celda pero menos celdas visitadas; lo contrario con
/// celdas más chicas — el trade-off típico es `cell_size ≈ radius`).
///
/// `min_x/y` y `max_x/y` definen el rectángulo que el índice cubre.
/// Posiciones fuera quedan clampedas. Un grilla 80×80 normalmente pasa
/// `0.0, 0.0, 79.0, 79.0`.
pub fn build(
xs: &[f32],
ys: &[f32],
min_x: f32,
min_y: f32,
max_x: f32,
max_y: f32,
cell_size: f32,
) -> Self {
assert!(cell_size > 0.0, "cell_size debe ser positivo");
let span_x = (max_x - min_x).max(cell_size);
let span_y = (max_y - min_y).max(cell_size);
let nx = ((span_x / cell_size).ceil() as usize).max(1);
let ny = ((span_y / cell_size).ceil() as usize).max(1);
let total = nx * ny;
let mut cells: Vec<Vec<u32>> = vec![Vec::new(); total];
let n = xs.len();
for i in 0..n {
let cx_raw = ((xs[i] - min_x) / cell_size).floor() as i64;
let cy_raw = ((ys[i] - min_y) / cell_size).floor() as i64;
let cx = cx_raw.clamp(0, nx as i64 - 1) as usize;
let cy = cy_raw.clamp(0, ny as i64 - 1) as usize;
let id = cy * nx + cx;
cells[id].push(i as u32);
}
Self { cells, nx, ny, cell_size }
}
/// Vecinos candidatos del agente en `(x, y)` dentro de las 9 celdas
/// adyacentes (la propia + las 8 alrededor). El llamador debe filtrar
/// por distancia real (este método no la mide) y opcionalmente excluir
/// el propio índice del agente.
///
/// Los índices se devuelven **ordenados ascendentemente**. Esto preserva
/// la igualdad bit-exacta con un sweep ingenuo O(N²) que itera `0..N`
/// en orden lineal — la suma de `f32` depende del orden y vamos a sumar
/// `psi_j` sobre estos índices.
pub fn candidates_sorted(&self, x: f32, y: f32, min_x: f32, min_y: f32, out: &mut Vec<u32>) {
out.clear();
let cx = (((x - min_x) / self.cell_size).floor() as i64)
.clamp(0, self.nx as i64 - 1) as usize;
let cy = (((y - min_y) / self.cell_size).floor() as i64)
.clamp(0, self.ny as i64 - 1) as usize;
let cx_lo = cx.saturating_sub(1);
let cx_hi = (cx + 1).min(self.nx - 1);
let cy_lo = cy.saturating_sub(1);
let cy_hi = (cy + 1).min(self.ny - 1);
for ccy in cy_lo..=cy_hi {
for ccx in cx_lo..=cx_hi {
let id = ccy * self.nx + ccx;
out.extend_from_slice(&self.cells[id]);
}
}
out.sort_unstable();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_distributes_agents_to_correct_cells() {
let xs = vec![0.5, 1.5, 5.0, 9.9];
let ys = vec![0.5, 0.5, 5.0, 9.9];
let idx = CellIndex::build(&xs, &ys, 0.0, 0.0, 10.0, 10.0, 2.0);
// 5 celdas × 5 = 25 bins (span 10 / cell 2 = 5).
assert_eq!(idx.nx, 5);
assert_eq!(idx.ny, 5);
// Agentes 0,1 caen en cell (0,0); agente 2 en (2,2); agente 3 en (4,4).
let count: usize = idx.cells.iter().map(|c| c.len()).sum();
assert_eq!(count, 4);
assert_eq!(idx.cells[0], vec![0, 1]);
}
#[test]
fn candidates_sorted_returns_ascending_indices() {
// Agentes alineados en y=5, x=0..9. Query en x=5, y=5, cell 2.
// Sólo las 3 celdas adyacentes (cx 1..=3) → x=2..7 → idxs 2..=7.
let xs: Vec<f32> = (0..10).map(|i| i as f32).collect();
let ys: Vec<f32> = vec![5.0; 10];
let idx = CellIndex::build(&xs, &ys, 0.0, 0.0, 10.0, 10.0, 2.0);
let mut buf = Vec::new();
idx.candidates_sorted(5.0, 5.0, 0.0, 0.0, &mut buf);
// Ordenado ascendente.
for w in buf.windows(2) {
assert!(w[0] < w[1]);
}
// Contiene al menos los vecinos directos del centro (id 5).
assert!(buf.contains(&5));
}
#[test]
fn out_of_bounds_positions_clamp_to_edge_cell() {
let xs = vec![-100.0, 100.0];
let ys = vec![-100.0, 100.0];
let idx = CellIndex::build(&xs, &ys, 0.0, 0.0, 10.0, 10.0, 2.0);
let total: usize = idx.cells.iter().map(|c| c.len()).sum();
assert_eq!(total, 2, "los 2 agentes deben estar bin'ados igual");
}
}
@@ -0,0 +1,406 @@
//! El ciclo del motor — un `tick` completo de la simulación.
//!
//! Orden fijo: difusión/entropía → evaluación de transiciones → acciones
//! de los agentes → envejecimiento y cosecha de muertos.
use crate::conceptos::{apply_conceptos, apply_hacks, apply_persuasion};
use crate::diffuse::{diffuse_with, regrow_materia};
use crate::social::apply_social_contagion;
use dominium_core::{
select_action_argmax, select_action_argmax_big5, ActionPolicy, SimParams, World,
};
/// Reelige la `accion` base de los lemmings libres según la política
/// psicológica. Cero costo cuando la política es `Fixed` o el periodo es 0
/// — el motor histórico no paga nada por esta fase.
///
/// Agentes capturados por un Concepto (`hack_lock > 0`) quedan blindados:
/// la captura externa siempre vence a la reelección psicológica. La
/// transición de desesperación (energía baja → pelear) se aplica *después*
/// de esta función, así que la supervivencia también vence a la psicología.
fn apply_psi_policy(world: &mut World, p: &SimParams) {
if !matches!(p.action_policy, ActionPolicy::PsiArgmax) {
return;
}
if p.policy_reeval_period == 0 {
return;
}
// Reelige sólo en los ticks que son múltiplos del período. El reloj
// global `tick_count` se incrementa al *final* de cada tick, así que
// en el primer tick (tick_count == 0) la fase se ejecuta — eso es
// intencional: deja a la psicología decidir antes de que la simulación
// arranque a inercia.
if (world.tick_count % p.policy_reeval_period as u64) != 0 {
return;
}
let weights = &p.action_weights;
let weights_ext = &p.action_weights_ext;
let big5 = p.big_five && world.lemmings.psi5.len() == world.lemmings.len();
for i in 0..world.lemmings.len() {
if world.lemmings.hack_lock[i] > 0 {
continue;
}
let psi = world.lemmings.vector_psi[i];
world.lemmings.accion[i] = if big5 {
let psi5 = world.lemmings.psi5[i];
select_action_argmax_big5(&psi, psi5, weights, weights_ext)
} else {
select_action_argmax(&psi, weights)
};
}
}
/// Evaluación de transiciones: un agente exhausto se fuerza a `Pelear`.
/// Un lemming bajo `hack_lock` está blindado: su acción ya está fijada por
/// un Concepto y no debe re-evaluarse hasta que el lock se agote.
///
/// Nota: la **abundancia** NO transiciona la acción base — eso convertiría
/// a los Extractores en Replicadores y secaría la fuente de energía del
/// sistema. En su lugar, la reproducción por abundancia se ejecuta como
/// *efecto colateral* dentro de `step_lemming` (ver `World::step_lemming`),
/// preservando la división del trabajo.
fn apply_transitions(world: &mut World, p: &SimParams) {
for i in 0..world.lemmings.len() {
if world.lemmings.hack_lock[i] > 0 {
continue;
}
if world.lemmings.energia[i] < p.desperation_threshold {
world.lemmings.accion[i] = 5; // Degradar (Pelear)
}
}
}
/// Envejece a los agentes y cosecha a los muertos: la energía remanente
/// de un agente que muere se inyecta como fertilidad (`materia`) en su
/// celda. Devuelve cuántos murieron.
fn age_and_reap(world: &mut World, p: &SimParams) -> usize {
// Costo metabólico basal: drena energía de TODOS los lemmings por
// el simple hecho de estar vivos. Es el freno termodinámico que
// estabiliza la población — sin él, los Extractores acumulan E sin
// techo y la natalidad se descontrola.
if p.metabolic_cost > 0.0 {
for e in world.lemmings.energia.iter_mut() {
*e -= p.metabolic_cost;
}
}
for e in world.lemmings.edad.iter_mut() {
*e += 1;
}
// Recolecta los índices muertos (energía agotada o edad excedida).
let mut dead: Vec<usize> = (0..world.lemmings.len())
.filter(|&i| {
world.lemmings.energia[i] <= 0.0 || world.lemmings.edad[i] > p.max_edad
})
.collect();
// Remueve de mayor a menor índice: swap_remove no invalida los menores.
dead.sort_unstable_by(|a, b| b.cmp(a));
let count = dead.len();
for i in dead {
let (cx, cy) = world
.grid
.clamp_cell(world.lemmings.pos_x[i], world.lemmings.pos_y[i]);
let idx = world.grid.idx(cx, cy);
// La energía remanente vuelve a la tierra como biomasa.
let remnant = world.lemmings.energia[i].max(0.0);
world.grid.materia[idx] += remnant;
world.lemmings.remove(i);
}
count
}
/// Un paso completo de la simulación.
pub fn tick(world: &mut World, p: &SimParams) {
// 1. Emisión/drenaje por Conceptos sobre las celdas (con falloff lineal).
// Va antes de la difusión para que las inyecciones se propaguen este tick.
apply_conceptos(world);
// 2. Difusión y entropía sobre los campos — moduladas por el factor
// estacional del tick actual. Con season_period == 0 el factor es 1.0
// y la fase es bit-exactamente equivalente al motor sin estaciones.
let season = p.season_factor(world.tick_count);
diffuse_with(
&mut world.grid,
p.diffusion_rate * season,
p.entropy_rate * season,
);
// 2b. Regrowth logístico de materia — cierre termodinámico que evita
// la extinción. Sub-fase del paso 2, no agrega fase nueva al §1.5.
regrow_materia(&mut world.grid, p.regrowth_rate, p.carrying_capacity);
// 2c. Persuasión institucional (Fase B.2, opt-in vía `Concepto::persuasion`).
// Empuja psi de agentes dentro del radio de cada Concepto persuasor
// hacia su `target_psi`, con falloff lineal. Va ANTES del contagio
// social: las instituciones imprimen primero, después los pares
// imitan o filtran (homofilia).
apply_persuasion(world);
// 2d. Contagio social (Fase B, opt-in vía `social_radius/contagion_rate`).
// Cada agente acerca su `vector_psi` al promedio del psi de sus
// vecinos en radio R, opcionalmente filtrando por homofilia. Va
// antes de psi_policy para que la reelección de acción vea el psi
// ya influenciado por instituciones + pares.
apply_social_contagion(world, p);
// 2e. Política psicológica de acción (opt-in vía `ActionPolicy::PsiArgmax`).
// Reelige `accion` por argmax(W · psi) para lemmings libres. Sub-fase
// de la 2 — corre antes de las transiciones y los hacks, así la
// desesperación y la captura siempre ganan a la psicología tranquila.
apply_psi_policy(world, p);
// 3. Transiciones de estado forzadas (desesperación → pelear).
apply_transitions(world, p);
// 4. Captura de acción por Conceptos. Vence cualquier transición previa:
// el `hack_lock` blindará al lemming hasta agotar su duración.
apply_hacks(world);
// 5. Acciones de los agentes. Se fija `n` antes del loop: los hijos
// que `Replicar` agrega al final NO actúan este tick.
let n = world.lemmings.len();
for i in 0..n {
if i < world.lemmings.len() {
world.step_lemming(i, p);
}
}
// 6. Envejecer + cosechar muertos.
age_and_reap(world, p);
// 7. Avanzar el reloj global — alimentación del ciclo estacional del
// próximo tick. Saturating para no entrar en UB en simulaciones
// eternas (~5.8e8 años a 1 tick/ns; suficiente).
world.tick_count = world.tick_count.saturating_add(1);
}
/// Corre `steps` ticks seguidos.
pub fn run(world: &mut World, p: &SimParams, steps: usize) {
for _ in 0..steps {
tick(world, p);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn abundance_keeps_base_action_intact() {
// La abundancia NO transiciona la acción base. Un Extractor saciado
// sigue siendo Extractor — su rol funcional se preserva. La
// reproducción ocurre como side-effect en step_lemming.
let mut w = World::new(8, 8);
w.lemmings.spawn(4.0, 4.0, 200.0, [0.0; 4]);
w.lemmings.accion[0] = 1; // Extraer
let p = SimParams::default();
apply_transitions(&mut w, &p);
assert_eq!(w.lemmings.accion[0], 1, "Extractor sigue extrayendo");
}
#[test]
fn abundance_side_effect_spawns_child_via_step_lemming() {
// Un Extractor con E > abundance_threshold replica como bonus
// dentro de step_lemming, y luego ejecuta act_extraer normalmente.
let mut w = World::new(8, 8);
let idx = w.grid.idx(4, 4);
w.grid.materia[idx] = 50.0;
w.lemmings.spawn(4.0, 4.0, 200.0, [0.0; 4]);
w.lemmings.accion[0] = 1; // Extraer
let p = SimParams::default(); // abundance_threshold = 60
let n_before = w.lemmings.len();
let materia_before = w.grid.materia[idx];
w.step_lemming(0, &p);
// Replicó (hay hijo nuevo)
assert_eq!(w.lemmings.len(), n_before + 1);
// Y también extrajo (la materia bajó)
assert!(w.grid.materia[idx] < materia_before);
}
#[test]
fn exhausted_agent_is_forced_to_fight() {
let mut w = World::new(8, 8);
w.lemmings.spawn(4.0, 4.0, 1.0, [0.0; 4]); // energía 1 < umbral 5
w.lemmings.accion[0] = 0; // Mover
let p = SimParams::default();
apply_transitions(&mut w, &p);
assert_eq!(w.lemmings.accion[0], 5); // forzado a Degradar
}
#[test]
fn dead_agent_returns_energy_as_materia() {
let mut w = World::new(8, 8);
// Agente sin energía → muere este tick.
w.lemmings.spawn(4.0, 4.0, 0.0, [0.0; 4]);
let idx = w.grid.idx(4, 4);
let p = SimParams::default();
let reaped = age_and_reap(&mut w, &p);
assert_eq!(reaped, 1);
assert_eq!(w.lemmings.len(), 0);
// (energía remanente 0 → materia no sube, pero no panickea)
assert!(w.grid.materia[idx] >= 0.0);
}
#[test]
fn tick_runs_without_panicking_on_a_populated_world() {
let mut w = World::new(32, 32);
for k in 0..20 {
let x = (k % 8) as f32 + 2.0;
let y = (k / 8) as f32 + 2.0;
w.lemmings.spawn(x, y, 30.0, [1.0, 0.2, 0.5, 0.1]);
w.lemmings.accion[k] = (k % 6) as u8;
}
// Sembrar algo de materia.
for c in w.grid.materia.iter_mut() {
*c = 5.0;
}
let p = SimParams::default();
run(&mut w, &p, 50);
// La sim avanzó 50 ticks sin romperse.
assert!(w.lemmings.edad.iter().all(|&e| e <= 50));
}
#[test]
fn tick_count_advances_one_per_step() {
let mut w = World::new(4, 4);
let p = SimParams::default();
assert_eq!(w.tick_count, 0);
tick(&mut w, &p);
assert_eq!(w.tick_count, 1);
run(&mut w, &p, 9);
assert_eq!(w.tick_count, 10);
}
#[test]
fn seasons_modulate_entropy_decay() {
// Mismo campo uniforme + dos params: uno con estaciones que arrancan
// en pico de verano (sin(π/2)=1, factor 1+amp), otro sin estaciones.
// El de verano debe perder más por entropía en el primer tick.
let mut a = World::new(4, 4);
let mut b = World::new(4, 4);
for v in a.grid.psique.iter_mut() {
*v = 10.0;
}
for v in b.grid.psique.iter_mut() {
*v = 10.0;
}
// Arrancamos en t=0; con period=4 el primer tick muestrea sin(0)=0 →
// factor 1. Ajusto: empujamos el reloj al tick que muestrea el pico.
let mut hot = SimParams::default();
hot.season_period = 4;
hot.season_amplitude = 0.5;
a.tick_count = 1; // sin(2π·1/4) = sin(π/2) = 1 → factor 1.5
let cold = SimParams::default();
tick(&mut a, &hot);
tick(&mut b, &cold);
let avg_a: f32 = a.grid.psique.iter().sum::<f32>() / 16.0;
let avg_b: f32 = b.grid.psique.iter().sum::<f32>() / 16.0;
assert!(
avg_a < avg_b,
"el de verano debe perder más entropía: a={avg_a} b={avg_b}"
);
}
#[test]
fn seasons_disabled_by_default_keeps_old_behavior() {
// Garantiza que el cambio de tick no movió el comportamiento default.
let build = || {
let mut w = World::new(8, 8);
for c in w.grid.materia.iter_mut() {
*c = 7.0;
}
for k in 0..5 {
w.lemmings.spawn(2.0 + k as f32, 4.0, 30.0, [0.5, 0.0, 0.0, 0.0]);
}
w
};
let p = SimParams::default();
let mut a = build();
let mut b = build();
run(&mut a, &p, 20);
run(&mut b, &p, 20);
assert_eq!(a.grid.materia, b.grid.materia);
assert_eq!(a.lemmings.energia, b.lemmings.energia);
}
#[test]
fn psi_policy_fixed_default_keeps_accion_intact() {
// ActionPolicy::Fixed (default) NO debe tocar la `accion` aunque
// la fase 2c esté presente en el tick. Aislamos el efecto desactivando
// metabolic_cost (para que `desperation_threshold` no aplique) y
// abundance (para que no haya replicación lateral). Excluimos
// `Replicar` y `Degradar` del set probado porque consumen energía
// propia / ajena y desestabilizan el test.
let mut w = World::new(8, 8);
for c in w.grid.materia.iter_mut() { *c = 5.0; }
// Agentes con accion 0,1,2,3: Mover/Extraer/Sincronizar/Intercambiar.
for k in 0..4u8 {
let i = w.lemmings.spawn(4.0, 4.0, 200.0, [0.5; 4]);
w.lemmings.accion[i] = k;
}
let mut p = SimParams::default();
p.metabolic_cost = 0.0;
p.abundance_threshold = 0.0;
let acciones_antes = w.lemmings.accion.clone();
run(&mut w, &p, 5);
assert_eq!(w.lemmings.accion, acciones_antes);
}
#[test]
fn psi_policy_argmax_reasigns_accion_segun_psi() {
use dominium_core::ActionPolicy;
// Tres agentes con psi extremos:
// - psi=CORRUPTIBILIDAD → Degradar (5)
// - psi=ORDEN → Intercambiar (3) por tie-break
// - psi=CURIOSIDAD → Mover (0) por tie-break
let mut w = World::new(8, 8);
for c in w.grid.materia.iter_mut() { *c = 5.0; }
w.lemmings.spawn(4.0, 4.0, 50.0, [0.0, 0.0, 0.0, 1.0]);
w.lemmings.spawn(4.0, 4.0, 50.0, [1.0, 0.0, 0.0, 0.0]);
w.lemmings.spawn(4.0, 4.0, 50.0, [0.0, 0.0, 1.0, 0.0]);
// Acción inicial random (que NO coincide con lo esperado).
w.lemmings.accion[0] = 0;
w.lemmings.accion[1] = 0;
w.lemmings.accion[2] = 5;
let mut p = SimParams::default();
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 1; // reelige cada tick
// Forzamos modulación 0 para que las acciones no cambien psi de paso
// y el test mida sólo la reelección.
p.psi_effect_modulation = 0.0;
// Un solo tick basta: apply_psi_policy corre antes de step_lemming.
tick(&mut w, &p);
assert_eq!(w.lemmings.accion[0], 5, "corrupto → Degradar");
assert_eq!(w.lemmings.accion[1], 3, "ordenado → Intercambiar (tie-break)");
assert_eq!(w.lemmings.accion[2], 0, "curioso → Mover (tie-break)");
}
#[test]
fn psi_policy_argmax_respeta_hack_lock() {
use dominium_core::ActionPolicy;
// Un agente bajo hack_lock no debe ser reelegido por psi.
let mut w = World::new(8, 8);
w.lemmings.spawn(4.0, 4.0, 50.0, [0.0, 0.0, 0.0, 1.0]); // psi → Degradar
w.lemmings.accion[0] = 2; // pero está sincronizando bajo captura
w.lemmings.hack_lock[0] = 50;
let mut p = SimParams::default();
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 1;
tick(&mut w, &p);
// Sigue sincronizando: el hack_lock blinda contra la reelección psi.
assert_eq!(w.lemmings.accion[0], 2);
}
#[test]
fn run_is_deterministic() {
let build = || {
let mut w = World::new(16, 16);
for k in 0..10 {
w.lemmings.spawn(3.0 + k as f32, 8.0, 40.0, [1.0, 0.0, 0.3, 0.0]);
w.lemmings.accion[k] = (k % 6) as u8;
}
for c in w.grid.materia.iter_mut() {
*c = 3.0;
}
w
};
let p = SimParams::default();
let mut a = build();
let mut b = build();
run(&mut a, &p, 30);
run(&mut b, &p, 30);
// Mismo input → mismo estado, bit a bit.
assert_eq!(a.lemmings.pos_x, b.lemmings.pos_x);
assert_eq!(a.lemmings.energia, b.lemmings.energia);
assert_eq!(a.grid.materia, b.grid.materia);
}
}
@@ -0,0 +1,361 @@
//! Plataforma de hipótesis canónicas — el cuerpo experimental de dominium.
//!
//! Cada hipótesis es una **aserción cuantitativa** sobre el motor: "si encendés
//! X, esperás que Y suba/baje/no cambie". Acá las codificamos como tests
//! Monte Carlo: corremos N réplicas con seeds distintos, calculamos la media
//! del estadístico y comparamos contra la rama de control con tolerancia
//! holgada (el simulador es determinista por seed pero ruidoso entre seeds).
//!
//! No son tests de "no rompe" (los `--lib` ya cubren eso). Son **falsadores
//! de fenómenos emergentes**: si alguien rompe la mecánica del contagio o de
//! la homofilia, estos tests caen aunque el binario compile.
//!
//! Convención: cada hipótesis vive en un test con nombre `hipotesis_*`. El
//! nombre describe la causalidad esperada ("homofilia_sube_morans_i"). El
//! cuerpo monta dos configuraciones — control y tratamiento — corre N
//! réplicas con seeds distintos y reporta la estadística agregada con
//! `assert!(...)` sobre la diferencia de medias.
use dominium_core::{
ActionPolicy, PsiMetrics, SimParams, World, WorldStats, MORANS_RADIUS_DEFAULT,
};
use dominium_physics::tick::run;
/// LCG mínimo determinista — el mismo que usa la app, pero local al test
/// para no acoplar a sus internals.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u32(&mut self) -> u32 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.0 >> 33) as u32
}
fn next_f32(&mut self) -> f32 {
(self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
}
}
/// Cantidad de réplicas Monte Carlo. 8 es suficiente para distinguir efectos
/// macroscópicos en estos sistemas; subir si las medias quedan cerca.
const MC_REPS: usize = 8;
/// Lado de la grilla cuadrada usada en los experimentos.
const GRID: usize = 40;
/// Población inicial por experimento. Suficientemente grande para que las
/// métricas tengan señal, suficientemente chica para que 8 réplicas × 200
/// ticks no demoren más de un par de segundos.
const POP: usize = 200;
/// Pasos de simulación por réplica. 200 alcanza para que el contagio sature
/// y la polarización converja en sus regímenes característicos.
const STEPS: usize = 200;
/// Construye un mundo con `POP` lemmings dispersos uniformemente y psi
/// también uniforme en `[0, 1]`. El seed controla *todo* el ruido: misma
/// seed → misma poblacion → mismo trayectoria.
fn build_world(seed: u64) -> World {
let mut w = World::new(GRID, GRID);
let mut rng = Lcg::new(seed);
// Pequeña materia uniforme para que los Extractores no se mueran.
for c in w.grid.materia.iter_mut() {
*c = 5.0;
}
for _ in 0..POP {
let x = rng.next_f32() * (GRID as f32 - 1.0);
let y = rng.next_f32() * (GRID as f32 - 1.0);
let psi = [
rng.next_f32(),
rng.next_f32(),
rng.next_f32(),
rng.next_f32(),
];
let i = w.lemmings.spawn_big5(x, y, 50.0, psi, rng.next_f32());
// Asignación de acciones balanceada para que no todos hagan lo mismo.
w.lemmings.accion[i] = (rng.next_u32() % 6) as u8;
}
w
}
/// Corre `steps` ticks y devuelve las métricas finales.
fn run_and_measure(w: &mut World, p: &SimParams, steps: usize) -> (PsiMetrics, WorldStats) {
run(w, p, steps);
(PsiMetrics::from_world(w), WorldStats::from_world(w))
}
/// Promedia una métrica escalar sobre `MC_REPS` réplicas.
fn mean_over_reps<F>(mut compute: F) -> f64
where
F: FnMut(u64) -> f32,
{
let mut sum: f64 = 0.0;
for r in 0..MC_REPS {
let seed = 0xD0_31_31_07u64.wrapping_add(r as u64 * 0x9E37_79B9);
sum += compute(seed) as f64;
}
sum / MC_REPS as f64
}
// ─────────────────────── Hipótesis 1 ────────────────────────
//
// **↑ homofilia ⇒ ↑ Moran's I.**
//
// Cuando la homofilia es fuerte, los agentes sólo se influyen con los
// psicológicamente parecidos. Las tribus emergen y se vuelven espacialmente
// segregadas → la autocorrelación espacial (Moran's I) del psi sube.
// Sin homofilia, el contagio universal homogeneiza y Moran's I tiende a 0.
//
// Estadístico: promedio de `moran_i[0]` (ORDEN) sobre 8 réplicas.
#[test]
fn hipotesis_homofilia_sube_morans_i() {
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.social_radius = 5.0;
p.contagion_rate = 0.15;
p.homophily_threshold = 0.0; // sin homofilia → contagio universal
let (m, _) = run_and_measure(&mut w, &p, STEPS);
m.moran_i[0].abs()
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.social_radius = 5.0;
p.contagion_rate = 0.15;
p.homophily_threshold = 0.4; // homofilia fuerte → tribus
let (m, _) = run_and_measure(&mut w, &p, STEPS);
m.moran_i[0].abs()
});
eprintln!(
"[H1] Moran_i[ORDEN]: baseline {:.4} vs homofilia {:.4}",
mean_baseline, mean_treatment
);
// El contagio universal ya produce algo de clustering por la geografía
// (radius 5 << diagonal del grid 40×40), así que el baseline arranca
// alto. La homofilia debe seguir levantándolo de forma consistente —
// umbral mínimo 0.05 absoluto sobre el baseline.
assert!(
mean_treatment > mean_baseline + 0.05,
"homofilia no levantó Moran's I: {} ≤ {} + 0.05",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 2 ────────────────────────
//
// **↑ contagion_rate con radio que cubre toda la grilla ⇒ ↓ varianza
// poblacional del psi.**
//
// Con contagio fuerte y radio suficiente para conectar a todos los agentes,
// la población converge a su promedio global y la varianza colapsa. Usamos
// `var_psi` en vez de polarización porque Esteban-Ray normaliza por span:
// con radios chicos pueden quedar clusters locales y la polarización subir;
// la varianza es la métrica honesta de "convergencia al consenso".
#[test]
fn hipotesis_contagio_universal_reduce_varianza() {
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.social_radius = 0.0;
p.contagion_rate = 0.0;
let (_, s) = run_and_measure(&mut w, &p, STEPS);
s.var_psi[0]
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
// Radio que cubre la diagonal del grid (40·√2 ≈ 57) → todos vecinos.
p.social_radius = 60.0;
p.contagion_rate = 0.30;
p.homophily_threshold = 0.0;
let (_, s) = run_and_measure(&mut w, &p, STEPS);
s.var_psi[0]
});
eprintln!(
"[H2] var(psi[ORDEN]): baseline {:.6} vs contagio universal {:.6}",
mean_baseline, mean_treatment
);
// Contagio verdaderamente universal debe colapsar la varianza al menos
// a la mitad (medirla por debajo del 50% del baseline).
assert!(
mean_treatment < mean_baseline * 0.5,
"contagio no colapsó la varianza: {} ≥ 0.5 × {}",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 3 ────────────────────────
//
// **PsiArgmax + psi_modulation ⇒ |corr(psi, accion)| más alta que Fixed.**
//
// Con la política psicológica encendida, la acción del agente se vuelve
// función del psi. La correlación punto-biserial entre cada componente del
// psi y la acción mayoritaria que esa componente premia debe crecer.
// Estadístico: max sobre `(k, a)` del valor absoluto de `psi_action_corr[k][a]`.
#[test]
fn hipotesis_psi_argmax_aumenta_correlacion_psi_accion() {
fn max_abs_corr(corr: &[[f32; 6]; 4]) -> f32 {
let mut m: f32 = 0.0;
for k in 0..4 {
for a in 0..6 {
let v = corr[k][a].abs();
if v > m {
m = v;
}
}
}
m
}
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let p = SimParams::default(); // Fixed
let (m, _) = run_and_measure(&mut w, &p, STEPS);
max_abs_corr(&m.psi_action_corr)
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 5; // reelige cada 5 ticks
p.psi_effect_modulation = 0.5;
let (m, _) = run_and_measure(&mut w, &p, STEPS);
max_abs_corr(&m.psi_action_corr)
});
eprintln!(
"[H3] max |corr(psi, accion)|: baseline {:.4} vs PsiArgmax {:.4}",
mean_baseline, mean_treatment
);
assert!(
mean_treatment > mean_baseline + 0.10,
"PsiArgmax no aumentó correlación: {} no supera {} + 0.10",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 4 ────────────────────────
//
// **regrowth_rate > 0 ⇒ población sostenida vs sin regrowth.**
//
// Sin regrowth, la materia se agota por Extraer y la población colapsa.
// Con regrowth, el cierre termodinámico mantiene un punto fijo `N* > 0`.
#[test]
fn hipotesis_regrowth_sostiene_poblacion() {
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.regrowth_rate = 0.0; // sin regrowth
p.carrying_capacity = 0.0;
let (_, s) = run_and_measure(&mut w, &p, STEPS);
s.n as f32
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let p = SimParams::default(); // default tiene regrowth_rate > 0
let (_, s) = run_and_measure(&mut w, &p, STEPS);
s.n as f32
});
eprintln!(
"[H4] N final: sin regrowth {:.1} vs con regrowth {:.1}",
mean_baseline, mean_treatment
);
assert!(
mean_treatment > mean_baseline + 5.0,
"regrowth no sostuvo población: {} ≤ {} + 5",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 5 ────────────────────────
//
// **Big Five con peso ext positivo ⇒ acciones sociales (Mover/Sync/Intercambiar)
// crecen vs Big Four bit-exacto.**
//
// Con `big_five=true` y `action_weights_ext` premiando Intercambiar/Sincronizar,
// la política argmax debería empujar a más agentes hacia esas acciones, sobre
// todo si `psi5` (Extraversion) es alta en promedio (lo es: nuestro builder
// muestrea uniforme en [0, 1] → media 0.5).
#[test]
fn hipotesis_big_five_levanta_acciones_sociales() {
fn share_social(s: &WorldStats) -> f32 {
let total: u32 = s.action_counts.iter().sum();
if total == 0 {
return 0.0;
}
// Mover (0) + Sincronizar (2) + Intercambiar (3)
(s.action_counts[0] + s.action_counts[2] + s.action_counts[3]) as f32
/ total as f32
}
let mean_baseline = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 5;
p.big_five = false;
let (_, s) = run_and_measure(&mut w, &p, STEPS);
share_social(&s)
});
let mean_treatment = mean_over_reps(|seed| {
let mut w = build_world(seed);
let mut p = SimParams::default();
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 5;
p.big_five = true;
// action_weights_ext default: Mover 0.4, Sync 0.6, Intercambiar 0.8.
let (_, s) = run_and_measure(&mut w, &p, STEPS);
share_social(&s)
});
eprintln!(
"[H5] fracción social: Big4 {:.4} vs Big5 {:.4}",
mean_baseline, mean_treatment
);
assert!(
mean_treatment > mean_baseline + 0.05,
"Big Five no levantó fracción social: {} ≤ {} + 0.05",
mean_treatment,
mean_baseline
);
}
// ─────────────────────── Hipótesis 6 ────────────────────────
//
// **Determinismo bit-exacto:** misma seed → misma trayectoria, incluso
// con todas las mecánicas opt-in encendidas a la vez. Si esto cae, algún
// componente nuevo metió no-determinismo (HashMap iteration, RNG global,
// reducción paralela). Es el guardián más importante de la plataforma.
#[test]
fn hipotesis_determinismo_bit_exacto_con_todas_las_mecanicas() {
let mut p = SimParams::default();
p.social_radius = 4.0;
p.contagion_rate = 0.10;
p.homophily_threshold = 0.4;
p.action_policy = ActionPolicy::PsiArgmax;
p.policy_reeval_period = 7;
p.psi_effect_modulation = 0.6;
p.big_five = true;
p.season_period = 50;
p.season_amplitude = 0.3;
let mut a = build_world(0xCAFE_BABE);
let mut b = build_world(0xCAFE_BABE);
run(&mut a, &p, 150);
run(&mut b, &p, 150);
assert_eq!(a.lemmings.pos_x, b.lemmings.pos_x);
assert_eq!(a.lemmings.pos_y, b.lemmings.pos_y);
assert_eq!(a.lemmings.energia, b.lemmings.energia);
assert_eq!(a.lemmings.vector_psi, b.lemmings.vector_psi);
assert_eq!(a.lemmings.psi5, b.lemmings.psi5);
assert_eq!(a.lemmings.accion, b.lemmings.accion);
assert_eq!(a.grid.materia, b.grid.materia);
let _ = MORANS_RADIUS_DEFAULT; // chequeo que la constante sigue exportada
}
@@ -0,0 +1,13 @@
[package]
name = "dominium-render-plan"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium — maqueta isométrica agnóstica: convierte un World en una lista de quads 2D ordenada por profundidad, lista para cualquier backend (GPUI, web, tui)."
[dependencies]
dominium-core = { path = "../dominium-core" }
dominium-iso = { path = "../dominium-iso" }
serde = { workspace = true }
@@ -0,0 +1,18 @@
# dominium-render-plan
> World → `Vec<Quad>` ordenado por pintor para [dominium](../README.md).
Toma un snapshot del `World` ([`dominium-core`](../dominium-core/README.md)) y produce una lista de `Quad { x, y, w, h, color, depth }` ordenada por pintor (back-to-front). Sin tocar el mundo — sólo lee. La proyección 30° viene de [`dominium-iso`](../dominium-iso/README.md). Output consumible por cualquier renderer (Llimphi/vello, WebGL, SVG).
## API
```rust
use dominium_render_plan::plan;
let quads = plan(&world); // Vec<Quad> ordenada
```
## Deps
- [`dominium-core`](../dominium-core/README.md), [`dominium-iso`](../dominium-iso/README.md)
- Cero deps gráficas
@@ -0,0 +1,18 @@
# dominium-render-plan
> World → painter-ordered `Vec<Quad>` for [dominium](../README.md).
Takes a `World` snapshot ([`dominium-core`](../dominium-core/README.md)) and produces a list of `Quad { x, y, w, h, color, depth }` ordered painter-style (back-to-front). Doesn't touch the world — only reads. 30° projection comes from [`dominium-iso`](../dominium-iso/README.md). Output consumable by any renderer (Llimphi/vello, WebGL, SVG).
## API
```rust
use dominium_render_plan::plan;
let quads = plan(&world); // ordered Vec<Quad>
```
## Deps
- [`dominium-core`](../dominium-core/README.md), [`dominium-iso`](../dominium-iso/README.md)
- Zero graphics deps
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
[package]
name = "dominium-sim"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "dominium — sesión de simulación agnóstica de GUI: posee el World + parámetros + reloj (tick/epoch/seed) + ring de snapshots (rewind) + trails + clusters k-means, y orquesta el avance/reseed/colapso. El frontend sólo guarda estado de vista (cámara, selección, paneles) y delega el ciclo de vida acá."
[dependencies]
dominium-core = { path = "../dominium-core" }
dominium-physics = { path = "../dominium-physics" }
+234
View File
@@ -0,0 +1,234 @@
//! Sesión de simulación de dominium — el estado de dominio y su ciclo de
//! vida, separados del frontend (regla #2).
//!
//! [`Sim`] posee el `World`, sus `SimParams`, el reloj (`tick`/`epoch`/
//! `rng_seed`), el ring de snapshots para rebobinar, los trails de posiciones
//! y las asignaciones de cluster k-means. Orquesta el avance del motor
//! (`dominium-physics`), el reseed manual y el reseed automático cuando la
//! población colapsa. El frontend (cámara, selección, render, paneles, menús)
//! sólo guarda estado de vista y delega el ciclo de vida acá — antes todo
//! esto vivía mezclado en el `Model` de `dominium-app-llimphi`.
use std::collections::VecDeque;
use dominium_core::{kmeans_psi, SimParams, World};
use dominium_physics::tick;
/// Cómo re-sembrar el mundo a partir de una semilla. El frontend la provee
/// (típicamente cargando su pack de Conceptos del disco), así `Sim` no
/// conoce ni el tamaño de grilla ni la IO de packs.
pub type Seeder = Box<dyn FnMut(u64) -> World>;
/// Sesión de simulación: estado de dominio + reloj + historia.
pub struct Sim {
pub world: World,
pub params: SimParams,
/// Si el motor avanza en cada [`Sim::step`]. El frontend lo togglea.
pub running: bool,
pub tick: u64,
pub epoch: u64,
pub rng_seed: u64,
/// Ring de snapshots del `World` — el último es el más reciente. Ver
/// [`Sim::displayed_world`] para la semántica de `rewind_offset`.
pub snapshots: VecDeque<World>,
/// Cuántos pasos atrás mira el usuario. `0` = presente (vivo).
pub rewind_offset: usize,
/// Para cada frame reciente, las posiciones `(x, y)` de los lemmings
/// vivos. `trails[k]` es el frame `tick - (len-1-k)`.
pub trails: VecDeque<Vec<(f32, f32)>>,
/// Asignación k-means → cluster por lemming (modo PsiCluster).
pub cluster_assignments: Vec<u8>,
/// Tick del último refresh de clusters (gated refresh).
pub cluster_last_refresh: u64,
snapshot_cap: usize,
trail_cap: usize,
kmeans_refresh_ticks: u64,
seeder: Seeder,
}
impl Sim {
/// Arranca una sesión con un `World` ya sembrado, sus parámetros, la
/// semilla del PRNG de reseed, las capacidades de los rings, el periodo
/// de refresh de k-means y el `seeder` que produce mundos nuevos.
#[allow(clippy::too_many_arguments)]
pub fn new(
world: World,
params: SimParams,
rng_seed: u64,
snapshot_cap: usize,
trail_cap: usize,
kmeans_refresh_ticks: u64,
running: bool,
seeder: Seeder,
) -> Self {
Self {
world,
params,
running,
tick: 0,
epoch: 0,
rng_seed,
snapshots: VecDeque::with_capacity(snapshot_cap),
rewind_offset: 0,
trails: VecDeque::with_capacity(trail_cap),
cluster_assignments: Vec::new(),
cluster_last_refresh: 0,
snapshot_cap,
trail_cap,
kmeans_refresh_ticks,
seeder,
}
}
/// Un paso de simulación; re-siembra si la población colapsa. Captura
/// también el snapshot del estado y el frame de trails (después de
/// avanzar, así el "presente" siempre coincide con `world`). Recalcula
/// los clusters sólo si `needs_clusters` y pasó el periodo de refresh —
/// el frontend pasa `true` cuando el render lo necesita (modo PsiCluster).
pub fn advance(&mut self, needs_clusters: bool) {
tick(&mut self.world, &self.params);
self.tick += 1;
if self.world.lemmings.is_empty() {
self.epoch += 1;
self.rng_seed = self.rng_seed.wrapping_mul(2862933555777941757).wrapping_add(1);
self.world = (self.seeder)(self.rng_seed);
self.tick = 0;
self.snapshots.clear();
self.trails.clear();
self.cluster_assignments.clear();
}
self.push_snapshot();
self.push_trail_frame();
if needs_clusters
&& self.tick.saturating_sub(self.cluster_last_refresh) >= self.kmeans_refresh_ticks
{
self.refresh_clusters();
}
}
/// Reseed manual: nueva semilla, mundo fresco, reloj a cero y rings
/// limpios. Vuelve al presente (`rewind_offset = 0`).
pub fn reseed(&mut self) {
self.rng_seed = self.rng_seed.wrapping_add(0x9E37_79B9);
self.world = (self.seeder)(self.rng_seed);
self.tick = 0;
self.epoch += 1;
self.snapshots.clear();
self.trails.clear();
self.rewind_offset = 0;
}
/// Recalcula `cluster_assignments` desde el `World` actual. Si
/// `kmeans_psi` devuelve `None` (pob < K), limpia las asignaciones.
pub fn refresh_clusters(&mut self) {
if let Some(km) = kmeans_psi(&self.world) {
self.cluster_assignments = km.assignments;
} else {
self.cluster_assignments.clear();
}
self.cluster_last_refresh = self.tick;
}
/// Empuja el `World` actual al ring (clone barato: SoA + Vec). Drop del
/// más viejo al exceder la capacidad.
pub fn push_snapshot(&mut self) {
if self.snapshots.len() == self.snapshot_cap {
self.snapshots.pop_front();
}
self.snapshots.push_back(self.world.clone());
}
/// Empuja el frame de posiciones de todos los lemmings vivos al ring.
pub fn push_trail_frame(&mut self) {
if self.trails.len() == self.trail_cap {
self.trails.pop_front();
}
let lem = &self.world.lemmings;
let frame: Vec<(f32, f32)> = (0..lem.len()).map(|i| (lem.pos_x[i], lem.pos_y[i])).collect();
self.trails.push_back(frame);
}
/// Devuelve el `World` que actualmente se está mostrando — el presente
/// (`world`) si no hay rewind, o el snapshot apropiado si lo hay.
pub fn displayed_world(&self) -> &World {
if self.rewind_offset == 0 || self.snapshots.is_empty() {
&self.world
} else {
let len = self.snapshots.len();
let idx = len.saturating_sub(1 + self.rewind_offset);
&self.snapshots[idx]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dominium_core::World;
// Seeder de prueba: un mundo chico determinista vacío de lemmings vivos
// sólo cuando lo pidamos. Para la mayoría de los tests no colapsa.
fn poblar(w: &mut World) {
for k in 0..8 {
w.lemmings.spawn(k as f32 % 4.0, 0.0, 50.0, [0.5; 4]);
}
}
fn sim_demo() -> Sim {
// Mundo mínimo 4×4 con algunos lemmings para que no colapse al toque.
let mut w = World::new(4, 4);
poblar(&mut w);
let seeder: Seeder = Box::new(|_s| {
let mut w = World::new(4, 4);
poblar(&mut w);
w
});
Sim::new(w, SimParams::default(), 0xC0FFEE, 4, 3, 30, true, seeder)
}
#[test]
fn advance_incrementa_tick_y_captura_historia() {
let mut s = sim_demo();
s.advance(false);
assert_eq!(s.tick, 1);
assert_eq!(s.snapshots.len(), 1);
assert_eq!(s.trails.len(), 1);
}
#[test]
fn rings_respetan_su_capacidad() {
let mut s = sim_demo();
for _ in 0..10 {
s.advance(false);
}
assert!(s.snapshots.len() <= 4, "snapshot ring capado");
assert!(s.trails.len() <= 3, "trail ring capado");
}
#[test]
fn reseed_resetea_reloj_y_vuelve_al_presente() {
let mut s = sim_demo();
s.advance(false);
s.advance(false);
s.rewind_offset = 1;
s.reseed();
assert_eq!(s.tick, 0);
assert_eq!(s.rewind_offset, 0);
assert!(s.snapshots.is_empty());
assert_eq!(s.epoch, 1);
}
#[test]
fn displayed_world_sigue_el_rewind() {
let mut s = sim_demo();
s.advance(false);
s.advance(false);
// presente
s.rewind_offset = 0;
assert!(std::ptr::eq(s.displayed_world(), &s.world));
// pasado: devuelve un snapshot, no el world vivo
s.rewind_offset = 1;
assert!(!std::ptr::eq(s.displayed_world(), &s.world));
}
}
Generated
+5289
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
# Cargo.toml raíz STANDALONE de dominium — front-door del simulador determinista
# de campo medio sobre Llimphi. Solo el código de dominium; Llimphi y lo
# fundacional por git-dep del monorepo tawasuyu.git.
[workspace]
resolver = "2"
members = [
"01_yachay/dominium/dominium-core",
"01_yachay/dominium/dominium-physics",
"01_yachay/dominium/dominium-sim",
"01_yachay/dominium/dominium-iso",
"01_yachay/dominium/dominium-render-plan",
"01_yachay/dominium/dominium-canvas-llimphi",
"01_yachay/dominium/dominium-app-llimphi",
"01_yachay/dominium/dominium-cli",
"01_yachay/dominium/dominium-notebook-kernel",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.80"
license = "MIT"
authors = ["Sergio <gerencia@jlsoltech.com>"]
publish = false
repository = "https://github.com/tawasuyu/dominium"
[workspace.dependencies]
# ============================================================
# Externas de crates.io (versión local, no compartidas por git-dep)
# ============================================================
serde = { version = "1", features = ["derive"] }
serde_json = "1"
libm = "0.2"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
async-trait = "0.1"
directories = "5"
pollster = "0.4"
png = "0.18"
tokio = { version = "1", features = ["full"] }
# ============================================================
# git-deps al monorepo tawasuyu (fuente única de verdad)
# ============================================================
# Registro de apps / menú global
app-bus = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
# i18n + bus de config del SO
rimay-localize = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
wawa-config = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
wawa-config-llimphi = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
# Notebook de pluma — kernel de simulación como celdas
pluma-notebook-core = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
pluma-notebook-exec = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
# ============================================================
# Llimphi (motor gráfico soberano) — bucle Elm, theme, widgets
# ============================================================
llimphi-ui = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
llimphi-theme = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
llimphi-motion = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
llimphi-clipboard = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
llimphi-widget-button = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
llimphi-widget-slider = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
llimphi-widget-text-input = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
llimphi-widget-menubar = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
llimphi-widget-edit-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
llimphi-widget-context-menu = { git = "https://git.tawasuyu.net/tawasuyu/tawasuyu.git", branch = "main" }
+63
View File
@@ -0,0 +1,63 @@
# dominium
> Deterministic mean-field simulator with vector agents — civilization, faith, war and money **emerge** from the arithmetic — in Rust, with a [Llimphi](https://github.com/tawasuyu/llimphi) UI.
`dominium` is a bit-for-bit deterministic simulator: thousands of vector agents (lemmings) live on a dense numeric substrate of **five physical layers** (`materia`, `psique`, `poder`, `oro`, `degradacion`), acting through six atomic actions (move, take, drop, transmit, attack, rest). Nobody scripts a society — the engine only adds floats, and civilization, belief, conflict and trade come out of the coupling. **Concepts** (church, bank, commune, lab) are metaprogrammable field emitters loaded as JSON at runtime: rewrite the domain without recompiling.
<p align="center">
<img src="docs/dominium_showreel.gif" alt="dominium showreel — an isometric procedural continent of blue seas, green plains and sienna ranges, populated by thousands of lemmings, with Concepts emitting fields and ψ-cluster recoloring over a live mean-field run" width="900">
<br>
<sub>a live mean-field run — the isometric continent, thousands of lemmings, Concepts emitting their fields, and ψ-cluster recoloring</sub>
</p>
## Run
```sh
# deterministic headless CLI
cargo run --release -p dominium-cli -- run --seed 42 --ticks 1000
# the live Llimphi app — isometric canvas + control panel (play/pause/reseed, sliders)
cargo run --release -p dominium-app-llimphi
```
## Install
```sh
cargo build --release
# binaries land in target/release/
```
- **Linux / macOS / Windows** — Llimphi UI.
- **Wawa / WASM** — `dominium-core`, `dominium-physics`, `dominium-iso`, `dominium-render-plan` compile with zero graphical deps.
- **Web** — via `dominium-notebook-kernel` (a pluma notebook kernel).
## Crates
| Crate | Role |
|---|---|
| `dominium-core` | Data + the six atomic actions + Concepts + worldgen. SoA grid of 5 layers, vector lemmings. Only `serde` + `libm`. |
| `dominium-physics` | The 6-phase tick: field diffusion + entropy + transitions, actions, aging. Bit-exact. |
| `dominium-sim` | Frontend-agnostic simulation session: World + clock (tick/epoch/seed) + snapshot ring (rewind) + trails + k-means clusters. |
| `dominium-iso` | CPU pseudo-3D isometric projection: fixed iso matrix + composite Z from 5 layers + analytic Lambert shadows. |
| `dominium-render-plan` | World → depth-sorted `Vec<Quad>`, ready for any backend. |
| `dominium-canvas-llimphi` | Llimphi/vello backend: a `RenderPlan``View<Msg>` with `paint_with`. |
| `dominium-app-llimphi` | The app — canvas + stats panel + play/pause/reseed, tick loop on its own thread re-entering via `Handle::dispatch`. |
| `dominium-cli` | Headless runner: cross-platform determinism checks, per-tick stats CSV, Concept packs. |
| `dominium-notebook-kernel` | Notebook kernel running cells over a World (5 languages: world/seed/tick/stats/param) on shared state. |
The chain `core → physics → iso → render-plan` is fully graphics-free; only the `*-llimphi` crates pull the GPU UI stack.
## How dependencies work
dominium is a full app. Llimphi (the sovereign GPU engine: Elm loop, theme, widgets) and every foundational dependency are pulled as **git dependencies** from the [`tawasuyu`](https://github.com/tawasuyu/tawasuyu) monorepo — the suite's source of truth. The simulation crates (`dominium-core/physics/sim/iso/render-plan`) are pure compute and depend only on `serde` + `libm`; the UI crates pull the GPU stack via git-dep.
## Considerations
- **Inviolable rule:** zero graphical deps in `core` / `physics` / `iso` / `render-plan`. Only `serde` and `libm`. Graphics live in `canvas-llimphi` / `app-llimphi`.
- **Bit-for-bit deterministic** given the same seed and the same version.
- **Concepts load at runtime** (`id + pos + radius + mods + hack`); the engine stays dumb, external AI is optional. They let you rewrite the domain without recompiling.
- **Everything non-fixed is data.** `SimParams` / `ZWeights` are serializable and live-editable from panel sliders; a whole scenario (params + relief + Concepts) serializes as one.
## License
MIT. Builds on [Llimphi](https://github.com/tawasuyu/llimphi) and the [tawasuyu](https://github.com/tawasuyu/tawasuyu) suite.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 MiB

Binary file not shown.