From e620a0d09555f6d6acd5c03a2983e11390fb7f8f Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Fri, 2 Oct 2026 01:38:12 +0200 Subject: [PATCH] feat: let a crate on top interact with an object it picks (#753) - A crate on top of benilla interacts with an object it picks through `Interact`, the object's own dispatcher with no select, and reads the streamed objects through `Objects` - A right-click on a GameObject, a corpse or empty world no longer marks the selection changed; only the unit leg's select touches it, as before the click took the pick - Interact's attack leg selects its unit through StartAttack, as the click does - An outside crate test reads Objects and writes Interact - Wrap the Interact finish's doc lines at the column limit - The outside-crate test's comment says what it picks Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-app/src/game_plugins.rs | 9 +- crates/benilla-app/src/lib.rs | 4 + crates/benilla-app/src/net.rs | 61 +++- crates/benilla-app/src/target/click.rs | 284 +++++++++++++++++-- crates/benilla-app/src/target/cursor_mode.rs | 104 ++++--- crates/benilla-app/src/target/mod.rs | 9 +- crates/benilla-app/tests/building_on_top.rs | 40 +++ 7 files changed, 449 insertions(+), 62 deletions(-) create mode 100644 crates/benilla-app/tests/building_on_top.rs diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index aa83695c..4a9e49ac 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -681,12 +681,17 @@ pub(crate) mod schedule_tests { /// already pair with: the steer is chained after follow's and writes the same `Player` /// fields, the arrival runs the click's dispatchers, and their `Transform` reads are the /// disjoint-lane kind. The dispatchers also carry the walk's start (`Player`, - /// `FollowState`, `Approach`, the stand request), which only a click or an arrival writes. + /// `FollowState`, `Approach`, the stand request), which only a click, an arrival or a + /// crate's interaction writes; + /// - a crate's interaction (`target::click::act_on_interact`) against what + /// `act_on_right_click` and the cursor classifier already pair with: it runs the click's + /// dispatchers over the classifier's reading of its object, and its attack leg + /// selects as the click does, so it writes `Selection` where the click's drain does. /// /// Raising the ceiling is a claim that a new undeclared order is acceptable: make it with the /// reason read off the dump, or declare the order (`.after`, a set, a `chain`). A resource /// that commutes by construction belongs in [`Classes`]. - const UPDATE_ACTIONABLE_CEILING: usize = 4_930; + const UPDATE_ACTIONABLE_CEILING: usize = 4_980; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 2cb91ff8..b6eeee9f 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -197,6 +197,10 @@ pub use benilla_world::build_id::BuildId; /// `benilla-worldview` shim. pub use benilla_world::worldview::run as run_worldview; pub use bevy::app::AppExit; +// A crate on top of benilla reads objects and interacts through these, with no protocol dependency. +pub use benilla_protocol::{messages::ObjectType, ObjectFields}; +pub use net::Objects; +pub use target::Interact; /// Builds and runs the client app. `build` is the launcher's compile-time git stamp, passed in as /// data so the sha lives in the shim's fingerprint and a commit does not recompile this crate. diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index ac22edc0..f54e7258 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -418,23 +418,24 @@ pub(crate) struct LoginAbandon(pub(crate) std::sync::Arc); -/// The object manager's guid lookup (`ClntObjMgrObjectPtr 0x468460`): a guid to its descriptor -/// store, whatever the kind, and an item's countdown cells. Read-only. +/// The streamed objects, whatever the kind, with their descriptor stores, our own player, and an +/// item's countdown cells. Read-only. The by-guid lookups are `ClntObjMgrObjectPtr` (`0x468460`). #[derive(SystemParam)] -pub(crate) struct Objects<'w, 's> { +pub struct Objects<'w, 's> { index: Res<'w, GuidIndex>, stores: Query<'w, 's, &'static ObjectStore>, countdowns: Query<'w, 's, &'static crate::items::Countdowns>, + me: Query<'w, 's, Entity, With>, } impl Objects<'_, '_> { - /// The entity behind a guid, if streamed. - pub(crate) fn entity(&self, guid: u64) -> Option { + /// The entity behind a guid, if streamed (`ClntObjMgrObjectPtr`, `0x468460`). + pub fn entity(&self, guid: u64) -> Option { self.index.0.get(&guid).copied() } - /// A streamed object's merged descriptor fields. - pub(crate) fn object(&self, guid: u64) -> Option<&ObjectFields> { + /// A streamed object's merged descriptor fields, found as `0x468460` does, by guid. + pub fn object(&self, guid: u64) -> Option<&ObjectFields> { self.index .0 .get(&guid) @@ -442,6 +443,19 @@ impl Objects<'_, '_> { .map(|s| &s.0) } + /// Every streamed object, items included. + pub fn iter(&self) -> impl Iterator + '_ { + self.index + .0 + .iter() + .filter_map(|(&guid, &e)| Some((guid, e, &self.stores.get(e).ok()?.0))) + } + + /// Our own player, once in the world. + pub fn player(&self) -> Option { + self.me.single().ok() + } + /// An item object's countdown cells. pub(crate) fn countdowns(&self, guid: u64) -> Option<&crate::items::Countdowns> { self.index @@ -2453,4 +2467,37 @@ mod tests { ); } } + + #[test] + fn objects_lists_every_streamed_object_and_names_our_own() { + use bevy::ecs::system::RunSystemOnce; + const ME: u64 = 0x5E1F; + const BOAR: u64 = 0xF130_0000_0000_0042; + let mut world = World::new(); + world.init_resource::(); + let fields = |health| { + ObjectStore(ObjectFields::from_pairs(&[( + benilla_protocol::field::FIELD_UNIT_HEALTH, + health, + )])) + }; + let me = world.spawn((SelfPlayer, Guid(ME), fields(256))).id(); + let boar = world.spawn((Guid(BOAR), fields(40))).id(); + world + .resource_mut::() + .0 + .extend([(ME, me), (BOAR, boar)]); + let (listed, player) = world + .run_system_once(|objects: Objects| { + let mut listed: Vec<_> = objects + .iter() + .map(|(guid, e, f)| (guid, e, f.unit_health())) + .collect(); + listed.sort_by_key(|&(guid, ..)| guid); + (listed, objects.player()) + }) + .unwrap(); + assert_eq!(listed, [(ME, me, Some(256)), (BOAR, boar, Some(40))]); + assert_eq!(player, Some(me)); + } } diff --git a/crates/benilla-app/src/target/click.rs b/crates/benilla-app/src/target/click.rs index f5b5587d..3eec2953 100644 --- a/crates/benilla-app/src/target/click.rs +++ b/crates/benilla-app/src/target/click.rs @@ -177,8 +177,9 @@ pub(crate) struct Feedback<'w> { } /// The dispatchers, which take the object (`0x5f0130`, `0x5df2a0`, `0x5df130`, `0x5f86b0`, -/// `0x5f05e0`): a right-click reaches them through [`act_on_right_click`] with Click to Move's -/// walk, an approach's arrival through [`act_on_arrival`] without it. +/// `0x5f05e0`): a right-click reaches them through [`act_on_right_click`] and a crate's +/// [`Interact`] through [`act_on_interact`], both with Click to Move's walk, an approach's arrival +/// through [`act_on_arrival`] without it. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct Dispatch<'w, 's> { seam: crate::creature_anim::AttackSeam<'w, 's>, @@ -620,11 +621,8 @@ impl Dispatch<'_, '_> { } } -/// On a clean right-click, select the press pick's unit and act by the cursor's classification -/// (the INTERACT leg `0x492820`): a GameObject, a corpse, an attack, loot, skin, or the -/// `UNIT_NPC_FLAGS` ladder ([`service_arm`]). The range gray (`unable`) suppresses every send but -/// attack, which the server holds until in reach; with Click to Move on, each dispatcher walks -/// there instead (`CanAutoInteract` is their approach flag, `0x60c170`, `0x5d6c6f`). +/// On a clean right-click, the INTERACT leg `0x492820` on the press pick: select it, then +/// [`interact`]. pub(super) fn act_on_right_click( mut clicks: MessageReader, // The press pick: the reference picks once, on the down edge (`0x481f00`). @@ -635,6 +633,24 @@ pub(super) fn act_on_right_click( if clicks.read().last().is_none() { return; } + interact(&mut dispatch, &press, Select::First(&mut selection)); +} + +/// Where [`interact`] selects its unit, if it does: the world click's entry (`0x492820`) selects +/// first, whatever leg follows; a crate's [`Interact`] runs the dispatcher alone, and only its +/// attack leg selects, through StartAttack (`0x5ecb70`, `SetSelection` at `0x5eccaa`). +enum Select<'a, 'w> { + First(&'a mut ResMut<'w, Selection>), + OnAttack(&'a mut ResMut<'w, Selection>), +} + +/// Act on a pick by the cursor's classification of it: a GameObject, a corpse, an attack, loot, +/// skin, or the `UNIT_NPC_FLAGS` ladder ([`service_arm`]); only the click selects first +/// (`0x493540`), and the attack leg of an [`Interact`] selects through StartAttack. The range gray +/// (`unable`) suppresses every send but attack, which the server holds until in reach; with Click +/// to Move on, each dispatcher walks there instead (`CanAutoInteract` is their approach flag, +/// `0x60c170`, `0x5d6c6f`). +fn interact(dispatch: &mut Dispatch, press: &PressPick, mut select: Select) { let (hovered, hovered_object, cursor) = (&press.hovered, &press.object, &press.cursor); let walk = dispatch.auto.can_auto_interact(); let self_mounted = dispatch.mounted(); @@ -784,16 +800,24 @@ pub(super) fn act_on_right_click( let me = dispatch.me(); // A mid-combat click on a vendor or corpse switches and stops, never swings (`0x5ecb70`); the // sword, not the fork, as the re-swing also needs the player's own legs. - let outcome = scan::commit( - &mut selection, - &mut dispatch.seam, - entity, - guid, - target, - me.is_some_and(|(_, _, e)| e), - me.map(|(_, g, _)| g), - press.attack(), - ); + // Deref'd only where it commits: a leg that does not select leaves the change tick alone. + let commit = |seam: &mut crate::creature_anim::AttackSeam, + selection: &mut ResMut| { + scan::commit( + selection, + seam, + entity, + guid, + target, + me.is_some_and(|(_, _, e)| e), + me.map(|(_, g, _)| g), + press.attack(), + ) + }; + let mut outcome = match &mut select { + Select::First(selection) => commit(&mut dispatch.seam, selection), + Select::OnAttack(_) => scan::CommitOutcome::default(), + }; match unit_branch(attack, dead_fork, leg) { UnitBranch::Attack => { // Silent after the select: the click's `0x60c247 call 0x5ecb70` has no `DisplayError`, @@ -804,6 +828,11 @@ pub(super) fn act_on_right_click( { // refused: the selection stands, no swing, nothing said } else { + // StartAttack's own select (`0x5eccaa`), past its gates and before the swing + // (`0x5eccfd`): the click's commit, with its stop and re-swing mid-combat. + if let Select::OnAttack(selection) = &mut select { + outcome = commit(&mut dispatch.seam, selection); + } debug!("right-click attack: {guid:#x}"); // `0x5ecb70`'s body through the seam; `swung` means the commit's re-swing already // went out, which `0x5eccda` keeps from sending twice. @@ -850,6 +879,44 @@ pub(super) fn act_on_right_click( } } +/// Interact with an object by running its own virtual dispatcher (slot `+0x60`): `0x60bea0` for a +/// unit or player, `0x5d6bf0` for a corpse, `0x5f8660` for a GameObject. It skips the world click's +/// select-first entry (`0x492820`); the attack leg selects through StartAttack (`0x5ecb70`), and +/// the service, loot, skin, corpse and GameObject legs select nothing. +#[derive(Message, Clone, Copy, PartialEq, Eq, Debug)] +pub struct Interact(pub Entity); + +/// Each [`Interact`], its object classified as a press over it would be. +pub(super) fn act_on_interact( + mut requests: MessageReader, + mut selection: ResMut, + kinds: Query<(&Guid, &crate::net::NetEntity)>, + mut set: ParamSet<(cursor_mode::CursorInputs, Dispatch)>, +) { + use benilla_protocol::EntityKind; + for &Interact(entity) in requests.read() { + let Ok((guid, net)) = kinds.get(entity) else { + continue; + }; + let mut pick = PressPick::default(); + match net.kind { + EntityKind::Unit | EntityKind::Player => { + (pick.hovered.target, pick.hovered.guid) = (Some(entity), Some(guid.0)); + } + EntityKind::Corpse => { + (pick.hovered.corpse, pick.hovered.corpse_guid) = (Some(entity), Some(guid.0)); + } + EntityKind::GameObject => { + (pick.object.target, pick.object.guid) = (Some(entity), Some(guid.0)); + } + EntityKind::DynamicObject | EntityKind::Other => continue, + } + (pick.cursor, pick.attack_fork) = + cursor_mode::classify(&set.p0(), &pick.hovered, &pick.object); + interact(&mut set.p1(), &pick, Select::OnAttack(&mut selection)); + } +} + /// The verb an approach owed, once it arrived (`0x60fa20`, run at the stop): the dispatcher alone, /// with no select, no cursor and no walk but Use's. Each looks its object up by type (`0x468460`), /// and the loot's asks for a unit (`0x60fa41`), so a corpse object walked to is not looted. @@ -1928,6 +1995,10 @@ mod tests { 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(ME))); let boar = world .spawn((Guid(BOAR), store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)]))) @@ -2082,6 +2153,33 @@ mod tests { ); } + #[test] + fn a_right_click_on_a_gameobject_leaves_the_selection_untouched() { + const STONE: u64 = 0x5703; + let (mut world, _boar) = right_click_world(); + let stone = world + .spawn((Guid(STONE), store(&[(GO_TYPE_FIELD, 23)]))) + .id(); + *world.resource_mut::() = PressPick { + object: HoveredObject { + target: Some(stone), + guid: Some(STONE), + distance: 5.0, + }, + cursor: WorldCursor { + kind: cursor_mode::CursorKind::Interact, + unable: false, + }, + ..PressPick::default() + }; + world + .resource_mut::>() + .write(WorldRightClick); + world.clear_trackers(); + world.run_system_once(act_on_right_click).unwrap(); + assert!(!world.is_resource_changed::()); + } + const VENDOR: u64 = 0x7E0D; /// Our body alive at the origin, Click to Move set as `walk`, and a vendor at `at`. @@ -2237,6 +2335,158 @@ mod tests { .collect() } + /// A crate's [`Interact`] on `entity`, the selection's change tick cleared first. + fn interact_with(world: &mut World, entity: Entity) { + world.clear_trackers(); + world + .resource_mut::>() + .write(Interact(entity)); + world.run_system_once(act_on_interact).unwrap(); + } + + /// The vendor leg (`0x5f0130`) runs the dispatcher alone: no select. + #[test] + fn an_interaction_opens_a_vendor_in_reach_and_leaves_the_selection() { + let (mut world, vendor, rx) = walking_world(Vec3::new(2.5, 0.0, 0.0), false); + interact_with(&mut world, vendor); + let sent: Vec = rx.try_iter().collect(); + assert!( + matches!( + sent.as_slice(), + [ClientCommand::ListInventory { guid: VENDOR }] + ), + "{sent:?}" + ); + assert_eq!(world.resource::().guid, None); + assert!(!world.is_resource_changed::()); + } + + #[test] + fn a_far_vendor_interaction_walks_only_with_click_to_move_on() { + for walk in [true, false] { + let (mut world, vendor, rx) = walking_world(Vec3::new(14.0, 0.0, 0.0), walk); + interact_with(&mut world, vendor); + assert_eq!( + world.resource::().active(), + walk, + "AutoInteract {walk}" + ); + let sent: Vec = rx.try_iter().collect(); + assert!(sent.is_empty(), "{sent:?}"); + } + } + + /// `0x5f8660` → `0x5f86b0`: in reach the object is used; out of reach, with no walk, the range + /// arm raises `ERR_USE_TOO_FAR` (`0x5f874b`) and sends nothing. Neither selects. + #[test] + fn an_interaction_uses_a_gameobject_in_reach_and_refuses_one_out_of_it() { + for (x, used) in [(2.0, true), (30.0, false)] { + let (mut world, _vendor, rx) = walking_world(Vec3::ZERO, false); + let chest = spawn_chest(&mut world, Vec3::new(x, 0.0, 0.0)); + interact_with(&mut world, chest); + let sent: Vec = rx.try_iter().collect(); + assert_eq!( + matches!(sent.as_slice(), [ClientCommand::GameObjUse { guid: CHEST }]), + used, + "{x} yd: {sent:?}" + ); + let keys: Vec<&str> = world + .resource::() + .0 + .iter() + .map(|e| e.key) + .collect(); + assert_eq!( + keys, + if used { + vec![] + } else { + vec!["ERR_USE_TOO_FAR"] + }, + "{x} yd" + ); + assert!(!world.is_resource_changed::(), "{x} yd"); + } + } + + const WOLF: u64 = 0xF130_0000_0000_0299; + + /// Our body hostile to nobody in particular and a wolf three yards off, the attack cursor on + /// it. + fn wolf_world() -> (World, Entity, crossbeam_channel::Receiver) { + const F_UNIT_FLAGS: u16 = benilla_protocol::field::FIELD_UNIT_FLAGS; + let (mut world, _vendor, rx) = walking_world(Vec3::ZERO, false); + let me = world + .query_filtered::>() + .single(&world) + .unwrap(); + world.entity_mut(me).insert(store(&[ + (F_HEALTH, 100), + (F_MAXHEALTH, 100), + (F_UNIT_FLAGS, 0x8), + ])); + let wolf = world + .spawn(( + Guid(WOLF), + store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)]), + Transform::from_xyz(3.0, 0.0, 0.0), + crate::net::NetEntity { + kind: benilla_protocol::EntityKind::Unit, + display_id: None, + scale: 1.0, + }, + )) + .id(); + (world, wolf, rx) + } + + /// The attack leg `0x60c247` calls StartAttack (`0x5ecb70`), which selects the target + /// (`SetSelection`, `0x5eccaa`) before the swing (`0x5eccfd`), as the right-click does. + #[test] + fn an_interaction_attacks_an_attackable_unit_and_selects_it() { + let (mut world, wolf, rx) = wolf_world(); + interact_with(&mut world, wolf); + let sent: Vec = rx.try_iter().collect(); + let at = |want: fn(&ClientCommand) -> bool| sent.iter().position(want); + let select = at(|c| matches!(c, ClientCommand::SetSelection { guid: WOLF })); + let swing = at(|c| matches!(c, ClientCommand::AttackSwing { guid: WOLF })); + assert!( + select.is_some() && swing.is_some() && select < swing, + "{sent:?}" + ); + assert_eq!(world.resource::().guid, Some(WOLF)); + } + + /// Mid-combat on another target the select is the click's: stop, select, swing. + #[test] + fn an_interaction_switching_targets_mid_combat_stops_selects_and_swings() { + const OLD: u64 = 0xF130_0000_0000_0111; + let (mut world, wolf, rx) = wolf_world(); + let old = world.spawn((Guid(OLD), store(&[(F_HEALTH, 100)]))).id(); + { + let mut selection = world.resource_mut::(); + selection.guid = Some(OLD); + selection.target = Some(old); + } + let me = world + .query_filtered::>() + .single(&world) + .unwrap(); + world.entity_mut(me).insert(Engaged(OLD)); + interact_with(&mut world, wolf); + let sent: Vec<&str> = rx + .try_iter() + .map(|c| match c { + ClientCommand::AttackStop => "stop", + ClientCommand::SetSelection { guid: WOLF } => "select", + ClientCommand::AttackSwing { guid: WOLF } => "swing", + _ => "other", + }) + .collect(); + assert_eq!(sent, ["stop", "select", "swing"]); + assert_eq!(world.resource::().guid, Some(WOLF)); + } + /// `0x5df2a0`: beyond melee reach the walk comes first and nothing is looted yet. #[test] fn a_far_body_to_loot_is_walked_to_before_the_loot() { diff --git a/crates/benilla-app/src/target/cursor_mode.rs b/crates/benilla-app/src/target/cursor_mode.rs index 2c9d5350..d16c8840 100644 --- a/crates/benilla-app/src/target/cursor_mode.rs +++ b/crates/benilla-app/src/target/cursor_mode.rs @@ -520,38 +520,79 @@ fn corpse_cursor(f: CorpseFacts) -> Option<(CursorKind, bool)> { None // the reference's "else clear": Point } -/// Resolve this frame's [`WorldCursor`]; no hover, or nothing resolvable, reads Point. -#[allow(clippy::type_complexity)] +/// Resolve this frame's [`WorldCursor`] over the hover. pub(super) fn classify_cursor( hovered: Res, hovered_object: Res, - factions: Option>, - reputations: Res, + inputs: CursorInputs, (mut cursor, mut fork): (ResMut, ResMut), - // `control_lost` is the attack leg's `[0xb4b3e4]` clear. - (player, approach): (Res, Res), - units: Query<( - &Transform, - Option<&ObjectStore>, - Option<&crate::go_anim::GoAnim>, - )>, - self_q: Query<(&Transform, &ObjectStore), With>, - go_inputs: super::lock::GoLockInputs, - player_actions: Res, - // `[0xb700e4]` and `[0xb700e8]`, the skin legs' learned-spell latches. - learned: Res, - quest: Res, - loot_cfg: Res, - keys: Res>, - // `[player+0x1d28/2c]`, the object whose loot window is open. - loot_latch: Res, - // `[0xb72038]`, the queued area; absent in a headless build, which reads as not queued. - stone: Option>, ) { + let (want, want_fork) = classify(&inputs, &hovered, &hovered_object); + if *cursor != want { + *cursor = want; + } + if *fork != want_fork { + *fork = want_fork; + } +} + +#[derive(bevy::ecs::system::SystemParam)] +pub(super) struct CursorInputs<'w, 's> { + factions: Option>, + reputations: Res<'w, Reputations>, + // `control_lost` is the attack leg's `[0xb4b3e4]` clear. + player: Res<'w, crate::player::Player>, + approach: Res<'w, crate::player::Approach>, + #[allow(clippy::type_complexity)] + units: Query< + 'w, + 's, + ( + &'static Transform, + Option<&'static ObjectStore>, + Option<&'static crate::go_anim::GoAnim>, + ), + >, + self_q: Query<'w, 's, (&'static Transform, &'static ObjectStore), With>, + go_inputs: super::lock::GoLockInputs<'w, 's>, + player_actions: Res<'w, crate::ui_action::PlayerActions>, + // `[0xb700e4]` and `[0xb700e8]`, the skin legs' learned-spell latches. + learned: Res<'w, crate::ui_action::LearnedAbilities>, + quest: Res<'w, crate::ui_quest::QuestGiver>, + loot_cfg: Res<'w, crate::ui_loot::LootConfig>, + keys: Res<'w, ButtonInput>, + // `[player+0x1d28/2c]`, the object whose loot window is open. + loot_latch: Res<'w, crate::ui_loot::LootLatch>, + // `[0xb72038]`, the queued area; absent in a headless build, which reads as not queued. + stone: Option>, +} + +/// The cursor and the attack fork over a pick; no pick, or nothing resolvable, reads Point. +pub(super) fn classify( + inputs: &CursorInputs, + hovered: &Hovered, + hovered_object: &HoveredObject, +) -> (WorldCursor, AttackFork) { + let CursorInputs { + factions, + reputations, + player, + approach, + units, + self_q, + go_inputs, + player_actions, + learned, + quest, + loot_cfg, + keys, + loot_latch, + stone, + } = inputs; let can_auto_interact = crate::player::can_auto_interact( approach.enabled, self_q.single().ok().map(|(_, s)| s), - &player, + player, ); // A GameObject that is not highlightable clears the cursor, as the reference's handler does. let resolve_go = || { @@ -644,7 +685,7 @@ pub(super) fn classify_cursor( if super::can_interact( Some(store), factions.as_deref(), - &reputations, + reputations, Some(self_store), ) { let status = hovered.guid.and_then(|g| quest.status(g)); @@ -658,7 +699,7 @@ pub(super) fn classify_cursor( if !super::can_attack( Some(store), factions.as_deref(), - &reputations, + reputations, Some(self_store), ) { return None; @@ -684,7 +725,7 @@ pub(super) fn classify_cursor( shift_held: shift, }) }; - let resolved = if go_is_nearest(&hovered, &hovered_object) { + let resolved = if go_is_nearest(hovered, hovered_object) { resolve_go() } else if hovered.corpse.is_some() { resolve_corpse() @@ -692,14 +733,7 @@ pub(super) fn classify_cursor( resolve_unit() }; let (kind, unable) = resolved.unwrap_or((CursorKind::Point, false)); - let want = WorldCursor { kind, unable }; - if *cursor != want { - *cursor = want; - } - let want_fork = AttackFork(attack_fork.get()); - if *fork != want_fork { - *fork = want_fork; - } + (WorldCursor { kind, unable }, AttackFork(attack_fork.get())) } #[cfg(test)] diff --git a/crates/benilla-app/src/target/mod.rs b/crates/benilla-app/src/target/mod.rs index ff7abf2f..fa71c603 100644 --- a/crates/benilla-app/src/target/mod.rs +++ b/crates/benilla-app/src/target/mod.rs @@ -75,6 +75,7 @@ pub(crate) use by_name::{AssistRequest, TargetByNameRequest}; pub(crate) use ring::{duel_rung, ring_reaction, ring_variant, Factions, RingVariant}; pub(crate) use click::DeselectGuid; +pub use click::Interact; /// Our target, set the instant we click, as the 1.12 client does without waiting for the server, /// and cleared on deselect or when it streams out. `CMSG_SET_SELECTION` carries the guid, which the @@ -286,6 +287,7 @@ impl Plugin for TargetPlugin { .add_message::() .add_message::() .add_message::() + .add_message::() .add_systems( Startup, ( @@ -327,7 +329,12 @@ impl Plugin for TargetPlugin { crate::spell::targeting::commit_ground_cast_on_click, crate::spell::targeting::commit_object_cast_on_click, // The verb a walk owes runs at its stop, ahead of this frame's click. - (click::act_on_arrival, click::act_on_right_click).chain(), + ( + click::act_on_arrival, + click::act_on_right_click, + click::act_on_interact, + ) + .chain(), // The loot close's guid-scoped deselect. click::clear_target_requests, // The script calls that touch the selection, the cast or the targeting cursor diff --git a/crates/benilla-app/tests/building_on_top.rs b/crates/benilla-app/tests/building_on_top.rs new file mode 100644 index 00000000..41f91458 --- /dev/null +++ b/crates/benilla-app/tests/building_on_top.rs @@ -0,0 +1,40 @@ +//! The seam `docs/CONTRIBUTING.md` ("Building on top") opens to a crate: read the streamed objects +//! through [`benilla_app::Objects`] and interact with one through [`benilla_app::Interact`], from +//! outside the crate. It fails to build if either goes private, with no feature needed. + +use benilla_app::{Interact, Objects}; +use bevy::prelude::*; + +/// What such a crate writes: an object it found, interacted with. +fn interact_with_the_nearest(objects: Objects, mut interact: MessageWriter) { + if let Some((_, entity, _)) = objects.iter().next() { + interact.write(Interact(entity)); + } +} + +/// A key press such a crate answers: the guid it picked, interacted with. +fn interact_with_guid(guid: u64, objects: &Objects, interact: &mut MessageWriter) { + if let Some(entity) = objects.entity(guid) { + interact.write(Interact(entity)); + } +} + +fn is_a_system(_: impl IntoSystem<(), (), M>) {} + +#[test] +fn a_crate_on_top_reads_objects_and_writes_interact() { + is_a_system(interact_with_the_nearest); + let _ = interact_with_guid; + + let mut app = App::new(); + app.add_message::(); + let entity = app.world_mut().spawn_empty().id(); + app.world_mut().write_message(Interact(entity)); + let sent: Vec = app + .world() + .resource::>() + .iter_current_update_messages() + .copied() + .collect(); + assert_eq!(sent, [Interact(entity)]); +}