commit 1860b51f701c2ed425a0d7b0336589db8fc6104d Author: Sergio Date: Tue Jun 16 23:22:40 2026 +0000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7141ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +*.pdb diff --git a/01_yachay/dominium/dominium-app-llimphi/Cargo.toml b/01_yachay/dominium/dominium-app-llimphi/Cargo.toml new file mode 100644 index 0000000..f3cce93 --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/Cargo.toml @@ -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 } diff --git a/01_yachay/dominium/dominium-app-llimphi/LEEME.md b/01_yachay/dominium/dominium-app-llimphi/LEEME.md new file mode 100644 index 0000000..5abae13 --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/LEEME.md @@ -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/) diff --git a/01_yachay/dominium/dominium-app-llimphi/README.md b/01_yachay/dominium/dominium-app-llimphi/README.md new file mode 100644 index 0000000..a9e7e9c --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/README.md @@ -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/) diff --git a/01_yachay/dominium/dominium-app-llimphi/conceptos.default.json b/01_yachay/dominium/dominium-app-llimphi/conceptos.default.json new file mode 100644 index 0000000..a10e50c --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/conceptos.default.json @@ -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 } + } + ] +} diff --git a/01_yachay/dominium/dominium-app-llimphi/examples/dominium_showreel.rs b/01_yachay/dominium/dominium-app-llimphi/examples/dominium_showreel.rs new file mode 100644 index 0000000..a3e8afd --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/examples/dominium_showreel.rs @@ -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, +} + +/// 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 { + 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> = 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(); +} diff --git a/01_yachay/dominium/dominium-app-llimphi/examples/pantallazo_dominium.rs b/01_yachay/dominium/dominium-app-llimphi/examples/pantallazo_dominium.rs new file mode 100644 index 0000000..c9a1789 --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/examples/pantallazo_dominium.rs @@ -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 { + 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> = 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(); +} diff --git a/01_yachay/dominium/dominium-app-llimphi/packs/andes.json b/01_yachay/dominium/dominium-app-llimphi/packs/andes.json new file mode 100644 index 0000000..06c396b --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/packs/andes.json @@ -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 } + } + ] +} diff --git a/01_yachay/dominium/dominium-app-llimphi/packs/capitalismo.json b/01_yachay/dominium/dominium-app-llimphi/packs/capitalismo.json new file mode 100644 index 0000000..ebb9d65 --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/packs/capitalismo.json @@ -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 } + } + ] +} diff --git a/01_yachay/dominium/dominium-app-llimphi/packs/mesopotamia.json b/01_yachay/dominium/dominium-app-llimphi/packs/mesopotamia.json new file mode 100644 index 0000000..63db931 --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/packs/mesopotamia.json @@ -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 + } + ] +} diff --git a/01_yachay/dominium/dominium-app-llimphi/src/consts.rs b/01_yachay/dominium/dominium-app-llimphi/src/consts.rs new file mode 100644 index 0000000..249c25d --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/src/consts.rs @@ -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; diff --git a/01_yachay/dominium/dominium-app-llimphi/src/main.rs b/01_yachay/dominium/dominium-app-llimphi/src/main.rs new file mode 100644 index 0000000..7d178f4 --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/src/main.rs @@ -0,0 +1,1025 @@ +//! `dominium-app-llimphi` — la ventana viva del simulador sobre +//! Llimphi. +//! +//! Compone la cadena agnóstica de dominium con el canvas Llimphi: +//! +//! ```text +//! dominium-core ─► dominium-physics ─► dominium-iso ─► +//! dominium-render-plan ─► dominium-canvas-llimphi ─► [esta ventana] +//! ``` +//! +//! Un loop de fondo (~11 Hz) avanza la simulación y reentra al +//! `update` vía `Handle::dispatch(Msg::Tick)`. Cuando la población +//! colapsa, el mundo se re-siembra solo. El panel derecho muestra +//! stats y dos controles (play/pausa, re-sembrar). +//! +//! El crate está partido en módulos: `consts` (constantes de mundo), +//! `model` (Model + Msg + enums de edición), `packs` (scenarios embebidos +//! + persistencia), `worldgen` (PRNG + ruido + `seed`), `sim` (transiciones +//! + helpers de mutación) y `view` (todas las vistas). Acá queda sólo el +//! `impl App` que las orquesta. + +mod consts; +mod model; +mod packs; +mod sim; +mod view; +mod worldgen; + +use std::time::Duration; + +use dominium_core::{BehaviorHack, Conceptos, PsiMetrics, SimParams, Trigger, WorldStats}; +use dominium_iso::{IsoProjector, ZWeights}; +use dominium_render_plan::{build_plan_with_overrides, PlanConfig, RenderLayer, RenderMode}; +use dominium_sim::Sim; +use llimphi_theme::Theme; +use llimphi_ui::llimphi_layout::taffy::prelude::{ + length, percent, Dimension, FlexDirection, Size, Style, +}; +use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, NamedKey, View}; +use llimphi_motion::{animate, motion, Tween}; +use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras}; +use llimphi_widget_edit_menu::{self as editmenu, EditAction, EditFlags}; +use llimphi_widget_menubar::{ + menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec, + DEFAULT_HEIGHT as MENU_H, +}; +use llimphi_widget_text_input::TextInputState; +use wawa_config_llimphi::theme_from_wawa; + +use crate::consts::{GRID, KMEANS_REFRESH_TICKS, SNAPSHOT_RING_CAP, TICK_MS, TRAIL_CAP}; +use crate::model::{Layer, Model, Msg, PanelTab, ParamSlot, ZSlot}; +use crate::packs::{ + default_conceptos, load_user_escenario, save_user_escenario, scenario_packs, +}; +use crate::sim::{ + lemming_color_for, mirror_zweights_to_relieve, overlay_trails, selected_mut, spawn_concepto_at, +}; +use crate::view::{canvas_pane, onboarding_bar, side_panel, status_bar}; +use crate::worldgen::{bioma_palette, seed}; + +struct Dominium; + +impl App for Dominium { + type Model = Model; + type Msg = Msg; + + fn title() -> &'static str { + "dominium · campo medio (llimphi)" + } + + fn initial_size() -> (u32, u32) { + (1120, 720) + } + + fn init(handle: &Handle) -> Model { + // Loop de tick a ~11 Hz; el handle ya sabe cómo dejar morir + // el thread cuando el event loop se cierre. + handle.spawn_periodic(Duration::from_millis(TICK_MS), || Msg::Tick); + + // Bus de configuración del SO. Theme y locale arrancan desde + // el archivo si existe; el watcher reentra al `update` cuando + // cambia. + let wawa_cfg = wawa_config::WawaConfig::load(); + let theme = theme_from_wawa(&wawa_cfg, &Theme::dark()); + let _ = rimay_localize::set_locale(&wawa_cfg.lang); + let handle_clone = handle.clone(); + let wawa_watcher = wawa_config::ConfigWatcher::spawn(move |new_cfg| { + handle_clone.dispatch(Msg::WawaConfigChanged(Box::new(new_cfg))); + }) + .map_err(|e| eprintln!("dominium · wawa-config watcher: {e}")) + .ok(); + + let rng_seed = 0xD0_31_31_07; + // SimParams con overrides puntuales. La iteración anterior puso + // `metabolic_cost=0.35` que con energía inicial 40-80 vacía a los + // lemmings en ~200 ticks → murieron todos en 10s. Acá la calibración + // afloja: drenaje basal modesto, threshold de réplica más bajo, hijos + // que arrancan con energía digna. La capacidad de carga sigue + // limitada por el regrowth + carrying_capacity, no por matar al + // adulto promedio. + let params = SimParams { + // Difusión y entropía bajas → la psique de los mares no se + // empuja a tierra en pocos ticks. (Defaults son 0.1 / 0.01.) + diffusion_rate: 0.02, + entropy_rate: 0.004, + // Regrowth limitado a la carga base de la llanura — sin esto + // el regrowth llena de materia incluso los mares (que tienen + // 0 inicial pero materia → carrying_capacity). + regrowth_rate: 0.004, + carrying_capacity: 40.0, + // Drenaje basal mínimo: 0.05 E/tick frena el techo sin matar + // la cohorte joven (energía inicial 40-80 dura > 800 ticks). + metabolic_cost: 0.05, + // Réplica menos cara → la sociedad alcanza un equilibrio + // dinámico en vez de extinguirse. + replicate_threshold: 28.0, + child_energy_frac: 0.45, + abundance_threshold: 50.0, + ..SimParams::default() + }; + // Relieve por bioma, recalibrado para los valores nuevos: + // - mares → z ≈ −15 (psique 200 × −0.075) + // - llanura → z ≈ +1.6 (materia 80 × 0.02) + // - colinas → z ≈ +6 (poder 15 × 0.4) + // - picos → z ≈ +21 (degradacion 16 × 1.3 + el resto) + let mut weights = ZWeights { + materia: 0.02, + psique: -0.075, + poder: 0.40, + oro: 0.0, + degradacion: 1.30, + }; + // Si el usuario guardó un escenario rico, su sintonía gana sobre los + // defaults de arriba (los Conceptos ya entraron por `seed()` → + // `load_user_pack`). Packs históricos sólo traen Conceptos: dejan + // `params`/`weights` intactos. Ver `packs::Escenario`. + let mut params = params; + if let Some(esc) = load_user_escenario() { + if let Some(p) = esc.params { + params = p; + } + if let Some(w) = esc.weights { + weights = w; + } + } + // Sesión de simulación: dominio + reloj + historia. El seeder + // recarga el pack del usuario en cada reseed/colapso (igual que antes). + let needs_psi5 = params.big_five; + let mut sim = Sim::new( + seed(rng_seed), + params, + rng_seed, + SNAPSHOT_RING_CAP, + TRAIL_CAP, + KMEANS_REFRESH_TICKS, + true, + Box::new(|s| seed(s)), + ); + // El mundo recién sembrado nace Big Four (psi5 vacío); si el + // escenario guardado pide Big Five, rellenamos la quinta columna + // antes del primer tick. + if needs_psi5 { + sim.world.lemmings.ensure_psi5_len(); + } + Model { + sim, + // Scale 3.0 para que la grilla 240×240 entre en pantalla. z_factor + // 0.55 levanta el relieve a algo perceptible sin que los picos + // exploten: mares ~−25 px, llanura plana, colinas ~+10 px, + // picos ~+30 px. La versión 0.35 anterior daba total ~9 px → el + // mapa parecía plano por completo. + 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, + // Textura procedural OFF por default: con miles de celdas, + // los micro-quads empiezan a tapar la maqueta y el render + // pierde claridad. El usuario lo prende en el tab Vista + // si quiere "estampa". + texture: false, + }, + selected: None, + sync_relieve: false, + id_input: TextInputState::new(), + id_input_focused: false, + scenario_idx: 0, + show_trails: false, + theme, + _wawa_watcher: wawa_watcher, + panel_tab: PanelTab::Mundo, + 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(), + } + } + + fn update(model: Model, msg: Msg, h: &Handle) -> Model { + let mut m = model; + match msg { + Msg::Tick => { + // Si el usuario está revisando el pasado, la sim queda + // congelada para no acumular divergencia con el ring. + if m.sim.running && m.sim.rewind_offset == 0 { + m.sim + .advance(matches!(m.cfg.render_mode, RenderMode::PsiCluster)); + } + } + Msg::TogglePlay => { + m.sim.running = !m.sim.running; + } + Msg::Reseed => { + m.sim.reseed(); + } + Msg::LimpiarConceptos => { + m.sim.world.conceptos.clear(); + // Romper los hack_locks vivos: sin Concepto que los sostenga, + // los lemmings vuelven a la lógica normal. + for lock in m.sim.world.lemmings.hack_lock.iter_mut() { + *lock = 0; + } + m.selected = None; + } + Msg::SembrarConceptos => { + m.sim.world.conceptos = default_conceptos(); + m.selected = None; + } + Msg::SelectConcepto(i) => { + if i < m.sim.world.conceptos.len() { + m.selected = Some(i); + } + } + Msg::DeselectConcepto => m.selected = None, + Msg::EditMod(layer, dv) => { + if let Some(i) = m.selected { + if let Some(c) = m.sim.world.conceptos.items.get_mut(i) { + let slot = match layer { + Layer::Materia => &mut c.mods.materia, + Layer::Psique => &mut c.mods.psique, + Layer::Poder => &mut c.mods.poder, + Layer::Oro => &mut c.mods.oro, + }; + *slot = (*slot + dv).clamp(-1.0, 1.0); + } + } + } + Msg::EditRadius(dv) => { + if let Some(i) = m.selected { + if let Some(c) = m.sim.world.conceptos.items.get_mut(i) { + c.radius = (c.radius + dv).clamp(0.5, 20.0); + } + } + } + Msg::DeleteSelected => { + if let Some(i) = m.selected.take() { + if i < m.sim.world.conceptos.len() { + m.sim.world.conceptos.remove(i); + for lock in m.sim.world.lemmings.hack_lock.iter_mut() { + *lock = 0; + } + } + } + } + Msg::EditParam(slot, dv) => { + let (lo, hi) = slot.range(); + match slot { + ParamSlot::ClimbCost => { + m.sim.params.climb_cost = (m.sim.params.climb_cost + dv).clamp(lo, hi) + } + ParamSlot::DiffusionRate => { + m.sim.params.diffusion_rate = + (m.sim.params.diffusion_rate + dv).clamp(lo, hi) + } + ParamSlot::EntropyRate => { + m.sim.params.entropy_rate = (m.sim.params.entropy_rate + dv).clamp(lo, hi) + } + ParamSlot::MoveCost => { + m.sim.params.move_cost = (m.sim.params.move_cost + dv).clamp(lo, hi) + } + ParamSlot::SeasonPeriod => { + let v = (m.sim.params.season_period as f32 + dv).clamp(lo, hi); + m.sim.params.season_period = v as u32; + } + ParamSlot::SeasonAmplitude => { + m.sim.params.season_amplitude = + (m.sim.params.season_amplitude + dv).clamp(lo, hi) + } + ParamSlot::PsiModulation => { + m.sim.params.psi_effect_modulation = + (m.sim.params.psi_effect_modulation + dv).clamp(lo, hi) + } + ParamSlot::SocialRadius => { + m.sim.params.social_radius = + (m.sim.params.social_radius + dv).clamp(lo, hi) + } + ParamSlot::ContagionRate => { + m.sim.params.contagion_rate = + (m.sim.params.contagion_rate + dv).clamp(lo, hi) + } + ParamSlot::HomophilyThreshold => { + m.sim.params.homophily_threshold = + (m.sim.params.homophily_threshold + dv).clamp(lo, hi) + } + ParamSlot::ExtractRate => { + m.sim.params.extract_rate = (m.sim.params.extract_rate + dv).clamp(lo, hi) + } + ParamSlot::TradeAmount => { + m.sim.params.trade_amount = (m.sim.params.trade_amount + dv).clamp(lo, hi) + } + ParamSlot::RegrowthRate => { + m.sim.params.regrowth_rate = (m.sim.params.regrowth_rate + dv).clamp(lo, hi) + } + ParamSlot::CarryingCapacity => { + m.sim.params.carrying_capacity = + (m.sim.params.carrying_capacity + dv).clamp(lo, hi) + } + ParamSlot::MetabolicCost => { + m.sim.params.metabolic_cost = + (m.sim.params.metabolic_cost + dv).clamp(lo, hi) + } + ParamSlot::ReplicateThreshold => { + m.sim.params.replicate_threshold = + (m.sim.params.replicate_threshold + dv).clamp(lo, hi) + } + ParamSlot::AbundanceThreshold => { + m.sim.params.abundance_threshold = + (m.sim.params.abundance_threshold + dv).clamp(lo, hi) + } + ParamSlot::MoveSpeed => { + m.sim.params.move_speed = (m.sim.params.move_speed + dv).clamp(lo, hi) + } + ParamSlot::SyncRate => { + m.sim.params.sync_rate = (m.sim.params.sync_rate + dv).clamp(lo, hi) + } + ParamSlot::DegrPerExtract => { + m.sim.params.degr_per_extract = + (m.sim.params.degr_per_extract + dv).clamp(lo, hi) + } + ParamSlot::ChildEnergyFrac => { + m.sim.params.child_energy_frac = + (m.sim.params.child_energy_frac + dv).clamp(lo, hi) + } + ParamSlot::FightDamage => { + m.sim.params.fight_damage = (m.sim.params.fight_damage + dv).clamp(lo, hi) + } + ParamSlot::AbsorbFrac => { + m.sim.params.absorb_frac = (m.sim.params.absorb_frac + dv).clamp(lo, hi) + } + ParamSlot::DesperationThreshold => { + m.sim.params.desperation_threshold = + (m.sim.params.desperation_threshold + dv).clamp(lo, hi) + } + ParamSlot::MaxEdad => { + let v = (m.sim.params.max_edad as f32 + dv).clamp(lo, hi); + m.sim.params.max_edad = v as u32; + } + } + } + Msg::EditZWeight(slot, dv) => { + let s = match slot { + ZSlot::Materia => &mut m.weights.materia, + ZSlot::Psique => &mut m.weights.psique, + ZSlot::Poder => &mut m.weights.poder, + ZSlot::Oro => &mut m.weights.oro, + ZSlot::Degradacion => &mut m.weights.degradacion, + }; + *s = (*s + dv).clamp(-2.0, 2.0); + if m.sync_relieve { + mirror_zweights_to_relieve(&m.weights, &mut m.sim.params.relieve); + } + } + Msg::GuardarPack => { + save_user_escenario(&m.sim.params, &m.weights, &m.sim.world.conceptos) + } + Msg::CargarPack => { + if let Some(esc) = load_user_escenario() { + m.sim.world.conceptos = esc.conceptos; + // Sintonía del motor: sólo se aplica si el pack la trae + // (los packs históricos no, y entonces conservamos la + // vigente). Si entra en Big Five, rellenamos psi5 antes + // de que el tick consulte la quinta columna. + if let Some(params) = esc.params { + let needs_psi5 = params.big_five; + m.sim.params = params; + if needs_psi5 { + m.sim.world.lemmings.ensure_psi5_len(); + } + } + if let Some(weights) = esc.weights { + m.weights = weights; + if m.sync_relieve { + mirror_zweights_to_relieve(&m.weights, &mut m.sim.params.relieve); + } + } + for lock in m.sim.world.lemmings.hack_lock.iter_mut() { + *lock = 0; + } + m.selected = None; + } + } + Msg::CrearConcepto => { + let center = (GRID as f32) * 0.5; + spawn_concepto_at(&mut m, center, center); + } + Msg::CanvasClick(wx, wy) => { + // Primer click sobre el canvas también apaga el hint de + // onboarding — si llegó hasta acá, ya entendió que se + // puede interactuar con el mapa. + m.onboarding_done = true; + // Hit-test contra Conceptos existentes (centro + radio + // pickeable acotado). Si pega, selecciona sin crear; si + // no, crea un Concepto nuevo ahí. + let mut hit: Option = None; + for (i, c) in m.sim.world.conceptos.items.iter().enumerate() { + let dx = wx - c.pos_x; + let dy = wy - c.pos_y; + let pick_r = c.radius.min(3.0); + if dx * dx + dy * dy <= pick_r * pick_r { + hit = Some(i); + break; + } + } + match hit { + Some(i) => m.selected = Some(i), + None => spawn_concepto_at(&mut m, wx, wy), + } + } + Msg::ToggleSyncRelieve => { + m.sync_relieve = !m.sync_relieve; + if m.sync_relieve { + mirror_zweights_to_relieve(&m.weights, &mut m.sim.params.relieve); + } + } + Msg::ToggleAndina => { + // 0 ↔ 3 capas. El threshold no cambia. + m.cfg.andina_layers = if m.cfg.andina_layers == 0 { 3 } else { 0 }; + } + Msg::HackToggle => { + if let Some(c) = selected_mut(&mut m) { + c.hack = match c.hack { + Some(_) => None, + None => Some(BehaviorHack { + trigger: Trigger::Always, + forced_action: 2, // Sincronizar — el default más visible + duration: 30, + }), + }; + } + } + Msg::HackCycleTrigger => { + if let Some(c) = selected_mut(&mut m) { + if let Some(h) = c.hack.as_mut() { + h.trigger = match h.trigger { + Trigger::Always => Trigger::EnergiaBajo(15.0), + Trigger::EnergiaBajo(_) => Trigger::EdadSobre(100), + Trigger::EdadSobre(_) => Trigger::Always, + }; + } + } + } + Msg::HackCycleAction => { + if let Some(c) = selected_mut(&mut m) { + if let Some(h) = c.hack.as_mut() { + h.forced_action = (h.forced_action + 1) % 6; + } + } + } + Msg::HackEditTriggerParam(dv) => { + if let Some(c) = selected_mut(&mut m) { + if let Some(h) = c.hack.as_mut() { + h.trigger = match h.trigger { + Trigger::Always => Trigger::Always, + Trigger::EnergiaBajo(v) => { + Trigger::EnergiaBajo((v + dv).clamp(0.0, 100.0)) + } + Trigger::EdadSobre(v) => { + let next = (v as f32 + dv).clamp(0.0, 1000.0); + Trigger::EdadSobre(next as u32) + } + }; + } + } + } + Msg::HackEditDuration(dv) => { + if let Some(c) = selected_mut(&mut m) { + if let Some(h) = c.hack.as_mut() { + let next = (h.duration as f32 + dv).clamp(1.0, 500.0); + h.duration = next as u32; + } + } + } + Msg::CycleSprite => { + if let Some(c) = selected_mut(&mut m) { + // 0 (sin glifo) → 1..=SPRITE_COUNT → 0 ... + c.sprite_id = (c.sprite_id + 1) % (dominium_render_plan::SPRITE_COUNT + 1); + } + } + Msg::CanvasDragMove(dwx, dwy) => { + if let Some(c) = selected_mut(&mut m) { + let max = (GRID as f32) - 1.0; + c.pos_x = (c.pos_x + dwx).clamp(0.0, max); + c.pos_y = (c.pos_y + dwy).clamp(0.0, max); + } + } + Msg::FocusIdInput => { + if let Some(c) = m.selected.and_then(|i| m.sim.world.conceptos.items.get(i)) { + m.id_input.set_text(c.id.clone()); + m.id_input_focused = true; + } + } + Msg::BlurIdInput => { + m.id_input_focused = false; + } + Msg::IdInputKey(ev) => { + if m.id_input_focused && m.id_input.apply_key(&ev) { + let new_id = m.id_input.text().to_string(); + if let Some(c) = selected_mut(&mut m) { + c.id = new_id; + } + } + } + Msg::CycleScenario => { + let n = scenario_packs().len(); + m.scenario_idx = (m.scenario_idx + 1) % n; + } + Msg::LoadScenario => { + let packs = scenario_packs(); + let (_, json) = packs[m.scenario_idx]; + if let Ok(cs) = serde_json::from_str::(json) { + m.sim.world.conceptos = cs; + for lock in m.sim.world.lemmings.hack_lock.iter_mut() { + *lock = 0; + } + m.selected = None; + } + } + Msg::CycleRenderMode => { + m.cfg.render_mode = match m.cfg.render_mode { + RenderMode::Composite => RenderMode::Heatmap(RenderLayer::Materia), + RenderMode::Heatmap(RenderLayer::Degradacion) => RenderMode::PsiCluster, + RenderMode::Heatmap(l) => RenderMode::Heatmap(l.next()), + RenderMode::PsiCluster => RenderMode::Composite, + }; + // Forzar refresh inmediato del k-means al entrar al modo. + if matches!(m.cfg.render_mode, RenderMode::PsiCluster) { + m.sim.refresh_clusters(); + } + } + Msg::ToggleTrails => { + m.show_trails = !m.show_trails; + } + Msg::ToggleTexture => { + m.cfg.texture = !m.cfg.texture; + } + Msg::RewindBy(dv) => { + let cap = m.sim.snapshots.len().saturating_sub(1); + let cur = m.sim.rewind_offset as f32; + let next = (cur + dv).clamp(0.0, cap as f32); + m.sim.rewind_offset = next as usize; + } + Msg::RewindHome => { + m.sim.rewind_offset = 0; + } + Msg::ToggleBigFive => { + m.sim.params.big_five = !m.sim.params.big_five; + if m.sim.params.big_five { + // Saves Big Four que entraron sin columna psi5 hay que + // rellenarlos antes de que el motor consulte + // `lemmings.psi5[i]`. + m.sim.world.lemmings.ensure_psi5_len(); + } + } + Msg::CyclePsiPolicy => { + m.sim.params.action_policy = match m.sim.params.action_policy { + dominium_core::ActionPolicy::Fixed => { + if m.sim.params.policy_reeval_period == 0 { + m.sim.params.policy_reeval_period = 20; + } + dominium_core::ActionPolicy::PsiArgmax + } + dominium_core::ActionPolicy::PsiArgmax => { + dominium_core::ActionPolicy::Fixed + } + }; + } + Msg::WawaConfigChanged(cfg) => { + // Re-armamos el theme y el locale. El locale lo respeta + // el próximo `view()` porque `rimay_localize::t(...)` se + // re-llama cada frame. + m.theme = theme_from_wawa(&cfg, &m.theme); + if cfg.lang != rimay_localize::current_locale() { + let _ = rimay_localize::set_locale(&cfg.lang); + } + } + Msg::SelectTab(tab) => { + m.panel_tab = tab; + } + Msg::DismissOnboarding => { + m.onboarding_done = true; + } + Msg::MenuOpen(idx) => { + m.menu_open = idx; + m.menu_active = usize::MAX; + // Abrir un menú principal cierra el contextual de edición. + m.edit_menu = None; + // Animación de aparición/swap: cada vez que se abre (o se + // cambia de) menú, el dropdown se funde+desliza de nuevo. + if idx.is_some() { + m.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic); + animate(h, motion::FAST, || Msg::MenuTick); + } + } + Msg::MenuCommand(cmd) => { + m.menu_open = None; + return handle_menu_command(m, cmd, h); + } + Msg::MenuNav(dir) => { + if let Some(mi) = m.menu_open { + let menu = app_menu(&m); + m.menu_active = menubar_nav(&menu, mi, m.menu_active, dir); + } + } + Msg::MenuActivate => { + if let Some(mi) = m.menu_open { + let menu = app_menu(&m); + if let Some(cmd) = menubar_command_at(&menu, mi, m.menu_active) { + m.menu_open = None; + return handle_menu_command(m, cmd, h); + } + } + } + Msg::MenuTick => {} + Msg::EditNav(dir) => { + let flags = + EditFlags::from_editor(m.id_input.editor(), m.id_input.is_masked()); + m.edit_active = editmenu::edit_menu_step(flags, m.edit_active, dir); + } + Msg::EditActivate => { + let flags = + EditFlags::from_editor(m.id_input.editor(), m.id_input.is_masked()); + if let Some(action) = editmenu::edit_menu_action_at(flags, m.edit_active) { + m.edit_menu = None; + apply_edit_menu_action(&mut m, action); + } + } + Msg::EditMenuOpen(x, y) => { + // Sólo tiene sentido si hay un campo de texto focuseado; + // si no, abrirlo igual sobre un editor vacío es inocuo + // (todo aparece en gris), pero preferimos no molestar. + if m.id_input_focused { + m.edit_menu = Some((x, y)); + m.edit_active = usize::MAX; + m.menu_open = None; + m.edit_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic); + animate(h, motion::FAST, || Msg::MenuTick); + } + } + Msg::EditMenuAction(action) => { + m.edit_menu = None; + apply_edit_menu_action(&mut m, action); + } + Msg::CloseMenus => { + m.menu_open = None; + m.menu_active = usize::MAX; + m.edit_menu = None; + m.edit_active = usize::MAX; + } + } + m + } + + fn on_key(model: &Model, event: &KeyEvent) -> Option { + if event.state == KeyState::Pressed { + // Menú principal abierto: flechas navegan, ←/→ cambian de menú + // raíz (con wrap), ↑/↓ mueven la fila activa, Enter ejecuta, Esc + // cierra. Tiene prioridad sobre todo lo demás. + if let Some(mi) = model.menu_open { + let n = app_menu(model).menus.len().max(1); + return match &event.key { + Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus), + Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))), + Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))), + Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)), + Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)), + Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate), + _ => None, + }; + } + // Menú de edición abierto: ↑/↓ navegan, Enter ejecuta, Esc cierra. + if model.edit_menu.is_some() { + return match &event.key { + Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus), + Key::Named(NamedKey::ArrowDown) => Some(Msg::EditNav(1)), + Key::Named(NamedKey::ArrowUp) => Some(Msg::EditNav(-1)), + Key::Named(NamedKey::Enter) => Some(Msg::EditActivate), + _ => None, + }; + } + } + if !model.id_input_focused { + return None; + } + // Enter o Escape → cerrar la edición. + if event.state == KeyState::Pressed { + match &event.key { + Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Escape) => { + return Some(Msg::BlurIdInput); + } + _ => {} + } + } + Some(Msg::IdInputKey(event.clone())) + } + + fn view(model: &Model) -> View { + let theme = model.theme; + let shown = model.sim.displayed_world(); + let stats = WorldStats::from_world(shown); + + let status = status_bar(model, &theme); + // PsiMetrics es O(N²) por Moran — para N≈500 son ~250k operaciones + // por frame a 11 Hz, perfectamente costeable y nos da las métricas + // psicológicas en vivo sin un segundo bucle de cálculo. + let psi_metrics = PsiMetrics::from_world(shown); + let mut plan = build_plan_with_overrides( + shown, + &model.iso, + &model.weights, + &model.cfg, + |i| lemming_color_for(model, i), + ); + if model.show_trails && model.sim.rewind_offset == 0 { + overlay_trails(&mut plan, model); + } + let plan_cx = (plan.min_x + plan.max_x) * 0.5; + let plan_cy = (plan.min_y + plan.max_y) * 0.5; + let iso = model.iso; + let canvas = canvas_pane(plan) + .on_click_at(move |lx, ly, rw, rh| { + // Mapeo inverso al que aplica canvas-llimphi para centrar la maqueta: + // plan_pos = local - rect/2 + plan_center + let plan_x = lx - rw * 0.5 + plan_cx; + let plan_y = ly - rh * 0.5 + plan_cy; + let (wx, wy) = iso.unproject_floor(plan_x, plan_y); + let max = (GRID as f32) - 1.0; + if wx >= 0.0 && wx <= max && wy >= 0.0 && wy <= max { + Some(Msg::CanvasClick(wx, wy)) + } else { + None + } + }) + .draggable_at(move |phase, dx, dy, _lx0, _ly0| match phase { + DragPhase::Move => { + // La inversa iso es lineal → unproject(dx, dy) = delta de mundo. + let (wdx, wdy) = iso.unproject_floor(dx, dy); + if wdx == 0.0 && wdy == 0.0 { + None + } else { + Some(Msg::CanvasDragMove(wdx, wdy)) + } + } + DragPhase::End => None, + }); + 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 menu = app_menu(model); + let menubar = menubar_view(&menubar_spec(&menu, model, &theme)); + + let mut frame: Vec> = vec![menubar, status]; + if !model.onboarding_done { + frame.push(onboarding_bar(&theme)); + } + frame.push(body); + // El right-click se engancha en la raíz (origen 0,0 → las coords + // locales que llegan al handler ya son de ventana) y abre el menú + // de edición sobre el campo focuseado. El canvas tiene su propio + // click/drag, pero no captura right-click, así que la raíz es el + // catch-all. + 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) + .on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y))) + .children(frame) + } + + fn view_overlay(model: &Model) -> Option> { + let theme = model.theme; + // El menú de edición tiene prioridad si está abierto. + if let Some((x, y)) = model.edit_menu { + let flags = EditFlags::from_editor(model.id_input.editor(), model.id_input.is_masked()); + let (w, hgt) = Self::initial_size(); + let mut spec = editmenu::edit_context_menu( + (x, y), + (w as f32, hgt as f32), + &theme, + flags, + Msg::EditMenuAction, + Msg::CloseMenus, + ); + spec.active = model.edit_active; + return Some(context_menu_view_ex( + spec, + ContextMenuExtras { + appear: model.edit_anim.value(), + ..Default::default() + }, + )); + } + // Si no, el dropdown del menú principal. + let menu = app_menu(model); + menubar_overlay_animated( + &menubar_spec(&menu, model, &theme), + model.menu_active, + model.menu_anim.value(), + ) + } +} + +/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`. +fn menubar_spec<'a>( + menu: &'a app_bus::AppMenu, + model: &Model, + theme: &'a Theme, +) -> MenuBarSpec<'a, Msg> { + let (w, h) = Dominium::initial_size(); + 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())), + } +} + +/// Construye el menú principal reflejando el estado actual de la sim. +/// El submenú Editar opera sobre `id_input` (el campo de renombre) y se +/// pone en gris cuando no hay nada focuseado o no hay nada que hacer. +fn app_menu(model: &Model) -> app_bus::AppMenu { + use app_bus::{AppMenu, Menu, MenuItem}; + + // Estado del campo de texto focuseado para el submenú Editar. + let focused = model.id_input_focused; + let ed = model.id_input.editor(); + let has_sel = focused && ed.has_selection(); + let can_undo = focused && ed.can_undo(); + let can_redo = focused && ed.can_redo(); + let has_text = focused && !ed.is_empty(); + + let mut undo = MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z"); + if !can_undo { + undo = undo.disabled(); + } + let mut redo = MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y"); + if !can_redo { + redo = redo.disabled(); + } + let mut cut = MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated(); + let mut copy = MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C"); + if !has_sel { + cut = cut.disabled(); + copy = copy.disabled(); + } + let mut paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V"); + if !focused { + paste = paste.disabled(); + } + let mut sel_all = MenuItem::new("Seleccionar todo", "edit.selectall") + .shortcut("Ctrl+A") + .separated(); + if !has_text { + sel_all = sel_all.disabled(); + } + + // Editar conceptos: sólo con selección. + let has_concepto = model.selected.is_some(); + let mut borrar = MenuItem::new("Borrar concepto", "concepto.delete"); + let mut renombrar = MenuItem::new("Renombrar concepto…", "concepto.rename"); + if !has_concepto { + borrar = borrar.disabled(); + renombrar = renombrar.disabled(); + } + + let play_label = if model.sim.running { "Pausar" } else { "Reproducir" }; + let mut rewind = MenuItem::new("Volver al presente", "sim.rewindhome"); + if model.sim.rewind_offset == 0 { + rewind = rewind.disabled(); + } + + AppMenu::new() + .menu( + Menu::new("Archivo") + .item(MenuItem::new("Cargar pack de usuario", "file.loadpack")) + .item(MenuItem::new("Guardar pack de usuario", "file.savepack").separated()) + .item(MenuItem::new("Ciclar scenario", "file.cyclescenario")) + .item(MenuItem::new("Cargar scenario", "file.loadscenario")), + ) + .menu( + Menu::new("Editar") + .item(undo) + .item(redo) + .item(cut) + .item(copy) + .item(paste) + .item(sel_all) + .item(renombrar) + .item(borrar), + ) + .menu( + Menu::new("Simulación") + .item(MenuItem::new(play_label, "sim.toggleplay").shortcut("Espacio")) + .item(MenuItem::new("Re-sembrar mundo", "sim.reseed").separated()) + .item(MenuItem::new("Sembrar conceptos", "sim.sembrar")) + .item(MenuItem::new("Limpiar conceptos", "sim.limpiar")) + .item(MenuItem::new("Crear concepto", "sim.crear").separated()) + .item(MenuItem::new("Big Five ψ", "sim.bigfive")) + .item(MenuItem::new("Política de acción ψ", "sim.psipolicy").separated()) + .item(rewind), + ) + .menu( + Menu::new("Ver") + .item(MenuItem::new("Ciclar modo de render", "view.rendermode")) + .item(MenuItem::new("Trayectorias", "view.trails")) + .item(MenuItem::new("Textura procedural", "view.texture")) + .item(MenuItem::new("Terrazas andinas", "view.andina").separated()) + .item(MenuItem::new("Sincronizar relieve físico", "view.syncrelieve")), + ) + .menu( + Menu::new("Ayuda") + .item(MenuItem::new("Mostrar guía de uso", "help.onboarding")), + ) +} + +/// Traduce el `command` del menú principal al `Msg` real y lo despacha. +fn handle_menu_command(model: Model, command: String, h: &Handle) -> Model { + let target = match command.as_str() { + "file.loadpack" => Some(Msg::CargarPack), + "file.savepack" => Some(Msg::GuardarPack), + "file.cyclescenario" => Some(Msg::CycleScenario), + "file.loadscenario" => Some(Msg::LoadScenario), + "edit.undo" => Some(Msg::EditMenuAction(EditAction::Undo)), + "edit.redo" => Some(Msg::EditMenuAction(EditAction::Redo)), + "edit.cut" => Some(Msg::EditMenuAction(EditAction::Cut)), + "edit.copy" => Some(Msg::EditMenuAction(EditAction::Copy)), + "edit.paste" => Some(Msg::EditMenuAction(EditAction::Paste)), + "edit.selectall" => Some(Msg::EditMenuAction(EditAction::SelectAll)), + "concepto.rename" => Some(Msg::FocusIdInput), + "concepto.delete" => Some(Msg::DeleteSelected), + "sim.toggleplay" => Some(Msg::TogglePlay), + "sim.reseed" => Some(Msg::Reseed), + "sim.sembrar" => Some(Msg::SembrarConceptos), + "sim.limpiar" => Some(Msg::LimpiarConceptos), + "sim.crear" => Some(Msg::CrearConcepto), + "sim.bigfive" => Some(Msg::ToggleBigFive), + "sim.psipolicy" => Some(Msg::CyclePsiPolicy), + "sim.rewindhome" => Some(Msg::RewindHome), + "view.rendermode" => Some(Msg::CycleRenderMode), + "view.trails" => Some(Msg::ToggleTrails), + "view.texture" => Some(Msg::ToggleTexture), + "view.andina" => Some(Msg::ToggleAndina), + "view.syncrelieve" => Some(Msg::ToggleSyncRelieve), + "help.onboarding" => Some(Msg::DismissOnboarding), + _ => None, + }; + match target { + Some(msg) => Dominium::update(model, msg, h), + None => model, + } +} + +/// Aplica una acción del menú de edición al editor del campo focuseado +/// (`id_input`), replicando el bookkeeping de `Msg::IdInputKey`: si el +/// texto cambió, propaga el nuevo id al Concepto seleccionado. +fn apply_edit_menu_action(m: &mut Model, action: EditAction) { + if !m.id_input_focused { + return; + } + let r = editmenu::apply(m.id_input.editor_mut(), action, &mut m.clipboard); + if r.changed() { + let new_id = m.id_input.text().to_string(); + if let Some(c) = selected_mut(m) { + c.id = new_id; + } + } +} + +fn main() { + rimay_localize::init(); + llimphi_ui::run::(); +} diff --git a/01_yachay/dominium/dominium-app-llimphi/src/model.rs b/01_yachay/dominium/dominium-app-llimphi/src/model.rs new file mode 100644 index 0000000..ceaae48 --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/src/model.rs @@ -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, + /// 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, + /// 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, + /// 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, + /// 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, + /// 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), + /// 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), + /// 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, +} diff --git a/01_yachay/dominium/dominium-app-llimphi/src/packs.rs b/01_yachay/dominium/dominium-app-llimphi/src/packs.rs new file mode 100644 index 0000000..5a3c3ec --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/src/packs.rs @@ -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::(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 { + 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, + /// Relieve visual (`ZWeights`). `None` = no tocar el relieve vigente. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weights: Option, + /// 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 { + if raw.contains("\"conceptos\"") { + match serde_json::from_str::(raw) { + Ok(esc) => return Some(esc), + Err(e) => eprintln!("dominium · escenario malformado: {e}"), + } + } + match serde_json::from_str::(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 { + 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 { + load_user_escenario().map(|e| e.conceptos) +} diff --git a/01_yachay/dominium/dominium-app-llimphi/src/sim.rs b/01_yachay/dominium/dominium-app-llimphi/src/sim.rs new file mode 100644 index 0000000..63c0cfb --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/src/sim.rs @@ -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); + } +} diff --git a/01_yachay/dominium/dominium-app-llimphi/src/view.rs b/01_yachay/dominium/dominium-app-llimphi/src/view.rs new file mode 100644 index 0000000..6affc53 --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/src/view.rs @@ -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 { + 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::( + "✕", + &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 { + 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 { + let canvas_bg = llimphi_ui::llimphi_raster::peniko::Color::from_rgba8(11, 13, 18, 255); + let canvas = canvas_view::(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 { + 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> = 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 { + 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 { + let buttons: Vec> = 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::( + &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>, + 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>, + 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>, + 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>, + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + slider_view( + label, + value, + -2.0, + 2.0, + palette, + move |phase, dv| match phase { + DragPhase::Move => Some(Msg::EditZWeight(slot, dv)), + DragPhase::End => None, + }, + ) +} diff --git a/01_yachay/dominium/dominium-app-llimphi/src/worldgen.rs b/01_yachay/dominium/dominium-app-llimphi/src/worldgen.rs new file mode 100644 index 0000000..34162b1 --- /dev/null +++ b/01_yachay/dominium/dominium-app-llimphi/src/worldgen.rs @@ -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) +} diff --git a/01_yachay/dominium/dominium-canvas-llimphi/Cargo.toml b/01_yachay/dominium/dominium-canvas-llimphi/Cargo.toml new file mode 100644 index 0000000..d2ec35a --- /dev/null +++ b/01_yachay/dominium/dominium-canvas-llimphi/Cargo.toml @@ -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` 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" diff --git a/01_yachay/dominium/dominium-canvas-llimphi/LEEME.md b/01_yachay/dominium/dominium-canvas-llimphi/LEEME.md new file mode 100644 index 0000000..4862397 --- /dev/null +++ b/01_yachay/dominium/dominium-canvas-llimphi/LEEME.md @@ -0,0 +1,10 @@ +# dominium-canvas-llimphi + +> Backend Llimphi (vello) para [dominium](../README.md). + +Convierte el `Vec` 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) diff --git a/01_yachay/dominium/dominium-canvas-llimphi/README.md b/01_yachay/dominium/dominium-canvas-llimphi/README.md new file mode 100644 index 0000000..e218958 --- /dev/null +++ b/01_yachay/dominium/dominium-canvas-llimphi/README.md @@ -0,0 +1,10 @@ +# dominium-canvas-llimphi + +> Llimphi (vello) backend for [dominium](../README.md). + +Converts the `Vec` 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) diff --git a/01_yachay/dominium/dominium-canvas-llimphi/examples/canvas_demo.rs b/01_yachay/dominium/dominium-canvas-llimphi/examples/canvas_demo.rs new file mode 100644 index 0000000..54e1ab0 --- /dev/null +++ b/01_yachay/dominium/dominium-canvas-llimphi/examples/canvas_demo.rs @@ -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) -> Model { + Model { world: seed() } + } + + fn update(model: Model, _: Msg, _: &Handle) -> Model { + model + } + + fn view(model: &Model) -> View { + 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::(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::(); +} diff --git a/01_yachay/dominium/dominium-canvas-llimphi/src/lib.rs b/01_yachay/dominium/dominium-canvas-llimphi/src/lib.rs new file mode 100644 index 0000000..365f0ca --- /dev/null +++ b/01_yachay/dominium/dominium-canvas-llimphi/src/lib.rs @@ -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` 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(plan: RenderPlan, background: Option) -> View +where + Msg: Clone + 'static, +{ + // El plan es Send + Sync (Vec 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)); + } +} diff --git a/01_yachay/dominium/dominium-cli/Cargo.toml b/01_yachay/dominium/dominium-cli/Cargo.toml new file mode 100644 index 0000000..eaf7e94 --- /dev/null +++ b/01_yachay/dominium/dominium-cli/Cargo.toml @@ -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 } diff --git a/01_yachay/dominium/dominium-cli/LEEME.md b/01_yachay/dominium/dominium-cli/LEEME.md new file mode 100644 index 0000000..54bc5f7 --- /dev/null +++ b/01_yachay/dominium/dominium-cli/LEEME.md @@ -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` diff --git a/01_yachay/dominium/dominium-cli/README.md b/01_yachay/dominium/dominium-cli/README.md new file mode 100644 index 0000000..e02af6e --- /dev/null +++ b/01_yachay/dominium/dominium-cli/README.md @@ -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` diff --git a/01_yachay/dominium/dominium-cli/src/main.rs b/01_yachay/dominium/dominium-cli/src/main.rs new file mode 100644 index 0000000..f7342f6 --- /dev/null +++ b/01_yachay/dominium/dominium-cli/src/main.rs @@ -0,0 +1,1132 @@ +//! `dominium-cli` — corre la simulación sin ventana y dumpa stats. +//! +//! Útil para: +//! - **Validar determinismo cross-platform**: corré dos veces con el +//! mismo seed en x86 y ARM; los CSV deben ser bit-exactos. +//! - **Experimentar con packs**: cargá un `conceptos.json` y mirá la +//! población/materia a lo largo de N ticks sin esperar a la ventana. +//! - **Profiling**: medir el throughput del motor (tps). +//! +//! Comandos: +//! +//! ```text +//! dominium-cli run --seed 42 --ticks 1000 --grid 40 --lemmings 50 +//! dominium-cli run --conceptos pack.json --csv stats.csv +//! ``` +//! +//! Cada fila del CSV: `tick,poblacion,materia_total,oro_total,energia_total,degradacion_total`. + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use dominium_core::{ + apply_event, ActionPolicy, BehaviorHack, Concepto, Conceptos, Epoch, Event, LayerMods, + PsiMetrics, SimParams, Trigger, World, WorldStats, +}; +use dominium_physics::tick; + +/// Forma rica del pack guardada por `dominium-app-llimphi`: sintonía del +/// motor + relieve visual + Conceptos. La CLI sólo consume `params` y +/// `conceptos` (el relieve visual es cosa del render). `weights` se acepta +/// para no romper el parseo, pero se ignora. +#[derive(serde::Serialize, serde::Deserialize)] +struct EscenarioFile { + #[serde(default, skip_serializing_if = "Option::is_none")] + params: Option, + #[serde(default)] + conceptos: Conceptos, +} + +/// Lee un pack JSON tolerando los dos formatos del app: escenario rico +/// `{ params?, weights?, conceptos }` o pack histórico `{ items: [...] }`. +/// Devuelve los Conceptos y, si el archivo es rico, los `SimParams` que +/// trae — así un escenario tuneado en la app se reproduce headless. El +/// discriminante es la clave `conceptos`. +fn load_pack(path: &std::path::Path) -> Result<(Conceptos, Option)> { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("leyendo {}", path.display()))?; + if raw.contains("\"conceptos\"") { + let e: EscenarioFile = serde_json::from_str(&raw) + .with_context(|| format!("parseando escenario {}", path.display()))?; + Ok((e.conceptos, e.params)) + } else { + let cs: Conceptos = serde_json::from_str(&raw) + .with_context(|| format!("parseando {}", path.display()))?; + Ok((cs, None)) + } +} + +#[derive(Parser, Debug)] +#[command(version, about = "Headless runner for the dominium simulator")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand, Debug)] +enum Cmd { + /// Modo interactivo: arranca un mundo y acepta comandos línea por línea. + /// Comandos: + /// step [N] — avanza N ticks (default 1) + /// stats — imprime poblacion/materia/oro/energia + /// list — lista los Conceptos activos + /// add ID X Y R [HACK] — agrega un Concepto en (x,y) con radius + /// del N — borra el Concepto con índice N + /// load PATH — carga un pack JSON + /// save PATH — guarda el pack actual + /// csv PATH — abre archivo CSV para los próximos step + /// quit — sale + Repl { + #[arg(long, default_value_t = 0xD0_31_31_07_u64)] + seed: u64, + #[arg(long, default_value_t = 40)] + grid: usize, + #[arg(long, default_value_t = 50)] + lemmings: usize, + #[arg(long)] + conceptos: Option, + }, + /// Corre N ticks y opcionalmente escribe stats a CSV. + Run { + /// Seed del PRNG para sembrar el mundo (determinista). + #[arg(long, default_value_t = 0xD0_31_31_07_u64)] + seed: u64, + /// Cantidad de ticks a correr. + #[arg(long, default_value_t = 200)] + ticks: u64, + /// Lado de la grilla cuadrada. + #[arg(long, default_value_t = 40)] + grid: usize, + /// Población inicial de lemmings. + #[arg(long, default_value_t = 50)] + lemmings: usize, + /// Pack JSON de Conceptos a cargar. Vacío = sin Conceptos. + #[arg(long)] + conceptos: Option, + /// Archivo CSV destino. Vacío = imprime resumen a stdout. + #[arg(long)] + csv: Option, + /// Período del ciclo estacional en ticks. `0` = sin estaciones. + #[arg(long, default_value_t = 0)] + season_period: u32, + /// Amplitud del ciclo estacional ∈ [0, 1]. `0` = sin estaciones. + #[arg(long, default_value_t = 0.0)] + season_amplitude: f32, + /// Intensidad de la modulación de efectos por `vector_psi` (Fase A). + /// `0.0` (default) = comportamiento histórico bit-exacto. Rango útil + /// 0.0..1.0; valores mayores amplifican la heterogeneidad por psi. + #[arg(long, default_value_t = 0.0)] + psi_modulation: f32, + /// Política de elección de acción. `fixed` (default) = la acción se + /// hereda y nunca se reelige. `psi-argmax` = cada + /// `--policy-period` ticks los lemmings reeligen su acción como + /// `argmax(action_weights · psi)`. + #[arg(long, value_parser = parse_action_policy, default_value = "fixed")] + action_policy: ActionPolicy, + /// Período de reelección para `--action-policy psi-argmax`. `0` + /// deshabilita la reelección incluso con la política activa. + #[arg(long, default_value_t = 0)] + policy_period: u32, + /// Radio de contagio social (Fase B). `0.0` (default) deshabilita + /// el contagio. Los agentes en este radio acercan su psi al + /// promedio local cada tick. + #[arg(long, default_value_t = 0.0)] + social_radius: f32, + /// Tasa de contagio social (Fase B). `0.0` (default) deshabilita. + /// Rango útil 0.01..0.20. + #[arg(long, default_value_t = 0.0)] + contagion_rate: f32, + /// Umbral de homofilia (Fase B.2). `0.0` = contagio universal. + /// Con valor > 0, sólo influyen vecinos con distancia psi < + /// umbral. Rango útil 0.3..1.0 para producir tribus aisladas. + #[arg(long, default_value_t = 0.0)] + homophily_threshold: f32, + /// CSV de población inicial (Fase D.1). Header opcional; columnas + /// requeridas: `psi_orden, psi_miedo, psi_curiosidad, + /// psi_corruptibilidad`. Columnas opcionales: `x, y, energia, + /// accion`. Si están presentes ganan sobre los valores generados + /// por el PRNG; si faltan, se rellenan con el PRNG sembrado por + /// `--seed`. Cuando se usa, `--lemmings` queda ignorado. + #[arg(long)] + from_csv: Option, + /// Timeline JSON de eventos a inyectar (Fase D.1). Lista ordenada + /// de `{tick, kind: Shock|PsiNudge, ...}`. Antes de cada `tick()`, + /// los eventos cuyo `tick` coincide con el reloj global se aplican + /// en orden de aparición en el archivo. + #[arg(long)] + events_json: Option, + }, + /// Monte Carlo sweep (Fase D.2): barre un parámetro en `--steps` + /// puntos × `--reps` corridas con seeds distintos, escribe un CSV + /// donde cada fila es una corrida con su valor de parámetro, seed y + /// métricas finales (n, Gini, polarización, correlaciones, conteos + /// de acción). Determinista bit-exacto: dos sweeps con los mismos + /// argumentos producen CSV idéntico. + /// + /// Nombres de `--param` válidos: `psi_modulation`, `contagion_rate`, + /// `social_radius`, `homophily_threshold`, `policy_period`. + /// Para `policy_period`, los valores se redondean al entero más + /// cercano (es u32). Cuando se barre `policy_period > 0` conviene + /// pasar `--action-policy psi-argmax` para que la política se + /// active. + Sweep { + /// Nombre del parámetro a barrer. + #[arg(long)] + param: String, + /// Valor mínimo del rango (inclusive). + #[arg(long)] + min: f32, + /// Valor máximo del rango (inclusive). + #[arg(long)] + max: f32, + /// Cantidad de puntos del barrido (≥ 2). `steps=2` produce sólo + /// `min` y `max`; `steps=N` divide en `N-1` intervalos iguales. + #[arg(long, default_value_t = 10)] + steps: usize, + /// Repeticiones por punto, con seeds distintos. + #[arg(long, default_value_t = 3)] + reps: usize, + /// Ticks por corrida. + #[arg(long, default_value_t = 500)] + ticks: u64, + /// Seed base del sweep. Cada repetición usa `seed_base + rep`. + #[arg(long, default_value_t = 0xD0_31_31_07_u64)] + seed_base: u64, + #[arg(long, default_value_t = 40)] + grid: usize, + #[arg(long, default_value_t = 100)] + lemmings: usize, + /// Pack de Conceptos a aplicar en cada corrida. + #[arg(long)] + conceptos: Option, + /// Población inicial desde CSV (todos los reps comparten la + /// misma; el seed sólo modula los valores faltantes del CSV). + /// Cuando se pasa, `--lemmings` se ignora. + #[arg(long)] + from_csv: Option, + /// CSV de salida (obligatorio). + #[arg(long)] + csv: PathBuf, + /// Política de acción base (se mantiene fija durante el sweep). + #[arg(long, value_parser = parse_action_policy, default_value = "fixed")] + action_policy: ActionPolicy, + /// Valores baseline de parámetros NO barridos. Si barrés + /// `psi_modulation`, el resto se mantiene en estos valores. + #[arg(long, default_value_t = 0.0)] + psi_modulation: f32, + #[arg(long, default_value_t = 0)] + policy_period: u32, + #[arg(long, default_value_t = 0.0)] + social_radius: f32, + #[arg(long, default_value_t = 0.0)] + contagion_rate: f32, + #[arg(long, default_value_t = 0.0)] + homophily_threshold: f32, + }, +} + +fn parse_action_policy(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "fixed" => Ok(ActionPolicy::Fixed), + "psi-argmax" | "psiargmax" | "argmax" => Ok(ActionPolicy::PsiArgmax), + other => Err(format!( + "policy desconocida `{other}`; usá `fixed` o `psi-argmax`" + )), + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.cmd { + Cmd::Run { + seed, + ticks, + grid, + lemmings, + conceptos, + csv, + season_period, + season_amplitude, + psi_modulation, + action_policy, + policy_period, + social_radius, + contagion_rate, + homophily_threshold, + from_csv, + events_json, + } => run_sim( + seed, + ticks, + grid, + lemmings, + conceptos.as_deref(), + csv.as_deref(), + season_period, + season_amplitude, + psi_modulation, + action_policy, + policy_period, + social_radius, + contagion_rate, + homophily_threshold, + from_csv.as_deref(), + events_json.as_deref(), + ), + Cmd::Repl { seed, grid, lemmings, conceptos } => { + repl(seed, grid, lemmings, conceptos.as_deref()) + } + Cmd::Sweep { + param, + min, + max, + steps, + reps, + ticks, + seed_base, + grid, + lemmings, + conceptos, + from_csv, + csv, + action_policy, + psi_modulation, + policy_period, + social_radius, + contagion_rate, + homophily_threshold, + } => run_sweep(SweepArgs { + param, + min, + max, + steps, + reps, + ticks, + seed_base, + grid, + lemmings, + conceptos_path: conceptos.as_deref(), + from_csv: from_csv.as_deref(), + csv_out: csv.as_path(), + action_policy, + base_psi_modulation: psi_modulation, + base_policy_period: policy_period, + base_social_radius: social_radius, + base_contagion_rate: contagion_rate, + base_homophily_threshold: homophily_threshold, + }), + } +} + +fn run_sim( + seed: u64, + ticks: u64, + grid: usize, + lemmings: usize, + conceptos_path: Option<&std::path::Path>, + csv_path: Option<&std::path::Path>, + season_period: u32, + season_amplitude: f32, + psi_modulation: f32, + action_policy: ActionPolicy, + policy_period: u32, + social_radius: f32, + contagion_rate: f32, + homophily_threshold: f32, + from_csv: Option<&std::path::Path>, + events_json: Option<&std::path::Path>, +) -> Result<()> { + let mut world = build_world(seed, grid, lemmings); + if let Some(path) = from_csv { + let n = seed_population_from_csv(&mut world, path, seed) + .with_context(|| format!("leyendo CSV de población {}", path.display()))?; + eprintln!("dominium-cli · población cargada desde CSV: {n} agentes"); + } + let events: Vec = if let Some(path) = events_json { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("leyendo {}", path.display()))?; + let evs: Vec = serde_json::from_str(&raw) + .with_context(|| format!("parseando timeline {}", path.display()))?; + eprintln!("dominium-cli · timeline cargada: {} eventos", evs.len()); + evs + } else { + Vec::new() + }; + // Si el pack es un escenario rico, sus `params` son la base (economía, + // relieve físico, big_five…); los flags de CLI de abajo sólo pisan los + // campos estacionales/sociales que el subcomando expone. Un pack + // histórico (sólo Conceptos) deja la base en `default`. + let mut base_params = None; + if let Some(path) = conceptos_path { + let (cs, params) = load_pack(path)?; + world.conceptos = cs; + base_params = params; + } + let mut params = base_params.unwrap_or_default(); + params.season_period = season_period; + params.season_amplitude = season_amplitude; + params.psi_effect_modulation = psi_modulation; + params.action_policy = action_policy; + params.policy_reeval_period = policy_period; + params.social_radius = social_radius; + params.contagion_rate = contagion_rate; + params.homophily_threshold = homophily_threshold; + + let mut writer: Option> = match csv_path { + Some(p) => Some(BufWriter::new( + File::create(p).with_context(|| format!("abriendo {}", p.display()))?, + )), + None => None, + }; + if let Some(w) = writer.as_mut() { + writeln!(w, "{}", CSV_HEADER)?; + } + + let t0 = std::time::Instant::now(); + for t in 0..ticks { + // Aplica eventos cuyo tick coincide con el reloj actual ANTES del + // tick() — el shock entra en juego en este paso (la difusión lo + // propaga). Orden lineal en `events` para determinismo: ante dos + // eventos en el mismo tick, se aplican en orden de aparición. + let now = world.tick_count; + for ev in &events { + if ev.tick == now { + apply_event(&mut world, &ev.kind); + } + } + tick(&mut world, ¶ms); + if let Some(w) = writer.as_mut() { + write_row(w, &world, t + 1)?; + } + if world.lemmings.is_empty() { + eprintln!("colapso en tick {} — población vacía", t + 1); + break; + } + } + let dt = t0.elapsed(); + if let Some(w) = writer.as_mut() { + w.flush()?; + } + let final_stats = WorldStats::from_world(&world); + let psi = PsiMetrics::from_world(&world); + let tps = (ticks as f64) / dt.as_secs_f64().max(1e-9); + println!( + "ok · {} ticks en {:.2?} ({:.0} tps) · seed={} grid={}×{} · poblacion={} materia={:.0} oro={:.0} energia={:.0} gini_e={:.3}", + ticks, + dt, + tps, + seed, + grid, + grid, + final_stats.n, + final_stats.total_materia, + final_stats.total_oro, + final_stats.total_energia, + final_stats.gini_energia, + ); + println!( + " psi · polariz=[{:.3} {:.3} {:.3} {:.3}] · corr(CORR↔Extraer)={:+.3} corr(CORR↔Degradar)={:+.3} corr(ORDEN↔Intercamb.)={:+.3} corr(MIEDO↔Mover)={:+.3}", + psi.polarization[0], + psi.polarization[1], + psi.polarization[2], + psi.polarization[3], + psi.psi_action_corr[3][1], + psi.psi_action_corr[3][5], + psi.psi_action_corr[0][3], + psi.psi_action_corr[1][0], + ); + println!( + " psi · moran_i=[{:+.3} {:+.3} {:+.3} {:+.3}] (autocorrelación espacial, +1=segregación, 0=azar, -1=ajedrez)", + psi.moran_i[0], psi.moran_i[1], psi.moran_i[2], psi.moran_i[3], + ); + Ok(()) +} + +/// Encabezado CSV: orden estable usado por `write_row` y por el header del +/// REPL. Cualquier reordenamiento debe replicarse en `write_row`. +/// +/// Columnas Fase C parcial (PsiMetrics): +/// - `pol_psi{0..3}`: polarización Esteban-Ray por componente del psi. +/// - `corr_{psi}_{accion}`: correlación punto-biserial entre el componente +/// del psi y el indicador binario de la acción. Seis pares canónicos +/// alineados con la matriz `action_weights` por default — los que +/// esperamos que se enciendan cuando `ActionPolicy::PsiArgmax` funciona. +const CSV_HEADER: &str = "tick,epoca,poblacion,materia,psique,poder,oro,degradacion,energia,mean_edad,gini_e,var_psi0,var_psi1,var_psi2,var_psi3,act_mover,act_extraer,act_sync,act_trade,act_repl,act_degr,pol_psi0,pol_psi1,pol_psi2,pol_psi3,corr_corr_extraer,corr_corr_degradar,corr_orden_intercambiar,corr_orden_replicar,corr_miedo_mover,corr_curiosidad_sync,moran_psi0,moran_psi1,moran_psi2,moran_psi3"; + +/// Escribe una fila al CSV usando `WorldStats` + `PsiMetrics` — formato +/// estable con `:.3` para floats macro y `:.6` para correlaciones (rango +/// `[-1,1]`, queremos resolución fina). +fn write_row(w: &mut W, world: &World, t: u64) -> std::io::Result<()> { + let s = WorldStats::from_world(world); + let e = Epoch::classify(&s); + let p = PsiMetrics::from_world(world); + // Índices semánticos para legibilidad — coinciden con `lemmings.rs`. + const ORDEN: usize = 0; + const MIEDO: usize = 1; + const CURIOSIDAD: usize = 2; + const CORR: usize = 3; + // Y con `world::Action::from_u8`. + const MOVER: usize = 0; + const EXTRAER: usize = 1; + const SYNC: usize = 2; + const INTERCAMBIAR: usize = 3; + const REPLICAR: usize = 4; + const DEGRADAR: usize = 5; + writeln!( + w, + "{},{},{},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.6},{:.6},{:.6},{:.6},{:.6},{},{},{},{},{},{},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6}", + t, + e.label(), + s.n, + s.total_materia, + s.total_psique, + s.total_poder, + s.total_oro, + s.total_degradacion, + s.total_energia, + s.mean_edad, + s.gini_energia, + s.var_psi[0], + s.var_psi[1], + s.var_psi[2], + s.var_psi[3], + s.action_counts[0], + s.action_counts[1], + s.action_counts[2], + s.action_counts[3], + s.action_counts[4], + s.action_counts[5], + p.polarization[0], + p.polarization[1], + p.polarization[2], + p.polarization[3], + p.psi_action_corr[CORR][EXTRAER], + p.psi_action_corr[CORR][DEGRADAR], + p.psi_action_corr[ORDEN][INTERCAMBIAR], + p.psi_action_corr[ORDEN][REPLICAR], + p.psi_action_corr[MIEDO][MOVER], + p.psi_action_corr[CURIOSIDAD][SYNC], + p.moran_i[0], + p.moran_i[1], + p.moran_i[2], + p.moran_i[3], + ) +} + +// PRNG mínimo (mismo LCG que el app — bit-exacto). +struct Lcg(u64); +impl Lcg { + fn new(s: u64) -> Self { Self(s) } + 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 + } +} + +/// Modo interactivo. Cada línea = un comando. Errores no fatales se +/// imprimen y el loop continúa. +fn repl( + seed: u64, + grid: usize, + lemmings: usize, + conceptos_path: Option<&std::path::Path>, +) -> Result<()> { + let mut world = build_world(seed, grid, lemmings); + let mut params = SimParams::default(); + if let Some(p) = conceptos_path { + let (cs, esc_params) = load_pack(p)?; + world.conceptos = cs; + if let Some(esc) = esc_params { + params = esc; + } + } + let mut tick_count: u64 = 0; + let mut csv_writer: Option> = None; + println!("dominium-cli repl · seed={seed} grid={grid}×{grid} lemmings={lemmings}"); + println!("comandos: step [N] | stats | list | add ID X Y R [HACK] | del N |"); + println!(" load PATH | save PATH | csv PATH | quit"); + println!("(HACK opcional: 'hack ACTION DURATION' fuerza acción 0..5 N ticks)"); + + use std::io::{BufRead, Write as _}; + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + loop { + print!("dominium[{tick_count}]> "); + stdout.flush().ok(); + let mut line = String::new(); + if stdin.lock().read_line(&mut line)? == 0 { + println!(); + break; + } + let line = line.trim(); + if line.is_empty() { + continue; + } + let mut parts = line.split_whitespace(); + let cmd = parts.next().unwrap_or(""); + match cmd { + "quit" | "q" | "exit" => break, + "step" => { + let n: u64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1); + for _ in 0..n { + tick(&mut world, ¶ms); + tick_count += 1; + if let Some(w) = csv_writer.as_mut() { + write_row(w, &world, tick_count)?; + } + if world.lemmings.is_empty() { + println!("colapso en tick {tick_count}"); + break; + } + } + let s = WorldStats::from_world(&world); + println!( + "tick={} pop={} materia={:.0} oro={:.0} energia={:.0} gini_e={:.3}", + tick_count, s.n, s.total_materia, s.total_oro, s.total_energia, s.gini_energia + ); + } + "stats" => { + let s = WorldStats::from_world(&world); + println!( + "tick={} epoca={} pop={} materia={:.0} oro={:.0} energia={:.0} degradacion={:.0} mean_edad={:.1} gini_e={:.3}", + tick_count, + Epoch::classify(&s).label(), + s.n, + s.total_materia, + s.total_oro, + s.total_energia, + s.total_degradacion, + s.mean_edad, + s.gini_energia + ); + println!( + " var_psi=[O:{:.3} M:{:.3} C:{:.3} K:{:.3}] acciones=[mover:{} extraer:{} sync:{} trade:{} repl:{} degr:{}]", + s.var_psi[0], + s.var_psi[1], + s.var_psi[2], + s.var_psi[3], + s.action_counts[0], + s.action_counts[1], + s.action_counts[2], + s.action_counts[3], + s.action_counts[4], + s.action_counts[5], + ); + } + "list" => { + if world.conceptos.is_empty() { + println!("(sin conceptos)"); + } + for (i, c) in world.conceptos.items.iter().enumerate() { + println!( + " [{i}] {:<16} pos=({:.1},{:.1}) r={:.1} mods={{m:{:+.2} p:{:+.2} P:{:+.2} o:{:+.2}}} hack={}", + c.id, c.pos_x, c.pos_y, c.radius, + c.mods.materia, c.mods.psique, c.mods.poder, c.mods.oro, + c.hack.is_some() + ); + } + } + "add" => match parse_add(parts) { + Ok(c) => { + let i = world.conceptos.add(c); + println!("ok · concepto[{i}] agregado"); + } + Err(e) => println!("error: {e}"), + }, + "del" => { + let Some(idx_str) = parts.next() else { + println!("uso: del N"); + continue; + }; + match idx_str.parse::() { + Ok(i) if i < world.conceptos.len() => { + world.conceptos.remove(i); + println!("ok · concepto[{i}] borrado"); + } + Ok(i) => println!("error: índice fuera de rango ({i})"), + Err(e) => println!("error: {e}"), + } + } + "load" => { + let Some(path) = parts.next() else { + println!("uso: load PATH"); + continue; + }; + match load_pack(std::path::Path::new(path)) { + Ok((cs, esc_params)) => { + world.conceptos = cs; + if let Some(esc) = esc_params { + params = esc; + println!( + "ok · {} conceptos + sintonía del escenario aplicada", + world.conceptos.len() + ); + } else { + println!("ok · {} conceptos cargados", world.conceptos.len()); + } + } + Err(e) => println!("error: {e}"), + } + } + "save" => { + let Some(path) = parts.next() else { + println!("uso: save PATH"); + continue; + }; + // Guarda el escenario rico: sintonía vigente + Conceptos, + // mismo formato que el app. Recargable con `load` o por la + // app (que rellena el relieve visual con su default). + let esc = EscenarioFile { + params: Some(params.clone()), + conceptos: world.conceptos.clone(), + }; + match serde_json::to_string_pretty(&esc) { + Ok(json) => match std::fs::write(path, json) { + Ok(()) => println!( + "ok · escenario ({} conceptos + sintonía) guardado en {path}", + world.conceptos.len() + ), + Err(e) => println!("error: {e}"), + }, + Err(e) => println!("error: {e}"), + } + } + "csv" => { + let Some(path) = parts.next() else { + println!("uso: csv PATH"); + continue; + }; + match File::create(path) { + Ok(f) => { + let mut w = BufWriter::new(f); + writeln!(w, "{}", CSV_HEADER)?; + csv_writer = Some(w); + println!("ok · CSV abierto en {path}"); + } + Err(e) => println!("error: {e}"), + } + } + _ => println!("comando desconocido: {cmd}"), + } + } + if let Some(w) = csv_writer.as_mut() { + w.flush().ok(); + } + Ok(()) +} + +/// Parsea `add ID X Y R [hack ACTION DURATION]`. Si hack está, trigger +/// queda en `Always` con esa acción y duración. +fn parse_add<'a>(mut parts: impl Iterator) -> Result { + let id = parts.next().context("falta ID")?.to_string(); + let x: f32 = parts.next().context("falta X")?.parse().context("X inválido")?; + let y: f32 = parts.next().context("falta Y")?.parse().context("Y inválido")?; + let r: f32 = parts.next().context("falta R")?.parse().context("R inválido")?; + let hack = match parts.next() { + Some("hack") => { + let action: u8 = parts + .next() + .context("falta ACTION para hack")? + .parse() + .context("ACTION inválido")?; + let dur: u32 = parts + .next() + .context("falta DURATION para hack")? + .parse() + .context("DURATION inválido")?; + Some(BehaviorHack { + trigger: Trigger::Always, + forced_action: action, + duration: dur, + }) + } + Some(other) => anyhow::bail!("token inesperado: {other}"), + None => None, + }; + Ok(Concepto { + id, + sprite_id: 0, + pos_x: x, + pos_y: y, + radius: r, + mods: LayerMods::default(), + hack, + persuasion: None, + }) +} + +/// Carga una población inicial desde CSV (Fase D.1). Reemplaza COMPLETAMENTE +/// la población actual del `world` (la grilla se preserva — el sustrato +/// sigue siendo el sembrado por el PRNG). Devuelve la cantidad de agentes +/// cargados. +/// +/// Header opcional. Columnas reconocidas: +/// - `psi_orden, psi_miedo, psi_curiosidad, psi_corruptibilidad` +/// (requeridas si hay header; si no, las primeras 4 columnas se asumen +/// en este orden). +/// - `x, y` (opcionales): posición; si faltan, se generan con el PRNG. +/// - `energia` (opcional): energía inicial; default 40 + rng·40. +/// - `accion` (opcional): byte de acción (0..=5); default reusa el bucket +/// módulo k del `build_world`. +fn seed_population_from_csv(world: &mut World, path: &std::path::Path, seed: u64) -> Result { + let raw = std::fs::read_to_string(path).context("leyendo CSV")?; + let mut lines = raw.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')); + let first = lines.next().context("CSV vacío")?; + // Detecta header: si tiene letras alfabéticas (no sólo dígitos/puntos/comas/menos) + // asume header. Si no, primera fila es ya un agente. + let has_header = first + .chars() + .any(|c| c.is_alphabetic() && c != 'e' && c != 'E'); + // Mapeo de columna → posición. Sin header, asumimos PSI_ORDEN, + // PSI_MIEDO, PSI_CURIOSIDAD, PSI_CORRUPTIBILIDAD en columnas 0..3. + let mut col_psi_o = Some(0usize); + let mut col_psi_m = Some(1usize); + let mut col_psi_c = Some(2usize); + let mut col_psi_k = Some(3usize); + let mut col_x: Option = None; + let mut col_y: Option = None; + let mut col_energia: Option = None; + let mut col_accion: Option = None; + if has_header { + col_psi_o = None; + col_psi_m = None; + col_psi_c = None; + col_psi_k = None; + for (i, name) in first.split(',').map(str::trim).enumerate() { + match name { + "psi_orden" => col_psi_o = Some(i), + "psi_miedo" => col_psi_m = Some(i), + "psi_curiosidad" => col_psi_c = Some(i), + "psi_corruptibilidad" => col_psi_k = Some(i), + "x" => col_x = Some(i), + "y" => col_y = Some(i), + "energia" => col_energia = Some(i), + "accion" => col_accion = Some(i), + _ => {} // columna desconocida — la ignoramos + } + } + } + let col_psi_o = col_psi_o.context("falta columna psi_orden")?; + let col_psi_m = col_psi_m.context("falta columna psi_miedo")?; + let col_psi_c = col_psi_c.context("falta columna psi_curiosidad")?; + let col_psi_k = col_psi_k.context("falta columna psi_corruptibilidad")?; + // Reemplazo total de población. La grilla se preserva. + world.lemmings = dominium_core::Lemmings::new(); + // PRNG sembrado por --seed: alimenta x/y/energia faltantes. Determinista. + let mut rng = Lcg::new(seed); + let mut rows_iter: Box> = if has_header { + Box::new(lines) + } else { + Box::new(std::iter::once(first).chain(lines)) + }; + let max = (world.grid.width.max(world.grid.height) as f32) - 1.0; + let mut k = 0usize; + while let Some(line) = rows_iter.next() { + let fields: Vec<&str> = line.split(',').map(str::trim).collect(); + let get = |c: usize| -> Result { + fields + .get(c) + .with_context(|| format!("fila {k} faltan columnas (idx {c})"))? + .parse::() + .with_context(|| format!("fila {k} col {c} no parsea como f32")) + }; + let psi = [ + get(col_psi_o)?, + get(col_psi_m)?, + get(col_psi_c)?, + get(col_psi_k)?, + ]; + let x = match col_x { + Some(c) => get(c)?.clamp(0.0, max), + None => rng.next_f32() * max, + }; + let y = match col_y { + Some(c) => get(c)?.clamp(0.0, max), + None => rng.next_f32() * max, + }; + let energia = match col_energia { + Some(c) => get(c)?.max(0.0), + None => 40.0 + rng.next_f32() * 40.0, + }; + let i = world.lemmings.spawn(x, y, energia, psi); + let accion = match col_accion { + Some(c) => { + let raw = fields + .get(c) + .with_context(|| format!("fila {k} falta accion (idx {c})"))? + .trim(); + raw.parse::() + .with_context(|| format!("fila {k} accion no parsea como u8"))? + .min(5) + } + None => match k % 20 { + 0..=5 => 1, + 6..=11 => 3, + 12..=15 => 0, + 16..=18 => 4, + _ => 2, + }, + }; + world.lemmings.accion[i] = accion; + k += 1; + } + Ok(k) +} + +fn build_world(seed: u64, grid: usize, lemmings: usize) -> World { + let mut w = World::new(grid, grid); + let mut rng = Lcg::new(seed); + for cy in 0..grid { + for cx in 0..grid { + let idx = w.grid.idx(cx, cy); + let m = rng.next_f32(); + w.grid.materia[idx] = m * m * 60.0; + if rng.next_f32() > 0.92 { + w.grid.oro[idx] = rng.next_f32() * 40.0; + } + w.grid.psique[idx] = rng.next_f32() * 12.0; + } + } + for k in 0..lemmings { + 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(x, y, 40.0 + rng.next_f32() * 40.0, psi); + // Distribución calibrada al punto fijo (ver dominium-app-llimphi): + // 30% Extraer + 30% Trade + 20% Mover + 15% Replicar + 5% Sync. + w.lemmings.accion[i] = match k % 20 { + 0..=5 => 1, + 6..=11 => 3, + 12..=15 => 0, + 16..=18 => 4, + _ => 2, + } as u8; + } + w +} + +// --------------------------------------------------------------------- +// D.2 — Monte Carlo sweep +// --------------------------------------------------------------------- + +/// Métricas finales de una corrida — todo lo que el sweep escribe en CSV. +struct SweepRowMetrics { + n: usize, + gini_e: f32, + mean_edad: f32, + polariz: [f32; 4], + psi_action_corr: [[f32; 6]; 4], + moran_i: [f32; 4], + action_counts: [u32; 6], +} + +impl SweepRowMetrics { + fn from_world(world: &World) -> Self { + let s = WorldStats::from_world(world); + let p = PsiMetrics::from_world(world); + Self { + n: s.n, + gini_e: s.gini_energia, + mean_edad: s.mean_edad, + polariz: p.polarization, + psi_action_corr: p.psi_action_corr, + moran_i: p.moran_i, + action_counts: s.action_counts, + } + } +} + +/// Corre N ticks sobre el `world` con los `params` dados, devolviendo las +/// métricas finales. Pura — sin I/O — para que el sweep pueda +/// paralelizar trivialmente si más adelante hace falta. +fn simulate_one(mut world: World, params: &SimParams, ticks: u64) -> SweepRowMetrics { + for _ in 0..ticks { + tick(&mut world, params); + if world.lemmings.is_empty() { + break; + } + } + SweepRowMetrics::from_world(&world) +} + +/// Parámetros del sweep — los empaquetamos para no pasar 15 args sueltos +/// al `run_sweep`. +struct SweepArgs<'a> { + param: String, + min: f32, + max: f32, + steps: usize, + reps: usize, + ticks: u64, + seed_base: u64, + grid: usize, + lemmings: usize, + conceptos_path: Option<&'a std::path::Path>, + from_csv: Option<&'a std::path::Path>, + csv_out: &'a std::path::Path, + action_policy: ActionPolicy, + base_psi_modulation: f32, + base_policy_period: u32, + base_social_radius: f32, + base_contagion_rate: f32, + base_homophily_threshold: f32, +} + +fn run_sweep(a: SweepArgs) -> Result<()> { + if a.steps < 2 { + anyhow::bail!("--steps debe ser ≥ 2 (recibí {})", a.steps); + } + // Carga única del pack de Conceptos — todos los reps usan la misma + // lista. La determinismo se mantiene porque la lista no se permuta. + let conceptos = if let Some(path) = a.conceptos_path { + let (cs, _params) = load_pack(path)?; + Some(cs) + } else { + None + }; + let mut writer = BufWriter::new( + File::create(a.csv_out) + .with_context(|| format!("abriendo CSV salida {}", a.csv_out.display()))?, + ); + writeln!( + writer, + "param_name,param_value,seed,rep,n,gini_e,mean_edad,\ + pol_psi0,pol_psi1,pol_psi2,pol_psi3,\ + corr_corr_extraer,corr_corr_degradar,corr_orden_intercambiar,corr_orden_replicar,corr_miedo_mover,corr_curiosidad_sync,\ + moran_psi0,moran_psi1,moran_psi2,moran_psi3,\ + act_mover,act_extraer,act_sync,act_trade,act_repl,act_degr" + )?; + let t0 = std::time::Instant::now(); + let total_runs = a.steps * a.reps; + let mut completed = 0usize; + for step in 0..a.steps { + // Linspace inclusivo entre min y max. + let value = if a.steps == 1 { + a.min + } else { + a.min + (a.max - a.min) * (step as f32 / (a.steps as f32 - 1.0)) + }; + for rep in 0..a.reps { + let seed = a.seed_base.wrapping_add(rep as u64); + // Construir el mundo: PRNG sembrado por `seed`; si hay CSV de + // población, sobrescribe los agentes. + let mut world = build_world(seed, a.grid, a.lemmings); + if let Some(path) = a.from_csv { + seed_population_from_csv(&mut world, path, seed) + .with_context(|| format!("CSV pop {}", path.display()))?; + } + if let Some(cs) = &conceptos { + world.conceptos = cs.clone(); + } + // Aplicar el valor al parámetro elegido sobre la baseline. + let mut params = SimParams::default(); + params.action_policy = a.action_policy; + params.psi_effect_modulation = a.base_psi_modulation; + params.policy_reeval_period = a.base_policy_period; + params.social_radius = a.base_social_radius; + params.contagion_rate = a.base_contagion_rate; + params.homophily_threshold = a.base_homophily_threshold; + apply_param_override(&mut params, &a.param, value) + .with_context(|| format!("--param {}", a.param))?; + let m = simulate_one(world, ¶ms, a.ticks); + const ORDEN: usize = 0; + const MIEDO: usize = 1; + const CURIOSIDAD: usize = 2; + const CORR: usize = 3; + const MOVER: usize = 0; + const EXTRAER: usize = 1; + const SYNC: usize = 2; + const INTERCAMBIAR: usize = 3; + const REPLICAR: usize = 4; + const DEGRADAR: usize = 5; + writeln!( + writer, + "{},{:.6},{},{},{},{:.6},{:.3},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{},{},{},{},{},{}", + a.param, + value, + seed, + rep, + m.n, + m.gini_e, + m.mean_edad, + m.polariz[0], + m.polariz[1], + m.polariz[2], + m.polariz[3], + m.psi_action_corr[CORR][EXTRAER], + m.psi_action_corr[CORR][DEGRADAR], + m.psi_action_corr[ORDEN][INTERCAMBIAR], + m.psi_action_corr[ORDEN][REPLICAR], + m.psi_action_corr[MIEDO][MOVER], + m.psi_action_corr[CURIOSIDAD][SYNC], + m.moran_i[0], + m.moran_i[1], + m.moran_i[2], + m.moran_i[3], + m.action_counts[0], + m.action_counts[1], + m.action_counts[2], + m.action_counts[3], + m.action_counts[4], + m.action_counts[5], + )?; + completed += 1; + } + eprintln!( + "sweep · step {}/{}: param={} value={:.4} · ({} corridas)", + step + 1, + a.steps, + a.param, + value, + a.reps, + ); + } + writer.flush()?; + let dt = t0.elapsed(); + println!( + "ok · sweep `{}` [{:.3}..{:.3}] · {} steps × {} reps = {} corridas en {:.2?}", + a.param, + a.min, + a.max, + a.steps, + a.reps, + total_runs, + dt, + ); + let _ = completed; + Ok(()) +} + +/// Modifica el `SimParams` para el parámetro indicado por nombre. +fn apply_param_override(params: &mut SimParams, name: &str, value: f32) -> Result<()> { + match name { + "psi_modulation" => params.psi_effect_modulation = value, + "contagion_rate" => params.contagion_rate = value, + "social_radius" => params.social_radius = value, + "homophily_threshold" => params.homophily_threshold = value, + // policy_period es u32 — redondeamos al entero más cercano y + // clampeamos a 0 para que valores negativos no overflowen. + "policy_period" => { + params.policy_reeval_period = value.max(0.0).round() as u32; + } + other => anyhow::bail!( + "param desconocido `{other}`; opciones: psi_modulation, contagion_rate, \ + social_radius, homophily_threshold, policy_period" + ), + } + Ok(()) +} diff --git a/01_yachay/dominium/dominium-core/Cargo.toml b/01_yachay/dominium/dominium-core/Cargo.toml new file mode 100644 index 0000000..8d1e554 --- /dev/null +++ b/01_yachay/dominium/dominium-core/Cargo.toml @@ -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 } diff --git a/01_yachay/dominium/dominium-core/LEEME.md b/01_yachay/dominium/dominium-core/LEEME.md new file mode 100644 index 0000000..ae4b788 --- /dev/null +++ b/01_yachay/dominium/dominium-core/LEEME.md @@ -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` 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) diff --git a/01_yachay/dominium/dominium-core/README.md b/01_yachay/dominium/dominium-core/README.md new file mode 100644 index 0000000..b40a539 --- /dev/null +++ b/01_yachay/dominium/dominium-core/README.md @@ -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` 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) diff --git a/01_yachay/dominium/dominium-core/src/conceptos.rs b/01_yachay/dominium/dominium-core/src/conceptos.rs new file mode 100644 index 0000000..fae157f --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/conceptos.rs @@ -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, + /// 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, +} + +/// 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, +} + +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()); + } +} diff --git a/01_yachay/dominium/dominium-core/src/epoch.rs b/01_yachay/dominium/dominium-core/src/epoch.rs new file mode 100644 index 0000000..b07f8dc --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/epoch.rs @@ -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()); + } + } +} diff --git a/01_yachay/dominium/dominium-core/src/events.rs b/01_yachay/dominium/dominium-core/src/events.rs new file mode 100644 index 0000000..013f3b8 --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/events.rs @@ -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 = 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 = serde_json::from_str(&s).expect("deserializa"); + assert_eq!(events, back); + } +} diff --git a/01_yachay/dominium/dominium-core/src/grid.rs b/01_yachay/dominium/dominium-core/src/grid.rs new file mode 100644 index 0000000..59c1105 --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/grid.rs @@ -0,0 +1,89 @@ +//! El Sustrato Plano — grilla SoA de 5 capas de `f32`. + +use serde::{Deserialize, Serialize}; + +/// Grilla de campos: 5 capas paralelas, cada una `width × height` `f32`, +/// indexadas `y * width + x`. Toda la física opera sobre estos arrays +/// contiguos (cache-friendly). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Grid { + pub width: usize, + pub height: usize, + /// Biomasa / energía / alimento disponible. + pub materia: Vec, + /// Densidad de información / frecuencia dogmática. + pub psique: Vec, + /// Tensión de control / deuda / atractores del Estado Profundo. + pub poder: Vec, + /// Materia prima densa intercambiable. + pub oro: Vec, + /// Contaminación / cicatrices industriales del suelo. + pub degradacion: Vec, +} + +impl Grid { + /// Grilla de `width × height` con todas las capas en cero. + pub fn new(width: usize, height: usize) -> Self { + let n = width * height; + Self { + width, + height, + materia: vec![0.0; n], + psique: vec![0.0; n], + poder: vec![0.0; n], + oro: vec![0.0; n], + degradacion: vec![0.0; n], + } + } + + /// Cantidad de celdas (`width * height`). + pub fn cells(&self) -> usize { + self.width * self.height + } + + /// Índice plano de `(x, y)`. El caller garantiza bounds válidos. + pub fn idx(&self, x: usize, y: usize) -> usize { + y * self.width + x + } + + /// `true` si `(x, y)` cae dentro de la grilla. + pub fn in_bounds(&self, x: i64, y: i64) -> bool { + x >= 0 && y >= 0 && (x as usize) < self.width && (y as usize) < self.height + } + + /// Clampa una coordenada continua a una celda válida. + pub fn clamp_cell(&self, x: f32, y: f32) -> (usize, usize) { + let cx = (x.floor() as i64).clamp(0, self.width as i64 - 1) as usize; + let cy = (y.floor() as i64).clamp(0, self.height as i64 - 1) as usize; + (cx, cy) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_grid_is_zeroed() { + let g = Grid::new(8, 4); + assert_eq!(g.cells(), 32); + assert!(g.materia.iter().all(|&v| v == 0.0)); + assert_eq!(g.materia.len(), 32); + } + + #[test] + fn idx_and_bounds() { + let g = Grid::new(10, 5); + assert_eq!(g.idx(3, 2), 23); + assert!(g.in_bounds(9, 4)); + assert!(!g.in_bounds(10, 4)); + assert!(!g.in_bounds(-1, 0)); + } + + #[test] + fn clamp_cell_keeps_in_range() { + let g = Grid::new(10, 10); + assert_eq!(g.clamp_cell(-5.0, 3.7), (0, 3)); + assert_eq!(g.clamp_cell(99.0, 99.0), (9, 9)); + } +} diff --git a/01_yachay/dominium/dominium-core/src/lemmings.rs b/01_yachay/dominium/dominium-core/src/lemmings.rs new file mode 100644 index 0000000..748ab4c --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/lemmings.rs @@ -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` (`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, + pub pos_y: Vec, + /// Contador incremental de ticks de vida. + pub edad: Vec, + /// Escalar de salud; si llega a 0 el agente muere. + pub energia: Vec, + /// Tensores de sesgo interno `[Orden, Miedo, Curiosidad, Corruptibilidad]`. + pub vector_psi: Vec<[f32; 4]>, + /// Byte discriminador de la máquina de estados (0-5). + pub accion: Vec, + /// 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, + /// 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, +} + +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 { + 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 { + 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); + } +} diff --git a/01_yachay/dominium/dominium-core/src/lib.rs b/01_yachay/dominium/dominium-core/src/lib.rs new file mode 100644 index 0000000..359a4e7 --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/lib.rs @@ -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}; diff --git a/01_yachay/dominium/dominium-core/src/metrics.rs b/01_yachay/dominium/dominium-core/src/metrics.rs new file mode 100644 index 0000000..bccee04 --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/metrics.rs @@ -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` 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 = 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); + } +} diff --git a/01_yachay/dominium/dominium-core/src/params.rs b/01_yachay/dominium/dominium-core/src/params.rs new file mode 100644 index 0000000..7adf1c1 --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/params.rs @@ -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(), + } + } +} diff --git a/01_yachay/dominium/dominium-core/src/psi_metrics.rs b/01_yachay/dominium/dominium-core/src/psi_metrics.rs new file mode 100644 index 0000000..e7c5943 --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/psi_metrics.rs @@ -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, + /// 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 { + 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(); + } +} diff --git a/01_yachay/dominium/dominium-core/src/world.rs b/01_yachay/dominium/dominium-core/src/world.rs new file mode 100644 index 0000000..48cb024 --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/world.rs @@ -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 { + 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); + } +} diff --git a/01_yachay/dominium/dominium-core/src/worldgen.rs b/01_yachay/dominium/dominium-core/src/worldgen.rs new file mode 100644 index 0000000..985bc20 --- /dev/null +++ b/01_yachay/dominium/dominium-core/src/worldgen.rs @@ -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` 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 { + 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" + ); + } + } +} diff --git a/01_yachay/dominium/dominium-iso/Cargo.toml b/01_yachay/dominium/dominium-iso/Cargo.toml new file mode 100644 index 0000000..01d31b1 --- /dev/null +++ b/01_yachay/dominium/dominium-iso/Cargo.toml @@ -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 } diff --git a/01_yachay/dominium/dominium-iso/LEEME.md b/01_yachay/dominium/dominium-iso/LEEME.md new file mode 100644 index 0000000..813a747 --- /dev/null +++ b/01_yachay/dominium/dominium-iso/LEEME.md @@ -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 diff --git a/01_yachay/dominium/dominium-iso/README.md b/01_yachay/dominium/dominium-iso/README.md new file mode 100644 index 0000000..420174f --- /dev/null +++ b/01_yachay/dominium/dominium-iso/README.md @@ -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 diff --git a/01_yachay/dominium/dominium-iso/src/lib.rs b/01_yachay/dominium/dominium-iso/src/lib.rs new file mode 100644 index 0000000..ea2ebc5 --- /dev/null +++ b/01_yachay/dominium/dominium-iso/src/lib.rs @@ -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)); + } +} diff --git a/01_yachay/dominium/dominium-notebook-kernel/Cargo.toml b/01_yachay/dominium/dominium-notebook-kernel/Cargo.toml new file mode 100644 index 0000000..b067cd8 --- /dev/null +++ b/01_yachay/dominium/dominium-notebook-kernel/Cargo.toml @@ -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>; 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" diff --git a/01_yachay/dominium/dominium-notebook-kernel/LEEME.md b/01_yachay/dominium/dominium-notebook-kernel/LEEME.md new file mode 100644 index 0000000..add56c1 --- /dev/null +++ b/01_yachay/dominium/dominium-notebook-kernel/LEEME.md @@ -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) diff --git a/01_yachay/dominium/dominium-notebook-kernel/README.md b/01_yachay/dominium/dominium-notebook-kernel/README.md new file mode 100644 index 0000000..9c36343 --- /dev/null +++ b/01_yachay/dominium/dominium-notebook-kernel/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) diff --git a/01_yachay/dominium/dominium-notebook-kernel/examples/notebook_dominium_demo.rs b/01_yachay/dominium/dominium-notebook-kernel/examples/notebook_dominium_demo.rs new file mode 100644 index 0000000..75d4033 --- /dev/null +++ b/01_yachay/dominium/dominium-notebook-kernel/examples/notebook_dominium_demo.rs @@ -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::() + ); + } + + // 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(), + ) +} diff --git a/01_yachay/dominium/dominium-notebook-kernel/src/lib.rs b/01_yachay/dominium/dominium-notebook-kernel/src/lib.rs new file mode 100644 index 0000000..0dfee5b --- /dev/null +++ b/01_yachay/dominium/dominium-notebook-kernel/src/lib.rs @@ -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>`): 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, + pub params: SimParams, +} + +/// Kernel ECS dominium. El estado se comparte entre celdas vía +/// `Arc>` — los notebooks reactivos lo leen y escriben en +/// orden topológico garantizado por `pluma-notebook-exec`. +pub struct DominiumKernel { + state: Arc>, +} + +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> { + 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 { + 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>, +) -> Result { + 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>, +) -> Result { + let mut it = source.split_whitespace(); + let n: usize = parse_required(it.next(), "N")?; + let seed: u64 = it + .next() + .map(|s| { + s.parse::().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>, +) -> Result { + 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>, +) -> Result { + 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>, +) -> Result { + let mut s = lock(state)?; + let mut changed: Vec = 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>, +) -> Result { + let mut it = source.split_whitespace(); + let w_px: u32 = it + .next() + .map(|s| { + s.parse::().map_err(|_| { + KernelError::Runtime(format!("WIDTH inválido: '{s}'")) + }) + }) + .transpose()? + .unwrap_or(256); + let h_px: u32 = it + .next() + .map(|s| { + s.parse::().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::().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 { + 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 = 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 { + let mut out: Vec = 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) -> KernelOutput { + let mut rows: Vec> = 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::>() + .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) -> KernelOutput { + let s = msg.into(); + CellOutput { + stdout: s.clone(), + value: None, + payload: OutputPayload::Text(s), + } +} + +fn lock<'a>( + state: &'a Arc>, +) -> Result, KernelError> { + state + .lock() + .map_err(|_| KernelError::Runtime("kernel state envenenado".into())) +} + +fn parse_required( + raw: Option<&str>, + name: &str, +) -> Result { + let raw = raw.ok_or_else(|| KernelError::Runtime(format!("falta {name}")))?; + raw.parse::() + .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); + } +} diff --git a/01_yachay/dominium/dominium-physics/Cargo.toml b/01_yachay/dominium/dominium-physics/Cargo.toml new file mode 100644 index 0000000..2e1d801 --- /dev/null +++ b/01_yachay/dominium/dominium-physics/Cargo.toml @@ -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 } diff --git a/01_yachay/dominium/dominium-physics/LEEME.md b/01_yachay/dominium/dominium-physics/LEEME.md new file mode 100644 index 0000000..b4b335a --- /dev/null +++ b/01_yachay/dominium/dominium-physics/LEEME.md @@ -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` diff --git a/01_yachay/dominium/dominium-physics/README.md b/01_yachay/dominium/dominium-physics/README.md new file mode 100644 index 0000000..a4dd0e7 --- /dev/null +++ b/01_yachay/dominium/dominium-physics/README.md @@ -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` diff --git a/01_yachay/dominium/dominium-physics/src/conceptos.rs b/01_yachay/dominium/dominium-physics/src/conceptos.rs new file mode 100644 index 0000000..66792cf --- /dev/null +++ b/01_yachay/dominium/dominium-physics/src/conceptos.rs @@ -0,0 +1,392 @@ +//! Aplicación de Conceptos sobre la grilla y sobre los Lemmings. +//! +//! Dos pasos puros, sin estado interno, recorriendo la `Vec` 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"); + } +} diff --git a/01_yachay/dominium/dominium-physics/src/diffuse.rs b/01_yachay/dominium/dominium-physics/src/diffuse.rs new file mode 100644 index 0000000..2650b19 --- /dev/null +++ b/01_yachay/dominium/dominium-physics/src/diffuse.rs @@ -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); + } + } +} diff --git a/01_yachay/dominium/dominium-physics/src/lib.rs b/01_yachay/dominium/dominium-physics/src/lib.rs new file mode 100644 index 0000000..bf43f10 --- /dev/null +++ b/01_yachay/dominium/dominium-physics/src/lib.rs @@ -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}; diff --git a/01_yachay/dominium/dominium-physics/src/social.rs b/01_yachay/dominium/dominium-physics/src/social.rs new file mode 100644 index 0000000..8039dcd --- /dev/null +++ b/01_yachay/dominium/dominium-physics/src/social.rs @@ -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 = 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 = 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 = 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::() + / 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::() + / 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] + ); + } + } + } +} diff --git a/01_yachay/dominium/dominium-physics/src/spatial.rs b/01_yachay/dominium/dominium-physics/src/spatial.rs new file mode 100644 index 0000000..0f29a5a --- /dev/null +++ b/01_yachay/dominium/dominium-physics/src/spatial.rs @@ -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>, + 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![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) { + 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 = (0..10).map(|i| i as f32).collect(); + let ys: Vec = 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"); + } +} diff --git a/01_yachay/dominium/dominium-physics/src/tick.rs b/01_yachay/dominium/dominium-physics/src/tick.rs new file mode 100644 index 0000000..b08a868 --- /dev/null +++ b/01_yachay/dominium/dominium-physics/src/tick.rs @@ -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 = (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::() / 16.0; + let avg_b: f32 = b.grid.psique.iter().sum::() / 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); + } +} diff --git a/01_yachay/dominium/dominium-physics/tests/hypotheses.rs b/01_yachay/dominium/dominium-physics/tests/hypotheses.rs new file mode 100644 index 0000000..0014f2c --- /dev/null +++ b/01_yachay/dominium/dominium-physics/tests/hypotheses.rs @@ -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(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 +} diff --git a/01_yachay/dominium/dominium-render-plan/Cargo.toml b/01_yachay/dominium/dominium-render-plan/Cargo.toml new file mode 100644 index 0000000..80f406b --- /dev/null +++ b/01_yachay/dominium/dominium-render-plan/Cargo.toml @@ -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 } diff --git a/01_yachay/dominium/dominium-render-plan/LEEME.md b/01_yachay/dominium/dominium-render-plan/LEEME.md new file mode 100644 index 0000000..f53cb10 --- /dev/null +++ b/01_yachay/dominium/dominium-render-plan/LEEME.md @@ -0,0 +1,18 @@ +# dominium-render-plan + +> World → `Vec` 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 ordenada +``` + +## Deps + +- [`dominium-core`](../dominium-core/README.md), [`dominium-iso`](../dominium-iso/README.md) +- Cero deps gráficas diff --git a/01_yachay/dominium/dominium-render-plan/README.md b/01_yachay/dominium/dominium-render-plan/README.md new file mode 100644 index 0000000..4115f4d --- /dev/null +++ b/01_yachay/dominium/dominium-render-plan/README.md @@ -0,0 +1,18 @@ +# dominium-render-plan + +> World → painter-ordered `Vec` 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 +``` + +## Deps + +- [`dominium-core`](../dominium-core/README.md), [`dominium-iso`](../dominium-iso/README.md) +- Zero graphics deps diff --git a/01_yachay/dominium/dominium-render-plan/src/lib.rs b/01_yachay/dominium/dominium-render-plan/src/lib.rs new file mode 100644 index 0000000..7f0a5ba --- /dev/null +++ b/01_yachay/dominium/dominium-render-plan/src/lib.rs @@ -0,0 +1,1533 @@ +//! `dominium-render-plan` — la maqueta isométrica, agnóstica de backend. +//! +//! El último eslabón antes de la pantalla. Toma un [`World`] lógico, lo +//! proyecta con un [`IsoProjector`] y emite una lista plana de +//! [`Quad`]s 2D ya ordenados de atrás hacia adelante: cualquier backend +//! (GPUI, `` web, TUI) sólo tiene que pintarlos en orden. +//! +//! Aquí no hay `gpui`, ni `wgpu`, ni `f64`: sólo aritmética `f32` y +//! `dominium-iso`. La regla de la spec —cero dependencias gráficas en el +//! núcleo— se respeta hasta el penúltimo crate. +//! +//! ```text +//! World ──► build_plan(iso, weights, cfg) ──► RenderPlan { quads } +//! │ +//! backend.paint(quad) ◄─────┘ (en orden) +//! ``` +//! +//! - Una celda → un quad-rombo aproximado, coloreado por la mezcla de sus +//! 5 capas (la altura sale del `Z` compuesto, el color de la psique del +//! suelo). +//! - Un Lemming → un quad-marca posado sobre el relieve de su celda. +//! - Todo se ordena por `depth = x + y` (orden de pintor isométrico). + +#![forbid(unsafe_code)] + +use dominium_core::World; +use dominium_iso::{IsoProjector, ZWeights}; +use serde::{Deserialize, Serialize}; + +/// Color RGBA lineal, componentes en `0.0..=1.0`. +pub type Color = [f32; 4]; + +/// Un rectángulo 2D en coordenadas de pantalla, listo para pintar. El +/// origen `(0,0)` es el centro de la proyección; el backend traslada. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Quad { + /// Esquina superior-izquierda, eje X de pantalla. + pub x: f32, + /// Esquina superior-izquierda, eje Y de pantalla. + pub y: f32, + /// Ancho en pixels. + pub w: f32, + /// Alto en pixels. + pub h: f32, + /// Color RGBA. + pub color: Color, + /// Clave de orden de pintor: menor = más al fondo. El plan ya viene + /// ordenado, pero se conserva por si el backend reordena. + pub depth: f32, +} + +/// Un cuadrilátero arbitrario de 4 vértices en coordenadas de pantalla. +/// Lo usamos para las **caras laterales** del prisma isométrico de cada +/// celda — paralelogramos que no encajan en un `Quad` axis-aligned, y dan +/// la sensación de maqueta 3D / papel cortado. +/// +/// Vértices en orden anti-horario empezando por la esquina sup-izq, según +/// la convención del backend (BezPath cierra el path). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Polygon { + pub vertices: [(f32, f32); 4], + pub color: Color, + pub depth: f32, +} + +/// Paleta: un color por capa de la grilla, más el de los Lemmings. El +/// color de cada celda es la mezcla de estos pesada por el valor relativo +/// de cada capa. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Palette { + /// Color de una celda sin ningún campo (terreno desnudo). + pub floor: Color, + pub materia: Color, + pub psique: Color, + pub poder: Color, + pub oro: Color, + pub degradacion: Color, + /// Color de la marca de un Lemming. + pub lemming: Color, + /// Color del aura de influencia de un Concepto (translúcida). + pub concepto_aura: Color, + /// Color de la base de un Concepto (la "pared" de la mini-pirámide). + pub concepto_base: Color, + /// Color del tope de un Concepto (la "luz" de la mini-pirámide). + pub concepto: Color, + /// Color de sombra proyectada (RGBA con alpha bajo). + pub shadow: Color, +} + +impl Default for Palette { + /// Paleta "tablero psicológico": verde materia, azul psique, rojo + /// poder, ámbar oro, violeta degradación. + fn default() -> Self { + Self { + floor: [0.10, 0.11, 0.13, 1.0], + materia: [0.30, 0.72, 0.38, 1.0], + psique: [0.32, 0.55, 0.86, 1.0], + poder: [0.84, 0.27, 0.24, 1.0], + oro: [0.90, 0.74, 0.24, 1.0], + degradacion: [0.52, 0.30, 0.62, 1.0], + lemming: [0.96, 0.96, 0.98, 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], + } + } +} + +/// Una de las 5 capas del Sustrato, para selección de heatmap. Coincide +/// 1:1 con los índices `RELIEVE_*` del core. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RenderLayer { + Materia, + Psique, + Poder, + Oro, + Degradacion, +} + +impl RenderLayer { + /// Etiqueta corta para HUD/picker. + pub fn label(self) -> &'static str { + match self { + RenderLayer::Materia => "materia", + RenderLayer::Psique => "psique", + RenderLayer::Poder => "poder", + RenderLayer::Oro => "oro", + RenderLayer::Degradacion => "degrad.", + } + } + + /// Ciclado para pickers de UI: Materia → Psique → … → Materia. + pub fn next(self) -> RenderLayer { + match self { + RenderLayer::Materia => RenderLayer::Psique, + RenderLayer::Psique => RenderLayer::Poder, + RenderLayer::Poder => RenderLayer::Oro, + RenderLayer::Oro => RenderLayer::Degradacion, + RenderLayer::Degradacion => RenderLayer::Materia, + } + } + + /// Devuelve el valor de la capa en la celda `idx`. + pub fn value_at(self, world: &World, idx: usize) -> f32 { + let g = &world.grid; + match self { + RenderLayer::Materia => g.materia[idx], + RenderLayer::Psique => g.psique[idx], + RenderLayer::Poder => g.poder[idx], + RenderLayer::Oro => g.oro[idx], + RenderLayer::Degradacion => g.degradacion[idx], + } + } +} + +/// Cómo colorear las celdas del suelo. `Composite` mezcla las 5 capas +/// según la paleta (modo por defecto, lo que el simulador siempre fue). +/// `Heatmap(layer)` ignora las otras capas y pinta una sola en gradiente +/// `floor → palette[layer]` — útil para ver dónde se concentra una capa +/// específica sin que las otras la enmascaren. +/// +/// `PsiCluster` deja el suelo en `Composite` pero los lemmings se colorean +/// según la asignación k-means de `psi_metrics::kmeans_psi`. Los colores por +/// cluster los provee el caller vía [`build_plan_with_overrides`]; si se +/// usa `build_plan` (compat), `PsiCluster` se comporta como `Composite`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RenderMode { + Composite, + Heatmap(RenderLayer), + PsiCluster, +} + +impl Default for RenderMode { + fn default() -> Self { + RenderMode::Composite + } +} + +/// Ajustes de la maqueta: tamaños de quad y paleta. Lo que un panel +/// expondría como controles de presentación (no afectan la simulación). +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct PlanConfig { + /// Lado del quad de una celda, en pixels. + pub tile: f32, + /// Lado del quad-marca de un Lemming, en pixels. + pub lemming_size: f32, + /// Cuánto se eleva la marca del Lemming sobre el relieve de su celda, + /// en unidades de `Z`. + pub lemming_lift: f32, + /// Lado del quad-marca central de un Concepto, en pixels. + pub concepto_size: f32, + /// Cuánto se eleva la marca de un Concepto sobre el relieve, en `Z`. + pub concepto_lift: f32, + /// Vector en coordenadas de mundo `(dx, dy)` que indica **hacia dónde + /// cae la sombra** desde el pie de la entidad. Equivalente a la + /// dirección opuesta al sol. Default: hacia abajo-derecha (luz desde + /// arriba-izquierda, convención de maqueta clásica). + pub light_dir: (f32, f32), + /// Cantidad de capas adicionales que emite cada celda con relieve + /// significativo, estilo "estampa andina" (mapa topográfico de papel + /// cortado). Cada capa se apila a una fracción de `z` con un tile + /// progresivamente más chico y un tono ligeramente más oscuro. 0 = off. + pub andina_layers: u32, + /// Umbral mínimo de `z` para activar las capas concéntricas en una + /// celda — celdas planas no se descomponen. + pub andina_threshold: f32, + pub palette: Palette, + /// Modo de coloreo de las celdas. Default `Composite` = el render + /// histórico. `Heatmap(L)` aísla una capa. + #[serde(default)] + pub render_mode: RenderMode, + /// Si está activo, cada techo siembra micro-quads procedurales que + /// insinúan textura según la capa dominante: matorrales en celdas + /// fértiles, brillos en oro, grietas en degradación. PRNG determinista + /// por `(cx, cy)` así el patrón no titila entre frames. + #[serde(default)] + pub texture: bool, +} + +impl Default for PlanConfig { + fn default() -> Self { + Self { + tile: 18.0, + lemming_size: 9.0, + lemming_lift: 0.6, + concepto_size: 14.0, + concepto_lift: 1.4, + light_dir: (0.55, 0.35), + andina_layers: 0, + andina_threshold: 1.0, + palette: Palette::default(), + render_mode: RenderMode::Composite, + texture: false, + } + } +} + +/// Un carácter rasterizado por encima de los quads — usado por los +/// glifos de `sprite_id` de Conceptos. El backend lo pinta vía +/// `llimphi-text::draw_block` con tamaño + color del Glyph. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Glyph { + /// Carácter unicode a pintar. + pub ch: char, + /// Esquina sup-izq donde debería caer el bounding box del glifo. + /// El backend puede centrarlo si quiere. + pub x: f32, + pub y: f32, + pub size_px: f32, + pub color: Color, + /// Profundidad (informativa — los glifos se pintan después de los + /// quads, así que sirve para sub-orden entre glifos si fuera necesario). + pub depth: f32, +} + +/// Lista de quads ordenada de atrás hacia adelante + caja envolvente. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RenderPlan { + /// Quads ya ordenados por `depth` ascendente: píntalos en orden. + pub quads: Vec, + /// Polígonos arbitrarios (caras laterales 3D, sombras paralelogramo) + /// ordenados por `depth` ascendente. El backend los **intercala** con + /// los quads por depth para mantener el orden de pintor isométrico. + #[serde(default)] + pub polygons: Vec, + /// Glifos a pintar **después** de los quads, en orden de inserción. + /// El backend usa `llimphi-text` para rasterizarlos. Hoy sólo se usa + /// como fallback `?` para `sprite_id` desconocidos. + #[serde(default)] + pub glyphs: Vec, + /// Primitivas vectoriales de los sprites de Conceptos, a pintar + /// **después** de los quads/polígonos, en orden de inserción. El + /// backend las rasteriza con vello (relleno/trazo/disco). + #[serde(default)] + pub sprites: Vec, + /// Caja envolvente de todos los quads — el backend la usa para + /// centrar o escalar la vista. + pub min_x: f32, + pub min_y: f32, + pub max_x: f32, + pub max_y: f32, +} + +impl RenderPlan { + /// Ancho de la caja envolvente. + pub fn width(&self) -> f32 { + self.max_x - self.min_x + } + + /// Alto de la caja envolvente. + pub fn height(&self) -> f32 { + self.max_y - self.min_y + } +} + +/// Mapeo opaco `sprite_id → char`. El motor no le da semántica; sirve +/// para que el panel y el backend gráfico se pongan de acuerdo sobre +/// qué glifo pintar. `0` = sin glifo; `1..=8` definidos; el resto cae +/// a un `?` para feedback visual cuando hay un id desconocido. +pub fn glyph_for_sprite(id: u32) -> Option { + match id { + 0 => None, + 1 => Some('☩'), // cruz — iglesia + 2 => Some('¤'), // moneda — banco + 3 => Some('⌂'), // casa — comuna + 4 => Some('⚗'), // alambique — laboratorio + 5 => Some('☉'), // sol — centro + 6 => Some('☽'), // luna + 7 => Some('★'), // estrella + 8 => Some('◬'), // triángulo — chacana + _ => Some('?'), + } +} + +/// Cantidad de sprite_ids con sprite definido (excluye 0 y el fallback). +/// Útil para los pickers de UI que ciclan a través de las opciones. +pub const SPRITE_COUNT: u32 = 8; + +/// Nombre legible de un `sprite_id` — para los pickers del panel. `0` no +/// dibuja nada; `1..=8` son la librería; el resto es desconocido. +pub fn sprite_name(id: u32) -> &'static str { + match id { + 0 => "—", + 1 => "iglesia", + 2 => "banco", + 3 => "casa", + 4 => "laboratorio", + 5 => "sol", + 6 => "luna", + 7 => "estrella", + 8 => "chacana", + _ => "?", + } +} + +/// Una primitiva vectorial de un sprite procedural, ya resuelta a +/// coordenadas de pantalla. El backend la rasteriza con vello: +/// - `Fill` — polígono cerrado relleno (≥3 vértices). +/// - `Stroke` — polilínea (abierta) con grosor `width` px. +/// - `Disc` — disco relleno de centro `(cx, cy)` y radio `r`. +/// +/// Es el reemplazo "dibujo de verdad" de los glifos opacos: cada Concepto +/// emite un puñado de estas en vez de un solo carácter unicode. Cero +/// assets en disco, cero shaders — sólo geometría que vello rellena. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SpritePrim { + Fill { + points: Vec<(f32, f32)>, + color: Color, + }, + Stroke { + points: Vec<(f32, f32)>, + width: f32, + color: Color, + }, + Disc { + cx: f32, + cy: f32, + r: f32, + color: Color, + }, +} + +/// Tinta + acento + color de recorte de un sprite. `ink` pinta el detalle +/// oscuro (trazos, puertas); `accent` el relleno temático; `carve` es el +/// color de fondo con que se "talla" un hueco (lo usa la luna y el ojo de +/// la chacana, pasándoles el color del tope del Concepto). +struct SpriteInk { + ink: Color, + accent: Color, + carve: Color, +} + +/// Acento temático por `sprite_id`. La tinta es un gris-tinta común; el +/// acento le da identidad de color a cada icono (oro de iglesia, piedra de +/// banco, terracota de casa, líquido cian de laboratorio, etc.). +fn sprite_palette(id: u32, carve: Color) -> SpriteInk { + let ink = [0.07, 0.07, 0.10, 1.0]; + let accent = match id { + 1 => [0.86, 0.71, 0.33, 1.0], // iglesia — oro + 2 => [0.78, 0.78, 0.83, 1.0], // banco — piedra + 3 => [0.81, 0.46, 0.31, 1.0], // casa — terracota + 4 => [0.31, 0.71, 0.86, 1.0], // laboratorio — líquido cian + 5 => [0.98, 0.82, 0.26, 1.0], // sol — amarillo + 6 => [0.92, 0.92, 0.80, 1.0], // luna — marfil + 7 => [0.96, 0.81, 0.36, 1.0], // estrella — oro + 8 => [0.82, 0.36, 0.30, 1.0], // chacana — rojo andino + _ => [0.80, 0.80, 0.80, 1.0], + }; + SpriteInk { ink, accent, carve } +} + +/// Emite las primitivas del sprite `id` centradas en `(cx, cy)`, a tamaño +/// `size` (lado del icono en px), recortando huecos con `carve` (el color +/// del tope del Concepto). Devuelve vacío para `id == 0` o ids +/// desconocidos — esos caen al glifo `?` de feedback. Coordenadas de +/// pantalla, eje Y hacia abajo. Cada icono se autoría en el cuadrado +/// unitario `[-0.5, 0.5]²` y se escala/traslada por `size` y `(cx, cy)`. +pub fn sprite_prims(id: u32, cx: f32, cy: f32, size: f32, carve: Color) -> Vec { + let pal = sprite_palette(id, carve); + // Punto del cuadrado unitario → pantalla. + let p = |ux: f32, uy: f32| (cx + ux * size, cy + uy * size); + // Grosor de trazo base, proporcional al icono. + let sw = size * 0.085; + let mut v: Vec = Vec::new(); + + match id { + // 1 · IGLESIA — cuerpo + techo a dos aguas + cruz. + 1 => { + v.push(SpritePrim::Fill { + points: vec![p(-0.28, 0.5), p(-0.28, -0.05), p(0.28, -0.05), p(0.28, 0.5)], + color: pal.accent, + }); + v.push(SpritePrim::Fill { + points: vec![p(-0.38, -0.05), p(0.0, -0.32), p(0.38, -0.05)], + color: pal.ink, + }); + v.push(SpritePrim::Stroke { + points: vec![p(0.0, -0.32), p(0.0, -0.6)], + width: sw, + color: pal.ink, + }); + v.push(SpritePrim::Stroke { + points: vec![p(-0.11, -0.5), p(0.11, -0.5)], + width: sw, + color: pal.ink, + }); + } + // 2 · BANCO — frontón neoclásico + tres columnas + zócalo. + 2 => { + v.push(SpritePrim::Fill { + points: vec![p(-0.46, 0.02), p(0.0, -0.3), p(0.46, 0.02)], + color: pal.accent, + }); + for &cxn in &[-0.28_f32, 0.0, 0.28] { + v.push(SpritePrim::Stroke { + points: vec![p(cxn, 0.06), p(cxn, 0.42)], + width: sw * 1.3, + color: pal.accent, + }); + } + v.push(SpritePrim::Fill { + points: vec![p(-0.46, 0.42), p(0.46, 0.42), p(0.46, 0.52), p(-0.46, 0.52)], + color: pal.ink, + }); + } + // 3 · CASA / COMUNA — cuerpo + techo + puerta. + 3 => { + v.push(SpritePrim::Fill { + points: vec![p(-0.3, 0.5), p(-0.3, 0.02), p(0.3, 0.02), p(0.3, 0.5)], + color: pal.accent, + }); + v.push(SpritePrim::Fill { + points: vec![p(-0.42, 0.02), p(0.0, -0.34), p(0.42, 0.02)], + color: pal.ink, + }); + v.push(SpritePrim::Fill { + points: vec![p(-0.09, 0.5), p(-0.09, 0.2), p(0.09, 0.2), p(0.09, 0.5)], + color: pal.ink, + }); + } + // 4 · LABORATORIO — matraz: cuello + cuerpo cónico + burbuja. + 4 => { + v.push(SpritePrim::Fill { + points: vec![ + p(-0.1, -0.06), + p(0.1, -0.06), + p(0.32, 0.48), + p(-0.32, 0.48), + ], + color: pal.accent, + }); + v.push(SpritePrim::Stroke { + points: vec![p(-0.1, -0.42), p(-0.1, -0.06)], + width: sw, + color: pal.ink, + }); + v.push(SpritePrim::Stroke { + points: vec![p(0.1, -0.42), p(0.1, -0.06)], + width: sw, + color: pal.ink, + }); + v.push(SpritePrim::Stroke { + points: vec![p(-0.16, -0.42), p(0.16, -0.42)], + width: sw, + color: pal.ink, + }); + v.push(SpritePrim::Disc { + cx: p(0.05, 0.3).0, + cy: p(0.05, 0.3).1, + r: size * 0.07, + color: pal.carve, + }); + } + // 5 · SOL — disco central + ocho rayos. + 5 => { + let dirs: [(f32, f32); 8] = [ + (1.0, 0.0), + (0.707, -0.707), + (0.0, -1.0), + (-0.707, -0.707), + (-1.0, 0.0), + (-0.707, 0.707), + (0.0, 1.0), + (0.707, 0.707), + ]; + for (dx, dy) in dirs { + v.push(SpritePrim::Stroke { + points: vec![p(dx * 0.3, dy * 0.3), p(dx * 0.5, dy * 0.5)], + width: sw, + color: pal.accent, + }); + } + v.push(SpritePrim::Disc { + cx, + cy, + r: size * 0.22, + color: pal.accent, + }); + } + // 6 · LUNA — disco lleno menos un disco de recorte → creciente. + 6 => { + v.push(SpritePrim::Disc { + cx, + cy, + r: size * 0.34, + color: pal.accent, + }); + let (hx, hy) = p(0.18, -0.07); + v.push(SpritePrim::Disc { + cx: hx, + cy: hy, + r: size * 0.3, + color: pal.carve, + }); + } + // 7 · ESTRELLA — polígono de 5 puntas (10 vértices, radio alterno). + 7 => { + // Vértices unitarios ordenados por ángulo ascendente (alternan + // exterior/interior), precomputados para no usar trig en runtime. + const STAR: [(f32, f32, bool); 10] = [ + (0.951, 0.309, true), // 18° + (0.588, 0.809, false), // 54° + (0.0, 1.0, true), // 90° + (-0.588, 0.809, false), // 126° + (-0.951, 0.309, true), // 162° + (-0.951, -0.309, false),// 198° + (-0.588, -0.809, true), // 234° + (0.0, -1.0, false), // 270° + (0.588, -0.809, true), // 306° + (0.951, -0.309, false), // 342° + ]; + let (ro, ri) = (0.5_f32, 0.21_f32); + let pts = STAR + .iter() + .map(|&(ux, uy, outer)| { + let r = if outer { ro } else { ri }; + p(ux * r, uy * r) + }) + .collect(); + v.push(SpritePrim::Fill { + points: pts, + color: pal.accent, + }); + } + // 8 · CHACANA — cruz andina escalonada (cruz de 12 vértices) + ojo. + 8 => { + let a = 0.17_f32; // semiancho de brazo + let b = 0.5_f32; // extensión del brazo + v.push(SpritePrim::Fill { + points: vec![ + p(-a, -b), + p(a, -b), + p(a, -a), + p(b, -a), + p(b, a), + p(a, a), + p(a, b), + p(-a, b), + p(-a, a), + p(-b, a), + p(-b, -a), + p(-a, -a), + ], + color: pal.accent, + }); + v.push(SpritePrim::Disc { + cx, + cy, + r: size * 0.1, + color: pal.carve, + }); + } + _ => return Vec::new(), + } + v +} + +/// Mezcla `n` colores con pesos: `Σ wᵢ·colorᵢ / Σ wᵢ`. Alpha del primero. +fn blend(parts: &[(f32, Color)]) -> Color { + let total: f32 = parts.iter().map(|(w, _)| *w).sum(); + if total <= f32::EPSILON { + return [0.0, 0.0, 0.0, 1.0]; + } + let mut out = [0.0f32; 4]; + for (w, c) in parts { + let k = w / total; + for ch in 0..3 { + out[ch] += k * c[ch]; + } + } + out[3] = 1.0; + out +} + +/// Color de una celda: mezcla de la paleta pesada por el valor relativo +/// de sus 5 capas. Una celda vacía cae al color `floor`. +fn cell_color(world: &World, idx: usize, pal: &Palette) -> Color { + let g = &world.grid; + let layers = [ + (g.materia[idx].max(0.0), pal.materia), + (g.psique[idx].max(0.0), pal.psique), + (g.poder[idx].max(0.0), pal.poder), + (g.oro[idx].max(0.0), pal.oro), + (g.degradacion[idx].max(0.0), pal.degradacion), + ]; + let total: f32 = layers.iter().map(|(v, _)| *v).sum(); + if total <= f32::EPSILON { + return pal.floor; + } + blend(&layers) +} + +/// Oscurece un color por un factor multiplicativo (mantiene alpha). +/// Útil para sombrear caras laterales: el techo va en color base, las +/// caras visibles a la luz en factor 0.72, las en sombra en 0.55. +fn shade(c: Color, k: f32) -> Color { + [c[0] * k, c[1] * k, c[2] * k, c[3]] +} + +/// PRNG determinista a partir de `(cx, cy)`. Hash xorshift de 32 bits, +/// suficiente para sembrar texturas que no titilen entre frames. +fn cell_hash(cx: usize, cy: usize, salt: u32) -> u32 { + let mut h = (cx as u32) + .wrapping_mul(0x9E37_79B1) + .wrapping_add((cy as u32).wrapping_mul(0x85EB_CA6B)) + .wrapping_add(salt.wrapping_mul(0xC2B2_AE35)); + h ^= h >> 16; + h = h.wrapping_mul(0x7feb_352d); + h ^= h >> 15; + h = h.wrapping_mul(0x846c_a68b); + h ^= h >> 16; + h +} + +/// Float ∈ [0, 1) determinista para la celda. +fn cell_rand(cx: usize, cy: usize, salt: u32) -> f32 { + (cell_hash(cx, cy, salt) >> 8) as f32 / (1u32 << 24) as f32 +} + +/// Siembra micro-quads sobre el techo de una celda según la capa +/// dominante. Cada decoración cae dentro del rombo del techo — +/// aproximamos como un rect axis-aligned para mantener costo bajo: +/// son detalles pequeños donde el sesgo visual es despreciable. +/// +/// Sólo emite quads cuando la celda tiene "algo que mostrar" — si está +/// vacía o todas las capas son insignificantes, no agrega ruido. +fn add_texture( + out: &mut Vec, + world: &World, + idx: usize, + cx: usize, + cy: usize, + sx_center: f32, + sy_center: f32, + half_extent: f32, + depth: f32, + pal: &Palette, +) { + let g = &world.grid; + let m = g.materia[idx]; + let p = g.psique[idx]; + let pw = g.poder[idx]; + let o = g.oro[idx]; + let d = g.degradacion[idx]; + + // Layout: las decoraciones se ubican en posiciones pseudo-aleatorias + // dentro del rectángulo (sx ± half_extent, sy ± half_extent). + let put = |out: &mut Vec, salt: u32, size: f32, color: Color| { + let rx = cell_rand(cx, cy, salt) * 2.0 - 1.0; + let ry = cell_rand(cx, cy, salt + 1) * 2.0 - 1.0; + // Margen para que el dot no caiga pisando el borde. + let r = half_extent * 0.7; + out.push(Quad { + x: sx_center + rx * r - size * 0.5, + y: sy_center + ry * r - size * 0.5, + w: size, + h: size, + color, + depth: depth + 0.05, + }); + }; + + // Matorrales: 1 a 3 puntos verde oscuro según cuánta materia. + if m > 8.0 { + let n = if m > 50.0 { 3 } else if m > 20.0 { 2 } else { 1 }; + let dark_green = shade(pal.materia, 0.45); + for k in 0..n { + put(out, 11 + k as u32 * 7, half_extent * 0.18, dark_green); + } + } + // Brillo dorado: 1 mota pequeña amarilla cuando hay oro. + if o > 4.0 { + let glow = [ + (pal.oro[0] + 0.2).min(1.0), + (pal.oro[1] + 0.2).min(1.0), + (pal.oro[2] * 0.4).max(0.0), + 1.0, + ]; + put(out, 41, half_extent * 0.16, glow); + } + // Grietas: para degradacion alta, una mota violeta oscura. + if d > 0.5 { + let scar = shade(pal.degradacion, 0.55); + put(out, 57, half_extent * 0.22, scar); + if d > 2.0 { + put(out, 71, half_extent * 0.16, scar); + } + } + // Halo psíquico: cuando psique es muy alta, una mota azulada + // semitransparente — sugiere niebla espiritual. + if p > 8.0 { + let mist = [pal.psique[0], pal.psique[1], pal.psique[2], 0.55]; + put(out, 83, half_extent * 0.22, mist); + } + // Vena de poder: trazo rojizo cuando poder concentrado. + if pw > 6.0 { + let vein = shade(pal.poder, 0.85); + put(out, 97, half_extent * 0.14, vein); + } +} + +/// Color de una celda en modo Heatmap de una sola capa: gradiente +/// `pal.floor → color de capa` saturando a `1.0` cuando el valor llega al +/// rango de referencia. Las celdas vacías quedan `floor`; las saturadas +/// quedan exactamente el color de la capa. +/// +/// `scale` define qué valor cuenta como "saturado" — algo flexible porque +/// las capas viven en escalas distintas (materia llega a centenares, +/// degradacion suele ser sub-unidad). Default razonable para una grilla +/// 40×40 con SimParams default. +fn heatmap_color(world: &World, idx: usize, pal: &Palette, layer: RenderLayer) -> Color { + let v = layer.value_at(world, idx).max(0.0); + // Escala de referencia por capa: el orden de magnitud típico en una + // corrida normal. Saturación lineal. + let scale = match layer { + RenderLayer::Materia => 60.0, + RenderLayer::Psique => 20.0, + RenderLayer::Poder => 15.0, + RenderLayer::Oro => 25.0, + RenderLayer::Degradacion => 5.0, + }; + let t = (v / scale).clamp(0.0, 1.0); + let tip = match layer { + RenderLayer::Materia => pal.materia, + RenderLayer::Psique => pal.psique, + RenderLayer::Poder => pal.poder, + RenderLayer::Oro => pal.oro, + RenderLayer::Degradacion => pal.degradacion, + }; + let base = pal.floor; + [ + base[0] + (tip[0] - base[0]) * t, + base[1] + (tip[1] - base[1]) * t, + base[2] + (tip[2] - base[2]) * t, + 1.0, + ] +} + +/// Construye la maqueta isométrica de un `World`. +/// +/// Wrapper sobre [`build_plan_with_overrides`] que pinta todos los lemmings +/// con `cfg.palette.lemming`. Equivalente a la firma histórica del crate. +pub fn build_plan( + world: &World, + iso: &IsoProjector, + weights: &ZWeights, + cfg: &PlanConfig, +) -> RenderPlan { + let default_color = cfg.palette.lemming; + build_plan_with_overrides(world, iso, weights, cfg, |_| default_color) +} + +/// Versión de [`build_plan`] que permite teñir cada lemming individualmente +/// vía la closure `lemming_color`. Indispensable para `RenderMode::PsiCluster`: +/// el caller corre `kmeans_psi` y devuelve el color del cluster del lemming +/// `i`. Para el resto de modos, basta pasar `|_| cfg.palette.lemming`. +pub fn build_plan_with_overrides( + world: &World, + iso: &IsoProjector, + weights: &ZWeights, + cfg: &PlanConfig, + lemming_color: impl Fn(usize) -> Color, +) -> RenderPlan { + let g = &world.grid; + let mut quads: Vec = Vec::with_capacity(world.lemmings.len() * 2); + // Cada celda emite 1 techo + hasta 2 caras laterales (este, sur). + let mut polygons: Vec = Vec::with_capacity(g.cells() * 3); + let mut glyphs: Vec = Vec::with_capacity(world.conceptos.len()); + let mut sprites: Vec = Vec::new(); + + // Lookup de la altura de una celda — None para fuera de bounds. + let z_at = |cx: i64, cy: i64| -> Option { + if cx < 0 || cy < 0 || cx >= g.width as i64 || cy >= g.height as i64 { + None + } else { + Some(weights.z_of(g, g.idx(cx as usize, cy as usize))) + } + }; + + // Half-side de una celda en coordenadas de mundo. El tile (en pixels) + // queda aplicado por el `IsoProjector.scale` — acá pensamos en mundo. + let hs = 0.5_f32; + + // --- Celdas: techo (rombo iso) + caras laterales visibles (paralelogramos) --- + for cy in 0..g.height { + for cx in 0..g.width { + let idx = g.idx(cx, cy); + let z = weights.z_of(g, idx); + let color = match cfg.render_mode { + RenderMode::Composite | RenderMode::PsiCluster => { + cell_color(world, idx, &cfg.palette) + } + RenderMode::Heatmap(layer) => { + heatmap_color(world, idx, &cfg.palette, layer) + } + }; + let depth = cx as f32 + cy as f32; + let fx = cx as f32; + let fy = cy as f32; + + // 4 esquinas del techo proyectadas: NW, NE, SE, SW (sentido + // horario porque la proyección iso invierte el eje Y). + let p_nw = iso.project(fx - hs, fy - hs, z); + let p_ne = iso.project(fx + hs, fy - hs, z); + let p_se = iso.project(fx + hs, fy + hs, z); + let p_sw = iso.project(fx - hs, fy + hs, z); + + // Estampa andina: capas previas como rombos concéntricos. + if cfg.andina_layers > 0 && z > cfg.andina_threshold { + let n = cfg.andina_layers as f32; + for k in 0..cfg.andina_layers { + let frac = (k as f32) / n; + let z_k = z * frac; + let s = 1.0 - frac * 0.18; + let dark = 0.6 + frac * 0.35; + let color_k = shade(color, dark); + let p_nw_k = iso.project(fx - hs * s, fy - hs * s, z_k); + let p_ne_k = iso.project(fx + hs * s, fy - hs * s, z_k); + let p_se_k = iso.project(fx + hs * s, fy + hs * s, z_k); + let p_sw_k = iso.project(fx - hs * s, fy + hs * s, z_k); + polygons.push(Polygon { + vertices: [p_nw_k, p_ne_k, p_se_k, p_sw_k], + color: color_k, + depth: depth - 0.001 * (cfg.andina_layers - k) as f32, + }); + } + } + + // Techo (cima a `z`). + polygons.push(Polygon { + vertices: [p_nw, p_ne, p_se, p_sw], + color, + depth, + }); + + // Textura procedural: micro-quads sobre el techo según capa + // dominante. Determinista por (cx, cy). + if cfg.texture { + // Centro del techo y extensión ≈ media diagonal del rombo. + let sx_top = (p_nw.0 + p_se.0) * 0.5; + let sy_top = (p_nw.1 + p_se.1) * 0.5; + let dx = (p_ne.0 - p_nw.0).abs(); + let dy = (p_se.1 - p_ne.1).abs(); + let extent = (dx + dy) * 0.25; + add_texture( + &mut quads, + world, + idx, + cx, + cy, + sx_top, + sy_top, + extent, + depth, + &cfg.palette, + ); + } + + // Caras laterales — sólo si hay borde "abierto": la vecina no + // existe o está más abajo. Sino, su propio techo ya oculta + // esta pared y emitirla sería trabajo perdido. + // + // Las caras bajan hasta la altura de la vecina (no hasta 0), + // así celdas escalonadas se ven como escalones reales. Si no + // hay vecina (borde del grid), bajan hasta 0. + let east_z = z_at(cx as i64 + 1, cy as i64).unwrap_or(0.0); + if east_z < z { + let p_ne_b = iso.project(fx + hs, fy - hs, east_z); + let p_se_b = iso.project(fx + hs, fy + hs, east_z); + polygons.push(Polygon { + // Orden cerrado: arriba-norte, arriba-sur, abajo-sur, abajo-norte. + vertices: [p_ne, p_se, p_se_b, p_ne_b], + color: shade(color, 0.72), + depth: depth + 0.30, + }); + } + let south_z = z_at(cx as i64, cy as i64 + 1).unwrap_or(0.0); + if south_z < z { + let p_se_b = iso.project(fx + hs, fy + hs, south_z); + let p_sw_b = iso.project(fx - hs, fy + hs, south_z); + polygons.push(Polygon { + // Arriba-este, abajo-este, abajo-oeste, arriba-oeste. + vertices: [p_se, p_se_b, p_sw_b, p_sw], + color: shade(color, 0.55), + depth: depth + 0.40, + }); + } + } + } + + // --- Conceptos: aura + sombra proyectada + base + tope --- + // Cuatro quads cuentan una mini-estructura volumétrica: + // 1) aura: halo translúcido en el suelo (depth -0.5) + // 2) sombra: rect oscuro al pie de la luz (depth -0.4, antes de cells) + // 3) base: cuadro ancho al ras del relieve (depth +0.5, "pared") + // 4) tope: cuadro chico elevado por `concepto_lift` (depth +0.75) + for c in &world.conceptos.items { + let (cx, cy) = g.clamp_cell(c.pos_x, c.pos_y); + let z_floor = weights.z_of(g, g.idx(cx, cy)); + + // Aura al ras del suelo. + let (ax, ay) = iso.project(c.pos_x, c.pos_y, 0.0); + let aura = c.radius * 2.0 * cfg.tile; + quads.push(Quad { + x: ax - aura * 0.5, + y: ay - aura * 0.5, + w: aura, + h: aura, + color: cfg.palette.concepto_aura, + depth: c.pos_x + c.pos_y - 0.5, + }); + + // Sombra proyectada en la dirección opuesta a la luz, largo + // proporcional a la altura del tope. + let z_top = z_floor + cfg.concepto_lift; + let (sx, sy) = iso.shadow(c.pos_x, c.pos_y, z_top, cfg.light_dir); + quads.push(Quad { + x: sx - cfg.concepto_size * 0.7, + y: sy - cfg.concepto_size * 0.35, + w: cfg.concepto_size * 1.4, + h: cfg.concepto_size * 0.7, + color: cfg.palette.shadow, + depth: c.pos_x + c.pos_y - 0.4, + }); + + // Base apoyada en el relieve — más ancha y oscura: la "pared". + let (bx, by) = iso.project(c.pos_x, c.pos_y, z_floor); + let base_size = cfg.concepto_size * 1.35; + quads.push(Quad { + x: bx - base_size * 0.5, + y: by - base_size * 0.5, + w: base_size, + h: base_size, + color: cfg.palette.concepto_base, + depth: c.pos_x + c.pos_y + 0.5, + }); + + // Tope elevado — más chico y brillante: la "luz". + let (tx, ty) = iso.project(c.pos_x, c.pos_y, z_top); + quads.push(Quad { + x: tx - cfg.concepto_size * 0.5, + y: ty - cfg.concepto_size * 0.5, + w: cfg.concepto_size, + h: cfg.concepto_size, + color: cfg.palette.concepto, + depth: c.pos_x + c.pos_y + 0.75, + }); + + // Sprite vectorial del sprite_id, posado sobre el tope. La librería + // (`sprite_prims`) cubre 1..=8 con iconos reales; `0` no dibuja + // nada; un id desconocido cae al glifo `?` para feedback visual. + let sprite_size = cfg.concepto_size * 1.7; + let prims = sprite_prims(c.sprite_id, tx, ty - sprite_size * 0.08, sprite_size, cfg.palette.concepto); + if prims.is_empty() { + if c.sprite_id != 0 { + let glyph_size = cfg.concepto_size * 1.15; + glyphs.push(Glyph { + ch: '?', + // Aproximamos el centrado: parley pinta desde la esquina sup-izq. + x: tx - glyph_size * 0.4, + y: ty - glyph_size * 0.6, + size_px: glyph_size, + color: [0.05, 0.05, 0.08, 1.0], + depth: c.pos_x + c.pos_y + 0.85, + }); + } + } else { + sprites.extend(prims); + } + } + + // --- Lemmings: sombra al ras + marca posada sobre el relieve --- + let lem = &world.lemmings; + for i in 0..lem.len() { + let (px, py) = (lem.pos_x[i], lem.pos_y[i]); + let (cx, cy) = g.clamp_cell(px, py); + let z = weights.z_of(g, g.idx(cx, cy)) + cfg.lemming_lift; + + // Sombra proyectada — pequeña, plana, al suelo de su celda. + let (sx, sy) = iso.shadow(px, py, z, cfg.light_dir); + quads.push(Quad { + x: sx - cfg.lemming_size * 0.45, + y: sy - cfg.lemming_size * 0.25, + w: cfg.lemming_size * 0.9, + h: cfg.lemming_size * 0.5, + color: cfg.palette.shadow, + depth: px + py + 0.3, + }); + + // Marca del lemming — color por override (PsiCluster pinta por + // cluster k-means; los demás modos pasan `cfg.palette.lemming`). + let (mx, my) = iso.project(px, py, z); + quads.push(Quad { + x: mx - cfg.lemming_size * 0.5, + y: my - cfg.lemming_size * 0.5, + w: cfg.lemming_size, + h: cfg.lemming_size, + color: lemming_color(i), + // +0.5 → la marca se pinta después de su celda y de las + // celdas con su misma diagonal. + depth: px + py + 0.5, + }); + } + + // --- Orden de pintor: atrás (depth bajo) primero --- + quads.sort_by(|a, b| { + a.depth.partial_cmp(&b.depth).unwrap_or(core::cmp::Ordering::Equal) + }); + polygons.sort_by(|a, b| { + a.depth.partial_cmp(&b.depth).unwrap_or(core::cmp::Ordering::Equal) + }); + + // --- Caja envolvente: cubre quads + polygons + glifos --- + let mut plan = RenderPlan { quads, polygons, glyphs, sprites, ..Default::default() }; + let mut have_bounds = false; + let bump = |plan: &mut RenderPlan, have: &mut bool, x: f32, y: f32, w: f32, h: f32| { + if !*have { + plan.min_x = x; + plan.min_y = y; + plan.max_x = x + w; + plan.max_y = y + h; + *have = true; + } else { + plan.min_x = plan.min_x.min(x); + plan.min_y = plan.min_y.min(y); + plan.max_x = plan.max_x.max(x + w); + plan.max_y = plan.max_y.max(y + h); + } + }; + // Snapshot las refs en variables locales para no chocar con el mut borrow. + let q_iter: Vec<(f32, f32, f32, f32)> = plan + .quads + .iter() + .map(|q| (q.x, q.y, q.w, q.h)) + .collect(); + for (x, y, w, h) in q_iter { + bump(&mut plan, &mut have_bounds, x, y, w, h); + } + let pg_iter: Vec<[(f32, f32); 4]> = plan.polygons.iter().map(|p| p.vertices).collect(); + for v in pg_iter { + for (vx, vy) in v { + bump(&mut plan, &mut have_bounds, vx, vy, 0.0, 0.0); + } + } + plan +} + +#[cfg(test)] +mod tests { + use super::*; + + fn iso() -> IsoProjector { + IsoProjector::new(1.0, 10.0) + } + + #[test] + fn empty_world_yields_one_top_polygon_per_cell() { + // Plano (z=0 en todos lados) → solo techos, sin caras laterales. + let world = World::new(5, 4); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + assert_eq!(plan.polygons.len(), 20); + // Sin lemmings ni conceptos → sin quads. + assert_eq!(plan.quads.len(), 0); + } + + #[test] + fn each_lemming_adds_two_quads_shadow_and_marker() { + let mut world = World::new(8, 8); + world.lemmings.spawn(2.0, 3.0, 50.0, [1.0, 0.0, 0.0, 0.0]); + world.lemmings.spawn(5.0, 5.0, 50.0, [0.0, 1.0, 0.0, 0.0]); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + // 2 lemmings × 2 quads (sombra + marca) — las celdas ahora son polygons. + assert_eq!(plan.quads.len(), 4); + assert_eq!(plan.polygons.len(), 64); + } + + #[test] + fn polygons_are_depth_sorted_back_to_front() { + let mut world = World::new(6, 6); + world.lemmings.spawn(3.0, 3.0, 50.0, [0.0; 4]); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + for w in plan.polygons.windows(2) { + assert!(w[0].depth <= w[1].depth, "techos van de atrás hacia adelante"); + } + for w in plan.quads.windows(2) { + assert!(w[0].depth <= w[1].depth); + } + } + + #[test] + fn lemming_draws_after_its_cell() { + // Lemming en la celda (2,2): su marca (depth 4.5) debe ir tras la + // celda (2,2) (depth 4.0). + let mut world = World::new(6, 6); + world.lemmings.spawn(2.0, 2.0, 50.0, [0.0; 4]); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + let cfg = PlanConfig::default(); + let marca = plan + .quads + .iter() + .find(|q| q.w == cfg.lemming_size) + .expect("hay una marca"); + assert_eq!(marca.depth, 4.5); + } + + #[test] + fn empty_cell_uses_floor_color() { + let world = World::new(3, 3); + let cfg = PlanConfig::default(); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &cfg); + // El primer polygon es el techo de la celda más al fondo: floor. + assert_eq!(plan.polygons[0].color, cfg.palette.floor); + } + + #[test] + fn high_materia_cell_leans_green() { + // Una celda con materia → su techo en color `materia`; las otras + // celdas vacías van en `floor`. (La celda alta también emite caras + // laterales sombreadas — las filtramos por color exacto.) + let mut world = World::new(3, 3); + let idx = world.grid.idx(1, 1); + world.grid.materia[idx] = 100.0; + let cfg = PlanConfig::default(); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &cfg); + let painted: Vec<_> = plan + .polygons + .iter() + .filter(|p| p.color == cfg.palette.materia) + .collect(); + assert_eq!(painted.len(), 1, "un solo techo con color materia"); + } + + #[test] + fn cell_color_blends_two_layers() { + let mut world = World::new(3, 3); + let idx = world.grid.idx(0, 0); + world.grid.materia[idx] = 50.0; + world.grid.poder[idx] = 50.0; + let pal = Palette::default(); + let c = cell_color(&world, idx, &pal); + // Mezcla 50/50 de verde materia y rojo poder → canal por canal. + for ch in 0..3 { + let expected = 0.5 * pal.materia[ch] + 0.5 * pal.poder[ch]; + assert!((c[ch] - expected).abs() < 1e-5); + } + } + + #[test] + fn bounding_box_encloses_every_quad_and_polygon() { + let mut world = World::new(7, 5); + world.lemmings.spawn(3.0, 2.0, 50.0, [0.0; 4]); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + for q in &plan.quads { + assert!(q.x >= plan.min_x - 1e-3); + assert!(q.y >= plan.min_y - 1e-3); + assert!(q.x + q.w <= plan.max_x + 1e-3); + assert!(q.y + q.h <= plan.max_y + 1e-3); + } + for p in &plan.polygons { + for (vx, vy) in p.vertices { + assert!(vx >= plan.min_x - 1e-3); + assert!(vy >= plan.min_y - 1e-3); + assert!(vx <= plan.max_x + 1e-3); + assert!(vy <= plan.max_y + 1e-3); + } + } + assert!(plan.width() > 0.0 && plan.height() > 0.0); + } + + #[test] + fn heatmap_isolates_one_layer() { + let mut world = World::new(3, 3); + let i_mat = world.grid.idx(0, 0); + let i_pow = world.grid.idx(2, 0); + world.grid.materia[i_mat] = 60.0; + world.grid.poder[i_pow] = 15.0; + let mut cfg = PlanConfig::default(); + cfg.render_mode = RenderMode::Heatmap(RenderLayer::Materia); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &cfg); + // El techo de (0,0) (depth 0) y (2,0) (depth 2) son los que + // queremos. En heatmap(materia), (0,0) llega a saturación + // (color materia) y (2,0) queda en floor porque no tiene materia. + let p_mat = plan + .polygons + .iter() + .find(|p| p.depth == 0.0) + .expect("techo (0,0)"); + let p_pow = plan + .polygons + .iter() + .find(|p| p.depth == 2.0) + .expect("techo (2,0)"); + assert_eq!(p_mat.color, cfg.palette.materia); + assert_eq!(p_pow.color, cfg.palette.floor); + } + + #[test] + fn plan_is_deterministic() { + let mut world = World::new(10, 10); + world.lemmings.spawn(4.0, 6.0, 50.0, [0.5, 0.2, 0.1, 0.7]); + let idx = world.grid.idx(2, 2); + world.grid.materia[idx] = 33.0; + let a = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + let b = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + assert_eq!(a.quads, b.quads); + assert_eq!(a.polygons, b.polygons); + } + + #[test] + fn each_concepto_adds_four_quads_aura_shadow_base_top() { + use dominium_core::{Concepto, LayerMods}; + let mut world = World::new(8, 8); + world.conceptos.add(Concepto { + id: "iglesia".into(), + sprite_id: 0, + pos_x: 4.0, + pos_y: 4.0, + radius: 2.0, + mods: LayerMods::default(), + hack: None, + persuasion: None, + }); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + // 4 quads del concepto (aura + sombra + base + tope). Las celdas + // viven en polygons ahora. + assert_eq!(plan.quads.len(), 4); + assert_eq!(plan.polygons.len(), 64); + } + + #[test] + fn concepto_top_paints_after_its_lemming_neighbors() { + use dominium_core::{Concepto, LayerMods}; + let mut world = World::new(8, 8); + // Lemming en (4,4), concepto también en (4,4): el tope del concepto + // (depth 8.75) debe ir tras la marca del lemming (depth 8.5). + world.lemmings.spawn(4.0, 4.0, 50.0, [0.0; 4]); + world.conceptos.add(Concepto { + id: "iglesia".into(), + sprite_id: 0, + pos_x: 4.0, + pos_y: 4.0, + radius: 1.5, + mods: LayerMods::default(), + hack: None, + persuasion: None, + }); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + let cfg = PlanConfig::default(); + let lemming_marker_depth = plan + .quads + .iter() + .find(|q| q.w == cfg.lemming_size && q.color == cfg.palette.lemming) + .expect("hay un lemming") + .depth; + let concepto_top_depth = plan + .quads + .iter() + .find(|q| q.w == cfg.concepto_size && q.color == cfg.palette.concepto) + .expect("hay un tope de concepto") + .depth; + assert!(concepto_top_depth > lemming_marker_depth); + } + + #[test] + fn shadow_falls_along_light_dir_world_x() { + use dominium_core::{Concepto, LayerMods}; + // light_dir = (1, 0) → la sombra cae +x en mundo → en pantalla iso + // x' = (x - y)*cos30 crece. La sombra queda a la derecha del tope. + let mut world = World::new(8, 8); + world.conceptos.add(Concepto { + id: "torre".into(), + sprite_id: 0, + pos_x: 4.0, + pos_y: 4.0, + radius: 1.0, + mods: LayerMods::default(), + hack: None, + persuasion: None, + }); + let cfg = PlanConfig { light_dir: (1.0, 0.0), ..Default::default() }; + let plan = build_plan(&world, &iso(), &ZWeights::default(), &cfg); + let shadow = plan + .quads + .iter() + .find(|q| q.color == cfg.palette.shadow) + .expect("hay una sombra del concepto"); + let top = plan + .quads + .iter() + .find(|q| q.w == cfg.concepto_size && q.color == cfg.palette.concepto) + .expect("hay un tope"); + let shadow_cx = shadow.x + shadow.w * 0.5; + let top_cx = top.x + top.w * 0.5; + assert!(shadow_cx > top_cx, "centro de sombra debe quedar a la derecha del tope"); + } + + #[test] + fn andina_disabled_keeps_one_top_per_cell() { + // Con andina_layers = 0, una celda alta emite techo + caras + // laterales hacia las vecinas bajas — pero ningún rombo extra + // andino. + let mut world = World::new(3, 3); + let center = world.grid.idx(1, 1); + world.grid.materia[center] = 100.0; + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + // 9 techos + caras laterales (la celda alta emite 2 caras: este + // hacia (2,1) y sur hacia (1,2), ambas tienen z=0). + let tops = plan + .polygons + .iter() + .filter(|p| p.depth.fract() == 0.0) + .count(); + assert_eq!(tops, 9); + } + + #[test] + fn andina_enabled_stacks_extra_layers_on_high_relief() { + let mut world = World::new(3, 3); + let center = world.grid.idx(1, 1); + world.grid.materia[center] = 100.0; // z = 100 >> threshold 1.0 + let cfg = PlanConfig { + andina_layers: 3, + andina_threshold: 1.0, + ..Default::default() + }; + let plan = build_plan(&world, &iso(), &ZWeights::default(), &cfg); + // Las 3 capas andinas tienen depths estrictamente menores que el + // depth del techo (2.0), pero mayores que 1.99 (micro-shift -0.001). + let andina = plan + .polygons + .iter() + .filter(|p| p.depth > 1.99 && p.depth < 2.0) + .count(); + assert_eq!(andina, 3, "tres capas andinas extras en la celda alta"); + } + + #[test] + fn andina_skips_flat_cells_below_threshold() { + let world = World::new(4, 4); // todas las celdas en z = 0 + let cfg = PlanConfig { + andina_layers: 3, + andina_threshold: 1.0, + ..Default::default() + }; + let plan = build_plan(&world, &iso(), &ZWeights::default(), &cfg); + // 16 techos + 0 capas andinas + 0 caras laterales (todo plano). + assert_eq!(plan.polygons.len(), 16); + } + + #[test] + fn z_weights_raise_the_terrain() { + // Con materia alta y peso de relieve, el techo de la celda sube. + let mut world = World::new(3, 3); + let idx = world.grid.idx(1, 1); + world.grid.materia[idx] = 50.0; + let flat = build_plan( + &world, + &iso(), + &ZWeights { materia: 0.0, ..ZWeights::default() }, + &PlanConfig::default(), + ); + let raised = build_plan( + &world, + &iso(), + &ZWeights { materia: 1.0, ..ZWeights::default() }, + &PlanConfig::default(), + ); + let cfg = PlanConfig::default(); + // El techo coloreado `materia` es único: comparo su Y promedio. + let pick = |p: &RenderPlan| { + let top = p + .polygons + .iter() + .find(|pg| pg.color == cfg.palette.materia) + .unwrap(); + (top.vertices[0].1 + + top.vertices[1].1 + + top.vertices[2].1 + + top.vertices[3].1) + / 4.0 + }; + assert!(pick(&raised) < pick(&flat), "el relieve sube el techo"); + } + + #[test] + fn side_face_emitted_when_neighbor_is_lower() { + // Pico aislado en (1,1) → emite 2 caras laterales (este+sur) + // hacia las celdas (2,1) y (1,2) que están en z=0. + let mut world = World::new(3, 3); + let idx = world.grid.idx(1, 1); + world.grid.materia[idx] = 50.0; + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + let cfg = PlanConfig::default(); + // Caras: color shade(materia, 0.72) y shade(materia, 0.55). + let east_color = shade(cfg.palette.materia, 0.72); + let south_color = shade(cfg.palette.materia, 0.55); + let east_count = plan + .polygons + .iter() + .filter(|p| p.color == east_color) + .count(); + let south_count = plan + .polygons + .iter() + .filter(|p| p.color == south_color) + .count(); + assert_eq!(east_count, 1, "una cara este"); + assert_eq!(south_count, 1, "una cara sur"); + } + + #[test] + fn side_face_skipped_when_neighbor_is_same_or_higher() { + // Todas las celdas planas (z=0): ninguna emite caras. + let world = World::new(4, 4); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + // Solo techos. + for p in &plan.polygons { + assert_eq!(p.depth.fract(), 0.0, "polygon no-techo emitido en plano"); + } + } + + #[test] + fn sprite_library_covers_one_to_eight() { + // Cada id 1..=8 produce ≥1 primitiva; 0 y un desconocido, ninguna. + for id in 1..=SPRITE_COUNT { + assert!( + !sprite_prims(id, 0.0, 0.0, 10.0, [1.0; 4]).is_empty(), + "sprite {id} debería emitir primitivas" + ); + } + assert!(sprite_prims(0, 0.0, 0.0, 10.0, [1.0; 4]).is_empty()); + assert!(sprite_prims(99, 0.0, 0.0, 10.0, [1.0; 4]).is_empty()); + } + + #[test] + fn known_sprite_emits_vectors_not_glyph() { + use dominium_core::{Concepto, LayerMods}; + let mut world = World::new(8, 8); + world.conceptos.add(Concepto { + id: "iglesia".into(), + sprite_id: 1, // iglesia → librería vectorial + pos_x: 4.0, + pos_y: 4.0, + radius: 1.5, + mods: LayerMods::default(), + hack: None, + persuasion: None, + }); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + assert!(!plan.sprites.is_empty(), "el concepto emite sprites"); + assert!(plan.glyphs.is_empty(), "ya no se usa glifo para ids conocidos"); + } + + #[test] + fn unknown_sprite_falls_back_to_question_glyph() { + use dominium_core::{Concepto, LayerMods}; + let mut world = World::new(8, 8); + world.conceptos.add(Concepto { + id: "raro".into(), + sprite_id: 99, // desconocido → fallback '?' + pos_x: 4.0, + pos_y: 4.0, + radius: 1.5, + mods: LayerMods::default(), + hack: None, + persuasion: None, + }); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + assert!(plan.sprites.is_empty()); + assert_eq!(plan.glyphs.len(), 1); + assert_eq!(plan.glyphs[0].ch, '?'); + } + + #[test] + fn sprite_id_zero_draws_nothing() { + use dominium_core::{Concepto, LayerMods}; + let mut world = World::new(8, 8); + world.conceptos.add(Concepto { + id: "mudo".into(), + sprite_id: 0, + pos_x: 4.0, + pos_y: 4.0, + radius: 1.5, + mods: LayerMods::default(), + hack: None, + persuasion: None, + }); + let plan = build_plan(&world, &iso(), &ZWeights::default(), &PlanConfig::default()); + assert!(plan.sprites.is_empty() && plan.glyphs.is_empty()); + } +} diff --git a/01_yachay/dominium/dominium-sim/Cargo.toml b/01_yachay/dominium/dominium-sim/Cargo.toml new file mode 100644 index 0000000..8df83e2 --- /dev/null +++ b/01_yachay/dominium/dominium-sim/Cargo.toml @@ -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" } diff --git a/01_yachay/dominium/dominium-sim/src/lib.rs b/01_yachay/dominium/dominium-sim/src/lib.rs new file mode 100644 index 0000000..c1eccf4 --- /dev/null +++ b/01_yachay/dominium/dominium-sim/src/lib.rs @@ -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 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, + /// 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>, + /// Asignación k-means → cluster por lemming (modo PsiCluster). + pub cluster_assignments: Vec, + /// 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)); + } +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4731401 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5289 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e98018dbef3583d751dbb96e07b8728fb99581360e1c3df408af16f4a80b821" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02ecb52198c7cf5f8d3e9ffc03d2ca0a5c7201926befd96721437829da4c5c6a" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17cb8b66cef272d48161b02a6317cc2bdd5f98bb0a5e79c68f704a5862aa396b" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5376ba4cc23312587634abb5250b1ce8618f01a55915608209aafd01efb4bf8c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe5862066316f6caaf02cd3aecd54bced25503ac5dbbfd0d03a42bc1246217" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.0", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "app-bus" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "directories", + "serde", + "toml", +] + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dominium-app-llimphi" +version = "0.1.0" +dependencies = [ + "app-bus", + "directories", + "dominium-canvas-llimphi", + "dominium-core", + "dominium-iso", + "dominium-render-plan", + "dominium-sim", + "llimphi-clipboard", + "llimphi-motion", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-context-menu", + "llimphi-widget-edit-menu", + "llimphi-widget-menubar", + "llimphi-widget-slider", + "llimphi-widget-text-input", + "png 0.18.1", + "pollster", + "rimay-localize", + "serde", + "serde_json", + "wawa-config", + "wawa-config-llimphi", +] + +[[package]] +name = "dominium-canvas-llimphi" +version = "0.1.0" +dependencies = [ + "dominium-core", + "dominium-iso", + "dominium-render-plan", + "llimphi-ui", +] + +[[package]] +name = "dominium-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "dominium-core", + "dominium-physics", + "serde", + "serde_json", +] + +[[package]] +name = "dominium-core" +version = "0.1.0" +dependencies = [ + "libm", + "serde", + "serde_json", +] + +[[package]] +name = "dominium-iso" +version = "0.1.0" +dependencies = [ + "dominium-core", + "libm", + "serde", +] + +[[package]] +name = "dominium-notebook-kernel" +version = "0.1.0" +dependencies = [ + "async-trait", + "dominium-core", + "dominium-physics", + "pluma-notebook-core", + "pluma-notebook-exec", + "png 0.18.1", + "tokio", +] + +[[package]] +name = "dominium-physics" +version = "0.1.0" +dependencies = [ + "dominium-core", + "libm", +] + +[[package]] +name = "dominium-render-plan" +version = "0.1.0" +dependencies = [ + "dominium-core", + "dominium-iso", + "serde", +] + +[[package]] +name = "dominium-sim" +version = "0.1.0" +dependencies = [ + "dominium-core", + "dominium-physics", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff3336bc0b87fe42305047263fa60d2eabd650d29cbe62fdeb2a66c7a0a595f9" +dependencies = [ + "bytemuck", + "hashbrown 0.15.5", + "icu_locale_core", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "read-fonts 0.35.0", + "roxmltree", + "smallvec", + "windows 0.58.0", + "windows-core 0.58.0", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "format" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "blake3", + "postcard", + "serde", + "serde-big-array", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.13.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "llimphi-clipboard" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "arboard", + "llimphi-widget-text-editor", +] + +[[package]] +name = "llimphi-compositor" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-layout", + "llimphi-text", + "vello", + "wgpu", +] + +[[package]] +name = "llimphi-hal" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "pollster", + "raw-window-handle", + "wgpu", + "winit", +] + +[[package]] +name = "llimphi-layout" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "taffy", +] + +[[package]] +name = "llimphi-motion" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-raster" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-hal", + "pollster", + "vello", +] + +[[package]] +name = "llimphi-text" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "parley", + "vello", +] + +[[package]] +name = "llimphi-theme" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-raster", +] + +[[package]] +name = "llimphi-ui" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "accesskit", + "accesskit_winit", + "arboard", + "llimphi-compositor", + "llimphi-hal", + "llimphi-layout", + "llimphi-raster", + "llimphi-text", + "pollster", + "uuid", +] + +[[package]] +name = "llimphi-widget-button" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-context-menu" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-panel", +] + +[[package]] +name = "llimphi-widget-edit-menu" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-context-menu", + "llimphi-widget-text-editor", +] + +[[package]] +name = "llimphi-widget-menubar" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "app-bus", + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-button", + "llimphi-widget-context-menu", +] + +[[package]] +name = "llimphi-widget-panel" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-slider" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-theme", + "llimphi-ui", +] + +[[package]] +name = "llimphi-widget-text-editor" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-editor-core", + "tree-sitter", +] + +[[package]] +name = "llimphi-widget-text-editor-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "peniko", + "ropey", + "tree-sitter", + "tree-sitter-python", + "tree-sitter-rust", +] + +[[package]] +name = "llimphi-widget-text-input" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-theme", + "llimphi-ui", + "llimphi-widget-text-editor", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.13.0", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.0", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.0", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parley" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26746861bb76dbc9bcd5ed1b0b55d2fedf291100961251702a031ab2abd2ce52" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.15.5", + "linebender_resource_handle", + "skrifa 0.37.0", + "swash", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "pluma-notebook-core" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "blake3", + "format", + "serde", +] + +[[package]] +name = "pluma-notebook-exec" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "async-trait", + "pluma-notebook-core", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rimay-localize" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "fluent-bundle", + "once_cell", + "parking_lot", + "sys-locale", + "thiserror 2.0.18", + "tracing", + "unic-langid", +] + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.0", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c" +dependencies = [ + "skrifa 0.42.1", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio 1.2.1", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tree-sitter" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vello" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72fef40773530322d5c2ffe3c1107e9874bd8239ac137d1c2b6c1edad695146e" +dependencies = [ + "bytemuck", + "futures-intrusive", + "log", + "peniko", + "png 0.17.16", + "skrifa 0.40.0", + "static_assertions", + "thiserror 2.0.18", + "vello_encoding", + "vello_shaders", + "wgpu", +] + +[[package]] +name = "vello_encoding" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c91203ec4b483440614a9a5c7c2d991932af72c5349659a63ec49476f0b79c" +dependencies = [ + "bytemuck", + "guillotiere", + "peniko", + "skrifa 0.40.0", + "smallvec", +] + +[[package]] +name = "vello_shaders" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a765d44d4bd354146e44f9a860f4e92effd91a97302549be9e47f0a18d8128c" +dependencies = [ + "bytemuck", + "log", + "naga", + "thiserror 2.0.18", + "vello_encoding", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wawa-config" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "directories", + "notify", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "wawa-config-llimphi" +version = "0.1.0" +source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?branch=main#063a7ad66a139e17b21c384098e04f6b3d19aafd" +dependencies = [ + "llimphi-theme", + "wawa-config", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.13.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.0", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.0", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.13.0", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.0", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "zbus" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" +dependencies = [ + "quick-xml", + "serde", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "zerofrom", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow 1.0.3", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6d37f94 --- /dev/null +++ b/Cargo.toml @@ -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 "] +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" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b01078 --- /dev/null +++ b/README.md @@ -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. + +

+ 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 +
+ a live mean-field run — the isometric continent, thousands of lemmings, Concepts emitting their fields, and ψ-cluster recoloring +

+ +## 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`, ready for any backend. | +| `dominium-canvas-llimphi` | Llimphi/vello backend: a `RenderPlan` → `View` 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. diff --git a/docs/dominium_showreel.gif b/docs/dominium_showreel.gif new file mode 100644 index 0000000..5036cf6 Binary files /dev/null and b/docs/dominium_showreel.gif differ diff --git a/docs/dominium_showreel.mp4 b/docs/dominium_showreel.mp4 new file mode 100644 index 0000000..0fc6abe Binary files /dev/null and b/docs/dominium_showreel.mp4 differ