diff --git a/plans/0000-roadmap.md b/plans/0000-roadmap.md index 329cdf4..e4c23fc 100644 --- a/plans/0000-roadmap.md +++ b/plans/0000-roadmap.md @@ -144,7 +144,13 @@ no plan yet is a stub and keeps its number until its plan is written. entities get their history retroactively, discovers aliases, and merges duplicates, and ends each pass by rewriting `session.md`, the brief the next session starts from. The only thing it may do to the - record is point at the notes. *You can now: come back tomorrow and + record is point at the notes. When an entity grows enough content, the + Archivist may promote it from a single file to a directory-style + entity (`places/foohaven/` with an `entry.md`), so a map or item art + extracted from a tagged fence in narration gets its own file beside it + (`places/foohaven/maps/north-gate.md`) and the entry links it from a + `## Maps` list, with a DM-facing way to pull that file's content when + it is needed. *You can now: come back tomorrow and find the innkeeper's page written up and a brief for where you left off.* ([0012](0012-the-archivist.md)) - [ ] **The DM's storytelling voice.** Instructions that teach diff --git a/plans/0013-the-aside.md b/plans/0013-the-aside.md index 6a78a92..c78ab76 100644 --- a/plans/0013-the-aside.md +++ b/plans/0013-the-aside.md @@ -10,8 +10,9 @@ mysteries with committed answers, no trap the DM planned two rooms ago. The DM should get to hallucinate secret truth too. The founding rule tells us where it has to live: hallucination becomes canon when it is written down. So secrets get a written home, behind the screen. The -mechanism is one tool, `aside`, and one markdown form for secret -content that works across the whole file system. +mechanism is one markdown form for secret content that works across the +whole file system, and the DM writes it inside its ordinary narration +the way it writes a map or a letter, with no tool call in between. ## Why thinking tokens are not enough @@ -33,17 +34,29 @@ four ways: Thinking stays what it is, a scratchpad. The prompt rule is: if it must still be true tomorrow, write it behind the screen. -## The `aside` tool +## Writing an aside -The DM calls `aside` with a piece of secret canon. The engine appends -it to the transcript at the current game time, the same way `mark` -writes at the current clock. The model gets a terse receipt, because -the API requires a tool result. The player sees nothing: no line, no -icon, no pause. +The DM writes an aside where the moment calls for it, inside its +narration, as a fenced code block tagged `aside`. It never stops to +make a tool call or read a receipt: committing a secret is part of +telling the story, so it flows the way narrating a scene does. -The secret is still provable after the fact. The transcript on disk -timestamps it, and file history shows the answer existed before the -player found it. A human DM cannot prove that. +```markdown +The party steps into the hall. The torches gutter. + +```aside +The answer is already on record: the killer is [[the innkeeper]]. +``` + +A bell tolls somewhere above. +``` + +The player sees none of it: no line, no icon, no pause. The engine +recognizes the tag at render time and commits nothing of the block to +the player's scrollback. The full narration, fence and all, still lands +in the transcript, timestamped at the current clock, so the secret is +as provable as any `mark`: the file history shows the answer existed +before the player found it. A human DM cannot prove that. ## The form on disk @@ -63,14 +76,15 @@ a code block's language string separate from its source, so the engine tells an aside from an ordinary code block without inspecting the content. The content is verbatim, which an aside wants: this text is engine machinery, not in-world speech, so nothing inside it should be -rendered as markdown, and a later map, item, or letter block rides the -same fenced-and-tagged mechanism. +rendered as markdown, and a map, item, or letter block rides the same +fenced-and-tagged mechanism. -Because the content is verbatim, a markdown viewer shows the block as -code. That is the right look for a screened block: it tells anyone -reading the world files that this is DM machinery, not fiction. A -renderer that ever shows the DM's side of the table captions it "The -DM notes:". +An aside stands alone: blank line before and after the fence, nothing +else on the fence lines. A base-system line teaches the DM this, so the +renderer can rely on the block being its own. Because the content is +verbatim, a markdown viewer shows the block as code. That is the right +look for a screened block: it tells anyone reading the world files that +this is DM machinery, not fiction. ## Behind the screen, everywhere @@ -89,14 +103,19 @@ who owns the disk. ## What the engine does with an aside +- **The renderer hides it.** An aside is a rendering concern, not a + tool. The markdown stream recognizes the tag and commits nothing to + the player's rows; the prose before and after the fence still prints + and the aside silently disappears. A renderer that ever shows the + DM's side of the table captions it "The DM notes:". - **The scanner reads it.** Secret entities are entities: `[[The Crows]]` in an aside establishes a stub like any narration - reference would. Asides are tool output, not narration deltas, so - the tool feeds the scanner's state itself rather than riding the - bus. + reference would. Because an aside is narration, it rides the bus like + any other delta, and the scanner catches its wikilinks with no extra + plumbing. - **Replay routes it.** On restart, asides rejoin the DM's - conversation history and never reach a player-facing rendering. - This lands with 0011's step 5. + conversation history and never reach a player-facing rendering. This + lands with 0011's step 5. - **`recall` sees behind the screen, and says so.** Search returns whole time-block entries, so the ```` ```aside ```` fence travels with any hit that contains one, and the DM can tell a recalled secret from @@ -125,10 +144,11 @@ stream it always had room for in the visibility vocabulary. ## Implementation order -- [ ] 1. The form: recognize and append `[!aside]` blocks in the - transcript, at the current clock -- [ ] 2. The tool: `aside` in the DM's toolbox, terse receipt, no - player-facing output +- [x] 1. The form: the DM writes a fenced ```` ```aside ```` block inside + its narration; the transcript records it verbatim at the current + clock +- [x] 2. The renderer: recognize the tag and commit nothing of the block + to the player, with a base-system line teaching the DM the form - [ ] 3. The scanner path: aside text runs through wikilink resolution and can establish stubs - [ ] 4. Replay routing, with 0011 step 5: asides to the DM's history diff --git a/src/dm/base-system.md b/src/dm/base-system.md index 58857c5..d299e16 100644 --- a/src/dm/base-system.md +++ b/src/dm/base-system.md @@ -51,6 +51,18 @@ See Timekeeping for the clock and the history tools, `mark` and `recall`. Wikili A player line that opens with `(out of character)` is table talk, not the character speaking or acting. Answer it out of character too, briefly and plainly. The story does not move, nobody in the world hears it, and the clock does not mark. Return to the story when the player does. +## Aside + +You can hold secret truth behind the screen. A mystery's answer, a hidden motive, a trap planned two rooms ago: write it as its own fenced code block tagged `aside`, blank line before and after, nothing else on the fence lines. + +```` +```aside +your secret canon here +``` +```` + +The player sees none of it, not a line. The engine records it, timestamped, and the next session can still read it. A secret you never write down is gone when the session ends and cannot be proven fair later. Write it down the moment you decide it. A secret is still an entity, so name everyone and everything in it with a wikilink. + ## Timekeeping The campaign clock is entirely yours. It moves only when you call `mark`. Time never passes on its own. diff --git a/src/markdown/block.rs b/src/markdown/block.rs index 63cc1ed..f6db05b 100644 --- a/src/markdown/block.rs +++ b/src/markdown/block.rs @@ -82,7 +82,7 @@ fn render(tag: Tag<'static>, events: &mut Events, width: usize, base: Style) -> Tag::Heading { level, .. } => heading(events, end, level, width, base), Tag::List(start) => list(events, end, start, width, base), Tag::BlockQuote(_) => quote(events, end, width, base), - Tag::CodeBlock(_) => code_block(events, end, width), + Tag::CodeBlock(kind) => code_block(events, end, width, kind), Tag::HtmlBlock => html_block(events, end, width, base), Tag::Table(alignments) => table::render(events, alignments, width, base), // A paragraph, and every other tag that gets this far, holds @@ -229,7 +229,29 @@ fn gutter_line(line: Line<'static>) -> Line<'static> { /// hands back, so there is nothing to strip. A source line longer than /// the width left over after the indent hard-wraps at that width, the /// way a too-long word does. -fn code_block(events: &mut Events, end: TagEnd, width: usize) -> Vec> { +/// +/// A fenced block tagged [`crate::markdown::ASIDE_TAG`] is an aside, +/// the DM's secret canon. It renders nothing to the player; its events +/// are consumed so it cannot leak into later blocks, and its rows simply +/// never appear. The transcript still records the raw narration, fence +/// and all, so the secret stays provable on disk. +fn code_block( + events: &mut Events, + end: TagEnd, + width: usize, + kind: pulldown_cmark::CodeBlockKind<'static>, +) -> Vec> { + // An aside is a rendering concern: the block is recorded but nothing + // of it reaches the player. Drain it to its own end tag so the rest + // of the document continues cleanly, but render nothing. + if is_aside(&kind) { + while let Some(event) = events.next() { + if event == Event::End(end) { + break; + } + } + return Vec::new(); + } let mut source = String::new(); while let Some(event) = events.next() { if event == Event::End(end) { @@ -252,6 +274,15 @@ fn code_block(events: &mut Events, end: TagEnd, width: usize) -> Vec bool { + matches!( + kind, + pulldown_cmark::CodeBlockKind::Fenced(tag) if tag.trim() == crate::markdown::ASIDE_TAG + ) +} + /// An HTML block: its source lines, verbatim, one row per line, /// hard-wrapped at `width` columns, in `base`: `Style::default` at the /// top level, or a block quote's `style::quote` inside one. An HTML block diff --git a/src/markdown/block_tests.rs b/src/markdown/block_tests.rs index 2fc35f7..a895d43 100644 --- a/src/markdown/block_tests.rs +++ b/src/markdown/block_tests.rs @@ -406,3 +406,42 @@ fn blocks_of_different_kinds_still_get_exactly_one_empty_row() { ] ); } + +#[test] +fn an_aside_block_renders_nothing() { + assert_eq!( + rows("```aside\nsecret canon\n```", 40), + Vec::>::new() + ); +} + +#[test] +fn an_aside_hides_its_content_but_keeps_the_prose_around_it() { + assert_eq!( + rows( + "The party steps in.\n\n```aside\nThe killer is [[the innkeeper]].\n```\n\nA bell tolls.", + 40 + ), + vec![ + Line::raw("The party steps in."), + Line::default(), + Line::raw("A bell tolls."), + ] + ); +} + +#[test] +fn an_aside_alone_between_two_lines_leaves_no_extra_blank_row() { + assert_eq!( + rows("first\n\n```aside\nsecret\n```\n\nsecond", 40), + vec![Line::raw("first"), Line::default(), Line::raw("second"),] + ); +} + +#[test] +fn an_ordinary_fenced_block_still_renders_as_code() { + assert_eq!( + rows("```\ncode\n```", 40), + vec![Line::from(Span::styled(" code", style::code()))] + ); +} diff --git a/src/markdown/mod.rs b/src/markdown/mod.rs index 0458a62..72e6f93 100644 --- a/src/markdown/mod.rs +++ b/src/markdown/mod.rs @@ -16,6 +16,14 @@ mod table; pub use stream::MarkdownStream; +/// The tag that marks a fenced code block as behind the screen. A block +/// whose opening fence is [`ASIDE_TAG`] is the DM's secret canon: it +/// records in the transcript at the current clock, but no player-facing +/// rendering shows it. Honest narration cannot produce the tag by +/// accident, and the parser hands it back separate from the source, so +/// recognising it never inspects the content. +pub const ASIDE_TAG: &str = "aside"; + use pulldown_cmark::{Event, Options, Parser, Tag}; use ratatui::style::Style; use ratatui::text::{Line, Text}; @@ -105,9 +113,16 @@ fn options() -> Options { } /// Stitches rendered blocks together with one empty row between each. +/// +/// A block that rendered nothing, an aside, adds no row and no spacing, +/// so the prose around it stays as close together as if it were not +/// there. fn join(blocks: Vec>>) -> Vec> { let mut rows = Vec::new(); for block in blocks { + if block.is_empty() { + continue; + } if !rows.is_empty() { rows.push(Line::default()); }