use std::marker::PhantomData;
use bevy_asset::{AssetEvent, Assets, Handle};
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::message::MessageReader;
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{Commands, Query, Res, ResMut};
use bevy_ecs::world::World;
use bevy_log::{info, warn};
use brink_format::LineEntry;
use brink_runtime::{
ContextAccess, ExternalFnHandler, FastRng, FlowInstance, FlowLocal, ReplayHandler, ReplayMode,
ReplayRecorder, RuntimeError,
};
use crate::asset::{BrinkProgram, BrinkStoryAsset, LineTablesAsset, ProgramAsset};
use crate::capability::{CapabilityManifest, CapabilityRegistry, check_load_capability_gate};
use crate::event::BrinkFlowReset;
use crate::flow::BrinkFlow;
use crate::globals::{BrinkContext, BrinkGlobals, flow_context_view};
use crate::request::FlowStart;
#[derive(Resource, Clone, Copy, Debug, Default)]
pub struct BrinkReplayConfig {
pub query_mode: ReplayMode,
}
#[derive(Component, Clone, Copy, Debug)]
pub struct ReplayQueryModeOverride(pub ReplayMode);
#[derive(Component)]
pub struct BrinkReplayLog<M: Send + Sync + 'static = ()> {
pub start: FlowStart,
pub story: Handle<BrinkStoryAsset>,
pub choices_made: Vec<usize>,
pub recorder: ReplayRecorder,
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> BrinkReplayLog<M> {
pub(crate) fn new(start: FlowStart, story: Handle<BrinkStoryAsset>) -> Self {
Self {
start,
story,
choices_made: Vec::new(),
recorder: ReplayRecorder::new(),
_marker: PhantomData,
}
}
}
#[expect(
clippy::needless_pass_by_value,
clippy::type_complexity,
reason = "bevy systems take Res/Query by value and have complex query tuples"
)]
#[expect(
clippy::too_many_arguments,
reason = "bevy system: flow/program/story/line-table state plus the #997 capability manifest+registry gate"
)]
#[expect(
clippy::too_many_lines,
reason = "bevy system: hot-reload reconstruction plus the #997 capability load-boundary gate"
)]
pub fn replay_on_reload<M: Send + Sync + 'static>(
mut events: MessageReader<AssetEvent<ProgramAsset>>,
mut flows: Query<(
Entity,
&mut BrinkFlow<M>,
&BrinkProgram<M>,
&mut BrinkContext<M>,
&mut BrinkReplayLog<M>,
)>,
globals: Option<ResMut<BrinkGlobals<M>>>,
programs: Res<Assets<ProgramAsset>>,
stories: Res<Assets<BrinkStoryAsset>>,
line_tables_assets: Res<Assets<LineTablesAsset>>,
capability_manifest: Res<CapabilityManifest>,
capability_registry: Res<CapabilityRegistry<M>>,
mut commands: Commands,
) {
let Some(mut globals) = globals else {
return; };
let mut any_modified = false;
for event in events.read() {
if matches!(event, AssetEvent::Modified { .. }) {
any_modified = true;
}
}
if !any_modified {
return;
}
for (entity, mut flow, brink_program, mut context, log) in &mut flows {
let Some(program_asset) = programs.get(&brink_program.handle) else {
continue;
};
let story_ident = log
.story
.path()
.map_or_else(|| format!("{:?}", log.story.id()), ToString::to_string);
if let Err(err) = check_load_capability_gate(
&program_asset.program,
&program_asset.effect_rows,
&capability_manifest,
&capability_registry,
story_ident,
) {
warn!("replay: {err}; leaving entity {entity:?} on its pre-reload flow");
continue;
}
let line_tables: &[Vec<LineEntry>] = match stories
.get(&log.story)
.and_then(|bundle| line_tables_assets.get(&bundle.line_tables))
{
Some(lt_asset) => <_asset.tables,
None => continue,
};
commands.trigger(BrinkFlowReset::<M>::new(entity));
let new_flow_result = match &log.start {
FlowStart::Root => Some(FlowInstance::new_at_root(&program_asset.program)),
FlowStart::Address(name) => program_asset
.program
.find_address(name)
.map(|(idx, _)| FlowInstance::new_at(&program_asset.program, idx)),
};
let Some((new_flow, _fresh_ctx)) = new_flow_result else {
warn!(
"replay: knot '{:?}' missing in reloaded program; entity {entity:?} will start at root",
log.start
);
let (root_flow, _) = FlowInstance::new_at_root(&program_asset.program);
commands
.entity(entity)
.insert(BrinkFlow::<M>::new(root_flow));
continue;
};
context.inner = FlowLocal::new();
flow.inner = new_flow;
let log = log.into_inner();
let replay = ReplayHandler::new(&mut log.recorder);
let mut replay_failed = false;
for (i, &choice_idx) in log.choices_made.iter().enumerate() {
let mut view = flow_context_view(&mut globals, &mut context);
if let Err(err) = step_to_next_choices(
&mut flow.inner,
&program_asset.program,
line_tables,
&mut view,
&replay,
) {
warn!(
"replay: failed to reach choice point {i} for entity {entity:?}: {err}; \
stopping replay"
);
replay_failed = true;
break;
}
let mut view = flow_context_view(&mut globals, &mut context);
if let Err(err) = flow.inner.choose(&mut view, choice_idx) {
warn!(
"replay: choose({choice_idx}) at step {i} for entity {entity:?}: {err}; \
stopping replay"
);
replay_failed = true;
break;
}
}
if replay_failed {
continue;
}
let mut view = flow_context_view(&mut globals, &mut context);
match flow.advance_until_terminal(
&program_asset.program,
line_tables,
&mut view,
&replay,
entity,
&mut commands,
) {
Ok(_) => {
info!(
"replay: rebuilt flow on entity {entity:?} from start={:?} +{} choice(s)",
log.start,
log.choices_made.len()
);
}
Err(err) => {
warn!("replay: advance after replay failed on entity {entity:?}: {err}");
}
}
}
}
fn step_to_next_choices(
flow: &mut FlowInstance,
program: &brink_runtime::Program,
line_tables: &[Vec<LineEntry>],
context: &mut (impl ContextAccess + ?Sized),
handler: &dyn ExternalFnHandler,
) -> Result<(), RuntimeError> {
flow.drive_to_terminal::<FastRng>(program, line_tables, context, handler, None)?;
Ok(())
}
pub(crate) fn take_recorder<M: Send + Sync + 'static>(
world: &mut World,
entity: Entity,
) -> Option<ReplayRecorder> {
world
.get_mut::<BrinkReplayLog<M>>(entity)
.map(|mut log| std::mem::take(&mut log.recorder))
}
pub(crate) fn put_recorder<M: Send + Sync + 'static>(
world: &mut World,
entity: Entity,
recorder: ReplayRecorder,
) {
if let Some(mut log) = world.get_mut::<BrinkReplayLog<M>>(entity) {
log.recorder = recorder;
}
}
pub(crate) fn record_external<M: Send + Sync + 'static>(
world: &mut World,
entity: Entity,
name: &str,
args: &[brink_format::Value],
result: &brink_format::Value,
) {
if let Some(mut log) = world.get_mut::<BrinkReplayLog<M>>(entity) {
log.recorder.record(name, args, result);
}
}
#[cfg(test)]
mod tests {
use bevy_app::App;
use bevy_asset::Assets;
use bevy_ecs::component::Component;
use bevy_ecs::prelude::*;
use crate::capability::{
BrinkCapabilityAppExt as _, CapabilityEffects, CapabilityManifest,
CapabilityManifestExternal,
};
use crate::request::BrinkFlowRequest;
use crate::test_support::{add_story_assets, compile_test_story};
#[derive(Resource, Default)]
struct ResetCount(u32);
fn install_reset_counter(app: &mut App) {
app.insert_resource(ResetCount::default());
app.add_observer(
|_: On<crate::event::BrinkFlowReset<()>>, mut count: ResMut<ResetCount>| {
count.0 += 1;
},
);
}
fn compile_with_effect_rows(
source: &str,
) -> (
brink_runtime::Program,
Vec<Vec<brink_format::LineEntry>>,
Vec<brink_format::EffectRowEntry>,
) {
let source = source.to_string();
let out = brink_compiler::compile("t.ink", move |p| {
if p == "t.ink" {
Ok(source.clone())
} else {
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
}
})
.expect("test fixture should compile");
let mut inkb = Vec::new();
brink_format::write_inkb(&out.data, &mut inkb);
let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
let (program, tables) = brink_runtime::link(&loaded).expect("link");
(program, tables, loaded.effect_rows)
}
fn install_transform_manifest(app: &mut App) {
let mut manifest = CapabilityManifest::default();
manifest.externals.push(CapabilityManifestExternal {
name: "get_position".to_string(),
effects: CapabilityEffects {
reads: vec!["Transform".to_string()],
writes: vec![],
detect: std::collections::BTreeMap::new(),
},
});
app.insert_resource(manifest);
}
#[derive(Component)]
struct Transform;
const V1_SOURCE: &str = "=== start ===\nhello\n-> END\n";
const V2_SOURCE_CALLS_GET_POSITION: &str = "EXTERNAL get_position(id)\n=== start ===\n~ temp x = get_position(0)\nBRAND NEW WORDS\n-> END\n";
#[test]
fn hot_reload_missing_capability_refuses_rebuild() {
let mut app = App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
app.add_plugins(crate::BrinkPlugin::<()>::default());
install_transform_manifest(&mut app);
install_reset_counter(&mut app);
let (program_v1, tables_v1, ctx_v1) = compile_test_story(V1_SOURCE);
let story = add_story_assets(&mut app, program_v1, tables_v1, ctx_v1);
app.world_mut().spawn(
BrinkFlowRequest::<()>::builder()
.story(story.clone())
.build(),
);
app.update();
let (program_v2, tables_v2, effect_rows_v2) =
compile_with_effect_rows(V2_SOURCE_CALLS_GET_POSITION);
let program_handle = {
let stories = app.world().resource::<Assets<crate::BrinkStoryAsset>>();
stories.get(&story).expect("story bundle").program.clone()
};
let line_tables_handle = {
let stories = app.world().resource::<Assets<crate::BrinkStoryAsset>>();
stories
.get(&story)
.expect("story bundle")
.line_tables
.clone()
};
{
let mut programs = app
.world_mut()
.resource_mut::<Assets<crate::asset::ProgramAsset>>();
if let Some(mut slot) = programs.get_mut(&program_handle) {
slot.program = program_v2;
slot.effect_rows = effect_rows_v2;
}
}
{
let mut tables = app
.world_mut()
.resource_mut::<Assets<crate::asset::LineTablesAsset>>();
if let Some(mut slot) = tables.get_mut(&line_tables_handle) {
slot.tables = tables_v2;
}
}
app.update();
app.update();
assert_eq!(
app.world().resource::<ResetCount>().0,
0,
"a reload that would drop below the manifest-required Transform \
capability must be refused before BrinkFlowReset fires — the \
#912 hard-error boundary must hold on the replay path too"
);
}
#[test]
fn hot_reload_with_satisfied_capability_rebuilds_normally() {
let mut app = App::new();
app.add_plugins(bevy_asset::AssetPlugin::default());
app.add_plugins(crate::BrinkPlugin::<()>::default());
install_transform_manifest(&mut app);
install_reset_counter(&mut app);
app.register_capability::<(), Transform>("Transform");
let (program_v1, tables_v1, ctx_v1) = compile_test_story(V1_SOURCE);
let story = add_story_assets(&mut app, program_v1, tables_v1, ctx_v1);
app.world_mut().spawn(
BrinkFlowRequest::<()>::builder()
.story(story.clone())
.build(),
);
app.update();
let (program_v2, tables_v2, effect_rows_v2) =
compile_with_effect_rows(V2_SOURCE_CALLS_GET_POSITION);
let program_handle = {
let stories = app.world().resource::<Assets<crate::BrinkStoryAsset>>();
stories.get(&story).expect("story bundle").program.clone()
};
let line_tables_handle = {
let stories = app.world().resource::<Assets<crate::BrinkStoryAsset>>();
stories
.get(&story)
.expect("story bundle")
.line_tables
.clone()
};
{
let mut programs = app
.world_mut()
.resource_mut::<Assets<crate::asset::ProgramAsset>>();
if let Some(mut slot) = programs.get_mut(&program_handle) {
slot.program = program_v2;
slot.effect_rows = effect_rows_v2;
}
}
{
let mut tables = app
.world_mut()
.resource_mut::<Assets<crate::asset::LineTablesAsset>>();
if let Some(mut slot) = tables.get_mut(&line_tables_handle) {
slot.tables = tables_v2;
}
}
app.update();
app.update();
assert_eq!(
app.world().resource::<ResetCount>().0,
1,
"a reload whose required capabilities are all registered must \
proceed exactly as before — the gate must not block a \
legitimate reload"
);
}
}