diff --git a/src/engine/ink.rs b/src/engine/ink.rs index 3946af5..f677067 100644 --- a/src/engine/ink.rs +++ b/src/engine/ink.rs @@ -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 { + 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"), diff --git a/src/engine/kv.rs b/src/engine/kv.rs index 94886bd..348f09b 100644 --- a/src/engine/kv.rs +++ b/src/engine/kv.rs @@ -3,42 +3,69 @@ //! 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; -pub type Store = HashMap; +/// 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 { + 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::() { return ValueType::Int(i); } - if let Ok(f) = s.parse::() { 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::() { return Value::Int(i); } + if let Ok(x) = s.parse::() { 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); @@ -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::().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"); + } } diff --git a/src/engine/signals.rs b/src/engine/signals.rs index 0713de8..34c5c32 100644 --- a/src/engine/signals.rs +++ b/src/engine/signals.rs @@ -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); diff --git a/src/session.rs b/src/session.rs index be7f55a..d049333 100644 --- a/src/session.rs +++ b/src/session.rs @@ -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 { #[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");