Skip to main content

bevy_brink/
globals.rs

1//! Story-wide `World` (shared) and per-flow `FlowLocal` (private) state.
2//!
3//! (F6.2 — see `docs/scoped-flow-state-spec.md`'s F6 AMENDMENT.) Every flow
4//! spawned under a marker `M` advances against the **same** shared
5//! [`World`], carried on [`BrinkGlobals<M>`] — a single `Resource`, not a
6//! per-flow clone. [`BrinkContext<M>`] holds a flow's own private
7//! [`FlowLocal`] override layer, fresh (empty) at spawn.
8//!
9//! Story-state is routed World-vs-Local per unit (globals, visit/turn
10//! counts, turn index, RNG) by the [`ResolvedPolicy`](brink_runtime::ResolvedPolicy)
11//! `BrinkGlobals`'s `World` was created with (see
12//! [`BrinkPlugin::with_policy`](crate::BrinkPlugin::with_policy) /
13//! [`BrinkWorldPolicy`]). The **default policy homes every unit to World** —
14//! byte-identical to plain ink, zero-surprise for the common single-flow
15//! case: reads and writes to a World-scoped unit are immediately visible to
16//! every flow sharing that `World`, with no "commit" step, because they were
17//! never forked in the first place.
18//!
19//! A flow's `FlowLocal` is that flow's own durable memory for the
20//! **Local**-scoped units a host opts into via policy overrides (see
21//! `docs/scoped-flow-state-spec.md`'s "The policy"): it persists for the
22//! flow's lifetime, is never auto-merged anywhere, and there is no
23//! `commit_from`/`commit_progress`/`commit_globals_only`-style verb — those
24//! compensated for the old full-`World`-clone-per-flow model, which this
25//! scoping removes outright. When private state needs to become shared
26//! (an NPC's private mood counter raising a global "hostile" flag), that
27//! promotion is written **in ink**, where it's visible, not bolted on as a
28//! Bevy-side merge helper.
29//!
30//! Build the per-step routing view with [`flow_context_view`].
31//!
32//! ## Save/load (F6.3)
33//!
34//! A save is **one [`SaveState`] for the shared `World`, plus one per entity
35//! flow**, composed **host-side** — this module exposes thin per-context
36//! helpers, not a save-file format or a pre-composed bundle type (see the
37//! F6 AMENDMENT, ruling 4, in `docs/scoped-flow-state-spec.md`): a host
38//! collects the map of `SaveState`s (e.g. `entity -> SaveState` plus one
39//! world `SaveState`) into whatever container/on-disk format it wants.
40//!
41//! - [`BrinkGlobals::save_state`] / [`BrinkGlobals::load_state`] — the
42//!   shared `World`, direct (`World` implements `ContextAccess` itself, no
43//!   routing view needed).
44//! - [`save_flow_state`] / [`load_flow_state`] — one flow, routed through
45//!   its [`ContextView`] (built the same way [`flow_context_view`] builds
46//!   one for a step): saving captures **effective** values (a `Local`
47//!   override where the flow has one, else the live `World` value on a
48//!   read-through miss); loading routes by scope, so `Local`-scoped entries
49//!   land in the flow's own `FlowLocal` and `World`-scoped entries write
50//!   straight through to the shared `World`.
51//!
52//! **Load order matters for the `World`-scoped entries to converge
53//! correctly:** load the world `SaveState` via [`BrinkGlobals::load_state`]
54//! first, then each entity's `SaveState` via [`load_flow_state`] second.
55//! Because every entity snapshot taken at the same save moment carries
56//! *identical* `World`-scoped values, each entity's load rewrites those
57//! same values back into the shared `World` — idempotent, not a conflict —
58//! while routing that entity's own `Local`-scoped entries into its private
59//! `FlowLocal`. Loading entities in any order (or omitting the world load
60//! entirely, relying on entity loads alone) still converges to the same
61//! `World` state, since every entity carries the same World-scoped values;
62//! the explicit world-first load is the documented, simplest-to-reason-about
63//! order.
64//!
65//! **State-only, not position.** Save/load captures *game state* — globals,
66//! visit/turn counts, turn index, RNG (see [`brink_runtime::save_state`]'s
67//! docs for the precise contents) — never a flow's execution position
68//! (call stack / program counter). A loaded entity does not resume
69//! mid-line: the host re-enters it at a knot of its choosing (typically via
70//! [`FlowStart::Address`](crate::FlowStart::Address) on a fresh
71//! [`BrinkFlowRequest`](crate::BrinkFlowRequest)), and the restored state
72//! (a private "have I greeted them" visit count, a private mood variable)
73//! is what makes that re-entry pick up where the entity left off, not a
74//! restored call stack.
75//!
76//! **[`LoadReport`] tolerance.** Both load paths return a [`LoadReport`]
77//! rather than an error: a saved global the current program no longer
78//! declares is dropped and named in
79//! [`LoadReport::unknown_globals`](brink_runtime::LoadReport::unknown_globals)
80//! so the host can surface it (e.g. after a story patch that renamed or
81//! removed a `VAR`); saved visit/turn counts for a **named** scope the
82//! program no longer has are retained harmlessly rather than reported. A
83//! saved **anonymous** scope's entry (no author label — a gather, choice
84//! point, or sequence) that no longer resolves is different: it can never
85//! be recovered the way a named miss sometimes can, so it is counted in
86//! [`LoadReport::anonymous_states_dropped`](brink_runtime::LoadReport::anonymous_states_dropped)
87//! instead (issue #1674) — the bounded fallout is a once-only choice
88//! reappearing or a sequence restarting. See [`brink_runtime::load_state`]'s
89//! docs for the full reconciliation semantics, including the one
90//! behavioral note worth restating here: a stale saved entry (a
91//! global/scope the *current* program lacks) is not re-emitted by a later
92//! save — [`brink_runtime::save_state`] enumerates the current program's
93//! own globals/containers, not whatever the live context happens to hold —
94//! so ghost entries from an old program version don't round-trip through
95//! save after save indefinitely.
96
97use std::marker::PhantomData;
98
99use bevy_ecs::component::Component;
100use bevy_ecs::resource::Resource;
101use brink_runtime::{
102    ContextAccess, ContextView, ExecMode, FlowLocal, LoadReport, Program, SaveState, World,
103    WorldPolicy,
104};
105
106/// The single shared [`World`] for a story identified by marker `M`.
107///
108/// Holds globals, visit/turn counts, RNG seed, and the
109/// [`ResolvedPolicy`](brink_runtime::ResolvedPolicy) that routes every unit
110/// World-vs-Local (resolved once, at creation, from the host's
111/// [`BrinkWorldPolicy<M>`]). The plugin auto-inserts this on first
112/// fulfillment (see [`fulfill_flow_requests`](crate::fulfill_flow_requests))
113/// and never replaces it afterward — every flow spawned under `M` advances
114/// against this same `World` for the app's lifetime.
115#[derive(Resource)]
116pub struct BrinkGlobals<M: Send + Sync + 'static = ()> {
117    pub inner: World,
118    _marker: PhantomData<fn() -> M>,
119}
120
121impl<M: Send + Sync + 'static> BrinkGlobals<M> {
122    /// Wrap an already-created [`World`] (e.g. from
123    /// [`brink_runtime::World::new`], resolved against a program + policy)
124    /// in a Bevy `Resource`.
125    #[must_use]
126    pub fn new(world: World) -> Self {
127        Self {
128            inner: world,
129            _marker: PhantomData,
130        }
131    }
132
133    /// Capture the shared `World`'s durable game state as a [`SaveState`] —
134    /// see the module docs' "Save/load" section. Thin wrapper over
135    /// [`brink_runtime::save_state`] with `World` itself as the
136    /// [`ContextAccess`](brink_runtime::ContextAccess) implementor, so this
137    /// reads raw `World` storage: any unit a policy homes to `Local` was
138    /// never written here (`Local` writes land only in a flow's own
139    /// `FlowLocal`), so this captures exactly the `World`-scoped half of the
140    /// story's state. Does **not** capture any entity's private state —
141    /// pair with [`save_flow_state`] per entity to save a complete world.
142    #[must_use]
143    pub fn save_state(&self, program: &Program) -> SaveState {
144        brink_runtime::save_state(program, &self.inner)
145    }
146
147    /// Reconcile a [`SaveState`] into the shared `World` directly (no
148    /// routing view — every unit lands in `World` storage regardless of
149    /// what the policy says, mirroring [`save_state`](Self::save_state)'s
150    /// symmetric read). Load the world state **before** any entity's, via
151    /// [`load_flow_state`] — see the module docs' "Save/load" section for
152    /// why load order matters (it doesn't change the converged result, but
153    /// world-first is the simplest order to reason about).
154    pub fn load_state(&mut self, program: &Program, save: &SaveState) -> LoadReport {
155        brink_runtime::load_state(program, &mut self.inner, save)
156    }
157
158    /// Read an ink global by name — the ergonomic host-side read seam (G2,
159    /// issue #1059). Collapses the manual `Program::global_index(name)` +
160    /// `ContextAccess::global(idx)` reach (which also requires importing the
161    /// `ContextAccess` trait) into one call.
162    ///
163    /// **Panic-free miss behavior:** returns `None`, never panics, when
164    /// `program` declares no global named `name` — a typo'd or renamed
165    /// global reads as "absent" rather than crashing the host. Callers that
166    /// need to distinguish "no such global" from a real `Value::Null` should
167    /// check `program.global_index(name)` directly.
168    ///
169    /// This always reads the shared `World`-scoped value (`self.inner`),
170    /// matching `save_state`'s scope — it does not route through a flow's
171    /// `Local` override layer. For a flow-scoped read use
172    /// [`flow_context_view`] and read through the resulting [`ContextView`]
173    /// instead.
174    #[must_use]
175    pub fn get(&self, program: &Program, name: &str) -> Option<&brink_format::Value> {
176        let idx = program.global_index(name)?;
177        Some(self.inner.global(idx))
178    }
179}
180
181/// Capture one flow's *effective* durable game state as a [`SaveState`] —
182/// see the module docs' "Save/load" section.
183///
184/// Builds a [`ContextView`] over `globals` and `ctx` exactly like
185/// [`flow_context_view`] does for a step, then delegates to
186/// [`brink_runtime::save_state`] through it: a `World`-scoped unit reads
187/// `globals`' live shared value; a `Local`-scoped unit reads `ctx`'s own
188/// override where present, else falls through to `globals`' value. So two
189/// entities saved at the same moment carry byte-identical values for every
190/// `World`-scoped unit (the idempotent-rewrite property [`load_flow_state`]
191/// relies on) while each carries its own distinct `Local`-scoped values.
192#[must_use]
193pub fn save_flow_state<M: Send + Sync + 'static>(
194    globals: &mut BrinkGlobals<M>,
195    ctx: &mut BrinkContext<M>,
196    program: &Program,
197) -> SaveState {
198    let view = flow_context_view(globals, ctx);
199    brink_runtime::save_state(program, &view)
200}
201
202/// Reconcile a [`SaveState`] into one flow, routed by scope — see the
203/// module docs' "Save/load" section.
204///
205/// Builds a [`ContextView`] over `globals` and `ctx` exactly like
206/// [`flow_context_view`] does for a step, then delegates to
207/// [`brink_runtime::load_state`] through it: a `Local`-scoped entry writes
208/// into `ctx`'s own [`FlowLocal`] overrides (this flow's private memory,
209/// invisible to every other flow); a `World`-scoped entry writes straight
210/// through to `globals`' shared `World`, immediately visible to every flow
211/// sharing it. Loading several entities' saves in sequence (all taken at
212/// the same save moment, so all carrying the same `World`-scoped values) is
213/// therefore an idempotent rewrite of the shared `World`, not a conflict.
214pub fn load_flow_state<M: Send + Sync + 'static>(
215    globals: &mut BrinkGlobals<M>,
216    ctx: &mut BrinkContext<M>,
217    program: &Program,
218    save: &SaveState,
219) -> LoadReport {
220    let mut view = flow_context_view(globals, ctx);
221    brink_runtime::load_state(program, &mut view, save)
222}
223
224/// Host-supplied [`WorldPolicy`] for marker `M`'s shared [`BrinkGlobals`]
225/// `World`, installed once at plugin setup via
226/// [`BrinkPlugin::with_policy`](crate::BrinkPlugin::with_policy) and read by
227/// [`fulfill_flow_requests`](crate::fulfill_flow_requests) when it creates
228/// `BrinkGlobals<M>` on first fulfillment.
229///
230/// **Base ⊕ host-overrides, from day one:** base is an empty `WorldPolicy`
231/// today (`WorldPolicy::default()` — every unit `World`-scoped); a
232/// compiler-emitted base (a flow-private storage class for `VAR`s + knot
233/// marking) is future work (#473) — the *only* place `brink-format` would
234/// change for it. Until then this resource's `policy` field **is** the
235/// whole installed policy.
236#[derive(Resource, Clone, Default)]
237pub struct BrinkWorldPolicy<M: Send + Sync + 'static = ()> {
238    pub policy: WorldPolicy,
239    _marker: PhantomData<fn() -> M>,
240}
241
242impl<M: Send + Sync + 'static> BrinkWorldPolicy<M> {
243    #[must_use]
244    pub(crate) fn new(policy: WorldPolicy) -> Self {
245        Self {
246            policy,
247            _marker: PhantomData,
248        }
249    }
250}
251
252/// The host-selected [`ExecMode`] every flow of marker `M` starts in
253/// (F35, ruled 2026-07-19).
254///
255/// Inserted once by [`BrinkPlugin::build`](crate::BrinkPlugin) and applied
256/// to each [`FlowInstance`](brink_runtime::FlowInstance) at spawn by
257/// `fulfill_flow_requests`. Unlike core `brink-runtime` — whose
258/// [`ExecMode::default`] is always [`Dev`](ExecMode::Dev) — bevy-brink's
259/// default keys off the build profile: `Dev` under `debug_assertions`
260/// (editor / `cargo run`), `Prod` in a release build (`cargo build
261/// --release`), so a shipped game defaults to the keep-moving posture and
262/// an in-editor session to the fault-loud one. A host overrides either way
263/// with [`BrinkPlugin::with_exec_mode`](crate::BrinkPlugin::with_exec_mode).
264#[derive(Resource, Clone, Copy)]
265pub struct BrinkExecMode<M: Send + Sync + 'static = ()> {
266    pub mode: ExecMode,
267    _marker: PhantomData<fn() -> M>,
268}
269
270impl<M: Send + Sync + 'static> BrinkExecMode<M> {
271    #[must_use]
272    pub(crate) fn new(mode: ExecMode) -> Self {
273        Self {
274            mode,
275            _marker: PhantomData,
276        }
277    }
278}
279
280impl<M: Send + Sync + 'static> Default for BrinkExecMode<M> {
281    /// The profile-keyed default (F35): `Dev` under `debug_assertions`,
282    /// `Prod` otherwise. This is the one place bevy-brink diverges from the
283    /// core runtime's always-`Dev` [`ExecMode::default`].
284    fn default() -> Self {
285        Self::new(if cfg!(debug_assertions) {
286            ExecMode::Dev
287        } else {
288            ExecMode::Prod
289        })
290    }
291}
292
293/// A single flow's private override layer over the shared
294/// [`BrinkGlobals<M>`] `World`.
295///
296/// Inserted by `fulfill_flow_requests` alongside [`BrinkFlow`](crate::BrinkFlow),
297/// always fresh (empty) — spawning a flow takes no policy or seed parameter;
298/// see the F6 AMENDMENT ruling 1 in `docs/scoped-flow-state-spec.md`. Reads
299/// of a `Local`-scoped unit fall through to `World`'s value until this
300/// flow's first local write; writes to a `World`-scoped unit always land in
301/// the shared `World`, immediately visible to every other flow sharing it.
302#[derive(Component, Default)]
303pub struct BrinkContext<M: Send + Sync + 'static = ()> {
304    pub inner: FlowLocal,
305    _marker: PhantomData<fn() -> M>,
306}
307
308impl<M: Send + Sync + 'static> BrinkContext<M> {
309    #[must_use]
310    pub fn new(local: FlowLocal) -> Self {
311        Self {
312            inner: local,
313            _marker: PhantomData,
314        }
315    }
316}
317
318/// Build the [`ContextView`] routing view for one flow's step: `World`-scoped
319/// units go straight to the shared `globals`; `Local`-scoped units read
320/// through / write to `ctx`'s own override layer (see
321/// `docs/scoped-flow-state-spec.md`).
322///
323/// Construct fresh for each step/call — it's a transient, step-scoped borrow
324/// of both `&mut World` and `&mut FlowLocal`, never stored.
325pub fn flow_context_view<'a, M: Send + Sync + 'static>(
326    globals: &'a mut BrinkGlobals<M>,
327    ctx: &'a mut BrinkContext<M>,
328) -> ContextView<'a> {
329    ContextView::new(&mut globals.inner, &mut ctx.inner)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::test_support::compile_test_story;
336
337    #[test]
338    fn get_reads_a_declared_global_by_name() {
339        let (program, _tables, world) = compile_test_story("VAR mood = 7\n-> END\n");
340        let globals = BrinkGlobals::<()>::new(world);
341        assert_eq!(
342            globals.get(&program, "mood"),
343            Some(&brink_format::Value::Int(7))
344        );
345    }
346
347    #[test]
348    fn get_sees_writes_made_through_context_access() {
349        let (program, _tables, world) = compile_test_story("VAR mood = 7\n-> END\n");
350        let mut globals = BrinkGlobals::<()>::new(world);
351        let idx = program.global_index("mood").expect("mood is declared");
352        globals.inner.set_global(idx, brink_format::Value::Int(41));
353        assert_eq!(
354            globals.get(&program, "mood"),
355            Some(&brink_format::Value::Int(41))
356        );
357    }
358
359    #[test]
360    fn get_returns_none_for_an_unknown_name_without_panicking() {
361        let (program, _tables, world) = compile_test_story("VAR mood = 7\n-> END\n");
362        let globals = BrinkGlobals::<()>::new(world);
363        assert_eq!(globals.get(&program, "does_not_exist"), None);
364    }
365}