use alloc::collections::BTreeMap;
use alloc::string::ToString;
use alloc::vec;
use alloc::vec::Vec;
use std::sync::{Arc, Mutex};
use super::*;
use crate::components::{
Behavior, BehaviorExpr, BehaviorLiteral, BehaviorLocal, BehaviorNode, BehaviorQuery,
DespawnRequest, EntityTarget, PropInstance, SpawnRequest, StoryCommand, StoryPlayback,
Transform, VariableDecl, VisibilityRequest,
};
use crate::ecs::{
ExecutionTrace, HEADLESS_SYSTEMS, TraceEvent, TracePaths, TraceStep, TraceVal, World,
asset_id::AssetId,
};
use super::test_world::{TestWorld, world_with};
fn system(world: &mut TestWorld) -> BehaviorSystem {
let mut sys = BehaviorSystem::new();
sys.init(&mut world.ctx());
sys
}
fn entities_matching(ctx: &PipelineContext, tags: &[u8]) -> Vec<Entity> {
let mut scratch = Vec::new();
let mut out = Vec::new();
BehaviorSystem::entities_matching_into(ctx, tags, &mut scratch, &mut out);
out
}
fn tick(sys: &mut BehaviorSystem, world: &mut TestWorld, dt: f32) {
world.elapsed += dt;
let elapsed = world.elapsed;
sys.tick(&mut world.ctx(), dt, elapsed);
}
fn spawn_prop(world: &mut TestWorld, position: [f32; 3]) -> Entity {
let entity = world.components.push_typed(PropInstance);
world.components.insert_typed(
entity,
Transform {
position,
rotation_deg: [0.0; 3],
scale: [1.0; 3],
},
);
entity
}
fn set_var(var: &str, value: i32, add: bool) -> BehaviorNode {
BehaviorNode::Set {
var: var.to_string(),
value: BehaviorExpr::Int(value),
add,
}
}
fn var(sys: &BehaviorSystem, name: &str) -> i32 {
match var_val(sys, name) {
Val::Int(i) => i,
other => panic!("variable '{name}' is {other:?}, not an int"),
}
}
fn var_val(sys: &BehaviorSystem, name: &str) -> Val {
sys.var_table
.slot_of(name)
.and_then(|s| sys.vars.get(s as usize))
.copied()
.unwrap_or(Val::Int(0))
}
fn count<E: 'static>(world: &mut TestWorld, cursor: &mut EventCursor) -> usize {
world
.ctx()
.events::<E>()
.map(|e| e.read(cursor).count())
.unwrap_or(0)
}
#[test]
fn start_fires_once_and_writes_a_variable() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Start,
body: vec![set_var("visits", 1, true)],
..Default::default()
}]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "visits"), 1);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "visits"), 1, "start fires exactly once");
}
#[test]
fn tick_source_fires_every_tick() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
body: vec![set_var("n", 1, true)],
..Default::default()
}]);
let mut sys = system(&mut world);
for _ in 0..3 {
tick(&mut sys, &mut world, 0.016);
}
assert_eq!(var(&sys, "n"), 3);
}
#[test]
fn a_scoped_behavior_runs_once_per_matching_entity() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
body: vec![set_var("seen", 1, true)],
..Default::default()
}]);
spawn_prop(&mut world, [0.0; 3]);
spawn_prop(&mut world, [1.0, 0.0, 0.0]);
spawn_prop(&mut world, [2.0, 0.0, 0.0]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "seen"), 3, "one run per Prop");
}
#[test]
fn locals_are_per_entity() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
locals: vec![BehaviorLocal {
name: "count".into(),
value: BehaviorLiteral::Int(0),
}],
body: vec![BehaviorNode::SetLocal {
local: "count".into(),
value: BehaviorExpr::Int(1),
add: true,
}],
..Default::default()
}]);
spawn_prop(&mut world, [0.0; 3]);
spawn_prop(&mut world, [1.0, 0.0, 0.0]);
let mut sys = system(&mut world);
for _ in 0..4 {
tick(&mut sys, &mut world, 0.016);
}
let locals: Vec<Val> = sys.instances[0].iter().map(|i| i.locals[0]).collect();
assert_eq!(
locals,
vec![Val::Int(4), Val::Int(4)],
"each entity counts independently"
);
}
#[test]
fn self_moves_only_its_own_entity() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
body: vec![BehaviorNode::SetTransform {
entity: BehaviorExpr::SelfEntity,
position: Some(BehaviorExpr::Add(
Box::new(BehaviorExpr::Position(Box::new(BehaviorExpr::SelfEntity))),
Box::new(BehaviorExpr::Vec3([1.0, 0.0, 0.0])),
)),
rotation_deg: None,
scale: None,
}],
..Default::default()
}]);
let a = spawn_prop(&mut world, [0.0; 3]);
let b = spawn_prop(&mut world, [10.0, 0.0, 0.0]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
let ctx = world.ctx();
assert_eq!(ctx.get::<Transform>(a).unwrap().position, [1.0, 0.0, 0.0]);
assert_eq!(ctx.get::<Transform>(b).unwrap().position, [11.0, 0.0, 0.0]);
}
#[test]
fn a_query_counts_matching_entities() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
queries: vec![BehaviorQuery {
name: "props".into(),
has: vec!["Prop".into()],
}],
body: vec![BehaviorNode::Set {
var: "n".into(),
value: BehaviorExpr::Count("props".into()),
add: false,
}],
..Default::default()
}]);
spawn_prop(&mut world, [0.0; 3]);
spawn_prop(&mut world, [1.0, 0.0, 0.0]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "n"), 2);
}
#[test]
fn for_each_binds_every_queried_entity() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
queries: vec![BehaviorQuery {
name: "props".into(),
has: vec!["Prop".into()],
}],
body: vec![BehaviorNode::ForEach {
query: "props".into(),
bind: "e".into(),
body: vec![BehaviorNode::SetTransform {
entity: BehaviorExpr::Bind("e".into()),
position: Some(BehaviorExpr::Vec3([5.0, 0.0, 0.0])),
rotation_deg: None,
scale: None,
}],
}],
..Default::default()
}]);
let a = spawn_prop(&mut world, [0.0; 3]);
let b = spawn_prop(&mut world, [1.0, 0.0, 0.0]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
let ctx = world.ctx();
assert_eq!(ctx.get::<Transform>(a).unwrap().position, [5.0, 0.0, 0.0]);
assert_eq!(ctx.get::<Transform>(b).unwrap().position, [5.0, 0.0, 0.0]);
}
#[test]
fn distance_gates_a_condition() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
queries: vec![BehaviorQuery {
name: "props".into(),
has: vec!["Prop".into()],
}],
body: vec![
BehaviorNode::Let {
name: "first".into(),
value: BehaviorExpr::First("props".into()),
},
BehaviorNode::If {
cond: BehaviorExpr::Lt(
Box::new(BehaviorExpr::Distance(
Box::new(BehaviorExpr::Bind("first".into())),
Box::new(BehaviorExpr::Named(Some(AssetId(7)))),
)),
Box::new(BehaviorExpr::Float(5.0)),
),
then: vec![set_var("near", 1, false)],
otherwise: vec![set_var("near", 0, false)],
},
],
..Default::default()
}]);
let near = spawn_prop(&mut world, [0.0; 3]);
let far = spawn_prop(&mut world, [100.0, 0.0, 0.0]);
let mut index = BTreeMap::new();
index.insert(AssetId(7), far);
world.resources.insert(EntityByName(index));
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "near"), 0, "100 units apart is not near");
world
.components
.get_mut::<Transform>(near)
.unwrap()
.position = [98.0, 0.0, 0.0];
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "near"), 1);
}
#[test]
fn despawn_of_self_addresses_the_entity_not_a_name() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
body: vec![BehaviorNode::Despawn {
target: BehaviorExpr::SelfEntity,
}],
..Default::default()
}]);
let entity = spawn_prop(&mut world, [0.0; 3]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
let mut cursor = EventCursor::default();
let requests: Vec<EntityTarget> = world
.ctx()
.events::<DespawnRequest>()
.map(|e| e.read(&mut cursor).map(|r| r.target).collect())
.unwrap_or_default();
assert_eq!(requests, vec![EntityTarget::Entity(entity)]);
}
#[test]
fn a_timer_fires_on_its_interval() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Timer {
interval: 1.0,
repeat: true,
},
body: vec![set_var("ticks", 1, true)],
..Default::default()
}]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.5);
assert_eq!(var(&sys, "ticks"), 0);
tick(&mut sys, &mut world, 0.6);
assert_eq!(var(&sys, "ticks"), 1);
tick(&mut sys, &mut world, 1.0);
assert_eq!(var(&sys, "ticks"), 2);
}
#[test]
fn a_delay_postpones_the_body() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Start,
delay: 1.0,
body: vec![set_var("late", 1, false)],
..Default::default()
}]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "late"), 0, "the delay has not elapsed");
tick(&mut sys, &mut world, 1.5);
assert_eq!(var(&sys, "late"), 1);
}
#[test]
fn a_variable_source_advances_one_link_per_tick() {
let mut world = world_with(vec![
Behavior {
on: BehaviorSource::Start,
body: vec![set_var("a", 1, false)],
..Default::default()
},
Behavior {
on: BehaviorSource::Variable("a".into()),
body: vec![set_var("b", 1, false)],
..Default::default()
},
]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "a"), 1);
assert_eq!(var(&sys, "b"), 0, "the chain has not advanced yet");
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "b"), 1);
}
#[test]
fn spawn_emits_a_request_and_binds_nothing_this_tick() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Start,
body: vec![
BehaviorNode::Spawn {
template: Some(AssetId(3)),
position: [0.0, 1.0, 0.0],
rotation_deg: [0.0; 3],
scale: [1.0; 3],
lifetime: 2.0,
bind: Some("made".into()),
},
BehaviorNode::Despawn {
target: BehaviorExpr::Bind("made".into()),
},
],
..Default::default()
}]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
let mut spawn_cursor = EventCursor::default();
assert_eq!(count::<SpawnRequest>(&mut world, &mut spawn_cursor), 1);
let mut despawn_cursor = EventCursor::default();
assert_eq!(
count::<DespawnRequest>(&mut world, &mut despawn_cursor),
0,
"a spawn binding holds nothing until the request is applied"
);
}
#[test]
fn instances_track_entities_appearing_and_disappearing() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
body: vec![set_var("runs", 1, true)],
..Default::default()
}]);
let first = spawn_prop(&mut world, [0.0; 3]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "runs"), 1);
spawn_prop(&mut world, [1.0, 0.0, 0.0]);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "runs"), 3, "two entities now match");
world.components.despawn(first);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "runs"), 4, "one entity remains");
}
#[test]
fn spawned_does_not_fire_for_the_initial_population() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Spawned,
scope: vec!["Prop".into()],
body: vec![set_var("born", 1, true)],
..Default::default()
}]);
spawn_prop(&mut world, [0.0; 3]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "born"), 0, "authored entities are not spawned");
spawn_prop(&mut world, [1.0, 0.0, 0.0]);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "born"), 1, "an entity that appeared later is");
}
#[test]
fn query_order_is_stable_across_a_removal() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
queries: vec![BehaviorQuery {
name: "props".into(),
has: vec!["Prop".into()],
}],
body: vec![],
..Default::default()
}]);
let a = spawn_prop(&mut world, [0.0; 3]);
let b = spawn_prop(&mut world, [1.0, 0.0, 0.0]);
let c = spawn_prop(&mut world, [2.0, 0.0, 0.0]);
let sys = system(&mut world);
let before = entities_matching(&world.ctx(), &sys.programs[0].queries[0]);
assert_eq!(before, vec![a, b, c]);
world.components.despawn(b);
let after = entities_matching(&world.ctx(), &sys.programs[0].queries[0]);
assert_eq!(after, vec![a, c], "the survivors keep their relative order");
}
#[test]
fn a_query_intersects_every_declared_component() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
queries: vec![BehaviorQuery {
name: "placed".into(),
has: vec!["Prop".into(), "Transform".into()],
}],
body: vec![],
..Default::default()
}]);
let placed = spawn_prop(&mut world, [0.0; 3]);
world.components.push_typed(PropInstance);
let sys = system(&mut world);
let matched = entities_matching(&world.ctx(), &sys.programs[0].queries[0]);
assert_eq!(matched, vec![placed]);
}
#[test]
fn each_run_gets_back_exactly_the_effects_it_produced() {
let mut world = world_with(vec![
Behavior {
asset_id: AssetId(1),
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
locals: vec![BehaviorLocal {
name: "one".into(),
value: BehaviorLiteral::Int(0),
}],
body: vec![BehaviorNode::SetLocal {
local: "one".into(),
value: BehaviorExpr::Int(1),
add: true,
}],
..Default::default()
},
Behavior {
asset_id: AssetId(2),
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
locals: vec![BehaviorLocal {
name: "many".into(),
value: BehaviorLiteral::Int(0),
}],
body: vec![
BehaviorNode::SetLocal {
local: "many".into(),
value: BehaviorExpr::Int(10),
add: true,
},
BehaviorNode::SetLocal {
local: "many".into(),
value: BehaviorExpr::Int(100),
add: true,
},
],
..Default::default()
},
]);
spawn_prop(&mut world, [0.0; 3]);
spawn_prop(&mut world, [1.0, 0.0, 0.0]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
let one: Vec<Val> = sys.instances[0].iter().map(|i| i.locals[0]).collect();
let many: Vec<Val> = sys.instances[1].iter().map(|i| i.locals[0]).collect();
assert_eq!(one, vec![Val::Int(1), Val::Int(1)], "one effect each");
assert_eq!(
many,
vec![Val::Int(110), Val::Int(110)],
"both effects each"
);
}
#[test]
fn alive_follows_the_world_not_this_ticks_queries() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
queries: vec![BehaviorQuery {
name: "props".into(),
has: vec!["Prop".into()],
}],
body: vec![
BehaviorNode::Set {
var: "target".into(),
value: BehaviorExpr::First("props".into()),
add: false,
},
BehaviorNode::If {
cond: BehaviorExpr::Alive(Box::new(BehaviorExpr::Var("target".into()))),
then: vec![set_var("still_here", 1, false)],
otherwise: vec![set_var("still_here", 0, false)],
},
],
..Default::default()
}]);
let entity = spawn_prop(&mut world, [0.0; 3]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "still_here"), 1, "a matched entity is alive");
world.components.remove_typed::<PropInstance>(entity);
world.components.remove_typed::<Transform>(entity);
tick(&mut sys, &mut world, 0.016);
assert_eq!(
var(&sys, "still_here"),
1,
"leaving every query and losing every component is not death",
);
world.components.despawn(entity);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "still_here"), 0, "a despawned entity is gone");
}
#[test]
fn a_body_reads_positions_from_before_this_ticks_writes() {
let mover = Behavior {
asset_id: AssetId(1),
on: BehaviorSource::Tick,
queries: vec![BehaviorQuery {
name: "props".into(),
has: vec!["Prop".into()],
}],
body: vec![BehaviorNode::SetTransform {
entity: BehaviorExpr::First("props".into()),
position: Some(BehaviorExpr::Vec3([9.0, 0.0, 0.0])),
rotation_deg: None,
scale: None,
}],
..Default::default()
};
let reader = Behavior {
asset_id: AssetId(2),
on: BehaviorSource::Tick,
queries: vec![BehaviorQuery {
name: "props".into(),
has: vec!["Prop".into()],
}],
body: vec![BehaviorNode::Set {
var: "seen_x".into(),
value: BehaviorExpr::Position(Box::new(BehaviorExpr::First("props".into()))),
add: false,
}],
..Default::default()
};
let mut world = world_with(vec![mover, reader]);
let entity = spawn_prop(&mut world, [1.0, 0.0, 0.0]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(
var_val(&sys, "seen_x"),
Val::Vec3([1.0, 0.0, 0.0]),
"the reader saw the position from before the mover wrote",
);
assert_eq!(
world.ctx().get::<Transform>(entity).unwrap().position,
[9.0, 0.0, 0.0],
"the move still landed",
);
}
#[test]
fn a_cooldown_rate_limits_firing() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
cooldown: 1.0,
body: vec![set_var("n", 1, true)],
..Default::default()
}]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.1);
tick(&mut sys, &mut world, 0.1);
assert_eq!(var(&sys, "n"), 1, "the second tick is inside the cooldown");
tick(&mut sys, &mut world, 1.2);
assert_eq!(var(&sys, "n"), 2);
}
#[derive(Debug, Clone, Default)]
struct MemoryStore(Arc<Mutex<Option<BehaviorState>>>);
impl MemoryStore {
fn written(&self) -> bool {
self.0.lock().expect("no test panics holding it").is_some()
}
}
impl BehaviorStore for MemoryStore {
fn read(&self) -> Option<BehaviorState> {
self.0.lock().expect("no test panics holding it").clone()
}
fn write(&self, state: &BehaviorState) {
*self.0.lock().expect("no test panics holding it") = Some(state.clone());
}
}
fn persisting_system(world: &mut TestWorld, store: &MemoryStore) -> BehaviorSystem {
let mut sys = BehaviorSystem::new().with_store(Box::new(store.clone()));
sys.init(&mut world.ctx());
sys
}
fn counter_behavior() -> Behavior {
Behavior {
asset_id: AssetId(1),
body: vec![set_var("visits", 1, true), BehaviorNode::Save],
once: true,
..Default::default()
}
}
fn despawn_named(target: u32) -> BehaviorNode {
BehaviorNode::Despawn {
target: BehaviorExpr::Named(Some(AssetId(target))),
}
}
#[test]
fn a_oneshot_timer_fires_exactly_once() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Timer {
interval: 1.0,
repeat: false,
},
body: vec![set_var("n", 1, true)],
..Default::default()
}]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 1.5);
tick(&mut sys, &mut world, 1.5);
tick(&mut sys, &mut world, 1.5);
assert_eq!(var(&sys, "n"), 1);
}
#[test]
fn once_limits_a_repeating_source() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Tick,
once: true,
body: vec![set_var("n", 1, true)],
..Default::default()
}]);
let mut sys = system(&mut world);
for _ in 0..5 {
tick(&mut sys, &mut world, 0.016);
}
assert_eq!(var(&sys, "n"), 1);
}
#[test]
fn nodes_apply_in_body_order() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Start,
body: vec![
set_var("n", 5, false),
set_var("n", 2, true),
set_var("n", 1, true),
],
..Default::default()
}]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "n"), 8);
}
#[test]
fn enter_fires_on_matching_crossings_only() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Enter(Some(AssetId(5))),
body: vec![despawn_named(7)],
..Default::default()
}]);
let mut index = BTreeMap::new();
let entity = world.components.push_typed(PropInstance);
index.insert(AssetId(7), entity);
world.resources.insert(EntityByName(index));
let mut sys = system(&mut world);
let mut cursor = EventCursor::default();
sys.step(&mut world.ctx());
assert_eq!(count::<DespawnRequest>(&mut world, &mut cursor), 0);
world.ctx().events_mut::<VolumeEvent>().send(VolumeEvent {
volume: AssetId(5),
entered: true,
});
sys.step(&mut world.ctx());
assert_eq!(count::<DespawnRequest>(&mut world, &mut cursor), 1);
world.ctx().events_mut::<VolumeEvent>().send(VolumeEvent {
volume: AssetId(5),
entered: false,
});
world.ctx().events_mut::<VolumeEvent>().send(VolumeEvent {
volume: AssetId(6),
entered: true,
});
sys.step(&mut world.ctx());
assert_eq!(count::<DespawnRequest>(&mut world, &mut cursor), 0);
}
#[test]
fn crossings_survive_a_menu_pause() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Enter(Some(AssetId(5))),
body: vec![despawn_named(7)],
..Default::default()
}]);
let mut index = BTreeMap::new();
let entity = world.components.push_typed(PropInstance);
index.insert(AssetId(7), entity);
world.resources.insert(EntityByName(index));
let mut sys = system(&mut world);
let mut cursor = EventCursor::default();
world.ctx().events_mut::<VolumeEvent>().send(VolumeEvent {
volume: AssetId(5),
entered: true,
});
world.ctx().insert_resource(MenuActive(true));
sys.step(&mut world.ctx());
assert_eq!(count::<DespawnRequest>(&mut world, &mut cursor), 0);
world.ctx().insert_resource(MenuActive(false));
sys.step(&mut world.ctx());
assert_eq!(count::<DespawnRequest>(&mut world, &mut cursor), 1);
}
#[test]
fn interact_fires_on_matching_press_only() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Interact(Some(AssetId(4))),
body: vec![despawn_named(7)],
..Default::default()
}]);
let mut index = BTreeMap::new();
let entity = world.components.push_typed(PropInstance);
index.insert(AssetId(7), entity);
world.resources.insert(EntityByName(index));
let mut sys = system(&mut world);
let mut cursor = EventCursor::default();
world
.ctx()
.events_mut::<InteractEvent>()
.send(InteractEvent { target: AssetId(9) });
sys.step(&mut world.ctx());
assert_eq!(count::<DespawnRequest>(&mut world, &mut cursor), 0);
world
.ctx()
.events_mut::<InteractEvent>()
.send(InteractEvent { target: AssetId(4) });
sys.step(&mut world.ctx());
assert_eq!(count::<DespawnRequest>(&mut world, &mut cursor), 1);
}
#[test]
fn show_and_hide_send_visibility_requests() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Start,
body: vec![
BehaviorNode::Hide {
target: BehaviorExpr::Named(Some(AssetId(3))),
},
BehaviorNode::Show {
target: BehaviorExpr::Named(Some(AssetId(3))),
},
],
..Default::default()
}]);
let mut index = BTreeMap::new();
let entity = world.components.push_typed(PropInstance);
index.insert(AssetId(3), entity);
world.resources.insert(EntityByName(index));
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
let mut cursor = EventCursor::default();
let visible: Vec<bool> = world
.ctx()
.events::<VisibilityRequest>()
.map(|e| e.read(&mut cursor).map(|r| r.visible).collect())
.unwrap_or_default();
assert_eq!(visible, vec![false, true], "hide then show, in body order");
}
#[test]
fn story_sends_the_playback_command() {
let mut world = world_with(vec![Behavior {
on: BehaviorSource::Start,
body: vec![BehaviorNode::Story(StoryPlayback::Continue)],
..Default::default()
}]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
let mut cursor = EventCursor::default();
assert_eq!(count::<StoryCommand>(&mut world, &mut cursor), 1);
}
#[test]
fn save_persists_vars_and_fired_state_across_runs() {
let store = MemoryStore::default();
let mut world = world_with(vec![counter_behavior()]);
let mut sys = persisting_system(&mut world, &store);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "visits"), 1);
let mut world2 = world_with(vec![counter_behavior()]);
let mut sys2 = persisting_system(&mut world2, &store);
assert_eq!(var(&sys2, "visits"), 1, "variable restored at init");
tick(&mut sys2, &mut world2, 0.016);
assert_eq!(
var(&sys2, "visits"),
1,
"the fired once behavior does not fire again"
);
}
#[test]
fn an_edited_behavior_loses_its_persisted_fired_flag() {
let store = MemoryStore::default();
let mut world = world_with(vec![counter_behavior()]);
let mut sys = persisting_system(&mut world, &store);
tick(&mut sys, &mut world, 0.016);
let mut edited = counter_behavior();
edited.body[0] = set_var("visits", 5, true);
let mut world2 = world_with(vec![edited]);
let mut sys2 = persisting_system(&mut world2, &store);
tick(&mut sys2, &mut world2, 0.016);
assert_eq!(var(&sys2, "visits"), 6, "restored 1 + refired add 5");
}
fn branching_behavior() -> Behavior {
Behavior {
asset_id: AssetId(7),
body: vec![BehaviorNode::If {
cond: BehaviorExpr::Bool(true),
then: vec![set_var("n", 1, true)],
otherwise: vec![set_var("n", 5, true)],
}],
..Default::default()
}
}
#[test]
fn without_a_request_no_trace_is_published() {
let mut world = world_with(vec![branching_behavior()]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert!(world.resources.get::<ExecutionTrace>().is_none());
}
#[test]
fn tracing_publishes_events_paths_and_values() {
let mut world = world_with(vec![branching_behavior()]);
world.resources.insert(TraceRequest::default());
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
let trace = world
.resources
.get::<ExecutionTrace>()
.expect("published while requested");
assert_eq!(trace.frame, 1);
let ran = |node| {
trace.events.contains(&TraceEvent {
behavior: AssetId(7),
node,
})
};
assert!(ran(0), "the if node ran");
assert!(ran(1), "the taken then arm ran");
assert!(!ran(2), "the else arm did not");
assert!(
trace.vars.contains(&("n".to_string(), TraceVal::Int(1))),
"world variables carry this tick's values: {:?}",
trace.vars
);
let paths = world
.resources
.get::<TracePaths>()
.expect("published with the first trace");
let of = |id: usize| &paths.0[0].1[id];
assert_eq!(of(0), &vec![TraceStep::Field("do"), TraceStep::Index(0)]);
assert_eq!(
of(1),
&vec![
TraceStep::Field("do"),
TraceStep::Index(0),
TraceStep::Field("if"),
TraceStep::Field("then"),
TraceStep::Index(0),
]
);
assert_eq!(of(2)[3], TraceStep::Field("else"));
}
#[test]
fn a_breakpoint_reports_a_hit() {
let mut world = world_with(vec![branching_behavior()]);
world.resources.insert(TraceRequest {
entity: None,
breakpoints: vec![TraceEvent {
behavior: AssetId(7),
node: 1,
}],
});
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
let trace = world.resources.get::<ExecutionTrace>().unwrap();
assert_eq!(
trace.hit,
Some(TraceEvent {
behavior: AssetId(7),
node: 1,
})
);
}
#[test]
fn tracing_surfaces_the_requested_entitys_locals() {
let scoped = Behavior {
asset_id: AssetId(9),
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
locals: vec![BehaviorLocal {
name: "count".into(),
value: BehaviorLiteral::Int(0),
}],
body: vec![BehaviorNode::SetLocal {
local: "count".into(),
value: BehaviorExpr::Int(1),
add: true,
}],
..Default::default()
};
let mut world = world_with(vec![scoped]);
let entity = spawn_prop(&mut world, [0.0; 3]);
world.resources.insert(TraceRequest {
entity: Some(entity.to_bits()),
breakpoints: Vec::new(),
});
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
tick(&mut sys, &mut world, 0.016);
let trace = world.resources.get::<ExecutionTrace>().unwrap();
assert_eq!(
trace.locals,
vec![(AssetId(9), "count".to_string(), TraceVal::Int(2))]
);
}
#[test]
fn transient_saves_neither_read_nor_write_state() {
let store = MemoryStore::default();
let mut world = world_with(vec![counter_behavior()]);
let mut sys = persisting_system(&mut world, &store);
tick(&mut sys, &mut world, 0.016);
assert!(store.written());
let mut world2 = world_with(vec![counter_behavior()]);
world2.resources.insert(TransientSaves(true));
let sys2 = persisting_system(&mut world2, &store);
assert_eq!(var(&sys2, "visits"), 0, "nothing restored at init");
let untouched = MemoryStore::default();
let mut world3 = world_with(vec![counter_behavior()]);
world3.resources.insert(TransientSaves(true));
let mut sys3 = persisting_system(&mut world3, &untouched);
tick(&mut sys3, &mut world3, 0.016);
assert_eq!(var(&sys3, "visits"), 1, "the once behavior fired fresh");
assert!(!untouched.written(), "nothing written");
}
#[test]
fn worlds_without_a_save_node_never_read_state() {
let store = MemoryStore::default();
let mut world = world_with(vec![counter_behavior()]);
let mut sys = persisting_system(&mut world, &store);
tick(&mut sys, &mut world, 0.016);
let mut world2 = world_with(vec![Behavior {
body: vec![set_var("visits", 0, false)],
..Default::default()
}]);
let sys2 = persisting_system(&mut world2, &store);
assert_eq!(var(&sys2, "visits"), 0);
}
#[test]
fn a_world_without_a_store_runs_and_persists_nothing() {
let mut world = world_with(vec![counter_behavior()]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var(&sys, "visits"), 1);
let mut second = world_with(vec![counter_behavior()]);
let sys2 = system(&mut second);
assert_eq!(var(&sys2, "visits"), 0, "nothing was there to restore");
}
#[test]
fn a_restored_variable_is_not_an_edge_for_variable_sources() {
let store = MemoryStore::default();
let mut world = world_with(vec![
counter_behavior(),
Behavior {
asset_id: AssetId(2),
on: BehaviorSource::Variable("visits".into()),
body: vec![set_var("echo", 1, true)],
..Default::default()
},
]);
let mut sys = persisting_system(&mut world, &store);
tick(&mut sys, &mut world, 0.016);
let mut world2 = world_with(vec![
counter_behavior(),
Behavior {
asset_id: AssetId(2),
on: BehaviorSource::Variable("visits".into()),
body: vec![set_var("echo", 1, true)],
..Default::default()
},
]);
let mut sys2 = persisting_system(&mut world2, &store);
tick(&mut sys2, &mut world2, 0.016);
assert_eq!(var(&sys2, "echo"), 0);
}
#[test]
fn behavior_gates_the_system_and_a_menu_freezes_it() {
let mut world = World::new();
world.add_component(Behavior {
body: vec![BehaviorNode::Story(StoryPlayback::Start)],
..Default::default()
});
world.start(HEADLESS_SYSTEMS).unwrap();
let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
assert_eq!(names, ["BehaviorSystem"]);
let mut cursor = EventCursor::default();
world.insert_resource(MenuActive(true));
world.step();
let fired = world
.events::<StoryCommand>()
.map(|e| e.read(&mut cursor).count())
.unwrap_or(0);
assert_eq!(fired, 0, "a paused world fires nothing");
world.insert_resource(MenuActive(false));
world.step();
let fired = world
.events::<StoryCommand>()
.expect("the behavior fired after unpause")
.read(&mut cursor)
.count();
assert_eq!(fired, 1);
}
fn world_with_vars(behaviors: Vec<Behavior>, vars: Vec<(&str, BehaviorLiteral)>) -> TestWorld {
let mut world = world_with(behaviors);
world.components.push_typed(Variables {
vars: vars
.into_iter()
.map(|(name, value)| VariableDecl {
name: name.to_string(),
value,
})
.collect(),
..Default::default()
});
world
}
#[test]
fn a_declared_variable_starts_at_its_declared_value() {
let mut world = world_with_vars(
vec![Behavior {
on: BehaviorSource::Start,
body: vec![],
..Default::default()
}],
vec![("health", BehaviorLiteral::Float(100.0))],
);
let sys = system(&mut world);
assert_eq!(var_val(&sys, "health"), Val::Float(100.0));
}
#[test]
fn a_float_variable_holds_a_float() {
let mut world = world_with_vars(
vec![Behavior {
on: BehaviorSource::Start,
body: vec![BehaviorNode::Set {
var: "health".into(),
value: BehaviorExpr::Float(-2.5),
add: true,
}],
..Default::default()
}],
vec![("health", BehaviorLiteral::Float(100.0))],
);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(
var_val(&sys, "health"),
Val::Float(97.5),
"a float variable does not truncate"
);
}
#[test]
fn a_vec3_variable_feeds_a_transform() {
let mut world = world_with_vars(
vec![Behavior {
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
body: vec![BehaviorNode::SetTransform {
entity: BehaviorExpr::SelfEntity,
position: Some(BehaviorExpr::Var("spawn".into())),
rotation_deg: None,
scale: None,
}],
..Default::default()
}],
vec![("spawn", BehaviorLiteral::Vec3([1.0, 2.0, 3.0]))],
);
let entity = spawn_prop(&mut world, [0.0; 3]);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(
world.ctx().get::<Transform>(entity).unwrap().position,
[1.0, 2.0, 3.0]
);
}
#[test]
fn an_undeclared_variable_is_still_an_integer() {
let mut world = world_with_vars(
vec![Behavior {
on: BehaviorSource::Start,
body: vec![set_var("loose", 3, true)],
..Default::default()
}],
vec![("health", BehaviorLiteral::Float(1.0))],
);
let mut sys = system(&mut world);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var_val(&sys, "loose"), Val::Int(3));
}
#[test]
fn a_typed_variable_survives_a_save_and_restore() {
let store = MemoryStore::default();
let author = || Behavior {
asset_id: AssetId(1),
on: BehaviorSource::Start,
body: vec![
BehaviorNode::Set {
var: "health".into(),
value: BehaviorExpr::Float(-25.0),
add: true,
},
BehaviorNode::Save,
],
..Default::default()
};
let mut world = world_with_vars(
vec![author()],
vec![("health", BehaviorLiteral::Float(100.0))],
);
let mut sys = persisting_system(&mut world, &store);
tick(&mut sys, &mut world, 0.016);
assert_eq!(var_val(&sys, "health"), Val::Float(75.0));
let mut world2 = world_with_vars(
vec![author()],
vec![("health", BehaviorLiteral::Float(100.0))],
);
let sys2 = persisting_system(&mut world2, &store);
assert_eq!(
var_val(&sys2, "health"),
Val::Float(75.0),
"the float restores as a float"
);
}
#[test]
fn a_retyped_variable_ignores_its_stale_save() {
let store = MemoryStore::default();
let saver = Behavior {
asset_id: AssetId(1),
on: BehaviorSource::Start,
body: vec![
BehaviorNode::Set {
var: "v".into(),
value: BehaviorExpr::Float(7.5),
add: false,
},
BehaviorNode::Save,
],
..Default::default()
};
let mut world = world_with_vars(vec![saver], vec![("v", BehaviorLiteral::Float(0.0))]);
let mut sys = persisting_system(&mut world, &store);
tick(&mut sys, &mut world, 0.016);
let mut world2 = world_with_vars(
vec![Behavior {
asset_id: AssetId(1),
body: vec![BehaviorNode::Save],
..Default::default()
}],
vec![("v", BehaviorLiteral::Int(3))],
);
let sys2 = persisting_system(&mut world2, &store);
assert_eq!(var_val(&sys2, "v"), Val::Int(3));
}
#[derive(Debug)]
struct ThreadedEval;
impl EvalScheduler for ThreadedEval {
fn workers(&self) -> usize {
4
}
fn run(&self, buckets: &mut [EvalBucket], eval: &(dyn Fn(&mut EvalBucket) + Send + Sync)) {
std::thread::scope(|scope| {
for bucket in buckets.iter_mut() {
scope.spawn(move || eval(bucket));
}
});
}
}
#[test]
fn parallel_eval_matches_serial_state() {
fn run(parallel: bool) -> (Vec<i32>, Vec<[f32; 3]>) {
let mover = Behavior {
on: BehaviorSource::Tick,
scope: vec!["Prop".into()],
body: vec![
set_var("total", 1, true),
BehaviorNode::SetTransform {
entity: BehaviorExpr::SelfEntity,
position: Some(BehaviorExpr::Add(
Box::new(BehaviorExpr::Position(Box::new(BehaviorExpr::SelfEntity))),
Box::new(BehaviorExpr::Vec3([0.25, 0.0, 0.0])),
)),
rotation_deg: None,
scale: None,
},
],
..Default::default()
};
let counter = Behavior {
on: BehaviorSource::Tick,
body: vec![BehaviorNode::Set {
var: "double".into(),
value: BehaviorExpr::Mul(
Box::new(BehaviorExpr::Var("total".into())),
Box::new(BehaviorExpr::Int(2)),
),
add: false,
}],
..Default::default()
};
let mut world = world_with(vec![mover, counter]);
if parallel {
world.resources.insert(ScheduleMode::Parallel);
}
for i in 0..150 {
spawn_prop(&mut world, [i as f32, 0.0, 0.0]);
}
let mut sys = BehaviorSystem::new();
if parallel {
sys = sys.with_scheduler(Box::new(ThreadedEval));
}
sys.init(&mut world.ctx());
for _ in 0..5 {
tick(&mut sys, &mut world, 0.016);
}
let vars = vec![var(&sys, "total"), var(&sys, "double")];
let positions = world
.ctx()
.query::<Transform>()
.map(|t| t.position)
.collect();
(vars, positions)
}
let serial = run(false);
let parallel = run(true);
assert_eq!(serial, parallel);
assert_eq!(serial.0[0], 750);
}
fn step(sys: &mut BehaviorSystem, world: &mut TestWorld) {
sys.step(&mut world.ctx());
}
fn counter(id: u32, var: &str, value: i32) -> Behavior {
Behavior {
asset_id: AssetId(id),
on: BehaviorSource::Tick,
body: vec![set_var(var, value, false)],
..Default::default()
}
}
fn edit_behavior(world: &mut TestWorld, at: usize, def: Behavior) {
world.components.values_mut::<Behavior>()[at] = def;
}
#[test]
fn an_edited_body_runs_without_a_reload() {
let mut world = world_with(vec![counter(1, "n", 1)]);
let mut sys = system(&mut world);
step(&mut sys, &mut world);
assert_eq!(var(&sys, "n"), 1);
edit_behavior(&mut world, 0, counter(1, "n", 9));
step(&mut sys, &mut world);
assert_eq!(var(&sys, "n"), 9, "the edited body is what ran");
}
#[test]
fn an_untouched_column_never_recompiles() {
let mut world = world_with(vec![counter(1, "n", 1)]);
let mut sys = system(&mut world);
step(&mut sys, &mut world);
let before = sys.sources;
step(&mut sys, &mut world);
assert_eq!(sys.sources, before, "nothing wrote either column");
}
#[test]
fn an_edit_leaves_another_behaviors_clocks_alone() {
let once = Behavior {
asset_id: AssetId(1),
on: BehaviorSource::Tick,
once: true,
body: vec![set_var("fired", 1, true)],
..Default::default()
};
let mut world = world_with(vec![once, counter(2, "n", 1)]);
let mut sys = system(&mut world);
step(&mut sys, &mut world);
step(&mut sys, &mut world);
assert_eq!(var(&sys, "fired"), 1, "`once` fired exactly once");
edit_behavior(&mut world, 1, counter(2, "n", 2));
step(&mut sys, &mut world);
assert_eq!(var(&sys, "fired"), 1, "and did not fire again");
assert_eq!(var(&sys, "n"), 2, "while the edited one took effect");
}
#[test]
fn the_edited_behavior_starts_fresh() {
let body = |value| Behavior {
asset_id: AssetId(1),
on: BehaviorSource::Tick,
once: true,
body: vec![set_var("n", value, true)],
..Default::default()
};
let mut world = world_with(vec![body(1)]);
let mut sys = system(&mut world);
step(&mut sys, &mut world);
step(&mut sys, &mut world);
assert_eq!(var(&sys, "n"), 1);
edit_behavior(&mut world, 0, body(10));
step(&mut sys, &mut world);
assert_eq!(var(&sys, "n"), 11, "the new body ran once");
step(&mut sys, &mut world);
assert_eq!(var(&sys, "n"), 11, "and `once` still holds");
}
#[test]
fn an_edited_declaration_reaches_a_running_world() {
let mut world = world_with_vars(
vec![Behavior {
asset_id: AssetId(1),
on: BehaviorSource::Tick,
body: vec![set_var("other", 1, false)],
..Default::default()
}],
vec![("score", BehaviorLiteral::Int(0))],
);
let mut sys = system(&mut world);
step(&mut sys, &mut world);
assert_eq!(var(&sys, "score"), 0);
world.components.values_mut::<Variables>()[0] = Variables {
vars: vec![VariableDecl {
name: "score".to_string(),
value: BehaviorLiteral::Int(12),
}],
..Default::default()
};
step(&mut sys, &mut world);
assert_eq!(var(&sys, "score"), 12);
}
#[test]
fn an_untouched_declaration_keeps_its_running_value() {
let mut world = world_with_vars(
vec![Behavior {
asset_id: AssetId(1),
on: BehaviorSource::Tick,
body: vec![set_var("score", 1, true)],
..Default::default()
}],
vec![
("score", BehaviorLiteral::Int(0)),
("lives", BehaviorLiteral::Int(3)),
],
);
let mut sys = system(&mut world);
step(&mut sys, &mut world);
step(&mut sys, &mut world);
assert_eq!(var(&sys, "score"), 2);
world.components.values_mut::<Variables>()[0] = Variables {
vars: vec![
VariableDecl {
name: "score".to_string(),
value: BehaviorLiteral::Int(0),
},
VariableDecl {
name: "lives".to_string(),
value: BehaviorLiteral::Int(5),
},
],
..Default::default()
};
step(&mut sys, &mut world);
assert_eq!(
var(&sys, "score"),
3,
"the run's count carried and advanced"
);
assert_eq!(var(&sys, "lives"), 5, "the edited declaration took effect");
}
#[test]
fn the_trace_path_table_is_republished_after_an_edit() {
let one = Behavior {
asset_id: AssetId(1),
on: BehaviorSource::Tick,
body: vec![set_var("n", 1, false)],
..Default::default()
};
let two = Behavior {
body: vec![set_var("n", 1, false), set_var("m", 1, false)],
..one.clone()
};
let mut world = world_with(vec![one]);
world.resources.insert(TraceRequest::default());
let mut sys = system(&mut world);
step(&mut sys, &mut world);
assert_eq!(world.resources.get::<TracePaths>().unwrap().0[0].1.len(), 1);
edit_behavior(&mut world, 0, two);
step(&mut sys, &mut world);
assert_eq!(
world.resources.get::<TracePaths>().unwrap().0[0].1.len(),
2,
"the new body's nodes are addressable"
);
}
#[test]
fn a_pending_run_follows_its_program_or_goes_with_it() {
let delayed = |id, var| Behavior {
asset_id: AssetId(id),
on: BehaviorSource::Tick,
once: true,
delay: 1.0,
body: vec![set_var(var, 1, true)],
..Default::default()
};
let mut world = world_with(vec![delayed(1, "gone"), delayed(2, "kept")]);
let mut sys = system(&mut world);
step(&mut sys, &mut world);
assert_eq!(
sys.pending.len(),
2,
"both firings are waiting out their delay"
);
edit_behavior(&mut world, 0, delayed(1, "edited"));
step(&mut sys, &mut world);
assert_eq!(
sys.pending.iter().map(|p| p.0).collect::<Vec<_>>(),
vec![1, 0],
"the survivor keeps its place; the edited body scheduled a fresh run"
);
}
#[test]
fn a_frozen_world_adopts_an_edit_before_it_resumes() {
let mut world = world_with(vec![counter(1, "n", 1)]);
let mut sys = system(&mut world);
world.resources.insert(MenuActive(true));
edit_behavior(&mut world, 0, counter(1, "n", 9));
step(&mut sys, &mut world);
assert_eq!(var(&sys, "n"), 0, "a frozen world still fires nothing");
world.resources.insert(MenuActive(false));
step(&mut sys, &mut world);
assert_eq!(var(&sys, "n"), 9);
}