Skip to main content

bevy_brink/
request.rs

1//! Request-component pattern for spawning flows.
2//!
3//! Consumers spawn an entity carrying a [`BrinkFlowRequest<M>`] and a
4//! handle to a [`BrinkStoryAsset`](crate::BrinkStoryAsset). A
5//! plugin-managed system ([`fulfill_flow_requests`]) waits for the
6//! story's sub-assets to load, builds a `FlowInstance`, replaces the
7//! request component with [`BrinkFlow<M>`](crate::BrinkFlow), the
8//! [`BrinkStory<M>`](crate::BrinkStory) bundle (program + locale
9//! handles), and a fresh per-flow [`BrinkContext<M>`](crate::BrinkContext).
10//!
11//! No polling, no readiness latches: the user just spawns the request
12//! and lets the plugin fulfill it whenever assets become available.
13
14use std::marker::PhantomData;
15
16use bevy_asset::{Assets, Handle};
17use bevy_ecs::component::Component;
18use bevy_ecs::entity::Entity;
19use bevy_ecs::query::Without;
20use bevy_ecs::system::{Commands, Query, Res, ResMut};
21use bevy_log::error;
22// Only the `#[cfg(debug_assertions)]` variant of `warn_post_fulfillment_mutations`
23// below calls `warn!`; in non-debug builds (e.g. the `bench` profile used by
24// `benches/scenario_bench.rs` under `--features bench-counters`) that variant
25// isn't compiled, so an unconditional import would be unused there.
26#[cfg(debug_assertions)]
27use bevy_log::warn;
28use brink_runtime::{FlowInstance, FlowLocal, World};
29
30use crate::asset::{BrinkStory, BrinkStoryAsset, ProgramAsset};
31use crate::capability::{CapabilityManifest, CapabilityRegistry, check_load_capability_gate};
32use crate::flow::BrinkFlow;
33use crate::globals::{BrinkContext, BrinkExecMode, BrinkGlobals, BrinkWorldPolicy};
34
35/// Where a freshly-spawned flow should begin executing.
36#[derive(Default, Clone, Debug)]
37pub enum FlowStart {
38    /// File root — the program's first container. Suitable for trivial
39    /// demos and tests; most games spawn at named knots instead.
40    #[default]
41    Root,
42    /// Resolve a knot/stitch name to a starting position. Errors at
43    /// fulfillment if the name is unknown.
44    Address(String),
45}
46
47/// Marker component requesting that this entity become a flow once its
48/// story assets are available.
49///
50/// Spawn it with [`BrinkFlowRequest::builder`] (a `bon`-generated
51/// builder) and let the fulfillment system handle the rest:
52///
53/// ```no_run
54/// # use bevy_asset::AssetServer;
55/// # use bevy_ecs::system::{Commands, Res};
56/// # use bevy_brink::{BrinkFlowRequest, FlowStart};
57/// # fn example(mut commands: Commands, asset_server: Res<AssetServer>) {
58/// commands.spawn(
59///     BrinkFlowRequest::<()>::builder()
60///         .story(asset_server.load("dialogue.ink"))
61///         .start(FlowStart::Address("intro_scene".into()))
62///         .build(),
63/// );
64/// # }
65/// ```
66///
67/// The fulfillment system removes this component and inserts
68/// [`BrinkFlow<M>`](crate::BrinkFlow), the [`BrinkStory<M>`](crate::BrinkStory)
69/// bundle, and a fresh per-flow [`BrinkContext<M>`](crate::BrinkContext)
70/// once the program and line-tables subassets are loaded. Spawning a flow
71/// takes no seed/policy parameter — its `FlowLocal` starts empty and its
72/// story-state routes World-vs-Local per the policy installed once at
73/// [`BrinkPlugin::with_policy`](crate::BrinkPlugin::with_policy) (see the F6
74/// AMENDMENT in `docs/scoped-flow-state-spec.md`). Mutating the request
75/// after fulfillment is a no-op (in debug builds, a warning is emitted via
76/// [`warn_post_fulfillment_mutations`]).
77#[derive(Component, bon::Builder)]
78pub struct BrinkFlowRequest<M: Send + Sync + 'static = ()> {
79    /// The story to spawn this flow against.
80    pub story: Handle<BrinkStoryAsset>,
81    /// Where to start. Defaults to `FlowStart::Root`.
82    #[builder(default)]
83    pub start: FlowStart,
84    #[builder(skip)]
85    _marker: PhantomData<fn() -> M>,
86}
87
88/// Plugin-managed system: walk pending [`BrinkFlowRequest<M>`] entities,
89/// fulfill each whose assets are ready, and bootstrap the entity's
90/// per-flow components.
91///
92/// Behavior:
93///
94/// - Skips requests whose `BrinkStoryAsset` (or any of its sub-assets)
95///   isn't loaded yet — the request just waits.
96/// - Once the program asset is available, runs the load-boundary
97///   admission check (issue #912, RULED option (b)): if this marker's
98///   [`CapabilityRegistry<M>`] is missing any manifest-required
99///   capability the story's externals declare, the load is refused
100///   outright — logged as a [`crate::capability::CapabilityError::LoadRejected`] naming the
101///   marker, the story, and every missing capability, and the request is
102///   removed without ever creating a flow. This is the hard, immediate,
103///   per-marker version of the tier-1 admission rule
104///   [`compute_container_access`](crate::capability::compute_container_access)
105///   already applies at call time — the load itself now fails loudly
106///   instead of joining to a silently-incomplete access table.
107/// - On first fulfillment for marker `M`, creates the single shared
108///   [`BrinkGlobals<M>`] `World` via [`World::new`], resolving the policy
109///   installed at [`BrinkPlugin::with_policy`](crate::BrinkPlugin::with_policy)
110///   against this program's symbol table. If the policy names an unknown
111///   variable or knot/stitch ([`PolicyError`](brink_runtime::PolicyError)),
112///   this is logged as a clear setup error (not a panic) and the request is
113///   removed — every later request for this marker will hit the same error
114///   until the host fixes its policy.
115/// - Inserts a fresh, empty per-flow [`BrinkContext<M>`] component — no
116///   seeding, no policy parameter (see the F6 AMENDMENT in
117///   `docs/scoped-flow-state-spec.md`).
118/// - Inserts the [`BrinkStory<M>`] bundle (program + locale handles).
119/// - Errors and removes the request if `FlowStart::Address` references
120///   a name that isn't in the program.
121#[expect(
122    clippy::needless_pass_by_value,
123    reason = "bevy systems take Res/Query by value"
124)]
125#[expect(
126    clippy::too_many_arguments,
127    reason = "bevy system: flow + globals + locale assets/resources for spawn-time locale reconcile"
128)]
129pub fn fulfill_flow_requests<M: Send + Sync + 'static>(
130    requests: Query<(Entity, &BrinkFlowRequest<M>), Without<BrinkFlow<M>>>,
131    stories: Res<Assets<BrinkStoryAsset>>,
132    programs: Res<Assets<ProgramAsset>>,
133    capability_manifest: Res<CapabilityManifest>,
134    capability_registry: Res<CapabilityRegistry<M>>,
135    globals: Option<Res<BrinkGlobals<M>>>,
136    policy: Res<BrinkWorldPolicy<M>>,
137    exec_mode: Res<BrinkExecMode<M>>,
138    current_locale: Option<Res<crate::locale::BrinkCurrentLocale<M>>>,
139    locales: Res<Assets<crate::locale::LocaleAsset>>,
140    mut line_tables: ResMut<Assets<crate::asset::LineTablesAsset>>,
141    mut cache: ResMut<crate::locale::LocalizedTablesCache<M>>,
142    mut commands: Commands,
143) {
144    // Whether BrinkGlobals<M> exists yet. Tracked separately from `globals`
145    // (an `Option<Res<_>>` snapshot from system start) so that once this
146    // batch creates it on the first request, later requests in the same
147    // batch don't try to create it again.
148    let mut globals_ready = globals.is_some();
149
150    for (entity, req) in &requests {
151        let Some(bundle) = stories.get(&req.story) else {
152            continue;
153        };
154        let Some(program_asset) = programs.get(&bundle.program) else {
155            continue;
156        };
157
158        // Issue #912's load-boundary admission check: the manifest is
159        // app-global but the registry is per-marker, so this story's
160        // externals must resolve every manifest-required capability
161        // against *this marker's* CapabilityRegistry<M> before the load
162        // may proceed — a hard, immediate error naming the marker, the
163        // story, and every missing capability, not a silent
164        // per-container UnknownCapability err-table discovered later.
165        let story = req
166            .story
167            .path()
168            .map_or_else(|| format!("{:?}", req.story.id()), ToString::to_string);
169        if let Err(err) = check_load_capability_gate(
170            &program_asset.program,
171            &program_asset.effect_rows,
172            &capability_manifest,
173            &capability_registry,
174            story,
175        ) {
176            error!("BrinkFlowRequest: {err}; removing request");
177            commands.entity(entity).remove::<BrinkFlowRequest<M>>();
178            continue;
179        }
180
181        if !globals_ready {
182            match World::new(&program_asset.program, &policy.policy) {
183                Ok(world) => {
184                    commands.insert_resource(BrinkGlobals::<M>::new(world));
185                    globals_ready = true;
186                }
187                Err(err) => {
188                    error!(
189                        "BrinkFlowRequest: world policy error creating BrinkGlobals: {err}; \
190                         removing request (fix the policy passed to BrinkPlugin::with_policy)"
191                    );
192                    commands.entity(entity).remove::<BrinkFlowRequest<M>>();
193                    continue;
194                }
195            }
196        }
197
198        // Resolve start position.
199        let mut flow = match &req.start {
200            FlowStart::Root => {
201                let (flow, _ctx) = FlowInstance::new_at_root(&program_asset.program);
202                flow
203            }
204            FlowStart::Address(name) => {
205                let Some((idx, _)) = program_asset.program.find_address(name) else {
206                    error!("BrinkFlowRequest: knot '{name}' not found; removing request");
207                    commands.entity(entity).remove::<BrinkFlowRequest<M>>();
208                    continue;
209                };
210                let (flow, _ctx) = FlowInstance::new_at(&program_asset.program, idx);
211                flow
212            }
213        };
214        // F35 (ruled 2026-07-19): stamp the host-selected (or
215        // profile-defaulted) ExecMode. Core `FlowInstance` starts in `Dev`;
216        // bevy-brink's `BrinkExecMode` default keys off `debug_assertions`
217        // (see `BrinkPlugin::with_exec_mode`).
218        flow.set_exec_mode(exec_mode.mode);
219
220        // Resolve the flow's starting locale: base unless a global locale is
221        // active and its overlay is loaded (otherwise base now, caught up by
222        // `catch_up_loaded_locales` when the `.inkl` loads). `BrinkBaseLocale`
223        // retains the canonical base so future switches always overlay it.
224        let base_handle = bundle.line_tables.clone();
225        let active_handle = crate::locale::initial_locale_handle::<M>(
226            &base_handle,
227            program_asset,
228            current_locale.as_deref(),
229            &locales,
230            &mut cache,
231            &mut line_tables,
232        );
233
234        // Materialize real components, drop the request.
235        let mut entity_cmds = commands.entity(entity);
236        entity_cmds.remove::<BrinkFlowRequest<M>>();
237        entity_cmds.insert((
238            BrinkFlow::<M>::new(flow),
239            BrinkContext::<M>::new(FlowLocal::new()),
240            BrinkStory::<M>::new(bundle.program.clone(), active_handle),
241            crate::locale::BrinkBaseLocale::<M>::new(base_handle),
242        ));
243
244        // In dev builds, attach a replay log so hot-reload can rebuild
245        // the flow and replay choices.
246        #[cfg(feature = "dev")]
247        entity_cmds.insert(crate::replay::BrinkReplayLog::<M>::new(
248            req.start.clone(),
249            req.story.clone(),
250        ));
251    }
252}
253
254/// Debug-build warning system: detects entities that have *both*
255/// `BrinkFlowRequest<M>` and `BrinkFlow<M>` (which only happens if the
256/// user re-inserts the request after fulfillment). Mutating the request
257/// post-fulfillment has no effect — the system warns so the bug is
258/// visible during development.
259#[cfg(debug_assertions)]
260#[expect(clippy::type_complexity, reason = "bevy query filter type")]
261pub fn warn_post_fulfillment_mutations<M: Send + Sync + 'static>(
262    misuse: Query<
263        Entity,
264        (
265            bevy_ecs::query::With<BrinkFlowRequest<M>>,
266            bevy_ecs::query::With<BrinkFlow<M>>,
267        ),
268    >,
269) {
270    for entity in &misuse {
271        warn!(
272            "entity {entity:?} has both BrinkFlowRequest<M> and BrinkFlow<M> — \
273             mutating the request after fulfillment is a no-op. To re-spawn, \
274             despawn the entity and spawn a fresh request."
275        );
276    }
277}
278
279// Never called: `BrinkPlugin::build` only wires the debug variant above
280// (its `app.add_systems` call is itself `#[cfg(debug_assertions)]`-gated),
281// so this stub exists purely to give the generic a body in non-debug
282// builds. Confirmed via `RUSTFLAGS="-C debug-assertions=off" cargo clippy
283// -p bevy-brink --features bench-counters` (#923) — the profile that
284// actually flips `debug_assertions` off is `bench` (built by
285// `benches/scenario_bench.rs`, gated on this same feature), which no
286// default CI job exercised until #923 wired one up.
287#[cfg(not(debug_assertions))]
288#[expect(
289    dead_code,
290    reason = "generic stub kept for API parity with the debug_assertions variant; never called in release/bench profiles"
291)]
292pub fn warn_post_fulfillment_mutations<M: Send + Sync + 'static>() {}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::test_support::{add_story_assets, compile_test_story, make_test_app};
298
299    /// One tick is enough to fulfill a request once its assets are
300    /// already present.
301    #[test]
302    fn fulfillment_replaces_request_with_flow_components() {
303        let mut app = make_test_app();
304        let (program, tables, ctx) =
305            compile_test_story("=== start ===\nhello\n* [Continue] -> END\n");
306        let story = add_story_assets(&mut app, program, tables, ctx);
307
308        let entity = app
309            .world_mut()
310            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
311            .id();
312
313        app.update();
314
315        let world = app.world();
316        let entity_ref = world.entity(entity);
317        assert!(
318            entity_ref.contains::<BrinkFlow<()>>(),
319            "fulfilled entity should have BrinkFlow"
320        );
321        assert!(
322            entity_ref.contains::<crate::BrinkProgram<()>>(),
323            "fulfilled entity should have BrinkProgram"
324        );
325        assert!(
326            entity_ref.contains::<crate::BrinkLocale<()>>(),
327            "fulfilled entity should have BrinkLocale"
328        );
329        assert!(
330            entity_ref.contains::<BrinkContext<()>>(),
331            "fulfilled entity should have BrinkContext"
332        );
333        assert!(
334            !entity_ref.contains::<BrinkFlowRequest<()>>(),
335            "request component should be removed after fulfillment"
336        );
337        assert!(
338            world.contains_resource::<BrinkGlobals<()>>(),
339            "globals should be inserted on first fulfillment"
340        );
341    }
342
343    /// F35 (ruled 2026-07-19): bevy-brink's `ExecMode` default keys off the
344    /// build profile — `Dev` under `debug_assertions`. `cargo test` is a
345    /// debug build (so `cfg!(debug_assertions)` holds here), so a flow
346    /// spawned with `BrinkPlugin::default()` (no `with_exec_mode`) starts in
347    /// `Dev`. (The core-runtime default is also `Dev`, but for the opposite
348    /// reason — it is profile-independent; this asserts the bevy default
349    /// resolves to `Dev` here regardless.)
350    #[test]
351    #[cfg(debug_assertions)]
352    fn default_exec_mode_is_dev_in_debug_build() {
353        let mut app = make_test_app();
354        assert_eq!(
355            app.world().resource::<BrinkExecMode<()>>().mode,
356            brink_runtime::ExecMode::Dev,
357            "BrinkPlugin default must resolve to Dev under debug_assertions"
358        );
359
360        let (program, tables, ctx) = compile_test_story("=== start ===\nhi\n* [Continue] -> END\n");
361        let story = add_story_assets(&mut app, program, tables, ctx);
362        let entity = app
363            .world_mut()
364            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
365            .id();
366        app.update();
367
368        assert_eq!(
369            app.world()
370                .entity(entity)
371                .get::<BrinkFlow<()>>()
372                .expect("flow materialized")
373                .inner
374                .exec_mode(),
375            brink_runtime::ExecMode::Dev,
376            "a flow spawned under the default plugin must start in Dev in a debug build"
377        );
378    }
379
380    /// F35: the host override. `with_exec_mode(Prod)` pins every spawned
381    /// flow to `Prod` regardless of build profile.
382    #[test]
383    fn with_exec_mode_override_stamps_spawned_flow() {
384        let mut app = App::new();
385        app.add_plugins(bevy_asset::AssetPlugin::default());
386        app.add_plugins(
387            crate::BrinkPlugin::<()>::default().with_exec_mode(brink_runtime::ExecMode::Prod),
388        );
389        assert_eq!(
390            app.world().resource::<BrinkExecMode<()>>().mode,
391            brink_runtime::ExecMode::Prod,
392            "with_exec_mode(Prod) must install a Prod resource"
393        );
394
395        let (program, tables, ctx) = compile_test_story("=== start ===\nhi\n* [Continue] -> END\n");
396        let story = add_story_assets(&mut app, program, tables, ctx);
397        let entity = app
398            .world_mut()
399            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
400            .id();
401        app.update();
402
403        assert_eq!(
404            app.world()
405                .entity(entity)
406                .get::<BrinkFlow<()>>()
407                .expect("flow materialized")
408                .inner
409                .exec_mode(),
410            brink_runtime::ExecMode::Prod,
411            "with_exec_mode(Prod) must stamp Prod onto the spawned flow"
412        );
413    }
414
415    /// In dev builds, the replay log gets attached automatically so
416    /// hot-reload works.
417    #[test]
418    #[cfg(feature = "dev")]
419    fn fulfillment_attaches_replay_log_in_dev() {
420        let mut app = make_test_app();
421        let (program, tables, ctx) =
422            compile_test_story("=== start ===\nhello\n* [Continue] -> END\n");
423        let story = add_story_assets(&mut app, program, tables, ctx);
424
425        let entity = app
426            .world_mut()
427            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
428            .id();
429
430        app.update();
431
432        assert!(
433            app.world()
434                .entity(entity)
435                .contains::<crate::replay::BrinkReplayLog<()>>(),
436            "BrinkReplayLog should be attached when dev feature is enabled"
437        );
438    }
439
440    /// `FlowStart::Address` resolves at fulfillment time. If the address
441    /// is unknown, the request is removed and no flow is materialized.
442    #[test]
443    fn fulfillment_removes_request_for_unknown_address() {
444        let mut app = make_test_app();
445        let (program, tables, ctx) = compile_test_story(
446            "=== start ===\nhello\n* [Continue] -> END\n=== outro ===\nbye\n-> END\n",
447        );
448        let story = add_story_assets(&mut app, program, tables, ctx);
449
450        let entity = app
451            .world_mut()
452            .spawn(
453                BrinkFlowRequest::<()>::builder()
454                    .story(story)
455                    .start(FlowStart::Address("nonexistent_knot".to_string()))
456                    .build(),
457            )
458            .id();
459
460        app.update();
461
462        let entity_ref = app.world().entity(entity);
463        assert!(
464            !entity_ref.contains::<BrinkFlowRequest<()>>(),
465            "request should be removed when address can't be resolved"
466        );
467        assert!(
468            !entity_ref.contains::<BrinkFlow<()>>(),
469            "no flow should materialize for unresolvable address"
470        );
471    }
472
473    /// `FlowStart::Address` resolves when the knot exists.
474    #[test]
475    fn fulfillment_resolves_named_address() {
476        let mut app = make_test_app();
477        let (program, tables, ctx) = compile_test_story(
478            "=== start ===\nhello\n* [Continue] -> END\n=== outro ===\nbye\n-> END\n",
479        );
480        let story = add_story_assets(&mut app, program, tables, ctx);
481
482        let entity = app
483            .world_mut()
484            .spawn(
485                BrinkFlowRequest::<()>::builder()
486                    .story(story)
487                    .start(FlowStart::Address("outro".to_string()))
488                    .build(),
489            )
490            .id();
491
492        app.update();
493
494        assert!(
495            app.world().entity(entity).contains::<BrinkFlow<()>>(),
496            "flow should materialize when address resolves"
497        );
498    }
499
500    /// Multiple flow requests share the same `BrinkGlobals` — the first
501    /// fulfillment seeds it, subsequent ones reuse.
502    #[test]
503    fn multiple_requests_share_globals() {
504        let mut app = make_test_app();
505        let (program, tables, ctx) =
506            compile_test_story("VAR shared_counter = 0\n=== start ===\nhi\n* [Continue] -> END\n");
507        let story = add_story_assets(&mut app, program, tables, ctx);
508
509        let e1 = app
510            .world_mut()
511            .spawn(
512                BrinkFlowRequest::<()>::builder()
513                    .story(story.clone())
514                    .build(),
515            )
516            .id();
517        let e2 = app
518            .world_mut()
519            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
520            .id();
521
522        app.update();
523
524        let world = app.world();
525        assert!(world.entity(e1).contains::<BrinkFlow<()>>());
526        assert!(world.entity(e2).contains::<BrinkFlow<()>>());
527        // Single resource for the marker — both flows reference it via
528        // the system's ResMut<BrinkGlobals<M>>.
529        assert!(world.contains_resource::<BrinkGlobals<()>>());
530    }
531
532    // ── F6.2: scoped-flow-state semantics (shared-by-default World) ──────
533    //
534    // The pre-F6.2 model gave every flow its OWN full `World` clone —
535    // isolation was the default and had to be explicitly committed back.
536    // F6.2 flips that: one shared `World` per marker, private state is the
537    // opt-in case via `WorldPolicy` overrides. These three tests are the
538    // semantic anchor for that flip (see the F6 AMENDMENT in
539    // `docs/scoped-flow-state-spec.md`): (a) default policy shares live,
540    // (b) a `Local` override isolates just the named unit while the rest
541    // stays shared, (c) a bad override name fails cleanly, not a panic.
542
543    use crate::globals::flow_context_view;
544    use crate::{Advance, BrinkGlobals};
545    use bevy_app::App;
546    use bevy_ecs::system::SystemState;
547    use brink_runtime::{Scope, Step, StoryStatus, WorldPolicy};
548
549    /// Per-entity text accumulated across a single `advance_until_terminal`
550    /// drive, via the `BrinkLineDelivered` observer registered by
551    /// [`install_text_accumulator`]. Terminals carry no payload of their
552    /// own (§7 — `docs/prose-dialect-spec.md`) — any trailing content
553    /// already arrived as its own preceding `Step::Line`/`BrinkLineDelivered`
554    /// event, which is why both [`drive_entity`] and
555    /// [`drive_all_active_via_advance_until_terminal`] accumulate across
556    /// every event a drive fires rather than reading the single terminal
557    /// `Advance::Step`'s own (now-empty) text. Relies on
558    /// `SystemState::apply` (or an equivalent `Commands` flush) having run
559    /// before this is read — see both helpers' own use.
560    #[derive(bevy_ecs::resource::Resource, Default)]
561    struct PendingText(std::collections::HashMap<Entity, String>);
562
563    /// Register the `BrinkLineDelivered` observer [`drive_entity`]/
564    /// [`drive_all_active_via_advance_until_terminal`] rely on to recover a
565    /// flow's full drive text. Call once per test `App` (see
566    /// [`app_with_save_policy`]).
567    fn install_text_accumulator(app: &mut App) {
568        app.insert_resource(PendingText::default());
569        app.add_observer(
570            |trigger: bevy_ecs::observer::On<crate::BrinkLineDelivered<()>>,
571             mut pending: ResMut<PendingText>| {
572                let ev = trigger.event();
573                pending.0.entry(ev.entity).or_default().push_str(&ev.text);
574            },
575        );
576    }
577
578    /// System-state shape for [`drive_all_active_via_advance_until_terminal`]:
579    /// every flow's entity/component tuple plus the assets and shared
580    /// globals needed to build a [`flow_context_view`] and drive through
581    /// [`BrinkFlow::advance_until_terminal`]. Mirrors [`FlowQuery`] below
582    /// (the F6.3 helpers' equivalent), one entity earlier in the tuple since
583    /// this helper drives every active flow rather than one named entity.
584    type DriveAllQuery = SystemState<(
585        Query<
586            'static,
587            'static,
588            (
589                Entity,
590                &'static mut BrinkFlow<()>,
591                &'static mut BrinkContext<()>,
592                &'static crate::BrinkProgram<()>,
593                &'static crate::BrinkLocale<()>,
594            ),
595        >,
596        ResMut<'static, BrinkGlobals<()>>,
597        Res<'static, Assets<ProgramAsset>>,
598        Res<'static, Assets<crate::asset::LineTablesAsset>>,
599        Commands<'static, 'static>,
600    )>;
601
602    /// Drive every `Active` flow (skips ones already at a terminal status,
603    /// e.g. `Done` from an earlier pass in the same test) to its next
604    /// terminal step, returning one accumulated text per flow driven. Shared
605    /// by the F6.2 tests below. Requires [`install_text_accumulator`] to
606    /// have been called on `app` first.
607    ///
608    /// Drives through the production [`BrinkFlow::advance_until_terminal`]
609    /// path (so `emit_drive_outcome` — and the `BrinkLineDelivered` observer
610    /// events it fires — actually gets exercised here; see #2104) rather
611    /// than the raw [`FlowInstance::drive`] this helper used until now.
612    /// `advance_until_terminal` delivers its intermediate content only as
613    /// `Commands`-deferred observer events, which is why this drives via an
614    /// explicit `SystemState`/[`SystemState::apply`] cycle — mirroring the
615    /// F6.3 tests' [`drive_entity`] helper — instead of running as a
616    /// scheduled `Update` system: `apply` flushes the `Commands` (so the
617    /// observer runs and [`PendingText`] is populated) before this function
618    /// reads it back, sidestepping the same-system flush-timing footgun a
619    /// scheduled system would hit rather than working around it by reaching
620    /// for the raw drive op.
621    fn drive_all_active_via_advance_until_terminal(app: &mut App) -> Vec<String> {
622        let mut state: DriveAllQuery = SystemState::new(app.world_mut());
623        let (mut flows, mut globals, programs, tables, mut commands) =
624            state.get_mut(app.world_mut()).expect("system params");
625
626        let mut driven = Vec::new();
627        for (entity, mut flow, mut ctx, prog, loc) in &mut flows {
628            if flow.inner.status() != StoryStatus::Active {
629                continue;
630            }
631            let (Some(p), Some(t)) = (programs.get(&prog.handle), tables.get(&loc.handle)) else {
632                continue;
633            };
634            let mut view = flow_context_view(&mut globals, &mut ctx);
635            let advance = flow
636                .advance_until_terminal(
637                    &p.program,
638                    &t.tables,
639                    &mut view,
640                    &brink_runtime::FallbackHandler,
641                    entity,
642                    &mut commands,
643                )
644                .expect("advance");
645            match advance {
646                Advance::Step(_) => {}
647                // None of the F6.2 tests use externals.
648                Advance::AwaitingQuery => unreachable!("unexpected pending external in F6.2 tests"),
649            }
650            driven.push(entity);
651        }
652        state.apply(app.world_mut());
653
654        driven
655            .into_iter()
656            .map(|entity| {
657                app.world_mut()
658                    .resource_mut::<PendingText>()
659                    .0
660                    .remove(&entity)
661                    .unwrap_or_default()
662            })
663            .collect()
664    }
665
666    /// (a) Default policy (`WorldPolicy::default()`, installed automatically
667    /// by `BrinkPlugin::default()`): two flows spawned over the same marker
668    /// share the one `BrinkGlobals<M>` `World` live. Each flow's `~ counter
669    /// = counter + 1` lands in the SAME shared slot, so the two flows'
670    /// outputs are "1" and "2" — not both "1", which is what independent
671    /// per-flow copies (the pre-F6.2 model) would have produced.
672    #[test]
673    fn default_policy_two_flows_share_one_world_global() {
674        let mut app = App::new();
675        app.add_plugins(bevy_asset::AssetPlugin::default());
676        app.add_plugins(crate::BrinkPlugin::<()>::default());
677        install_text_accumulator(&mut app);
678
679        let (program, tables, ctx) = compile_test_story(
680            "VAR counter = 0\n~ counter = counter + 1\nCounter is {counter}.\n-> DONE\n",
681        );
682        let story = add_story_assets(&mut app, program, tables, ctx);
683
684        app.world_mut().spawn(
685            BrinkFlowRequest::<()>::builder()
686                .story(story.clone())
687                .build(),
688        );
689        app.world_mut()
690            .spawn(BrinkFlowRequest::<()>::builder().story(story).build());
691        app.update(); // fulfill both
692
693        let mut texts = drive_all_active_via_advance_until_terminal(&mut app); // drive both to Done in one pass
694        texts.sort();
695        assert_eq!(
696            texts,
697            vec!["Counter is 1.\n".to_string(), "Counter is 2.\n".to_string()],
698            "two flows sharing the default (all-World) policy should observe \
699             cumulative shared state, not independent per-flow copies; got {texts:?}"
700        );
701    }
702
703    /// (b) A policy with a `Local`-scoped knot (`overrides: {"start":
704    /// Local}`) alongside the `World`-scoped default: each flow's visit
705    /// count for the `start` knot is its own, so both flows entering it for
706    /// their first time see the sequence's first branch ("Hello") — but the
707    /// plain `VAR shared_visits` stays `World`-scoped by the (untouched)
708    /// default, so it keeps counting across both flows.
709    #[test]
710    fn local_knot_override_isolates_visit_state_while_world_var_stays_shared() {
711        let mut app = App::new();
712        app.add_plugins(bevy_asset::AssetPlugin::default());
713        let mut policy = WorldPolicy::default();
714        policy.overrides.insert("start".to_string(), Scope::Local);
715        app.add_plugins(crate::BrinkPlugin::<()>::default().with_policy(policy));
716        install_text_accumulator(&mut app);
717
718        // Root diverts straight into `start` — a real `-> start` divert, so
719        // entering it goes through the normal goto machinery that bumps
720        // its visit count (starting a flow directly AT a knot via
721        // `FlowStart::Address` does not: only diverting *into* a knot
722        // counts as a visit).
723        let (program, tables, ctx) = compile_test_story(
724            "VAR shared_visits = 0\n-> start\n=== start ===\n\
725             ~ shared_visits = shared_visits + 1\n\
726             {start: Hello|Welcome back} (shared {shared_visits}).\n-> DONE\n",
727        );
728        let story = add_story_assets(&mut app, program, tables, ctx);
729
730        // Flow 1: spawn, fulfill, drive to its first Done in isolation so
731        // the shared-VAR assertion below has an unambiguous "after flow 1"
732        // checkpoint.
733        app.world_mut().spawn(
734            BrinkFlowRequest::<()>::builder()
735                .story(story.clone())
736                .build(),
737        );
738        app.update(); // fulfill flow 1
739        let flow1_text = drive_all_active_via_advance_until_terminal(&mut app) // drive flow 1 to Done
740            .remove(0);
741        assert!(
742            flow1_text.contains("Hello"),
743            "flow 1's first-ever visit to a Local-scoped knot should take \
744             the sequence's first branch; got {flow1_text:?}"
745        );
746        assert!(
747            flow1_text.contains("shared 1"),
748            "the World-scoped VAR should count flow 1's visit; got {flow1_text:?}"
749        );
750
751        // Flow 2: a fresh flow entering the SAME Local-scoped knot. If the
752        // knot's visit count were (incorrectly) World-scoped, flow 2 would
753        // see "Welcome back" (visit count already 1); because it's Local,
754        // flow 2's own count starts at 0 and it also sees "Hello".
755        app.world_mut()
756            .spawn(BrinkFlowRequest::<()>::builder().story(story).build());
757        app.update(); // fulfill flow 2 (flow 1 no longer matches `Without<BrinkFlow<M>>`)
758        let flow2_text = drive_all_active_via_advance_until_terminal(&mut app) // drive flow 2 to Done
759            .remove(0);
760        assert!(
761            flow2_text.contains("Hello"),
762            "flow 2's own Local visit count should also start fresh, \
763             independent of flow 1's; got {flow2_text:?}"
764        );
765        assert!(
766            flow2_text.contains("shared 2"),
767            "the World-scoped VAR should keep counting across flows \
768             (flow 1's 1, then flow 2's 2); got {flow2_text:?}"
769        );
770    }
771
772    /// (c) A policy override naming a variable/knot the program doesn't
773    /// declare is a [`brink_runtime::PolicyError`] at `BrinkGlobals`
774    /// creation — `fulfill_flow_requests` must surface it as a logged
775    /// fulfillment error on the offending request (removed, not left
776    /// dangling), and — the actual point of this test — must not panic.
777    #[test]
778    fn unknown_policy_override_surfaces_as_fulfillment_error_not_panic() {
779        let mut app = App::new();
780        app.add_plugins(bevy_asset::AssetPlugin::default());
781        let mut policy = WorldPolicy::default();
782        policy
783            .overrides
784            .insert("does_not_exist".to_string(), Scope::Local);
785        app.add_plugins(crate::BrinkPlugin::<()>::default().with_policy(policy));
786
787        let (program, tables, ctx) = compile_test_story("Hello.\n-> END\n");
788        let story = add_story_assets(&mut app, program, tables, ctx);
789        let entity = app
790            .world_mut()
791            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
792            .id();
793
794        // The point of the test: this must not panic.
795        app.update();
796
797        assert!(
798            !app.world().entity(entity).contains::<BrinkFlow<()>>(),
799            "flow should not materialize when the policy fails to resolve"
800        );
801        assert!(
802            !app.world()
803                .entity(entity)
804                .contains::<BrinkFlowRequest<()>>(),
805            "the invalid request should be removed, not left pending forever"
806        );
807        assert!(
808            !app.world().contains_resource::<BrinkGlobals<()>>(),
809            "BrinkGlobals must never be created from a policy that fails to resolve"
810        );
811    }
812
813    // ── F6.3: per-entity SaveState durability ─────────────────────────────
814    //
815    // A save is one SaveState for the shared World + one per entity,
816    // composed host-side (see the F6 AMENDMENT ruling 4 and the
817    // "Save/load" section of globals.rs's module docs). These tests drive
818    // two flows to diverge their private state under a policy with a
819    // Local-marked VAR and a Local-marked knot, save world + both entities,
820    // then load into a completely FRESH app (fresh World, fresh
821    // FlowInstances re-entered at a knot) and check each flow recovers
822    // exactly its own private state while the shared VAR converges once.
823
824    use crate::globals::{load_flow_state, save_flow_state};
825    use brink_format::Value;
826    use brink_runtime::ContextAccess;
827
828    /// The ink source shared by the F6.3 tests: `mood` (a private counter)
829    /// and the `greet` knot's own visit count (read via `READ_COUNT`, so
830    /// assertions don't depend on interpreting sequence-cycling text) are
831    /// marked `Local` by the test policy; `shared_count` stays `World` by
832    /// the (untouched) default. Printing numeric values rather than relying
833    /// on `{greet: A|B}`-style sequence text keeps the assertions exact and
834    /// independent of sequence-indexing semantics.
835    const SAVE_TEST_SRC: &str = "VAR shared_count = 0\nVAR mood = 0\n-> greet\n\
836         === greet ===\n\
837         ~ mood = mood + 1\n\
838         ~ shared_count = shared_count + 1\n\
839         Greeting mood={mood} visits={READ_COUNT(-> greet)} shared={shared_count}\n\
840         * [Again] -> greet\n\
841         * [Done] -> END\n";
842
843    /// `mood` + `greet`'s own visit count are private per flow; everything
844    /// else (including `shared_count`) stays World-scoped by default.
845    fn save_test_policy() -> WorldPolicy {
846        let mut policy = WorldPolicy::default();
847        policy.overrides.insert("mood".to_string(), Scope::Local);
848        policy.overrides.insert("greet".to_string(), Scope::Local);
849        policy
850    }
851
852    /// System-state shape shared by the driving/save/load helpers below:
853    /// every flow component plus the assets and shared globals needed to
854    /// build a [`flow_context_view`] and advance/save/load through it.
855    /// `'static` lifetimes here follow the same pattern as `flow.rs`'s
856    /// `ChooseState` — a type alias for a one-off `SystemState`, not a
857    /// generic query type.
858    type FlowQuery = SystemState<(
859        Query<
860            'static,
861            'static,
862            (
863                &'static mut BrinkFlow<()>,
864                &'static mut BrinkContext<()>,
865                &'static crate::BrinkProgram<()>,
866                &'static crate::BrinkLocale<()>,
867            ),
868        >,
869        ResMut<'static, BrinkGlobals<()>>,
870        Res<'static, Assets<ProgramAsset>>,
871        Res<'static, Assets<crate::LineTablesAsset>>,
872        Commands<'static, 'static>,
873    )>;
874
875    /// Drive one entity's flow to its next terminal line, via
876    /// `flow_context_view` exactly like a real advance system would build
877    /// it. Panics if the flow parks on a world-access external (none of
878    /// these tests use externals) or if any required asset/component is
879    /// missing.
880    ///
881    /// Terminals carry no payload of their own (§7) — any trailing content
882    /// already arrived as its own preceding `Step::Line`/`BrinkLineDelivered`
883    /// event, so this accumulates text via the `BrinkLineDelivered` observer
884    /// [`install_text_accumulator`] registers (callers must have called it
885    /// on `app`, e.g. via [`app_with_save_policy`]) rather than reading it
886    /// off the terminal `Advance::Step` alone.
887    fn drive_entity(app: &mut App, entity: Entity) -> (String, Step) {
888        let mut state: FlowQuery = SystemState::new(app.world_mut());
889        let (mut flows, mut globals, programs, tables, mut commands) =
890            state.get_mut(app.world_mut()).expect("system params");
891        let (mut flow, mut ctx, prog, loc) = flows.get_mut(entity).expect("flow components");
892        let program = &programs.get(&prog.handle).expect("program asset").program;
893        let line_tables = &tables.get(&loc.handle).expect("line tables asset").tables;
894        let mut view = flow_context_view(&mut globals, &mut ctx);
895        let advance = flow
896            .advance_until_terminal(
897                program,
898                line_tables,
899                &mut view,
900                &brink_runtime::FallbackHandler,
901                entity,
902                &mut commands,
903            )
904            .expect("advance");
905        state.apply(app.world_mut());
906        let step = match advance {
907            Advance::Step(step) => step,
908            // None of the F6.3 tests use externals, so a pause here can
909            // only be a bug in the test setup.
910            Advance::AwaitingQuery => unreachable!("unexpected pending external in F6.3 tests"),
911        };
912        let text = app
913            .world_mut()
914            .resource_mut::<PendingText>()
915            .0
916            .remove(&entity)
917            .unwrap_or_default();
918        (text, step)
919    }
920
921    /// Pick choice `index` on one entity's flow.
922    fn choose_entity(app: &mut App, entity: Entity, index: usize) {
923        let mut state: FlowQuery = SystemState::new(app.world_mut());
924        let (mut flows, mut globals, _programs, _tables, _commands) =
925            state.get_mut(app.world_mut()).expect("system params");
926        let (mut flow, mut ctx, _prog, _loc) = flows.get_mut(entity).expect("flow components");
927        let mut view = flow_context_view(&mut globals, &mut ctx);
928        flow.choose(&mut view, index).expect("choose");
929        state.apply(app.world_mut());
930    }
931
932    /// Spawn a `BrinkFlowRequest` for `story` starting at `start` and run
933    /// one tick to fulfill it. Returns the entity.
934    fn spawn_fulfilled(app: &mut App, story: &Handle<BrinkStoryAsset>, start: FlowStart) -> Entity {
935        let entity = app
936            .world_mut()
937            .spawn(
938                BrinkFlowRequest::<()>::builder()
939                    .story(story.clone())
940                    .start(start)
941                    .build(),
942            )
943            .id();
944        app.update();
945        entity
946    }
947
948    /// A fresh app wired with `BrinkPlugin` under the F6.3 test policy.
949    fn app_with_save_policy() -> App {
950        let mut app = App::new();
951        app.add_plugins(bevy_asset::AssetPlugin::default());
952        app.add_plugins(crate::BrinkPlugin::<()>::default().with_policy(save_test_policy()));
953        install_text_accumulator(&mut app);
954        app
955    }
956
957    /// (a) Full roundtrip: two flows diverge their private state (flow A
958    /// visits `greet` once, flow B twice — different `mood` values and
959    /// different `greet` visit counts) while sharing `shared_count`. Save
960    /// world + both entities, build a completely FRESH app (fresh
961    /// `Program` compile, fresh `World`, fresh `FlowInstance`s re-entered
962    /// at `greet` via `FlowStart::Address`), load world then each entity,
963    /// and check each flow's re-entry sees ITS OWN restored private state
964    /// — not the other flow's, not a fresh start — while the shared VAR is
965    /// the single converged value from both saves.
966    #[test]
967    #[expect(
968        clippy::similar_names,
969        reason = "the paired a/b entity naming is the point of the test"
970    )]
971    fn full_roundtrip_world_plus_two_entities() {
972        // ── App 1: drive two flows to diverge, then save. ──
973        let mut app1 = app_with_save_policy();
974        let (program1, tables1, ctx1) = compile_test_story(SAVE_TEST_SRC);
975        let story1 = add_story_assets(&mut app1, program1, tables1, ctx1);
976
977        let entity_a = spawn_fulfilled(&mut app1, &story1, FlowStart::Root);
978        let entity_b = spawn_fulfilled(&mut app1, &story1, FlowStart::Root);
979
980        // Flow A: one pass through `greet` (mood=1, greet visits=1).
981        let (text_a, _) = drive_entity(&mut app1, entity_a);
982        assert!(
983            text_a.contains("mood=1") && text_a.contains("visits=1"),
984            "flow A's first pass; got {text_a:?}"
985        );
986
987        // Flow B: two passes through `greet` (mood=2, greet visits=2).
988        let (text_b1, _) = drive_entity(&mut app1, entity_b);
989        assert!(
990            text_b1.contains("mood=1") && text_b1.contains("visits=1"),
991            "flow B's first pass; got {text_b1:?}"
992        );
993        choose_entity(&mut app1, entity_b, 0); // "Again" -> greet
994        let (text_b2, _) = drive_entity(&mut app1, entity_b);
995        assert!(
996            text_b2.contains("mood=2") && text_b2.contains("visits=2"),
997            "flow B's second pass; got {text_b2:?}"
998        );
999
1000        // Capture saves — world once, then each entity — all reading the
1001        // same settled state (no mutation happens between these calls).
1002        let (world_save, save_a, save_b) = {
1003            let mut state: FlowQuery = SystemState::new(app1.world_mut());
1004            let (mut flows, mut globals, programs, _tables, _commands) =
1005                state.get_mut(app1.world_mut()).expect("system params");
1006
1007            let handle = flows.get(entity_a).expect("flow a").2.handle.clone();
1008            let program = &programs.get(&handle).expect("program asset").program;
1009
1010            let world_save = globals.save_state(program);
1011
1012            let (_flow_a, mut ctx_a, _p, _l) = flows.get_mut(entity_a).expect("flow a");
1013            let save_a = save_flow_state(&mut globals, &mut ctx_a, program);
1014
1015            let (_flow_b, mut ctx_b, _p, _l) = flows.get_mut(entity_b).expect("flow b");
1016            let save_b = save_flow_state(&mut globals, &mut ctx_b, program);
1017
1018            (world_save, save_a, save_b)
1019        };
1020
1021        // Sanity on what got captured before crossing into the fresh app.
1022        assert_eq!(save_a.globals.get("mood"), Some(&Value::Int(1)));
1023        assert_eq!(save_b.globals.get("mood"), Some(&Value::Int(2)));
1024        // shared_count must be identical across world + both entity saves —
1025        // the "same save moment" property `load_flow_state`'s idempotent
1026        // World rewrite depends on.
1027        assert_eq!(save_a.globals.get("shared_count"), Some(&Value::Int(3)));
1028        assert_eq!(save_b.globals.get("shared_count"), Some(&Value::Int(3)));
1029        assert_eq!(world_save.globals.get("shared_count"), Some(&Value::Int(3)));
1030
1031        // ── App 2: a completely fresh app — new compile, new World, new
1032        // FlowInstances re-entered at `greet` (not resumed mid-line). ──
1033        let mut app2 = app_with_save_policy();
1034        let (program2, tables2, ctx2) = compile_test_story(SAVE_TEST_SRC);
1035        let story2 = add_story_assets(&mut app2, program2, tables2, ctx2);
1036
1037        let entity_a2 =
1038            spawn_fulfilled(&mut app2, &story2, FlowStart::Address("greet".to_string()));
1039        let entity_b2 =
1040            spawn_fulfilled(&mut app2, &story2, FlowStart::Address("greet".to_string()));
1041
1042        // Load world first, then each entity through its own view.
1043        {
1044            let mut state: FlowQuery = SystemState::new(app2.world_mut());
1045            let (mut flows, mut globals, programs, _tables, _commands) =
1046                state.get_mut(app2.world_mut()).expect("system params");
1047
1048            let handle = flows.get(entity_a2).expect("flow a2").2.handle.clone();
1049            let program = &programs.get(&handle).expect("program asset").program;
1050
1051            let world_report = globals.load_state(program, &world_save);
1052            assert!(
1053                world_report.is_clean(),
1054                "world load should be clean: {world_report:?}"
1055            );
1056
1057            let (_flow, mut ctx_a2, _p, _l) = flows.get_mut(entity_a2).expect("flow a2");
1058            let report_a = load_flow_state(&mut globals, &mut ctx_a2, program, &save_a);
1059            assert!(
1060                report_a.is_clean(),
1061                "entity A load should be clean: {report_a:?}"
1062            );
1063
1064            let (_flow, mut ctx_b2, _p, _l) = flows.get_mut(entity_b2).expect("flow b2");
1065            let report_b = load_flow_state(&mut globals, &mut ctx_b2, program, &save_b);
1066            assert!(
1067                report_b.is_clean(),
1068                "entity B load should be clean: {report_b:?}"
1069            );
1070        }
1071
1072        // Re-enter each flow at `greet` (FlowStart::Address does NOT bump
1073        // greet's own visit count — see `fulfillment_resolves_named_address`
1074        // and its sibling comments above — so READ_COUNT still reflects the
1075        // RESTORED count, not a fresh 0, proving state (not position) is
1076        // what carries the resume forward).
1077        let (resumed_a_text, _) = drive_entity(&mut app2, entity_a2);
1078        assert!(
1079            resumed_a_text.contains("mood=2") && resumed_a_text.contains("visits=1"),
1080            "flow A2 should resume from its own restored state (mood 1->2, \
1081             greet visits still 1, unbumped by address-entry); got {resumed_a_text:?}",
1082        );
1083        let (resumed_b_text, _) = drive_entity(&mut app2, entity_b2);
1084        assert!(
1085            resumed_b_text.contains("mood=3") && resumed_b_text.contains("visits=2"),
1086            "flow B2 should resume from ITS OWN restored state (mood 2->3, \
1087             greet visits still 2) — distinct from flow A2's; got {resumed_b_text:?}",
1088        );
1089    }
1090
1091    /// (b) Entity load routes by scope: loading a `SaveState` carrying both
1092    /// a `Local`-marked `VAR` (`mood`) and a `World`-marked `VAR`
1093    /// (`shared_count`) into one flow lands `mood` in that entity's own
1094    /// `FlowLocal` — invisible through the shared `World` directly, and
1095    /// invisible to any *other* flow's view — while `shared_count` lands in
1096    /// the shared `World` exactly as saved (idempotent rewrite), visible
1097    /// both through the raw `World` and through any flow's view.
1098    #[test]
1099    #[expect(
1100        clippy::too_many_lines,
1101        reason = "one scope-routing scenario checked from three vantage points"
1102    )]
1103    fn entity_load_routes_local_to_flow_local_and_world_stays_shared() {
1104        let mut app = app_with_save_policy();
1105        let (program, tables, ctx) = compile_test_story(SAVE_TEST_SRC);
1106        let story = add_story_assets(&mut app, program, tables, ctx);
1107        let entity = spawn_fulfilled(&mut app, &story, FlowStart::Address("greet".to_string()));
1108        // A second, untouched flow sharing the same BrinkGlobals — used to
1109        // prove the loaded Local value is NOT visible globally.
1110        let other = spawn_fulfilled(&mut app, &story, FlowStart::Address("greet".to_string()));
1111
1112        // Hand-built SaveState: mood (Local) = 42, shared_count (World) = 7.
1113        let mut save = brink_runtime::SaveState {
1114            version: brink_runtime::SAVE_FORMAT_VERSION,
1115            globals: std::collections::BTreeMap::new(),
1116            global_ids: std::collections::BTreeMap::new(),
1117            visits: Vec::new(),
1118            turns: Vec::new(),
1119            turn_index: 0,
1120            rng_seed: 0,
1121            previous_random: 0,
1122            suspended: None,
1123        };
1124        save.globals.insert("mood".to_string(), Value::Int(42));
1125        save.globals
1126            .insert("shared_count".to_string(), Value::Int(7));
1127
1128        {
1129            let mut state: FlowQuery = SystemState::new(app.world_mut());
1130            let (mut flows, mut globals, programs, _tables, _commands) =
1131                state.get_mut(app.world_mut()).expect("system params");
1132            let handle = flows.get(entity).expect("flow").2.handle.clone();
1133            let program = &programs.get(&handle).expect("program asset").program;
1134            let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
1135            let report = load_flow_state(&mut globals, &mut ctx, program, &save);
1136            assert!(report.is_clean(), "load should be clean: {report:?}");
1137        }
1138
1139        let mood_idx = {
1140            let programs = app.world().resource::<Assets<ProgramAsset>>();
1141            let handle = app
1142                .world()
1143                .entity(entity)
1144                .get::<crate::BrinkProgram<()>>()
1145                .expect("BrinkProgram")
1146                .handle
1147                .clone();
1148            programs
1149                .get(&handle)
1150                .expect("program asset")
1151                .program
1152                .global_index("mood")
1153                .expect("mood global")
1154        };
1155        let shared_idx = {
1156            let programs = app.world().resource::<Assets<ProgramAsset>>();
1157            let handle = app
1158                .world()
1159                .entity(entity)
1160                .get::<crate::BrinkProgram<()>>()
1161                .expect("BrinkProgram")
1162                .handle
1163                .clone();
1164            programs
1165                .get(&handle)
1166                .expect("program asset")
1167                .program
1168                .global_index("shared_count")
1169                .expect("shared_count global")
1170        };
1171
1172        // The loaded entity's own EFFECTIVE view sees the restored mood.
1173        {
1174            let mut state: FlowQuery = SystemState::new(app.world_mut());
1175            let (mut flows, mut globals, _programs, _tables, _commands) =
1176                state.get_mut(app.world_mut()).expect("system params");
1177            let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
1178            let view = flow_context_view(&mut globals, &mut ctx);
1179            assert_eq!(
1180                view.global(mood_idx),
1181                &Value::Int(42),
1182                "the loaded entity's own view should see the restored Local mood"
1183            );
1184            assert_eq!(
1185                view.global(shared_idx),
1186                &Value::Int(7),
1187                "the loaded entity's own view should see the restored World shared_count"
1188            );
1189        }
1190
1191        // Raw World storage never received the Local write.
1192        {
1193            let globals = app.world().resource::<BrinkGlobals<()>>();
1194            assert_ne!(
1195                globals.inner.global(mood_idx),
1196                &Value::Int(42),
1197                "Local-scoped mood must NOT have been written into the shared World"
1198            );
1199            assert_eq!(
1200                globals.inner.global(shared_idx),
1201                &Value::Int(7),
1202                "World-scoped shared_count should have rewritten the shared World directly"
1203            );
1204        }
1205
1206        // A completely different flow sharing the same BrinkGlobals does
1207        // NOT see the loaded entity's private mood (proves it landed in
1208        // THAT entity's own FlowLocal, not anywhere globally visible) but
1209        // DOES see the shared shared_count (World-scoped, visible to all).
1210        {
1211            let mut state: FlowQuery = SystemState::new(app.world_mut());
1212            let (mut flows, mut globals, _programs, _tables, _commands) =
1213                state.get_mut(app.world_mut()).expect("system params");
1214            let (_flow, mut ctx, _p, _l) = flows.get_mut(other).expect("other flow");
1215            let view = flow_context_view(&mut globals, &mut ctx);
1216            assert_ne!(
1217                view.global(mood_idx),
1218                &Value::Int(42),
1219                "a different flow must not see another entity's private mood"
1220            );
1221            assert_eq!(
1222                view.global(shared_idx),
1223                &Value::Int(7),
1224                "a different flow should see the same shared shared_count"
1225            );
1226        }
1227    }
1228
1229    /// (c) `LoadReport` surfaces unknown globals without erroring: a
1230    /// `SaveState` naming a `VAR` the program doesn't declare loads
1231    /// cleanly for every other entry, with the unknown name reported.
1232    #[test]
1233    fn load_report_surfaces_unknown_globals() {
1234        let mut app = app_with_save_policy();
1235        let (program, tables, ctx) = compile_test_story(SAVE_TEST_SRC);
1236        let story = add_story_assets(&mut app, program, tables, ctx);
1237        let entity = spawn_fulfilled(&mut app, &story, FlowStart::Address("greet".to_string()));
1238
1239        let mut save = brink_runtime::SaveState {
1240            version: brink_runtime::SAVE_FORMAT_VERSION,
1241            globals: std::collections::BTreeMap::new(),
1242            global_ids: std::collections::BTreeMap::new(),
1243            visits: Vec::new(),
1244            turns: Vec::new(),
1245            turn_index: 0,
1246            rng_seed: 0,
1247            previous_random: 0,
1248            suspended: None,
1249        };
1250        save.globals.insert("mood".to_string(), Value::Int(5));
1251        save.globals
1252            .insert("does_not_exist".to_string(), Value::Int(99));
1253
1254        let (report, mood_idx) = {
1255            let mut state: FlowQuery = SystemState::new(app.world_mut());
1256            let (mut flows, mut globals, programs, _tables, _commands) =
1257                state.get_mut(app.world_mut()).expect("system params");
1258            let handle = flows.get(entity).expect("flow").2.handle.clone();
1259            let program = &programs.get(&handle).expect("program asset").program;
1260            let mood_idx = program.global_index("mood").expect("mood global");
1261            let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
1262            let report = load_flow_state(&mut globals, &mut ctx, program, &save);
1263            (report, mood_idx)
1264        };
1265
1266        assert!(!report.is_clean(), "report should not be clean: {report:?}");
1267        assert_eq!(report.unknown_globals, vec!["does_not_exist".to_string()]);
1268
1269        // The known entry still applied despite the unknown one.
1270        let mut state: FlowQuery = SystemState::new(app.world_mut());
1271        let (mut flows, mut globals, _programs, _tables, _commands) =
1272            state.get_mut(app.world_mut()).expect("system params");
1273        let (_flow, mut ctx, _p, _l) = flows.get_mut(entity).expect("flow");
1274        let view = flow_context_view(&mut globals, &mut ctx);
1275        assert_eq!(
1276            view.global(mood_idx),
1277            &Value::Int(5),
1278            "the known global should still apply even though another was unknown"
1279        );
1280    }
1281
1282    /// Issue #912 (RULED 2026-07-18, option (b)): the manifest is
1283    /// app-global but `CapabilityRegistry<M>` is per-marker, so a
1284    /// multi-marker app can have one marker whose registry satisfies a
1285    /// story's manifest-required capabilities and another whose registry
1286    /// doesn't — for the *same* story asset, loaded under both markers at
1287    /// once. Loading under the marker that has the capability registered
1288    /// must succeed exactly as the single-marker path always has; loading
1289    /// under the marker that doesn't must be refused outright (no
1290    /// `BrinkFlow` inserted, request removed) rather than silently
1291    /// producing a story with an incomplete capability join.
1292    #[test]
1293    fn per_marker_capability_gate_admits_one_marker_and_rejects_another() {
1294        use bevy_asset::AssetPlugin;
1295        use bevy_ecs::component::Component;
1296
1297        use crate::asset::{LineTablesAsset, fresh_context};
1298        use crate::capability::{
1299            BrinkCapabilityAppExt as _, CapabilityEffects, CapabilityManifest,
1300            CapabilityManifestExternal,
1301        };
1302
1303        #[derive(Component)]
1304        struct Transform;
1305
1306        struct MarkerHasCapability;
1307        struct MarkerMissingCapability;
1308
1309        let mut app = App::new();
1310        app.add_plugins(AssetPlugin::default());
1311        app.add_plugins(crate::BrinkPlugin::<MarkerHasCapability>::default());
1312        app.add_plugins(crate::BrinkPlugin::<MarkerMissingCapability>::default());
1313
1314        // Only the "has" marker ever registers Transform.
1315        app.register_capability::<MarkerHasCapability, Transform>("Transform");
1316
1317        // The manifest is a single, app-global resource — shared by both
1318        // markers, per the ruling.
1319        let mut manifest = CapabilityManifest::default();
1320        manifest.externals.push(CapabilityManifestExternal {
1321            name: "get_position".to_string(),
1322            effects: CapabilityEffects {
1323                reads: vec!["Transform".to_string()],
1324                writes: vec![],
1325                detect: std::collections::BTreeMap::new(),
1326            },
1327        });
1328        app.insert_resource(manifest);
1329
1330        let source = "EXTERNAL get_position(id)\n=== start ===\n\
1331                       ~ temp x = get_position(0)\nHello.\n-> END\n";
1332        let out = brink_compiler::compile("t.ink", move |p| {
1333            if p == "t.ink" {
1334                Ok(source.to_string())
1335            } else {
1336                Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
1337            }
1338        })
1339        .expect("compile");
1340        let mut inkb = Vec::new();
1341        brink_format::write_inkb(&out.data, &mut inkb);
1342        let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
1343        let (program, tables) = brink_runtime::link(&loaded).expect("link");
1344        let initial_context = fresh_context(&program);
1345
1346        let world = app.world_mut();
1347        let program_handle = world
1348            .resource_mut::<Assets<ProgramAsset>>()
1349            .add(ProgramAsset {
1350                program,
1351                initial_context,
1352                effect_rows: loaded.effect_rows,
1353            });
1354        let tables_handle = world
1355            .resource_mut::<Assets<LineTablesAsset>>()
1356            .add(LineTablesAsset { tables });
1357        let story_handle = world
1358            .resource_mut::<Assets<BrinkStoryAsset>>()
1359            .add(BrinkStoryAsset {
1360                program: program_handle,
1361                line_tables: tables_handle,
1362            });
1363
1364        let entity_has = app
1365            .world_mut()
1366            .spawn(
1367                BrinkFlowRequest::<MarkerHasCapability>::builder()
1368                    .story(story_handle.clone())
1369                    .build(),
1370            )
1371            .id();
1372        let entity_missing = app
1373            .world_mut()
1374            .spawn(
1375                BrinkFlowRequest::<MarkerMissingCapability>::builder()
1376                    .story(story_handle)
1377                    .build(),
1378            )
1379            .id();
1380
1381        app.update();
1382
1383        let world = app.world();
1384        assert!(
1385            world
1386                .entity(entity_has)
1387                .contains::<BrinkFlow<MarkerHasCapability>>(),
1388            "marker with Transform registered should load successfully"
1389        );
1390        assert!(
1391            !world
1392                .entity(entity_has)
1393                .contains::<BrinkFlowRequest<MarkerHasCapability>>(),
1394            "fulfilled request should be removed"
1395        );
1396
1397        assert!(
1398            !world
1399                .entity(entity_missing)
1400                .contains::<BrinkFlow<MarkerMissingCapability>>(),
1401            "marker missing Transform must not get a flow — the load must be rejected"
1402        );
1403        assert!(
1404            !world
1405                .entity(entity_missing)
1406                .contains::<BrinkFlowRequest<MarkerMissingCapability>>(),
1407            "the rejected request must be removed too, not left pending forever"
1408        );
1409    }
1410
1411    /// Single-marker apps (the common case, `M = ()`) never insert a
1412    /// `CapabilityManifest` or call `register_capability` — both default
1413    /// to empty via `init_resource`. An empty manifest declares no
1414    /// capabilities for any external, so `missing_capabilities` is always
1415    /// empty and every existing single-marker fulfillment behaves exactly
1416    /// as before this issue's gate was added.
1417    #[test]
1418    fn single_marker_path_with_no_manifest_is_unaffected_by_the_gate() {
1419        let mut app = make_test_app();
1420        let (program, tables, ctx) =
1421            compile_test_story("=== start ===\nhello\n* [Continue] -> END\n");
1422        let story = add_story_assets(&mut app, program, tables, ctx);
1423
1424        let entity = app
1425            .world_mut()
1426            .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1427            .id();
1428
1429        app.update();
1430
1431        assert!(
1432            app.world().entity(entity).contains::<BrinkFlow<()>>(),
1433            "no manifest means no required capabilities, so fulfillment proceeds as before"
1434        );
1435    }
1436}