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    /// Where this line came from in the author's source (W7/#3300
108    /// transcript provenance): the first contributing line-table entry's
109    /// `source_location` — file plus UTF-8 byte range as the compiler
110    /// consumed it. `None` when no entry contributed one (pure
111    /// interpolation, or a program compiled without locations).
112    pub source: Option<brink_format::SourceLocation>,
113    /// This line's classification. See [`Element`]'s own doc for what's
114    /// populated today and what isn't.
115    pub element: Element,
116}
117
118/// A single step of story output from [`Story::continue_single`].
119///
120/// The enum tells the caller what to do next:
121/// - `Line` — more output may follow, keep calling `continue_single`.
122/// - `Done` — this turn's output is complete. Call `continue_single`
123///   again for the next turn (the story isn't over).
124/// - `Choices` — pick a choice via [`Story::choose`], then resume.
125/// - `End` — the story has permanently ended.
126///
127/// **Terminals carry no payload** (`docs/prose-dialect-spec.md` §7, RULED —
128/// this replaces the earlier `Line` enum, whose terminal variants fused
129/// trailing text onto the outcome). Any trailing content that precedes a
130/// terminal is always delivered first as its own `Step::Line` — a caller
131/// draining `continue_single` in a loop sees the same total text either
132/// way, just spread across one more step when a turn ends mid-line.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum Step {
135    /// One line of story content. More may follow — keep calling
136    /// [`Story::continue_single`].
137    Line(OutputLine),
138    /// The story is presenting choices. Call [`Story::choose`] then
139    /// resume with [`Story::continue_single`].
140    Choices(Vec<Choice>),
141    /// This turn's output is complete (ink `-> DONE`). The story isn't
142    /// over — call [`Story::continue_single`] again for more.
143    Done,
144    /// The story has permanently ended (ink `-> END`).
145    End,
146    /// A flow parked at an `await` site (the `FlowFrame` model,
147    /// `docs/flow-suspension-spec.md` §10.1). Like `Done`, a park is a
148    /// **turn boundary**: text accumulated before the park flushes with
149    /// it (as its own preceding `Step::Line`), so the pre-`await` state is
150    /// never held hostage. The host wakes the flow via
151    /// [`Story::wake_check`] and drives it when it wants output — a park
152    /// never auto-continues.
153    ///
154    /// **Runtime-unreachable until FS-3r.** No code path in today's
155    /// runtime constructs this variant — the E052 lowering fence
156    /// (`docs/flow-suspension-spec.md` §11.4) keeps `await` from
157    /// producing bytecode, so `park`/`spill`/`resume` do not yet exist.
158    /// See `step_suspended_is_terminal_and_never_constructed_in_runtime`
159    /// for the "nothing constructs it" guard.
160    Suspended,
161}
162
163impl Step {
164    /// The text content of this step. Only [`Step::Line`] carries any —
165    /// every other (terminal) variant returns the empty string, since
166    /// terminals carry no payload.
167    #[must_use]
168    pub fn text(&self) -> &str {
169        match self {
170            Self::Line(line) => &line.text,
171            Self::Choices(_) | Self::Done | Self::End | Self::Suspended => "",
172        }
173    }
174
175    /// The tags associated with this step. Only [`Step::Line`] carries
176    /// any — every other (terminal) variant returns an empty slice.
177    #[must_use]
178    pub fn tags(&self) -> &[String] {
179        match self {
180            Self::Line(line) => &line.tags,
181            Self::Choices(_) | Self::Done | Self::End | Self::Suspended => &[],
182        }
183    }
184
185    /// Returns true if this is a terminal variant (`Choices`, `Done`,
186    /// `End`, or `Suspended`) — anything but `Line`. A park is a turn
187    /// boundary, so `Suspended` is terminal.
188    #[must_use]
189    pub fn is_terminal(&self) -> bool {
190        !matches!(self, Self::Line(_))
191    }
192}
193
194/// Outcome of a single [`FlowInstance::advance`] step.
195///
196/// Like [`Step`], but with an extra variant for when a binding handler
197/// deferred an external call ([`ExternalResult::Pending`]) — e.g. a
198/// world-access query hit during normal playback. The flow is paused with
199/// its state intact: inspect the pending call via
200/// [`pending_external_name`](FlowInstance::pending_external_name) /
201/// [`pending_external_args`](FlowInstance::pending_external_args), supply
202/// the result with [`resolve_external`](FlowInstance::resolve_external),
203/// then call [`advance`](FlowInstance::advance) again.
204///
205/// [`step_single_line`](FlowInstance::step_single_line) is the simpler API
206/// for consumers whose handler never pauses — it maps `AwaitingExternal`
207/// to an error.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub enum StepOutcome {
210    /// A step of output, or a yield point (`Done`/`Choices`/`End`).
211    Step(Step),
212    /// The flow paused on a deferred external; resolve it and `advance`.
213    AwaitingExternal,
214}
215
216/// A single choice presented to the player.
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct Choice {
219    pub text: String,
220    /// Position among the choices presented — the value [`Story::choose`]
221    /// takes. Numbers the visible choices contiguously (an invisible
222    /// fallback never occupies a slot), matching C#'s `Choice.index` /
223    /// `ChooseChoiceIndex` (issue #3527).
224    pub index: usize,
225    pub tags: Vec<String>,
226    /// `+` (sticky, offered again) vs `*` (once-only) in the source — the
227    /// bytecode's `ChoiceFlags::once_only`, inverted. A host that echoes the
228    /// taken choice can mark it the way it was written (#3435).
229    pub sticky: bool,
230    /// Where the choice's text came from in the author's source (#3435) —
231    /// the first `LineRef` of the choice's display fragment, the same rule
232    /// [`OutputLine::source`] uses. `None` when the display is not a
233    /// fragment (a constant string, an empty label) or the line table
234    /// carries no location.
235    pub source: Option<brink_format::SourceLocation>,
236}
237
238// ── Stats ───────────────────────────────────────────────────────────────────
239
240/// Lightweight counters tracking VM activity over a story's lifetime.
241///
242/// Always-on — incrementing a `u64` is effectively free compared to opcode
243/// dispatch. Use [`Story::stats`] to read after a run.
244#[derive(Debug, Clone, Default)]
245pub struct Stats {
246    /// Total opcodes dispatched.
247    pub opcodes: u64,
248    /// Total `vm::step` calls from the outer loop.
249    pub steps: u64,
250    /// Threads forked (via `ThreadCall` and choice creation).
251    pub threads_created: u64,
252    /// Threads that completed and were popped.
253    pub threads_completed: u64,
254    /// Call frames pushed onto thread stacks.
255    pub frames_pushed: u64,
256    /// Call frames popped from thread stacks.
257    pub frames_popped: u64,
258    /// Choice sets presented to the player.
259    pub choices_presented: u64,
260    /// Individual choices selected.
261    pub choices_selected: u64,
262    /// `CallStack::snapshot` cache hits (reused existing `Arc`).
263    pub snapshot_cache_hits: u64,
264    /// `CallStack::snapshot` cache misses (new allocation).
265    pub snapshot_cache_misses: u64,
266    /// `CallStack::materialize` calls (flattened inherited prefix).
267    pub materializations: u64,
268    /// Executed-instruction histogram by discriminant byte (256 entries,
269    /// sized on first use). Bench instrumentation only: the optimizer's
270    /// peephole work needs to know which instructions — and which pairs —
271    /// actually run, not which exist in the artifact.
272    #[cfg(feature = "bench-counters")]
273    pub opcode_hist: Vec<u64>,
274    /// Executed instruction-pair histogram, indexed `prev << 8 | next`
275    /// (65 536 entries, sized on first use).
276    #[cfg(feature = "bench-counters")]
277    pub bigram_hist: Vec<u64>,
278    /// The previously executed discriminant, for `bigram_hist`.
279    #[cfg(feature = "bench-counters")]
280    pub last_disc: Option<u8>,
281}