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:
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
@@ -20,8 +22,9 @@
use std::fs::read_to_string;
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 {
Text(String),
@@ -140,7 +143,7 @@ impl Story {
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);
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.
fn sync_to_kv(&self, kv: &mut Store) {
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);
}
}
}
}
/// [`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)]
mod tests {
use super::*;
use bladeink::value_type::ValueType;
// Handkompiliertes Minimal-Ink (inklecate-Format wie cube.ink.json):
// VAR snaks = 0
@@ -183,7 +208,7 @@ mod tests {
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!(matches!(kv["snaks"], Value::Int(1)));
assert!(!kv.contains_key("_local"), "_-Prefix ist skript-lokal");
}
@@ -191,7 +216,7 @@ mod tests {
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));
kv.insert("snaks".into(), Value::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"),
+76 -34
View File
@@ -3,40 +3,67 @@
//! Single Source of Truth für alle Spielvariablen (Quest-Flags, Inventar-
//! Counter, Story-Beats). Ink-Skripte haben eigene `variables_state`-Stores;
//! 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.
//!
//! Wir nutzen `bladeink::ValueType` als Value, damit die Sync-Konvertierung
//! gratis ist (kein Mapping zwischen Rust- und Ink-Typen nötig).
//! [`Value`] ist ein eigener, Ink- und frontend-neutraler Typ. Die Umrechnung
//! 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::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:
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
pub fn parse_value(s: &str) -> ValueType {
let s = s.trim();
match s {
"true" => return ValueType::Bool(true),
"false" => return ValueType::Bool(false),
_ => {}
impl Value {
/// Als Ganzzahl lesen (für `inc`): `Bool` zählt als 0/1, `Float` schneidet
/// ab, `Str` hat keinen Zahlwert. Spiegelt die Coercion, die vorher
/// bladeink lieferte.
pub fn as_int(&self) -> Option<i32> {
match self {
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.
pub fn format_value(v: &ValueType) -> String {
match v {
ValueType::Bool(b) => b.to_string(),
ValueType::Int(i) => i.to_string(),
ValueType::Float(f) => f.to_string(),
other => other.coerce_to_string().unwrap_or_else(|_| "<?>".into()),
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Value::Bool(b) => write!(f, "{b}"),
Value::Int(i) => write!(f, "{i}"),
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) {
@@ -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 = name.trim();
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);
store.insert(name.to_string(), ValueType::Int(cur + delta));
let cur = store.get(name).and_then(Value::as_int).unwrap_or(0);
store.insert(name.to_string(), Value::Int(cur + delta));
}
pub fn apply_clear(args: &str, store: &mut Store) {
@@ -65,29 +92,37 @@ mod tests {
#[test]
fn parse_value_types() {
assert!(matches!(parse_value("true"), ValueType::Bool(true)));
assert!(matches!(parse_value("false"), ValueType::Bool(false)));
assert!(matches!(parse_value("42"), ValueType::Int(42)));
assert!(matches!(parse_value("-7"), ValueType::Int(-7)));
assert!(matches!(parse_value("1.5"), ValueType::Float(f) if f == 1.5));
assert!(matches!(parse_value("hallo"), ValueType::String(_)));
assert!(matches!(parse_value("true"), Value::Bool(true)));
assert!(matches!(parse_value("false"), Value::Bool(false)));
assert!(matches!(parse_value("42"), Value::Int(42)));
assert!(matches!(parse_value("-7"), Value::Int(-7)));
assert!(matches!(parse_value("1.5"), Value::Float(f) if f == 1.5));
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]
fn set_inc_clear_roundtrip() {
let mut s = Store::new();
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);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 4);
assert_eq!(s["clicks"].as_int().unwrap(), 4);
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
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);
assert!(!s.contains_key("clicks"));
@@ -99,4 +134,11 @@ mod tests {
apply_set("only_name", &mut s);
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()));
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![
Action::PlaySound("Mushroom.005".into()),
]);
@@ -259,7 +259,7 @@ mod tests {
let mut ctx = game.action_ctx(None);
dispatch("thing", &mut ctx);
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]
@@ -268,7 +268,7 @@ mod tests {
let mut ctx = game.action_ctx(None);
// Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen.
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.
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::player::{self, Player};
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).
const LOOK_SENS: f32 = 0.0025;
@@ -393,7 +393,7 @@ impl Session {
let mut keys: Vec<&String> = self.game.kv.keys().collect();
keys.sort();
keys.into_iter()
.map(|k| format!(" {k} = {}", kv::format_value(&self.game.kv[k])))
.map(|k| format!(" {k} = {}", self.game.kv[k]))
.collect()
}
}
@@ -416,6 +416,7 @@ fn help() -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::kv::Value;
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
// 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`.
let r = s.exec("signal set has_key true");
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));
}
@@ -456,7 +457,7 @@ mod tests {
assert!(matches!(s.mode, Mode::Menu));
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
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]
@@ -571,7 +572,7 @@ mod tests {
// Vorwärts (−Z, yaw=0) in die Zone laufen; irgendwann feuert die
// Flanke genau einmal (danach: drin = still).
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.
s.game.kv.remove("zone_hit");
@@ -586,7 +587,7 @@ mod tests {
s.mode = Mode::FirstPerson;
// Klick-Ziel: feuert den Property-Wert, nicht signal_key("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
// unbekannt und ohne Table ein stilles No-Op, aber kein Fehler.
let r = s.exec("use Irgendwas.003");