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:
+137
-16
@@ -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(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user