diff --git a/src/cli.rs b/src/cli.rs index 884d36d..c963d99 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -87,6 +87,31 @@ enum Command { #[arg(long, value_name = "LEVEL")] reasoning_effort: Option, }, + /// 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, + /// The base URL of the OpenAI-compatible API to talk to. + #[arg(long, value_name = "URL")] + api_base: Option, + /// The model to play with. + #[arg(long, value_name = "NAME")] + model: Option, + /// 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, + }, /// Print a completion script for a shell. #[command(after_help = COMPLETIONS_AFTER_HELP)] Completions { @@ -150,6 +175,23 @@ pub fn run( 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), } } @@ -216,6 +258,81 @@ fn run_sandbox( 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 `/` +/// 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/` overlay mounted above this one. +#[cfg(not(coverage))] +fn run_play( + overrides: &crate::config::Overrides, + session_layers: &[PathBuf], + name: &str, + worlds_root: Option, +) -> 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); + + if resumed { + println!("play resumes an existing world this session:"); + } else { + println!("play begins a new world this session:"); + } + println!(" world: {}", world_dir.display()); + println!(" player: {}", player_root.display()); + println!("mount stack, lowest first:"); + for layer in &layers { + println!(" {}", layer.display()); + } + + 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())?; diff --git a/src/config.rs b/src/config.rs index 1e4e0c1..86691fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,6 +26,10 @@ pub struct Config { /// provider verbatim. `None` omits the field from every request, so /// the provider's own default applies. pub reasoning_effort: Option, + /// Where named worlds live, when neither `--worlds-root` nor + /// `STORIED_WORLDS` says. See `crate::worlds::resolve_root` for the + /// full order. + pub worlds_root: Option, } /// Values that replace the config file's `api_base`, `model`, and @@ -46,6 +50,7 @@ struct RawConfig { model: Option, api_key_env: Option, reasoning_effort: Option, + worlds_root: Option, } /// Everything that can go wrong loading the config file. @@ -185,12 +190,14 @@ fn resolve_config( })?; let reasoning_effort = overrides.reasoning_effort.clone().or(raw.reasoning_effort); + let worlds_root = raw.worlds_root.map(PathBuf::from); Ok(Config { api_base, api_key, model, reasoning_effort, + worlds_root, }) } diff --git a/src/config_tests.rs b/src/config_tests.rs index 08a46ca..ce6a060 100644 --- a/src/config_tests.rs +++ b/src/config_tests.rs @@ -215,6 +215,36 @@ fn the_example_config_parses_and_sets_a_reasoning_effort() { assert_eq!(config.reasoning_effort, Some("high".to_string())); } +#[test] +fn worlds_root_is_none_when_absent_from_the_file() { + let path = PathBuf::from("/config/storied/config.toml"); + let contents = "api_base = \"https://example.test\"\n\ + model = \"test-model\"\n\ + api_key_env = \"TEST_API_KEY\"\n"; + + let config = + resolve_config(contents, &path, &no_overrides(), &env_with_key("sk-live")).unwrap(); + + assert_eq!(config.worlds_root, None); +} + +#[test] +fn worlds_root_loads_from_the_file() { + let path = PathBuf::from("/config/storied/config.toml"); + let contents = "api_base = \"https://example.test\"\n\ + model = \"test-model\"\n\ + api_key_env = \"TEST_API_KEY\"\n\ + worlds_root = \"/srv/storied-worlds\"\n"; + + let config = + resolve_config(contents, &path, &no_overrides(), &env_with_key("sk-live")).unwrap(); + + assert_eq!( + config.worlds_root, + Some(PathBuf::from("/srv/storied-worlds")) + ); +} + #[test] fn a_syntax_error_is_a_malformed_error() { let path = PathBuf::from("/config/storied/config.toml"); diff --git a/src/dm/dm_session_start_tests.rs b/src/dm/dm_session_start_tests.rs index e746a7c..094c289 100644 --- a/src/dm/dm_session_start_tests.rs +++ b/src/dm/dm_session_start_tests.rs @@ -297,6 +297,7 @@ fn a_transcript_that_cannot_be_read_fails_the_dm_at_startup() { api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, + worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], diff --git a/src/dm/dm_tests.rs b/src/dm/dm_tests.rs index 3d1c827..7d4a997 100644 --- a/src/dm/dm_tests.rs +++ b/src/dm/dm_tests.rs @@ -24,6 +24,7 @@ pub(super) fn dm_for(api_base: String) -> Dm { api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, + worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], @@ -41,6 +42,7 @@ fn dm_with_reasoning_effort(api_base: String, reasoning_effort: &str) -> Dm { api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: Some(reasoning_effort.to_string()), + worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], @@ -58,6 +60,7 @@ pub(super) fn dm_with_campaign(api_base: String, world: &TempDir) -> Dm { api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, + worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], @@ -381,6 +384,7 @@ fn dm_over(api_base: String, layers: &[PathBuf]) -> Result { api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, + worlds_root: None, }, Arc::new(fixtures::mount(&[])), layers, diff --git a/src/dm/dm_tool_round_tests.rs b/src/dm/dm_tool_round_tests.rs index 715bbf6..9232d84 100644 --- a/src/dm/dm_tool_round_tests.rs +++ b/src/dm/dm_tool_round_tests.rs @@ -32,6 +32,7 @@ fn dm_with_seeded_dice(api_base: String, seed: u64) -> Dm { api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, + worlds_root: None, }, toolbox, empty_context(), @@ -196,6 +197,7 @@ fn a_post_turn_transcript_write_failure_appends_a_warning_instead_of_failing_the api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, + worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], diff --git a/src/dm/report_tests.rs b/src/dm/report_tests.rs index 41c359b..4a6763f 100644 --- a/src/dm/report_tests.rs +++ b/src/dm/report_tests.rs @@ -36,6 +36,7 @@ fn dm(layers: &[PathBuf], world: &TempDir) -> Dm { api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, + worlds_root: None, }, Arc::new(fixtures::mount(&[])), layers, diff --git a/src/lib.rs b/src/lib.rs index 004c8e0..e4a6a48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod knowledge; pub mod markdown; pub mod play; pub mod srd; +pub mod worlds; pub mod wrap; pub fn greeting() -> &'static str { diff --git a/src/play/mod.rs b/src/play/mod.rs index afd1fdd..81a3edd 100644 --- a/src/play/mod.rs +++ b/src/play/mod.rs @@ -51,6 +51,7 @@ mod tests { api_key: "sk-test".to_string(), model: "a-model".to_string(), reasoning_effort: None, + worlds_root: None, }; assert_eq!(banner(&config), "a-model @ https://api.example.test/v1"); diff --git a/src/play/worker.rs b/src/play/worker.rs index 8ac8b44..44e2194 100644 --- a/src/play/worker.rs +++ b/src/play/worker.rs @@ -274,6 +274,7 @@ mod tests { api_key: "sk-test".to_string(), model: "a-model".to_string(), reasoning_effort: None, + worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], @@ -290,6 +291,7 @@ mod tests { api_key: "sk-test".to_string(), model: "a-model".to_string(), reasoning_effort: None, + worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], diff --git a/src/worlds.rs b/src/worlds.rs new file mode 100644 index 0000000..7e7a2ee --- /dev/null +++ b/src/worlds.rs @@ -0,0 +1,116 @@ +//! Named worlds and the root they live under. +//! +//! `storied play ` opens `/`, where the root resolves +//! through [`resolve_root`]. A named world's directory is the world layer +//! itself, unlike the sandbox's `world/` and `player/` pair: there is no +//! `world/` subdirectory inside it. The player's own knowledge lives +//! outside the world entirely, at a fixed data directory shared across +//! every world, with a `worlds/` overlay that mounts above the +//! world it is about. See [`crate::play::sandbox`] for the sandbox's own +//! scaffold, which this module does not touch. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Resolves the worlds root: `flag` first, then `env`, then `config`, +/// then the XDG data home default. +/// +/// `env` is treated as unset when empty, the same as a real environment +/// variable nobody bothered to export. +pub fn resolve_root( + flag: Option<&Path>, + env: Option<&str>, + config: Option<&Path>, + xdg_data_home: Option<&str>, + home: &str, +) -> PathBuf { + if let Some(dir) = flag { + return dir.to_path_buf(); + } + if let Some(dir) = env.filter(|value| !value.is_empty()) { + return PathBuf::from(dir); + } + if let Some(dir) = config { + return dir.to_path_buf(); + } + default_root(xdg_data_home, home) +} + +/// Computes storied's data directory from `xdg_data_home` and `home`. +/// +/// Uses `xdg_data_home` when it is set and non-empty, otherwise +/// `{home}/.local/share`. +fn data_home(xdg_data_home: Option<&str>, home: &str) -> PathBuf { + match xdg_data_home { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => Path::new(home).join(".local").join("share"), + } +} + +/// The default worlds root, `{data home}/storied/worlds`, used when no +/// flag, environment variable, or config key names one. +pub fn default_root(xdg_data_home: Option<&str>, home: &str) -> PathBuf { + data_home(xdg_data_home, home) + .join("storied") + .join("worlds") +} + +/// The player's own directory, `{data home}/storied/player`. Fixed, +/// unlike the worlds root: it does not move with `--worlds-root` or +/// `STORIED_WORLDS`, because it is not about where worlds live. +pub fn player_root(xdg_data_home: Option<&str>, home: &str) -> PathBuf { + data_home(xdg_data_home, home) + .join("storied") + .join("player") +} + +/// Interprets a line typed at the "create it?" prompt. Only `y` or +/// `yes`, matched without regard to case or surrounding space, means +/// yes. +pub fn wants_to_create(answer: &str) -> bool { + matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + +/// The player's notes for one world, `{player root}/worlds/{name}`. +fn player_world_dir(player_root: &Path, name: &str) -> PathBuf { + player_root.join("worlds").join(name) +} + +/// Builds the mount stack for a named world, lowest layer first: the +/// session layers, the world's own directory, the player's own +/// knowledge, then the player's notes for this world alone. +pub fn mount_stack( + session_layers: &[PathBuf], + world_dir: &Path, + player_root: &Path, + name: &str, +) -> Vec { + let mut layers = session_layers.to_vec(); + layers.push(world_dir.to_path_buf()); + layers.push(player_root.to_path_buf()); + layers.push(player_world_dir(player_root, name)); + layers +} + +/// Creates `root/name` and every directory above it that does not exist +/// yet, and returns the world's directory. +pub fn create_world_dir(root: &Path, name: &str) -> io::Result { + let dir = root.join(name); + fs::create_dir_all(&dir)?; + Ok(dir) +} + +/// Creates the player's own directory and its `worlds/` overlay +/// for one world, when either does not exist yet, and returns the +/// overlay's directory. +pub fn ensure_player_dirs(player_root: &Path, name: &str) -> io::Result { + fs::create_dir_all(player_root)?; + let world_notes = player_world_dir(player_root, name); + fs::create_dir_all(&world_notes)?; + Ok(world_notes) +} + +#[cfg(test)] +#[path = "worlds_tests.rs"] +mod tests; diff --git a/src/worlds_tests.rs b/src/worlds_tests.rs new file mode 100644 index 0000000..f18b886 --- /dev/null +++ b/src/worlds_tests.rs @@ -0,0 +1,207 @@ +//! Tests for `worlds.rs`, split out to keep the production file under +//! the project's file-length guideline. + +use super::*; +use tempfile::TempDir; + +#[test] +fn resolve_root_prefers_the_flag_over_everything_else() { + let root = resolve_root( + Some(Path::new("/flag")), + Some("/env"), + Some(Path::new("/config")), + Some("/xdg"), + "/home/player", + ); + + assert_eq!(root, PathBuf::from("/flag")); +} + +#[test] +fn resolve_root_falls_back_to_the_env_var_when_the_flag_is_absent() { + let root = resolve_root( + None, + Some("/env"), + Some(Path::new("/config")), + Some("/xdg"), + "/home/player", + ); + + assert_eq!(root, PathBuf::from("/env")); +} + +#[test] +fn resolve_root_treats_an_empty_env_var_as_unset() { + let root = resolve_root( + None, + Some(""), + Some(Path::new("/config")), + Some("/xdg"), + "/home/player", + ); + + assert_eq!(root, PathBuf::from("/config")); +} + +#[test] +fn resolve_root_falls_back_to_the_config_key_when_the_flag_and_env_are_absent() { + let root = resolve_root( + None, + None, + Some(Path::new("/config")), + Some("/xdg"), + "/home/player", + ); + + assert_eq!(root, PathBuf::from("/config")); +} + +#[test] +fn resolve_root_falls_back_to_the_default_when_nothing_else_says() { + let root = resolve_root(None, None, None, Some("/xdg"), "/home/player"); + + assert_eq!(root, PathBuf::from("/xdg/storied/worlds")); +} + +#[test] +fn default_root_uses_xdg_data_home_when_set() { + let root = default_root(Some("/xdg"), "/home/player"); + + assert_eq!(root, PathBuf::from("/xdg/storied/worlds")); +} + +#[test] +fn default_root_falls_back_to_home_local_share_when_xdg_is_unset() { + let root = default_root(None, "/home/player"); + + assert_eq!( + root, + PathBuf::from("/home/player/.local/share/storied/worlds") + ); +} + +#[test] +fn default_root_falls_back_to_home_local_share_when_xdg_is_empty() { + let root = default_root(Some(""), "/home/player"); + + assert_eq!( + root, + PathBuf::from("/home/player/.local/share/storied/worlds") + ); +} + +#[test] +fn player_root_uses_xdg_data_home_when_set() { + let root = player_root(Some("/xdg"), "/home/player"); + + assert_eq!(root, PathBuf::from("/xdg/storied/player")); +} + +#[test] +fn player_root_falls_back_to_home_local_share_when_xdg_is_unset() { + let root = player_root(None, "/home/player"); + + assert_eq!( + root, + PathBuf::from("/home/player/.local/share/storied/player") + ); +} + +#[test] +fn wants_to_create_accepts_y() { + assert!(wants_to_create("y")); +} + +#[test] +fn wants_to_create_accepts_yes_regardless_of_case() { + assert!(wants_to_create("YES")); +} + +#[test] +fn wants_to_create_trims_surrounding_space_and_the_trailing_newline() { + assert!(wants_to_create(" yes\n")); +} + +#[test] +fn wants_to_create_rejects_no() { + assert!(!wants_to_create("n")); +} + +#[test] +fn wants_to_create_rejects_an_empty_line() { + assert!(!wants_to_create("")); +} + +#[test] +fn wants_to_create_rejects_a_word_that_only_starts_with_y() { + assert!(!wants_to_create("yeah")); +} + +#[test] +fn mount_stack_orders_session_layers_then_world_then_player_then_player_world() { + let session_layers = vec![PathBuf::from("/srd"), PathBuf::from("/rules")]; + + let layers = mount_stack( + &session_layers, + Path::new("/worlds/drowned-coast"), + Path::new("/player"), + "drowned-coast", + ); + + assert_eq!( + layers, + vec![ + PathBuf::from("/srd"), + PathBuf::from("/rules"), + PathBuf::from("/worlds/drowned-coast"), + PathBuf::from("/player"), + PathBuf::from("/player/worlds/drowned-coast"), + ] + ); +} + +#[test] +fn create_world_dir_creates_the_named_directory_under_the_root() { + let root = TempDir::new().unwrap(); + + let dir = create_world_dir(root.path(), "drowned-coast").unwrap(); + + assert!(dir.is_dir()); + assert_eq!(dir, root.path().join("drowned-coast")); +} + +#[test] +fn create_world_dir_is_fine_when_the_directory_already_exists() { + let root = TempDir::new().unwrap(); + create_world_dir(root.path(), "drowned-coast").unwrap(); + + let dir = create_world_dir(root.path(), "drowned-coast").unwrap(); + + assert!(dir.is_dir()); +} + +#[test] +fn ensure_player_dirs_creates_the_player_root_and_its_world_overlay() { + let player = TempDir::new().unwrap(); + let player_root = player.path().join("player"); + + let world_notes = ensure_player_dirs(&player_root, "drowned-coast").unwrap(); + + assert!(player_root.is_dir()); + assert!(world_notes.is_dir()); + assert_eq!( + world_notes, + player_root.join("worlds").join("drowned-coast") + ); +} + +#[test] +fn ensure_player_dirs_is_fine_when_the_directories_already_exist() { + let player = TempDir::new().unwrap(); + let player_root = player.path().join("player"); + ensure_player_dirs(&player_root, "drowned-coast").unwrap(); + + let world_notes = ensure_player_dirs(&player_root, "drowned-coast").unwrap(); + + assert!(world_notes.is_dir()); +}