Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714use std::path::{Path, PathBuf};
use clap::{CommandFactory, Parser, Subcommand, ValueHint};use clap_complete::Shell;
use crate::dice;use crate::srd::fetch::{self, FetchOutcome, SrdSources};use crate::srd::verify;
#[derive(Parser)]#[command( name = "storied", about = "A terminal storytelling experience, driven by an LLM")]pub struct Cli { #[command(subcommand)] command: Option<Command>,}
/// The dice grammar, printed after `storied roll --help`'s usage line. This/// mirrors the grammar in `src/dice.rs`; keep the two in sync.const ROLL_AFTER_HELP: &str = "\Notation is one or more terms joined by + and -, like `3d6-1 + 4d100+5`.A term is a dice group (XdY) or a flat modifier.
A group's count is optional (`d20` is `1d20`); `%` means 100 (`d%` is`d100`). Valid die sizes: 2, 3, 4, 6, 8, 10, 12, 20, 100.
Suffixes attach to a group, in any order, case-insensitive. Rerolls andminimums settle before a keep/drop suffix picks which dice count. khN, klN keep the highest/lowest N; bare kh/kl keeps 1 dhN, dlN drop the highest/lowest N roN reroll once when the roll equals N, keeping the new value ro<N reroll once when the roll is less than N ro>N reroll once when the roll is greater than N rN reroll a die until the roll no longer equals N r<N reroll until the roll is no longer less than N r>N reroll until the roll is no longer greater than N miN raise each die's value to at least N
Examples: 2d20kh advantage 2d20kl disadvantage 4d6kh3 ability score, keeping the best 3 of 4 4d6dl1 the same roll as 4d6kh3, said the other way 2d6ro<3 Great Weapon Fighting d20ro1 Halfling Luck 8d6mi2 Elemental Adept";
#[derive(Subcommand)]enum Command { /// Manage the SRD rules pipeline. Srd { #[command(subcommand)] command: SrdCommand, }, /// Roll dice notation once and print the result. #[command(after_help = ROLL_AFTER_HELP)] Roll { /// Dice notation to roll, like `d20` or `2d6+3`. notation: String, }, /// Talk to a DM with no world and no character, to try out tools and rules. // Coverage builds leave this command out, along with the terminal it // needs. See `src/play/mod.rs`. #[cfg(not(coverage))] Sandbox { /// A file whose contents go to the DM as the player's first /// line, so a test scenario starts without retyping it. #[arg(value_name = "SCENARIO", value_hint = ValueHint::FilePath)] scenario: Option<PathBuf>, /// A directory to keep the world and player knowledge in, instead /// of a temporary one. A world already there resumes; one that /// does not exist yet is created. Nothing under it is deleted /// when the session ends. #[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)] world: Option<PathBuf>, /// The base URL of the OpenAI-compatible API to talk to. #[arg(long, value_name = "URL")] api_base: Option<String>, /// The model to play with. #[arg(long, value_name = "NAME")] model: Option<String>, /// How much the model deliberates before it answers. Valid /// values depend on the provider, for example low, high, max on /// DeepInfra's DeepSeek models. #[arg(long, value_name = "LEVEL")] reasoning_effort: Option<String>, }, /// Play in a named, persistent world, creating it after you confirm /// if it is new. // Coverage builds leave this command out, along with the terminal it // needs. See `src/play/mod.rs`. #[cfg(not(coverage))] Play { /// The world's name, a directory under the worlds root. #[arg(value_name = "NAME")] name: String, /// Where named worlds live. Overrides STORIED_WORLDS, the config /// file's worlds_root key, and the default. #[arg(long, value_name = "DIR", value_hint = ValueHint::DirPath)] worlds_root: Option<PathBuf>, /// The base URL of the OpenAI-compatible API to talk to. #[arg(long, value_name = "URL")] api_base: Option<String>, /// The model to play with. #[arg(long, value_name = "NAME")] model: Option<String>, /// How much the model deliberates before it answers. Valid /// values depend on the provider, for example low, high, max on /// DeepInfra's DeepSeek models. #[arg(long, value_name = "LEVEL")] reasoning_effort: Option<String>, }, /// Print a completion script for a shell. #[command(after_help = COMPLETIONS_AFTER_HELP)] Completions { /// The shell to print a script for. #[arg(value_name = "SHELL")] shell: Shell, },}
/// Where to put the script, printed after `storied completions --help`.const COMPLETIONS_AFTER_HELP: &str = "\For bash, write the script where bash-completion loads it on demand: storied completions bash > ~/.local/share/bash-completion/completions/storied";
#[derive(Subcommand)]enum SrdCommand { /// Download and verify the SRD PDF. Fetch, /// Check the SRD corpus against its vendored source and schema. Verify,}
/// Runs the parsed command, using `srd_sources` for `storied srd fetch`,/// `layer_root` for `storied srd verify`, and `session_layers` as the DM's/// ordered knowledge layers in `storied sandbox`.pub fn run( cli: Cli, srd_sources: &SrdSources, layer_root: &Path, // `storied sandbox` is the only reader of the layers, and coverage // builds leave that command out along with the terminal it needs, so // the parameter has no reader there. #[cfg_attr(coverage, allow(unused_variables))] session_layers: &[PathBuf],) -> Result<(), String> { match cli.command { None => { println!("{}", crate::greeting()); Ok(()) } Some(Command::Srd { command: SrdCommand::Fetch, }) => run_srd_fetch(srd_sources), Some(Command::Srd { command: SrdCommand::Verify, }) => run_srd_verify(layer_root), Some(Command::Roll { notation }) => run_roll(¬ation), #[cfg(not(coverage))] Some(Command::Sandbox { scenario, world, api_base, model, reasoning_effort, }) => run_sandbox( &crate::config::Overrides { api_base, model, reasoning_effort, }, session_layers, scenario.as_deref(), world, ), #[cfg(not(coverage))] Some(Command::Play { name, worlds_root, api_base, model, reasoning_effort, }) => run_play( &crate::config::Overrides { api_base, model, reasoning_effort, }, session_layers, &name, worlds_root, ), Some(Command::Completions { shell }) => run_completions(shell), }}
/// Prints the completion script for `shell` on stdout.fn run_completions(shell: Shell) -> Result<(), String> { clap_complete::generate( shell, &mut Cli::command(), "storied", &mut std::io::stdout(), ); Ok(())}
/// Boots a world for a sandbox session and plays until the player quits./// When `scenario` names a file, its contents open the session as the/// player's first line. When `world` names a directory, the session keeps/// its world and player knowledge there instead of a temporary one deleted/// on exit, so a later run with the same directory resumes it. `/settings`/// shows where the session's world and player directories live.#[cfg(not(coverage))]fn run_sandbox( overrides: &crate::config::Overrides, session_layers: &[PathBuf], scenario: Option<&Path>, world: Option<PathBuf>,) -> Result<(), String> { use crate::play::sandbox::Sandbox;
let opening = match scenario { Some(path) => { let text = std::fs::read_to_string(path) .map_err(|error| format!("could not read {}: {error}", path.display()))?; if text.trim().is_empty() { return Err(format!("{} is empty", path.display())); } Some(text) } None => None, };
let sandbox = match world { Some(root) => Sandbox::named(root)?, None => Sandbox::new()?, }; let mut layers = session_layers.to_vec(); layers.push(sandbox.world_dir()); layers.push(sandbox.player_dir());
crate::play::run(overrides, &layers, &sandbox.world_dir(), opening)}
/// Opens the named world `name` and plays until the player quits.////// The worlds root resolves through `crate::worlds::resolve_root`:/// `worlds_root` first, then `STORIED_WORLDS`, then the config file's own/// `worlds_root` key, then the XDG data home default. When `<root>/<name>`/// does not exist yet, this asks at the prompt before it creates/// anything; an answer other than `y` or `yes` ends the command having/// touched nothing. The world directory is the world layer itself, with/// no `world/` subdirectory inside it, and the player's own knowledge/// lives outside the world at a fixed directory shared across every/// world, with a `worlds/<name>` overlay mounted above this one./// `/settings` shows the world directory, the player directory, and the/// full mount stack once the session starts.#[cfg(not(coverage))]fn run_play( overrides: &crate::config::Overrides, session_layers: &[PathBuf], name: &str, worlds_root: Option<PathBuf>,) -> Result<(), String> { use std::io::Write;
let config = crate::config::load(overrides).map_err(|error| error.to_string())?; let home = std::env::var("HOME").unwrap_or_default(); let xdg_data_home = std::env::var("XDG_DATA_HOME").ok(); let env_worlds_root = std::env::var("STORIED_WORLDS").ok(); let root = crate::worlds::resolve_root( worlds_root.as_deref(), env_worlds_root.as_deref(), config.worlds_root.as_deref(), xdg_data_home.as_deref(), &home, );
let world_dir = root.join(name); let resumed = world_dir.is_dir(); if !resumed { print!( "{} does not exist yet. Create it? [y/N] ", world_dir.display() ); std::io::stdout() .flush() .map_err(|error| error.to_string())?; let mut answer = String::new(); std::io::stdin() .read_line(&mut answer) .map_err(|error| error.to_string())?; if !crate::worlds::wants_to_create(&answer) { println!("{} not created.", world_dir.display()); return Ok(()); } } crate::worlds::create_world_dir(&root, name) .map_err(|error| format!("could not create {}: {error}", world_dir.display()))?;
let player_root = crate::worlds::player_root(xdg_data_home.as_deref(), &home); crate::worlds::ensure_player_dirs(&player_root, name) .map_err(|error| format!("could not create {}: {error}", player_root.display()))?;
let layers = crate::worlds::mount_stack(session_layers, &world_dir, &player_root, name);
crate::play::run(overrides, &layers, &world_dir, None)}
fn run_srd_fetch(sources: &SrdSources) -> Result<(), String> { let (pdf_outcome, text_outcome) = fetch::fetch_all(sources).map_err(|error| error.to_string())?; report_fetch(&sources.pdf.destination, pdf_outcome); report_fetch(&sources.text.destination, text_outcome); Ok(())}
/// Runs every check against the corpus layer at `layer_root`, printing one/// failure per line and a summary line on success.fn run_srd_verify(layer_root: &Path) -> Result<(), String> { render_verify_report(&verify::verify(layer_root))}
/// Prints `report`'s failures one per line, then either a summary line (no/// failures) or a terse error naming how many there were.fn render_verify_report(report: &verify::Report) -> Result<(), String> { for failure in &report.failures { println!("{failure}"); } if report.failures.is_empty() { println!("{}", report.summary()); Ok(()) } else { Err(format!("{} failure(s)", report.failures.len())) }}
/// Parses `notation`, rolls it once with a real random source, and prints/// its story.fn run_roll(notation: &str) -> Result<(), String> { let parsed = dice::parse(notation).map_err(|error| error.to_string())?; let result = dice::roll(&parsed, &mut rand::rng()); println!("{}", dice::story::tell(&result)); Ok(())}
fn report_fetch(destination: &Path, outcome: FetchOutcome) { match outcome { FetchOutcome::AlreadyPresent => { println!("{} is already present.", destination.display()); } FetchOutcome::Downloaded => { println!("Downloaded {}.", destination.display()); } }}
/// Maps a CLI result to a process exit code, printing the error on failure.pub fn exit_code(result: Result<(), String>) -> u8 { match result { Ok(()) => 0, Err(message) => { eprintln!("{message}"); 1 } }}
#[cfg(test)]mod tests { use super::*; use crate::srd::fetch::{FetchConfig, SrdMeta, TextFetchConfig}; use sha2::{Digest, Sha256}; use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; use std::path::PathBuf; use std::thread::JoinHandle; use tempfile::TempDir;
fn fetch_cli() -> Cli { Cli { command: Some(Command::Srd { command: SrdCommand::Fetch, }), } }
fn verify_cli() -> Cli { Cli { command: Some(Command::Srd { command: SrdCommand::Verify, }), } }
fn roll_cli(notation: &str) -> Cli { Cli { command: Some(Command::Roll { notation: notation.to_string(), }), } }
fn completions_cli(shell: Shell) -> Cli { Cli { command: Some(Command::Completions { shell }), } }
fn unused_sources() -> SrdSources { SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0".to_string(), destination: PathBuf::from("unused"), expected_sha256: "0".repeat(64), }, text: never_dialed_text_config(), meta: fixture_meta(), } }
fn sha256_hex(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); hasher .finalize() .iter() .map(|byte| format!("{byte:02x}")) .collect() }
/// Serves `body` once to the first connection it accepts, as a complete /// HTTP/1.1 response, then returns the URL to fetch it from. fn serve_once(body: Vec<u8>) -> (String, JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let handle = std::thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); let mut request = [0u8; 1024]; let _ = stream.read(&mut request).unwrap(); let header = format!( "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); stream.write_all(header.as_bytes()).unwrap(); stream.write_all(&body).unwrap(); }); (format!("http://{addr}/fixture.pdf"), handle) }
/// Appends a tar entry for `path` to `builder`, as a directory when /// `contents` is `None` or as a file with that content otherwise. fn append_entry(builder: &mut tar::Builder<Vec<u8>>, path: &str, contents: Option<&[u8]>) { let mut header = tar::Header::new_gnu(); header.set_path(path).unwrap(); header.set_mode(0o644); match contents { Some(bytes) => { header.set_size(bytes.len() as u64); header.set_cksum(); builder.append(&header, bytes).unwrap(); } None => { header.set_size(0); header.set_entry_type(tar::EntryType::Directory); header.set_cksum(); builder.append(&header, std::io::empty()).unwrap(); } } }
/// Builds a tiny gzipped tarball with one top-level directory and one /// file inside it, standing in for the real dnd.srd.5.2.1 tarball. fn tiny_tar_gz(top_dir: &str) -> Vec<u8> { let mut builder = tar::Builder::new(Vec::new()); append_entry(&mut builder, &format!("{top_dir}/"), None); append_entry( &mut builder, &format!("{top_dir}/file.md"), Some(b"# Fixture\n"), ); let tar_bytes = builder.into_inner().unwrap();
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); encoder.write_all(&tar_bytes).unwrap(); encoder.finish().unwrap() }
fn never_dialed_text_config() -> TextFetchConfig { TextFetchConfig { url: "http://127.0.0.1:0".to_string(), destination: PathBuf::from("unused"), expected_tarball_sha256: "0".repeat(64), commit: "deadbeef".to_string(), meta_path: PathBuf::from("unused-meta.yaml"), } }
fn fixture_meta() -> SrdMeta { SrdMeta { version: "5.2.1".to_string(), pdf_path: "sources/SRD_CC_v5.2.1.pdf".to_string(), pdf_url: "https://example.test/srd.pdf".to_string(), pdf_sha256: "a".repeat(64), text_path: "sources/dnd.srd.5.2.1/".to_string(), text_url: "https://example.test/dnd.srd.5.2.1".to_string(), text_commit: "deadbeef".to_string(), text_tarball_sha256: "b".repeat(64), } }
#[test] fn run_with_no_command_prints_the_greeting() { let sources = SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0".to_string(), destination: PathBuf::from("unused"), expected_sha256: "0".repeat(64), }, text: never_dialed_text_config(), meta: fixture_meta(), }; let layer_dir = TempDir::new().unwrap();
let result = run(Cli { command: None }, &sources, layer_dir.path(), &[]);
assert_eq!(result, Ok(())); }
#[test] fn run_srd_fetch_reports_an_already_present_file() { let pdf_dir = TempDir::new().unwrap(); let text_dir = TempDir::new().unwrap(); let meta_dir = TempDir::new().unwrap(); let layer_dir = TempDir::new().unwrap(); let pdf_body = b"already present".to_vec(); let pdf_expected_sha256 = sha256_hex(&pdf_body); let pdf_destination = pdf_dir.path().join("fixture.pdf"); fs::write(&pdf_destination, &pdf_body).unwrap(); let text_destination = text_dir.path().join("dnd.srd.5.2.1"); fs::create_dir_all(&text_destination).unwrap(); let meta_path = meta_dir.path().join("meta.yaml"); fs::write( &meta_path, "sources:\n - path: sources/dnd.srd.5.2.1/\n commit: deadbeef\n", ) .unwrap(); let sources = SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0".to_string(), destination: pdf_destination, expected_sha256: pdf_expected_sha256, }, text: TextFetchConfig { url: "http://127.0.0.1:0".to_string(), destination: text_destination, expected_tarball_sha256: "0".repeat(64), commit: "deadbeef".to_string(), meta_path, }, meta: fixture_meta(), };
let result = run(fetch_cli(), &sources, layer_dir.path(), &[]);
assert_eq!(result, Ok(())); }
#[test] fn run_srd_fetch_reports_a_successful_download() { let pdf_body = b"freshly downloaded".to_vec(); let pdf_expected_sha256 = sha256_hex(&pdf_body); let (pdf_url, pdf_server) = serve_once(pdf_body); let text_body = tiny_tar_gz("dnd.srd.5.2.1-deadbeef"); let text_expected_sha256 = sha256_hex(&text_body); let (text_url, text_server) = serve_once(text_body); let pdf_dir = TempDir::new().unwrap(); let text_dir = TempDir::new().unwrap(); let meta_dir = TempDir::new().unwrap(); let layer_dir = TempDir::new().unwrap(); let sources = SrdSources { pdf: FetchConfig { url: pdf_url, destination: pdf_dir.path().join("fixture.pdf"), expected_sha256: pdf_expected_sha256, }, text: TextFetchConfig { url: text_url, destination: text_dir.path().join("dnd.srd.5.2.1"), expected_tarball_sha256: text_expected_sha256, commit: "deadbeef".to_string(), meta_path: meta_dir.path().join("meta.yaml"), }, meta: fixture_meta(), };
let result = run(fetch_cli(), &sources, layer_dir.path(), &[]);
assert_eq!(result, Ok(())); pdf_server.join().unwrap(); text_server.join().unwrap(); }
#[test] fn run_srd_fetch_reports_a_download_failure_as_an_error() { let pdf_dir = TempDir::new().unwrap(); let layer_dir = TempDir::new().unwrap(); let sources = SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0/fixture.pdf".to_string(), destination: pdf_dir.path().join("fixture.pdf"), expected_sha256: "0".repeat(64), }, text: never_dialed_text_config(), meta: fixture_meta(), };
let result = run(fetch_cli(), &sources, layer_dir.path(), &[]);
assert!(result.is_err()); }
#[test] fn run_srd_verify_reports_failures_as_an_error() { let sources = SrdSources { pdf: FetchConfig { url: "http://127.0.0.1:0".to_string(), destination: PathBuf::from("unused"), expected_sha256: "0".repeat(64), }, text: never_dialed_text_config(), meta: fixture_meta(), }; let layer_dir = TempDir::new().unwrap();
let result = run(verify_cli(), &sources, layer_dir.path(), &[]);
assert!(result.is_err()); }
#[test] fn run_completions_prints_a_script_for_bash() { let layer_dir = TempDir::new().unwrap();
let result = run( completions_cli(Shell::Bash), &unused_sources(), layer_dir.path(), &[], );
assert_eq!(result, Ok(())); }
#[test] fn run_roll_prints_the_story_for_valid_notation() { let layer_dir = TempDir::new().unwrap();
let result = run(roll_cli("4d6kh3"), &unused_sources(), layer_dir.path(), &[]);
assert_eq!(result, Ok(())); }
#[test] fn run_roll_reports_a_parse_error_for_invalid_notation() { let layer_dir = TempDir::new().unwrap();
let result = run(roll_cli("banana"), &unused_sources(), layer_dir.path(), &[]);
assert!(result.is_err()); }
#[test] fn render_verify_report_succeeds_with_a_summary_when_there_are_no_failures() { let report = verify::Report { failures: vec![], counts: std::collections::HashMap::new(), };
let result = render_verify_report(&report);
assert_eq!(result, Ok(())); }
#[test] fn render_verify_report_reports_the_failure_count_as_an_error() { let report = verify::Report { failures: vec![ verify::Failure::new("spells/fireball.md", "missing name"), verify::Failure::new("monsters/goblin.md", "missing ac"), ], counts: std::collections::HashMap::new(), };
let result = render_verify_report(&report);
assert_eq!(result, Err("2 failure(s)".to_string())); }
#[test] fn exit_code_is_zero_on_success() { assert_eq!(exit_code(Ok(())), 0); }
#[test] fn exit_code_is_one_on_failure() { assert_eq!(exit_code(Err("something broke".to_string())), 1); }}