Signal-System mit Inky und KV-Store

This commit is contained in:
2026-06-12 22:36:43 +02:00
parent ff80b3cc0f
commit 97eff4f9eb
16 changed files with 1432 additions and 1 deletions
+26
View File
@@ -0,0 +1,26 @@
//! Asset-Pfadauflösung: Distribution-Layout (assets/ neben der Binary)
//! oder Dev-Layout (assets/ im CWD, also Projektroot bei `cargo run`).
use std::path::PathBuf;
use std::sync::OnceLock;
static BASE: OnceLock<PathBuf> = OnceLock::new();
fn base() -> &'static PathBuf {
BASE.get_or_init(|| {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
if dir.join("assets").exists() {
return dir.to_path_buf();
}
}
}
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
})
}
/// Wandelt einen relativen Pfad wie `"assets/signals.toml"` in einen
/// absoluten Pfad relativ zum Asset-Basisverzeichnis um.
pub fn path(rel: &str) -> String {
base().join(rel).to_string_lossy().into_owned()
}
+63
View File
@@ -0,0 +1,63 @@
//! Owner des dispatch-relevanten Spielzustands und der Borrow-View, durch
//! die Signal-Actions und Ink-Story-Steps darauf zugreifen.
//!
//! `Game` hält die Slots, die Actions lesen oder mutieren dürfen.
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
//! konstruiert und durch die Dispatch-Pipeline gereicht.
//!
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht
//! existiert oder den Kern nichts angeht, produzieren [`Effect`]-Werte
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
//! testbar und kennt keines der Subsysteme.
use crate::ink;
use crate::kv::Store;
use crate::signals::Signals;
/// Von Actions angeforderte Wirkung außerhalb des Kerns. Die Queue in
/// `Game::effects` gehört nach jedem Dispatch geleert (konsumiert).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
/// Instance unsichtbar + kollisionslos + nicht mehr interagierbar machen.
HideObject(String),
/// WAV unter `assets/audio/<name>` als SFX abspielen.
PlaySound(String),
/// Palette/Farbschema unter `assets/palettes/<name>` laden.
LoadPalette(String),
}
pub struct Game {
pub kv: Store,
pub signals: Signals,
pub story: Option<ink::Story>,
pub effects: Vec<Effect>,
}
pub struct ActionCtx<'a> {
pub kv: &'a mut Store,
pub signals: &'a Signals,
pub story: &'a mut Option<ink::Story>,
pub effects: &'a mut Vec<Effect>,
/// Auslöser-Identifier: bei Objekt-Interaktion der volle Instance-Name
/// (mit Blender-Suffix, z.B. `Mushroom.005`). `$self` in Action-Args
/// wird damit substituiert. `None` bei Signalen ohne Quell-Instance
/// (z.B. `init`, Story-Tags).
pub instance_name: Option<String>,
}
impl Game {
pub fn new(signals: Signals) -> Self {
Self { kv: Store::new(), signals, story: None, effects: Vec::new() }
}
pub fn action_ctx(&mut self, instance_name: Option<String>) -> ActionCtx<'_> {
ActionCtx {
kv: &mut self.kv,
signals: &self.signals,
story: &mut self.story,
effects: &mut self.effects,
instance_name,
}
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Dünner Adapter um die `bladeink`-Crate, gekoppelt an unseren `kv::Store`.
//!
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
//! (Skript sieht aktuelle Spielvariablen)
//! - nach `continue`: alle KV-Keys, die das Skript kennt, zurücklesen
//! (Ink-`~ var = …`-Writes landen im KV)
//!
//! Variablen die der KV kennt, das Skript aber nicht als `VAR` deklariert,
//! lehnt bladeink beim `set_variable` ab — wir ignorieren das still.
//! Variablen die nur im Skript existieren, werden beim Read-Back ignoriert
//! (KV bleibt minimal: nur was Rust kennt).
use std::fs::read_to_string;
use bladeink::story::Story as BladeStory;
use crate::kv::Store;
pub enum StoryState {
Text(String),
Choice { prompt: String, options: Vec<String> },
End,
}
pub struct Story {
inner: BladeStory,
tags: Vec<String>,
}
impl Story {
pub fn load(path: &str, kv: &Store) -> Self {
let src = read_to_string(path)
.unwrap_or_else(|e| panic!("ink load {}: {}", path, e));
let inner = BladeStory::new(&src)
.unwrap_or_else(|e| panic!("ink parse {}: {:?}", path, e));
let mut s = Self { inner, tags: Vec::new() };
s.sync_from_kv(kv);
s
}
pub fn take_tags(&mut self) -> Vec<String> { std::mem::take(&mut self.tags) }
pub fn cont(&mut self, kv: &mut Store) -> StoryState {
self.sync_from_kv(kv);
// continue_maximally ruft intern reset_output() vor jedem cont() —
// damit überschriebe der Tag der letzten Zeile alle vorherigen.
// Wir loopen selbst und sammeln Tags nach jedem Schritt ein, sonst
// gehen z.B. `# set has_key true` am Knoten-Anfang bei mehrzeiligem
// Text verloren.
let mut text = String::new();
while self.inner.can_continue() {
let chunk = self.inner.cont()
.unwrap_or_else(|e| panic!("ink cont: {:?}", e));
text.push_str(&chunk);
if let Ok(t) = self.inner.get_current_tags() {
self.tags.extend(t);
}
}
let text = text.trim().to_string();
self.sync_to_kv(kv);
let choices = self.inner.get_current_choices();
if !choices.is_empty() {
let options = choices.iter().map(|c| c.text.clone()).collect();
return StoryState::Choice { prompt: text, options };
}
if !text.is_empty() { return StoryState::Text(text); }
StoryState::End
}
pub fn choose(&mut self, i: usize) {
self.inner.choose_choice_index(i)
.unwrap_or_else(|e| panic!("ink choose {}: {:?}", i, e));
}
fn sync_from_kv(&mut self, kv: &Store) {
for (name, value) in kv {
// set_variable schlägt fehl wenn der Name kein deklariertes
// VAR im Skript ist — still ignorieren.
let _ = self.inner.set_variable(name, value);
}
}
fn sync_to_kv(&self, kv: &mut Store) {
let keys: Vec<String> = kv.keys().cloned().collect();
for name in keys {
if let Some(v) = self.inner.get_variable(&name) {
kv.insert(name, v);
}
}
}
}
+102
View File
@@ -0,0 +1,102 @@
//! 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::ink`]). Damit der KV authoritativ 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).
use std::collections::HashMap;
use bladeink::value_type::ValueType;
pub type Store = HashMap<String, ValueType>;
/// 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),
_ => {}
}
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()),
}
}
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(|v| v.coerce_to_int().ok()).unwrap_or(0);
store.insert(name.to_string(), ValueType::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"), 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(_)));
}
#[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);
apply_inc("clicks", &mut s);
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 4);
apply_inc("clicks -2", &mut s);
assert_eq!(s["clicks"].coerce_to_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);
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());
}
}
+141 -1
View File
@@ -1,3 +1,143 @@
//! wds — Weirdcore Dating Simulator.
//!
//! CLI-Phase: Die Kern-Systeme (Signal/Action-Dispatcher, KV-Store,
//! Ink-Stories) laufen headless; diese REPL ist ihr erstes Frontend.
//! Der Renderer kommt später dazu (siehe notes/renderer-plan.md) und
//! konsumiert dieselben Schnittstellen — insbesondere `StoryState` und
//! die `Effect`-Queue.
use std::io::{self, Write};
mod assets;
mod game;
mod ink;
mod kv;
mod signals;
mod story_ctrl;
use game::{Effect, Game};
use ink::StoryState;
fn main() {
println!("Hello, world!");
let signals_path = assets::path("assets/signals.toml");
let table = signals::load_signals(&signals_path);
println!("wds-Konsole — {} Signale aus {}. `help` für Befehle.",
table.len(), signals_path);
let mut game = Game::new(table);
// Reserviertes `[init]`-Signal: KV-Defaults setzen, bevor Stories laufen.
fire(&mut game, "init", None);
while let Some(line) = read_line("> ") {
let line = line.trim();
match line {
"" => {}
"quit" | "exit" => break,
"help" => print_help(),
"kv" => dump_kv(&game),
"reload" => {
game.signals = signals::load_signals(&signals_path);
println!("{} Signale geladen.", game.signals.len());
}
_ => {
if let Some(name) = line.strip_prefix("use ") {
// Objekt-Interaktion simulieren: Signal ist der gestrippte
// Name, $self der volle — wie der LMB-Klick-Pfad in irl3d.
let name = name.trim();
let key = signals::signal_key(name).to_string();
fire(&mut game, &key, Some(name.to_string()));
} else {
fire(&mut game, line, None);
}
}
}
}
}
fn print_help() {
println!(" <signal|action> Signal feuern oder Action direkt ausführen");
println!(" (z.B. `Cube`, `set has_key true`, `inc foo 3`)");
println!(" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`");
println!(" feuert Signal `Mushroom` mit $self = Mushroom.005");
println!(" kv KV-Store anzeigen");
println!(" reload signals.toml neu laden");
println!(" quit beenden");
}
/// Signal dispatchen, Effects ausgeben und — falls eine Action eine Story
/// gestartet hat — den Dialog-Loop fahren.
fn fire(game: &mut Game, signal: &str, instance: Option<String>) {
{
let mut ctx = game.action_ctx(instance);
signals::dispatch(signal, &mut ctx);
}
drain_effects(game);
if game.story.is_some() {
run_story(game);
}
}
/// Dialog-Loop: Story schrittweise treiben, Tags zurück in den Dispatcher,
/// Choices über nummerierte Eingabe. Läuft bis `End` (oder EOF).
fn run_story(game: &mut Game) {
let mut sel: Option<usize> = None;
loop {
let state = {
let mut ctx = game.action_ctx(None);
let (state, tags) = story_ctrl::advance(sel, &mut ctx);
for t in &tags { signals::dispatch(t, &mut ctx); }
state
};
drain_effects(game);
match state {
None | Some(StoryState::End) => break,
Some(StoryState::Text(text)) => {
println!("\n{text}");
if read_line(" [Enter] ").is_none() { break; }
sel = None;
}
Some(StoryState::Choice { prompt, options }) => {
if !prompt.is_empty() { println!("\n{prompt}"); }
for (i, o) in options.iter().enumerate() {
println!(" {}) {o}", i + 1);
}
sel = loop {
let Some(line) = read_line(" wahl> ") else { return; };
if let Ok(n) = line.trim().parse::<usize>() {
if (1..=options.len()).contains(&n) { break Some(n - 1); }
}
println!(" (1..{})", options.len());
};
}
}
}
}
fn drain_effects(game: &mut Game) {
for e in game.effects.drain(..) {
match e {
Effect::HideObject(n) => println!("[effect] hide_object {n}"),
Effect::PlaySound(n) => println!("[effect] play_sound {n}"),
Effect::LoadPalette(n) => println!("[effect] load_palette {n}"),
}
}
}
fn dump_kv(game: &Game) {
let mut keys: Vec<&String> = game.kv.keys().collect();
keys.sort();
for k in keys {
println!(" {k} = {}", kv::format_value(&game.kv[k]));
}
}
/// Zeile von stdin lesen; `None` bei EOF (Ctrl-D) oder Lesefehler.
fn read_line(prompt: &str) -> Option<String> {
print!("{prompt}");
io::stdout().flush().ok();
let mut buf = String::new();
match io::stdin().read_line(&mut buf) {
Ok(0) | Err(_) => None,
Ok(_) => Some(buf.trim_end().to_string()),
}
}
+196
View File
@@ -0,0 +1,196 @@
//! Signal → Action Dispatcher.
//!
//! Triggers (Objekt-Interaktion, Zone-Enter, Ink-Tag, Konsole) feuern
//! *Signals* als Strings. Eine zentrale Table in `assets/signals.toml` mappt
//! jedes Signal auf eine Liste von Actions. Actions sind selbst wieder
//! Strings im Format `"verb args"` — dieselbe Vokabular-Familie wie
//! Ink-Tags, sodass beide auf dieselbe Execute-Pipeline laufen.
//!
//! Builtin-Verben:
//! `start_ink <script>` Story aus `assets/interactions/` in den
//! Story-Slot laden; das Frontend treibt sie
//! dann über `story_ctrl::advance`.
//! `debug_log <text>` eprintln!
//! `set <name> <value>` KV setzen (true/false/i32/f32/string)
//! `inc <name> [<delta>]` KV-Integer inkrementieren (Default +1)
//! `clear <name>` KV-Eintrag entfernen
//! `hide_object <name>` → Effect::HideObject
//! `play_sound <file>` → Effect::PlaySound
//! `load_palette <file>` → Effect::LoadPalette
//!
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
//! wie `hide_object $self` für viele duplizierte Instances wirken, ohne pro
//! Instance einen Eintrag in signals.toml zu brauchen.
//!
//! Dispatch-Reihenfolge: erst Signal-Lookup; steht der Name in der Table,
//! laufen alle gemappten Actions. Sonst wird der String direkt als
//! Builtin-Aufruf probiert — so wirken Ink-Tags wie `# set has_key true`
//! ohne Umweg über signals.toml. Unbekannte Verben werden still ignoriert.
use std::collections::HashMap;
use std::fs::read_to_string;
use crate::game::{ActionCtx, Effect};
use crate::kv;
use crate::story_ctrl;
pub type Actions = Vec<String>;
pub type Signals = HashMap<String, Actions>;
/// Strippt Blenders Duplikat-Suffix (`.NNN` mit reinen Ziffern am Ende),
/// damit `Mushroom.001`, `Mushroom.042` etc. alle auf `[Mushroom]` in
/// signals.toml mappen. Andere Punkte im Namen bleiben unberührt, solange
/// der letzte Token nicht rein numerisch ist.
pub fn signal_key(name: &str) -> &str {
if let Some(dot) = name.rfind('.') {
let tail = &name[dot + 1..];
if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
return &name[..dot];
}
}
name
}
pub fn dispatch(signal: &str, ctx: &mut ActionCtx) {
// Signal-Name in der Table? Dann alle gemappten Actions ausführen.
if let Some(actions) = ctx.signals.get(signal) {
let actions = actions.clone(); // entkoppelt vom ctx.signals-Borrow
for a in &actions { execute(a, ctx); }
return;
}
// Sonst direkt als Builtin probieren (Ink-Tag-Pfad).
execute(signal, ctx);
}
fn execute(cmd: &str, ctx: &mut ActionCtx) {
// $self → ctx.instance_name. Substituieren bevor wir Verb/Args splitten,
// damit Tokens wie `hide_object $self` einheitlich funktionieren. Ohne
// instance_name (z.B. `init`-Signal) bleibt `$self` stehen — die Action
// zielt dann ins Leere, was für Effects ein No-Op beim Konsumenten ist.
let cmd_owned: String;
let cmd_ref: &str = if cmd.contains("$self") {
if let Some(name) = ctx.instance_name.as_deref() {
cmd_owned = cmd.replace("$self", name);
&cmd_owned
} else { cmd }
} else { cmd };
let (verb, args) = cmd_ref.split_once(char::is_whitespace).unwrap_or((cmd_ref, ""));
let args = args.trim();
match verb {
"start_ink" => story_ctrl::start(args, ctx),
"debug_log" => eprintln!("[signal] {args}"),
"set" => kv::apply_set(args, ctx.kv),
"inc" => kv::apply_inc(args, ctx.kv),
"clear" => kv::apply_clear(args, ctx.kv),
"hide_object" => ctx.effects.push(Effect::HideObject(args.to_string())),
"play_sound" => ctx.effects.push(Effect::PlaySound(args.to_string())),
"load_palette" => ctx.effects.push(Effect::LoadPalette(args.to_string())),
_ => { /* Unbekannter Verb/Tag → still ignorieren */ }
}
}
/// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem
/// `actions = [ "…", … ]`-Array. Bewusst handgerollt statt toml-Crate —
/// das Format ist trivial und die Engine bleibt dependency-arm.
pub fn parse_signals(src: &str) -> Signals {
let mut map: Signals = HashMap::new();
let mut cur = String::new();
let mut in_arr = false;
for line in src.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') { continue; }
if !in_arr && line.starts_with('[') {
cur = line.trim_matches(|c| c == '[' || c == ']').to_string();
map.entry(cur.clone()).or_default();
continue;
}
if !in_arr && line.starts_with("actions") && line.contains('[') {
in_arr = true;
continue;
}
if in_arr {
if line.starts_with(']') { in_arr = false; continue; }
let s = line.trim_end_matches(',').trim().trim_matches('"');
if s.is_empty() { continue; }
map.entry(cur.clone()).or_default().push(s.to_string());
}
}
map
}
/// Lädt `signals.toml`. Fehlende Datei → leere Table (kein Fehler), damit
/// ein Projekt ohne Signals lauffähig bleibt.
pub fn load_signals(path: &str) -> Signals {
match read_to_string(path) {
Ok(src) => parse_signals(&src),
Err(_) => Signals::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::Game;
#[test]
fn signal_key_strips_blender_suffix() {
assert_eq!(signal_key("Mushroom.005"), "Mushroom");
assert_eq!(signal_key("Mushroom"), "Mushroom");
assert_eq!(signal_key("my.thing.with.dots"), "my.thing.with.dots");
assert_eq!(signal_key("name."), "name.");
}
#[test]
fn parse_signals_table() {
let src = r#"
# Kommentar
[init]
actions = [
"set foo 1",
"debug_log hi",
]
[Empty]
actions = [
]
"#;
let s = parse_signals(src);
assert_eq!(s["init"], vec!["set foo 1", "debug_log hi"]);
assert!(s["Empty"].is_empty());
}
#[test]
fn dispatch_runs_actions_and_queues_effects() {
let mut signals = Signals::new();
signals.insert("pickup".into(), vec![
"inc items".into(),
"play_sound pickup.wav".into(),
"hide_object $self".into(),
]);
let mut game = Game::new(signals);
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.effects, vec![
Effect::PlaySound("pickup.wav".into()),
Effect::HideObject("Mushroom.005".into()),
]);
}
#[test]
fn unknown_signal_falls_through_to_builtin() {
let mut game = Game::new(Signals::new());
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());
// Unbekanntes Verb ist ein stilles No-Op.
let mut ctx = game.action_ctx(None);
dispatch("color story_a", &mut ctx);
assert!(game.effects.is_empty());
}
}
+33
View File
@@ -0,0 +1,33 @@
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState`
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
//!
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
//! (vermeidet den Modul-Zyklus signals → story_ctrl → signals).
use crate::assets;
use crate::game::ActionCtx;
use crate::ink::{Story, StoryState};
/// Lädt ein Ink-Skript (Pfad relativ zu `assets/interactions/`) in den
/// Story-Slot. Der erste Schritt passiert noch nicht — das Frontend ruft
/// dafür `advance(None, …)`.
pub fn start(script: &str, ctx: &mut ActionCtx) {
let path = assets::path(&format!("assets/interactions/{script}"));
*ctx.story = Some(Story::load(&path, ctx.kv));
}
/// Treibt die laufende Story einen Schritt weiter. `sel` ist die
/// Choice-Auswahl bzw. `None` beim ersten Schritt oder Text-Weiterklicken.
/// Bei `End` wird der Story-Slot geleert. Rückgabe: neuer Zustand (oder
/// `None` wenn gar keine Story läuft) plus Tags zum Dispatchen.
pub fn advance(sel: Option<usize>, ctx: &mut ActionCtx) -> (Option<StoryState>, Vec<String>) {
let Some(st) = ctx.story.as_mut() else { return (None, Vec::new()); };
if let Some(i) = sel { st.choose(i); }
let state = st.cont(ctx.kv);
let tags = st.take_tags();
if matches!(state, StoryState::End) { *ctx.story = None; }
(Some(state), tags)
}