Skip to main content

wyrd_bevy/
lib.rs

1//! Thin Bevy 0.19 bridge for Wyrd — no graph topology on Entities.
2//!
3//! Host tick order: [`WyrdSet::Sample`] → [`WyrdSet::Loom`] → [`WyrdSet::Apply`].
4//! The plugin only drives loom; games own sample/apply systems. Bevy
5//! **Messages** (`WyrdSignalConfirm`) are post-apply VFX/UI confirmations —
6//! never Weave Threads. f32 signal path only.
7
8#![allow(clippy::result_large_err)] // Preserve contextual public BindError payloads.
9
10use bevy::prelude::*;
11use core::sync::atomic::{AtomicUsize, Ordering};
12use wyrd::core::{HostTime, ONE, ZERO};
13use wyrd::graph::Weave;
14use wyrd::runtime::{BindError, HandleError, HostPathId, Outbox, Runtime, SenseId};
15
16/// Ordered host integration sets. Sample → Loom → Apply.
17#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
18pub enum WyrdSet {
19    /// Host writes dense senses from world state.
20    Sample,
21    /// Plugin-driven settle (`loom_all`).
22    Loom,
23    /// Host applies outbox to components / world effects.
24    Apply,
25}
26
27/// One bound Runtime. Host owns sampling and applying outbox.
28pub struct WyrdInstance {
29    label: String,
30    runtime: Runtime,
31    tick: u64,
32}
33
34impl WyrdInstance {
35    /// Bind `weave` with default opts under a host-visible label.
36    pub fn new(label: impl Into<String>, weave: Weave) -> Result<Self, BindError> {
37        let runtime = Runtime::bind(weave, Default::default())?;
38        Ok(Self {
39            label: label.into(),
40            runtime,
41            tick: 0,
42        })
43    }
44
45    /// Host-visible instance label (not a weave id).
46    pub fn label(&self) -> &str {
47        &self.label
48    }
49
50    /// Monotonic frame counter advanced by [`loom_all`].
51    pub fn tick(&self) -> u64 {
52        self.tick
53    }
54
55    /// Borrow the bound dense runtime for resolve/sample/apply helpers.
56    pub fn runtime(&self) -> &Runtime {
57        &self.runtime
58    }
59
60    /// Mutably borrow the runtime for port writes before loom.
61    pub fn runtime_mut(&mut self) -> &mut Runtime {
62        &mut self.runtime
63    }
64
65    /// Outbox view for the current frame (valid until the next `begin_frame`).
66    pub fn outbox(&self) -> Outbox<'_> {
67        self.runtime.outbox()
68    }
69
70    /// Resolve a `SignalIn` name once at setup (not each sample).
71    pub fn sense_id(&self, name: &str) -> Option<SenseId> {
72        self.runtime.sense_id(name)
73    }
74
75    /// Resolve a `SignalOut` path once at setup (not each apply).
76    pub fn path_id(&self, path: &str) -> Option<HostPathId> {
77        self.runtime.path_id(path)
78    }
79}
80
81/// Stable, opaque handle to an instance stored in [`WyrdWorld`].
82///
83/// Handles are generational: removing an instance permanently invalidates its
84/// handle, even if a later insertion reuses the same storage slot.
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
86pub struct WyrdInstanceId {
87    owner: usize,
88    index: usize,
89    generation: u64,
90}
91
92struct WyrdInstanceSlot {
93    generation: u64,
94    instance: Option<WyrdInstance>,
95    next_free: Option<usize>,
96}
97
98// Monotonic owner identity prevents handles from crossing WyrdWorld resources.
99static NEXT_WORLD_OWNER: AtomicUsize = AtomicUsize::new(1);
100
101fn next_world_owner() -> usize {
102    NEXT_WORLD_OWNER
103        .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |owner| {
104            owner.checked_add(1)
105        })
106        .expect("WyrdWorld owner token space exhausted")
107}
108
109/// All active Wyrd instances, addressed through stable generational handles.
110#[derive(Resource)]
111pub struct WyrdWorld {
112    owner: usize,
113    slots: Vec<WyrdInstanceSlot>,
114    free_head: Option<usize>,
115    len: usize,
116}
117
118impl Default for WyrdWorld {
119    fn default() -> Self {
120        Self {
121            owner: next_world_owner(),
122            slots: Vec::new(),
123            free_head: None,
124            len: 0,
125        }
126    }
127}
128
129impl WyrdWorld {
130    /// Insert an instance and return its stable handle.
131    pub fn insert(&mut self, instance: WyrdInstance) -> WyrdInstanceId {
132        let (index, generation) = if let Some(index) = self.free_head {
133            let slot = &mut self.slots[index];
134            self.free_head = slot.next_free.take();
135            debug_assert!(slot.instance.is_none());
136            slot.instance = Some(instance);
137            (index, slot.generation)
138        } else {
139            let index = self.slots.len();
140            self.slots.push(WyrdInstanceSlot {
141                generation: 0,
142                instance: Some(instance),
143                next_free: None,
144            });
145            (index, 0)
146        };
147        self.len += 1;
148        WyrdInstanceId {
149            owner: self.owner,
150            index,
151            generation,
152        }
153    }
154
155    /// Remove and return an instance when `id` is still current.
156    ///
157    /// A slot whose generation reaches [`u64::MAX`] is retired after removal
158    /// instead of wrapping around and making an ancient handle valid again.
159    pub fn remove(&mut self, id: WyrdInstanceId) -> Option<WyrdInstance> {
160        if id.owner != self.owner {
161            return None;
162        }
163        let slot = self.slots.get_mut(id.index)?;
164        if slot.generation != id.generation {
165            return None;
166        }
167        let instance = slot.instance.take()?;
168        self.len -= 1;
169
170        if let Some(generation) = slot.generation.checked_add(1) {
171            slot.generation = generation;
172            slot.next_free = self.free_head;
173            self.free_head = Some(id.index);
174        } else {
175            slot.next_free = None;
176        }
177
178        Some(instance)
179    }
180
181    /// Borrow an instance when `id` is still current.
182    pub fn get(&self, id: WyrdInstanceId) -> Option<&WyrdInstance> {
183        if id.owner != self.owner {
184            return None;
185        }
186        let slot = self.slots.get(id.index)?;
187        if slot.generation == id.generation {
188            slot.instance.as_ref()
189        } else {
190            None
191        }
192    }
193
194    /// Mutably borrow an instance when `id` is still current.
195    pub fn get_mut(&mut self, id: WyrdInstanceId) -> Option<&mut WyrdInstance> {
196        if id.owner != self.owner {
197            return None;
198        }
199        let slot = self.slots.get_mut(id.index)?;
200        if slot.generation == id.generation {
201            slot.instance.as_mut()
202        } else {
203            None
204        }
205    }
206
207    /// Iterate over active instance handles and shared references.
208    pub fn iter(&self) -> impl Iterator<Item = (WyrdInstanceId, &WyrdInstance)> {
209        self.slots.iter().enumerate().filter_map(|(index, slot)| {
210            slot.instance.as_ref().map(|instance| {
211                (
212                    WyrdInstanceId {
213                        owner: self.owner,
214                        index,
215                        generation: slot.generation,
216                    },
217                    instance,
218                )
219            })
220        })
221    }
222
223    /// Iterate over active instance handles and mutable references.
224    pub fn iter_mut(&mut self) -> impl Iterator<Item = (WyrdInstanceId, &mut WyrdInstance)> {
225        let owner = self.owner;
226        self.slots
227            .iter_mut()
228            .enumerate()
229            .filter_map(move |(index, slot)| {
230                let generation = slot.generation;
231                slot.instance.as_mut().map(|instance| {
232                    (
233                        WyrdInstanceId {
234                            owner,
235                            index,
236                            generation,
237                        },
238                        instance,
239                    )
240                })
241            })
242    }
243
244    /// Number of active instances.
245    pub fn len(&self) -> usize {
246        self.len
247    }
248
249    /// Whether there are no active instances.
250    pub fn is_empty(&self) -> bool {
251        self.len == 0
252    }
253}
254
255/// Demo/test binding for the and-door sample (not a general host primitive).
256/// Resolve your own `SenseId` / `HostPathId` fields at setup for production games.
257#[derive(Resource, Clone, Copy, Debug)]
258pub struct AndDoorBinding {
259    /// Dense sense for the first pressure plate `SignalIn`.
260    pub plate_a: SenseId,
261    /// Dense sense for the second pressure plate `SignalIn`.
262    pub plate_b: SenseId,
263    /// Dense host path for the `door.open` `SignalOut`.
264    pub door_path: HostPathId,
265    /// Generational handle to the bound door weave instance.
266    pub instance: WyrdInstanceId,
267}
268
269/// Host-owned door state on an Entity (not a Wyrd Knot).
270#[derive(Component, Clone, Copy, Debug, Default, PartialEq, Eq)]
271pub struct Door {
272    /// Host-owned door state mirrored from the weave outbox.
273    pub open: bool,
274}
275
276/// Confirmation that a SignalOut level was applied by the host (VFX/UI only).
277///
278/// **Not** a Thread. Topology lives only in the Weave.
279#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
280pub struct WyrdSignalConfirm {
281    /// Host path whose applied level changed this frame.
282    pub path: HostPathId,
283    /// Truthy interpretation of the applied signal (`is_truthy`).
284    pub truthy: bool,
285}
286
287/// Registers [`WyrdWorld`], confirmation messages, ordered sets, and [`loom_all`].
288pub struct WyrdPlugin;
289
290impl Plugin for WyrdPlugin {
291    fn build(&self, app: &mut App) {
292        app.init_resource::<WyrdWorld>()
293            .add_message::<WyrdSignalConfirm>()
294            .configure_sets(
295                Update,
296                (WyrdSet::Sample, WyrdSet::Loom, WyrdSet::Apply).chain(),
297            )
298            .add_systems(Update, loom_all.in_set(WyrdSet::Loom));
299    }
300}
301
302/// Advance tick, clear outbox, loom every instance.
303/// Host systems write senses in `WyrdSet::Sample` and read outbox in `WyrdSet::Apply`.
304///
305/// Loom is infallible after a successful bind (validate already ran).
306pub fn loom_all(mut world: ResMut<WyrdWorld>) {
307    for (_, inst) in world.iter_mut() {
308        inst.tick = inst.tick.wrapping_add(1);
309        inst.runtime.begin_frame(HostTime { tick: inst.tick });
310        inst.runtime.loom();
311    }
312}
313
314/// Write ONE/ZERO into a sense port through its dense [`SenseId`].
315#[inline]
316pub fn set_sense_bool(inst: &mut WyrdInstance, id: SenseId, on: bool) -> Result<(), HandleError> {
317    let v = if on { ONE } else { ZERO };
318    inst.runtime.port_writer().set_sense(id, v)
319}
320
321/// Read truthy SignalOut by HostPathId.
322///
323/// Returns [`HandleError::ForeignRuntime`] when `path` belongs to another
324/// instance, and [`HandleError::InvalidHostPath`] for an invalid local path.
325pub fn signal_truthy(inst: &WyrdInstance, path: HostPathId) -> Result<bool, HandleError> {
326    inst.runtime.path_name(path)?;
327    Ok(inst
328        .runtime
329        .outbox()
330        .signals()
331        .iter()
332        .find(|s| s.path == path)
333        .map(|s| wyrd::core::is_truthy(s.value))
334        .unwrap_or(false))
335}
336
337/// Apply a SignalOut level into a host `bool`, returning `true` if the value changed.
338///
339/// Returns the same handle validation errors as [`signal_truthy`] and leaves
340/// `slot` unchanged when validation fails.
341pub fn apply_signal_bool(
342    inst: &WyrdInstance,
343    path: HostPathId,
344    slot: &mut bool,
345) -> Result<bool, HandleError> {
346    let next = signal_truthy(inst, path)?;
347    let changed = *slot != next;
348    *slot = next;
349    Ok(changed)
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use wyrd::core::{KnotKind, SignalDomain};
356
357    fn and_door_weave() -> Weave {
358        let mut b = Weave::builder("door").unwrap();
359        let pa = b
360            .knot("plate_a", KnotKind::signal_in(SignalDomain::Bool))
361            .unwrap();
362        let pb = b
363            .knot("plate_b", KnotKind::signal_in(SignalDomain::Bool))
364            .unwrap();
365        let both = b.knot("both", KnotKind::and2()).unwrap();
366        let door = b
367            .knot(
368                "door",
369                KnotKind::signal_out("door.open", SignalDomain::Bool),
370            )
371            .unwrap();
372        let from = b.output(&pa, "out").unwrap();
373        let to = b.input(&both, "in_0").unwrap();
374        b.connect(from, to).unwrap();
375        let from = b.output(&pb, "out").unwrap();
376        let to = b.input(&both, "in_1").unwrap();
377        b.connect(from, to).unwrap();
378        let from = b.output(&both, "out").unwrap();
379        let to = b.input(&door, "in").unwrap();
380        b.connect(from, to).unwrap();
381        b.build().unwrap()
382    }
383
384    #[derive(Resource, Default)]
385    struct DoorOpen(bool);
386
387    #[derive(Resource, Clone, Copy)]
388    struct PlateState {
389        a: bool,
390        b: bool,
391    }
392
393    fn sample_plates(
394        plates: Option<Res<PlateState>>,
395        binding: Res<AndDoorBinding>,
396        mut world: ResMut<WyrdWorld>,
397    ) {
398        let Some(plates) = plates else {
399            return;
400        };
401        let Some(inst) = world.get_mut(binding.instance) else {
402            return;
403        };
404        set_sense_bool(inst, binding.plate_a, plates.a).expect("bound plate_a handle");
405        set_sense_bool(inst, binding.plate_b, plates.b).expect("bound plate_b handle");
406    }
407
408    fn apply_door(binding: Res<AndDoorBinding>, world: Res<WyrdWorld>, mut door: ResMut<DoorOpen>) {
409        let Some(inst) = world.get(binding.instance) else {
410            return;
411        };
412        door.0 = signal_truthy(inst, binding.door_path).expect("bound door path");
413    }
414
415    fn apply_door_component(
416        binding: Res<AndDoorBinding>,
417        world: Res<WyrdWorld>,
418        mut q: Query<&mut Door>,
419        mut confirms: MessageWriter<WyrdSignalConfirm>,
420    ) {
421        let Some(inst) = world.get(binding.instance) else {
422            return;
423        };
424        for mut door in &mut q {
425            if apply_signal_bool(inst, binding.door_path, &mut door.open).expect("bound door path")
426            {
427                confirms.write(WyrdSignalConfirm {
428                    path: binding.door_path,
429                    truthy: door.open,
430                });
431            }
432        }
433    }
434
435    #[test]
436    fn headless_app_and_door() {
437        let mut app = App::new();
438        app.add_plugins(MinimalPlugins)
439            .add_plugins(WyrdPlugin)
440            .init_resource::<DoorOpen>();
441
442        let weave = and_door_weave();
443        let inst = WyrdInstance::new("demo", weave).unwrap();
444        assert!(inst.sense_id("nope").is_none());
445        assert!(inst.path_id("nope").is_none());
446        let plate_a = inst.sense_id("plate_a").unwrap();
447        let plate_b = inst.sense_id("plate_b").unwrap();
448        let door_path = inst.path_id("door.open").unwrap();
449        let foreign = WyrdInstance::new("foreign", and_door_weave()).unwrap();
450        assert_eq!(
451            signal_truthy(&inst, foreign.path_id("door.open").unwrap()),
452            Err(HandleError::ForeignRuntime {
453                handle: "host path"
454            })
455        );
456        let mut open = true;
457        assert_eq!(
458            apply_signal_bool(&inst, foreign.path_id("door.open").unwrap(), &mut open),
459            Err(HandleError::ForeignRuntime {
460                handle: "host path"
461            })
462        );
463        assert!(open, "a rejected handle must not mutate host state");
464
465        let instance = app.world_mut().resource_mut::<WyrdWorld>().insert(inst);
466        let binding = AndDoorBinding {
467            plate_a,
468            plate_b,
469            door_path,
470            instance,
471        };
472        app.insert_resource(binding);
473        app.add_systems(Update, sample_plates.in_set(WyrdSet::Sample));
474        app.add_systems(Update, apply_door.in_set(WyrdSet::Apply));
475
476        app.update();
477        assert!(!app.world().resource::<DoorOpen>().0);
478
479        app.world_mut()
480            .insert_resource(PlateState { a: true, b: false });
481        app.update();
482        assert!(!app.world().resource::<DoorOpen>().0);
483
484        app.world_mut()
485            .insert_resource(PlateState { a: true, b: true });
486        app.update();
487        assert!(app.world().resource::<DoorOpen>().0);
488
489        let removed = app
490            .world_mut()
491            .resource_mut::<WyrdWorld>()
492            .remove(binding.instance)
493            .unwrap();
494        let removed_tick = removed.tick();
495        app.update();
496        assert_eq!(removed.tick(), removed_tick);
497    }
498
499    #[derive(Resource, Default)]
500    struct ConfirmLog(Vec<WyrdSignalConfirm>);
501
502    fn log_confirms(mut reader: MessageReader<WyrdSignalConfirm>, mut log: ResMut<ConfirmLog>) {
503        for m in reader.read() {
504            log.0.push(*m);
505        }
506    }
507
508    #[test]
509    fn door_component_and_confirmation_message() {
510        let mut app = App::new();
511        app.add_plugins(MinimalPlugins)
512            .add_plugins(WyrdPlugin)
513            .init_resource::<ConfirmLog>();
514
515        let weave = and_door_weave();
516        let inst = WyrdInstance::new("demo", weave).unwrap();
517        let plate_a = inst.sense_id("plate_a").unwrap();
518        let plate_b = inst.sense_id("plate_b").unwrap();
519        let door_path = inst.path_id("door.open").unwrap();
520        let instance = app.world_mut().resource_mut::<WyrdWorld>().insert(inst);
521        let binding = AndDoorBinding {
522            plate_a,
523            plate_b,
524            door_path,
525            instance,
526        };
527        let door_path = binding.door_path;
528        app.insert_resource(binding);
529        app.world_mut().spawn(Door { open: false });
530        app.add_systems(Update, sample_plates.in_set(WyrdSet::Sample));
531        app.add_systems(Update, apply_door_component.in_set(WyrdSet::Apply));
532        app.add_systems(Update, log_confirms.after(WyrdSet::Apply));
533
534        app.world_mut()
535            .insert_resource(PlateState { a: true, b: true });
536        app.update();
537
538        let door = app
539            .world_mut()
540            .query::<&Door>()
541            .single(app.world())
542            .expect("door entity");
543        assert!(door.open);
544
545        {
546            let log = app.world().resource::<ConfirmLog>();
547            assert!(
548                log.0.iter().any(|c| c.path == door_path && c.truthy),
549                "expected WyrdSignalConfirm for door.open"
550            );
551        }
552
553        app.world_mut()
554            .resource_mut::<WyrdWorld>()
555            .remove(binding.instance)
556            .expect("bound instance");
557        app.update();
558        assert_eq!(app.world().resource::<ConfirmLog>().0.len(), 1);
559    }
560
561    #[test]
562    fn instance_state_is_exposed_through_accessors() {
563        let mut inst = WyrdInstance::new("demo", and_door_weave()).unwrap();
564        assert_eq!(inst.label(), "demo");
565        assert_eq!(inst.tick(), 0);
566        assert!(inst.runtime().sense_id("plate_a").is_some());
567        assert!(inst.runtime_mut().sense_id("plate_b").is_some());
568        assert!(inst.outbox().signals().is_empty());
569    }
570
571    #[test]
572    fn generational_ids_survive_removal_and_reject_stale_handles() {
573        let mut world = WyrdWorld::default();
574        let first = world.insert(WyrdInstance::new("first", and_door_weave()).unwrap());
575        let second = world.insert(WyrdInstance::new("second", and_door_weave()).unwrap());
576
577        let removed = world.remove(first).unwrap();
578        assert_eq!(removed.label(), "first");
579        assert_eq!(world.get(second).unwrap().label(), "second");
580        assert!(world.get(first).is_none());
581
582        let replacement = world.insert(WyrdInstance::new("replacement", and_door_weave()).unwrap());
583        assert_eq!(replacement.index, first.index);
584        assert_ne!(replacement.generation, first.generation);
585        assert!(world.get(first).is_none());
586        assert!(world.remove(first).is_none());
587        assert_eq!(world.get(replacement).unwrap().label(), "replacement");
588        assert_eq!(world.len(), 2);
589        assert_eq!(world.iter().count(), 2);
590        let iter_ids: Vec<_> = world.iter().map(|(id, _)| id).collect();
591        assert!(iter_ids.iter().all(|id| world.get(*id).is_some()));
592        let iter_mut_ids: Vec<_> = world.iter_mut().map(|(id, _)| id).collect();
593        assert!(iter_mut_ids.iter().all(|id| world.get(*id).is_some()));
594        assert!(!world.is_empty());
595    }
596
597    #[test]
598    fn instance_ids_are_rejected_by_other_worlds() {
599        let mut first_world = WyrdWorld::default();
600        let mut second_world = WyrdWorld::default();
601        let first_id = first_world.insert(WyrdInstance::new("first", and_door_weave()).unwrap());
602        let second_id = second_world.insert(WyrdInstance::new("second", and_door_weave()).unwrap());
603
604        assert!(second_world.get(first_id).is_none());
605        assert!(second_world.get_mut(first_id).is_none());
606        assert!(second_world.remove(first_id).is_none());
607        assert_eq!(second_world.get(second_id).unwrap().label(), "second");
608    }
609
610    #[test]
611    fn replacing_world_resource_invalidates_old_ids() {
612        let mut app = App::new();
613        app.init_resource::<WyrdWorld>();
614        let old_id = app
615            .world_mut()
616            .resource_mut::<WyrdWorld>()
617            .insert(WyrdInstance::new("old", and_door_weave()).unwrap());
618
619        app.insert_resource(WyrdWorld::default());
620        let new_id = app
621            .world_mut()
622            .resource_mut::<WyrdWorld>()
623            .insert(WyrdInstance::new("new", and_door_weave()).unwrap());
624
625        let world = app.world().resource::<WyrdWorld>();
626        assert!(world.get(old_id).is_none());
627        assert_eq!(world.get(new_id).unwrap().label(), "new");
628    }
629
630    #[test]
631    fn generation_overflow_retires_slot() {
632        let mut world = WyrdWorld::default();
633        let original = world.insert(WyrdInstance::new("original", and_door_weave()).unwrap());
634        world.slots[original.index].generation = u64::MAX;
635        let final_id = WyrdInstanceId {
636            owner: world.owner,
637            index: original.index,
638            generation: u64::MAX,
639        };
640
641        world.remove(final_id).unwrap();
642        let replacement = world.insert(WyrdInstance::new("replacement", and_door_weave()).unwrap());
643
644        assert_ne!(replacement.index, final_id.index);
645        assert!(world.get(final_id).is_none());
646        assert_eq!(world.get(replacement).unwrap().label(), "replacement");
647    }
648}