diff --git a/crates/benilla-app/src/target/by_name.rs b/crates/benilla-app/src/target/by_name.rs index 70095e8a..f301f57c 100644 --- a/crates/benilla-app/src/target/by_name.rs +++ b/crates/benilla-app/src/target/by_name.rs @@ -521,7 +521,7 @@ pub(super) fn follow_requests( #[allow(clippy::type_complexity)] // one bundled param, the app's convention for big query sets pub(crate) struct SelectCommit<'w, 's> { pub(super) selection: ResMut<'w, Selection>, - pub(super) seam: crate::creature_anim::AttackSeam<'w, 's>, + pub(crate) seam: crate::creature_anim::AttackSeam<'w, 's>, // Our own body: the guid, the store `can_attack` reads, and whether we are mid-swing. me: Query< 'w, @@ -597,12 +597,22 @@ impl SelectCommit<'_, '_> { super::click::deselect(&mut self.selection, &mut self.seam, engaged); } + /// `0x48f3a0`'s select on a looted object, which tests `TYPEMASK_UNIT` first (`0x48f3af`). + pub(crate) fn select_unit(&mut self, guid: u64) { + let Some(&entity) = self.index.0.get(&guid) else { + return; + }; + if self.stores.get(entity).is_ok_and(ObjectStore::is_unit) { + self.commit(entity, guid); + } + } + /// Select a resolved guid, `0x489a40`'s arm 1, through [`scan::commit`]. pub(super) fn commit(&mut self, entity: Entity, guid: u64) { let me = self.me.single().ok(); - // `scan::commit` takes the new target's attackability from its caller: the same - // `can_attack` the cursor and TAB pass. - let attackable = super::relations::can_attack( + // The re-swing's gate is `StartAttack`'s on the new target (`0x5ecc16`): alive, then + // `CanAttack`, so a body is switched to and never swung at. + let attackable = scan::attack_target_valid( self.stores.get(entity).ok(), self.factions.as_deref(), &self.reputations, diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index f4e34338..cf22805d 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -70,6 +70,8 @@ pub(crate) use relations::{ pub(crate) use scan::{AttackNearestRequest, AttackPick}; // The chat layer's by-name asks (`/target`, `/assist`). pub(crate) use by_name::{AssistRequest, PlayerLookup, TargetByNameRequest}; +// The one SetSelection path, for the loot response's select (`0x48f3a0`). +pub(crate) use by_name::SelectCommit; // The reaction decode and its faction catalog, which also tint the target frame // (`TargetFrame_CheckFaction`); `duel_rung` is the same walk, for `/reaction`. pub(crate) use ring::{duel_rung, ring_reaction, ring_variant, Factions, RingVariant}; diff --git a/crates/benilla-app/src/target/scan.rs b/crates/benilla-app/src/target/scan.rs index 8d167c7b..12fcc4f2 100644 --- a/crates/benilla-app/src/target/scan.rs +++ b/crates/benilla-app/src/target/scan.rs @@ -690,7 +690,7 @@ impl AttackPick<'_, '_> { /// The final gate's target legs (`0x613152`–`0x613169`): alive, where a zero-health target passes /// iff `UNIT_DYNAMIC_FLAGS` bit 5 is set (`0x613159`), and the full `CanAttack 0x606980`. A target /// with no descriptor fails [`can_attack`]; the reference always holds a live unit here. -fn attack_target_valid( +pub(super) fn attack_target_valid( store: Option<&ObjectStore>, factions: Option<&Factions>, reputations: &Reputations, diff --git a/crates/benilla-app/src/ui_loot/net.rs b/crates/benilla-app/src/ui_loot/net.rs index ce24974f..ffd92655 100644 --- a/crates/benilla-app/src/ui_loot/net.rs +++ b/crates/benilla-app/src/ui_loot/net.rs @@ -1,7 +1,7 @@ //! The loot window's packet handlers: the [`LootState`] session and the [`LootLatch`] the feed //! reads, the fishing verdicts, and the item push behind "You receive …". -use benilla_protocol::messages::{ItemPushResult, LootItem}; +use benilla_protocol::messages::ItemPushResult; use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; @@ -25,23 +25,28 @@ pub(super) fn register(app: &mut App) { .net_handler(K::Disconnected, on_session_end); } -fn on_response( - In(ev): In, - mut loot: ResMut, - mut latch: ResMut, - commands: Res, -) { - if let SessionEvent::LootResponse { +/// An admitted response selects a looted unit (`0x5ebc35` → `0x48f3a0`) before the window opens +/// (`0x4c1cb0`), so the outgoing target's teardown closes a window still open on it. +fn on_response(In(ev): In, mut select: crate::target::SelectCommit) { + let SessionEvent::LootResponse { guid, loot_type, gold, items, } = ev - { - loot_response( - guid, loot_type, gold, items, &mut loot, &mut latch, &commands, - ); + else { + return; + }; + let seam = &mut select.seam; + if !admit_response(guid, loot_type, &mut seam.loot_latch, &seam.net) { + return; } + debug!( + "net: loot response {guid:#x} type {loot_type} gold {gold} {} item(s)", + items.len() + ); + select.select_unit(guid); + select.seam.loot.open(guid, loot_type, gold, items); } fn on_error( @@ -151,17 +156,9 @@ const SERVER_STARTED_LOOT: [u8; 3] = [2, 3, 4]; /// `SMSG_LOOT_RESPONSE`'s item shape, behind the admission gate `0x5eb924`: it opens when the /// latch holds the packet's guid, or when the latch is cold and the type is server-started. /// Anything else is refused (`0x5eb963`): no window, a `CMSG_LOOT_RELEASE` for the packet's guid, -/// and the latch cleared whatever it held. An open writes the latch (`0x5ebb60`) and plays no -/// anim; a chest already knelt at its `SMSG_SPELL_GO` (`0x6e831b`). -fn loot_response( - guid: u64, - loot_type: u8, - gold: u32, - items: Vec, - loot: &mut LootState, - latch: &mut LootLatch, - net: &NetCommands, -) { +/// and the latch cleared whatever it held. An admitted one writes the latch (`0x5ebb60`) and plays +/// no anim; a chest already knelt at its `SMSG_SPELL_GO` (`0x6e831b`). +fn admit_response(guid: u64, loot_type: u8, latch: &mut LootLatch, net: &NetCommands) -> bool { let accept = match latch.0 { Some(latched) => latched == guid, None => SERVER_STARTED_LOOT.contains(&loot_type), @@ -176,15 +173,11 @@ fn loot_response( let _ = net.0.send(ClientCommand::LootRelease { guid }); } latch.0 = None; // not guid-matched: `0x5eb9d2` clears whatever was there - return; + return false; } - debug!( - "net: loot response {guid:#x} type {loot_type} gold {gold} {} item(s)", - items.len() - ); - loot.open(guid, loot_type, gold, items); // A fishing bobber first arms here and still does not kneel: `LootKneel` decides the pose. latch.0 = Some(guid); + true } /// `SMSG_FISH_ESCAPED` (the skill roll failed) or `SMSG_FISH_NOT_HOOKED` (clicked before the @@ -513,48 +506,68 @@ mod tests { /// No `CMSG_LOOT` armed the latch; a chest's answer is wire type 2 (`0x5eb94b`-`0x5eb95b`). #[test] fn a_cold_latch_admits_a_server_started_loot_and_arms_on_it() { - let (net, rx) = net(); - let mut loot = LootState::default(); - let mut latch = LootLatch::default(); - assert_eq!(latch.0, None, "no CMSG_LOOT was sent, so nothing armed it"); + let (mut world, rx) = tabbed_world(); + assert_eq!( + world.resource::().0, + None, + "no CMSG_LOOT was sent, so nothing armed it" + ); - loot_response(CHEST, 2, 0, Vec::new(), &mut loot, &mut latch, &net); - assert_eq!(latch.0, Some(CHEST), "the open window is the loot session"); - assert_eq!(loot.source(), Some(CHEST), "…and the window opened"); + respond(&mut world, CHEST, 2); + assert_eq!( + world.resource::().0, + Some(CHEST), + "the open window is the loot session" + ); + assert_eq!( + world.resource::().source(), + Some(CHEST), + "…and the window opened" + ); assert!( rx.try_recv().is_err(), "an accepted response bounces nothing" ); - loot_release_response(CHEST, &mut loot, &mut latch, no_item!()); - assert_eq!(latch.0, None, "the release ends the session"); + world + .run_system_once_with( + on_release_response, + SessionEvent::LootReleaseResponse { guid: CHEST }, + ) + .expect("the handler runs as a one-shot system"); + assert_eq!( + world.resource::().0, + None, + "the release ends the session" + ); } /// The `CMSG_LOOT` send armed the same guid: the match branch (`0x5eb93e`). #[test] fn a_matching_latch_admits_any_loot_type() { - let (net, _rx) = net(); - let mut loot = LootState::default(); - let mut latch = LootLatch(Some(CORPSE)); // the `CMSG_LOOT` send - loot_response(CORPSE, 1, 0, Vec::new(), &mut loot, &mut latch, &net); - assert_eq!(latch.0, Some(CORPSE)); - assert_eq!(loot.source(), Some(CORPSE)); + let (mut world, _rx) = tabbed_world(); + world.resource_mut::().0 = Some(CORPSE); // the `CMSG_LOOT` send + respond(&mut world, CORPSE, 1); + assert_eq!(world.resource::().0, Some(CORPSE)); + assert_eq!(world.resource::().source(), Some(CORPSE)); } /// The refusal arm (`0x5eb963`): a type-1 answer on a cold latch answers nothing we asked. #[test] fn a_cold_latch_refuses_a_corpse_typed_response_and_bounces_it() { - let (net, rx) = net(); - let mut loot = LootState::default(); - let mut latch = LootLatch::default(); + let (mut world, rx) = tabbed_world(); - loot_response(CORPSE, 1, 0, Vec::new(), &mut loot, &mut latch, &net); + respond(&mut world, CORPSE, 1); assert_eq!( - loot.source(), + world.resource::().source(), None, "no window for an unasked-for corpse answer" ); - assert_eq!(latch.0, None, "…and nothing latched"); + assert_eq!( + world.resource::().0, + None, + "…and nothing latched" + ); assert!( matches!(rx.try_recv(), Ok(ClientCommand::LootRelease { guid }) if guid == CORPSE), "the refusal releases the object the PACKET named" @@ -564,12 +577,15 @@ mod tests { /// The refusal's clear is not guid-matched (`0x5eb9d2`): a response for B drops a latch on A. #[test] fn a_refusal_drops_whatever_latch_was_live_not_just_a_matching_one() { - let (net, rx) = net(); - let mut loot = LootState::default(); - let mut latch = LootLatch(Some(CORPSE)); + let (mut world, rx) = tabbed_world(); + world.resource_mut::().0 = Some(CORPSE); - loot_response(CHEST, 1, 0, Vec::new(), &mut loot, &mut latch, &net); - assert_eq!(latch.0, None, "A's latch is dropped by B's refusal"); + respond(&mut world, CHEST, 1); + assert_eq!( + world.resource::().0, + None, + "A's latch is dropped by B's refusal" + ); assert!( matches!(rx.try_recv(), Ok(ClientCommand::LootRelease { guid }) if guid == CHEST), "…and it is B that gets released" @@ -687,6 +703,367 @@ mod tests { .is_none()); } + /// A live creature: `TYPEMASK_OBJECT | TYPEMASK_UNIT`, so it is the Tab target. + const KOBOLD: u64 = 0xF130_0000_0600_00CD; + /// A creature flagged `UNIT_FLAG_NOT_SELECTABLE`. + const UNSELECTABLE: u64 = 0xF130_0000_0700_00EF; + + const OBJECT_FIELD_TYPE: u16 = 2; + const OBJECT_FIELD_ENTRY: u16 = 3; + /// `OBJECT_FIELD_TYPE` masks: `TYPEMASK_OBJECT` with `UNIT`, `PLAYER` or `GAMEOBJECT`. + const TYPE_UNIT: u32 = 0x09; + const TYPE_PLAYER: u32 = 0x19; + const TYPE_GAMEOBJECT: u32 = 0x21; + /// `UNIT_FLAG_NOT_SELECTABLE`. + const NOT_SELECTABLE: u32 = 1 << 25; + const KOBOLD_ENTRY: u32 = 6; + const CORPSE_ENTRY: u32 = 257; + + /// Us, the Tab target selected, and a corpse, a chest and an unselectable unit beside it, all + /// streamed: the issue's spot, with what [`on_response`] reads and the selection commit writes. + fn tabbed_world() -> (World, crossbeam_channel::Receiver) { + let mut world = World::new(); + let rx = seat_tabbed(&mut world); + (world, rx) + } + + /// [`tabbed_world`]'s seating, into any world. + fn seat_tabbed(world: &mut World) -> crossbeam_channel::Receiver { + use crate::net::{Guid, GuidIndex, NetEntity, ObjectStore, SelfPlayer}; + use benilla_protocol::field::{FIELD_UNIT_FLAGS, FIELD_UNIT_HEALTH}; + use benilla_protocol::messages::{ObjectFields, ObjectType}; + use benilla_protocol::EntityKind::{GameObject, Unit}; + let unit = |kind, type_mask, pairs: &[(u16, u32)]| { + let mut fields = vec![(OBJECT_FIELD_TYPE, type_mask)]; + fields.extend_from_slice(pairs); + ( + NetEntity { + kind, + display_id: None, + scale: 1.0, + }, + ObjectStore(ObjectFields::from_pairs(&fields).into_created(match kind { + GameObject => ObjectType::GameObject, + _ => ObjectType::Unit, + })), + ) + }; + let (net, rx) = net(); + world.insert_resource(net); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::>(); + world.spawn(( + SelfPlayer, + Guid(1), + unit(Unit, TYPE_PLAYER, &[(FIELD_UNIT_HEALTH, 100)]), + )); + for (guid, bundle) in [ + ( + KOBOLD, + unit( + Unit, + TYPE_UNIT, + &[(OBJECT_FIELD_ENTRY, KOBOLD_ENTRY), (FIELD_UNIT_HEALTH, 100)], + ), + ), + ( + CORPSE, + unit( + Unit, + TYPE_UNIT, + &[(OBJECT_FIELD_ENTRY, CORPSE_ENTRY), (FIELD_UNIT_HEALTH, 0)], + ), + ), + (CHEST, unit(GameObject, TYPE_GAMEOBJECT, &[])), + ( + UNSELECTABLE, + unit( + Unit, + TYPE_UNIT, + &[(FIELD_UNIT_HEALTH, 0), (FIELD_UNIT_FLAGS, NOT_SELECTABLE)], + ), + ), + ] { + let e = world.spawn((Guid(guid), bundle)).id(); + world.resource_mut::().0.insert(guid, e); + } + let kobold = world.resource::().0[&KOBOLD]; + let mut selection = world.resource_mut::(); + selection.target = Some(kobold); + selection.guid = Some(KOBOLD); + rx + } + + /// A response for `guid` of `loot_type` with no rows, through the registered handler. + fn respond(world: &mut World, guid: u64, loot_type: u8) { + world + .run_system_once_with( + on_response, + SessionEvent::LootResponse { + guid, + loot_type, + gold: 0, + items: Vec::new(), + }, + ) + .expect("the handler runs as a one-shot system"); + } + + /// `0x5ebc35` → `0x48f3a0` → `SetSelection 0x493540` on the looted unit's guid. + #[test] + fn a_loot_window_opening_on_a_unit_selects_it() { + let (mut world, rx) = tabbed_world(); + world.resource_mut::().0 = Some(CORPSE); // the `CMSG_LOOT` send + respond(&mut world, CORPSE, 1); + assert_eq!(world.resource::().source(), Some(CORPSE)); + let selection = world.resource::(); + assert_eq!( + selection.guid, + Some(CORPSE), + "the looted body is the target" + ); + assert_eq!( + selection.last, + Some(KOBOLD), + "…through SetSelection's stamp" + ); + let sent: Vec<_> = rx.try_iter().collect(); + assert!( + matches!(sent[..], [ClientCommand::SetSelection { guid }] if guid == CORPSE), + "{sent:?}" + ); + + // The setter's dedup: a second window on the selected body sends nothing. + respond(&mut world, CORPSE, 1); + assert!(rx.try_recv().is_err()); + } + + /// `0x48f3a0` tests `TYPEMASK_UNIT`; `SetSelection`'s `IsSelectable` refuses the flagged unit; + /// a refused response never reaches `0x5ebc35`. + #[test] + fn a_chest_an_unselectable_unit_and_a_refusal_leave_the_target() { + for (guid, latched, loot_type) in [ + (CHEST, true, 1), + (UNSELECTABLE, true, 1), + (CORPSE, false, 1), + ] { + let (mut world, rx) = tabbed_world(); + world.resource_mut::().0 = latched.then_some(guid); + respond(&mut world, guid, loot_type); + assert_eq!( + world.resource::().guid, + Some(KOBOLD), + "{guid:#x}" + ); + assert!( + !rx.try_iter() + .any(|c| matches!(c, ClientCommand::SetSelection { .. })), + "{guid:#x}" + ); + } + } + + /// The selection comes before the window (`0x48f3be`, then `0x4c1cb0`): the outgoing target's + /// teardown closes the window still open on it, its release ahead of the new selection. + #[test] + fn the_selection_tears_down_the_old_targets_window_before_the_new_one_opens() { + let (mut world, rx) = tabbed_world(); + world + .resource_mut::() + .open(KOBOLD, 2, 0, Vec::new()); + world.resource_mut::().0 = Some(CORPSE); + respond(&mut world, CORPSE, 1); + let sent: Vec<_> = rx.try_iter().collect(); + assert!( + matches!( + sent[..], + [ClientCommand::LootRelease { guid: a }, ClientCommand::SetSelection { guid: b }] + if a == KOBOLD && b == CORPSE + ), + "{sent:?}" + ); + assert_eq!(world.resource::().source(), Some(CORPSE)); + // [`LootLatch`]'s deviation: the reference's teardown zeroes it here (`0x48f2c9`). + assert_eq!(world.resource::().0, Some(CORPSE)); + } + + /// `SetSelection`'s switch while swinging (`0x493a08`, `0x4938a1`): the swing at the Tab target + /// stops, and none opens on the body. + #[test] + fn looting_while_swinging_at_the_tab_target_stops_the_swing() { + use crate::net::SelfPlayer; + let (mut world, rx) = tabbed_world(); + let me = world + .query_filtered::>() + .single(&world) + .expect("us"); + world + .entity_mut(me) + .insert(crate::creature_anim::Engaged(KOBOLD)); + world.resource_mut::().0 = Some(CORPSE); + respond(&mut world, CORPSE, 1); + let sent: Vec<_> = rx.try_iter().collect(); + assert!( + matches!( + sent[..], + [ClientCommand::AttackStop, ClientCommand::SetSelection { guid }] if guid == CORPSE + ), + "{sent:?}" + ); + } + + /// `StartAttack`'s target gate (`0x5ecc16`-`0x5ecc29`): a body is no swing target even when + /// hostile, so looting one mid-swing stops and selects, and swings at nothing. + #[test] + fn looting_a_hostile_body_while_swinging_never_swings_at_it() { + use crate::net::{ObjectStore, SelfPlayer}; + use benilla_protocol::field::{FIELD_UNIT_FACTIONTEMPLATE, FIELD_UNIT_HEALTH}; + use benilla_protocol::messages::{ObjectFields, ObjectType}; + const UNIT_FIELD_BYTES_0: u16 = 36; + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let (factions, stormwind, reps) = crate::target::stormwind_fixture(&mut chain, 0); + let me_store = ObjectStore( + ObjectFields::from_pairs(&[ + (OBJECT_FIELD_TYPE, TYPE_PLAYER), + (FIELD_UNIT_HEALTH, 100), + (FIELD_UNIT_FACTIONTEMPLATE, stormwind), + (UNIT_FIELD_BYTES_0, crate::target::HUMAN_WARRIOR), + ]) + .into_created(ObjectType::Player), + ); + let creature = |template, health| { + ObjectStore( + ObjectFields::from_pairs(&[ + (OBJECT_FIELD_TYPE, TYPE_UNIT), + (FIELD_UNIT_HEALTH, health), + (FIELD_UNIT_FACTIONTEMPLATE, template), + ]) + .into_created(ObjectType::Unit), + ) + }; + let hostile = (1u32..4096) + .find(|&t| { + crate::target::can_attack( + Some(&creature(t, 100)), + Some(&factions), + &reps, + Some(&me_store), + ) + }) + .expect("a template a Stormwind human can attack"); + + let (mut world, rx) = tabbed_world(); + world.insert_resource(factions); + world.insert_resource(reps); + let me = world + .query_filtered::>() + .single(&world) + .expect("us"); + world + .entity_mut(me) + .insert((me_store, crate::creature_anim::Engaged(KOBOLD))); + let corpse = world.resource::().0[&CORPSE]; + world.entity_mut(corpse).insert(creature(hostile, 0)); + world.resource_mut::().0 = Some(CORPSE); + respond(&mut world, CORPSE, 1); + let sent: Vec<_> = rx.try_iter().collect(); + assert!( + matches!( + sent[..], + [ClientCommand::AttackStop, ClientCommand::SetSelection { guid }] if guid == CORPSE + ), + "{sent:?}" + ); + } + + /// The issue's symptom on the stock frames: the Tab target is on `TargetFrame` when the body's + /// window opens, and the unit feed then names the body there (`TargetFrame.lua:63`, + /// `UnitFrame.lua:25`). + #[test] + fn the_stock_target_frame_names_the_looted_body() { + use benilla_ui::script::UiScript; + benilla_formats::wow_data_or_skip!(); + let mut s = UiScript::new().expect("VM"); + s.set_screen_size(1024.0, 768.0); + for f in crate::ui_script::test_ui::production_order(&[&[ + "Interface\\FrameXML\\Fonts.xml", + "Interface\\FrameXML\\GlobalStrings.lua", + "Interface\\FrameXML\\UIParent.xml", + "Interface\\FrameXML\\BasicControls.xml", + "Interface\\FrameXML\\MoneyFrame.lua", + "Interface\\FrameXML\\MoneyFrame.xml", + "Interface\\FrameXML\\GameTooltip.xml", + "Interface\\FrameXML\\UIDropDownMenu.xml", + "Interface\\FrameXML\\TextStatusBar.lua", + "Interface\\FrameXML\\TextStatusBar.xml", + "Interface\\FrameXML\\BuffFrame.xml", + "Interface\\FrameXML\\CombatFeedback.xml", + "Interface\\FrameXML\\UnitPopup.xml", + "Interface\\FrameXML\\UnitFrame.xml", + "Interface\\FrameXML\\PlayerFrame.xml", + "Interface\\FrameXML\\PartyFrame.xml", + "Interface\\FrameXML\\TargetFrame.xml", + ]]) { + crate::ui_script::test_ui::load_ui(&s, f); + } + + let mut app = App::new(); + seat_tabbed(app.world_mut()); + let mut names = crate::names::NameCache::default(); + for (entry, name) in [ + (KOBOLD_ENTRY, "Kobold Vermin"), + (CORPSE_ENTRY, "Kobold Worker"), + ] { + names.insert_creature( + entry, + Some(crate::names::CreatureRecord { + name: name.into(), + subname: None, + creature_type: 7, + pet_family: 0, + rank: 0, + type_flags: 0, + civilian: false, + racial_leader: false, + display_id: 0, + }), + ); + } + app.insert_resource(names) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .insert_non_send_resource(s); + crate::ui_unit::add_unit_feed(&mut app); + let shown = |app: &mut App| -> String { + let s = app.world().non_send_resource::(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + s.eval("return TargetFrame:IsShown() and TargetFrame.name:GetText() or ''") + .expect("the target frame's name") + }; + + app.update(); + assert_eq!(shown(&mut app), "Kobold Vermin", "the Tab target"); + + app.world_mut().resource_mut::().0 = Some(CORPSE); + respond(app.world_mut(), CORPSE, 1); + app.update(); + assert_eq!(shown(&mut app), "Kobold Worker", "the looted body"); + } + /// Only a releasing arm reaches `UnlockItem 0x495420`, through the tail `0x5ebac2`. #[test] fn a_releasing_loot_error_on_the_opened_item_unlocks_it() { diff --git a/crates/benilla-app/src/ui_unit.rs b/crates/benilla-app/src/ui_unit.rs index e5b45051..4fdaf27c 100644 --- a/crates/benilla-app/src/ui_unit.rs +++ b/crates/benilla-app/src/ui_unit.rs @@ -1618,6 +1618,14 @@ fn combo_edge(last: Option<(u8, u64)>, now: (u8, u64)) -> Option { (last != Some(now)).then(|| last.map(|(count, _)| count) != Some(now.0)) } +/// The bare unit feed, for a test driving the stock unit frames off engine state. +#[cfg(test)] +pub(crate) fn add_unit_feed(app: &mut App) { + app.init_resource::() + .add_message::() + .add_systems(Update, feed_units); +} + #[cfg(test)] mod tests { use super::*;