Files
Dominium/01_yachay/dominium/dominium-sim/src/lib.rs
T
sergio 1860b51f70 feat: dominium standalone — simulador de campo medio sobre Llimphi
Front-door publicable de dominium: los 9 crates propios como path
members; Llimphi, app-bus, rimay-localize, wawa-config y pluma-notebook
por git-dep al monorepo tawasuyu.git (branch=main). cargo check
--workspace --all-targets pasa exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:22:40 +00:00

235 lines
8.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Sesión de simulación de dominium — el estado de dominio y su ciclo de
//! vida, separados del frontend (regla #2).
//!
//! [`Sim`] posee el `World`, sus `SimParams`, el reloj (`tick`/`epoch`/
//! `rng_seed`), el ring de snapshots para rebobinar, los trails de posiciones
//! y las asignaciones de cluster k-means. Orquesta el avance del motor
//! (`dominium-physics`), el reseed manual y el reseed automático cuando la
//! población colapsa. El frontend (cámara, selección, render, paneles, menús)
//! sólo guarda estado de vista y delega el ciclo de vida acá — antes todo
//! esto vivía mezclado en el `Model` de `dominium-app-llimphi`.
use std::collections::VecDeque;
use dominium_core::{kmeans_psi, SimParams, World};
use dominium_physics::tick;
/// Cómo re-sembrar el mundo a partir de una semilla. El frontend la provee
/// (típicamente cargando su pack de Conceptos del disco), así `Sim` no
/// conoce ni el tamaño de grilla ni la IO de packs.
pub type Seeder = Box<dyn FnMut(u64) -> World>;
/// Sesión de simulación: estado de dominio + reloj + historia.
pub struct Sim {
pub world: World,
pub params: SimParams,
/// Si el motor avanza en cada [`Sim::step`]. El frontend lo togglea.
pub running: bool,
pub tick: u64,
pub epoch: u64,
pub rng_seed: u64,
/// Ring de snapshots del `World` — el último es el más reciente. Ver
/// [`Sim::displayed_world`] para la semántica de `rewind_offset`.
pub snapshots: VecDeque<World>,
/// Cuántos pasos atrás mira el usuario. `0` = presente (vivo).
pub rewind_offset: usize,
/// Para cada frame reciente, las posiciones `(x, y)` de los lemmings
/// vivos. `trails[k]` es el frame `tick - (len-1-k)`.
pub trails: VecDeque<Vec<(f32, f32)>>,
/// Asignación k-means → cluster por lemming (modo PsiCluster).
pub cluster_assignments: Vec<u8>,
/// Tick del último refresh de clusters (gated refresh).
pub cluster_last_refresh: u64,
snapshot_cap: usize,
trail_cap: usize,
kmeans_refresh_ticks: u64,
seeder: Seeder,
}
impl Sim {
/// Arranca una sesión con un `World` ya sembrado, sus parámetros, la
/// semilla del PRNG de reseed, las capacidades de los rings, el periodo
/// de refresh de k-means y el `seeder` que produce mundos nuevos.
#[allow(clippy::too_many_arguments)]
pub fn new(
world: World,
params: SimParams,
rng_seed: u64,
snapshot_cap: usize,
trail_cap: usize,
kmeans_refresh_ticks: u64,
running: bool,
seeder: Seeder,
) -> Self {
Self {
world,
params,
running,
tick: 0,
epoch: 0,
rng_seed,
snapshots: VecDeque::with_capacity(snapshot_cap),
rewind_offset: 0,
trails: VecDeque::with_capacity(trail_cap),
cluster_assignments: Vec::new(),
cluster_last_refresh: 0,
snapshot_cap,
trail_cap,
kmeans_refresh_ticks,
seeder,
}
}
/// Un paso de simulación; re-siembra si la población colapsa. Captura
/// también el snapshot del estado y el frame de trails (después de
/// avanzar, así el "presente" siempre coincide con `world`). Recalcula
/// los clusters sólo si `needs_clusters` y pasó el periodo de refresh —
/// el frontend pasa `true` cuando el render lo necesita (modo PsiCluster).
pub fn advance(&mut self, needs_clusters: bool) {
tick(&mut self.world, &self.params);
self.tick += 1;
if self.world.lemmings.is_empty() {
self.epoch += 1;
self.rng_seed = self.rng_seed.wrapping_mul(2862933555777941757).wrapping_add(1);
self.world = (self.seeder)(self.rng_seed);
self.tick = 0;
self.snapshots.clear();
self.trails.clear();
self.cluster_assignments.clear();
}
self.push_snapshot();
self.push_trail_frame();
if needs_clusters
&& self.tick.saturating_sub(self.cluster_last_refresh) >= self.kmeans_refresh_ticks
{
self.refresh_clusters();
}
}
/// Reseed manual: nueva semilla, mundo fresco, reloj a cero y rings
/// limpios. Vuelve al presente (`rewind_offset = 0`).
pub fn reseed(&mut self) {
self.rng_seed = self.rng_seed.wrapping_add(0x9E37_79B9);
self.world = (self.seeder)(self.rng_seed);
self.tick = 0;
self.epoch += 1;
self.snapshots.clear();
self.trails.clear();
self.rewind_offset = 0;
}
/// Recalcula `cluster_assignments` desde el `World` actual. Si
/// `kmeans_psi` devuelve `None` (pob < K), limpia las asignaciones.
pub fn refresh_clusters(&mut self) {
if let Some(km) = kmeans_psi(&self.world) {
self.cluster_assignments = km.assignments;
} else {
self.cluster_assignments.clear();
}
self.cluster_last_refresh = self.tick;
}
/// Empuja el `World` actual al ring (clone barato: SoA + Vec). Drop del
/// más viejo al exceder la capacidad.
pub fn push_snapshot(&mut self) {
if self.snapshots.len() == self.snapshot_cap {
self.snapshots.pop_front();
}
self.snapshots.push_back(self.world.clone());
}
/// Empuja el frame de posiciones de todos los lemmings vivos al ring.
pub fn push_trail_frame(&mut self) {
if self.trails.len() == self.trail_cap {
self.trails.pop_front();
}
let lem = &self.world.lemmings;
let frame: Vec<(f32, f32)> = (0..lem.len()).map(|i| (lem.pos_x[i], lem.pos_y[i])).collect();
self.trails.push_back(frame);
}
/// Devuelve el `World` que actualmente se está mostrando — el presente
/// (`world`) si no hay rewind, o el snapshot apropiado si lo hay.
pub fn displayed_world(&self) -> &World {
if self.rewind_offset == 0 || self.snapshots.is_empty() {
&self.world
} else {
let len = self.snapshots.len();
let idx = len.saturating_sub(1 + self.rewind_offset);
&self.snapshots[idx]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dominium_core::World;
// Seeder de prueba: un mundo chico determinista vacío de lemmings vivos
// sólo cuando lo pidamos. Para la mayoría de los tests no colapsa.
fn poblar(w: &mut World) {
for k in 0..8 {
w.lemmings.spawn(k as f32 % 4.0, 0.0, 50.0, [0.5; 4]);
}
}
fn sim_demo() -> Sim {
// Mundo mínimo 4×4 con algunos lemmings para que no colapse al toque.
let mut w = World::new(4, 4);
poblar(&mut w);
let seeder: Seeder = Box::new(|_s| {
let mut w = World::new(4, 4);
poblar(&mut w);
w
});
Sim::new(w, SimParams::default(), 0xC0FFEE, 4, 3, 30, true, seeder)
}
#[test]
fn advance_incrementa_tick_y_captura_historia() {
let mut s = sim_demo();
s.advance(false);
assert_eq!(s.tick, 1);
assert_eq!(s.snapshots.len(), 1);
assert_eq!(s.trails.len(), 1);
}
#[test]
fn rings_respetan_su_capacidad() {
let mut s = sim_demo();
for _ in 0..10 {
s.advance(false);
}
assert!(s.snapshots.len() <= 4, "snapshot ring capado");
assert!(s.trails.len() <= 3, "trail ring capado");
}
#[test]
fn reseed_resetea_reloj_y_vuelve_al_presente() {
let mut s = sim_demo();
s.advance(false);
s.advance(false);
s.rewind_offset = 1;
s.reseed();
assert_eq!(s.tick, 0);
assert_eq!(s.rewind_offset, 0);
assert!(s.snapshots.is_empty());
assert_eq!(s.epoch, 1);
}
#[test]
fn displayed_world_sigue_el_rewind() {
let mut s = sim_demo();
s.advance(false);
s.advance(false);
// presente
s.rewind_offset = 0;
assert!(std::ptr::eq(s.displayed_world(), &s.world));
// pasado: devuelve un snapshot, no el world vivo
s.rewind_offset = 1;
assert!(!std::ptr::eq(s.displayed_world(), &s.world));
}
}