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:
@@ -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** (0–8%): trazo bezier draw-on + punto teal, sobre negro.
|
||||||
|
//! Breve — no debe dominar.
|
||||||
|
//! - **diorama** (6–100%): 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** (86–100%): "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 (86–100%) ─────────────────────────────────────────
|
||||||
|
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 (6–14%) 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 }
|
||||||
@@ -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`
|
||||||
@@ -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 }
|
||||||
@@ -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 30–80: 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 / (1−p) ) / σ_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 }
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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, ¶ms);
|
||||||
|
}
|
||||||
|
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" }
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+71
@@ -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" }
|
||||||
@@ -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.
Reference in New Issue
Block a user