67c4a366c7
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>
220 lines
8.6 KiB
Rust
220 lines
8.6 KiB
Rust
//! 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<String> },
|
|
End,
|
|
}
|
|
|
|
pub struct Story {
|
|
inner: BladeStory,
|
|
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 {
|
|
/// 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<Self, String> {
|
|
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<Self, String> {
|
|
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<String> { 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(_)));
|
|
}
|
|
}
|