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}