Ink: KV-Auto-Import und Crash-Härtung

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:19:46 +02:00
parent 31134f1494
commit 67c4a366c7
+137 -16
View File
@@ -3,13 +3,19 @@
//! Bei jedem `cont()` läuft ein bidirektionaler Sync: //! Bei jedem `cont()` läuft ein bidirektionaler Sync:
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln //! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
//! (Skript sieht aktuelle Spielvariablen) //! (Skript sieht aktuelle Spielvariablen)
//! - nach `continue`: alle KV-Keys, die das Skript kennt, zurücklesen //! - nach `continue`: **alle** im Skript deklarierten `VAR`s zurücklesen
//! (Ink-`~ var = …`-Writes landen im KV) //! (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, //! Ausnahme: `VAR`s mit `_`-Prefix sind skript-lokal und werden in keine
//! lehnt bladeink beim `set_variable` ab — wir ignorieren das still. //! Richtung synchronisiert. KV-Variablen, die das Skript nicht als `VAR`
//! Variablen die nur im Skript existieren, werden beim Read-Back ignoriert //! deklariert, lehnt bladeink beim `set_variable` ab — wir ignorieren das
//! (KV bleibt minimal: nur was Rust kennt). //! 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 std::fs::read_to_string;
@@ -26,6 +32,33 @@ pub enum StoryState {
pub struct Story { pub struct Story {
inner: BladeStory, inner: BladeStory,
tags: Vec<String>, tags: Vec<String>,
/// Im Skript deklarierte globale `VAR`s (ohne `_`-lokale) — die Menge,
/// die `sync_to_kv` in den KV exportiert.
vars: Vec<String>,
/// 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<String> {
const PAT: &str = "\"VAR=\":\"";
let mut names: Vec<String> = 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 { impl Story {
@@ -35,9 +68,21 @@ impl Story {
pub fn load(path: &str, kv: &Store) -> Result<Self, String> { pub fn load(path: &str, kv: &Store) -> Result<Self, String> {
let src = read_to_string(path) let src = read_to_string(path)
.map_err(|e| format!("ink load {path}: {e}"))?; .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<Self, String> {
let inner = BladeStory::new(src)
.map_err(|e| format!("ink parse {path}: {e:?}"))?; .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); s.sync_from_kv(kv);
Ok(s) Ok(s)
} }
@@ -54,8 +99,16 @@ impl Story {
// Text verloren. // Text verloren.
let mut text = String::new(); let mut text = String::new();
while self.inner.can_continue() { while self.inner.can_continue() {
let chunk = self.inner.cont() // Runtime-Fehler (z.B. „ran out of content" bei einem Skript-Pfad
.unwrap_or_else(|e| panic!("ink cont: {:?}", e)); // 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); text.push_str(&chunk);
if let Ok(t) = self.inner.get_current_tags() { if let Ok(t) = self.inner.get_current_tags() {
self.tags.extend(t); self.tags.extend(t);
@@ -75,24 +128,92 @@ impl Story {
} }
pub fn choose(&mut self, i: usize) { pub fn choose(&mut self, i: usize) {
self.inner.choose_choice_index(i) // Fehler (z.B. Index außerhalb der Choices) nur melden — der nächste
.unwrap_or_else(|e| panic!("ink choose {}: {:?}", i, e)); // `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) { fn sync_from_kv(&mut self, kv: &Store) {
for (name, value) in kv { for (name, value) in kv {
if name.starts_with('_') { continue; } // skript-lokal
// set_variable schlägt fehl wenn der Name kein deklariertes // set_variable schlägt fehl wenn der Name kein deklariertes
// VAR im Skript ist — still ignorieren. // VAR im Skript ist — still ignorieren.
let _ = self.inner.set_variable(name, value); 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) { fn sync_to_kv(&self, kv: &mut Store) {
let keys: Vec<String> = kv.keys().cloned().collect(); for name in &self.vars {
for name in keys { if let Some(v) = self.inner.get_variable(name) {
if let Some(v) = self.inner.get_variable(&name) { kv.insert(name.clone(), v);
kv.insert(name, 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(_)));
}
}