use std::collections::{BTreeMap, BTreeSet};
use std::marker::PhantomData;
use bevy_app::App;
use bevy_ecs::change_detection::DetectChangesMut as _;
use bevy_ecs::entity::{Entity, EntityMapper};
use bevy_ecs::event::EntityEvent;
use bevy_ecs::observer::On;
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{Commands, In, Query, Res, ResMut};
use bevy_ecs::world::World;
use bevy_log::warn;
use brink_format::Value;
use brink_runtime::{Program, SaveState};
use serde::Serialize;
use serde::de::DeserializeOwned;
use thiserror::Error;
use crate::asset::{BrinkProgram, ProgramAsset};
use crate::bindings::BrinkQueryInput;
use crate::event::BrinkTurnDone;
use crate::globals::{BrinkContext, BrinkGlobals, save_flow_state};
use bevy_asset::Assets;
pub trait HandleKind: Send + Sync + 'static {
const KIND: &'static str;
type Resource: Send + Sync + 'static;
type SaveKey: Serialize + DeserializeOwned + Clone + Send + Sync + 'static;
fn save_key(&self, world: &World, res: &Self::Resource) -> Option<Self::SaveKey>;
fn resolve(&self, world: &mut World, key: &Self::SaveKey) -> Option<Self::Resource>;
}
#[derive(Resource)]
pub struct HandleRegistry<K: HandleKind> {
implementor: K,
next_id: u64,
live: BTreeMap<u64, K::Resource>,
}
impl<K: HandleKind> HandleRegistry<K> {
#[must_use]
pub fn new(implementor: K) -> Self {
Self {
implementor,
next_id: 0,
live: BTreeMap::new(),
}
}
pub fn mint(&mut self, resource: K::Resource) -> u64 {
let id = self.next_id;
self.next_id += 1;
self.live.insert(id, resource);
id
}
pub fn mint_value(&mut self, program: &Program, resource: K::Resource) -> Option<Value> {
let kind = program.name_id(K::KIND)?;
let id = self.mint(resource);
Some(Value::handle(kind, id))
}
#[must_use]
pub fn get(&self, id: u64) -> Option<&K::Resource> {
self.live.get(&id)
}
#[must_use]
pub fn contains(&self, id: u64) -> bool {
self.live.contains_key(&id)
}
pub fn remove(&mut self, id: u64) -> Option<K::Resource> {
self.live.remove(&id)
}
#[must_use]
pub fn len(&self) -> usize {
self.live.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.live.is_empty()
}
pub fn get_or_dead<M: Send + Sync + 'static>(
&self,
id: u64,
commands: &mut Commands,
flow: Entity,
) -> Option<&K::Resource> {
let found = self.live.get(&id);
if found.is_none() {
commands.trigger(BrinkDeadHandleDeref::<M>::new(flow, K::KIND, id));
}
found
}
}
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
pub struct HandleSaveEntry {
pub id: u64,
pub key: serde_json::Value,
}
#[derive(Debug, Default)]
struct KindRehydrateOutcome {
rebound: Vec<u64>,
dead_by_resolve: Vec<u64>,
}
trait ErasedHandleRegistry: Send + Sync + 'static {
fn kind_name(&self) -> &'static str;
fn is_valid(&self, world: &World, id: u64) -> bool;
fn gc_retain(&self, world: &mut World, keep: &BTreeSet<u64>) -> (usize, usize);
fn snapshot(&self, world: &World) -> Vec<HandleSaveEntry>;
fn rebind_selected(
&self,
world: &mut World,
referenced: &BTreeSet<u64>,
persisted: &[HandleSaveEntry],
) -> KindRehydrateOutcome;
}
struct RegistryOps<K: HandleKind>(PhantomData<fn() -> K>);
impl<K: HandleKind> Default for RegistryOps<K> {
fn default() -> Self {
Self(PhantomData)
}
}
impl<K: HandleKind> ErasedHandleRegistry for RegistryOps<K> {
fn kind_name(&self) -> &'static str {
K::KIND
}
fn is_valid(&self, world: &World, id: u64) -> bool {
world
.get_resource::<HandleRegistry<K>>()
.is_some_and(|reg| reg.contains(id))
}
fn gc_retain(&self, world: &mut World, keep: &BTreeSet<u64>) -> (usize, usize) {
let Some(mut reg) = world.get_resource_mut::<HandleRegistry<K>>() else {
return (0, 0);
};
let before = reg.live.len();
reg.live.retain(|id, _| keep.contains(id));
let after = reg.live.len();
(before - after, after)
}
fn snapshot(&self, world: &World) -> Vec<HandleSaveEntry> {
let Some(reg) = world.get_resource::<HandleRegistry<K>>() else {
return Vec::new();
};
reg.live
.iter()
.filter_map(|(id, resource)| {
let key = reg.implementor.save_key(world, resource)?;
let key = match serde_json::to_value(&key) {
Ok(key) => key,
Err(err) => {
warn!(
"brink: handle kind {:?} id {id} failed to serialize its SaveKey ({err}); omitting from snapshot (will rehydrate as dead_ephemeral)"
, K::KIND
);
return None;
}
};
Some(HandleSaveEntry { id: *id, key })
})
.collect()
}
fn rebind_selected(
&self,
world: &mut World,
referenced: &BTreeSet<u64>,
persisted: &[HandleSaveEntry],
) -> KindRehydrateOutcome {
let mut outcome = KindRehydrateOutcome::default();
let by_id: BTreeMap<u64, &serde_json::Value> =
persisted.iter().map(|e| (e.id, &e.key)).collect();
let reserve_through = referenced
.iter()
.copied()
.chain(persisted.iter().map(|e| e.id))
.max();
world.resource_scope(
|world, mut reg: bevy_ecs::change_detection::Mut<HandleRegistry<K>>| {
if let Some(max_id) = reserve_through {
reg.next_id = reg.next_id.max(max_id + 1);
}
for &id in referenced {
let Some(key_json) = by_id.get(&id) else {
continue;
};
let resolved = match serde_json::from_value::<K::SaveKey>((*key_json).clone())
{
Ok(key) => reg.implementor.resolve(world, &key),
Err(err) => {
warn!(
"brink: handle kind {:?} id {id} failed to deserialize its persisted SaveKey ({err}); treating as dead_by_resolve"
, K::KIND
);
None
}
};
match resolved {
Some(resource) => {
reg.live.insert(id, resource);
outcome.rebound.push(id);
}
None => outcome.dead_by_resolve.push(id),
}
}
},
);
outcome
}
}
#[derive(Resource)]
pub struct HandleKinds<M: Send + Sync + 'static = ()> {
kinds: BTreeMap<&'static str, Box<dyn ErasedHandleRegistry>>,
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> Default for HandleKinds<M> {
fn default() -> Self {
Self {
kinds: BTreeMap::new(),
_marker: PhantomData,
}
}
}
impl<M: Send + Sync + 'static> HandleKinds<M> {
pub fn kind_names(&self) -> impl Iterator<Item = &'static str> + '_ {
self.kinds.keys().copied()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.kinds.is_empty()
}
}
pub trait BrinkHandleAppExt {
fn register_handle_kind<M: Send + Sync + 'static, K: HandleKind>(
&mut self,
implementor: K,
) -> &mut Self;
}
impl BrinkHandleAppExt for App {
fn register_handle_kind<M: Send + Sync + 'static, K: HandleKind>(
&mut self,
implementor: K,
) -> &mut Self {
self.world_mut()
.insert_resource(HandleRegistry::<K>::new(implementor));
self.world_mut()
.get_resource_or_insert_with(HandleKinds::<M>::default);
self.world_mut()
.resource_mut::<HandleKinds<M>>()
.kinds
.insert(K::KIND, Box::new(RegistryOps::<K>::default()));
self
}
}
#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
pub struct HandleSaveState {
pub entries: BTreeMap<String, Vec<HandleSaveEntry>>,
}
#[must_use]
pub fn save_handles<M: Send + Sync + 'static>(world: &World) -> HandleSaveState {
let mut out = HandleSaveState::default();
if let Some(kinds) = world.get_resource::<HandleKinds<M>>() {
for ops in kinds.kinds.values() {
let entries = ops.snapshot(world);
if !entries.is_empty() {
out.entries.insert(ops.kind_name().to_string(), entries);
}
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RehydrationPolicy {
#[default]
Lenient,
StrictKinds,
}
#[derive(Debug, Clone, Default)]
pub struct RehydrationReport {
pub rebound: Vec<(String, u64)>,
pub dead_by_resolve: Vec<(String, u64)>,
pub dead_ephemeral: Vec<(String, u64)>,
pub dead_by_unregistered_kind: Vec<(String, u64)>,
}
impl RehydrationReport {
#[must_use]
pub fn is_fully_rebound(&self) -> bool {
self.dead_by_resolve.is_empty()
&& self.dead_ephemeral.is_empty()
&& self.dead_by_unregistered_kind.is_empty()
}
}
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum HandleLoadError {
#[error("unregistered handle kind(s) at load: {0:?}")]
UnregisteredKinds(Vec<String>),
}
pub fn load_handles<M: Send + Sync + 'static>(
world: &mut World,
program: &Program,
referenced: &SaveState,
persisted: &HandleSaveState,
policy: RehydrationPolicy,
) -> Result<RehydrationReport, HandleLoadError> {
let mut by_kind: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
collect_from_save_state(referenced, program, &mut by_kind);
let registered: BTreeSet<&str> = world
.get_resource::<HandleKinds<M>>()
.map(|kinds| kinds.kind_names().collect())
.unwrap_or_default();
if policy == RehydrationPolicy::StrictKinds {
let unregistered: Vec<String> = by_kind
.keys()
.filter(|k| !registered.contains(k.as_str()))
.cloned()
.collect();
if !unregistered.is_empty() {
return Err(HandleLoadError::UnregisteredKinds(unregistered));
}
}
world.get_resource_or_insert_with(HandleEntityRemap::default);
if let Some(mut remap) = world.get_resource_mut::<HandleEntityRemap>() {
remap.clear();
}
let mut report = RehydrationReport::default();
world.resource_scope::<HandleKinds<M>, _>(|world, kinds| {
for (kind_name, ids) in &by_kind {
let Some(ops) = kinds.kinds.get(kind_name.as_str()) else {
report
.dead_by_unregistered_kind
.extend(ids.iter().map(|id| (kind_name.clone(), *id)));
continue;
};
let persisted_for_kind = persisted
.entries
.get(kind_name)
.map_or(&[][..], Vec::as_slice);
let by_persisted_id: BTreeSet<u64> = persisted_for_kind.iter().map(|e| e.id).collect();
let outcome = ops.rebind_selected(world, ids, persisted_for_kind);
report.rebound.extend(
outcome
.rebound
.into_iter()
.map(|id| (kind_name.clone(), id)),
);
report.dead_by_resolve.extend(
outcome
.dead_by_resolve
.into_iter()
.map(|id| (kind_name.clone(), id)),
);
report.dead_ephemeral.extend(
ids.iter()
.filter(|id| !by_persisted_id.contains(id))
.map(|id| (kind_name.clone(), *id)),
);
}
});
Ok(report)
}
fn collect_handles(value: &Value, program: &Program, out: &mut BTreeMap<String, BTreeSet<u64>>) {
match value {
Value::Handle { kind, id } => {
if let Some(name) = program.name_checked(*kind) {
out.entry(name.to_string()).or_default().insert(*id);
}
}
Value::Array(items) => {
for v in items.iter() {
collect_handles(v, program, out);
}
}
Value::Map(map) => {
for v in map.values() {
collect_handles(v, program, out);
}
}
Value::Record { fields, .. } => {
for v in fields.iter() {
collect_handles(v, program, out);
}
}
Value::Closure(closure) => {
for entry in &closure.env {
collect_handles(&entry.payload, program, out);
}
}
_ => {}
}
}
fn collect_from_save_state(
save: &SaveState,
program: &Program,
out: &mut BTreeMap<String, BTreeSet<u64>>,
) {
for value in save.globals.values() {
collect_handles(value, program, out);
}
}
#[derive(Resource, Default, Debug)]
pub struct HandleEntityRemap {
map: BTreeMap<Entity, Entity>,
}
impl HandleEntityRemap {
pub fn clear(&mut self) {
self.map.clear();
}
}
impl EntityMapper for HandleEntityRemap {
fn get_mapped(&mut self, source: Entity) -> Entity {
self.map.get(&source).copied().unwrap_or(source)
}
fn set_mapped(&mut self, source: Entity, target: Entity) {
self.map.insert(source, target);
}
}
#[derive(EntityEvent)]
pub struct BrinkDeadHandleDeref<M: Send + Sync + 'static = ()> {
pub entity: Entity,
pub kind: &'static str,
pub id: u64,
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> BrinkDeadHandleDeref<M> {
pub(crate) fn new(entity: Entity, kind: &'static str, id: u64) -> Self {
Self {
entity,
kind,
id,
_marker: PhantomData,
}
}
}
pub fn is_valid_system<M: Send + Sync + 'static>(
In((entity, args)): In<BrinkQueryInput>,
world: &World,
) -> Value {
let Some((kind, id)) = args.first().and_then(Value::as_handle) else {
return Value::Bool(false);
};
let Some(program_component) = world.get::<BrinkProgram<M>>(entity) else {
return Value::Bool(false);
};
let Some(program) = world
.get_resource::<Assets<ProgramAsset>>()
.and_then(|assets| assets.get(&program_component.handle))
else {
return Value::Bool(false);
};
let Some(kind_name) = program.program.name_checked(kind) else {
return Value::Bool(false);
};
let Some(kinds) = world.get_resource::<HandleKinds<M>>() else {
return Value::Bool(false);
};
let Some(ops) = kinds.kinds.get(kind_name) else {
return Value::Bool(false);
};
Value::Bool(ops.is_valid(world, id))
}
#[derive(Debug, Clone, Default)]
pub struct KindRetention {
pub live: usize,
pub last_gc_dropped: usize,
pub sweeps: u64,
}
#[derive(Resource, Debug, Clone)]
pub struct HandleRetentionMetrics<M: Send + Sync + 'static = ()> {
pub per_kind: BTreeMap<String, KindRetention>,
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> Default for HandleRetentionMetrics<M> {
fn default() -> Self {
Self {
per_kind: BTreeMap::new(),
_marker: PhantomData,
}
}
}
impl<M: Send + Sync + 'static> HandleRetentionMetrics<M> {
fn record(&mut self, kind: &str, dropped: usize, live: usize) {
let entry = self.per_kind.entry(kind.to_string()).or_default();
entry.live = live;
entry.last_gc_dropped = dropped;
entry.sweeps += 1;
}
}
fn sweep_registries<M: Send + Sync + 'static>(
world: &mut World,
reachable: &BTreeMap<String, BTreeSet<u64>>,
) {
let empty = BTreeSet::new();
world.resource_scope::<HandleKinds<M>, _>(|world, kinds| {
for ops in kinds.kinds.values() {
let keep = reachable.get(ops.kind_name()).unwrap_or(&empty);
let (dropped, live) = ops.gc_retain(world, keep);
if let Some(mut metrics) = world.get_resource_mut::<HandleRetentionMetrics<M>>() {
metrics.record(ops.kind_name(), dropped, live);
}
}
});
}
#[expect(
clippy::needless_pass_by_value,
reason = "bevy systems take Res params by value"
)]
pub fn gc_on_turn_done<M: Send + Sync + 'static>(
_on: On<BrinkTurnDone<M>>,
kinds: Option<Res<HandleKinds<M>>>,
mut globals: Option<ResMut<BrinkGlobals<M>>>,
programs: Res<Assets<ProgramAsset>>,
mut contexts: Query<(&BrinkProgram<M>, &mut BrinkContext<M>)>,
mut commands: Commands,
) {
if kinds.is_none_or(|k| k.is_empty()) {
return;
}
let Some(globals) = globals.as_mut() else {
return;
};
let globals = globals.bypass_change_detection();
let mut reachable: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
for (program_component, mut ctx) in &mut contexts {
let Some(program_asset) = programs.get(&program_component.handle) else {
continue;
};
let state = save_flow_state(globals, &mut ctx, &program_asset.program);
collect_from_save_state(&state, &program_asset.program, &mut reachable);
}
commands.queue(move |world: &mut World| {
sweep_registries::<M>(world, &reachable);
});
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet as Set;
use bevy_ecs::system::RunSystemOnce as _;
use brink_format::SaveState;
use brink_runtime::ContextAccess as _;
use serde::{Deserialize, Serialize};
use super::*;
use crate::BrinkFlowRequest;
use crate::bindings::advance_flow;
use crate::test_support::{add_story_assets, compile_test_story, make_test_app};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
struct TimerSaveKey {
remaining_secs: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct TimerState {
remaining_secs: f32,
}
struct TimerKind;
impl HandleKind for TimerKind {
const KIND: &'static str = "Timer";
type Resource = TimerState;
type SaveKey = TimerSaveKey;
fn save_key(&self, _world: &World, res: &Self::Resource) -> Option<Self::SaveKey> {
Some(TimerSaveKey {
remaining_secs: res.remaining_secs,
})
}
fn resolve(&self, _world: &mut World, key: &Self::SaveKey) -> Option<Self::Resource> {
Some(TimerState {
remaining_secs: key.remaining_secs,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct NpcSaveKey {
guid: String,
}
#[derive(Debug, Clone, PartialEq)]
struct NpcState {
guid: String,
}
struct NpcKind;
impl HandleKind for NpcKind {
const KIND: &'static str = "Npc";
type Resource = NpcState;
type SaveKey = NpcSaveKey;
fn save_key(&self, _world: &World, res: &Self::Resource) -> Option<Self::SaveKey> {
Some(NpcSaveKey {
guid: res.guid.clone(),
})
}
fn resolve(&self, world: &mut World, key: &Self::SaveKey) -> Option<Self::Resource> {
let alive = world.get_resource::<AliveNpcs>()?;
alive.0.contains(&key.guid).then(|| NpcState {
guid: key.guid.clone(),
})
}
}
#[derive(Resource, Default)]
struct AliveNpcs(Set<String>);
struct TransientKind;
impl HandleKind for TransientKind {
const KIND: &'static str = "Transient";
type Resource = ();
type SaveKey = ();
fn save_key(&self, _world: &World, (): &Self::Resource) -> Option<Self::SaveKey> {
None
}
fn resolve(&self, _world: &mut World, (): &Self::SaveKey) -> Option<Self::Resource> {
Some(())
}
}
fn empty_save_state() -> SaveState {
SaveState {
version: brink_runtime::SAVE_FORMAT_VERSION,
globals: BTreeMap::new(),
visits: Vec::new(),
turns: Vec::new(),
turn_index: 0,
rng_seed: 0,
previous_random: 0,
global_ids: BTreeMap::new(),
suspended: None,
}
}
fn referencing(global: &str, value: Value) -> SaveState {
let mut save = empty_save_state();
save.globals.insert(global.to_string(), value);
save
}
#[test]
fn mint_and_get_roundtrip() {
let mut reg = HandleRegistry::<TimerKind>::new(TimerKind);
let id = reg.mint(TimerState {
remaining_secs: 3.0,
});
assert_eq!(
reg.get(id),
Some(&TimerState {
remaining_secs: 3.0
})
);
assert!(reg.contains(id));
assert_eq!(reg.len(), 1);
}
#[test]
fn mint_value_none_when_kind_never_interned() {
let (program, tables, ctx) = compile_test_story("Hi.\n-> DONE\n");
let mut app = make_test_app();
add_story_assets(&mut app, program, tables, ctx);
let program = &app
.world()
.resource::<Assets<ProgramAsset>>()
.iter()
.next()
.expect("one program asset")
.1
.program;
let mut reg = HandleRegistry::<TimerKind>::new(TimerKind);
assert!(
reg.mint_value(
program,
TimerState {
remaining_secs: 1.0
}
)
.is_none()
);
}
#[test]
fn mint_value_resolves_interned_kind_name() {
let (program, tables, ctx) = compile_test_story("VAR Timer = 0\nHi.\n-> DONE\n");
let mut reg = HandleRegistry::<TimerKind>::new(TimerKind);
let value = reg
.mint_value(
&program,
TimerState {
remaining_secs: 5.0,
},
)
.expect("Timer was interned via the VAR declaration");
let (kind, _id) = value.as_handle().expect("a handle value");
assert_eq!(program.name_checked(kind), Some("Timer"));
drop(tables);
drop(ctx);
}
#[test]
fn is_valid_true_for_live_registered_handle() {
let (program, tables, ctx) = compile_test_story("VAR Timer = 0\nHi.\n-> DONE\n");
let mut app = make_test_app();
app.register_handle_kind::<(), TimerKind>(TimerKind);
let story = add_story_assets(&mut app, program, tables, ctx);
let entity = app
.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build())
.id();
app.update();
let handle_value = {
let world = app.world_mut();
let program = &world
.resource::<Assets<ProgramAsset>>()
.iter()
.next()
.expect("program asset")
.1
.program;
let kind = program.name_id("Timer").expect("interned");
let mut reg = world.resource_mut::<HandleRegistry<TimerKind>>();
Value::handle(
kind,
reg.mint(TimerState {
remaining_secs: 2.0,
}),
)
};
let result = app
.world_mut()
.run_system_once_with(is_valid_system::<()>, (entity, vec![handle_value]))
.expect("is_valid runs");
assert_eq!(result, Value::Bool(true));
}
#[test]
fn is_valid_false_for_dead_or_non_handle() {
let (program, tables, ctx) = compile_test_story("VAR Timer = 0\nHi.\n-> DONE\n");
let mut app = make_test_app();
app.register_handle_kind::<(), TimerKind>(TimerKind);
let story = add_story_assets(&mut app, program, tables, ctx);
let entity = app
.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build())
.id();
app.update();
let result = app
.world_mut()
.run_system_once_with(is_valid_system::<()>, (entity, vec![Value::Int(1)]))
.expect("is_valid runs");
assert_eq!(result, Value::Bool(false));
let kind = {
let world = app.world_mut();
let program = &world
.resource::<Assets<ProgramAsset>>()
.iter()
.next()
.expect("program asset")
.1
.program;
program.name_id("Timer").expect("interned")
};
let result = app
.world_mut()
.run_system_once_with(
is_valid_system::<()>,
(entity, vec![Value::handle(kind, 9999)]),
)
.expect("is_valid runs");
assert_eq!(result, Value::Bool(false));
}
#[test]
fn save_resolve_live() {
let (program, _tables, _ctx) =
compile_test_story("VAR npc_ref = 0\nVAR Npc = 0\nHi.\n-> DONE\n");
let mut world = World::new();
world.insert_resource(HandleKinds::<()>::default());
world.insert_resource(AliveNpcs(Set::from(["abc".to_string()])));
world.get_resource_or_insert_with(HandleKinds::<()>::default);
world.insert_resource(HandleRegistry::<NpcKind>::new(NpcKind));
world
.resource_mut::<HandleKinds<()>>()
.kinds
.insert(NpcKind::KIND, Box::new(RegistryOps::<NpcKind>::default()));
let kind = program.name_id("Npc").expect("interned");
let id = world
.resource_mut::<HandleRegistry<NpcKind>>()
.mint(NpcState {
guid: "abc".to_string(),
});
let persisted = save_handles::<()>(&world);
assert_eq!(persisted.entries["Npc"].len(), 1);
let referenced = referencing("npc_ref", Value::handle(kind, id));
let report = load_handles::<()>(
&mut world,
&program,
&referenced,
&persisted,
RehydrationPolicy::Lenient,
)
.expect("lenient load never errors");
assert_eq!(report.rebound, vec![("Npc".to_string(), id)]);
assert!(report.is_fully_rebound());
assert_eq!(
world.resource::<HandleRegistry<NpcKind>>().get(id),
Some(&NpcState {
guid: "abc".to_string()
})
);
}
#[test]
fn save_despawn_load_dead_declared_fallback() {
let (program, _tables, _ctx) =
compile_test_story("VAR npc_ref = 0\nVAR Npc = 0\nHi.\n-> DONE\n");
let mut world = World::new();
world.insert_resource(HandleKinds::<()>::default());
world.insert_resource(AliveNpcs(Set::from(["abc".to_string()])));
world.insert_resource(HandleRegistry::<NpcKind>::new(NpcKind));
world
.resource_mut::<HandleKinds<()>>()
.kinds
.insert(NpcKind::KIND, Box::new(RegistryOps::<NpcKind>::default()));
let kind = program.name_id("Npc").expect("interned");
let id = world
.resource_mut::<HandleRegistry<NpcKind>>()
.mint(NpcState {
guid: "abc".to_string(),
});
let persisted = save_handles::<()>(&world);
world.resource_mut::<AliveNpcs>().0.clear();
world.resource_mut::<HandleRegistry<NpcKind>>().remove(id);
let referenced = referencing("npc_ref", Value::handle(kind, id));
let report = load_handles::<()>(
&mut world,
&program,
&referenced,
&persisted,
RehydrationPolicy::Lenient,
)
.expect("lenient load never errors");
assert_eq!(report.dead_by_resolve, vec![("Npc".to_string(), id)]);
assert!(!report.is_fully_rebound());
assert_eq!(world.resource::<HandleRegistry<NpcKind>>().get(id), None);
let declared_fallback = world
.resource::<HandleRegistry<NpcKind>>()
.get(id)
.map_or(Value::Int(-1), |_| Value::Int(0));
assert_eq!(declared_fallback, Value::Int(-1));
}
#[test]
fn timer_reconstruction_after_restart() {
let (program, _tables, _ctx) =
compile_test_story("VAR timer_ref = 0\nVAR Timer = 0\nHi.\n-> DONE\n");
let mut world = World::new();
world.insert_resource(HandleKinds::<()>::default());
world.insert_resource(HandleRegistry::<TimerKind>::new(TimerKind));
world.resource_mut::<HandleKinds<()>>().kinds.insert(
TimerKind::KIND,
Box::new(RegistryOps::<TimerKind>::default()),
);
let kind = program.name_id("Timer").expect("interned");
let id = world
.resource_mut::<HandleRegistry<TimerKind>>()
.mint(TimerState {
remaining_secs: 12.5,
});
let persisted = save_handles::<()>(&world);
assert_eq!(
persisted.entries["Timer"][0].key,
serde_json::json!({ "remaining_secs": 12.5 })
);
world.resource_mut::<HandleRegistry<TimerKind>>().remove(id);
let referenced = referencing("timer_ref", Value::handle(kind, id));
let report = load_handles::<()>(
&mut world,
&program,
&referenced,
&persisted,
RehydrationPolicy::Lenient,
)
.expect("lenient load never errors");
assert_eq!(report.rebound, vec![("Timer".to_string(), id)]);
assert_eq!(
world.resource::<HandleRegistry<TimerKind>>().get(id),
Some(&TimerState {
remaining_secs: 12.5
})
);
}
#[test]
fn dead_ephemeral_when_kind_declines_to_persist() {
let (program, _tables, _ctx) =
compile_test_story("VAR t_ref = 0\nVAR Transient = 0\nHi.\n-> DONE\n");
let mut world = World::new();
world.insert_resource(HandleKinds::<()>::default());
world.insert_resource(HandleRegistry::<TransientKind>::new(TransientKind));
world.resource_mut::<HandleKinds<()>>().kinds.insert(
TransientKind::KIND,
Box::new(RegistryOps::<TransientKind>::default()),
);
let kind = program.name_id("Transient").expect("interned");
let id = world
.resource_mut::<HandleRegistry<TransientKind>>()
.mint(());
let persisted = save_handles::<()>(&world);
assert!(!persisted.entries.contains_key("Transient"));
let referenced = referencing("t_ref", Value::handle(kind, id));
let report = load_handles::<()>(
&mut world,
&program,
&referenced,
&persisted,
RehydrationPolicy::Lenient,
)
.expect("lenient load never errors");
assert_eq!(report.dead_ephemeral, vec![("Transient".to_string(), id)]);
assert!(report.rebound.is_empty());
assert!(report.dead_by_resolve.is_empty());
}
#[test]
fn mint_after_load_does_not_collide_with_dead_by_resolve_token() {
let (program, _tables, _ctx) =
compile_test_story("VAR npc_ref = 0\nVAR Npc = 0\nHi.\n-> DONE\n");
let mut world = World::new();
world.insert_resource(HandleKinds::<()>::default());
world.insert_resource(AliveNpcs(Set::from(["abc".to_string()])));
world.insert_resource(HandleRegistry::<NpcKind>::new(NpcKind));
world
.resource_mut::<HandleKinds<()>>()
.kinds
.insert(NpcKind::KIND, Box::new(RegistryOps::<NpcKind>::default()));
let kind = program.name_id("Npc").expect("interned");
let dead_id = world
.resource_mut::<HandleRegistry<NpcKind>>()
.mint(NpcState {
guid: "abc".to_string(),
});
assert_eq!(dead_id, 0);
let persisted = save_handles::<()>(&world);
world.resource_mut::<AliveNpcs>().0.clear();
world
.resource_mut::<HandleRegistry<NpcKind>>()
.remove(dead_id);
let referenced = referencing("npc_ref", Value::handle(kind, dead_id));
let report = load_handles::<()>(
&mut world,
&program,
&referenced,
&persisted,
RehydrationPolicy::Lenient,
)
.expect("lenient load never errors");
assert_eq!(report.dead_by_resolve, vec![("Npc".to_string(), dead_id)]);
let minted_id = world
.resource_mut::<HandleRegistry<NpcKind>>()
.mint(NpcState {
guid: "def".to_string(),
});
assert_ne!(
minted_id, dead_id,
"mint after load must not collide with a dead-by-resolve token's id \
still referenced by ink state"
);
}
#[test]
fn mint_after_load_does_not_collide_with_dead_ephemeral_token() {
let (program, _tables, _ctx) =
compile_test_story("VAR t_ref = 0\nVAR Transient = 0\nHi.\n-> DONE\n");
let mut world = World::new();
world.insert_resource(HandleKinds::<()>::default());
world.insert_resource(HandleRegistry::<TransientKind>::new(TransientKind));
world.resource_mut::<HandleKinds<()>>().kinds.insert(
TransientKind::KIND,
Box::new(RegistryOps::<TransientKind>::default()),
);
let kind = program.name_id("Transient").expect("interned");
let dead_id = world
.resource_mut::<HandleRegistry<TransientKind>>()
.mint(());
assert_eq!(dead_id, 0);
let persisted = save_handles::<()>(&world);
assert!(!persisted.entries.contains_key("Transient"));
let referenced = referencing("t_ref", Value::handle(kind, dead_id));
let report = load_handles::<()>(
&mut world,
&program,
&referenced,
&persisted,
RehydrationPolicy::Lenient,
)
.expect("lenient load never errors");
assert_eq!(
report.dead_ephemeral,
vec![("Transient".to_string(), dead_id)]
);
let minted_id = world
.resource_mut::<HandleRegistry<TransientKind>>()
.mint(());
assert_ne!(
minted_id, dead_id,
"mint after load must not collide with a dead-ephemeral token's id \
still referenced by ink state"
);
}
#[test]
fn unregistered_kind_lenient_reports_strict_fails() {
let (program, _tables, _ctx) =
compile_test_story("VAR ghost_ref = 0\nVAR Ghost = 0\nHi.\n-> DONE\n");
let kind = program.name_id("Ghost").expect("interned");
let referenced = referencing("ghost_ref", Value::handle(kind, 7));
let persisted = HandleSaveState::default();
let mut lenient_world = World::new();
lenient_world.insert_resource(HandleKinds::<()>::default());
let report = load_handles::<()>(
&mut lenient_world,
&program,
&referenced,
&persisted,
RehydrationPolicy::Lenient,
)
.expect("lenient never errors, even for unregistered kinds");
assert_eq!(
report.dead_by_unregistered_kind,
vec![("Ghost".to_string(), 7)]
);
let mut strict_world = World::new();
strict_world.insert_resource(HandleKinds::<()>::default());
let err = load_handles::<()>(
&mut strict_world,
&program,
&referenced,
&persisted,
RehydrationPolicy::StrictKinds,
)
.expect_err("StrictKinds fails loudly on an unregistered kind");
assert_eq!(
err,
HandleLoadError::UnregisteredKinds(vec!["Ghost".to_string()])
);
}
#[test]
fn sweep_drops_unreachable_keeps_reachable() {
let mut world = World::new();
world.insert_resource(HandleKinds::<()>::default());
world.insert_resource(HandleRetentionMetrics::<()>::default());
world.insert_resource(HandleRegistry::<TimerKind>::new(TimerKind));
world.resource_mut::<HandleKinds<()>>().kinds.insert(
TimerKind::KIND,
Box::new(RegistryOps::<TimerKind>::default()),
);
let (reachable_id, orphan_id) = {
let mut reg = world.resource_mut::<HandleRegistry<TimerKind>>();
(
reg.mint(TimerState {
remaining_secs: 1.0,
}),
reg.mint(TimerState {
remaining_secs: 2.0,
}),
)
};
let mut reachable = BTreeMap::new();
reachable.insert("Timer".to_string(), Set::from([reachable_id]));
sweep_registries::<()>(&mut world, &reachable);
let reg = world.resource::<HandleRegistry<TimerKind>>();
assert!(reg.contains(reachable_id));
assert!(!reg.contains(orphan_id));
let metrics = world.resource::<HandleRetentionMetrics<()>>();
let timer = &metrics.per_kind["Timer"];
assert_eq!(timer.live, 1);
assert_eq!(timer.last_gc_dropped, 1);
assert_eq!(timer.sweeps, 1);
}
#[test]
fn gc_on_turn_done_is_wired_by_the_plugin() {
let (program, tables, ctx) =
compile_test_story("VAR target = 0\nVAR Timer = 0\nHi.\n-> DONE\n");
let mut app = make_test_app();
app.register_handle_kind::<(), TimerKind>(TimerKind);
let story = add_story_assets(&mut app, program, tables, ctx);
let entity = app
.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build())
.id();
app.update();
let (kind, target_idx) = {
let world = app.world();
let program = &world
.resource::<Assets<ProgramAsset>>()
.iter()
.next()
.expect("program asset")
.1
.program;
(
program.name_id("Timer").expect("interned"),
program.global_index("target").expect("declared"),
)
};
let (reachable_id, orphan_id) = {
let mut reg = app.world_mut().resource_mut::<HandleRegistry<TimerKind>>();
(
reg.mint(TimerState {
remaining_secs: 1.0,
}),
reg.mint(TimerState {
remaining_secs: 2.0,
}),
)
};
app.world_mut()
.resource_mut::<BrinkGlobals<()>>()
.inner
.set_global(target_idx, Value::handle(kind, reachable_id));
{
let world = app.world_mut();
loop {
let step = advance_flow::<()>(world, entity).expect("advances to -> DONE");
if step.is_terminal() {
break;
}
}
world.flush();
}
app.update();
let world = app.world();
let reg = world.resource::<HandleRegistry<TimerKind>>();
assert!(
reg.contains(reachable_id),
"reachable token must survive the sweep"
);
assert!(
!reg.contains(orphan_id),
"unreferenced token must be dropped by the -> DONE sweep"
);
}
#[test]
fn handle_kinds_is_empty_tracks_registration() {
let mut app = make_test_app();
assert!(
app.world().resource::<HandleKinds<()>>().is_empty(),
"no kind registered → empty",
);
app.register_handle_kind::<(), TimerKind>(TimerKind);
assert!(
!app.world().resource::<HandleKinds<()>>().is_empty(),
"after register_handle_kind → non-empty",
);
}
#[test]
fn gc_on_turn_done_skips_scan_when_no_kinds_registered() {
let (program, tables, ctx) = compile_test_story("Hi.\n-> DONE\n");
let mut app = make_test_app();
let story = add_story_assets(&mut app, program, tables, ctx);
let entity = app
.world_mut()
.spawn(BrinkFlowRequest::<()>::builder().story(story).build())
.id();
app.update();
assert!(
app.world().resource::<HandleKinds<()>>().is_empty(),
"gate precondition: no kind registered",
);
{
let world = app.world_mut();
loop {
let step = advance_flow::<()>(world, entity).expect("advances to -> DONE");
if step.is_terminal() {
break;
}
}
world.flush();
}
app.update();
assert!(
app.world()
.resource::<HandleRetentionMetrics<()>>()
.per_kind
.is_empty(),
"handle-free -> DONE must record no retention metrics (gate skipped the sweep)",
);
}
}