From 2dd338c890f5c77db10ebbfbb700df3cebde5d8b Mon Sep 17 00:00:00 2001 From: irrlicht Date: Sat, 13 Jun 2026 02:57:22 +0200 Subject: [PATCH] Refactor --- src/cli.rs | 136 ++++++++++++++++++++++++++++++ src/{ => engine}/assets.rs | 0 src/{ => engine}/game.rs | 12 ++- src/{ => engine}/ink.rs | 2 +- src/{ => engine}/kv.rs | 2 +- src/engine/mod.rs | 24 ++++++ src/{ => engine}/signals.rs | 10 +-- src/{ => engine}/story_ctrl.rs | 6 +- src/main.rs | 146 +++------------------------------ 9 files changed, 188 insertions(+), 150 deletions(-) create mode 100644 src/cli.rs rename src/{ => engine}/assets.rs (100%) rename src/{ => engine}/game.rs (85%) rename src/{ => engine}/ink.rs (99%) rename src/{ => engine}/kv.rs (97%) create mode 100644 src/engine/mod.rs rename src/{ => engine}/signals.rs (97%) rename src/{ => engine}/story_ctrl.rs (93%) diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..58bb613 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,136 @@ +//! Konsolen-REPL — das erste Frontend des Kerns. +//! +//! Konsumiert die `engine`-Schnittstellen: Signale via `signals::dispatch`, +//! Dialoge via `story_ctrl::advance`, deferred Actions aus `game.actions`. +//! Der Renderer wird später dieselben Schnittstellen konsumieren; dieses +//! Modul bleibt daneben als Debug-Werkzeug bestehen. + +use std::io::{self, Write}; + +use crate::engine::game::{Action, Game}; +use crate::engine::ink::StoryState; +use crate::engine::{kv, signals, story_ctrl}; + +/// REPL-Hauptschleife. Übernimmt das Game, feuert das reservierte +/// `[init]`-Signal (KV-Defaults, bevor Stories laufen) und liest dann +/// Befehle bis `quit`/EOF. `signals_path` wird für `reload` gebraucht. +pub fn run(mut game: Game, signals_path: &str) { + println!("wds-Konsole — {} Signale aus {}. `help` für Befehle.", + game.signals.len(), signals_path); + + 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(sig) = line.strip_prefix("signal ") { + fire(&mut game, 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(); + fire(&mut game, &key, Some(name.to_string())); + } else { + println!("unbekannter Befehl: {line:?} — `help` für Befehle"); + } + } + } + } +} + +fn print_help() { + println!(" signal Signal feuern oder Action direkt ausführen"); + println!(" (z.B. `signal Cube`, `signal set has_key true`)"); + println!(" use 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, deferred Actions ausgeben und — falls eine Action +/// eine Story gestartet hat — den Dialog-Loop fahren. +fn fire(game: &mut Game, signal: &str, instance: Option) { + { + let mut ctx = game.action_ctx(instance); + signals::dispatch(signal, &mut ctx); + } + drain_actions(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 = 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_actions(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::() { + if (1..=options.len()).contains(&n) { break Some(n - 1); } + } + println!(" (1..{})", options.len()); + }; + } + } + } +} + +fn drain_actions(game: &mut Game) { + for a in game.actions.drain(..) { + match a { + Action::HideObject(n) => println!("[action] hide_object {n}"), + Action::PlaySound(n) => println!("[action] play_sound {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 { + 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()), + } +} diff --git a/src/assets.rs b/src/engine/assets.rs similarity index 100% rename from src/assets.rs rename to src/engine/assets.rs diff --git a/src/game.rs b/src/engine/game.rs similarity index 85% rename from src/game.rs rename to src/engine/game.rs index 45bc6da..cd17939 100644 --- a/src/game.rs +++ b/src/engine/game.rs @@ -11,9 +11,15 @@ //! 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; +use std::collections::HashMap; + +use crate::engine::ink; +use crate::engine::kv::Store; + +/// Signal-Table aus `assets/signals.toml`: Signal-Name → Action-Strings. +/// Geladen/geparst von [`crate::engine::signals`]; definiert ist der Typ +/// hier beim Zustand, damit `game` ← `signals` eine Einbahnstraße bleibt. +pub type Signals = HashMap>; /// Action, die der Kern nicht selbst ausführt, sondern ans Frontend /// weiterreicht. Die Queue in `Game::actions` gehört nach jedem Dispatch diff --git a/src/ink.rs b/src/engine/ink.rs similarity index 99% rename from src/ink.rs rename to src/engine/ink.rs index eff0fa5..e08bb4e 100644 --- a/src/ink.rs +++ b/src/engine/ink.rs @@ -15,7 +15,7 @@ use std::fs::read_to_string; use bladeink::story::Story as BladeStory; -use crate::kv::Store; +use crate::engine::kv::Store; pub enum StoryState { Text(String), diff --git a/src/kv.rs b/src/engine/kv.rs similarity index 97% rename from src/kv.rs rename to src/engine/kv.rs index baa13a6..94886bd 100644 --- a/src/kv.rs +++ b/src/engine/kv.rs @@ -3,7 +3,7 @@ //! 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 +//! [`crate::engine::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 diff --git a/src/engine/mod.rs b/src/engine/mod.rs new file mode 100644 index 0000000..c2755dc --- /dev/null +++ b/src/engine/mod.rs @@ -0,0 +1,24 @@ +//! Headless-Kern: alles, was läuft, ohne dass ein Frontend existiert. +//! +//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert +//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die +//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module. +//! Frontends (CLI-REPL jetzt, Renderer später) sind Geschwister-Module, +//! die `engine` konsumieren; die Richtung kehrt sich nie um. +//! +//! Interner Abhängigkeitsgraph (azyklisch, Pfeil = „benutzt"): +//! +//! kv ← ink ← game ← story_ctrl ← signals +//! ↑ ↑ +//! kv assets +//! +//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte +//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst +//! signals::dispatch zu rufen). + +pub mod assets; +pub mod game; +pub mod ink; +pub mod kv; +pub mod signals; +pub mod story_ctrl; diff --git a/src/signals.rs b/src/engine/signals.rs similarity index 97% rename from src/signals.rs rename to src/engine/signals.rs index f3d7256..a2f70e1 100644 --- a/src/signals.rs +++ b/src/engine/signals.rs @@ -30,11 +30,9 @@ use std::collections::HashMap; use std::fs::read_to_string; -use crate::game::{Action, ActionCtx}; -use crate::kv; -use crate::story_ctrl; - -pub type Signals = HashMap>; +use crate::engine::game::{Action, ActionCtx, Signals}; +use crate::engine::kv; +use crate::engine::story_ctrl; /// Strippt Blenders Duplikat-Suffix (`.NNN` mit reinen Ziffern am Ende), /// damit `Mushroom.001`, `Mushroom.042` etc. alle auf `[Mushroom]` in @@ -129,7 +127,7 @@ pub fn load_signals(path: &str) -> Signals { #[cfg(test)] mod tests { use super::*; - use crate::game::Game; + use crate::engine::game::Game; #[test] fn signal_key_strips_blender_suffix() { diff --git a/src/story_ctrl.rs b/src/engine/story_ctrl.rs similarity index 93% rename from src/story_ctrl.rs rename to src/engine/story_ctrl.rs index 614cf69..53ee29a 100644 --- a/src/story_ctrl.rs +++ b/src/engine/story_ctrl.rs @@ -7,9 +7,9 @@ //! 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}; +use crate::engine::assets; +use crate::engine::game::ActionCtx; +use crate::engine::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 diff --git a/src/main.rs b/src/main.rs index 708de17..5e45be7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,144 +1,18 @@ //! 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 `Action`-Queue. +//! Aufbau: `engine/` ist der headless Kern (Signal/Action-Dispatcher, +//! KV-Store, Ink-Stories); Frontends konsumieren ihn über `StoryState` +//! und die `Action`-Queue. Aktuell einziges Frontend: die CLI-REPL in +//! `cli`. Der Renderer kommt als zweites dazu (notes/renderer-plan.md). -use std::io::{self, Write}; +mod cli; +mod engine; -mod assets; -mod game; -mod ink; -mod kv; -mod signals; -mod story_ctrl; - -use game::{Action, Game}; -use ink::StoryState; +use engine::game::Game; +use engine::{assets, signals}; fn main() { let signals_path = assets::path("assets/signals.toml"); - let signal_table = signals::load_signals(&signals_path); - println!("wds-Konsole — {} Signale aus {}. `help` für Befehle.", - signal_table.len(), signals_path); - let mut game = Game::new(signal_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(sig) = line.strip_prefix("signal ") { - fire(&mut game, 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(); - fire(&mut game, &key, Some(name.to_string())); - } else { - println!("unbekannter Befehl: {line:?} — `help` für Befehle"); - } - } - } - } -} - -fn print_help() { - println!(" signal Signal feuern oder Action direkt ausführen"); - println!(" (z.B. `signal Cube`, `signal set has_key true`)"); - println!(" use 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, deferred Actions ausgeben und — falls eine Action -/// eine Story gestartet hat — den Dialog-Loop fahren. -fn fire(game: &mut Game, signal: &str, instance: Option) { - { - let mut ctx = game.action_ctx(instance); - signals::dispatch(signal, &mut ctx); - } - drain_actions(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 = 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_actions(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::() { - if (1..=options.len()).contains(&n) { break Some(n - 1); } - } - println!(" (1..{})", options.len()); - }; - } - } - } -} - -fn drain_actions(game: &mut Game) { - for a in game.actions.drain(..) { - match a { - Action::HideObject(n) => println!("[action] hide_object {n}"), - Action::PlaySound(n) => println!("[action] play_sound {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 { - 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()), - } + let game = Game::new(signals::load_signals(&signals_path)); + cli::run(game, &signals_path); }