From 67c4a366c7532e22eb7bdf71f32986351bace8f6 Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sun, 12 Jul 2026 15:19:46 +0200 Subject: [PATCH] =?UTF-8?q?Ink:=20KV-Auto-Import=20und=20Crash-H=C3=A4rtun?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync_to_kv übernimmt jetzt alle im Skript deklarierten VARs in den KV-Store (Auto-Import per Quelltext-Scan, da bladeink keine Enumeration bietet); `_`-Prefix bleibt skript-lokal. So wandern Werte wie snaks/favor_nails automatisch in per start_ink verkettete Folgeskripte. cont()/choose() melden Ink-Runtime-Fehler nach stderr und beenden den Dialog, statt zu panicken — ein auslaufender Skript-Pfad darf das laufende Spiel nicht crashen. Co-Authored-By: Claude Opus 4.8 --- src/engine/ink.rs | 153 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 137 insertions(+), 16 deletions(-) diff --git a/src/engine/ink.rs b/src/engine/ink.rs index 6e47e3d..3946af5 100644 --- a/src/engine/ink.rs +++ b/src/engine/ink.rs @@ -3,13 +3,19 @@ //! 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 KV-Keys, die das Skript kennt, zurücklesen -//! (Ink-`~ var = …`-Writes landen im KV) +//! - 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. //! -//! Variablen die der KV kennt, das Skript aber nicht als `VAR` deklariert, -//! lehnt bladeink beim `set_variable` ab — wir ignorieren das still. -//! Variablen die nur im Skript existieren, werden beim Read-Back ignoriert -//! (KV bleibt minimal: nur was Rust kennt). +//! 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; @@ -26,6 +32,33 @@ pub enum StoryState { 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 { @@ -35,9 +68,21 @@ impl Story { pub fn load(path: &str, kv: &Store) -> Result { let src = read_to_string(path) .map_err(|e| format!("ink load {path}: {e}"))?; - let inner = BladeStory::new(&src) + 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() }; + let mut s = Self { + inner, + tags: Vec::new(), + vars: global_var_names(src), + path: path.to_string(), + }; s.sync_from_kv(kv); Ok(s) } @@ -54,8 +99,16 @@ impl Story { // Text verloren. let mut text = String::new(); while self.inner.can_continue() { - let chunk = self.inner.cont() - .unwrap_or_else(|e| panic!("ink cont: {:?}", e)); + // 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); @@ -75,24 +128,92 @@ impl Story { } pub fn choose(&mut self, i: usize) { - self.inner.choose_choice_index(i) - .unwrap_or_else(|e| panic!("ink choose {}: {:?}", i, e)); + // 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) { - let keys: Vec = kv.keys().cloned().collect(); - for name in keys { - if let Some(v) = self.inner.get_variable(&name) { - kv.insert(name, v); + 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(_))); + } +}