1use std::collections::{BTreeMap, BTreeSet};
30use std::marker::PhantomData;
31
32use bevy_app::App;
33use bevy_ecs::change_detection::DetectChangesMut as _;
34use bevy_ecs::entity::{Entity, EntityMapper};
35use bevy_ecs::event::EntityEvent;
36use bevy_ecs::observer::On;
37use bevy_ecs::resource::Resource;
38use bevy_ecs::system::{Commands, In, Query, Res, ResMut};
39use bevy_ecs::world::World;
40use bevy_log::warn;
41use brink_format::Value;
42use brink_runtime::{Program, SaveState};
43use serde::Serialize;
44use serde::de::DeserializeOwned;
45use thiserror::Error;
46
47use crate::asset::{BrinkProgram, ProgramAsset};
48use crate::bindings::BrinkQueryInput;
49use crate::event::BrinkTurnDone;
50use crate::globals::{BrinkContext, BrinkGlobals, save_flow_state};
51use bevy_asset::Assets;
52
53pub trait HandleKind: Send + Sync + 'static {
68 const KIND: &'static str;
70 type Resource: Send + Sync + 'static;
72 type SaveKey: Serialize + DeserializeOwned + Clone + Send + Sync + 'static;
75
76 fn save_key(&self, world: &World, res: &Self::Resource) -> Option<Self::SaveKey>;
80
81 fn resolve(&self, world: &mut World, key: &Self::SaveKey) -> Option<Self::Resource>;
86}
87
88#[derive(Resource)]
97pub struct HandleRegistry<K: HandleKind> {
98 implementor: K,
99 next_id: u64,
100 live: BTreeMap<u64, K::Resource>,
101}
102
103impl<K: HandleKind> HandleRegistry<K> {
104 #[must_use]
105 pub fn new(implementor: K) -> Self {
106 Self {
107 implementor,
108 next_id: 0,
109 live: BTreeMap::new(),
110 }
111 }
112
113 pub fn mint(&mut self, resource: K::Resource) -> u64 {
115 let id = self.next_id;
116 self.next_id += 1;
117 self.live.insert(id, resource);
118 id
119 }
120
121 pub fn mint_value(&mut self, program: &Program, resource: K::Resource) -> Option<Value> {
127 let kind = program.name_id(K::KIND)?;
128 let id = self.mint(resource);
129 Some(Value::handle(kind, id))
130 }
131
132 #[must_use]
133 pub fn get(&self, id: u64) -> Option<&K::Resource> {
134 self.live.get(&id)
135 }
136
137 #[must_use]
138 pub fn contains(&self, id: u64) -> bool {
139 self.live.contains_key(&id)
140 }
141
142 pub fn remove(&mut self, id: u64) -> Option<K::Resource> {
143 self.live.remove(&id)
144 }
145
146 #[must_use]
147 pub fn len(&self) -> usize {
148 self.live.len()
149 }
150
151 #[must_use]
152 pub fn is_empty(&self) -> bool {
153 self.live.is_empty()
154 }
155
156 pub fn get_or_dead<M: Send + Sync + 'static>(
160 &self,
161 id: u64,
162 commands: &mut Commands,
163 flow: Entity,
164 ) -> Option<&K::Resource> {
165 let found = self.live.get(&id);
166 if found.is_none() {
167 commands.trigger(BrinkDeadHandleDeref::<M>::new(flow, K::KIND, id));
168 }
169 found
170 }
171}
172
173#[derive(Debug, Clone, Serialize, serde::Deserialize)]
178pub struct HandleSaveEntry {
179 pub id: u64,
180 pub key: serde_json::Value,
181}
182
183#[derive(Debug, Default)]
186struct KindRehydrateOutcome {
187 rebound: Vec<u64>,
188 dead_by_resolve: Vec<u64>,
189}
190
191trait ErasedHandleRegistry: Send + Sync + 'static {
197 fn kind_name(&self) -> &'static str;
198 fn is_valid(&self, world: &World, id: u64) -> bool;
199 fn gc_retain(&self, world: &mut World, keep: &BTreeSet<u64>) -> (usize, usize);
202 fn snapshot(&self, world: &World) -> Vec<HandleSaveEntry>;
206 fn rebind_selected(
213 &self,
214 world: &mut World,
215 referenced: &BTreeSet<u64>,
216 persisted: &[HandleSaveEntry],
217 ) -> KindRehydrateOutcome;
218}
219
220struct RegistryOps<K: HandleKind>(PhantomData<fn() -> K>);
221
222impl<K: HandleKind> Default for RegistryOps<K> {
223 fn default() -> Self {
224 Self(PhantomData)
225 }
226}
227
228impl<K: HandleKind> ErasedHandleRegistry for RegistryOps<K> {
229 fn kind_name(&self) -> &'static str {
230 K::KIND
231 }
232
233 fn is_valid(&self, world: &World, id: u64) -> bool {
234 world
235 .get_resource::<HandleRegistry<K>>()
236 .is_some_and(|reg| reg.contains(id))
237 }
238
239 fn gc_retain(&self, world: &mut World, keep: &BTreeSet<u64>) -> (usize, usize) {
240 let Some(mut reg) = world.get_resource_mut::<HandleRegistry<K>>() else {
241 return (0, 0);
242 };
243 let before = reg.live.len();
244 reg.live.retain(|id, _| keep.contains(id));
245 let after = reg.live.len();
246 (before - after, after)
247 }
248
249 fn snapshot(&self, world: &World) -> Vec<HandleSaveEntry> {
250 let Some(reg) = world.get_resource::<HandleRegistry<K>>() else {
251 return Vec::new();
252 };
253 reg.live
254 .iter()
255 .filter_map(|(id, resource)| {
256 let key = reg.implementor.save_key(world, resource)?;
257 let key = match serde_json::to_value(&key) {
258 Ok(key) => key,
259 Err(err) => {
260 warn!(
269 "brink: handle kind {:?} id {id} failed to serialize its SaveKey ({err}); omitting from snapshot (will rehydrate as dead_ephemeral)"
270 , K::KIND
271 );
272 return None;
273 }
274 };
275 Some(HandleSaveEntry { id: *id, key })
276 })
277 .collect()
278 }
279
280 fn rebind_selected(
281 &self,
282 world: &mut World,
283 referenced: &BTreeSet<u64>,
284 persisted: &[HandleSaveEntry],
285 ) -> KindRehydrateOutcome {
286 let mut outcome = KindRehydrateOutcome::default();
287 let by_id: BTreeMap<u64, &serde_json::Value> =
288 persisted.iter().map(|e| (e.id, &e.key)).collect();
289 let reserve_through = referenced
303 .iter()
304 .copied()
305 .chain(persisted.iter().map(|e| e.id))
306 .max();
307 world.resource_scope(
308 |world, mut reg: bevy_ecs::change_detection::Mut<HandleRegistry<K>>| {
309 if let Some(max_id) = reserve_through {
310 reg.next_id = reg.next_id.max(max_id + 1);
311 }
312 for &id in referenced {
313 let Some(key_json) = by_id.get(&id) else {
314 continue;
318 };
319 let resolved = match serde_json::from_value::<K::SaveKey>((*key_json).clone())
320 {
321 Ok(key) => reg.implementor.resolve(world, &key),
322 Err(err) => {
323 warn!(
329 "brink: handle kind {:?} id {id} failed to deserialize its persisted SaveKey ({err}); treating as dead_by_resolve"
330 , K::KIND
331 );
332 None
333 }
334 };
335 match resolved {
336 Some(resource) => {
337 reg.live.insert(id, resource);
338 outcome.rebound.push(id);
339 }
340 None => outcome.dead_by_resolve.push(id),
341 }
342 }
343 },
344 );
345 outcome
346 }
347}
348
349#[derive(Resource)]
353pub struct HandleKinds<M: Send + Sync + 'static = ()> {
354 kinds: BTreeMap<&'static str, Box<dyn ErasedHandleRegistry>>,
355 _marker: PhantomData<fn() -> M>,
356}
357
358impl<M: Send + Sync + 'static> Default for HandleKinds<M> {
359 fn default() -> Self {
360 Self {
361 kinds: BTreeMap::new(),
362 _marker: PhantomData,
363 }
364 }
365}
366
367impl<M: Send + Sync + 'static> HandleKinds<M> {
368 pub fn kind_names(&self) -> impl Iterator<Item = &'static str> + '_ {
370 self.kinds.keys().copied()
371 }
372
373 #[must_use]
377 pub fn is_empty(&self) -> bool {
378 self.kinds.is_empty()
379 }
380}
381
382pub trait BrinkHandleAppExt {
384 fn register_handle_kind<M: Send + Sync + 'static, K: HandleKind>(
388 &mut self,
389 implementor: K,
390 ) -> &mut Self;
391}
392
393impl BrinkHandleAppExt for App {
394 fn register_handle_kind<M: Send + Sync + 'static, K: HandleKind>(
395 &mut self,
396 implementor: K,
397 ) -> &mut Self {
398 self.world_mut()
399 .insert_resource(HandleRegistry::<K>::new(implementor));
400 self.world_mut()
401 .get_resource_or_insert_with(HandleKinds::<M>::default);
402 self.world_mut()
403 .resource_mut::<HandleKinds<M>>()
404 .kinds
405 .insert(K::KIND, Box::new(RegistryOps::<K>::default()));
406 self
407 }
408}
409
410#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
418pub struct HandleSaveState {
419 pub entries: BTreeMap<String, Vec<HandleSaveEntry>>,
420}
421
422#[must_use]
426pub fn save_handles<M: Send + Sync + 'static>(world: &World) -> HandleSaveState {
427 let mut out = HandleSaveState::default();
428 if let Some(kinds) = world.get_resource::<HandleKinds<M>>() {
429 for ops in kinds.kinds.values() {
430 let entries = ops.snapshot(world);
431 if !entries.is_empty() {
432 out.entries.insert(ops.kind_name().to_string(), entries);
433 }
434 }
435 }
436 out
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
446pub enum RehydrationPolicy {
447 #[default]
448 Lenient,
449 StrictKinds,
450}
451
452#[derive(Debug, Clone, Default)]
455pub struct RehydrationReport {
456 pub rebound: Vec<(String, u64)>,
458 pub dead_by_resolve: Vec<(String, u64)>,
461 pub dead_ephemeral: Vec<(String, u64)>,
464 pub dead_by_unregistered_kind: Vec<(String, u64)>,
467}
468
469impl RehydrationReport {
470 #[must_use]
471 pub fn is_fully_rebound(&self) -> bool {
472 self.dead_by_resolve.is_empty()
473 && self.dead_ephemeral.is_empty()
474 && self.dead_by_unregistered_kind.is_empty()
475 }
476}
477
478#[derive(Debug, Error, Clone, PartialEq, Eq)]
481pub enum HandleLoadError {
482 #[error("unregistered handle kind(s) at load: {0:?}")]
485 UnregisteredKinds(Vec<String>),
486}
487
488pub fn load_handles<M: Send + Sync + 'static>(
501 world: &mut World,
502 program: &Program,
503 referenced: &SaveState,
504 persisted: &HandleSaveState,
505 policy: RehydrationPolicy,
506) -> Result<RehydrationReport, HandleLoadError> {
507 let mut by_kind: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
508 collect_from_save_state(referenced, program, &mut by_kind);
509
510 let registered: BTreeSet<&str> = world
511 .get_resource::<HandleKinds<M>>()
512 .map(|kinds| kinds.kind_names().collect())
513 .unwrap_or_default();
514
515 if policy == RehydrationPolicy::StrictKinds {
516 let unregistered: Vec<String> = by_kind
517 .keys()
518 .filter(|k| !registered.contains(k.as_str()))
519 .cloned()
520 .collect();
521 if !unregistered.is_empty() {
522 return Err(HandleLoadError::UnregisteredKinds(unregistered));
523 }
524 }
525
526 world.get_resource_or_insert_with(HandleEntityRemap::default);
527 if let Some(mut remap) = world.get_resource_mut::<HandleEntityRemap>() {
528 remap.clear();
529 }
530
531 let mut report = RehydrationReport::default();
532 world.resource_scope::<HandleKinds<M>, _>(|world, kinds| {
533 for (kind_name, ids) in &by_kind {
534 let Some(ops) = kinds.kinds.get(kind_name.as_str()) else {
535 report
536 .dead_by_unregistered_kind
537 .extend(ids.iter().map(|id| (kind_name.clone(), *id)));
538 continue;
539 };
540 let persisted_for_kind = persisted
541 .entries
542 .get(kind_name)
543 .map_or(&[][..], Vec::as_slice);
544 let by_persisted_id: BTreeSet<u64> = persisted_for_kind.iter().map(|e| e.id).collect();
545 let outcome = ops.rebind_selected(world, ids, persisted_for_kind);
546 report.rebound.extend(
547 outcome
548 .rebound
549 .into_iter()
550 .map(|id| (kind_name.clone(), id)),
551 );
552 report.dead_by_resolve.extend(
553 outcome
554 .dead_by_resolve
555 .into_iter()
556 .map(|id| (kind_name.clone(), id)),
557 );
558 report.dead_ephemeral.extend(
559 ids.iter()
560 .filter(|id| !by_persisted_id.contains(id))
561 .map(|id| (kind_name.clone(), *id)),
562 );
563 }
564 });
565
566 Ok(report)
567}
568
569fn collect_handles(value: &Value, program: &Program, out: &mut BTreeMap<String, BTreeSet<u64>>) {
575 match value {
576 Value::Handle { kind, id } => {
577 if let Some(name) = program.name_checked(*kind) {
578 out.entry(name.to_string()).or_default().insert(*id);
579 }
580 }
581 Value::Array(items) => {
582 for v in items.iter() {
583 collect_handles(v, program, out);
584 }
585 }
586 Value::Map(map) => {
587 for v in map.values() {
588 collect_handles(v, program, out);
589 }
590 }
591 Value::Record { fields, .. } => {
592 for v in fields.iter() {
593 collect_handles(v, program, out);
594 }
595 }
596 Value::Closure(closure) => {
597 for entry in &closure.env {
598 collect_handles(&entry.payload, program, out);
599 }
600 }
601 _ => {}
602 }
603}
604
605fn collect_from_save_state(
606 save: &SaveState,
607 program: &Program,
608 out: &mut BTreeMap<String, BTreeSet<u64>>,
609) {
610 for value in save.globals.values() {
611 collect_handles(value, program, out);
612 }
613}
614
615#[derive(Resource, Default, Debug)]
625pub struct HandleEntityRemap {
626 map: BTreeMap<Entity, Entity>,
627}
628
629impl HandleEntityRemap {
630 pub fn clear(&mut self) {
631 self.map.clear();
632 }
633}
634
635impl EntityMapper for HandleEntityRemap {
636 fn get_mapped(&mut self, source: Entity) -> Entity {
637 self.map.get(&source).copied().unwrap_or(source)
638 }
639
640 fn set_mapped(&mut self, source: Entity, target: Entity) {
641 self.map.insert(source, target);
642 }
643}
644
645#[derive(EntityEvent)]
652pub struct BrinkDeadHandleDeref<M: Send + Sync + 'static = ()> {
653 pub entity: Entity,
654 pub kind: &'static str,
655 pub id: u64,
656 _marker: PhantomData<fn() -> M>,
657}
658
659impl<M: Send + Sync + 'static> BrinkDeadHandleDeref<M> {
660 pub(crate) fn new(entity: Entity, kind: &'static str, id: u64) -> Self {
661 Self {
662 entity,
663 kind,
664 id,
665 _marker: PhantomData,
666 }
667 }
668}
669
670pub fn is_valid_system<M: Send + Sync + 'static>(
682 In((entity, args)): In<BrinkQueryInput>,
683 world: &World,
684) -> Value {
685 let Some((kind, id)) = args.first().and_then(Value::as_handle) else {
686 return Value::Bool(false);
687 };
688 let Some(program_component) = world.get::<BrinkProgram<M>>(entity) else {
689 return Value::Bool(false);
690 };
691 let Some(program) = world
692 .get_resource::<Assets<ProgramAsset>>()
693 .and_then(|assets| assets.get(&program_component.handle))
694 else {
695 return Value::Bool(false);
696 };
697 let Some(kind_name) = program.program.name_checked(kind) else {
698 return Value::Bool(false);
699 };
700 let Some(kinds) = world.get_resource::<HandleKinds<M>>() else {
701 return Value::Bool(false);
702 };
703 let Some(ops) = kinds.kinds.get(kind_name) else {
704 return Value::Bool(false);
705 };
706 Value::Bool(ops.is_valid(world, id))
707}
708
709#[derive(Debug, Clone, Default)]
715pub struct KindRetention {
716 pub live: usize,
718 pub last_gc_dropped: usize,
720 pub sweeps: u64,
722}
723
724#[derive(Resource, Debug, Clone)]
725pub struct HandleRetentionMetrics<M: Send + Sync + 'static = ()> {
726 pub per_kind: BTreeMap<String, KindRetention>,
727 _marker: PhantomData<fn() -> M>,
728}
729
730impl<M: Send + Sync + 'static> Default for HandleRetentionMetrics<M> {
731 fn default() -> Self {
732 Self {
733 per_kind: BTreeMap::new(),
734 _marker: PhantomData,
735 }
736 }
737}
738
739impl<M: Send + Sync + 'static> HandleRetentionMetrics<M> {
740 fn record(&mut self, kind: &str, dropped: usize, live: usize) {
741 let entry = self.per_kind.entry(kind.to_string()).or_default();
742 entry.live = live;
743 entry.last_gc_dropped = dropped;
744 entry.sweeps += 1;
745 }
746}
747
748fn sweep_registries<M: Send + Sync + 'static>(
755 world: &mut World,
756 reachable: &BTreeMap<String, BTreeSet<u64>>,
757) {
758 let empty = BTreeSet::new();
759 world.resource_scope::<HandleKinds<M>, _>(|world, kinds| {
760 for ops in kinds.kinds.values() {
761 let keep = reachable.get(ops.kind_name()).unwrap_or(&empty);
762 let (dropped, live) = ops.gc_retain(world, keep);
763 if let Some(mut metrics) = world.get_resource_mut::<HandleRetentionMetrics<M>>() {
764 metrics.record(ops.kind_name(), dropped, live);
765 }
766 }
767 });
768}
769
770#[expect(
779 clippy::needless_pass_by_value,
780 reason = "bevy systems take Res params by value"
781)]
782pub fn gc_on_turn_done<M: Send + Sync + 'static>(
783 _on: On<BrinkTurnDone<M>>,
784 kinds: Option<Res<HandleKinds<M>>>,
785 mut globals: Option<ResMut<BrinkGlobals<M>>>,
786 programs: Res<Assets<ProgramAsset>>,
787 mut contexts: Query<(&BrinkProgram<M>, &mut BrinkContext<M>)>,
788 mut commands: Commands,
789) {
790 if kinds.is_none_or(|k| k.is_empty()) {
797 return;
798 }
799
800 let Some(globals) = globals.as_mut() else {
801 return;
802 };
803 let globals = globals.bypass_change_detection();
815
816 let mut reachable: BTreeMap<String, BTreeSet<u64>> = BTreeMap::new();
817 for (program_component, mut ctx) in &mut contexts {
823 let Some(program_asset) = programs.get(&program_component.handle) else {
824 continue;
825 };
826 let state = save_flow_state(globals, &mut ctx, &program_asset.program);
827 collect_from_save_state(&state, &program_asset.program, &mut reachable);
828 }
829
830 commands.queue(move |world: &mut World| {
831 sweep_registries::<M>(world, &reachable);
832 });
833}
834
835#[cfg(test)]
836mod tests {
837 use std::collections::BTreeSet as Set;
838
839 use bevy_ecs::system::RunSystemOnce as _;
840 use brink_format::SaveState;
841 use brink_runtime::ContextAccess as _;
842 use serde::{Deserialize, Serialize};
843
844 use super::*;
845 use crate::BrinkFlowRequest;
846 use crate::bindings::advance_flow;
847 use crate::test_support::{add_story_assets, compile_test_story, make_test_app};
848
849 #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
854 struct TimerSaveKey {
855 remaining_secs: f32,
856 }
857 #[derive(Debug, Clone, Copy, PartialEq)]
858 struct TimerState {
859 remaining_secs: f32,
860 }
861 struct TimerKind;
862 impl HandleKind for TimerKind {
863 const KIND: &'static str = "Timer";
864 type Resource = TimerState;
865 type SaveKey = TimerSaveKey;
866 fn save_key(&self, _world: &World, res: &Self::Resource) -> Option<Self::SaveKey> {
867 Some(TimerSaveKey {
868 remaining_secs: res.remaining_secs,
869 })
870 }
871 fn resolve(&self, _world: &mut World, key: &Self::SaveKey) -> Option<Self::Resource> {
872 Some(TimerState {
873 remaining_secs: key.remaining_secs,
874 })
875 }
876 }
877
878 #[derive(Debug, Clone, Serialize, Deserialize)]
882 struct NpcSaveKey {
883 guid: String,
884 }
885 #[derive(Debug, Clone, PartialEq)]
886 struct NpcState {
887 guid: String,
888 }
889 struct NpcKind;
890 impl HandleKind for NpcKind {
891 const KIND: &'static str = "Npc";
892 type Resource = NpcState;
893 type SaveKey = NpcSaveKey;
894 fn save_key(&self, _world: &World, res: &Self::Resource) -> Option<Self::SaveKey> {
895 Some(NpcSaveKey {
896 guid: res.guid.clone(),
897 })
898 }
899 fn resolve(&self, world: &mut World, key: &Self::SaveKey) -> Option<Self::Resource> {
900 let alive = world.get_resource::<AliveNpcs>()?;
901 alive.0.contains(&key.guid).then(|| NpcState {
902 guid: key.guid.clone(),
903 })
904 }
905 }
906 #[derive(Resource, Default)]
907 struct AliveNpcs(Set<String>);
908
909 struct TransientKind;
911 impl HandleKind for TransientKind {
912 const KIND: &'static str = "Transient";
913 type Resource = ();
914 type SaveKey = ();
915 fn save_key(&self, _world: &World, (): &Self::Resource) -> Option<Self::SaveKey> {
916 None
917 }
918 fn resolve(&self, _world: &mut World, (): &Self::SaveKey) -> Option<Self::Resource> {
919 Some(())
920 }
921 }
922
923 fn empty_save_state() -> SaveState {
924 SaveState {
925 version: brink_runtime::SAVE_FORMAT_VERSION,
926 globals: BTreeMap::new(),
927 visits: Vec::new(),
928 turns: Vec::new(),
929 turn_index: 0,
930 rng_seed: 0,
931 previous_random: 0,
932 global_ids: BTreeMap::new(),
933 suspended: None,
934 }
935 }
936
937 fn referencing(global: &str, value: Value) -> SaveState {
938 let mut save = empty_save_state();
939 save.globals.insert(global.to_string(), value);
940 save
941 }
942
943 #[test]
946 fn mint_and_get_roundtrip() {
947 let mut reg = HandleRegistry::<TimerKind>::new(TimerKind);
948 let id = reg.mint(TimerState {
949 remaining_secs: 3.0,
950 });
951 assert_eq!(
952 reg.get(id),
953 Some(&TimerState {
954 remaining_secs: 3.0
955 })
956 );
957 assert!(reg.contains(id));
958 assert_eq!(reg.len(), 1);
959 }
960
961 #[test]
962 fn mint_value_none_when_kind_never_interned() {
963 let (program, tables, ctx) = compile_test_story("Hi.\n-> DONE\n");
964 let mut app = make_test_app();
965 add_story_assets(&mut app, program, tables, ctx);
966 let program = &app
967 .world()
968 .resource::<Assets<ProgramAsset>>()
969 .iter()
970 .next()
971 .expect("one program asset")
972 .1
973 .program;
974 let mut reg = HandleRegistry::<TimerKind>::new(TimerKind);
975 assert!(
978 reg.mint_value(
979 program,
980 TimerState {
981 remaining_secs: 1.0
982 }
983 )
984 .is_none()
985 );
986 }
987
988 #[test]
989 fn mint_value_resolves_interned_kind_name() {
990 let (program, tables, ctx) = compile_test_story("VAR Timer = 0\nHi.\n-> DONE\n");
991 let mut reg = HandleRegistry::<TimerKind>::new(TimerKind);
992 let value = reg
993 .mint_value(
994 &program,
995 TimerState {
996 remaining_secs: 5.0,
997 },
998 )
999 .expect("Timer was interned via the VAR declaration");
1000 let (kind, _id) = value.as_handle().expect("a handle value");
1001 assert_eq!(program.name_checked(kind), Some("Timer"));
1002 drop(tables);
1003 drop(ctx);
1004 }
1005
1006 #[test]
1009 fn is_valid_true_for_live_registered_handle() {
1010 let (program, tables, ctx) = compile_test_story("VAR Timer = 0\nHi.\n-> DONE\n");
1011 let mut app = make_test_app();
1012 app.register_handle_kind::<(), TimerKind>(TimerKind);
1013 let story = add_story_assets(&mut app, program, tables, ctx);
1014 let entity = app
1015 .world_mut()
1016 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1017 .id();
1018 app.update(); let handle_value = {
1021 let world = app.world_mut();
1022 let program = &world
1023 .resource::<Assets<ProgramAsset>>()
1024 .iter()
1025 .next()
1026 .expect("program asset")
1027 .1
1028 .program;
1029 let kind = program.name_id("Timer").expect("interned");
1030 let mut reg = world.resource_mut::<HandleRegistry<TimerKind>>();
1031 Value::handle(
1032 kind,
1033 reg.mint(TimerState {
1034 remaining_secs: 2.0,
1035 }),
1036 )
1037 };
1038
1039 let result = app
1040 .world_mut()
1041 .run_system_once_with(is_valid_system::<()>, (entity, vec![handle_value]))
1042 .expect("is_valid runs");
1043 assert_eq!(result, Value::Bool(true));
1044 }
1045
1046 #[test]
1047 fn is_valid_false_for_dead_or_non_handle() {
1048 let (program, tables, ctx) = compile_test_story("VAR Timer = 0\nHi.\n-> DONE\n");
1049 let mut app = make_test_app();
1050 app.register_handle_kind::<(), TimerKind>(TimerKind);
1051 let story = add_story_assets(&mut app, program, tables, ctx);
1052 let entity = app
1053 .world_mut()
1054 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1055 .id();
1056 app.update();
1057
1058 let result = app
1060 .world_mut()
1061 .run_system_once_with(is_valid_system::<()>, (entity, vec![Value::Int(1)]))
1062 .expect("is_valid runs");
1063 assert_eq!(result, Value::Bool(false));
1064
1065 let kind = {
1067 let world = app.world_mut();
1068 let program = &world
1069 .resource::<Assets<ProgramAsset>>()
1070 .iter()
1071 .next()
1072 .expect("program asset")
1073 .1
1074 .program;
1075 program.name_id("Timer").expect("interned")
1076 };
1077 let result = app
1078 .world_mut()
1079 .run_system_once_with(
1080 is_valid_system::<()>,
1081 (entity, vec![Value::handle(kind, 9999)]),
1082 )
1083 .expect("is_valid runs");
1084 assert_eq!(result, Value::Bool(false));
1085 }
1086
1087 #[test]
1090 fn save_resolve_live() {
1091 let (program, _tables, _ctx) =
1092 compile_test_story("VAR npc_ref = 0\nVAR Npc = 0\nHi.\n-> DONE\n");
1093 let mut world = World::new();
1094 world.insert_resource(HandleKinds::<()>::default());
1095 world.insert_resource(AliveNpcs(Set::from(["abc".to_string()])));
1096 world.get_resource_or_insert_with(HandleKinds::<()>::default);
1097 world.insert_resource(HandleRegistry::<NpcKind>::new(NpcKind));
1099 world
1100 .resource_mut::<HandleKinds<()>>()
1101 .kinds
1102 .insert(NpcKind::KIND, Box::new(RegistryOps::<NpcKind>::default()));
1103
1104 let kind = program.name_id("Npc").expect("interned");
1105 let id = world
1106 .resource_mut::<HandleRegistry<NpcKind>>()
1107 .mint(NpcState {
1108 guid: "abc".to_string(),
1109 });
1110
1111 let persisted = save_handles::<()>(&world);
1112 assert_eq!(persisted.entries["Npc"].len(), 1);
1113
1114 let referenced = referencing("npc_ref", Value::handle(kind, id));
1115 let report = load_handles::<()>(
1116 &mut world,
1117 &program,
1118 &referenced,
1119 &persisted,
1120 RehydrationPolicy::Lenient,
1121 )
1122 .expect("lenient load never errors");
1123
1124 assert_eq!(report.rebound, vec![("Npc".to_string(), id)]);
1125 assert!(report.is_fully_rebound());
1126 assert_eq!(
1127 world.resource::<HandleRegistry<NpcKind>>().get(id),
1128 Some(&NpcState {
1129 guid: "abc".to_string()
1130 })
1131 );
1132 }
1133
1134 #[test]
1135 fn save_despawn_load_dead_declared_fallback() {
1136 let (program, _tables, _ctx) =
1137 compile_test_story("VAR npc_ref = 0\nVAR Npc = 0\nHi.\n-> DONE\n");
1138 let mut world = World::new();
1139 world.insert_resource(HandleKinds::<()>::default());
1140 world.insert_resource(AliveNpcs(Set::from(["abc".to_string()])));
1141 world.insert_resource(HandleRegistry::<NpcKind>::new(NpcKind));
1142 world
1143 .resource_mut::<HandleKinds<()>>()
1144 .kinds
1145 .insert(NpcKind::KIND, Box::new(RegistryOps::<NpcKind>::default()));
1146
1147 let kind = program.name_id("Npc").expect("interned");
1148 let id = world
1149 .resource_mut::<HandleRegistry<NpcKind>>()
1150 .mint(NpcState {
1151 guid: "abc".to_string(),
1152 });
1153 let persisted = save_handles::<()>(&world);
1154
1155 world.resource_mut::<AliveNpcs>().0.clear();
1159 world.resource_mut::<HandleRegistry<NpcKind>>().remove(id);
1160
1161 let referenced = referencing("npc_ref", Value::handle(kind, id));
1162 let report = load_handles::<()>(
1163 &mut world,
1164 &program,
1165 &referenced,
1166 &persisted,
1167 RehydrationPolicy::Lenient,
1168 )
1169 .expect("lenient load never errors");
1170
1171 assert_eq!(report.dead_by_resolve, vec![("Npc".to_string(), id)]);
1172 assert!(!report.is_fully_rebound());
1173 assert_eq!(world.resource::<HandleRegistry<NpcKind>>().get(id), None);
1174
1175 let declared_fallback = world
1179 .resource::<HandleRegistry<NpcKind>>()
1180 .get(id)
1181 .map_or(Value::Int(-1), |_| Value::Int(0));
1182 assert_eq!(declared_fallback, Value::Int(-1));
1183 }
1184
1185 #[test]
1186 fn timer_reconstruction_after_restart() {
1187 let (program, _tables, _ctx) =
1188 compile_test_story("VAR timer_ref = 0\nVAR Timer = 0\nHi.\n-> DONE\n");
1189 let mut world = World::new();
1190 world.insert_resource(HandleKinds::<()>::default());
1191 world.insert_resource(HandleRegistry::<TimerKind>::new(TimerKind));
1192 world.resource_mut::<HandleKinds<()>>().kinds.insert(
1193 TimerKind::KIND,
1194 Box::new(RegistryOps::<TimerKind>::default()),
1195 );
1196
1197 let kind = program.name_id("Timer").expect("interned");
1198 let id = world
1199 .resource_mut::<HandleRegistry<TimerKind>>()
1200 .mint(TimerState {
1201 remaining_secs: 12.5,
1202 });
1203 let persisted = save_handles::<()>(&world);
1204 assert_eq!(
1205 persisted.entries["Timer"][0].key,
1206 serde_json::json!({ "remaining_secs": 12.5 })
1207 );
1208
1209 world.resource_mut::<HandleRegistry<TimerKind>>().remove(id);
1212
1213 let referenced = referencing("timer_ref", Value::handle(kind, id));
1214 let report = load_handles::<()>(
1215 &mut world,
1216 &program,
1217 &referenced,
1218 &persisted,
1219 RehydrationPolicy::Lenient,
1220 )
1221 .expect("lenient load never errors");
1222
1223 assert_eq!(report.rebound, vec![("Timer".to_string(), id)]);
1224 assert_eq!(
1228 world.resource::<HandleRegistry<TimerKind>>().get(id),
1229 Some(&TimerState {
1230 remaining_secs: 12.5
1231 })
1232 );
1233 }
1234
1235 #[test]
1236 fn dead_ephemeral_when_kind_declines_to_persist() {
1237 let (program, _tables, _ctx) =
1238 compile_test_story("VAR t_ref = 0\nVAR Transient = 0\nHi.\n-> DONE\n");
1239 let mut world = World::new();
1240 world.insert_resource(HandleKinds::<()>::default());
1241 world.insert_resource(HandleRegistry::<TransientKind>::new(TransientKind));
1242 world.resource_mut::<HandleKinds<()>>().kinds.insert(
1243 TransientKind::KIND,
1244 Box::new(RegistryOps::<TransientKind>::default()),
1245 );
1246
1247 let kind = program.name_id("Transient").expect("interned");
1248 let id = world
1249 .resource_mut::<HandleRegistry<TransientKind>>()
1250 .mint(());
1251
1252 let persisted = save_handles::<()>(&world);
1253 assert!(!persisted.entries.contains_key("Transient"));
1256
1257 let referenced = referencing("t_ref", Value::handle(kind, id));
1258 let report = load_handles::<()>(
1259 &mut world,
1260 &program,
1261 &referenced,
1262 &persisted,
1263 RehydrationPolicy::Lenient,
1264 )
1265 .expect("lenient load never errors");
1266
1267 assert_eq!(report.dead_ephemeral, vec![("Transient".to_string(), id)]);
1268 assert!(report.rebound.is_empty());
1269 assert!(report.dead_by_resolve.is_empty());
1270 }
1271
1272 #[test]
1283 fn mint_after_load_does_not_collide_with_dead_by_resolve_token() {
1284 let (program, _tables, _ctx) =
1285 compile_test_story("VAR npc_ref = 0\nVAR Npc = 0\nHi.\n-> DONE\n");
1286 let mut world = World::new();
1287 world.insert_resource(HandleKinds::<()>::default());
1288 world.insert_resource(AliveNpcs(Set::from(["abc".to_string()])));
1289 world.insert_resource(HandleRegistry::<NpcKind>::new(NpcKind));
1290 world
1291 .resource_mut::<HandleKinds<()>>()
1292 .kinds
1293 .insert(NpcKind::KIND, Box::new(RegistryOps::<NpcKind>::default()));
1294
1295 let kind = program.name_id("Npc").expect("interned");
1296 let dead_id = world
1298 .resource_mut::<HandleRegistry<NpcKind>>()
1299 .mint(NpcState {
1300 guid: "abc".to_string(),
1301 });
1302 assert_eq!(dead_id, 0);
1303 let persisted = save_handles::<()>(&world);
1304
1305 world.resource_mut::<AliveNpcs>().0.clear();
1309 world
1310 .resource_mut::<HandleRegistry<NpcKind>>()
1311 .remove(dead_id);
1312
1313 let referenced = referencing("npc_ref", Value::handle(kind, dead_id));
1315 let report = load_handles::<()>(
1316 &mut world,
1317 &program,
1318 &referenced,
1319 &persisted,
1320 RehydrationPolicy::Lenient,
1321 )
1322 .expect("lenient load never errors");
1323 assert_eq!(report.dead_by_resolve, vec![("Npc".to_string(), dead_id)]);
1324
1325 let minted_id = world
1328 .resource_mut::<HandleRegistry<NpcKind>>()
1329 .mint(NpcState {
1330 guid: "def".to_string(),
1331 });
1332 assert_ne!(
1333 minted_id, dead_id,
1334 "mint after load must not collide with a dead-by-resolve token's id \
1335 still referenced by ink state"
1336 );
1337 }
1338
1339 #[test]
1340 fn mint_after_load_does_not_collide_with_dead_ephemeral_token() {
1341 let (program, _tables, _ctx) =
1342 compile_test_story("VAR t_ref = 0\nVAR Transient = 0\nHi.\n-> DONE\n");
1343 let mut world = World::new();
1344 world.insert_resource(HandleKinds::<()>::default());
1345 world.insert_resource(HandleRegistry::<TransientKind>::new(TransientKind));
1346 world.resource_mut::<HandleKinds<()>>().kinds.insert(
1347 TransientKind::KIND,
1348 Box::new(RegistryOps::<TransientKind>::default()),
1349 );
1350
1351 let kind = program.name_id("Transient").expect("interned");
1352 let dead_id = world
1357 .resource_mut::<HandleRegistry<TransientKind>>()
1358 .mint(());
1359 assert_eq!(dead_id, 0);
1360 let persisted = save_handles::<()>(&world);
1361 assert!(!persisted.entries.contains_key("Transient"));
1362
1363 let referenced = referencing("t_ref", Value::handle(kind, dead_id));
1365 let report = load_handles::<()>(
1366 &mut world,
1367 &program,
1368 &referenced,
1369 &persisted,
1370 RehydrationPolicy::Lenient,
1371 )
1372 .expect("lenient load never errors");
1373 assert_eq!(
1374 report.dead_ephemeral,
1375 vec![("Transient".to_string(), dead_id)]
1376 );
1377
1378 let minted_id = world
1381 .resource_mut::<HandleRegistry<TransientKind>>()
1382 .mint(());
1383 assert_ne!(
1384 minted_id, dead_id,
1385 "mint after load must not collide with a dead-ephemeral token's id \
1386 still referenced by ink state"
1387 );
1388 }
1389
1390 #[test]
1391 fn unregistered_kind_lenient_reports_strict_fails() {
1392 let (program, _tables, _ctx) =
1393 compile_test_story("VAR ghost_ref = 0\nVAR Ghost = 0\nHi.\n-> DONE\n");
1394 let kind = program.name_id("Ghost").expect("interned");
1395 let referenced = referencing("ghost_ref", Value::handle(kind, 7));
1396 let persisted = HandleSaveState::default();
1397
1398 let mut lenient_world = World::new();
1399 lenient_world.insert_resource(HandleKinds::<()>::default());
1400 let report = load_handles::<()>(
1401 &mut lenient_world,
1402 &program,
1403 &referenced,
1404 &persisted,
1405 RehydrationPolicy::Lenient,
1406 )
1407 .expect("lenient never errors, even for unregistered kinds");
1408 assert_eq!(
1409 report.dead_by_unregistered_kind,
1410 vec![("Ghost".to_string(), 7)]
1411 );
1412
1413 let mut strict_world = World::new();
1414 strict_world.insert_resource(HandleKinds::<()>::default());
1415 let err = load_handles::<()>(
1416 &mut strict_world,
1417 &program,
1418 &referenced,
1419 &persisted,
1420 RehydrationPolicy::StrictKinds,
1421 )
1422 .expect_err("StrictKinds fails loudly on an unregistered kind");
1423 assert_eq!(
1424 err,
1425 HandleLoadError::UnregisteredKinds(vec!["Ghost".to_string()])
1426 );
1427 }
1428
1429 #[test]
1432 fn sweep_drops_unreachable_keeps_reachable() {
1433 let mut world = World::new();
1434 world.insert_resource(HandleKinds::<()>::default());
1435 world.insert_resource(HandleRetentionMetrics::<()>::default());
1436 world.insert_resource(HandleRegistry::<TimerKind>::new(TimerKind));
1437 world.resource_mut::<HandleKinds<()>>().kinds.insert(
1438 TimerKind::KIND,
1439 Box::new(RegistryOps::<TimerKind>::default()),
1440 );
1441
1442 let (reachable_id, orphan_id) = {
1443 let mut reg = world.resource_mut::<HandleRegistry<TimerKind>>();
1444 (
1445 reg.mint(TimerState {
1446 remaining_secs: 1.0,
1447 }),
1448 reg.mint(TimerState {
1449 remaining_secs: 2.0,
1450 }),
1451 )
1452 };
1453
1454 let mut reachable = BTreeMap::new();
1455 reachable.insert("Timer".to_string(), Set::from([reachable_id]));
1456 sweep_registries::<()>(&mut world, &reachable);
1457
1458 let reg = world.resource::<HandleRegistry<TimerKind>>();
1459 assert!(reg.contains(reachable_id));
1460 assert!(!reg.contains(orphan_id));
1461
1462 let metrics = world.resource::<HandleRetentionMetrics<()>>();
1463 let timer = &metrics.per_kind["Timer"];
1464 assert_eq!(timer.live, 1);
1465 assert_eq!(timer.last_gc_dropped, 1);
1466 assert_eq!(timer.sweeps, 1);
1467 }
1468
1469 #[test]
1475 fn gc_on_turn_done_is_wired_by_the_plugin() {
1476 let (program, tables, ctx) =
1477 compile_test_story("VAR target = 0\nVAR Timer = 0\nHi.\n-> DONE\n");
1478 let mut app = make_test_app();
1479 app.register_handle_kind::<(), TimerKind>(TimerKind);
1480 let story = add_story_assets(&mut app, program, tables, ctx);
1481 let entity = app
1482 .world_mut()
1483 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1484 .id();
1485 app.update(); let (kind, target_idx) = {
1488 let world = app.world();
1489 let program = &world
1490 .resource::<Assets<ProgramAsset>>()
1491 .iter()
1492 .next()
1493 .expect("program asset")
1494 .1
1495 .program;
1496 (
1497 program.name_id("Timer").expect("interned"),
1498 program.global_index("target").expect("declared"),
1499 )
1500 };
1501 let (reachable_id, orphan_id) = {
1502 let mut reg = app.world_mut().resource_mut::<HandleRegistry<TimerKind>>();
1503 (
1504 reg.mint(TimerState {
1505 remaining_secs: 1.0,
1506 }),
1507 reg.mint(TimerState {
1508 remaining_secs: 2.0,
1509 }),
1510 )
1511 };
1512
1513 app.world_mut()
1517 .resource_mut::<BrinkGlobals<()>>()
1518 .inner
1519 .set_global(target_idx, Value::handle(kind, reachable_id));
1520
1521 {
1522 let world = app.world_mut();
1523 loop {
1529 let step = advance_flow::<()>(world, entity).expect("advances to -> DONE");
1530 if step.is_terminal() {
1531 break;
1532 }
1533 }
1534 world.flush();
1535 }
1536 app.update();
1537
1538 let world = app.world();
1539 let reg = world.resource::<HandleRegistry<TimerKind>>();
1540 assert!(
1541 reg.contains(reachable_id),
1542 "reachable token must survive the sweep"
1543 );
1544 assert!(
1545 !reg.contains(orphan_id),
1546 "unreferenced token must be dropped by the -> DONE sweep"
1547 );
1548 }
1549
1550 #[test]
1553 fn handle_kinds_is_empty_tracks_registration() {
1554 let mut app = make_test_app();
1555 assert!(
1556 app.world().resource::<HandleKinds<()>>().is_empty(),
1557 "no kind registered → empty",
1558 );
1559 app.register_handle_kind::<(), TimerKind>(TimerKind);
1560 assert!(
1561 !app.world().resource::<HandleKinds<()>>().is_empty(),
1562 "after register_handle_kind → non-empty",
1563 );
1564 }
1565
1566 #[test]
1572 fn gc_on_turn_done_skips_scan_when_no_kinds_registered() {
1573 let (program, tables, ctx) = compile_test_story("Hi.\n-> DONE\n");
1574 let mut app = make_test_app();
1575 let story = add_story_assets(&mut app, program, tables, ctx);
1578 let entity = app
1579 .world_mut()
1580 .spawn(BrinkFlowRequest::<()>::builder().story(story).build())
1581 .id();
1582 app.update(); assert!(
1585 app.world().resource::<HandleKinds<()>>().is_empty(),
1586 "gate precondition: no kind registered",
1587 );
1588
1589 {
1590 let world = app.world_mut();
1591 loop {
1597 let step = advance_flow::<()>(world, entity).expect("advances to -> DONE");
1598 if step.is_terminal() {
1599 break;
1600 }
1601 }
1602 world.flush();
1603 }
1604 app.update(); assert!(
1607 app.world()
1608 .resource::<HandleRetentionMetrics<()>>()
1609 .per_kind
1610 .is_empty(),
1611 "handle-free -> DONE must record no retention metrics (gate skipped the sweep)",
1612 );
1613 }
1614}