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:
co-authored by
Claude Sonnet 5
parent
247f7ef9fe
commit
236a643d04
+32
-7
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user