tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-voxel-studio/src/ai.rs Rust · 504 líneas · 18.5 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! Conexión IA **"poor"** del studio. Convierte prosa en artefactos del modelo:
2//! un [`Bioma`] (relieve + materiales), una [`SceneSpec`] o un [`CharSpec`]. Dos
3//! caminos en cascada donde aplica: LLM real (`pluma-llm`, autodetecta backend) y,
4//! si no hay modelo o falla el parseo, una **heurística local** offline — así el
5//! botón siempre produce algo.
6//!
7//! Tras el rediseño por niveles, los materiales son **autorables** (ids en el
8//! `Project`), así que la generación de un bioma referencia los materiales semilla
9//! vía [`MatRefs`] en vez de un `enum` cerrado.
10
11use llimphi_voxel::{Bioma, CharSpec, Clip, Forma, Mineral, MineralUso, ObjetoUso, Project, SceneSpec};
12use pluma_llm::pluma_llm_core::ChatRequest;
13use serde::Deserialize;
14
15/// Ids de los materiales **semilla** del proyecto que un bioma puede referenciar.
16/// Se resuelven una vez (con [`from_project`](Self::from_project)) y se capturan en
17/// el worker de IA — así la generación no necesita el `Project` entero.
18#[derive(Debug, Clone, Copy)]
19pub struct MatRefs {
20    pub sand: u64,
21    pub grass: u64,
22    pub rock: u64,
23    pub snow: u64,
24    pub cactus: u64,
25    pub leaf: u64,
26    pub wheat: u64,
27    pub bloom: u64,
28    pub tierra: u64,
29    pub arcilla: u64,
30    pub grava: u64,
31    pub arenisca: u64,
32    pub basalto: u64,
33    pub granito: u64,
34    pub marmol: u64,
35    pub hielo: u64,
36    pub lava: u64,
37    pub musgo: u64,
38}
39
40impl MatRefs {
41    /// Resuelve los ids semilla del proyecto (0 si falta alguno — no debería con el
42    /// proyecto de arranque).
43    pub fn from_project(p: &Project) -> Self {
44        use llimphi_voxel::Material::*;
45        let id = |m| p.material_id_for(m).unwrap_or(0);
46        Self {
47            sand: id(Sand),
48            grass: id(Grass),
49            rock: id(Rock),
50            snow: id(Snow),
51            cactus: id(Cactus),
52            leaf: id(Leaf),
53            wheat: id(Wheat),
54            bloom: id(Bloom),
55            tierra: id(Tierra),
56            arcilla: id(Arcilla),
57            grava: id(Grava),
58            arenisca: id(Arenisca),
59            basalto: id(Basalto),
60            granito: id(Granito),
61            marmol: id(Marmol),
62            hielo: id(Hielo),
63            lava: id(Lava),
64            musgo: id(Musgo),
65        }
66    }
67}
68
69/// Pregunta al LLM real (de `pluma-llm`, `from_env`) y devuelve el texto. `None` si
70/// el backend es **Mock** (sin credenciales) o si la red falla. Motor compartido de
71/// la asistencia; cada caller parsea la salida a su artefacto.
72fn ask_llm(system: &str, prompt: &str, max_tokens: u32) -> Option<String> {
73    let client = pluma_llm::del_sistema(None, Some("voxel-studio")).ok()?;
74    if client.model_id().to_lowercase().contains("mock") {
75        return None;
76    }
77    let req = ChatRequest::una_vuelta(prompt, max_tokens)
78        .con_sistema(system)
79        .con_temperatura(0.5);
80    let rt = tokio::runtime::Builder::new_current_thread()
81        .enable_all()
82        .build()
83        .ok()?;
84    rt.block_on(client.complete(&req)).ok().map(|r| r.content)
85}
86
87/// Recorta un texto al literal RON `( … )` que contenga (tolera ``` y ruido).
88fn ron_slice(text: &str) -> Option<&str> {
89    let start = text.find('(')?;
90    let end = text.rfind(')')?;
91    (end > start).then(|| &text[start..=end])
92}
93
94// =============================================================================
95//  Biomas
96// =============================================================================
97
98/// **Bioma desde prosa** (heurística local por palabras clave). Arranca de un preset
99/// (desierto o pradera) referenciando los materiales semilla `m` y lo ajusta según
100/// términos del prompt. Offline e instantánea — el alma "poor". El id queda en 0; lo
101/// asigna el `Project` al agregarlo.
102pub fn generate_bioma(prompt: &str, m: &MatRefs) -> Bioma {
103    let p = prompt.to_lowercase();
104    let has = |k: &str| p.contains(k);
105
106    let desierto = has("desierto") || has("arena") || has("seco") || has("duna");
107    let mut b = if desierto {
108        Bioma {
109            id: 0,
110            name: name_from(prompt, "bioma"),
111            base: 0.30,
112            dune: 0.05,
113            relief: 0.45,
114            mountains: 0.12,
115            water_level: 0.26,
116            rivers: 0.18,
117            peak_at: 1.0,
118            ground: m.sand,
119            cliff: m.rock,
120            peak: None,
121            objetos: vec![ObjetoUso { material: m.cactus, densidad: 0.010, forma: Forma::Columnar }],
122            minerales: vec![],
123            densidad_urbana: 0.4,
124            casas: Vec::new(),
125            fauna: vec![],
126            seres: vec![],
127        }
128    } else {
129        Bioma {
130            id: 0,
131            name: name_from(prompt, "bioma"),
132            base: 0.22,
133            dune: 0.10,
134            relief: 0.7,
135            mountains: 0.5,
136            water_level: 0.30,
137            rivers: 0.25,
138            peak_at: 0.80,
139            ground: m.grass,
140            cliff: m.rock,
141            peak: Some(m.snow),
142            objetos: vec![],
143            minerales: vec![],
144            densidad_urbana: 0.4,
145            casas: Vec::new(),
146            fauna: vec![],
147            seres: vec![],
148        }
149    };
150
151    if has("nieve") || has("monta") || has("montañ") || has("cumbre") || has("alpin") || has("pico") {
152        b.relief = b.relief.max(0.8);
153        b.mountains = b.mountains.max(0.65);
154        b.peak = Some(m.snow);
155        b.peak_at = 0.62;
156    }
157    if has("llano") || has("plano") || has("pradera") || has("planicie") || has("estepa") {
158        b.relief = b.relief.min(0.35);
159        b.mountains = b.mountains.min(0.2);
160    }
161    if has("agua") || has("lago") || has("mar") || has("océano") || has("oceano") || has("isla") || has("inund") {
162        b.water_level = b.water_level.max(0.5);
163    }
164    if has("río") || has("rio") || has("cauce") || has("arroyo") {
165        b.rivers = b.rivers.max(0.6);
166    }
167    if has("bosque") || has("selva") || has("verde") || has("pasto") {
168        b.ground = m.grass;
169    }
170    if has("cactus") || desierto {
171        if !b.objetos.iter().any(|o| o.forma == Forma::Columnar) {
172            b.objetos.push(ObjetoUso { material: m.cactus, densidad: 0.012, forma: Forma::Columnar });
173        }
174    }
175    // --- Vegetación por palabra clave (cada forma sólo si no está ya presente) ---
176    let add = |b: &mut Bioma, material: u64, densidad: f32, forma: Forma| {
177        if !b.objetos.iter().any(|o| o.forma == forma) {
178            b.objetos.push(ObjetoUso { material, densidad, forma });
179        }
180    };
181    if has("bosque") || has("selva") || has("árbol") || has("arbol") || has("frondo") {
182        add(&mut b, m.leaf, 0.045, Forma::Arbol);
183    }
184    if has("selva") || has("pino") || has("conífer") || has("conifer") || has("abeto") || has("nieve") || has("alpin") {
185        add(&mut b, m.leaf, 0.030, Forma::Conifera);
186    }
187    if has("palmera") || has("playa") || has("tropical") || has("oasis") || has("costa") {
188        add(&mut b, m.leaf, 0.010, Forma::Palmera);
189    }
190    if has("arbusto") || has("matorral") || has("maleza") || has("bosque") {
191        add(&mut b, m.leaf, 0.025, Forma::Arbusto);
192    }
193    if has("helecho") || has("selva") || has("húmed") || has("humed") || has("sotobosque") {
194        add(&mut b, m.cactus, 0.030, Forma::Helecho);
195    }
196    if has("flor") || has("jardín") || has("jardin") || has("prado") || has("pradera") {
197        add(&mut b, m.cactus, 0.012, Forma::Flor);
198    }
199    if has("pasto") || has("grama") || has("césped") || has("cesped") || has("pradera") || has("hierba") {
200        add(&mut b, m.grass, 0.06, Forma::Pasto);
201    }
202    // --- 2ª tanda: especies de selva / bosque / pradera ---
203    if has("selva") || has("jungla") || has("tropical") || has("bambú") || has("bambu") || has("caña") {
204        add(&mut b, m.cactus, 0.05, Forma::Bambu);
205    }
206    if has("selva") || has("jungla") || has("banano") || has("platan") || has("hoja grande") {
207        add(&mut b, m.leaf, 0.03, Forma::Platanera);
208    }
209    if has("roble") || has("encina") || has("añoso") || has("anoso") || has("frondo") {
210        add(&mut b, m.leaf, 0.015, Forma::Roble);
211    }
212    if has("hongo") || has("seta") || has("champiñón") || has("champinon") || has("húmed") || has("humed") {
213        add(&mut b, m.leaf, 0.012, Forma::Hongo);
214    }
215    if has("tocón") || has("tocon") || has("tronco") || has("cepa") || has("talado") || has("viejo bosque") {
216        add(&mut b, m.leaf, 0.006, Forma::Tocon);
217    }
218    if has("trigo") || has("cereal") || has("espiga") || has("cultivo") || has("dorado") || has("campo") {
219        add(&mut b, m.wheat, 0.05, Forma::Trigo);
220    }
221    if has("junco") || has("cañaveral") || has("canaveral") || has("totora") || has("humedal") || has("orilla") {
222        add(&mut b, m.leaf, 0.010, Forma::Junco);
223    }
224    if has("lavanda") || has("flores") || has("silvestre") || has("prado") || has("colorido") {
225        add(&mut b, m.bloom, 0.02, Forma::Lavanda);
226    }
227
228    // --- Terreno por palabra clave (la tanda de materiales de suelo/roca) ---
229    if has("volcán") || has("volcan") || has("lava") || has("basalto") || has("infierno") {
230        b.ground = m.basalto;
231        b.cliff = m.basalto;
232        b.peak = Some(m.lava);
233        b.peak_at = 0.7;
234    }
235    if has("glaciar") || has("hielo") || has("ártico") || has("artico") || has("polar") || has("congelad") {
236        b.ground = m.hielo;
237        b.cliff = m.hielo;
238        b.peak = Some(m.hielo);
239    }
240    if has("arcilla") || has("arcillos") || has("barro") || has("badland") {
241        b.ground = m.arcilla;
242    }
243    if has("arenisca") || has("cañón") || has("canon") || has("meseta") || has("mesa") {
244        b.cliff = m.arenisca;
245    }
246    if has("granito") || has("pedregos") || has("rocoso") || has("montañoso") {
247        b.cliff = m.granito;
248    }
249    if has("mármol") || has("marmol") || has("pulid") || has("templo") {
250        b.cliff = m.marmol;
251        b.peak = Some(m.marmol);
252    }
253    if has("tierra") || has("labrant") || has("arado") || has("campo") {
254        b.ground = m.tierra;
255    }
256    if has("grava") || has("cantera") || has("escombr") {
257        b.ground = m.grava;
258    }
259    if has("musgo") || has("húmed") || has("humed") || has("ciénaga") || has("cienaga") || has("pantano") {
260        b.ground = m.musgo;
261    }
262
263    // --- Minerales por palabra clave (además del suelo común: carbón + hierro) ---
264    let mineral = |b: &mut Bioma, min: Mineral| {
265        if !b.minerales.iter().any(|u| u.mineral == min) {
266            b.minerales.push(MineralUso::of(min));
267        }
268    };
269    // Toda roca trae los comunes salvo que sea un mundo sin relieve pelado.
270    if b.relief > 0.2 {
271        mineral(&mut b, Mineral::Carbon);
272        mineral(&mut b, Mineral::Hierro);
273    }
274    if has("mineral") || has("mina") || has("cueva") || has("caverna") || has("subsuelo") || has("roca") {
275        mineral(&mut b, Mineral::Cobre);
276        mineral(&mut b, Mineral::Cuarzo);
277    }
278    if has("carbón") || has("carbon") || has("hulla") {
279        mineral(&mut b, Mineral::Carbon);
280    }
281    if has("hierro") || has("metal") || has("óxido") || has("oxido") {
282        mineral(&mut b, Mineral::Hierro);
283    }
284    if has("cobre") || has("bronce") {
285        mineral(&mut b, Mineral::Cobre);
286    }
287    if has("oro") || has("dorado") || has("tesoro") || has("rico") {
288        mineral(&mut b, Mineral::Oro);
289    }
290    if has("plata") || has("argent") {
291        mineral(&mut b, Mineral::Plata);
292    }
293    if has("sal") || has("salino") || has("halita") {
294        mineral(&mut b, Mineral::Sal);
295    }
296    if has("lapis") || has("lapislázuli") || has("lapislazuli") || has("azul") {
297        mineral(&mut b, Mineral::Lapis);
298    }
299    if has("cuarzo") || has("cristal") {
300        mineral(&mut b, Mineral::Cuarzo);
301    }
302    if has("amatista") || has("geoda") || has("violeta") || has("púrpura") || has("purpura") {
303        mineral(&mut b, Mineral::Amatista);
304    }
305    if has("esmeralda") || has("gema") || has("joya") || has("selva") || has("jungla") {
306        mineral(&mut b, Mineral::Esmeralda);
307    }
308    if has("rubí") || has("rubi") || has("rojo") {
309        mineral(&mut b, Mineral::Rubi);
310    }
311    if has("diamante") || has("brillante") || has("preciad") {
312        mineral(&mut b, Mineral::Diamante);
313    }
314    b
315}
316
317// =============================================================================
318//  Escenas
319// =============================================================================
320
321/// "Brief" mínimo que el LLM emite para una escena: cuántos actores y qué gesto.
322#[derive(Deserialize)]
323struct SceneBrief {
324    actors: usize,
325    gesture: Clip,
326}
327
328const SCENE_SYSTEM: &str = "\
329Dirigís una escena voxel corta. Responde SÓLO con un literal RON de la forma
330(actors: N, gesture: G), sin markdown ni texto extra. N = cantidad de personajes
331(1..5). G = el gesto final: uno de Idle, Walk, Run, Wave, Point, Cheer.
332Ejemplo: (actors: 3, gesture: Cheer)";
333
334/// **Escena desde prosa** en el mundo `mundo` (id): LLM real (brief) → escena patrón;
335/// si no hay LLM o falla, heurística local. Siempre devuelve algo.
336pub fn generate_scene(prompt: &str, mundo: u64, dim: [u32; 3]) -> SceneSpec {
337    llm_scene(prompt, mundo, dim).unwrap_or_else(|| local_scene(prompt, mundo, dim))
338}
339
340fn llm_scene(prompt: &str, mundo: u64, dim: [u32; 3]) -> Option<SceneSpec> {
341    let brief: SceneBrief = ron::from_str(ron_slice(&ask_llm(SCENE_SYSTEM, prompt, 80)?)?).ok()?;
342    Some(SceneSpec::walk_and_emote(
343        scene_name(prompt),
344        mundo,
345        brief.actors.clamp(1, 5),
346        brief.gesture,
347        dim,
348    ))
349}
350
351fn local_scene(prompt: &str, mundo: u64, dim: [u32; 3]) -> SceneSpec {
352    let p = prompt.to_lowercase();
353    let n = parse_count(&p);
354    let gesture = if p.contains("salud") {
355        Clip::Wave
356    } else if p.contains("festej") || p.contains("celebr") || p.contains("baila") || p.contains("fiesta") {
357        Clip::Cheer
358    } else if p.contains("señal") || p.contains("apunt") || p.contains("muestr") {
359        Clip::Point
360    } else {
361        Clip::Wave
362    };
363    SceneSpec::walk_and_emote(scene_name(prompt), mundo, n, gesture, dim)
364}
365
366/// Cuántos actores pide el texto (palabra o dígito); 3 por defecto.
367fn parse_count(p: &str) -> usize {
368    for (w, n) in [
369        ("cinco", 5usize), ("cuatro", 4), ("tres", 3), ("dos", 2), ("una", 1), ("uno", 1),
370        (" 5", 5), (" 4", 4), (" 3", 3), (" 2", 2), (" 1", 1),
371    ] {
372        if p.contains(w) {
373            return n;
374        }
375    }
376    3
377}
378
379// =============================================================================
380//  Personajes (seres)
381// =============================================================================
382
383const CHAR_SYSTEM: &str = "\
384Generás un personaje voxel. Responde SÓLO con un literal RON de CharSpec, sin
385markdown ni texto extra. Campos: name (texto), age (Baby|Child|Teen|Adult|Elder),
386skin/shirt/pants (cada uno [r, g, b] en 0..1).
387Ejemplo: (name: \"rojo\", age: Adult, skin: [0.9, 0.72, 0.58], shirt: [0.82, 0.28, 0.26], pants: [0.2, 0.2, 0.28])";
388
389/// **Personaje desde prosa**: LLM real (`CharSpec` en RON) y, si no hay o falla, la
390/// heurística local. Siempre devuelve algo (id 0; lo asigna el `Project`).
391pub fn generate_character(prompt: &str) -> CharSpec {
392    llm_character(prompt).unwrap_or_else(|| local_character(prompt))
393}
394
395fn llm_character(prompt: &str) -> Option<CharSpec> {
396    ron::from_str::<CharSpec>(ron_slice(&ask_llm(CHAR_SYSTEM, prompt, 200)?)?).ok()
397}
398
399fn local_character(prompt: &str) -> CharSpec {
400    use llimphi_voxel::Age;
401    let p = prompt.to_lowercase();
402    let age = if p.contains("bebé") || p.contains("bebe") || p.contains("recién") || p.contains("recien") {
403        Age::Baby
404    } else if p.contains("niñ") || p.contains("nin") || p.contains("chic") {
405        Age::Child
406    } else if p.contains("joven") || p.contains("adolesc") || p.contains("teen") {
407        Age::Teen
408    } else if p.contains("ancian") || p.contains("viej") || p.contains("abuel") {
409        Age::Elder
410    } else {
411        Age::Adult
412    };
413    let mut c = CharSpec::new(scene_name(prompt), age);
414    if let Some(rgb) = parse_color(&p) {
415        c.shirt = rgb;
416    }
417    c
418}
419
420/// Color (`[r,g,b]` en `[0,1]`) nombrado en el texto, si lo hay.
421fn parse_color(p: &str) -> Option<[f32; 3]> {
422    let table: [(&str, [f32; 3]); 12] = [
423        ("roj", [0.82, 0.28, 0.26]),
424        ("celest", [0.50, 0.75, 0.92]),
425        ("azul", [0.22, 0.55, 0.78]),
426        ("verd", [0.30, 0.70, 0.40]),
427        ("amarill", [0.92, 0.80, 0.30]),
428        ("naranj", [0.90, 0.50, 0.22]),
429        ("violet", [0.62, 0.40, 0.78]),
430        ("morad", [0.62, 0.40, 0.78]),
431        ("rosa", [0.90, 0.55, 0.70]),
432        ("blanc", [0.92, 0.92, 0.92]),
433        ("negr", [0.14, 0.14, 0.16]),
434        ("marr", [0.45, 0.32, 0.22]),
435    ];
436    table.iter().find(|(k, _)| p.contains(k)).map(|(_, c)| *c)
437}
438
439/// Nombre de escena = primeras ~4 palabras de la descripción.
440fn scene_name(prompt: &str) -> String {
441    name_from(prompt, "escena IA")
442}
443
444/// Nombre a partir de las primeras ~4 palabras del prompt, con un default.
445fn name_from(prompt: &str, default: &str) -> String {
446    let s: String = prompt.split_whitespace().take(4).collect::<Vec<_>>().join(" ");
447    if s.is_empty() {
448        default.into()
449    } else {
450        s
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn refs() -> MatRefs {
459        MatRefs::from_project(&Project::starter())
460    }
461
462    #[test]
463    fn heuristica_detecta_desierto_con_cactus() {
464        let m = refs();
465        let b = generate_bioma("un desierto enorme con cactus", &m);
466        assert_eq!(b.ground, m.sand);
467        assert!(b.objetos.iter().any(|o| o.material == m.cactus));
468    }
469
470    #[test]
471    fn heuristica_detecta_montañas_nevadas() {
472        let m = refs();
473        let b = generate_bioma("cordillera con picos de nieve", &m);
474        assert_eq!(b.peak, Some(m.snow));
475        assert!(b.mountains >= 0.65);
476    }
477
478    #[test]
479    fn bioma_referencia_materiales_del_proyecto() {
480        let p = Project::starter();
481        let m = MatRefs::from_project(&p);
482        let b = generate_bioma("pradera verde con ríos", &m);
483        // Los ids referenciados existen como materiales del proyecto.
484        assert!(p.material(b.ground).is_some());
485        assert!(p.material(b.cliff).is_some());
486    }
487
488    #[test]
489    fn personaje_lee_edad_y_color() {
490        use llimphi_voxel::Age;
491        let c = local_character("un niño de remera roja");
492        assert_eq!(c.age, Age::Child);
493        assert_eq!(c.shirt, [0.82, 0.28, 0.26]);
494    }
495
496    #[test]
497    fn escena_lee_cantidad_y_gesto() {
498        let s = local_scene("dos personajes que festejan", 7, [128, 56, 128]);
499        assert_eq!(s.actors.len(), 2);
500        assert_eq!(s.mundo, 7);
501        let tiene_cheer = s.actors[0].keys.iter().any(|k| k.clip == Some(Clip::Cheer));
502        assert!(tiene_cheer, "festejar → Cheer");
503    }
504}