use raptor_replica::{ content::{ContentPack, Registry}, modding::{Command, ScriptEffectKind, ScriptRuntime}, persistence::Pilot, simulation::{EffectKind, Input, World}, }; use serde_json::json; fn fixture() -> Registry { let mut registry = Registry::default(); let pack: ContentPack = serde_json::from_value(json!({ "weapons":[{"id":"base:projectile_forward_guns","name":"Guns","damage":1,"speed":8,"cooldown":2,"behavior":"straight","target":"all"}], "items":[ {"id":"base:forward_guns","name":"Guns","price":0,"weapon":"base:projectile_forward_guns"}, {"id":"base:energy","name":"Shield","price":0,"max_count":100} ], "enemies":[ {"id":"base:phase_boss","name":"Phase boss","health":100,"speed":0, "art":{"asset":42,"logical_width":32,"logical_height":32,"frames":3}}, {"id":"base:invisible","name":"No artwork","health":100,"speed":0} ], "missions":[{"id":"base:sector_1_wave_1","name":"Fixture","duration":1000}] })).unwrap(); registry.apply(pack, "base").unwrap(); registry.validate().unwrap(); registry } #[test] fn boss_phase_frame_remains_selected_and_resumes_normal_animation() { let registry = fixture(); let pilot = Pilot::default(); let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); let entity = world .spawn(&"base:phase_boss".into(), 100, 20, ®istry) .unwrap(); assert!( serde_json::to_value(&world.enemies[0]) .unwrap() .get("pinned_art_frame") .is_none() ); let rng = world.rng.clone(); world .apply_commands( &[Command::Animation { entity, frame: Some(2), }], ®istry, ) .unwrap(); assert_eq!(world.rng, rng); let mut runtime = ScriptRuntime::new(); for _ in 0..17 { world .step(Input::default(), ®istry, &mut runtime) .unwrap(); assert_eq!(world.enemies[0].art_frame, 2); assert_eq!(world.enemies[0].pinned_art_frame, Some(2)); } let serialized = serde_json::to_vec(&world).unwrap(); let mut recovered: World = serde_json::from_slice(&serialized).unwrap(); recovered .step(Input::default(), ®istry, &mut runtime) .unwrap(); assert_eq!(recovered.enemies[0].art_frame, 2); recovered .apply_commands( &[Command::Animation { entity, frame: None, }], ®istry, ) .unwrap(); recovered .step(Input::default(), ®istry, &mut runtime) .unwrap(); assert_eq!(recovered.enemies[0].art_frame, recovered.enemies[0].age / 3); assert!(recovered.enemies[0].pinned_art_frame.is_none()); } #[test] fn invalid_phase_frame_rolls_back_the_entire_batch() { let registry = fixture(); let pilot = Pilot::default(); let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); let entity = world .spawn(&"base:phase_boss".into(), 100, 20, ®istry) .unwrap(); let invisible = world .spawn(&"base:invisible".into(), 20, 20, ®istry) .unwrap(); for (target, frame) in [ (entity, 3), (entity, u32::MAX), (invisible, 0), (u64::MAX, 0), (0, 0), ] { let before = world.digest(); let commands = [ Command::Credits { amount: 100 }, Command::Animation { entity: target, frame: Some(frame), }, ]; assert!(world.apply_commands(&commands, ®istry).is_err()); assert_eq!(world.digest(), before); } } #[test] fn scripts_cannot_pin_or_reset_original_enemy_animation() { let mut registry = fixture(); let pilot = Pilot::default(); let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); let entity = world .spawn(&"base:phase_boss".into(), 100, 20, ®istry) .unwrap(); registry .enemies .get_mut(&"base:phase_boss".into()) .unwrap() .legacy = Some(raptor_replica::assets::LegacyEnemy { name: "Native category fixture".into(), fields: vec![0; 26], shoot_type: vec![0; 24], engine_x: vec![0; 24], engine_y: vec![0; 24], engine_width: vec![0; 24], gun_x: vec![0; 24], gun_y: vec![0; 24], flight_x: vec![0; 32], flight_y: vec![0; 32], }); let before = world.digest(); for frame in [None, Some(1)] { assert!( world .apply_commands(&[Command::Animation { entity, frame }], ®istry) .is_err() ); assert_eq!(world.digest(), before); } } #[test] fn phase_callbacks_use_serializable_state_and_restore_the_tick_on_failure() { let mut registry = fixture(); registry .enemies .get_mut(&"base:phase_boss".into()) .unwrap() .script = Some("phase.rhai".into()); let mut runtime = ScriptRuntime::new(); runtime.add("base", "phase.rhai", r#" fn on_spawn(e,s,r) { #{commands:[#{op:"animation",entity:e.entity,frame:1}],state:#{phase:1}} } fn on_tick(e,s,r) { #{commands:[#{op:"animation",entity:e.entity,frame:s.phase}],state:s} } "#).unwrap(); let pilot = Pilot::default(); let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); world .spawn(&"base:phase_boss".into(), 100, 20, ®istry) .unwrap(); world .step(Input::default(), ®istry, &mut runtime) .unwrap(); assert_eq!(world.enemies[0].art_frame, 1); runtime .states .insert("base/phase.rhai".into(), json!({"phase":99})); let world_before = world.digest(); let states_before = runtime.states.clone(); let rng_before = runtime.rng.clone(); let error = world .step(Input::default(), ®istry, &mut runtime) .unwrap_err(); assert!(format!("{error:#}").contains("base/phase.rhai::on_tick")); assert_eq!(world.digest(), world_before); assert_eq!(runtime.states, states_before); assert_eq!(runtime.rng, rng_before); } #[test] fn script_ground_impact_scrolls_with_terrain_and_legacy_effect_defaults_stay_airborne() { let registry = fixture(); let pilot = Pilot::default(); let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); let default: Command = serde_json::from_value(json!({"op":"effect","x":80,"y":40})).unwrap(); world .apply_commands( &[ default, Command::Effect { x: 80, y: 40, kind: ScriptEffectKind::GroundBlast, }, Command::Effect { x: 80, y: 40, kind: ScriptEffectKind::SmallGround, }, Command::Effect { x: 80, y: 40, kind: ScriptEffectKind::SmallAir, }, ], ®istry, ) .unwrap(); assert_eq!(world.effects[0].kind, EffectKind::AirBlast); let mut runtime = ScriptRuntime::new(); for _ in 0..8 { world .step(Input::default(), ®istry, &mut runtime) .unwrap(); } assert_eq!(world.effects.len(), 4); assert_eq!(world.effects[0].y, 40); assert!(world.effects[1].y > 40); assert_eq!(world.effects[1].y, world.effects[2].y); assert_eq!(world.effects[3].y, 40); } #[test] fn invalid_effect_kind_and_out_of_bounds_positions_cannot_partially_commit() { assert!( serde_json::from_value::(json!({"op":"effect","x":0,"y":0,"kind":"unknown"})) .is_err() ); let registry = fixture(); let pilot = Pilot::default(); let mut world = World::new(&pilot.mission, &pilot, ®istry).unwrap(); for (x, y) in [(32769, 0), (0, -32769), (i32::MIN, 0), (0, i32::MAX)] { let before = world.digest(); let commands = [ Command::Credits { amount: 100 }, Command::Effect { x, y, kind: ScriptEffectKind::GroundBlast, }, ]; assert!(world.apply_commands(&commands, ®istry).is_err()); assert_eq!(world.digest(), before); } }