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}