//! Conexión IA **"poor"** del studio. Convierte prosa en artefactos del modelo: //! un [`Bioma`] (relieve + materiales), una [`SceneSpec`] o un [`CharSpec`]. Dos //! caminos en cascada donde aplica: LLM real (`pluma-llm`, autodetecta backend) y, //! si no hay modelo o falla el parseo, una **heurística local** offline — así el //! botón siempre produce algo. //! //! Tras el rediseño por niveles, los materiales son **autorables** (ids en el //! `Project`), así que la generación de un bioma referencia los materiales semilla //! vía [`MatRefs`] en vez de un `enum` cerrado. use llimphi_voxel::{Bioma, CharSpec, Clip, Forma, Mineral, MineralUso, ObjetoUso, Project, SceneSpec}; use pluma_llm::pluma_llm_core::ChatRequest; use serde::Deserialize; /// Ids de los materiales **semilla** del proyecto que un bioma puede referenciar. /// Se resuelven una vez (con [`from_project`](Self::from_project)) y se capturan en /// el worker de IA — así la generación no necesita el `Project` entero. #[derive(Debug, Clone, Copy)] pub struct MatRefs { pub sand: u64, pub grass: u64, pub rock: u64, pub snow: u64, pub cactus: u64, pub leaf: u64, pub wheat: u64, pub bloom: u64, pub tierra: u64, pub arcilla: u64, pub grava: u64, pub arenisca: u64, pub basalto: u64, pub granito: u64, pub marmol: u64, pub hielo: u64, pub lava: u64, pub musgo: u64, } impl MatRefs { /// Resuelve los ids semilla del proyecto (0 si falta alguno — no debería con el /// proyecto de arranque). pub fn from_project(p: &Project) -> Self { use llimphi_voxel::Material::*; let id = |m| p.material_id_for(m).unwrap_or(0); Self { sand: id(Sand), grass: id(Grass), rock: id(Rock), snow: id(Snow), cactus: id(Cactus), leaf: id(Leaf), wheat: id(Wheat), bloom: id(Bloom), tierra: id(Tierra), arcilla: id(Arcilla), grava: id(Grava), arenisca: id(Arenisca), basalto: id(Basalto), granito: id(Granito), marmol: id(Marmol), hielo: id(Hielo), lava: id(Lava), musgo: id(Musgo), } } } /// Pregunta al LLM real (de `pluma-llm`, `from_env`) y devuelve el texto. `None` si /// el backend es **Mock** (sin credenciales) o si la red falla. Motor compartido de /// la asistencia; cada caller parsea la salida a su artefacto. fn ask_llm(system: &str, prompt: &str, max_tokens: u32) -> Option { let client = pluma_llm::del_sistema(None, Some("voxel-studio")).ok()?; if client.model_id().to_lowercase().contains("mock") { return None; } let req = ChatRequest::una_vuelta(prompt, max_tokens) .con_sistema(system) .con_temperatura(0.5); let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .ok()?; rt.block_on(client.complete(&req)).ok().map(|r| r.content) } /// Recorta un texto al literal RON `( … )` que contenga (tolera ``` y ruido). fn ron_slice(text: &str) -> Option<&str> { let start = text.find('(')?; let end = text.rfind(')')?; (end > start).then(|| &text[start..=end]) } // ============================================================================= // Biomas // ============================================================================= /// **Bioma desde prosa** (heurística local por palabras clave). Arranca de un preset /// (desierto o pradera) referenciando los materiales semilla `m` y lo ajusta según /// términos del prompt. Offline e instantánea — el alma "poor". El id queda en 0; lo /// asigna el `Project` al agregarlo. pub fn generate_bioma(prompt: &str, m: &MatRefs) -> Bioma { let p = prompt.to_lowercase(); let has = |k: &str| p.contains(k); let desierto = has("desierto") || has("arena") || has("seco") || has("duna"); let mut b = if desierto { Bioma { id: 0, name: name_from(prompt, "bioma"), base: 0.30, dune: 0.05, relief: 0.45, mountains: 0.12, water_level: 0.26, rivers: 0.18, peak_at: 1.0, ground: m.sand, cliff: m.rock, peak: None, objetos: vec![ObjetoUso { material: m.cactus, densidad: 0.010, forma: Forma::Columnar }], minerales: vec![], densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![], seres: vec![], } } else { Bioma { id: 0, name: name_from(prompt, "bioma"), base: 0.22, dune: 0.10, relief: 0.7, mountains: 0.5, water_level: 0.30, rivers: 0.25, peak_at: 0.80, ground: m.grass, cliff: m.rock, peak: Some(m.snow), objetos: vec![], minerales: vec![], densidad_urbana: 0.4, casas: Vec::new(), fauna: vec![], seres: vec![], } }; if has("nieve") || has("monta") || has("montañ") || has("cumbre") || has("alpin") || has("pico") { b.relief = b.relief.max(0.8); b.mountains = b.mountains.max(0.65); b.peak = Some(m.snow); b.peak_at = 0.62; } if has("llano") || has("plano") || has("pradera") || has("planicie") || has("estepa") { b.relief = b.relief.min(0.35); b.mountains = b.mountains.min(0.2); } if has("agua") || has("lago") || has("mar") || has("océano") || has("oceano") || has("isla") || has("inund") { b.water_level = b.water_level.max(0.5); } if has("río") || has("rio") || has("cauce") || has("arroyo") { b.rivers = b.rivers.max(0.6); } if has("bosque") || has("selva") || has("verde") || has("pasto") { b.ground = m.grass; } if has("cactus") || desierto { if !b.objetos.iter().any(|o| o.forma == Forma::Columnar) { b.objetos.push(ObjetoUso { material: m.cactus, densidad: 0.012, forma: Forma::Columnar }); } } // --- Vegetación por palabra clave (cada forma sólo si no está ya presente) --- let add = |b: &mut Bioma, material: u64, densidad: f32, forma: Forma| { if !b.objetos.iter().any(|o| o.forma == forma) { b.objetos.push(ObjetoUso { material, densidad, forma }); } }; if has("bosque") || has("selva") || has("árbol") || has("arbol") || has("frondo") { add(&mut b, m.leaf, 0.045, Forma::Arbol); } if has("selva") || has("pino") || has("conífer") || has("conifer") || has("abeto") || has("nieve") || has("alpin") { add(&mut b, m.leaf, 0.030, Forma::Conifera); } if has("palmera") || has("playa") || has("tropical") || has("oasis") || has("costa") { add(&mut b, m.leaf, 0.010, Forma::Palmera); } if has("arbusto") || has("matorral") || has("maleza") || has("bosque") { add(&mut b, m.leaf, 0.025, Forma::Arbusto); } if has("helecho") || has("selva") || has("húmed") || has("humed") || has("sotobosque") { add(&mut b, m.cactus, 0.030, Forma::Helecho); } if has("flor") || has("jardín") || has("jardin") || has("prado") || has("pradera") { add(&mut b, m.cactus, 0.012, Forma::Flor); } if has("pasto") || has("grama") || has("césped") || has("cesped") || has("pradera") || has("hierba") { add(&mut b, m.grass, 0.06, Forma::Pasto); } // --- 2ª tanda: especies de selva / bosque / pradera --- if has("selva") || has("jungla") || has("tropical") || has("bambú") || has("bambu") || has("caña") { add(&mut b, m.cactus, 0.05, Forma::Bambu); } if has("selva") || has("jungla") || has("banano") || has("platan") || has("hoja grande") { add(&mut b, m.leaf, 0.03, Forma::Platanera); } if has("roble") || has("encina") || has("añoso") || has("anoso") || has("frondo") { add(&mut b, m.leaf, 0.015, Forma::Roble); } if has("hongo") || has("seta") || has("champiñón") || has("champinon") || has("húmed") || has("humed") { add(&mut b, m.leaf, 0.012, Forma::Hongo); } if has("tocón") || has("tocon") || has("tronco") || has("cepa") || has("talado") || has("viejo bosque") { add(&mut b, m.leaf, 0.006, Forma::Tocon); } if has("trigo") || has("cereal") || has("espiga") || has("cultivo") || has("dorado") || has("campo") { add(&mut b, m.wheat, 0.05, Forma::Trigo); } if has("junco") || has("cañaveral") || has("canaveral") || has("totora") || has("humedal") || has("orilla") { add(&mut b, m.leaf, 0.010, Forma::Junco); } if has("lavanda") || has("flores") || has("silvestre") || has("prado") || has("colorido") { add(&mut b, m.bloom, 0.02, Forma::Lavanda); } // --- Terreno por palabra clave (la tanda de materiales de suelo/roca) --- if has("volcán") || has("volcan") || has("lava") || has("basalto") || has("infierno") { b.ground = m.basalto; b.cliff = m.basalto; b.peak = Some(m.lava); b.peak_at = 0.7; } if has("glaciar") || has("hielo") || has("ártico") || has("artico") || has("polar") || has("congelad") { b.ground = m.hielo; b.cliff = m.hielo; b.peak = Some(m.hielo); } if has("arcilla") || has("arcillos") || has("barro") || has("badland") { b.ground = m.arcilla; } if has("arenisca") || has("cañón") || has("canon") || has("meseta") || has("mesa") { b.cliff = m.arenisca; } if has("granito") || has("pedregos") || has("rocoso") || has("montañoso") { b.cliff = m.granito; } if has("mármol") || has("marmol") || has("pulid") || has("templo") { b.cliff = m.marmol; b.peak = Some(m.marmol); } if has("tierra") || has("labrant") || has("arado") || has("campo") { b.ground = m.tierra; } if has("grava") || has("cantera") || has("escombr") { b.ground = m.grava; } if has("musgo") || has("húmed") || has("humed") || has("ciénaga") || has("cienaga") || has("pantano") { b.ground = m.musgo; } // --- Minerales por palabra clave (además del suelo común: carbón + hierro) --- let mineral = |b: &mut Bioma, min: Mineral| { if !b.minerales.iter().any(|u| u.mineral == min) { b.minerales.push(MineralUso::of(min)); } }; // Toda roca trae los comunes salvo que sea un mundo sin relieve pelado. if b.relief > 0.2 { mineral(&mut b, Mineral::Carbon); mineral(&mut b, Mineral::Hierro); } if has("mineral") || has("mina") || has("cueva") || has("caverna") || has("subsuelo") || has("roca") { mineral(&mut b, Mineral::Cobre); mineral(&mut b, Mineral::Cuarzo); } if has("carbón") || has("carbon") || has("hulla") { mineral(&mut b, Mineral::Carbon); } if has("hierro") || has("metal") || has("óxido") || has("oxido") { mineral(&mut b, Mineral::Hierro); } if has("cobre") || has("bronce") { mineral(&mut b, Mineral::Cobre); } if has("oro") || has("dorado") || has("tesoro") || has("rico") { mineral(&mut b, Mineral::Oro); } if has("plata") || has("argent") { mineral(&mut b, Mineral::Plata); } if has("sal") || has("salino") || has("halita") { mineral(&mut b, Mineral::Sal); } if has("lapis") || has("lapislázuli") || has("lapislazuli") || has("azul") { mineral(&mut b, Mineral::Lapis); } if has("cuarzo") || has("cristal") { mineral(&mut b, Mineral::Cuarzo); } if has("amatista") || has("geoda") || has("violeta") || has("púrpura") || has("purpura") { mineral(&mut b, Mineral::Amatista); } if has("esmeralda") || has("gema") || has("joya") || has("selva") || has("jungla") { mineral(&mut b, Mineral::Esmeralda); } if has("rubí") || has("rubi") || has("rojo") { mineral(&mut b, Mineral::Rubi); } if has("diamante") || has("brillante") || has("preciad") { mineral(&mut b, Mineral::Diamante); } b } // ============================================================================= // Escenas // ============================================================================= /// "Brief" mínimo que el LLM emite para una escena: cuántos actores y qué gesto. #[derive(Deserialize)] struct SceneBrief { actors: usize, gesture: Clip, } const SCENE_SYSTEM: &str = "\ Dirigís una escena voxel corta. Responde SÓLO con un literal RON de la forma (actors: N, gesture: G), sin markdown ni texto extra. N = cantidad de personajes (1..5). G = el gesto final: uno de Idle, Walk, Run, Wave, Point, Cheer. Ejemplo: (actors: 3, gesture: Cheer)"; /// **Escena desde prosa** en el mundo `mundo` (id): LLM real (brief) → escena patrón; /// si no hay LLM o falla, heurística local. Siempre devuelve algo. pub fn generate_scene(prompt: &str, mundo: u64, dim: [u32; 3]) -> SceneSpec { llm_scene(prompt, mundo, dim).unwrap_or_else(|| local_scene(prompt, mundo, dim)) } fn llm_scene(prompt: &str, mundo: u64, dim: [u32; 3]) -> Option { let brief: SceneBrief = ron::from_str(ron_slice(&ask_llm(SCENE_SYSTEM, prompt, 80)?)?).ok()?; Some(SceneSpec::walk_and_emote( scene_name(prompt), mundo, brief.actors.clamp(1, 5), brief.gesture, dim, )) } fn local_scene(prompt: &str, mundo: u64, dim: [u32; 3]) -> SceneSpec { let p = prompt.to_lowercase(); let n = parse_count(&p); let gesture = if p.contains("salud") { Clip::Wave } else if p.contains("festej") || p.contains("celebr") || p.contains("baila") || p.contains("fiesta") { Clip::Cheer } else if p.contains("señal") || p.contains("apunt") || p.contains("muestr") { Clip::Point } else { Clip::Wave }; SceneSpec::walk_and_emote(scene_name(prompt), mundo, n, gesture, dim) } /// Cuántos actores pide el texto (palabra o dígito); 3 por defecto. fn parse_count(p: &str) -> usize { for (w, n) in [ ("cinco", 5usize), ("cuatro", 4), ("tres", 3), ("dos", 2), ("una", 1), ("uno", 1), (" 5", 5), (" 4", 4), (" 3", 3), (" 2", 2), (" 1", 1), ] { if p.contains(w) { return n; } } 3 } // ============================================================================= // Personajes (seres) // ============================================================================= const CHAR_SYSTEM: &str = "\ Generás un personaje voxel. Responde SÓLO con un literal RON de CharSpec, sin markdown ni texto extra. Campos: name (texto), age (Baby|Child|Teen|Adult|Elder), skin/shirt/pants (cada uno [r, g, b] en 0..1). Ejemplo: (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])"; /// **Personaje desde prosa**: LLM real (`CharSpec` en RON) y, si no hay o falla, la /// heurística local. Siempre devuelve algo (id 0; lo asigna el `Project`). pub fn generate_character(prompt: &str) -> CharSpec { llm_character(prompt).unwrap_or_else(|| local_character(prompt)) } fn llm_character(prompt: &str) -> Option { ron::from_str::(ron_slice(&ask_llm(CHAR_SYSTEM, prompt, 200)?)?).ok() } fn local_character(prompt: &str) -> CharSpec { use llimphi_voxel::Age; let p = prompt.to_lowercase(); let age = if p.contains("bebé") || p.contains("bebe") || p.contains("recién") || p.contains("recien") { Age::Baby } else if p.contains("niñ") || p.contains("nin") || p.contains("chic") { Age::Child } else if p.contains("joven") || p.contains("adolesc") || p.contains("teen") { Age::Teen } else if p.contains("ancian") || p.contains("viej") || p.contains("abuel") { Age::Elder } else { Age::Adult }; let mut c = CharSpec::new(scene_name(prompt), age); if let Some(rgb) = parse_color(&p) { c.shirt = rgb; } c } /// Color (`[r,g,b]` en `[0,1]`) nombrado en el texto, si lo hay. fn parse_color(p: &str) -> Option<[f32; 3]> { let table: [(&str, [f32; 3]); 12] = [ ("roj", [0.82, 0.28, 0.26]), ("celest", [0.50, 0.75, 0.92]), ("azul", [0.22, 0.55, 0.78]), ("verd", [0.30, 0.70, 0.40]), ("amarill", [0.92, 0.80, 0.30]), ("naranj", [0.90, 0.50, 0.22]), ("violet", [0.62, 0.40, 0.78]), ("morad", [0.62, 0.40, 0.78]), ("rosa", [0.90, 0.55, 0.70]), ("blanc", [0.92, 0.92, 0.92]), ("negr", [0.14, 0.14, 0.16]), ("marr", [0.45, 0.32, 0.22]), ]; table.iter().find(|(k, _)| p.contains(k)).map(|(_, c)| *c) } /// Nombre de escena = primeras ~4 palabras de la descripción. fn scene_name(prompt: &str) -> String { name_from(prompt, "escena IA") } /// Nombre a partir de las primeras ~4 palabras del prompt, con un default. fn name_from(prompt: &str, default: &str) -> String { let s: String = prompt.split_whitespace().take(4).collect::>().join(" "); if s.is_empty() { default.into() } else { s } } #[cfg(test)] mod tests { use super::*; fn refs() -> MatRefs { MatRefs::from_project(&Project::starter()) } #[test] fn heuristica_detecta_desierto_con_cactus() { let m = refs(); let b = generate_bioma("un desierto enorme con cactus", &m); assert_eq!(b.ground, m.sand); assert!(b.objetos.iter().any(|o| o.material == m.cactus)); } #[test] fn heuristica_detecta_montañas_nevadas() { let m = refs(); let b = generate_bioma("cordillera con picos de nieve", &m); assert_eq!(b.peak, Some(m.snow)); assert!(b.mountains >= 0.65); } #[test] fn bioma_referencia_materiales_del_proyecto() { let p = Project::starter(); let m = MatRefs::from_project(&p); let b = generate_bioma("pradera verde con ríos", &m); // Los ids referenciados existen como materiales del proyecto. assert!(p.material(b.ground).is_some()); assert!(p.material(b.cliff).is_some()); } #[test] fn personaje_lee_edad_y_color() { use llimphi_voxel::Age; let c = local_character("un niño de remera roja"); assert_eq!(c.age, Age::Child); assert_eq!(c.shirt, [0.82, 0.28, 0.26]); } #[test] fn escena_lee_cantidad_y_gesto() { let s = local_scene("dos personajes que festejan", 7, [128, 56, 128]); assert_eq!(s.actors.len(), 2); assert_eq!(s.mundo, 7); let tiene_cheer = s.actors[0].keys.iter().any(|k| k.clip == Some(Clip::Cheer)); assert!(tiene_cheer, "festejar → Cheer"); } }