kv::Store von bladeink entkoppeln

Der Kern-KV war HashMap<String, bladeink::ValueType> — jedes Modul, das
eine Spielvariable las, hing damit an der öffentlichen API der Story-Crate.
"Konvertierung gratis" war der Grund, aber der Preis war eine
Dependency-Kante quer durch engine/.

Neuer eigener kv::Value (Bool/Int/Float/Str). Die Umrechnung auf Inks
ValueType passiert jetzt allein in ink.rs (to_ink/from_ink) — die einzige
Stelle im Kern, die bladeink überhaupt kennt. from_ink verwirft
Ink-eigene Typen (Listen, Divert-Referenzen), die im KV nichts verloren
haben.

- format_value → Display für Value
- coerce_to_int → Value::as_int (gleiche Semantik); coerce_to_bool hatte
  außer Tests keinen Aufrufer und entfällt, die Tests prüfen jetzt die
  gespeicherte Variante direkt
- bladeink bleibt Dependency (ink.rs braucht es), nur nicht mehr im
  KV-Vertrag

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0196AM4UyAJEXVtPvp5DdKr8
This commit is contained in:
irrlicht
2026-09-01 22:54:52 +02:00
co-authored by Claude Sonnet 5
parent 247f7ef9fe
commit 236a643d04
4 changed files with 118 additions and 50 deletions
+32 -7
View File
@@ -1,4 +1,6 @@
//! Dünner Adapter um die `bladeink`-Crate, gekoppelt an unseren `kv::Store`. //! Dünner Adapter um die `bladeink`-Crate — und die einzige Stelle im Kern,
//! die sie kennt. `to_ink`/`from_ink` rechnen zwischen `kv::Value` und Inks
//! `ValueType` um; alles andere spricht nur den neutralen KV-Typ.
//! //!
//! 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
@@ -20,8 +22,9 @@
use std::fs::read_to_string; use std::fs::read_to_string;
use bladeink::story::Story as BladeStory; use bladeink::story::Story as BladeStory;
use bladeink::value_type::ValueType;
use crate::engine::kv::Store; use crate::engine::kv::{Store, Value};
pub enum StoryState { pub enum StoryState {
Text(String), Text(String),
@@ -140,7 +143,7 @@ impl Story {
if name.starts_with('_') { continue; } // skript-lokal 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, &to_ink(value));
} }
} }
@@ -149,17 +152,39 @@ impl Story {
/// genau so wandern Werte in per `start_ink` verkettete Folgeskripte. /// 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) {
for name in &self.vars { for name in &self.vars {
if let Some(v) = self.inner.get_variable(name) { if let Some(v) = self.inner.get_variable(name).as_ref().and_then(from_ink) {
kv.insert(name.clone(), v); kv.insert(name.clone(), v);
} }
} }
} }
} }
/// [`Value`] → Inks `ValueType`. Zusammen mit [`from_ink`] die einzige Stelle,
/// an der der Kern-KV die Story-Crate berührt.
fn to_ink(v: &Value) -> ValueType {
match v {
Value::Bool(b) => ValueType::Bool(*b),
Value::Int(i) => ValueType::Int(*i),
Value::Float(f) => ValueType::Float(*f),
Value::Str(s) => ValueType::new(s.as_str()),
}
}
/// Inks `ValueType` → [`Value`]. `None` für Ink-eigene Typen (Listen, Divert-
/// und Variablen-Referenzen), die im KV nichts verloren haben.
fn from_ink(v: &ValueType) -> Option<Value> {
Some(match v {
ValueType::Bool(b) => Value::Bool(*b),
ValueType::Int(i) => Value::Int(*i),
ValueType::Float(f) => Value::Float(*f),
ValueType::String(s) => Value::Str(s.string.clone()),
_ => return None,
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use bladeink::value_type::ValueType;
// Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json): // Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json):
// VAR snaks = 0 // VAR snaks = 0
@@ -183,7 +208,7 @@ mod tests {
let mut kv = Store::new(); let mut kv = Store::new();
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap(); let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
assert!(matches!(s.cont(&mut kv), StoryState::Text(_))); assert!(matches!(s.cont(&mut kv), StoryState::Text(_)));
assert!(matches!(kv["snaks"], ValueType::Int(1))); assert!(matches!(kv["snaks"], Value::Int(1)));
assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal"); assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal");
} }
@@ -191,7 +216,7 @@ mod tests {
fn kv_values_flow_into_the_script() { fn kv_values_flow_into_the_script() {
// Vorbelegter KV-Wert ist im Skript sichtbar (Sync vor `continue`). // Vorbelegter KV-Wert ist im Skript sichtbar (Sync vor `continue`).
let mut kv = Store::new(); let mut kv = Store::new();
kv.insert("snaks".into(), ValueType::Int(7)); kv.insert("snaks".into(), Value::Int(7));
let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap(); let mut s = Story::from_src(MINIMAL, "test", &kv).unwrap();
match s.cont(&mut kv) { match s.cont(&mut kv) {
StoryState::Text(t) => assert_eq!(t, "S=7"), StoryState::Text(t) => assert_eq!(t, "S=7"),
+76 -34
View File
@@ -3,41 +3,68 @@
//! Single Source of Truth für alle Spielvariablen (Quest-Flags, Inventar- //! Single Source of Truth für alle Spielvariablen (Quest-Flags, Inventar-
//! Counter, Story-Beats). Ink-Skripte haben eigene `variables_state`-Stores; //! Counter, Story-Beats). Ink-Skripte haben eigene `variables_state`-Stores;
//! die werden bei jedem `cont()` mit diesem KV synchronisiert (siehe //! die werden bei jedem `cont()` mit diesem KV synchronisiert (siehe
//! [`crate::engine::ink`]). Damit der KV authoritativ bleibt: externe Actions //! [`crate::engine::ink`]). Damit der KV autoritativ bleibt: externe Actions
//! (`set`, `inc`, `clear`) ändern *immer* den KV, niemals direkt die Story. //! (`set`, `inc`, `clear`) ändern *immer* den KV, niemals direkt die Story.
//! //!
//! Wir nutzen `bladeink::ValueType` als Value, damit die Sync-Konvertierung //! [`Value`] ist ein eigener, Ink- und frontend-neutraler Typ. Die Umrechnung
//! gratis ist (kein Mapping zwischen Rust- und Ink-Typen nötig). //! auf `bladeink::ValueType` passiert allein an der Ink-Grenze in
//! [`crate::engine::ink`] — so hängt der Kern-KV nicht an der Story-Crate.
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt;
use bladeink::value_type::ValueType; pub type Store = HashMap<String, Value>;
pub type Store = HashMap<String, ValueType>; /// Ein KV-Wert. Deckt die Skalare ab, die signals.toml-Actions und Ink-`VAR`s
/// austauschen; Ink-eigene Typen (Listen, Divert-Referenzen) haben hier
/// bewusst keinen Platz.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Bool(bool),
Int(i32),
Float(f32),
Str(String),
}
/// Parst einen Action-Arg-String zu einem `ValueType`. Reihenfolge: impl Value {
/// `true`/`false` → Bool, dann i32, dann f32, sonst String. /// Als Ganzzahl lesen (für `inc`): `Bool` zählt als 0/1, `Float` schneidet
pub fn parse_value(s: &str) -> ValueType { /// ab, `Str` hat keinen Zahlwert. Spiegelt die Coercion, die vorher
let s = s.trim(); /// bladeink lieferte.
match s { pub fn as_int(&self) -> Option<i32> {
"true" => return ValueType::Bool(true), match self {
"false" => return ValueType::Bool(false), Value::Bool(b) => Some(*b as i32),
_ => {} Value::Int(i) => Some(*i),
Value::Float(f) => Some(*f as i32),
Value::Str(_) => None,
}
} }
if let Ok(i) = s.parse::<i32>() { return ValueType::Int(i); }
if let Ok(f) = s.parse::<f32>() { return ValueType::Float(f); }
ValueType::new(s)
} }
/// Menschenlesbare Darstellung für REPL-Dumps und Debug-Logs. /// Menschenlesbare Darstellung für REPL-Dumps und Debug-Logs.
pub fn format_value(v: &ValueType) -> String { impl fmt::Display for Value {
match v { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
ValueType::Bool(b) => b.to_string(), match self {
ValueType::Int(i) => i.to_string(), Value::Bool(b) => write!(f, "{b}"),
ValueType::Float(f) => f.to_string(), Value::Int(i) => write!(f, "{i}"),
other => other.coerce_to_string().unwrap_or_else(|_| "<?>".into()), Value::Float(x) => write!(f, "{x}"),
Value::Str(s) => write!(f, "{s}"),
} }
} }
}
/// Parst einen Action-Arg-String zu einem [`Value`]. Reihenfolge:
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
pub fn parse_value(s: &str) -> Value {
let s = s.trim();
match s {
"true" => return Value::Bool(true),
"false" => return Value::Bool(false),
_ => {}
}
if let Ok(i) = s.parse::<i32>() { return Value::Int(i); }
if let Ok(x) = s.parse::<f32>() { return Value::Float(x); }
Value::Str(s.to_string())
}
pub fn apply_set(args: &str, store: &mut Store) { pub fn apply_set(args: &str, store: &mut Store) {
let Some((name, value)) = args.split_once(char::is_whitespace) else { let Some((name, value)) = args.split_once(char::is_whitespace) else {
@@ -51,8 +78,8 @@ pub fn apply_inc(args: &str, store: &mut Store) {
let (name, delta) = args.split_once(char::is_whitespace).unwrap_or((args, "1")); let (name, delta) = args.split_once(char::is_whitespace).unwrap_or((args, "1"));
let name = name.trim(); let name = name.trim();
let delta = delta.trim().parse::<i32>().unwrap_or(1); let delta = delta.trim().parse::<i32>().unwrap_or(1);
let cur = store.get(name).and_then(|v| v.coerce_to_int().ok()).unwrap_or(0); let cur = store.get(name).and_then(Value::as_int).unwrap_or(0);
store.insert(name.to_string(), ValueType::Int(cur + delta)); store.insert(name.to_string(), Value::Int(cur + delta));
} }
pub fn apply_clear(args: &str, store: &mut Store) { pub fn apply_clear(args: &str, store: &mut Store) {
@@ -65,29 +92,37 @@ mod tests {
#[test] #[test]
fn parse_value_types() { fn parse_value_types() {
assert!(matches!(parse_value("true"), ValueType::Bool(true))); assert!(matches!(parse_value("true"), Value::Bool(true)));
assert!(matches!(parse_value("false"), ValueType::Bool(false))); assert!(matches!(parse_value("false"), Value::Bool(false)));
assert!(matches!(parse_value("42"), ValueType::Int(42))); assert!(matches!(parse_value("42"), Value::Int(42)));
assert!(matches!(parse_value("-7"), ValueType::Int(-7))); assert!(matches!(parse_value("-7"), Value::Int(-7)));
assert!(matches!(parse_value("1.5"), ValueType::Float(f) if f == 1.5)); assert!(matches!(parse_value("1.5"), Value::Float(f) if f == 1.5));
assert!(matches!(parse_value("hallo"), ValueType::String(_))); assert!(matches!(parse_value("hallo"), Value::Str(_)));
}
#[test]
fn as_int_coerces_like_ink_did() {
assert_eq!(Value::Bool(true).as_int(), Some(1));
assert_eq!(Value::Int(-7).as_int(), Some(-7));
assert_eq!(Value::Float(2.9).as_int(), Some(2));
assert_eq!(Value::Str("x".into()).as_int(), None);
} }
#[test] #[test]
fn set_inc_clear_roundtrip() { fn set_inc_clear_roundtrip() {
let mut s = Store::new(); let mut s = Store::new();
apply_set("clicks 3", &mut s); apply_set("clicks 3", &mut s);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 3); assert_eq!(s["clicks"].as_int().unwrap(), 3);
apply_inc("clicks", &mut s); apply_inc("clicks", &mut s);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 4); assert_eq!(s["clicks"].as_int().unwrap(), 4);
apply_inc("clicks -2", &mut s); apply_inc("clicks -2", &mut s);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 2); assert_eq!(s["clicks"].as_int().unwrap(), 2);
// inc auf unbekannten Key startet bei 0 // inc auf unbekannten Key startet bei 0
apply_inc("fresh 5", &mut s); apply_inc("fresh 5", &mut s);
assert_eq!(s["fresh"].coerce_to_int().unwrap(), 5); assert_eq!(s["fresh"].as_int().unwrap(), 5);
apply_clear("clicks", &mut s); apply_clear("clicks", &mut s);
assert!(!s.contains_key("clicks")); assert!(!s.contains_key("clicks"));
@@ -99,4 +134,11 @@ mod tests {
apply_set("only_name", &mut s); apply_set("only_name", &mut s);
assert!(s.is_empty()); assert!(s.is_empty());
} }
#[test]
fn display_is_plain_scalar() {
assert_eq!(Value::Bool(true).to_string(), "true");
assert_eq!(Value::Int(-3).to_string(), "-3");
assert_eq!(Value::Str("hi".into()).to_string(), "hi");
}
} }
+3 -3
View File
@@ -209,7 +209,7 @@ mod tests {
let mut ctx = game.action_ctx(Some("Mushroom.005".into())); let mut ctx = game.action_ctx(Some("Mushroom.005".into()));
dispatch("pickup", &mut ctx); dispatch("pickup", &mut ctx);
assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1); assert_eq!(game.kv["items"].as_int().unwrap(), 1);
assert_eq!(game.actions, vec![ assert_eq!(game.actions, vec![
Action::PlaySound("Mushroom.005".into()), Action::PlaySound("Mushroom.005".into()),
]); ]);
@@ -259,7 +259,7 @@ mod tests {
let mut ctx = game.action_ctx(None); let mut ctx = game.action_ctx(None);
dispatch("thing", &mut ctx); dispatch("thing", &mut ctx);
assert!(!game.kv.contains_key("has_key"), "Tippfehler darf nichts setzen"); assert!(!game.kv.contains_key("has_key"), "Tippfehler darf nichts setzen");
assert!(game.kv["ok"].coerce_to_bool().unwrap(), "gültige Action wirkt weiter"); assert!(matches!(game.kv["ok"], kv::Value::Bool(true)), "gültige Action wirkt weiter");
} }
#[test] #[test]
@@ -268,7 +268,7 @@ mod tests {
let mut ctx = game.action_ctx(None); let mut ctx = game.action_ctx(None);
// Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen. // Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen.
dispatch("set has_key true", &mut ctx); dispatch("set has_key true", &mut ctx);
assert!(game.kv["has_key"].coerce_to_bool().unwrap()); assert!(matches!(game.kv["has_key"], kv::Value::Bool(true)));
// Unbekanntes Verb ist ein stilles No-Op. // Unbekanntes Verb ist ein stilles No-Op.
let mut ctx = game.action_ctx(None); let mut ctx = game.action_ctx(None);
+7 -6
View File
@@ -4,7 +4,7 @@ use crate::engine::game::{Action, Game, ModeTarget};
use crate::engine::ink::StoryState; use crate::engine::ink::StoryState;
use crate::engine::player::{self, Player}; use crate::engine::player::{self, Player};
use crate::engine::trigger::Triggers; use crate::engine::trigger::Triggers;
use crate::engine::{kv, model, signals, story_ctrl}; use crate::engine::{model, signals, story_ctrl};
/// Maus-Empfindlichkeit der Sicht (Radiant/Pixel). /// Maus-Empfindlichkeit der Sicht (Radiant/Pixel).
const LOOK_SENS: f32 = 0.0025; const LOOK_SENS: f32 = 0.0025;
@@ -393,7 +393,7 @@ impl Session {
let mut keys: Vec<&String> = self.game.kv.keys().collect(); let mut keys: Vec<&String> = self.game.kv.keys().collect();
keys.sort(); keys.sort();
keys.into_iter() keys.into_iter()
.map(|k| format!(" {k} = {}", kv::format_value(&self.game.kv[k]))) .map(|k| format!(" {k} = {}", self.game.kv[k]))
.collect() .collect()
} }
} }
@@ -416,6 +416,7 @@ fn help() -> Vec<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::engine::kv::Value;
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler), // Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
// also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet // also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
@@ -440,7 +441,7 @@ mod tests {
// Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`. // Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`.
let r = s.exec("signal set has_key true"); let r = s.exec("signal set has_key true");
assert!(!r.quit); assert!(!r.quit);
assert!(s.game.kv["has_key"].coerce_to_bool().unwrap()); assert!(matches!(s.game.kv["has_key"], Value::Bool(true)));
assert!(matches!(s.mode, Mode::Free)); assert!(matches!(s.mode, Mode::Free));
} }
@@ -456,7 +457,7 @@ mod tests {
assert!(matches!(s.mode, Mode::Menu)); assert!(matches!(s.mode, Mode::Menu));
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free). // Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
s.exec("signal set in_menu true"); s.exec("signal set in_menu true");
assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap()); assert!(matches!(s.game.kv["in_menu"], Value::Bool(true)));
} }
#[test] #[test]
@@ -571,7 +572,7 @@ mod tests {
// Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die // Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die
// Flanke genau einmal (danach: drin = still). // Flanke genau einmal (danach: drin = still).
for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); } for _ in 0..120 { s.tick(&FrameInput { fwd: 1.0, ..Default::default() }, FIXED_DT); }
assert!(s.game.kv["zone_hit"].coerce_to_bool().unwrap(), "Zone sollte gefeuert haben"); assert!(matches!(s.game.kv["zone_hit"], Value::Bool(true)), "Zone sollte gefeuert haben");
// Weiterlaufen in der Zone darf nicht erneut feuern. // Weiterlaufen in der Zone darf nicht erneut feuern.
s.game.kv.remove("zone_hit"); s.game.kv.remove("zone_hit");
@@ -586,7 +587,7 @@ mod tests {
s.mode = Mode::FirstPerson; s.mode = Mode::FirstPerson;
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste"). // Klick-Ziel: feuert den Property-Wert, nicht signal_key("Kiste").
s.exec("use Kiste"); s.exec("use Kiste");
assert!(s.game.kv["kiste_geklickt"].coerce_to_bool().unwrap()); assert!(matches!(s.game.kv["kiste_geklickt"], Value::Bool(true)));
// Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier // Ohne Property wie gehabt: Name (suffix-gestrippt) als Signal — hier
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler. // unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
let r = s.exec("use Irgendwas.003"); let r = s.exec("use Irgendwas.003");