diff --git a/rules/drowned-coast/lore/factions/the-lantern-keepers.md b/rules/drowned-coast/lore/factions/the-lantern-keepers.md new file mode 100644 index 0000000..b00e513 --- /dev/null +++ b/rules/drowned-coast/lore/factions/the-lantern-keepers.md @@ -0,0 +1,14 @@ +--- +name: The Lantern Keepers +type: faction +--- + +# The Lantern Keepers + +The Lantern Keepers watch the bell tower at the end of Graywater's pier in rotating shifts, one household per night. Their charter is short: keep the bell rung through fog, keep a lantern burning past midnight during a storm tide, and stand over any grave until the priest closes it. + +Membership passes by household, not by blood. Any adult from a Graywater family can stand a watch, and most do at least once a season. The Keepers elect a warden every three years to hold the tower's one key and the ledger of who watched which night; the current warden is Torvald Ansen, a net-mender by trade. + +The watch exists because a lit shore keeps a drowning sailor swimming toward it instead of toward the dark, and because a [[rules/monsters/will-o-wisp]] working the tide flats reads as just another light unless someone on the tower is counting how many lanterns should be burning. The Keepers lost two watchers to exactly that trick eleven years ago, which is why the current charter requires two people on the tower every night, not one. + +The Keepers do not talk about [[lore/legends/the-drowning-bell]] with outsiders. Ask a Keeper directly and the shift changes the subject to the tide schedule. diff --git a/rules/drowned-coast/lore/legends/the-drowning-bell.md b/rules/drowned-coast/lore/legends/the-drowning-bell.md new file mode 100644 index 0000000..e06b0ee --- /dev/null +++ b/rules/drowned-coast/lore/legends/the-drowning-bell.md @@ -0,0 +1,12 @@ +--- +name: The Drowning Bell +type: legend +--- + +# The Drowning Bell + +Graywater's bell tower held a second bell once, cast the same year as the first, from the same batch of bronze. The story says a founding Keeper melted it down during the storm that killed eleven people on Low Street, because the first bell had rung clear through the whole night and the tide took its eleven anyway. + +What the story does not agree on is what he cast from the bronze instead. Half of Graywater says he cast the fittings that later went down with the ship Coldrun Maid. The other half says he cast the [[rules/magic-items/lantern-of-the-last-light]] that the Lantern Keepers still carry on the worst watches, the one said to let a warden ask the newly drowned one question before the sea finishes taking them. + +Both versions of the story end the same way: the bronze remembers what it heard the bell ring for, and it does not let go of it either. diff --git a/rules/drowned-coast/lore/people/mother-uldra.md b/rules/drowned-coast/lore/people/mother-uldra.md new file mode 100644 index 0000000..87b373d --- /dev/null +++ b/rules/drowned-coast/lore/people/mother-uldra.md @@ -0,0 +1,14 @@ +--- +name: Mother Uldra +type: person +--- + +# Mother Uldra + +Mother Uldra lives alone in a shack built on pilings over the tide flats south of [[lore/places/graywater]], reachable only at low tide. She has lived there for at least forty years; nobody in Graywater remembers her arriving, and nobody has seen her leave. + +She trades in cures, curses, and information, and she prices all three the same way: a tooth, a lock of hair, or a year off the asker's life, named plainly before she speaks. She does not haggle and she does not lie, though she is careful about which true things she tells. + +Uldra is a [[rules/monsters/salt-hag]]. The Lantern Keepers know this and leave her alone, on the understanding that she leaves the village's children alone in turn. The arrangement has held for longer than anyone currently living has been alive to check. + +Her shack smells of brine and tallow. Jars line every shelf, and most of what floats in them used to be someone. diff --git a/rules/drowned-coast/lore/places/graywater.md b/rules/drowned-coast/lore/places/graywater.md new file mode 100644 index 0000000..a2306b0 --- /dev/null +++ b/rules/drowned-coast/lore/places/graywater.md @@ -0,0 +1,14 @@ +--- +name: Graywater +type: place +--- + +# Graywater + +Graywater sits at the mouth of the Coldrun, where the river meets the sea over a mile of tide flats. Forty-one households fish the flats and the deep water past the bar, and the tide fills every cellar on Low Street twice a day. Nobody builds a root cellar on Low Street anymore; the salt gets into anything stored under three feet of dirt within a season. + +The village runs on three unspoken rules. Nobody swims after dark. Nobody buries a body without a Lantern Keeper standing over the grave. Nobody goes to [[lore/people/mother-uldra]] without something to trade, because she takes nothing on credit. + +The [[lore/factions/the-lantern-keepers]] hold the bell tower at the end of the pier, and every household owes them a night of watch once a season. The arrangement started three generations back, after the winter storm that took eleven people from Low Street in a single night, and it has not lapsed since. + +Graywater ships salt cod, kelp twine, and rendered oil south along the coast road. It buys grain, iron, and lamp glass, and it keeps its own dead close. diff --git a/rules/drowned-coast/meta.yaml b/rules/drowned-coast/meta.yaml new file mode 100644 index 0000000..ffa153d --- /dev/null +++ b/rules/drowned-coast/meta.yaml @@ -0,0 +1,4 @@ +name: The Drowned Coast +version: 0.1.0 +publisher: Saltworks Press (fictional) +description: A cold fishing coast where the salt preserves what the sea drowns. diff --git a/rules/drowned-coast/rules/magic-items/lantern-of-the-last-light.md b/rules/drowned-coast/rules/magic-items/lantern-of-the-last-light.md new file mode 100644 index 0000000..25effa8 --- /dev/null +++ b/rules/drowned-coast/rules/magic-items/lantern-of-the-last-light.md @@ -0,0 +1,21 @@ +--- +name: Lantern of the Last Light +type: magic-item +category: Wondrous Item +rarity: rare +attunement: true +--- + +# Lantern of the Last Light + +*Wondrous Item, Rare (Requires Attunement)* + +**Category:** Wondrous Item + +**Rarity:** Rare + +**Attunement:** Requires Attunement + +This hooded lantern burns on rendered whale oil and never runs dry while you are attuned to it. While lit, it sheds Bright Light in a 30-foot radius and Dim Light for an additional 30 feet, and any Undead creature that starts its turn in the lantern's light takes 5 (1d10) Radiant damage. + +***Last Light.*** As an action, you can snuff the lantern and speak the name of a creature that died within the last 24 hours. If that creature drowned, its spirit appears in the light for 1 minute and answers up to three questions truthfully, though it can lie about the manner of its own death. Once used, this property can't be used again until the next dusk. diff --git a/rules/drowned-coast/rules/monsters/brine-wraith.md b/rules/drowned-coast/rules/monsters/brine-wraith.md new file mode 100644 index 0000000..89c0a8d --- /dev/null +++ b/rules/drowned-coast/rules/monsters/brine-wraith.md @@ -0,0 +1,40 @@ +--- +name: Brine Wraith +type: monster +size: Medium +creature_type: Undead +alignment: Chaotic Evil +ac: "13" +hp: 45 (7d8 + 14) +speed: 30 ft., Swim 40 ft. +abilities: + str: {score: 14, mod: 2, save: 2} + dex: {score: 15, mod: 2, save: 2} + con: {score: 14, mod: 2, save: 2} + int: {score: 8, mod: -1, save: -1} + wis: {score: 12, mod: 1, save: 1} + cha: {score: 10, mod: 0, save: 0} +resistances: Cold +immunities: Necrotic, Poison; Exhaustion, Poisoned +senses: Darkvision 60 ft.; Passive Perception 11 +languages: Understands Common but can't speak +cr: 2 (XP 450; PB +2) +--- + +# Brine Wraith + +*Medium Undead, Chaotic Evil* + +## Traits + +***Amphibious.*** The wraith can breathe air and water. + +***Waterlogged.*** The wraith has Advantage on Strength checks and Strength saving throws made against effects that would move it or knock it Prone while it is touching water. + +## Actions + +***Multiattack.*** The wraith makes two Claw attacks. + +***Claw.*** *Melee Attack Roll:* +4, reach 5 ft. *Hit:* 6 (1d6 + 2) Slashing damage plus 4 (1d8) Cold damage. + +***Drag Under.*** *Melee Attack Roll:* +4, reach 5 ft., one creature within 5 feet of the wraith and within 10 feet of open water. *Hit:* The target has the Grappled condition (escape DC 12), and the wraith pulls it 5 feet toward the water. diff --git a/rules/drowned-coast/rules/monsters/salt-hag.md b/rules/drowned-coast/rules/monsters/salt-hag.md new file mode 100644 index 0000000..17d4d42 --- /dev/null +++ b/rules/drowned-coast/rules/monsters/salt-hag.md @@ -0,0 +1,41 @@ +--- +name: Salt Hag +type: monster +size: Medium +creature_type: Fey +alignment: Neutral Evil +ac: "16" +hp: 66 (9d8 + 27) +speed: 30 ft., Swim 30 ft. +abilities: + str: {score: 16, mod: 3, save: 3} + dex: {score: 13, mod: 1, save: 1} + con: {score: 16, mod: 3, save: 3} + int: {score: 13, mod: 1, save: 1} + wis: {score: 15, mod: 2, save: 2} + cha: {score: 14, mod: 2, save: 2} +skills: Deception +5, Perception +5, Survival +5 +senses: Darkvision 60 ft.; Passive Perception 15 +languages: Common, Primordial (Aquan) +cr: 3 (XP 700; PB +2) +--- + +# Salt Hag + +*Medium Fey, Neutral Evil* + +## Traits + +***Amphibious.*** The hag can breathe air and water. + +***Brine-Cured.*** The hag has Resistance to Necrotic and Poison damage. A corpse she has soaked in brine for at least 24 hours does not decay. + +***Tidal Sense.*** The hag can pinpoint the location of any creature within 60 feet of her that is touching salt water. + +## Actions + +***Multiattack.*** The hag makes two Claw attacks. + +***Claw.*** *Melee Attack Roll:* +6, reach 5 ft. *Hit:* 9 (2d6 + 3) Slashing damage. + +***Bargain of Salt (Recharge 5-6).*** *Wisdom Saving Throw:* DC 13, one creature within 30 feet that can hear the hag. *Failure:* The target is cursed for 24 hours. While cursed, the target must succeed on a DC 13 Wisdom saving throw before taking any action that would break a promise it made to the hag, or the action fails and its turn ends. diff --git a/rules/drowned-coast/rules/spells/tidebind.md b/rules/drowned-coast/rules/spells/tidebind.md new file mode 100644 index 0000000..acf59b3 --- /dev/null +++ b/rules/drowned-coast/rules/spells/tidebind.md @@ -0,0 +1,29 @@ +--- +name: Tidebind +type: spell +level: 2 +school: conjuration +classes: [druid, ranger, sorcerer, warlock] +casting_time: Action +range: 60 feet +components: V, S, M (a strand of dried kelp) +duration: Concentration, up to 1 minute +--- + +# Tidebind + +*Level 2 Conjuration (Druid, Ranger, Sorcerer, Warlock)* + +**Casting Time:** Action + +**Range:** 60 feet + +**Components:** V, S, M (a strand of dried kelp) + +**Duration:** Concentration, up to 1 minute + +Choose a creature you can see within range that is within 30 feet of a body of open water. Ropes of kelp and brine erupt from the nearest water and lash around the target. The target must succeed on a Strength saving throw or have the Restrained condition for the duration. A creature restrained this way can use its action to make a Strength (Athletics) check against your spell save DC, freeing itself on a success. + +At the end of each of the target's turns, if it is no longer within 30 feet of open water, the spell ends on it early. Unlike [[rules/spells/hold-person]], tidebind works on any creature, not only Humanoids. + +***Using a Higher-Level Spell Slot.*** You can target one additional creature within range for each spell slot level above 2. diff --git a/src/cli.rs b/src/cli.rs index 52c0bb0..ceaf99d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use clap::{Parser, Subcommand}; @@ -81,10 +81,15 @@ enum SrdCommand { Verify, } -/// Runs the parsed command, using `srd_sources` for `storied srd fetch`, and -/// `layer_root` for `storied srd verify` and as the DM's one knowledge -/// layer in `storied sandbox`. -pub fn run(cli: Cli, srd_sources: &SrdSources, layer_root: &Path) -> Result<(), String> { +/// 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, + session_layers: &[PathBuf], +) -> Result<(), String> { match cli.command { None => { println!("{}", crate::greeting()); @@ -98,9 +103,10 @@ pub fn run(cli: Cli, srd_sources: &SrdSources, layer_root: &Path) -> Result<(), }) => run_srd_verify(layer_root), Some(Command::Roll { notation }) => run_roll(¬ation), #[cfg(not(coverage))] - Some(Command::Sandbox { api_base, model }) => { - crate::play::run(&crate::config::Overrides { api_base, model }, layer_root) - } + Some(Command::Sandbox { api_base, model }) => crate::play::run( + &crate::config::Overrides { api_base, model }, + session_layers, + ), } } @@ -314,7 +320,7 @@ mod tests { }; let layer_dir = TempDir::new().unwrap(); - let result = run(Cli { command: None }, &sources, layer_dir.path()); + let result = run(Cli { command: None }, &sources, layer_dir.path(), &[]); assert_eq!(result, Ok(())); } @@ -353,7 +359,7 @@ mod tests { meta: fixture_meta(), }; - let result = run(fetch_cli(), &sources, layer_dir.path()); + let result = run(fetch_cli(), &sources, layer_dir.path(), &[]); assert_eq!(result, Ok(())); } @@ -386,7 +392,7 @@ mod tests { meta: fixture_meta(), }; - let result = run(fetch_cli(), &sources, layer_dir.path()); + let result = run(fetch_cli(), &sources, layer_dir.path(), &[]); assert_eq!(result, Ok(())); pdf_server.join().unwrap(); @@ -407,7 +413,7 @@ mod tests { meta: fixture_meta(), }; - let result = run(fetch_cli(), &sources, layer_dir.path()); + let result = run(fetch_cli(), &sources, layer_dir.path(), &[]); assert!(result.is_err()); } @@ -425,7 +431,7 @@ mod tests { }; let layer_dir = TempDir::new().unwrap(); - let result = run(verify_cli(), &sources, layer_dir.path()); + let result = run(verify_cli(), &sources, layer_dir.path(), &[]); assert!(result.is_err()); } @@ -434,7 +440,7 @@ mod tests { 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()); + let result = run(roll_cli("4d6kh3"), &unused_sources(), layer_dir.path(), &[]); assert_eq!(result, Ok(())); } @@ -443,7 +449,7 @@ mod tests { 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()); + let result = run(roll_cli("banana"), &unused_sources(), layer_dir.path(), &[]); assert!(result.is_err()); } diff --git a/src/knowledge/mod.rs b/src/knowledge/mod.rs index b0729be..ed97f77 100644 --- a/src/knowledge/mod.rs +++ b/src/knowledge/mod.rs @@ -26,3 +26,8 @@ pub use entry::Entry; pub use frontmatter::read_frontmatter; pub use mount::Mount; pub use search::{INLINE_BUDGET_BYTES, MAX_LISTED_MATCHES, Match, SearchResult, search}; + +/// The layers a play session mounts, lowest first: the SRD is the +/// foundation, and each later entry shadows addresses the ones before it +/// also define. The world layer joins this list in a later phase. +pub const SESSION_LAYERS: [&str; 2] = [crate::srd::SRD_LAYER_ROOT, "rules/drowned-coast"]; diff --git a/src/main.rs b/src/main.rs index 0f97a29..707f58d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use clap::Parser; @@ -6,6 +6,10 @@ fn main() -> std::process::ExitCode { let arguments = storied::cli::Cli::parse(); let srd_sources = storied::srd::fetch::SrdSources::srd_5_2_1(); let layer_root = Path::new(storied::srd::SRD_LAYER_ROOT); - let result = storied::cli::run(arguments, &srd_sources, layer_root); + let session_layers: Vec = storied::knowledge::SESSION_LAYERS + .iter() + .map(PathBuf::from) + .collect(); + let result = storied::cli::run(arguments, &srd_sources, layer_root, &session_layers); std::process::ExitCode::from(storied::cli::exit_code(result)) } diff --git a/src/play/terminal.rs b/src/play/terminal.rs index 5674bc7..b0a0e81 100644 --- a/src/play/terminal.rs +++ b/src/play/terminal.rs @@ -7,7 +7,7 @@ use std::env; use std::io::{self, Stdout, Write}; -use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -34,17 +34,17 @@ use super::worker::Worker; /// How long to wait for a key before looking at the worker again. const POLL_INTERVAL: Duration = Duration::from_millis(50); -/// Loads the config, opens the knowledge mount over `layer_root`, starts -/// the DM, and plays until the player quits. +/// Loads the config, opens the knowledge mount over `layers`, lowest +/// first, starts the DM, and plays until the player quits. /// /// The terminal goes back to how it was before the game, with the /// transcript still on the screen. An init that fails part way through has /// already put the terminal in raw mode, so that path restores it too. A /// mount that fails to open, the same as a config that fails to load, /// ends the game before the terminal changes anything. -pub fn run(overrides: &Overrides, layer_root: &Path) -> Result<(), String> { +pub fn run(overrides: &Overrides, layers: &[PathBuf]) -> Result<(), String> { let config = config::load(overrides).map_err(|error| error.to_string())?; - let mount = Arc::new(Mount::open(&[layer_root.to_path_buf()])?); + let mount = Arc::new(Mount::open(layers)?); let banner = super::banner(&config); let options = TerminalOptions { viewport: Viewport::Inline(VIEWPORT_HEIGHT), diff --git a/tests/layers.rs b/tests/layers.rs new file mode 100644 index 0000000..b09e56d --- /dev/null +++ b/tests/layers.rs @@ -0,0 +1,82 @@ +//! Integration tests over the play session's layer list: the SRD as the +//! foundation layer, with the Drowned Coast module mounted above it. +//! +//! [`wikilinks_in_layer`] walks every entry the Drowned Coast module +//! contributes and pulls out its `[[address]]` wikilinks, so a broken +//! link in new module content fails this suite instead of surfacing only +//! when a player follows it. + +use std::path::{Path, PathBuf}; + +use regex::Regex; +use storied::knowledge::{Mount, SESSION_LAYERS, normalize_address}; + +fn session_mount() -> Mount { + let layers: Vec = SESSION_LAYERS.iter().map(PathBuf::from).collect(); + Mount::open(&layers).expect("the session layers should open cleanly") +} + +fn wikilink_pattern() -> Regex { + Regex::new(r"\[\[([^\]]+)\]\]").expect("the wikilink pattern is a valid regex") +} + +/// Every wikilink address written inside `text`, normalized the same way +/// the `read` tool normalizes a pasted address. +fn wikilinks_in(text: &str) -> Vec { + wikilink_pattern() + .captures_iter(text) + .map(|capture| normalize_address(&capture[1])) + .collect() +} + +/// Every wikilink address inside any entry `mount` loaded from the layer +/// at `layer_root`. +fn wikilinks_in_layer(mount: &Mount, layer_root: &Path) -> Vec { + mount + .entries() + .filter(|entry| entry.layer.as_path() == layer_root) + .flat_map(|entry| wikilinks_in(&entry.body)) + .collect() +} + +#[test] +fn the_session_layers_open_cleanly() { + session_mount(); +} + +#[test] +fn a_drowned_coast_entry_resolves_by_address_with_its_name() { + let mount = session_mount(); + + let entry = mount.get("rules/monsters/brine-wraith").unwrap(); + + assert_eq!(entry.name, "Brine Wraith"); +} + +#[test] +fn an_srd_entry_still_resolves() { + let mount = session_mount(); + + let entry = mount.get("rules/monsters/ghoul").unwrap(); + + assert_eq!(entry.name, "Ghoul"); +} + +#[test] +fn every_wikilink_in_the_drowned_coast_layer_resolves() { + let mount = session_mount(); + let addresses = wikilinks_in_layer(&mount, Path::new("rules/drowned-coast")); + + assert!( + !addresses.is_empty(), + "expected the Drowned Coast module to contain wikilinks" + ); + let unresolved: Vec<&String> = addresses + .iter() + .filter(|address| mount.get(address).is_none()) + .collect(); + assert!( + unresolved.is_empty(), + "unresolved wikilinks in the Drowned Coast module: {unresolved:?}" + ); +}