//! Dünner Adapter um die `bladeink`-Crate, gekoppelt an unseren `kv::Store`. //! //! Bei jedem `cont()` läuft ein bidirektionaler Sync: //! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln //! (Skript sieht aktuelle Spielvariablen) //! - nach `continue`: **alle** im Skript deklarierten `VAR`s zurücklesen //! (Auto-Import). So wandern Werte wie `snaks` oder `favor_nails` ohne //! Registrierung automatisch ins nächste per `start_ink` verkettete //! Skript — die Autorin legt einfach `VAR`s an, die Engine trägt sie. //! //! Ausnahme: `VAR`s mit `_`-Prefix sind skript-lokal und werden in keine //! Richtung synchronisiert. KV-Variablen, die das Skript nicht als `VAR` //! deklariert, lehnt bladeink beim `set_variable` ab — wir ignorieren das //! still. //! //! bladeink bietet keine öffentliche Enumeration der Globals; die //! Deklarationsliste wird deshalb beim Laden per Quelltext-Scan aus dem //! kompilierten JSON gezogen (siehe [`global_var_names`]). use std::fs::read_to_string; use bladeink::story::Story as BladeStory; use crate::engine::kv::Store; pub enum StoryState { Text(String), Choice { prompt: String, options: Vec }, End, } pub struct Story { inner: BladeStory, tags: Vec, /// Im Skript deklarierte globale `VAR`s (ohne `_`-lokale) — die Menge, /// die `sync_to_kv` in den KV exportiert. vars: Vec, /// Skript-Pfad, nur für Fehlermeldungen. path: String, } /// Alle globalen `VAR`-Namen aus kompiliertem Ink-JSON ziehen. Inklecate /// kodiert jede Global-Zuweisung — die Deklaration in `"global decl"` ebenso /// wie jedes `~ var = …` — als `{"VAR=":"name"}`; ein simpler Scan über den /// Quelltext findet also genau die Globals (temp-Variablen kodieren als /// `"temp="` und Reads als `"VAR?"`). `_`-Prefix = skript-lokal → aussortiert. fn global_var_names(src: &str) -> Vec { const PAT: &str = "\"VAR=\":\""; let mut names: Vec = Vec::new(); let mut rest = src; while let Some(i) = rest.find(PAT) { rest = &rest[i + PAT.len()..]; if let Some(end) = rest.find('"') { let name = &rest[..end]; if !name.starts_with('_') && !names.iter().any(|n| n == name) { names.push(name.to_string()); } rest = &rest[end..]; } } names } impl Story { /// Lädt und parst ein Ink-Skript. Fehlende oder kaputte Datei liefert /// `Err(msg)` statt zu panicken — ein Tippfehler in einem `start_ink`-Tag /// darf das laufende Spiel nicht crashen; der Aufrufer meldet den Fehler. pub fn load(path: &str, kv: &Store) -> Result { let src = read_to_string(path) .map_err(|e| format!("ink load {path}: {e}"))?; Self::from_src(&src, path, kv) } /// Wie [`load`], nur direkt aus dem JSON-Quelltext — `path` dient /// ausschließlich Fehlermeldungen. Eigener Schritt, damit Tests ohne /// Dateisystem arbeiten können. fn from_src(src: &str, path: &str, kv: &Store) -> Result { let inner = BladeStory::new(src) .map_err(|e| format!("ink parse {path}: {e:?}"))?; let mut s = Self { inner, tags: Vec::new(), vars: global_var_names(src), path: path.to_string(), }; s.sync_from_kv(kv); Ok(s) } pub fn take_tags(&mut self) -> Vec { std::mem::take(&mut self.tags) } pub fn cont(&mut self, kv: &mut Store) -> StoryState { self.sync_from_kv(kv); // continue_maximally ruft intern reset_output() vor jedem cont() — // damit überschriebe der Tag der letzten Zeile alle vorherigen. // Wir loopen selbst und sammeln Tags nach jedem Schritt ein, sonst // gehen z.B. `# set has_key true` am Knoten-Anfang bei mehrzeiligem // Text verloren. let mut text = String::new(); while self.inner.can_continue() { // Runtime-Fehler (z.B. „ran out of content" bei einem Skript-Pfad // ohne Divert) beenden den Dialog und melden nach stderr — ein // Autoren-Fehler im Ink darf das laufende Spiel nicht crashen. let chunk = match self.inner.cont() { Ok(c) => c, Err(e) => { eprintln!("[ink] {}: {e:?}", self.path); return StoryState::End; } }; text.push_str(&chunk); if let Ok(t) = self.inner.get_current_tags() { self.tags.extend(t); } } let text = text.trim().to_string(); self.sync_to_kv(kv); let choices = self.inner.get_current_choices(); if !choices.is_empty() { let options = choices.iter().map(|c| c.text.clone()).collect(); return StoryState::Choice { prompt: text, options }; } if !text.is_empty() { return StoryState::Text(text); } StoryState::End } pub fn choose(&mut self, i: usize) { // Fehler (z.B. Index außerhalb der Choices) nur melden — der nächste // `cont()` präsentiert dann denselben Zustand erneut. if let Err(e) = self.inner.choose_choice_index(i) { eprintln!("[ink] {}: choose {i}: {e:?}", self.path); } } fn sync_from_kv(&mut self, kv: &Store) { for (name, value) in kv { if name.starts_with('_') { continue; } // skript-lokal // set_variable schlägt fehl wenn der Name kein deklariertes // VAR im Skript ist — still ignorieren. let _ = self.inner.set_variable(name, value); } } /// Auto-Import: alle deklarierten `VAR`s des Skripts in den KV übernehmen /// (siehe Modul-Doc). Der KV wächst damit um jede neue Skript-Variable — /// genau so wandern Werte in per `start_ink` verkettete Folgeskripte. fn sync_to_kv(&self, kv: &mut Store) { for name in &self.vars { if let Some(v) = self.inner.get_variable(name) { kv.insert(name.clone(), v); } } } } #[cfg(test)] mod tests { use super::*; use bladeink::value_type::ValueType; // Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json): // VAR snaks = 0 // VAR _local = 0 // S={snaks} → gibt den (ggf. aus dem KV gesyncten) Wert aus // ~ snaks = 1 // ~ _local = 5 // -> END const MINIMAL: &str = r##"{"inkVersion":21,"root":[["^S=","ev",{"VAR?":"snaks"},"out","/ev","\n","ev",1,{"VAR=":"snaks","re":true},"/ev","ev",5,{"VAR=":"_local","re":true},"/ev","end",["done",{"#n":"g-0"}],null],"done",{"global decl":["ev",0,{"VAR=":"snaks"},0,{"VAR=":"_local"},"/ev","end",null]}],"listDefs":{}}"##; #[test] fn global_var_names_scans_dedupes_and_filters_locals() { let names = global_var_names(MINIMAL); assert_eq!(names, vec!["snaks"]); // dedupliziert, `_local` gefiltert } #[test] fn declared_vars_auto_import_into_kv() { // KV kennt `snaks` vorher nicht — nach dem Lauf steht es trotzdem // drin (Auto-Import), `_local` bleibt draußen. let mut kv = Store::new(); let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap(); assert!(matches!(s.cont(&mut kv), StoryState::Text(_))); assert!(matches!(kv["snaks"], ValueType::Int(1))); assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal"); } #[test] fn kv_values_flow_into_the_script() { // Vorbelegter KV-Wert ist im Skript sichtbar (Sync vor `continue`). let mut kv = Store::new(); kv.insert("snaks".into(), ValueType::Int(7)); let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap(); match s.cont(&mut kv) { StoryState::Text(t) => assert_eq!(t, "S=7"), _ => panic!("erwartete Textzeile"), } } #[test] fn runtime_error_ends_story_instead_of_panicking() { // Flow läuft ohne `done`/`end` aus (wie ein Skript-Pfad ohne Divert) // → bladeink meldet „ran out of content". Erwartung: End, kein Panic. let src = r#"{"inkVersion":21,"root":[["^Hi","\n",null],null],"listDefs":{}}"#; let mut kv = Store::new(); let mut s = Story::from_src(src, "test", &kv).unwrap(); assert!(matches!(s.cont(&mut kv), StoryState::End)); } #[test] fn invalid_choice_is_reported_not_fatal() { let mut kv = Store::new(); let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap(); s.choose(3); // keine Choices vorhanden → Meldung, kein Panic assert!(matches!(s.cont(&mut kv), StoryState::Text(_))); } }