Files
wds/src/engine/kv.rs
T
irrlichtandClaude Sonnet 5 236a643d04 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
2026-09-01 22:54:52 +02:00

145 lines
4.7 KiB
Rust

//! Persistenter Game-State Key-Value-Store.
//!
//! 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 autoritativ bleibt: externe Actions
//! (`set`, `inc`, `clear`) ändern *immer* den KV, niemals direkt die Story.
//!
//! [`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;
pub type Store = HashMap<String, Value>;
/// 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),
}
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,
}
}
}
/// Menschenlesbare Darstellung für REPL-Dumps und Debug-Logs.
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) {
let Some((name, value)) = args.split_once(char::is_whitespace) else {
eprintln!("[kv] set: expected 'name value', got {:?}", args);
return;
};
store.insert(name.trim().to_string(), parse_value(value));
}
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(Value::as_int).unwrap_or(0);
store.insert(name.to_string(), Value::Int(cur + delta));
}
pub fn apply_clear(args: &str, store: &mut Store) {
store.remove(args.trim());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_value_types() {
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"].as_int().unwrap(), 3);
apply_inc("clicks", &mut s);
assert_eq!(s["clicks"].as_int().unwrap(), 4);
apply_inc("clicks -2", &mut s);
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"].as_int().unwrap(), 5);
apply_clear("clicks", &mut s);
assert!(!s.contains_key("clicks"));
}
#[test]
fn set_without_value_is_noop() {
let mut s = Store::new();
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");
}
}