Files
wds/src/session.rs
T
2026-06-13 21:05:07 +02:00

264 lines
9.9 KiB
Rust

//! Session: der geteilte Anwendungszustand über allen Frontends.
//!
//! Es existiert genau eine `Session` pro Lauf. CLI und Fenster steuern
//! dieselbe — das Fenster besitzt sie, die Terminal-Eingabe schickt nur
//! Befehlszeilen hinein. Dadurch kann es keinen Mismatch geben: ein
//! `Game`-Zustand, ein `Mode`, beide Frontends stellen ihn nur dar.
//!
//! Schicht: `engine ← session ← { cli, render }`. Die Session kennt kein
//! Frontend — `exec` nimmt eine Befehlszeile und *gibt* Ausgabezeilen
//! zurück (statt zu drucken), damit jedes Frontend sie frei darstellt
//! (Terminal jetzt, In-Fenster-Konsole später).
use crate::engine::game::{Action, Game};
use crate::engine::ink::StoryState;
use crate::engine::{kv, signals, story_ctrl};
/// Was die Frontends gerade darstellen sollen. Geteilt, damit CLI und GUI
/// nie uneins sind, ob ein Dialog läuft.
pub enum Mode {
/// Freie Welt — das Fenster rendert die Szene, die Konsole nimmt
/// Engine-Befehle.
Free,
/// Menü offen — wie der Dialog pausiert das Fenster die Welt und gibt die
/// Maus frei; Eingaben sind weiter Engine-Befehle (das Menü ist nur eine
/// andere Darstellung). Per `menu`-Befehl getoggelt.
Menu,
/// Ein Dialog läuft — das Fenster pausiert die Welt und zeigt Panels;
/// Eingaben treiben den Dialog statt Befehle.
Dialog(Dialog),
}
/// Aktueller Dialogschritt, frontend-neutral. `choices` leer = reiner Text,
/// der auf „weiter" (Leerzeile/Enter) wartet.
pub struct Dialog {
// Wird vom Panel-Renderer der UI-Phase gelesen; bis dahin läuft der
// Dialogtext über die Konsolen-Ausgabe (siehe `step`).
#[allow(dead_code)]
pub text: String,
pub choices: Vec<String>,
}
pub struct Session {
pub game: Game,
pub mode: Mode,
signals_path: String,
}
/// Ergebnis einer Eingabezeile: Ausgabezeilen plus, ob das Frontend
/// beenden soll (`quit`/`exit`).
pub struct ExecResult {
pub output: Vec<String>,
pub quit: bool,
}
impl ExecResult {
fn lines(output: Vec<String>) -> Self { Self { output, quit: false } }
fn empty() -> Self { Self { output: Vec::new(), quit: false } }
}
impl Session {
pub fn new(signals_path: String) -> Self {
let game = Game::new(signals::load_signals(&signals_path));
Self { game, mode: Mode::Menu, signals_path }
}
/// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
/// Eigene Methode statt im Konstruktor, damit das Frontend die Ausgabe
/// darstellen kann — und damit `init` bereits einen Dialog öffnen darf.
pub fn start(&mut self) -> Vec<String> {
self.fire("init", None)
}
/// Eine Eingabezeile verarbeiten. Im Dialog treibt sie den Dialog,
/// sonst ist sie ein Engine-Befehl. Dieselbe Eingabequelle, je nach
/// `mode` unterschiedlich gedeutet — so steuert Terminal *und* (später)
/// Klick denselben Dialog ohne Sonderpfad.
pub fn exec(&mut self, line: &str) -> ExecResult {
match self.mode {
Mode::Dialog(_) => self.dialog_input(line),
Mode::Free | Mode::Menu => self.command(line),
}
}
fn command(&mut self, line: &str) -> ExecResult {
let line = line.trim();
match line {
"" => ExecResult::empty(),
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
"menu" => {
// Free ↔ Menu umschalten (erreichbar nur aus diesen beiden,
// da Dialog anders geroutet wird).
self.mode = match self.mode {
Mode::Menu => Mode::Free,
_ => Mode::Menu,
};
ExecResult::empty()
}
"help" => ExecResult::lines(help()),
"kv" => ExecResult::lines(self.dump_kv()),
"reload" => {
self.game.signals = signals::load_signals(&self.signals_path);
ExecResult::lines(vec![
format!("{} Signale geladen.", self.game.signals.len()),
])
}
_ => {
if let Some(sig) = line.strip_prefix("signal ") {
ExecResult::lines(self.fire(sig.trim(), None))
} else 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();
ExecResult::lines(self.fire(&key, Some(name.to_string())))
} else {
ExecResult::lines(vec![
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
])
}
}
}
}
fn dialog_input(&mut self, line: &str) -> ExecResult {
let Mode::Dialog(d) = &self.mode else { return ExecResult::empty(); };
let sel = if d.choices.is_empty() {
None // reiner Text → Leerzeile/Enter blättert weiter
} else {
match line.trim().parse::<usize>() {
Ok(n) if (1..=d.choices.len()).contains(&n) => Some(n - 1),
_ => return ExecResult::lines(vec![format!(" (1..{})", d.choices.len())]),
}
};
ExecResult::lines(self.step(sel))
}
/// Signal dispatchen, deferred Actions einsammeln und — falls dadurch
/// eine Story startete — den ersten Dialogschritt ziehen.
fn fire(&mut self, signal: &str, instance: Option<String>) -> Vec<String> {
{
let mut ctx = self.game.action_ctx(instance);
signals::dispatch(signal, &mut ctx);
}
let mut out = self.drain_actions();
if self.game.story.is_some() {
out.extend(self.step(None));
}
out
}
/// Einen Story-Schritt treiben und `mode` daran anpassen. `sel` ist die
/// Choice-Auswahl bzw. `None` für ersten Schritt / Text-Weiterblättern.
/// Tags gehen wie gehabt zurück in den Dispatcher (siehe story_ctrl).
fn step(&mut self, sel: Option<usize>) -> Vec<String> {
let state = {
let mut ctx = self.game.action_ctx(None);
let (state, tags) = story_ctrl::advance(sel, &mut ctx);
for t in &tags { signals::dispatch(t, &mut ctx); }
state
};
let mut out = self.drain_actions();
match state {
None | Some(StoryState::End) => {
self.mode = Mode::Free;
}
Some(StoryState::Text(text)) => {
out.push(text.clone());
self.mode = Mode::Dialog(Dialog { text, choices: Vec::new() });
}
Some(StoryState::Choice { prompt, options }) => {
if !prompt.is_empty() { out.push(prompt.clone()); }
for (i, o) in options.iter().enumerate() {
out.push(format!(" {}) {o}", i + 1));
}
self.mode = Mode::Dialog(Dialog { text: prompt, choices: options });
}
}
out
}
fn drain_actions(&mut self) -> Vec<String> {
self.game.actions.drain(..).map(|a| match a {
Action::HideObject(n) => format!("[action] hide_object {n}"),
Action::PlaySound(n) => format!("[action] play_sound {n}"),
}).collect()
}
fn dump_kv(&self) -> Vec<String> {
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])))
.collect()
}
}
fn help() -> Vec<String> {
vec![
" signal <s> Signal feuern oder Action direkt ausführen".into(),
" (z.B. `signal Cube`, `signal set has_key true`)".into(),
" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
" kv KV-Store anzeigen".into(),
" menu Menü öffnen/schließen".into(),
" reload signals.toml neu laden".into(),
" quit beenden".into(),
]
}
#[cfg(test)]
mod tests {
use super::*;
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
// also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
// im Menü (Titelbildschirm); Tests des Befehlspfads setzen vorab `Free`.
fn empty_session() -> Session { Session::new("/nonexistent/signals.toml".into()) }
#[test]
fn starts_in_menu() {
assert!(matches!(empty_session().mode, Mode::Menu));
}
#[test]
fn quit_sets_flag() {
let mut s = empty_session();
assert!(s.exec("quit").quit);
}
#[test]
fn builtin_command_mutates_kv_and_stays_free() {
let mut s = empty_session();
s.mode = Mode::Free;
// 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.mode, Mode::Free));
}
#[test]
fn menu_command_toggles_free_and_menu() {
let mut s = empty_session();
s.mode = Mode::Free;
s.exec("menu");
assert!(matches!(s.mode, Mode::Menu));
s.exec("menu");
assert!(matches!(s.mode, Mode::Free));
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Free).
s.exec("menu");
s.exec("signal set in_menu true");
assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
}
#[test]
fn unknown_command_reports_and_stays_free() {
let mut s = empty_session();
s.mode = Mode::Free;
let r = s.exec("frobnicate");
assert_eq!(r.output.len(), 1);
assert!(matches!(s.mode, Mode::Free));
}
}