use bevy::prelude::*;
use bevy::{
ecs::event::EntityEvent,
platform::collections::{HashMap, HashSet},
reflect::GetTypeRegistration,
};
pub use bevy_enum_event::EnumEvent;
pub use bevy_fsm_macros::{FSMState, FSMTransition};
use std::any::TypeId;
#[macro_export]
macro_rules! fsm_observer {
($app:expr, $fsm_type:ty, $system:expr) => {{
let mut world = $app.world_mut();
let entity = {
let mut observer = world.add_observer($system);
observer.insert(bevy::prelude::Name::new(stringify!($system)));
observer.insert($crate::FSMObserverMarker::<$fsm_type>::default());
observer.id()
};
$crate::attach_observer_to_group::<$fsm_type>(&mut world, entity);
world.entity_mut(entity)
}};
}
#[derive(Component)]
#[doc(hidden)]
pub struct FSMObserverMarker<S: Send + Sync + 'static> {
_phantom: std::marker::PhantomData<S>,
}
impl<S: Send + Sync + 'static> Default for FSMObserverMarker<S> {
fn default() -> Self {
Self {
_phantom: std::marker::PhantomData,
}
}
}
#[derive(Component)]
#[doc(hidden)]
pub struct FSMObserverGroup<S: Send + Sync + 'static> {
_phantom: std::marker::PhantomData<S>,
}
impl<S: Send + Sync + 'static> Default for FSMObserverGroup<S> {
fn default() -> Self {
Self {
_phantom: std::marker::PhantomData,
}
}
}
#[derive(Event, Debug, Clone, Copy)]
pub struct StateChangeRequest<S: Copy + Send + Sync + 'static> {
pub entity: Entity,
pub next: S,
}
impl<S: Copy + Send + Sync + 'static> EntityEvent for StateChangeRequest<S> {
fn event_target(&self) -> Entity {
self.entity
}
}
#[derive(Event, Debug, Clone, Copy)]
pub struct Exit<S: Copy + Send + Sync + 'static> {
pub entity: Entity,
pub state: S,
}
impl<S: Copy + Send + Sync + 'static> EntityEvent for Exit<S> {
fn event_target(&self) -> Entity {
self.entity
}
}
#[derive(Event, Debug, Clone, Copy)]
pub struct Enter<S: Copy + Send + Sync + 'static> {
pub entity: Entity,
pub state: S,
}
impl<S: Copy + Send + Sync + 'static> EntityEvent for Enter<S> {
fn event_target(&self) -> Entity {
self.entity
}
}
#[derive(Event, Debug, Clone, Copy)]
pub struct Transition<F, T>
where
F: Copy + Send + Sync + 'static,
T: Copy + Send + Sync + 'static,
{
pub entity: Entity,
pub from: F,
pub to: T,
}
impl<F, T> EntityEvent for Transition<F, T>
where
F: Copy + Send + Sync + 'static,
T: Copy + Send + Sync + 'static,
{
fn event_target(&self) -> Entity {
self.entity
}
}
pub trait FSMTransition {
fn can_transition(from: Self, to: Self) -> bool
where
Self: Sized;
fn can_transition_ctx(world: &World, entity: Entity, from: Self, to: Self) -> bool
where
Self: Sized,
{
let _ = (world, entity);
Self::can_transition(from, to)
}
}
pub trait FSMState: Component + Copy + Eq + Send + Sync + 'static + FSMTransition {
fn can_transition(from: Self, to: Self) -> bool {
<Self as FSMTransition>::can_transition(from, to)
}
fn can_transition_ctx(world: &World, entity: Entity, from: Self, to: Self) -> bool {
<Self as FSMTransition>::can_transition_ctx(world, entity, from, to)
}
#[inline]
fn trigger_enter_variant(_commands: &mut Commands, _entity: Entity, _state: Self) {}
#[inline]
fn trigger_exit_variant(_commands: &mut Commands, _entity: Entity, _state: Self) {}
#[inline]
fn trigger_transition_variant(
_commands: &mut Commands,
_entity: Entity,
_from: Self,
_to: Self,
) {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect)]
pub enum RuleType {
All,
None,
Whitelist,
Blacklist,
}
#[derive(Component, Reflect, Debug)]
#[reflect(Component)]
pub struct FSMOverride<S: Copy + Eq + core::hash::Hash + Send + Sync + 'static> {
pub mode: RuleType,
transitions: HashSet<(S, S)>,
pub call_rules: bool,
}
impl<S> Default for FSMOverride<S>
where
S: Copy + Eq + core::hash::Hash + Send + Sync + 'static,
{
fn default() -> Self {
Self {
mode: RuleType::All,
transitions: HashSet::new(),
call_rules: false,
}
}
}
impl<S> FSMOverride<S>
where
S: Copy + Eq + core::hash::Hash + Send + Sync + 'static,
{
#[must_use]
pub fn allow_all() -> Self {
Self {
mode: RuleType::All,
transitions: HashSet::new(),
call_rules: false,
}
}
#[must_use]
pub fn deny_all() -> Self {
Self {
mode: RuleType::None,
transitions: HashSet::new(),
call_rules: false,
}
}
pub fn whitelist<I>(edges: I) -> Self
where
I: IntoIterator<Item = (S, S)>,
{
Self {
mode: RuleType::Whitelist,
transitions: edges.into_iter().collect(),
call_rules: false,
}
}
pub fn blacklist<I>(edges: I) -> Self
where
I: IntoIterator<Item = (S, S)>,
{
Self {
mode: RuleType::Blacklist,
transitions: edges.into_iter().collect(),
call_rules: false,
}
}
#[must_use]
pub fn with_rules(mut self) -> Self {
self.call_rules = true;
self
}
#[must_use]
pub fn and_allow<I>(mut self, edges: I) -> Self
where
I: IntoIterator<Item = (S, S)>,
{
self.transitions.extend(edges);
self
}
#[must_use]
pub fn and_deny<I>(mut self, edges: I) -> Self
where
I: IntoIterator<Item = (S, S)>,
{
self.transitions.extend(edges);
self
}
pub fn is_transition_allowed(&self, from: S, to: S) -> bool {
match self.mode {
RuleType::All => true,
RuleType::None => false,
RuleType::Whitelist => self.transitions.contains(&(from, to)),
RuleType::Blacklist => !self.transitions.contains(&(from, to)),
}
}
}
#[allow(clippy::needless_pass_by_value)]
pub fn on_fsm_added<S: FSMState>(trigger: On<Add, S>, mut commands: Commands, q_state: Query<&S>) {
let entity = trigger.entity;
let Ok(&state) = q_state.get(entity) else {
return;
};
commands.trigger(Enter::<S> { entity, state });
S::trigger_enter_variant(&mut commands, entity, state);
}
#[allow(clippy::needless_pass_by_value)]
pub fn apply_state_request<S: FSMState + core::hash::Hash>(
trigger: On<StateChangeRequest<S>>,
mut commands: Commands,
world: &World,
q_state: Query<&S>,
) {
let entity = trigger.event().entity;
let current = q_state.get(entity).ok().copied();
if let Some(cur) = current {
let next = trigger.event().next;
if cur == next {
return;
}
if let Some(cfg) = world.get::<FSMOverride<S>>(entity) {
let in_set = cfg.transitions.contains(&(cur, next));
match cfg.mode {
RuleType::All => {
if cfg.call_rules
&& !<S as FSMState>::can_transition_ctx(world, entity, cur, next)
{
return;
}
}
RuleType::None => {
return;
}
RuleType::Whitelist => {
if in_set {
} else {
if cfg.call_rules {
if !<S as FSMState>::can_transition_ctx(world, entity, cur, next) {
return;
}
} else {
return;
}
}
}
RuleType::Blacklist => {
if in_set {
return;
}
if cfg.call_rules
&& !<S as FSMState>::can_transition_ctx(world, entity, cur, next)
{
return;
}
}
}
} else {
if !<S as FSMState>::can_transition_ctx(world, entity, cur, next) {
return;
}
}
commands.trigger(Exit::<S> { entity, state: cur });
S::trigger_exit_variant(&mut commands, entity, cur);
commands.trigger(Transition::<S, S> {
entity,
from: cur,
to: next,
});
S::trigger_transition_variant(&mut commands, entity, cur, next);
commands.entity(entity).insert(next);
commands.trigger(Enter::<S> {
entity,
state: next,
});
S::trigger_enter_variant(&mut commands, entity, next);
}
}
pub struct FSMPlugin<S: FSMState + core::hash::Hash + Component> {
ignore_fsm_addition: bool,
_phantom: std::marker::PhantomData<S>,
}
impl<S: FSMState + core::hash::Hash + Component> Default for FSMPlugin<S> {
fn default() -> Self {
Self {
ignore_fsm_addition: false,
_phantom: std::marker::PhantomData,
}
}
}
impl<S: FSMState + core::hash::Hash + Component> FSMPlugin<S> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn ignore_fsm_addition(mut self) -> Self {
self.ignore_fsm_addition = true;
self
}
}
impl<S: FSMState + core::hash::Hash + Component + Reflect + GetTypeRegistration> Plugin
for FSMPlugin<S>
{
fn build(&self, app: &mut App) {
app.register_type::<S>();
{
let world = app.world_mut();
let group_entity = ensure_fsm_group::<S>(world);
let apply_entity = {
let mut observer = world.add_observer(apply_state_request::<S>);
observer.insert(Name::new("apply_state_request"));
observer.insert(FSMObserverMarker::<S>::default());
observer.id()
};
world.entity_mut(group_entity).add_child(apply_entity);
if !self.ignore_fsm_addition {
let added_entity = {
let mut observer = world.add_observer(on_fsm_added::<S>);
observer.insert(Name::new("on_fsm_added"));
observer.insert(FSMObserverMarker::<S>::default());
observer.id()
};
world.entity_mut(group_entity).add_child(added_entity);
}
}
}
}
#[derive(Resource)]
struct FSMObserverHierarchy {
root: Entity,
groups: HashMap<TypeId, Entity>,
}
#[derive(Component)]
struct FSMObserversRoot;
fn ensure_fsm_hierarchy(world: &mut World) -> Entity {
if let Some(hierarchy) = world.get_resource::<FSMObserverHierarchy>() {
return hierarchy.root;
}
let root = world
.spawn((Name::new("FSMObservers"), FSMObserversRoot))
.id();
world.insert_resource(FSMObserverHierarchy {
root,
groups: HashMap::default(),
});
root
}
fn ensure_fsm_group<S>(world: &mut World) -> Entity
where
S: Send + Sync + 'static,
{
let root = ensure_fsm_hierarchy(world);
let type_id = TypeId::of::<S>();
if let Some(group) = {
let hierarchy = world.resource::<FSMObserverHierarchy>();
hierarchy.groups.get(&type_id).copied()
} {
return group;
}
let type_name = std::any::type_name::<S>()
.split("::")
.last()
.unwrap_or("UnknownFSM")
.to_string();
let group = world
.spawn((Name::new(type_name), FSMObserverGroup::<S>::default()))
.id();
world.entity_mut(root).add_child(group);
world
.resource_mut::<FSMObserverHierarchy>()
.groups
.insert(type_id, group);
group
}
pub fn attach_observer_to_group<S>(world: &mut World, observer: Entity)
where
S: Send + Sync + 'static,
{
let group_entity = ensure_fsm_group::<S>(world);
world.entity_mut(group_entity).add_child(observer);
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Component, Clone, Copy, Debug, Hash, PartialEq, Eq)]
enum TestState {
A,
B,
C,
}
impl FSMState for TestState {}
impl FSMTransition for TestState {
fn can_transition(from: Self, to: Self) -> bool {
!(matches!(from, TestState::A) && matches!(to, TestState::C))
}
}
#[derive(Resource, Default)]
struct EventLog {
enters: Vec<TestState>,
exits: Vec<TestState>,
transitions: Vec<(TestState, TestState)>,
}
#[allow(clippy::needless_pass_by_value)]
fn on_enter(trigger: On<Enter<TestState>>, mut log: ResMut<EventLog>) {
log.enters.push(trigger.event().state);
}
#[allow(clippy::needless_pass_by_value)]
fn on_exit(trigger: On<Exit<TestState>>, mut log: ResMut<EventLog>) {
log.exits.push(trigger.event().state);
}
#[allow(clippy::needless_pass_by_value)]
fn on_transition(trigger: On<Transition<TestState, TestState>>, mut log: ResMut<EventLog>) {
let event = trigger.event();
log.transitions.push((event.from, event.to));
}
#[test]
fn transitions_apply_and_fire_events() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<EventLog>();
app.world_mut()
.add_observer(apply_state_request::<TestState>);
app.world_mut().add_observer(on_enter);
app.world_mut().add_observer(on_exit);
let e = app.world_mut().spawn(TestState::A).id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
assert_eq!(*app.world().get::<TestState>(e).unwrap(), TestState::B);
let log = app.world().resource::<EventLog>();
assert_eq!(log.exits, vec![TestState::A]);
assert_eq!(log.enters, vec![TestState::B]);
}
#[test]
fn guard_blocks_invalid_transitions() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<EventLog>();
app.world_mut()
.add_observer(apply_state_request::<TestState>);
let e = app.world_mut().spawn(TestState::A).id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::C,
});
app.update();
assert_eq!(*app.world().get::<TestState>(e).unwrap(), TestState::A);
}
#[test]
fn generic_transition_events_fire() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<EventLog>();
app.world_mut()
.add_observer(apply_state_request::<TestState>);
app.world_mut().add_observer(on_transition);
let e = app.world_mut().spawn(TestState::A).id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
let log = app.world().resource::<EventLog>();
assert_eq!(log.transitions, vec![(TestState::A, TestState::B)]);
}
#[test]
fn on_fsm_added_fires_initial_enter_events() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<EventLog>();
app.world_mut()
.add_observer(apply_state_request::<TestState>);
app.world_mut().add_observer(on_fsm_added::<TestState>);
app.world_mut().add_observer(on_enter);
let _e = app.world_mut().spawn(TestState::A).id();
app.update();
let log = app.world().resource::<EventLog>();
assert_eq!(log.enters, vec![TestState::A]);
}
#[test]
fn fsm_plugin_registers_core_observers_with_teststate() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<EventLog>();
app.world_mut()
.add_observer(apply_state_request::<TestState>);
app.world_mut().add_observer(on_fsm_added::<TestState>);
app.world_mut().add_observer(on_enter);
let e = app.world_mut().spawn(TestState::A).id();
app.update();
let log = app.world().resource::<EventLog>();
assert_eq!(log.enters, vec![TestState::A]);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
assert_eq!(*app.world().get::<TestState>(e).unwrap(), TestState::B);
}
#[allow(clippy::too_many_lines, clippy::uninlined_format_args)]
#[test]
fn fsm_observer_macro_registers_and_organizes() {
println!("\n=== TEST START: fsm_observer_macro_registers_and_organizes ===");
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<EventLog>();
app.world_mut()
.add_observer(apply_state_request::<TestState>);
println!("✓ App initialized with MinimalPlugins and apply_state_request observer");
let observer_entity = fsm_observer!(app, TestState, on_enter).id();
println!("✓ fsm_observer! macro called for TestState::on_enter");
println!(" Observer entity created: {:?}", observer_entity);
{
let world = app.world();
if let Some(name) = world.get::<Name>(observer_entity) {
println!(" Observer entity has Name: '{}'", name.as_str());
} else {
println!(" WARNING: Observer entity has NO Name component!");
}
if world
.get::<FSMObserverMarker<TestState>>(observer_entity)
.is_some()
{
println!(" Observer entity has FSMObserverMarker<TestState>");
} else {
println!(" WARNING: Observer entity has NO FSMObserverMarker!");
}
if let Some(child_of) = world.get::<ChildOf>(observer_entity) {
println!(
" Observer entity has ChildOf component, parent: {:?}",
child_of.parent()
);
} else {
println!(" WARNING: Observer entity has NO ChildOf component!");
println!(" This indicates add_child() might not be working as expected!");
}
}
println!("\n--- Checking if observer entity still exists BEFORE spawning test entity ---");
{
let world = app.world();
if world.get_entity(observer_entity).is_ok() {
println!(" ✓ Observer entity {:?} still exists", observer_entity);
} else {
println!(
" ERROR: Observer entity {:?} already despawned!",
observer_entity
);
}
}
let e = app.world_mut().spawn(TestState::A).id();
println!("✓ Entity spawned with TestState::A, entity id: {:?}", e);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
println!("✓ StateChangeRequest triggered: A -> B");
app.update();
println!("✓ App updated (commands flushed)");
println!("\n--- Checking if observer entity still exists AFTER app.update() ---");
{
let world = app.world();
if world.get_entity(observer_entity).is_ok() {
println!(" ✓ Observer entity {:?} still exists", observer_entity);
if let Some(name) = world.get::<Name>(observer_entity) {
println!(" Name: '{}'", name.as_str());
} else {
println!(" WARNING: Name component removed!");
}
} else {
println!(
" ERROR: Observer entity {:?} was despawned during app.update()!",
observer_entity
);
}
}
let log = app.world().resource::<EventLog>();
println!(
"✓ EventLog retrieved: enters={:?}, exits={:?}",
log.enters, log.exits
);
assert_eq!(log.enters, vec![TestState::B]);
println!("\n--- Checking FSMObservers root ---");
let root = {
let mut query = app
.world_mut()
.query_filtered::<(Entity, &Name), With<FSMObserversRoot>>();
let roots: Vec<_> = query.iter(app.world()).collect();
println!("Found {} FSMObserversRoot entities:", roots.len());
for (entity, name) in &roots {
println!(" - Entity {:?}, Name: '{}'", entity, name.as_str());
}
query
.iter(app.world())
.map(|(entity, _)| entity)
.next()
.expect("FSMObservers root entity should be created")
};
println!("✓ Root entity: {:?}", root);
println!("\n--- Checking TestState group ---");
let group = {
let mut query = app
.world_mut()
.query_filtered::<(Entity, &Name), With<FSMObserverGroup<TestState>>>();
let groups: Vec<_> = query.iter(app.world()).collect();
println!(
"Found {} FSMObserverGroup<TestState> entities:",
groups.len()
);
for (entity, name) in &groups {
println!(" - Entity {:?}, Name: '{}'", entity, name.as_str());
}
query
.iter(app.world())
.map(|(entity, _)| entity)
.next()
.expect("TestState group should exist")
};
println!("✓ Group entity: {:?}", group);
println!("\n--- Checking ALL entities with Name component ---");
{
let mut query = app.world_mut().query::<(Entity, &Name)>();
let all_named: Vec<_> = query.iter(app.world()).collect();
println!("Found {} entities with Name component:", all_named.len());
for (entity, name) in &all_named {
println!(" - Entity {:?}, Name: '{}'", entity, name.as_str());
}
}
println!("\n--- Searching specifically for 'on_enter' observer ---");
{
let mut query = app.world_mut().query::<(Entity, &Name)>();
let on_enter_entities: Vec<_> = query
.iter(app.world())
.filter(|(_, name)| name.as_str() == "on_enter")
.collect();
println!(
"Found {} entities named 'on_enter':",
on_enter_entities.len()
);
for (entity, name) in &on_enter_entities {
println!(" - Entity {:?}, Name: '{}'", entity, name.as_str());
}
}
println!("\n--- Checking hierarchy relationships (ChildOf) ---");
let mut group_is_child = false;
let mut observer_is_child = false;
{
println!("Checking all entities with ChildOf component:");
let mut child_count = 0;
let mut query = app.world_mut().query::<(Entity, &ChildOf, Option<&Name>)>();
for (entity_id, child_of, name) in query.iter(app.world()) {
child_count += 1;
let name_str = name.map_or("<no name>", Name::as_str);
println!(
" - Entity {:?}, Name: '{}', Parent: {:?}",
entity_id,
name_str,
child_of.parent()
);
if child_of.parent() == root && name_str == "TestState" {
println!(" ✓ MATCH: This is the TestState group as child of root");
group_is_child = true;
}
if child_of.parent() == group && name_str == "on_enter" {
println!(" ✓ MATCH: This is the on_enter observer as child of group");
observer_is_child = true;
}
}
println!("Found {} entities with ChildOf component", child_count);
}
println!("\n--- ALL ENTITIES IN WORLD ---");
{
let mut count = 0;
let mut query = app.world_mut().query::<(Entity, Option<&Name>)>();
for (entity_id, name) in query.iter(app.world()) {
count += 1;
print!(" - Entity {:?}", entity_id);
if let Some(name) = name {
print!(", Name: '{}'", name.as_str());
}
println!();
}
println!(" Total entities: {}", count);
}
println!("\n--- Final assertions ---");
println!("group_is_child: {}", group_is_child);
println!("observer_is_child: {}", observer_is_child);
assert!(
group_is_child,
"TestState group should be child of FSMObservers"
);
assert!(
observer_is_child,
"Observer should be parented under the TestState group"
);
println!("\n=== TEST END ===\n");
}
#[test]
fn fsm_config_whitelist_mode() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.world_mut()
.add_observer(apply_state_request::<TestState>);
let e = app
.world_mut()
.spawn((
TestState::A,
FSMOverride::whitelist([
(TestState::A, TestState::B),
(TestState::A, TestState::C), ]),
))
.id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
assert_eq!(*app.world().get::<TestState>(e).unwrap(), TestState::B);
app.world_mut().entity_mut(e).insert(TestState::A);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::C,
});
app.update();
assert_eq!(
*app.world().get::<TestState>(e).unwrap(),
TestState::C,
"Whitelist should override FSMTransition when call_rules is false"
);
}
#[test]
fn fsm_config_whitelist_with_rules() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.world_mut()
.add_observer(apply_state_request::<TestState>);
let e = app
.world_mut()
.spawn((
TestState::A,
FSMOverride::whitelist([
(TestState::A, TestState::C), ])
.with_rules(),
))
.id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::C,
});
app.update();
assert_eq!(
*app.world().get::<TestState>(e).unwrap(),
TestState::C,
"Whitelisted transition should accept regardless of FSMTransition"
);
app.world_mut().entity_mut(e).insert(TestState::A);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
assert_eq!(
*app.world().get::<TestState>(e).unwrap(),
TestState::B,
"Non-whitelisted but FSMTransition-valid transition should succeed with with_rules"
);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::C,
});
app.update();
assert_eq!(
*app.world().get::<TestState>(e).unwrap(),
TestState::C,
"Non-whitelisted transition should defer to FSMTransition when with_rules enabled"
);
}
#[test]
fn fsm_config_blacklist_mode() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.world_mut()
.add_observer(apply_state_request::<TestState>);
let e = app
.world_mut()
.spawn((
TestState::A,
FSMOverride::blacklist([(TestState::A, TestState::C)]),
))
.id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
assert_eq!(*app.world().get::<TestState>(e).unwrap(), TestState::B);
app.world_mut().entity_mut(e).insert(TestState::A);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::C,
});
app.update();
assert_eq!(
*app.world().get::<TestState>(e).unwrap(),
TestState::A,
"Blacklisted transition should be denied"
);
}
#[test]
fn fsm_config_blacklist_with_rules() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.world_mut()
.add_observer(apply_state_request::<TestState>);
let e = app
.world_mut()
.spawn((
TestState::A,
FSMOverride::blacklist([(TestState::B, TestState::C)]).with_rules(),
))
.id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::C,
});
app.update();
assert_eq!(
*app.world().get::<TestState>(e).unwrap(),
TestState::A,
"FSMTransition should block A->C even though not blacklisted"
);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
assert_eq!(*app.world().get::<TestState>(e).unwrap(), TestState::B);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::C,
});
app.update();
assert_eq!(
*app.world().get::<TestState>(e).unwrap(),
TestState::B,
"Blacklist should block B->C"
);
}
#[test]
fn fsm_config_deny_all_mode() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.world_mut()
.add_observer(apply_state_request::<TestState>);
let e = app
.world_mut()
.spawn((TestState::A, FSMOverride::<TestState>::deny_all()))
.id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
assert_eq!(*app.world().get::<TestState>(e).unwrap(), TestState::A);
}
#[test]
fn fsm_config_allow_all_mode() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.world_mut()
.add_observer(apply_state_request::<TestState>);
let e = app
.world_mut()
.spawn((TestState::A, FSMOverride::<TestState>::allow_all()))
.id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
assert_eq!(*app.world().get::<TestState>(e).unwrap(), TestState::B);
app.world_mut().entity_mut(e).insert(TestState::A);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::C,
});
app.update();
assert_eq!(
*app.world().get::<TestState>(e).unwrap(),
TestState::C,
"allow_all without call_rules should bypass FSMTransition"
);
}
#[test]
fn fsm_config_allow_all_with_rules() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.world_mut()
.add_observer(apply_state_request::<TestState>);
let e = app
.world_mut()
.spawn((
TestState::A,
FSMOverride::<TestState>::allow_all().with_rules(),
))
.id();
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::B,
});
app.update();
assert_eq!(*app.world().get::<TestState>(e).unwrap(), TestState::B);
app.world_mut().entity_mut(e).insert(TestState::A);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<TestState> {
entity: e,
next: TestState::C,
});
app.update();
assert_eq!(
*app.world().get::<TestState>(e).unwrap(),
TestState::A,
"allow_all with call_rules should enforce FSMTransition"
);
}
#[derive(Component, Reflect, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[reflect(Component)]
enum PluginTestState {
Initial,
Active,
Done,
}
impl FSMState for PluginTestState {}
impl FSMTransition for PluginTestState {
fn can_transition(from: Self, to: Self) -> bool {
matches!(
(from, to),
(PluginTestState::Initial, PluginTestState::Active)
| (PluginTestState::Active, PluginTestState::Done)
) || from == to
}
}
#[derive(Resource, Default)]
struct PluginEventLog {
enters: Vec<PluginTestState>,
}
#[allow(clippy::needless_pass_by_value)]
fn on_plugin_enter(trigger: On<Enter<PluginTestState>>, mut log: ResMut<PluginEventLog>) {
log.enters.push(trigger.event().state);
}
#[test]
fn fsm_plugin_fires_initial_enter_event() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.init_resource::<PluginEventLog>();
app.add_plugins(FSMPlugin::<PluginTestState>::default());
app.world_mut().add_observer(on_plugin_enter);
let entity = app.world_mut().spawn(PluginTestState::Initial).id();
app.update();
let log = app.world().resource::<PluginEventLog>();
assert_eq!(
log.enters,
vec![PluginTestState::Initial],
"FSMPlugin should fire Enter event for initial state when entity is spawned"
);
app.world_mut()
.commands()
.trigger(StateChangeRequest::<PluginTestState> {
entity,
next: PluginTestState::Active,
});
app.update();
assert_eq!(
*app.world().get::<PluginTestState>(entity).unwrap(),
PluginTestState::Active
);
let log = app.world().resource::<PluginEventLog>();
assert_eq!(
log.enters,
vec![PluginTestState::Initial, PluginTestState::Active],
"FSMPlugin should fire Enter events for both initial state and transitions"
);
}
}