Skip to main content

brink_runtime/story/
types.rs

1//! Small output/status types: [`StoryStatus`], [`Step`], [`OutputLine`],
2//! [`BlockId`], [`Element`], [`StepOutcome`], [`Choice`], [`Stats`].
3
4use alloc::collections::BTreeMap;
5use alloc::string::{String, ToString};
6use alloc::vec::Vec;
7
8/// The current execution status of a story.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum StoryStatus {
11    /// Ready to step.
12    Active,
13    /// Waiting for a choice selection via [`Story::choose`].
14    WaitingForChoice,
15    /// Hit a `done` opcode — can still resume after output is consumed.
16    Done,
17    /// Hit an `end` opcode — permanently finished.
18    Ended,
19}
20
21/// Opaque identifier grouping a run of adjacent content lines.
22///
23/// `docs/prose-dialect-spec.md` §3.7/§8d.2 (RULED): "block id is universal"
24/// — every run of same-element adjacent content lines carries one; hosts
25/// aggregate consecutive [`OutputLine`]s sharing a `BlockId` or ignore it
26/// entirely. Two `OutputLine`s carry the same id iff they belong to the same
27/// uninterrupted run — a terminal ([`Step::Choices`]/[`Step::Done`]/
28/// [`Step::End`]) or a host-directed jump always starts a new one.
29///
30/// Compile-time-baked block ids (per §3.6's attachment mechanism) are a
31/// superset of this. Issue #2108 (`docs/decision-log.md` 2026-08-03 "The
32/// element output model") delivers the first real instance: an
33/// `attach = StructName` convention handler's data is merged into the VM's
34/// output buffer (`brink_runtime::vm`'s `Opcode::AttachElement`/
35/// `Opcode::EndElementRun`) and every line materialized while it's live gets
36/// a copy in [`Element::data`] — but `BlockId` itself is **not** re-derived
37/// from that mechanism; it stays the plain terminator-counting value it
38/// always was (this field's own `next_block_id` doc, `brink_runtime::story::
39/// call_stack::Flow`). A run of adjacent lines sharing one attach group can
40/// therefore span more than one `BlockId` if it also crosses a real
41/// terminator — the two concepts have not been unified.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub struct BlockId(pub u64);
44
45/// A line's classification — kind + an open, preset-defined data map
46/// (`docs/prose-dialect-spec.md` §7/§3.5b, §3.6, sitting-5 ruling item 8:
47/// "the output format bakes no scene-specific fields — element data is an
48/// open map produced by conventions and handlers"). Deliberately a `String`
49/// kind, not a closed enum: the vocabulary belongs to whichever preset or
50/// `@[element]` handler classified the line, never to the runtime.
51///
52/// **`data` is real** (issue #2108, `docs/decision-log.md` 2026-08-03 "The
53/// element output model: attachment is block-level metadata, delivery is
54/// per-line"): an `attach = StructName` convention handler's claimed line
55/// consumes itself (no event — item 6, "AN EVENT EXISTS IFF A LINE EXISTS")
56/// and its returned struct's fields merge into `data` on every line in the
57/// run that follows (item 3: multiple attach handlers, e.g. `cue` then
58/// `parenthetical`, accumulate onto the same run). **`kind` stays the
59/// degenerate [`Self::NARRATIVE`] regardless** — classifying `kind` itself
60/// for a non-attach single-line handler (`heading`/`transition` reporting
61/// their own handler name as `kind`) is a distinct, still-open gap; see
62/// issue #2108's own follow-up notes. A line with no preceding attach
63/// convention still reports the always-correct [`Element::narrative`].
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Element {
66    /// The classifying handler's name, or [`Self::NARRATIVE`] for an
67    /// ordinary, unclassified line — always the latter today, see this
68    /// type's own doc.
69    pub kind: String,
70    /// Open, handler-defined payload. Empty when no `attach` convention
71    /// preceded this line.
72    pub data: BTreeMap<String, String>,
73}
74
75impl Element {
76    /// The degenerate kind every line reports today — `docs/prose-dialect-spec.md`
77    /// §7's own superset check: "schema-less ink → `element: narrative,
78    /// parts: [Text]`" (§1 puts it the same way: "the *degenerate case* —
79    /// an untyped narrative element with no spans").
80    pub const NARRATIVE: &'static str = "narrative";
81
82    /// The always-correct default: no handler classified this line.
83    #[must_use]
84    pub fn narrative() -> Self {
85        Self {
86            kind: Self::NARRATIVE.to_string(),
87            data: BTreeMap::new(),
88        }
89    }
90}
91
92/// One line of story content, carried inside [`Step::Line`].
93///
94/// `.text` stays a plain field rather than a `Vec<Part>` decomposition —
95/// that structured-markup surface (`docs/prose-dialect-spec.md` §7/§9.1's
96/// `Part::Span`) is still out of scope (issue #2108 populated
97/// [`Element::data`], the other half of the spec's element/markup layer, but
98/// deliberately not this one — see this issue's own tracked follow-up).
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct OutputLine {
101    /// The line's text content.
102    pub text: String,
103    /// Tags associated with this line.
104    pub tags: Vec<String>,
105    /// The run of adjacent content this line belongs to. See [`BlockId`].
106    pub block_id: BlockId,
107    /// This line's classification. See [`Element`]'s own doc for what's
108    /// populated today and what isn't.
109    pub element: Element,
110}
111
112/// A single step of story output from [`Story::continue_single`].
113///
114/// The enum tells the caller what to do next:
115/// - `Line` — more output may follow, keep calling `continue_single`.
116/// - `Done` — this turn's output is complete. Call `continue_single`
117///   again for the next turn (the story isn't over).
118/// - `Choices` — pick a choice via [`Story::choose`], then resume.
119/// - `End` — the story has permanently ended.
120///
121/// **Terminals carry no payload** (`docs/prose-dialect-spec.md` §7, RULED —
122/// this replaces the earlier `Line` enum, whose terminal variants fused
123/// trailing text onto the outcome). Any trailing content that precedes a
124/// terminal is always delivered first as its own `Step::Line` — a caller
125/// draining `continue_single` in a loop sees the same total text either
126/// way, just spread across one more step when a turn ends mid-line.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Step {
129    /// One line of story content. More may follow — keep calling
130    /// [`Story::continue_single`].
131    Line(OutputLine),
132    /// The story is presenting choices. Call [`Story::choose`] then
133    /// resume with [`Story::continue_single`].
134    Choices(Vec<Choice>),
135    /// This turn's output is complete (ink `-> DONE`). The story isn't
136    /// over — call [`Story::continue_single`] again for more.
137    Done,
138    /// The story has permanently ended (ink `-> END`).
139    End,
140    /// A flow parked at an `await` site (the `FlowFrame` model,
141    /// `docs/flow-suspension-spec.md` §10.1). Like `Done`, a park is a
142    /// **turn boundary**: text accumulated before the park flushes with
143    /// it (as its own preceding `Step::Line`), so the pre-`await` state is
144    /// never held hostage. The host wakes the flow via
145    /// [`Story::wake_check`] and drives it when it wants output — a park
146    /// never auto-continues.
147    ///
148    /// **Runtime-unreachable until FS-3r.** No code path in today's
149    /// runtime constructs this variant — the E052 lowering fence
150    /// (`docs/flow-suspension-spec.md` §11.4) keeps `await` from
151    /// producing bytecode, so `park`/`spill`/`resume` do not yet exist.
152    /// See `step_suspended_is_terminal_and_never_constructed_in_runtime`
153    /// for the "nothing constructs it" guard.
154    Suspended,
155}
156
157impl Step {
158    /// The text content of this step. Only [`Step::Line`] carries any —
159    /// every other (terminal) variant returns the empty string, since
160    /// terminals carry no payload.
161    #[must_use]
162    pub fn text(&self) -> &str {
163        match self {
164            Self::Line(line) => &line.text,
165            Self::Choices(_) | Self::Done | Self::End | Self::Suspended => "",
166        }
167    }
168
169    /// The tags associated with this step. Only [`Step::Line`] carries
170    /// any — every other (terminal) variant returns an empty slice.
171    #[must_use]
172    pub fn tags(&self) -> &[String] {
173        match self {
174            Self::Line(line) => &line.tags,
175            Self::Choices(_) | Self::Done | Self::End | Self::Suspended => &[],
176        }
177    }
178
179    /// Returns true if this is a terminal variant (`Choices`, `Done`,
180    /// `End`, or `Suspended`) — anything but `Line`. A park is a turn
181    /// boundary, so `Suspended` is terminal.
182    #[must_use]
183    pub fn is_terminal(&self) -> bool {
184        !matches!(self, Self::Line(_))
185    }
186}
187
188/// Outcome of a single [`FlowInstance::advance`] step.
189///
190/// Like [`Step`], but with an extra variant for when a binding handler
191/// deferred an external call ([`ExternalResult::Pending`]) — e.g. a
192/// world-access query hit during normal playback. The flow is paused with
193/// its state intact: inspect the pending call via
194/// [`pending_external_name`](FlowInstance::pending_external_name) /
195/// [`pending_external_args`](FlowInstance::pending_external_args), supply
196/// the result with [`resolve_external`](FlowInstance::resolve_external),
197/// then call [`advance`](FlowInstance::advance) again.
198///
199/// [`step_single_line`](FlowInstance::step_single_line) is the simpler API
200/// for consumers whose handler never pauses — it maps `AwaitingExternal`
201/// to an error.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum StepOutcome {
204    /// A step of output, or a yield point (`Done`/`Choices`/`End`).
205    Step(Step),
206    /// The flow paused on a deferred external; resolve it and `advance`.
207    AwaitingExternal,
208}
209
210/// A single choice presented to the player.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct Choice {
213    pub text: String,
214    pub index: usize,
215    pub tags: Vec<String>,
216}
217
218// ── Stats ───────────────────────────────────────────────────────────────────
219
220/// Lightweight counters tracking VM activity over a story's lifetime.
221///
222/// Always-on — incrementing a `u64` is effectively free compared to opcode
223/// dispatch. Use [`Story::stats`] to read after a run.
224#[derive(Debug, Clone, Default)]
225pub struct Stats {
226    /// Total opcodes dispatched.
227    pub opcodes: u64,
228    /// Total `vm::step` calls from the outer loop.
229    pub steps: u64,
230    /// Threads forked (via `ThreadCall` and choice creation).
231    pub threads_created: u64,
232    /// Threads that completed and were popped.
233    pub threads_completed: u64,
234    /// Call frames pushed onto thread stacks.
235    pub frames_pushed: u64,
236    /// Call frames popped from thread stacks.
237    pub frames_popped: u64,
238    /// Choice sets presented to the player.
239    pub choices_presented: u64,
240    /// Individual choices selected.
241    pub choices_selected: u64,
242    /// `CallStack::snapshot` cache hits (reused existing `Arc`).
243    pub snapshot_cache_hits: u64,
244    /// `CallStack::snapshot` cache misses (new allocation).
245    pub snapshot_cache_misses: u64,
246    /// `CallStack::materialize` calls (flattened inherited prefix).
247    pub materializations: u64,
248}