From 023e8d2ff8d795596d71073a1f475fe7ffa27c6b Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:15:35 +0200 Subject: [PATCH 01/13] Bugfix TakeBlessing() --- Widgets/System/Messaging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Widgets/System/Messaging.py b/Widgets/System/Messaging.py index 526f5f89a..fe1bc34bb 100644 --- a/Widgets/System/Messaging.py +++ b/Widgets/System/Messaging.py @@ -2768,7 +2768,7 @@ def ProcessMessages(): case SharedCommandType.SendDialog: GLOBAL_CACHE.Coroutines.append(SendDialog(index, message)) case SharedCommandType.GetBlessing: - pass + GLOBAL_CACHE.Coroutines.append(GetBlessing(index, message)) case SharedCommandType.OpenChest: GLOBAL_CACHE.Coroutines.append(OpenChest(index, message)) pass From b9fe76be2229b31c371b40e8a850563361c65aa3 Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Sat, 6 Jun 2026 12:41:31 +0200 Subject: [PATCH 02/13] Added multibox feature to MoveAndInteractWithGagdet --- Sources/ApoSource/ApoBottingLib/helpers.py | 78 +++++++++++++++++++++ Sources/ApoSource/ApoBottingLib/wrappers.py | 20 +++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/Sources/ApoSource/ApoBottingLib/helpers.py b/Sources/ApoSource/ApoBottingLib/helpers.py index 16a4ab15b..4d90599d2 100644 --- a/Sources/ApoSource/ApoBottingLib/helpers.py +++ b/Sources/ApoSource/ApoBottingLib/helpers.py @@ -317,6 +317,84 @@ def _send_multibox_get_blessing_with_target( ) +def _send_multibox_interact_with_target( + *, + target_blackboard_key: str = _MULTIBOX_DIALOG_TARGET_KEY, + alt_delay_ms: int = 2000, + timeout_ms: int = 30000, + poll_interval_ms: int = 100, + log: bool = False, +) -> BehaviorTree: + + from Py4GWCoreLib.GlobalCache import GLOBAL_CACHE + from Py4GWCoreLib.Player import Player + + alt_delay_ms = max(0, int(alt_delay_ms)) + + def _build(node: BehaviorTree.Node) -> BehaviorTree: + target_id = int(node.blackboard.get(target_blackboard_key, 0) or 0) + sender_email = str(Player.GetAccountEmail() or '') + + children: list[BehaviorTree | BehaviorTree.Node] = [] + if target_id > 0: + for idx, account in enumerate(GLOBAL_CACHE.ShMem.GetAllAccountData() or []): + receiver_email = str(getattr(account, 'AccountEmail', '') or '') + if not receiver_email or receiver_email == sender_email: + continue + + alt_step = RoutinesBT.Composite.Sequence( + RoutinesBT.Player.Wait(duration_ms=alt_delay_ms, log=log), + RoutinesBT.Shared.SendAndWait( + command=SharedCommandType.InteractWithTarget, + params=(float(target_id), 0.0, 0.0, 0.0), + recipients=[receiver_email], + timeout_ms=timeout_ms, + poll_interval_ms=poll_interval_ms, + log=log, + ), + name=f'MultiboxInteractAltStep_{idx}', + ) + + # A single slow/stuck alt must not abort the remaining accounts. The fallback + # logs (and then succeeds) so a swallowed account is visible rather than silent. + children.append( + BehaviorTree( + BehaviorTree.SelectorNode( + name=f'MultiboxInteractAltGuard_{idx}', + children=[ + alt_step, + RoutinesBT.Player.LogMessage( + source='ApoBottingLib', + to_console=True, + to_blackboard=False, + message=( + f'MoveAndInteractWithGadget multibox: alt account {receiver_email} ' + f'did not finish interacting within {timeout_ms} ms; skipping it.' + ), + ), + ], + ) + ) + ) + + if not children: + children.append( + BehaviorTree(BehaviorTree.SucceederNode(name='MultiboxInteractNoAltAccounts')) + ) + + return RoutinesBT.Composite.Sequence( + *children, + name='MultiboxInteractWithTargetSequence', + ) + + return BehaviorTree( + BehaviorTree.SubtreeNode( + name='SendMultiboxInteractWithTarget', + subtree_fn=_build, + ) + ) + + def _final_point(pos: PointOrPath) -> Vec2f: point = PointPath.final_point(pos) if point is None: diff --git a/Sources/ApoSource/ApoBottingLib/wrappers.py b/Sources/ApoSource/ApoBottingLib/wrappers.py index 0239ceb0c..066dbb76e 100644 --- a/Sources/ApoSource/ApoBottingLib/wrappers.py +++ b/Sources/ApoSource/ApoBottingLib/wrappers.py @@ -11,6 +11,7 @@ from .helpers import _POST_MOVEMENT_SETTLE_MS from .helpers import _send_multibox_auto_dialog from .helpers import _send_multibox_get_blessing_with_target +from .helpers import _send_multibox_interact_with_target from .helpers import _send_multibox_dialog_to_target from .helpers import _send_multibox_manual_dialog from .helpers import _send_multibox_take_dialog_with_target @@ -696,8 +697,19 @@ def TargetNearestGadgetAndInteract( pos: PointOrPath, target_distance: float = Range.Nearby.value, log: bool = False, + multi_account: bool = False, ) -> BehaviorTree: point = _final_point(pos) + if multi_account: + return _pause_heroai_for_action( + RoutinesBT.Composite.Sequence( + RoutinesBT.Agents.TargetNearestGadgetXY(x=point.x, y=point.y, distance=target_distance, log=log), + RoutinesBT.Player.InteractTarget(log=log), + _capture_current_target(), + _send_multibox_interact_with_target(log=log), + name="TargetNearestGadgetAndInteractMultiboxSequence", + ) + ) return _pause_heroai_for_action( RoutinesBT.Composite.Sequence( RoutinesBT.Agents.TargetNearestGadgetXY(x=point.x, y=point.y, distance=target_distance, log=log), @@ -838,8 +850,8 @@ def DialogAtXY( multi_account=multi_account, ) -def InteractWithGadgetAtXY(pos: PointOrPath, target_distance: float = 200.0) -> BehaviorTree: - return TargetNearestGadgetAndInteract(pos=pos, target_distance=target_distance, log=False) +def InteractWithGadgetAtXY(pos: PointOrPath, target_distance: float = 200.0, multi_account: bool = False) -> BehaviorTree: + return TargetNearestGadgetAndInteract(pos=pos, target_distance=target_distance, log=False, multi_account=multi_account) def TargetAndDialogByModelID( modelID_or_encStr: int | str, @@ -1282,7 +1294,9 @@ def MoveAndInteractWithGadget( pause_on_combat: bool | None = None, flag_heroes_to_waypoint: bool = False, log: bool = False, + multi_account: bool = False, ) -> BehaviorTree: + return _movement_with_runtime_pause( "MoveAndInteractWithGadget", lambda resolved_pause: RoutinesBT.Composite.Sequence( @@ -1295,7 +1309,7 @@ def MoveAndInteractWithGadget( ), _wait_until_player_stops_moving(log=log), Wait(_POST_MOVEMENT_SETTLE_MS, log=log), - TargetNearestGadgetAndInteract(pos=pos, target_distance=target_distance, log=log), + TargetNearestGadgetAndInteract(pos=pos, target_distance=target_distance, log=log, multi_account=multi_account), name="MoveAndInteractWithGadget", ), pause_on_combat=pause_on_combat, From bc01ac8e5a8c63f449706c149c333115b6b1fb2d Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:56:36 +0200 Subject: [PATCH 03/13] bugfix EVA --- Py4GWCoreLib/Builds/Skills/any/PvE.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Py4GWCoreLib/Builds/Skills/any/PvE.py b/Py4GWCoreLib/Builds/Skills/any/PvE.py index 50112ed07..bdfb09fef 100644 --- a/Py4GWCoreLib/Builds/Skills/any/PvE.py +++ b/Py4GWCoreLib/Builds/Skills/any/PvE.py @@ -183,10 +183,7 @@ def Ebon_Vanguard_Assassin_Support( target_agent_id = Routines.Targeting.PickClusteredTarget( cluster_radius=cluster_radius, - preferred_condition=lambda agent_id: ( - Agent.GetHealth(agent_id) > 0.3 - and (Agent.IsHexed(agent_id) or Agent.IsConditioned(agent_id)) - ), + preferred_condition=lambda agent_id: Agent.GetHealth(agent_id) > 0.3, filter_radius=Range.Spellcast.value, ) From b1afdad6423a106d92be4613149cd8d591dd6f03 Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:00:28 +0200 Subject: [PATCH 04/13] Bugfix allegiance check --- Py4GWCoreLib/routines_src/Agents.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Py4GWCoreLib/routines_src/Agents.py b/Py4GWCoreLib/routines_src/Agents.py index 35b53d5ec..386e20210 100644 --- a/Py4GWCoreLib/routines_src/Agents.py +++ b/Py4GWCoreLib/routines_src/Agents.py @@ -1,6 +1,6 @@ import importlib -from Py4GWCoreLib.enums_src.GameData_enums import Range +from Py4GWCoreLib.enums_src.GameData_enums import Allegiance, Range class _RProxy: def __getattr__(self, name: str): @@ -187,8 +187,10 @@ def GetFilteredEnemyArray(x, y, max_distance=4500.0, aggressive_only = False): """ from ..GlobalCache import GLOBAL_CACHE enemy_array = AgentArray.GetEnemyArray() + enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.IsValid(agent_id)) enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Utils.Distance((x,y), Agent.GetXY(agent_id)) <= max_distance) enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.IsAlive(agent_id)) + enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.GetAllegiance(agent_id)[0] == Allegiance.Enemy) enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Player.GetAgentID() != agent_id) if aggressive_only: enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.IsAggressive(agent_id)) From 6df29bb10c580af33e225e122ae10d37cb275621 Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:50:17 +0200 Subject: [PATCH 05/13] revert --- Py4GWCoreLib/routines_src/Agents.py | 4 +--- Py4GWCoreLib/routines_src/Targeting.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Py4GWCoreLib/routines_src/Agents.py b/Py4GWCoreLib/routines_src/Agents.py index 386e20210..35b53d5ec 100644 --- a/Py4GWCoreLib/routines_src/Agents.py +++ b/Py4GWCoreLib/routines_src/Agents.py @@ -1,6 +1,6 @@ import importlib -from Py4GWCoreLib.enums_src.GameData_enums import Allegiance, Range +from Py4GWCoreLib.enums_src.GameData_enums import Range class _RProxy: def __getattr__(self, name: str): @@ -187,10 +187,8 @@ def GetFilteredEnemyArray(x, y, max_distance=4500.0, aggressive_only = False): """ from ..GlobalCache import GLOBAL_CACHE enemy_array = AgentArray.GetEnemyArray() - enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.IsValid(agent_id)) enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Utils.Distance((x,y), Agent.GetXY(agent_id)) <= max_distance) enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.IsAlive(agent_id)) - enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.GetAllegiance(agent_id)[0] == Allegiance.Enemy) enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Player.GetAgentID() != agent_id) if aggressive_only: enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.IsAggressive(agent_id)) diff --git a/Py4GWCoreLib/routines_src/Targeting.py b/Py4GWCoreLib/routines_src/Targeting.py index 6fe818d9c..f5e18bf9b 100644 --- a/Py4GWCoreLib/routines_src/Targeting.py +++ b/Py4GWCoreLib/routines_src/Targeting.py @@ -7,7 +7,7 @@ def __getattr__(self, name: str): Routines = _RProxy() -from ..enums_src.GameData_enums import Range +from ..enums_src.GameData_enums import Allegiance, Range from ..Player import Player #region Targetting @@ -731,7 +731,14 @@ def PickClusteredTarget( player_pos = Player.GetXY() enemy_array = AgentArray.GetEnemyArray() enemy_array = AgentArray.Filter.ByDistance(enemy_array, player_pos, effective_filter_radius) - enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.IsAlive(agent_id)) + enemy_array = AgentArray.Filter.ByCondition( + enemy_array, + lambda agent_id: ( + Agent.IsValid(agent_id) + and not Agent.IsDead(agent_id) + and Agent.GetAllegiance(agent_id)[0] == Allegiance.Enemy + ), + ) if not enemy_array: return 0 From fd5e6af7b7faa3f4baab1074fc4a88df2e36d7f7 Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:47:45 +0200 Subject: [PATCH 06/13] Update HeroAI.py --- Py4GWCoreLib/Builds/Any/HeroAI.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Py4GWCoreLib/Builds/Any/HeroAI.py b/Py4GWCoreLib/Builds/Any/HeroAI.py index da815b2ef..2570fa241 100644 --- a/Py4GWCoreLib/Builds/Any/HeroAI.py +++ b/Py4GWCoreLib/Builds/Any/HeroAI.py @@ -161,6 +161,12 @@ def _run_contract(self, cached_data, is_in_combat: bool): contract_build.ResetTickState() if is_in_combat: + # Matched-build contracts bypass CombatClass.HandleCombat, which is the + # only place the leader's selected target gets auto-called to the party. + # Run the call hook here so leader target-calling works regardless of + # which build drives combat (leader-only + once-per-target gates live + # inside MaybeCallCombatTarget). + cached_data.combat_handler._maybe_call_leader_selected_target(cached_data) yield from contract_build.ProcessCombat() else: yield from contract_build.ProcessOOC() From 67a2b388c132636e02f4739116fcf9dffca1002c Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:31:42 +0200 Subject: [PATCH 07/13] bugfix: UnboundLocalError in DrawFollowFormationsQuickWindow when window collapsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An indentation regression left the "Follow Publish" section (thresholds, unstuck knobs, dirty_runtime_cfg check) outside the `if ImGui.Begin(...)` block. When the Follow Formations window was collapsed, Begin returned False, skipping `dirty_runtime_cfg = False`, but the dedented `if dirty_runtime_cfg:` still ran — raising UnboundLocalError every frame. Re-indent the block back inside Begin and restore the "Show Flagging Window" checkbox as a sibling of the dirty check (it was accidentally nested under it, only rendering on frames where a setting changed) --- HeroAI/ui_base.py | 298 +++++++++++++++++++++++----------------------- 1 file changed, 149 insertions(+), 149 deletions(-) diff --git a/HeroAI/ui_base.py b/HeroAI/ui_base.py index 01a7e781b..6a4e29dd2 100644 --- a/HeroAI/ui_base.py +++ b/HeroAI/ui_base.py @@ -2047,156 +2047,156 @@ def DrawFollowFormationsQuickWindow(cached_data: CacheData): HeroAI_BaseUI._set_party_follow_option(cached_data, "Avoidance", new_avoidance) HeroAI_BaseUI._refresh_follow_publisher_live(cached_data) - PyImGui.separator() - PyImGui.text("Follow Publish") - - if PyImGui.button("Print Follow Debug"): - HeroAI_BaseUI._print_follow_debug_dump() - - new_show_broadcast_follow_positions = PyImGui.checkbox("Draw Followers FollowPos (3D)", hero_globals.show_broadcast_follow_positions) - if new_show_broadcast_follow_positions != hero_globals.show_broadcast_follow_positions: - hero_globals.show_broadcast_follow_positions = new_show_broadcast_follow_positions - dirty_runtime_cfg = True - - new_show_followers_unstuck_overlay = PyImGui.checkbox("Draw Followers Unstuck (3D)", hero_globals.show_followers_unstuck_overlay) - if new_show_followers_unstuck_overlay != hero_globals.show_followers_unstuck_overlay: - hero_globals.show_followers_unstuck_overlay = new_show_followers_unstuck_overlay - dirty_runtime_cfg = True - - new_show_broadcast_follow_threshold_rings = PyImGui.checkbox("Draw Followers Threshold Rings (3D)", hero_globals.show_broadcast_follow_threshold_rings) - if new_show_broadcast_follow_threshold_rings != hero_globals.show_broadcast_follow_threshold_rings: - hero_globals.show_broadcast_follow_threshold_rings = new_show_broadcast_follow_threshold_rings - dirty_runtime_cfg = True - - new_show_stuck_avoidance_debug = PyImGui.checkbox("Stuck Avoidance Verbose Logs", hero_globals.show_stuck_avoidance_debug) - if new_show_stuck_avoidance_debug != hero_globals.show_stuck_avoidance_debug: - hero_globals.show_stuck_avoidance_debug = new_show_stuck_avoidance_debug - dirty_runtime_cfg = True - - presets = HeroAI_BaseUI._follow_threshold_presets() - preset_names = [name for name, _ in presets] - - d_idx = PyImGui.combo("Follow Threshold Preset", HeroAI_BaseUI._threshold_mode_index(HeroAI_BaseUI.follow_move_threshold_default_mode), preset_names) - d_name, d_val = presets[d_idx] - if d_name != HeroAI_BaseUI.follow_move_threshold_default_mode: - HeroAI_BaseUI.follow_move_threshold_default_mode = d_name - if d_val is not None: - HeroAI_BaseUI.follow_move_threshold_default = float(d_val) - dirty_runtime_cfg = True - new_default_thr = max(0.0, float(PyImGui.input_float("Follow Threshold", float(HeroAI_BaseUI.follow_move_threshold_default)))) - if abs(new_default_thr - HeroAI_BaseUI.follow_move_threshold_default) > 0.0001: - HeroAI_BaseUI.follow_move_threshold_default = new_default_thr - if HeroAI_BaseUI.follow_move_threshold_default_mode != "Manual": - HeroAI_BaseUI.follow_move_threshold_default_mode = "Manual" - dirty_runtime_cfg = True - - c_idx = PyImGui.combo("Combat Threshold Preset", HeroAI_BaseUI._threshold_mode_index(HeroAI_BaseUI.follow_move_threshold_combat_mode), preset_names) - c_name, c_val = presets[c_idx] - if c_name != HeroAI_BaseUI.follow_move_threshold_combat_mode: - HeroAI_BaseUI.follow_move_threshold_combat_mode = c_name - if c_val is not None: - HeroAI_BaseUI.follow_move_threshold_combat = float(c_val) - dirty_runtime_cfg = True - new_combat_thr = max(0.0, float(PyImGui.input_float("Combat Follow Threshold", float(HeroAI_BaseUI.follow_move_threshold_combat)))) - if abs(new_combat_thr - HeroAI_BaseUI.follow_move_threshold_combat) > 0.0001: - HeroAI_BaseUI.follow_move_threshold_combat = new_combat_thr - if HeroAI_BaseUI.follow_move_threshold_combat_mode != "Manual": - HeroAI_BaseUI.follow_move_threshold_combat_mode = "Manual" - dirty_runtime_cfg = True - - f_idx = PyImGui.combo("Flag Threshold Preset", HeroAI_BaseUI._threshold_mode_index(HeroAI_BaseUI.follow_move_threshold_flagged_mode), preset_names) - f_name, f_val = presets[f_idx] - if f_name != HeroAI_BaseUI.follow_move_threshold_flagged_mode: - HeroAI_BaseUI.follow_move_threshold_flagged_mode = f_name - if f_val is not None: - HeroAI_BaseUI.follow_move_threshold_flagged = float(f_val) - dirty_runtime_cfg = True - new_flagged_thr = max(0.0, float(PyImGui.input_float("Flag Threshold", float(HeroAI_BaseUI.follow_move_threshold_flagged)))) - if abs(new_flagged_thr - HeroAI_BaseUI.follow_move_threshold_flagged) > 0.0001: - HeroAI_BaseUI.follow_move_threshold_flagged = new_flagged_thr - if HeroAI_BaseUI.follow_move_threshold_flagged_mode != "Manual": - HeroAI_BaseUI.follow_move_threshold_flagged_mode = "Manual" - dirty_runtime_cfg = True + PyImGui.separator() + PyImGui.text("Follow Publish") + + if PyImGui.button("Print Follow Debug"): + HeroAI_BaseUI._print_follow_debug_dump() + + new_show_broadcast_follow_positions = PyImGui.checkbox("Draw Followers FollowPos (3D)", hero_globals.show_broadcast_follow_positions) + if new_show_broadcast_follow_positions != hero_globals.show_broadcast_follow_positions: + hero_globals.show_broadcast_follow_positions = new_show_broadcast_follow_positions + dirty_runtime_cfg = True + + new_show_followers_unstuck_overlay = PyImGui.checkbox("Draw Followers Unstuck (3D)", hero_globals.show_followers_unstuck_overlay) + if new_show_followers_unstuck_overlay != hero_globals.show_followers_unstuck_overlay: + hero_globals.show_followers_unstuck_overlay = new_show_followers_unstuck_overlay + dirty_runtime_cfg = True + + new_show_broadcast_follow_threshold_rings = PyImGui.checkbox("Draw Followers Threshold Rings (3D)", hero_globals.show_broadcast_follow_threshold_rings) + if new_show_broadcast_follow_threshold_rings != hero_globals.show_broadcast_follow_threshold_rings: + hero_globals.show_broadcast_follow_threshold_rings = new_show_broadcast_follow_threshold_rings + dirty_runtime_cfg = True + + new_show_stuck_avoidance_debug = PyImGui.checkbox("Stuck Avoidance Verbose Logs", hero_globals.show_stuck_avoidance_debug) + if new_show_stuck_avoidance_debug != hero_globals.show_stuck_avoidance_debug: + hero_globals.show_stuck_avoidance_debug = new_show_stuck_avoidance_debug + dirty_runtime_cfg = True + + presets = HeroAI_BaseUI._follow_threshold_presets() + preset_names = [name for name, _ in presets] + + d_idx = PyImGui.combo("Follow Threshold Preset", HeroAI_BaseUI._threshold_mode_index(HeroAI_BaseUI.follow_move_threshold_default_mode), preset_names) + d_name, d_val = presets[d_idx] + if d_name != HeroAI_BaseUI.follow_move_threshold_default_mode: + HeroAI_BaseUI.follow_move_threshold_default_mode = d_name + if d_val is not None: + HeroAI_BaseUI.follow_move_threshold_default = float(d_val) + dirty_runtime_cfg = True + new_default_thr = max(0.0, float(PyImGui.input_float("Follow Threshold", float(HeroAI_BaseUI.follow_move_threshold_default)))) + if abs(new_default_thr - HeroAI_BaseUI.follow_move_threshold_default) > 0.0001: + HeroAI_BaseUI.follow_move_threshold_default = new_default_thr + if HeroAI_BaseUI.follow_move_threshold_default_mode != "Manual": + HeroAI_BaseUI.follow_move_threshold_default_mode = "Manual" + dirty_runtime_cfg = True + + c_idx = PyImGui.combo("Combat Threshold Preset", HeroAI_BaseUI._threshold_mode_index(HeroAI_BaseUI.follow_move_threshold_combat_mode), preset_names) + c_name, c_val = presets[c_idx] + if c_name != HeroAI_BaseUI.follow_move_threshold_combat_mode: + HeroAI_BaseUI.follow_move_threshold_combat_mode = c_name + if c_val is not None: + HeroAI_BaseUI.follow_move_threshold_combat = float(c_val) + dirty_runtime_cfg = True + new_combat_thr = max(0.0, float(PyImGui.input_float("Combat Follow Threshold", float(HeroAI_BaseUI.follow_move_threshold_combat)))) + if abs(new_combat_thr - HeroAI_BaseUI.follow_move_threshold_combat) > 0.0001: + HeroAI_BaseUI.follow_move_threshold_combat = new_combat_thr + if HeroAI_BaseUI.follow_move_threshold_combat_mode != "Manual": + HeroAI_BaseUI.follow_move_threshold_combat_mode = "Manual" + dirty_runtime_cfg = True + + f_idx = PyImGui.combo("Flag Threshold Preset", HeroAI_BaseUI._threshold_mode_index(HeroAI_BaseUI.follow_move_threshold_flagged_mode), preset_names) + f_name, f_val = presets[f_idx] + if f_name != HeroAI_BaseUI.follow_move_threshold_flagged_mode: + HeroAI_BaseUI.follow_move_threshold_flagged_mode = f_name + if f_val is not None: + HeroAI_BaseUI.follow_move_threshold_flagged = float(f_val) + dirty_runtime_cfg = True + new_flagged_thr = max(0.0, float(PyImGui.input_float("Flag Threshold", float(HeroAI_BaseUI.follow_move_threshold_flagged)))) + if abs(new_flagged_thr - HeroAI_BaseUI.follow_move_threshold_flagged) > 0.0001: + HeroAI_BaseUI.follow_move_threshold_flagged = new_flagged_thr + if HeroAI_BaseUI.follow_move_threshold_flagged_mode != "Manual": + HeroAI_BaseUI.follow_move_threshold_flagged_mode = "Manual" + dirty_runtime_cfg = True - PyImGui.separator() - PyImGui.text("Follower Resolves (unstuck)") - # Stuck-avoidance live-tunable knobs. All sync cross-client via - # FollowRuntime.ini on the leader-write, follower-poll throttle in - # smart_unstuck.reload_smart_unstuck_config_from_ini. - # Geometry knobs: - # - Waypoint Smoothing: BT.Move "advance on approach" threshold. - # - Stuck Circle Radius: imaginary obstacle circle radius. The - # waypoint arc auto-scales — circle and arc stay in sync. - # - Enemy Detection Range: scan radius for the body-block - # fallback. When ≥1 enemy is in this range and the follower - # is stuck, the detour pivots to circles centered on the - # enemies instead of a single front-of-follower circle. - # Detection-sensitivity knobs (per ~500ms sample): - # - Stuck Sample Count: consecutive no-progress samples to - # trigger (1 = fire on the first comparison after baseline). - # - Min Distance Activate Unstuck: short-circuit detection when the - # follower is already this close to follow_xy. - # - No-Progress Move Units: sample counts as no-progress when - # the avatar moved less than this in the sample window. - # - No-Progress Close Units: sample counts as no-progress when - # the gap to follow_xy shrank by less than this. - from HeroAI.follow.smart_unstuck import ( - SMART_UNSTUCK_CFG, - reload_smart_unstuck_config_from_ini, - ) - new_waypoint_smoothing = max(1.0, float(PyImGui.input_float( - "Waypoint Smoothing", float(SMART_UNSTUCK_CFG.waypoint_smoothing) - ))) - new_touch_radius = max(50.0, min(400.0, float(PyImGui.input_float( - "Stuck Circle Radius", float(SMART_UNSTUCK_CFG.touch_radius) - )))) - new_enemy_range = max(50.0, min(400.0, float(PyImGui.input_float( - "Enemy Detection Range", float(SMART_UNSTUCK_CFG.enemy_detection_range) - )))) - new_sample_count = max(1, min(10, int(PyImGui.input_int( - "Stuck Sample Count", int(SMART_UNSTUCK_CFG.stuck_sample_count) - )))) - new_min_distance = max(50.0, min(600.0, float(PyImGui.input_float( - "Min Distance Activate Unstuck", float(SMART_UNSTUCK_CFG.min_distance_activate_unstuck) - )))) - new_move_units = max(1.0, min(100.0, float(PyImGui.input_float( - "No-Progress Move Units", float(SMART_UNSTUCK_CFG.no_progress_move_units) - )))) - new_close_units = max(1.0, min(100.0, float(PyImGui.input_float( - "No-Progress Close Units", float(SMART_UNSTUCK_CFG.no_progress_close_units) - )))) - new_early_exit = max(50.0, min(800.0, float(PyImGui.input_float( - "Min Dist Early Exit", float(SMART_UNSTUCK_CFG.obstacle_cleared_delta) - )))) - stuck_cfg_changed = ( - abs(new_waypoint_smoothing - SMART_UNSTUCK_CFG.waypoint_smoothing) > 0.0001 - or abs(new_touch_radius - SMART_UNSTUCK_CFG.touch_radius) > 0.0001 - or abs(new_enemy_range - SMART_UNSTUCK_CFG.enemy_detection_range) > 0.0001 - or new_sample_count != SMART_UNSTUCK_CFG.stuck_sample_count - or abs(new_min_distance - SMART_UNSTUCK_CFG.min_distance_activate_unstuck) > 0.0001 - or abs(new_move_units - SMART_UNSTUCK_CFG.no_progress_move_units) > 0.0001 - or abs(new_close_units - SMART_UNSTUCK_CFG.no_progress_close_units) > 0.0001 - or abs(new_early_exit - SMART_UNSTUCK_CFG.obstacle_cleared_delta) > 0.0001 - ) - if stuck_cfg_changed: - SMART_UNSTUCK_CFG.waypoint_smoothing = new_waypoint_smoothing - SMART_UNSTUCK_CFG.touch_radius = new_touch_radius - SMART_UNSTUCK_CFG.enemy_detection_range = new_enemy_range - SMART_UNSTUCK_CFG.stuck_sample_count = new_sample_count - SMART_UNSTUCK_CFG.min_distance_activate_unstuck = new_min_distance - SMART_UNSTUCK_CFG.no_progress_move_units = new_move_units - SMART_UNSTUCK_CFG.no_progress_close_units = new_close_units - SMART_UNSTUCK_CFG.obstacle_cleared_delta = new_early_exit - dirty_runtime_cfg = True - # Force-write to INI immediately so follower clients see the - # change within their next 1s reload poll. - HeroAI_BaseUI._save_follow_runtime_config(cached_data.formation_window_ini_key) - reload_smart_unstuck_config_from_ini(force_reload=True) - - if dirty_runtime_cfg: - HeroAI_BaseUI._save_follow_runtime_config(cached_data.formation_window_ini_key) - HeroAI_BaseUI._apply_follow_thresholds_to_party(cached_data) - HeroAI_BaseUI._refresh_follow_publisher_live(cached_data, reload_ini=True) + PyImGui.separator() + PyImGui.text("Follower Resolves (unstuck)") + # Stuck-avoidance live-tunable knobs. All sync cross-client via + # FollowRuntime.ini on the leader-write, follower-poll throttle in + # smart_unstuck.reload_smart_unstuck_config_from_ini. + # Geometry knobs: + # - Waypoint Smoothing: BT.Move "advance on approach" threshold. + # - Stuck Circle Radius: imaginary obstacle circle radius. The + # waypoint arc auto-scales — circle and arc stay in sync. + # - Enemy Detection Range: scan radius for the body-block + # fallback. When ≥1 enemy is in this range and the follower + # is stuck, the detour pivots to circles centered on the + # enemies instead of a single front-of-follower circle. + # Detection-sensitivity knobs (per ~500ms sample): + # - Stuck Sample Count: consecutive no-progress samples to + # trigger (1 = fire on the first comparison after baseline). + # - Min Distance Activate Unstuck: short-circuit detection when the + # follower is already this close to follow_xy. + # - No-Progress Move Units: sample counts as no-progress when + # the avatar moved less than this in the sample window. + # - No-Progress Close Units: sample counts as no-progress when + # the gap to follow_xy shrank by less than this. + from HeroAI.follow.smart_unstuck import ( + SMART_UNSTUCK_CFG, + reload_smart_unstuck_config_from_ini, + ) + new_waypoint_smoothing = max(1.0, float(PyImGui.input_float( + "Waypoint Smoothing", float(SMART_UNSTUCK_CFG.waypoint_smoothing) + ))) + new_touch_radius = max(50.0, min(400.0, float(PyImGui.input_float( + "Stuck Circle Radius", float(SMART_UNSTUCK_CFG.touch_radius) + )))) + new_enemy_range = max(50.0, min(400.0, float(PyImGui.input_float( + "Enemy Detection Range", float(SMART_UNSTUCK_CFG.enemy_detection_range) + )))) + new_sample_count = max(1, min(10, int(PyImGui.input_int( + "Stuck Sample Count", int(SMART_UNSTUCK_CFG.stuck_sample_count) + )))) + new_min_distance = max(50.0, min(600.0, float(PyImGui.input_float( + "Min Distance Activate Unstuck", float(SMART_UNSTUCK_CFG.min_distance_activate_unstuck) + )))) + new_move_units = max(1.0, min(100.0, float(PyImGui.input_float( + "No-Progress Move Units", float(SMART_UNSTUCK_CFG.no_progress_move_units) + )))) + new_close_units = max(1.0, min(100.0, float(PyImGui.input_float( + "No-Progress Close Units", float(SMART_UNSTUCK_CFG.no_progress_close_units) + )))) + new_early_exit = max(50.0, min(800.0, float(PyImGui.input_float( + "Min Dist Early Exit", float(SMART_UNSTUCK_CFG.obstacle_cleared_delta) + )))) + stuck_cfg_changed = ( + abs(new_waypoint_smoothing - SMART_UNSTUCK_CFG.waypoint_smoothing) > 0.0001 + or abs(new_touch_radius - SMART_UNSTUCK_CFG.touch_radius) > 0.0001 + or abs(new_enemy_range - SMART_UNSTUCK_CFG.enemy_detection_range) > 0.0001 + or new_sample_count != SMART_UNSTUCK_CFG.stuck_sample_count + or abs(new_min_distance - SMART_UNSTUCK_CFG.min_distance_activate_unstuck) > 0.0001 + or abs(new_move_units - SMART_UNSTUCK_CFG.no_progress_move_units) > 0.0001 + or abs(new_close_units - SMART_UNSTUCK_CFG.no_progress_close_units) > 0.0001 + or abs(new_early_exit - SMART_UNSTUCK_CFG.obstacle_cleared_delta) > 0.0001 + ) + if stuck_cfg_changed: + SMART_UNSTUCK_CFG.waypoint_smoothing = new_waypoint_smoothing + SMART_UNSTUCK_CFG.touch_radius = new_touch_radius + SMART_UNSTUCK_CFG.enemy_detection_range = new_enemy_range + SMART_UNSTUCK_CFG.stuck_sample_count = new_sample_count + SMART_UNSTUCK_CFG.min_distance_activate_unstuck = new_min_distance + SMART_UNSTUCK_CFG.no_progress_move_units = new_move_units + SMART_UNSTUCK_CFG.no_progress_close_units = new_close_units + SMART_UNSTUCK_CFG.obstacle_cleared_delta = new_early_exit + dirty_runtime_cfg = True + # Force-write to INI immediately so follower clients see the + # change within their next 1s reload poll. + HeroAI_BaseUI._save_follow_runtime_config(cached_data.formation_window_ini_key) + reload_smart_unstuck_config_from_ini(force_reload=True) + + if dirty_runtime_cfg: + HeroAI_BaseUI._save_follow_runtime_config(cached_data.formation_window_ini_key) + HeroAI_BaseUI._apply_follow_thresholds_to_party(cached_data) + HeroAI_BaseUI._refresh_follow_publisher_live(cached_data, reload_ini=True) if Map.IsExplorable() and Player.GetAgentID() == GLOBAL_CACHE.Party.GetPartyLeaderID(): new_show_flagging_window = PyImGui.checkbox("Show Flagging Window", hero_globals.show_flagging_window) From 5a892f524b5efd63e9f33ab2d639e1278d74cc5f Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:34:48 +0200 Subject: [PATCH 08/13] Reverse change. --- Py4GWCoreLib/Builds/Any/HeroAI.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Py4GWCoreLib/Builds/Any/HeroAI.py b/Py4GWCoreLib/Builds/Any/HeroAI.py index 2570fa241..da815b2ef 100644 --- a/Py4GWCoreLib/Builds/Any/HeroAI.py +++ b/Py4GWCoreLib/Builds/Any/HeroAI.py @@ -161,12 +161,6 @@ def _run_contract(self, cached_data, is_in_combat: bool): contract_build.ResetTickState() if is_in_combat: - # Matched-build contracts bypass CombatClass.HandleCombat, which is the - # only place the leader's selected target gets auto-called to the party. - # Run the call hook here so leader target-calling works regardless of - # which build drives combat (leader-only + once-per-target gates live - # inside MaybeCallCombatTarget). - cached_data.combat_handler._maybe_call_leader_selected_target(cached_data) yield from contract_build.ProcessCombat() else: yield from contract_build.ProcessOOC() From 61193a52d5bae7c287a8a6f3bd3f49b307042856 Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:47:32 +0200 Subject: [PATCH 09/13] Bugfix Agents --- .../Builds/Skills/mesmer/DominationMagic.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/Py4GWCoreLib/Builds/Skills/mesmer/DominationMagic.py b/Py4GWCoreLib/Builds/Skills/mesmer/DominationMagic.py index cc4d04fa8..e81f44bef 100644 --- a/Py4GWCoreLib/Builds/Skills/mesmer/DominationMagic.py +++ b/Py4GWCoreLib/Builds/Skills/mesmer/DominationMagic.py @@ -20,7 +20,7 @@ def __init__(self, build: BuildMgr) -> None: #region E def Energy_Surge(self) -> BuildCoroutine: - from Py4GWCoreLib import Agent, Player, Range, GLOBAL_CACHE + from Py4GWCoreLib import Agent, Allegiance, Player, Range, GLOBAL_CACHE energy_surge_id: int = Skill.GetID("Energy_Surge") aoe_range = GLOBAL_CACHE.Skill.Data.GetAoERange(energy_surge_id) or Range.Nearby.value @@ -48,7 +48,16 @@ def _is_enemy_casting_spell(agent_id: int) -> bool: filter_radius=Range.Spellcast.value, ) current_target_id = Player.GetTargetID() - if Agent.IsValid(current_target_id) and not Agent.IsDead(current_target_id): + # Only fall back to the current target when the gated picker found + # at least one real enemy and the current target itself is a live + # enemy — a snapshot-desynced ally (e.g. EVAS summon) must never + # win the 0 >= 0 comparison and eat the cast. + if ( + best_enemy_target_id + and Agent.IsValid(current_target_id) + and not Agent.IsDead(current_target_id) + and Agent.GetAllegiance(current_target_id)[0] == Allegiance.Enemy + ): current_target_score = Routines.Targeting.CountNearbyEnemies(current_target_id, aoe_range) best_enemy_score = Routines.Targeting.CountNearbyEnemies(best_enemy_target_id, aoe_range) if current_target_score >= best_enemy_score: @@ -96,7 +105,7 @@ def Cry_of_Frustration(self) -> BuildCoroutine: #region M @coordinates_whiteboard_skill_target(Skill.GetID("Mistrust")) def Mistrust(self) -> BuildCoroutine: - from Py4GWCoreLib import Agent, Player, Range, GLOBAL_CACHE + from Py4GWCoreLib import Agent, Allegiance, Player, Range, GLOBAL_CACHE mistrust_id: int = Skill.GetID("Mistrust") @@ -125,7 +134,16 @@ def _is_enemy_casting_spell(agent_id: int) -> bool: filter_radius=Range.Spellcast.value, ) current_target_id = Player.GetTargetID() - if Agent.IsValid(current_target_id) and not Agent.IsDead(current_target_id): + # Only fall back to the current target when the gated picker found + # at least one real enemy and the current target itself is a live + # enemy — a snapshot-desynced ally (e.g. EVAS summon) must never + # win the 0 >= 0 comparison and eat the cast. + if ( + best_enemy_target_id + and Agent.IsValid(current_target_id) + and not Agent.IsDead(current_target_id) + and Agent.GetAllegiance(current_target_id)[0] == Allegiance.Enemy + ): current_target_score = Routines.Targeting.CountNearbyEnemies(current_target_id, aoe_range) best_enemy_score = Routines.Targeting.CountNearbyEnemies(best_enemy_target_id, aoe_range) if current_target_score >= best_enemy_score: From 12d937313d123fb1c6041de220bec12047a5707c Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:01:35 +0200 Subject: [PATCH 10/13] Bug fix summon spirits / remove debugging logs --- Py4GWCoreLib/Builds/Skills/any/NoAttribute.py | 48 ++----------------- 1 file changed, 3 insertions(+), 45 deletions(-) diff --git a/Py4GWCoreLib/Builds/Skills/any/NoAttribute.py b/Py4GWCoreLib/Builds/Skills/any/NoAttribute.py index 3e99f10f8..ae6130c98 100644 --- a/Py4GWCoreLib/Builds/Skills/any/NoAttribute.py +++ b/Py4GWCoreLib/Builds/Skills/any/NoAttribute.py @@ -590,7 +590,6 @@ def _get_owned_core_spirits( def _summon_spirits(self, skill_id: int) -> BuildCoroutine: if not self.build.IsSkillEquipped(skill_id): - self.build._debug(f"Summon Spirits skipped: skill not equipped ({skill_id})", True) return False in_aggro = self.build.IsInAggro() @@ -601,28 +600,9 @@ def _summon_spirits(self, skill_id: int) -> BuildCoroutine: Range.Compass.value, include_owner_fallback=True, ) - if spirits: - self.build._debug( - "Summon Spirits owner fallback: nearby core spirits found with non-matching owner metadata", - True, - ) - else: - spirits_safe_compass = self._get_owned_core_spirits( - Range.SafeCompass.value, - include_owner_fallback=True, - ) - if spirits_safe_compass: - self.build._debug( - "Summon Spirits skipped: owned core spirits found, but all are outside compass range", - True, - ) - else: - self.build._debug("Summon Spirits skipped: no owned core spirits found", True) + if not spirits: return False - if not spirits: - return False - player_xy = Player.GetXY() spirit_distances = [ Utils.Distance(player_xy, Agent.GetXY(spirit_id)) @@ -634,42 +614,20 @@ def _summon_spirits(self, skill_id: int) -> BuildCoroutine: Range.Nearby.value < distance <= Range.Compass.value for distance in spirit_distances ) - mode_label = "aggro-nearby" else: should_reposition = any( Range.Spirit.value < distance <= Range.Compass.value for distance in spirit_distances ) - mode_label = "ooc-compass" if not should_reposition: - nearest_distance = min(spirit_distances) if spirit_distances else 0.0 - farthest_distance = max(spirit_distances) if spirit_distances else 0.0 - self.build._debug( - ( - "Summon Spirits skipped: all owned core spirits within threshold " - f"(nearest={nearest_distance:.1f}, farthest={farthest_distance:.1f}, mode={mode_label})" - ), - True, - ) return False - self.build._debug( - f"Summon Spirits trigger: owned core spirit is beyond spirit range (mode={mode_label})", - True, - ) - precheck_failure = self.build._get_can_cast_skill_failure_reason(skill_id) - if precheck_failure is not None: - self.build._debug(f"Summon Spirits precheck blocked: reason={precheck_failure}", True) + if not self.build.CanCastSkillID(skill_id): return False - result = (yield from self.build.CastSkillID( + return (yield from self.build.CastSkillID( skill_id=skill_id, log=False, aftercast_delay=250, )) - if result: - self.build._debug("Summon Spirits cast success", True) - else: - self.build._debug("Summon Spirits cast failed (CanCastSkillID or runtime cast failure)", True) - return result From fbb1071127bdc257b423f4155519de96b7725f8e Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:50:25 +0200 Subject: [PATCH 11/13] fix: write INI files atomically to prevent multibox client crash Concurrent multibox writes to the shared WidgetManager.ini tore the file (truncate-in-place save); reading it back logged a multi-line configparser error that tripped GW's TextParser assert and crashed the client. Started with b665952e/192a53be (apoguita, 2026-05-18), which broadcast widget enable/disable to every box on BottingTree lifecycle events. save() now writes a temp file + os.replace() (atomic on NTFS). --- Py4GWCoreLib/py4gwcorelib_src/IniHandler.py | 47 +++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/Py4GWCoreLib/py4gwcorelib_src/IniHandler.py b/Py4GWCoreLib/py4gwcorelib_src/IniHandler.py index 22b2281d4..19543c4fc 100644 --- a/Py4GWCoreLib/py4gwcorelib_src/IniHandler.py +++ b/Py4GWCoreLib/py4gwcorelib_src/IniHandler.py @@ -1,7 +1,15 @@ import os +import tempfile +import time import configparser from datetime import datetime +# Atomic save() retry budget for the os.replace rename step: a Windows AV or +# search indexer can transiently hold the destination file handle, so retry a +# few times (then fall back to an in-place write) instead of failing the save. +_ATOMIC_SAVE_RETRIES = 3 +_ATOMIC_SAVE_RETRY_DELAY_S = 0.01 + #region IniHandler class IniHandler: def __init__(self, filename: str): @@ -56,10 +64,43 @@ def reload(self) -> configparser.ConfigParser: def save(self, config: configparser.ConfigParser) -> None: """ - Save changes to the INI file. + Save changes to the INI file atomically. + + Serialize to a temp file in the same directory, flush+fsync, then + os.replace() it onto the target (an atomic rename on NTFS). This + prevents torn/half-written files when multiple processes (multibox + clients) write the same shared INI concurrently: a reader always sees + either the complete old file or the complete new one. If the rename + keeps failing (for example an AV/indexer holds the destination), fall + back to an in-place write so behavior is never worse than before. """ - with open(self.filename, 'w', encoding="utf-8") as configfile: - config.write(configfile) + directory = os.path.dirname(self.filename) or "." + tmp_path = None + try: + fd, tmp_path = tempfile.mkstemp(dir=directory, suffix=".ini.tmp") + with os.fdopen(fd, "w", encoding="utf-8") as configfile: + config.write(configfile) + configfile.flush() + os.fsync(configfile.fileno()) + + for attempt in range(_ATOMIC_SAVE_RETRIES): + try: + os.replace(tmp_path, self.filename) + tmp_path = None # renamed onto target; nothing to clean up + break + except PermissionError: + if attempt == _ATOMIC_SAVE_RETRIES - 1: + with open(self.filename, "w", encoding="utf-8") as configfile: + config.write(configfile) + else: + time.sleep(_ATOMIC_SAVE_RETRY_DELAY_S) + finally: + if tmp_path is not None: + try: + os.remove(tmp_path) + except OSError: + pass + self.config = config try: self.last_modified = os.path.getmtime(self.filename) From c01b96a068f8fe9ce53b916daf9d6281784246da Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:07:42 +0200 Subject: [PATCH 12/13] New widget stackdropper Fixed the rotten drop item scanner in C++. Right now with the rotten scanner the widget will only dropping one item; if you apply the fix to the scanner it will drop the appropriate amount. --- .../Guild Wars/Items & Loot/StackDropper.py | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 Widgets/Guild Wars/Items & Loot/StackDropper.py diff --git a/Widgets/Guild Wars/Items & Loot/StackDropper.py b/Widgets/Guild Wars/Items & Loot/StackDropper.py new file mode 100644 index 000000000..6d62c7745 --- /dev/null +++ b/Widgets/Guild Wars/Items & Loot/StackDropper.py @@ -0,0 +1,292 @@ +import PyInventory +import PyImGui +import PyItem + +from Py4GWCoreLib import * + +MODULE_NAME = "Stack Dropper" +MODULE_ICON = "Textures/Module_Icons/Template.png" + +INI_KEY = "" +INI_PATH = "Widgets/StackDropper" +INI_FILENAME = "StackDropper.ini" + +# State +target_model_id = 0 +hovered_model_id = 0 +hovered_quantity = 0 +auto_max_drop = False +dialog_was_open = False # track dialog state to only fire once per open + +# Auto-drop state +stack_size = 250 # how many units count as one "stack" to drop per pile +stacks_to_drop = 1 # how many such piles to drop +is_dropping = False +drop_model_id = 0 +drop_stack_size = 0 # stack size locked in when the run started +drop_start_units = 0 # total units of the model at run start +drop_stop_at_units = 0 # stop once live units reach this +drop_pending = [] # item_ids still to drop one pile from, planned at start +drop_last_units = -1 +drop_stall_ticks = 0 +drop_status = "" + +INVENTORY_BAGS = [Bags.Backpack, Bags.BeltPouch, Bags.Bag1, Bags.Bag2] + +MAX_BTN_HASH = 4008686776 +DROP_BTN_HASH = 4014954629 + +MAX_STACK_QTY = 250 # GW caps a single slot at 250, so a pile can't exceed this +DROP_TICK_MS = 100 +MAX_STALL_TICKS = 15 # confirm phase: abort after this many ticks without inventory change (1.5s) + +drop_timer = ThrottledTimer(DROP_TICK_MS) + + +def _get_item_model_id(item) -> int: + if hasattr(item, "model_id"): + return int(item.model_id) + return int(GLOBAL_CACHE.Item.GetModelID(int(item.item_id))) + + +def _get_item_quantity(item, default: int = 1) -> int: + if hasattr(item, "quantity"): + return int(item.quantity) + try: + return int(GLOBAL_CACHE.Item.Properties.GetQuantity(int(item.item_id))) + except Exception: + return default + + +def _update_hovered_item(): + global hovered_model_id, hovered_quantity + try: + hovered_item_id = int(GLOBAL_CACHE.Inventory.GetHoveredItemID()) + except Exception: + return + if hovered_item_id <= 0: + return + try: + item = PyItem.PyItem(hovered_item_id) + hovered_model_id = int(item.model_id) + hovered_quantity = int(item.quantity) + except Exception: + hovered_model_id = 0 + hovered_quantity = 0 + + +def _get_model_item_slots(model_id): + """Return [(item_id, quantity), ...] for every inventory slot holding the model.""" + slots = [] + for bag_enum in INVENTORY_BAGS: + try: + bag = PyInventory.Bag(bag_enum.value, bag_enum.name) + items = bag.GetItems() + except Exception: + continue + for item in items: + if not item or int(item.item_id) == 0: + continue + if _get_item_model_id(item) != model_id: + continue + qty = _get_item_quantity(item) + if qty > 0: + slots.append((int(item.item_id), qty)) + return slots + + +def _count_total_units(model_id): + return sum(qty for _, qty in _get_model_item_slots(model_id)) + + +def _count_available_piles(model_id, size): + """How many whole piles of `size` can be formed, counting per slot (a slot can + only contribute floor(qty / size) since each drop comes from a single slot).""" + if size <= 0: + return 0 + return sum(qty // size for _, qty in _get_model_item_slots(model_id)) + + +def _start_auto_drop(): + global is_dropping, drop_model_id, drop_stack_size, drop_pending + global drop_start_units, drop_stop_at_units, drop_last_units, drop_stall_ticks, drop_status + + if not Map.IsExplorable(): + drop_status = "Can only drop in explorable areas" + return + + size = max(1, min(MAX_STACK_QTY, stack_size)) + slots = _get_model_item_slots(target_model_id) + total = sum(qty for _, qty in slots) + + # Plan every drop up front from a snapshot, fullest slots first; the fast + # tick can't rely on the live count, it lags behind the issued drops. + pending = [] + for item_id, qty in sorted(slots, key=lambda s: s[1], reverse=True): + while qty >= size and len(pending) < stacks_to_drop: + pending.append(item_id) + qty -= size + + if not pending: + drop_status = f"No full stacks of {size} to drop" + return + + drop_model_id = target_model_id + drop_stack_size = size + drop_start_units = total + drop_stop_at_units = total - len(pending) * size + drop_pending = pending + drop_last_units = -1 + drop_stall_ticks = 0 + drop_status = "" + is_dropping = True + drop_timer.Reset() + + +def _process_auto_drop(): + """Issue one planned pile per tick, then wait for the inventory to confirm.""" + global is_dropping, drop_pending, drop_last_units, drop_stall_ticks, drop_status + + if not is_dropping: + return + if not drop_timer.IsExpired(): + return + drop_timer.Reset() + + # Issue phase: one pre-planned pile per tick. + if drop_pending: + item_id = drop_pending.pop(0) + GLOBAL_CACHE.Inventory.DropItem(item_id, drop_stack_size) + return + + # Confirm phase: all drops issued, wait for the live count to catch up. + current = _count_total_units(drop_model_id) + if current <= drop_stop_at_units: + is_dropping = False + dropped = drop_start_units - current + drop_status = f"Done - dropped {dropped} ({dropped // drop_stack_size} x {drop_stack_size})" + return + + # Abort if drops stop registering (e.g. dropping not allowed here) + if current == drop_last_units: + drop_stall_ticks += 1 + if drop_stall_ticks >= MAX_STALL_TICKS: + is_dropping = False + dropped = drop_start_units - current + drop_status = f"Aborted - {dropped} dropped, rest not registering" + else: + drop_stall_ticks = 0 + drop_last_units = current + + +def _auto_confirm_drop_dialog(): + """Fire once when dialog appears, then wait for it to close before firing again.""" + global dialog_was_open + + max_frame_id = UIManager.GetFrameIDByHash(MAX_BTN_HASH) + drop_frame_id = UIManager.GetFrameIDByHash(DROP_BTN_HASH) + dialog_open = ( + max_frame_id != 0 + and drop_frame_id != 0 + and UIManager.FrameExists(max_frame_id) + and UIManager.FrameExists(drop_frame_id) + ) + + if dialog_open and not dialog_was_open: + # Dialog just appeared — click Max then Drop once + GLOBAL_CACHE._ActionQueueManager.AddAction('ACTION', UIManager.FrameClick, max_frame_id) + GLOBAL_CACHE._ActionQueueManager.AddAction('ACTION', UIManager.FrameClick, drop_frame_id) + + dialog_was_open = dialog_open + + +def draw_widget(): + global INI_KEY, target_model_id, auto_max_drop, stack_size, stacks_to_drop, is_dropping, drop_status + + if ImGui.Begin(INI_KEY, MODULE_NAME, flags=PyImGui.WindowFlags.AlwaysAutoResize): + + # --- Hovered item picker --- + if hovered_model_id > 0: + PyImGui.text(f"Hovered: Model {hovered_model_id} (Qty: {hovered_quantity})") + PyImGui.same_line(0, 10) + if PyImGui.button("Use"): + target_model_id = hovered_model_id + else: + PyImGui.text("Hover an inventory item to detect it") + + PyImGui.separator() + + # --- Model ID input --- + PyImGui.text("Target Model ID:") + target_model_id = PyImGui.input_int("##model_id", target_model_id) + + if target_model_id > 0: + # --- Stack size (units per pile) --- + PyImGui.text("Stack size (qty per drop):") + stack_size = max(1, min(MAX_STACK_QTY, PyImGui.input_int("##stack_size", stack_size))) + + # --- Stacks to drop --- + PyImGui.text("Stacks to drop:") + stacks_to_drop = max(1, PyImGui.input_int("##stacks_to_drop", stacks_to_drop)) + + total_units = _count_total_units(target_model_id) + available = _count_available_piles(target_model_id, stack_size) + PyImGui.text(f"Available: {total_units} units = {available} stack(s) of {stack_size}") + + if is_dropping: + dropped = max(0, drop_start_units - total_units) + target = drop_start_units - drop_stop_at_units + PyImGui.text(f"Dropping... {dropped}/{target} units") + if PyImGui.button("Stop"): + is_dropping = False + drop_pending.clear() + drop_status = "Stopped by user" + else: + if PyImGui.button(f"Drop {min(stacks_to_drop, available)} stack(s) of {stack_size}"): + _start_auto_drop() + + if drop_status: + PyImGui.text(drop_status) + + PyImGui.separator() + + # --- Auto Max Drop toggle --- + auto_max_drop = PyImGui.checkbox("Auto Max Drop", auto_max_drop) + PyImGui.text("Drag items out of inventory.") + PyImGui.text("Dialog auto-confirms with Max qty.") + + ImGui.End(INI_KEY) + + _update_hovered_item() + + _process_auto_drop() + + if auto_max_drop: + _auto_confirm_drop_dialog() + + +def draw(): + global initialized + if initialized: + draw_widget() + + +def main(): + global INI_KEY, initialized + if initialized: + return + + if not Routines.Checks.Map.MapValid(): + return + + if not INI_KEY: + INI_KEY = IniManager().ensure_key(INI_PATH, INI_FILENAME) + if not INI_KEY: + return + IniManager().load_once(INI_KEY) + initialized = True + + +initialized = False +if __name__ == "__main__": + main() From 1aa776f3b24ca4827700bdcba286e5237bc4f988 Mon Sep 17 00:00:00 2001 From: Mitchell <46272535+sloppynacho@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:25:37 +0200 Subject: [PATCH 13/13] Add gadget interact lock & multibox handler Introduce a per-gadget INTERACT_AGENT whiteboard lock and multibox flow to serialize gadget interactions across in-group same-map accounts. WhiteboardLocks: add INTERACT_LOCK_* constants and helper functions (post/get/clear/is_blocked) with a long default lease to cover move+interact+loot+hold. enums: add SharedCommandType.InteractGadgetWithLock. ApoBottingLib.helpers: add _broadcast_gadget_interact_with_lock to select same-map group recipients and send InteractGadgetWithLock with an adaptive timeout. wrappers: switch multibox gadget interaction to use the new broadcast helper. Widgets/System/Messaging: import lock helpers, add InteractGadgetWithLock handler implementing the lock-then-interact lifecycle (contention loop, follow-path, interact, loot, hold, and cleanup), include the new command in suspended commands and message dispatching. Overall this prevents double-interacts by serializing turns and provides timeouts and safe cleanup on failures. --- Py4GWCoreLib/GlobalCache/SharedMemory.py | 17 +++ Py4GWCoreLib/GlobalCache/WhiteboardLocks.py | 134 ++++++++++++++++++++ Py4GWCoreLib/enums_src/Multiboxing_enums.py | 1 + Sources/ApoSource/ApoBottingLib/helpers.py | 134 +++++++++++--------- Sources/ApoSource/ApoBottingLib/wrappers.py | 16 ++- Widgets/System/Messaging.py | 98 ++++++++++++++ 6 files changed, 335 insertions(+), 65 deletions(-) diff --git a/Py4GWCoreLib/GlobalCache/SharedMemory.py b/Py4GWCoreLib/GlobalCache/SharedMemory.py index 462b92cbd..61b7e19f2 100644 --- a/Py4GWCoreLib/GlobalCache/SharedMemory.py +++ b/Py4GWCoreLib/GlobalCache/SharedMemory.py @@ -490,6 +490,23 @@ def ClearIntentsByOwner(self, owner_email: str) -> int: """Zero every intent slot whose OwnerEmail matches.""" return self.GetAllAccounts().ClearIntentsByOwner(owner_email) + def ClearLockByOwnerKindTarget( + self, + owner_email: str, + kind_id: int, + target_id: int, + group_id: int, + ) -> int: + """Zero active locks matching (owner, kind, target, group). Returns count cleared. + + Lets a lock owner release a held lock immediately (e.g. after a gadget + interaction or loot pickup) so waiting peers proceed at once instead of + waiting out the lease expiry. + """ + return self.GetAllAccounts().ClearLockByOwnerKindTarget( + owner_email, kind_id, target_id, group_id + ) + @frame_cache(category="SharedMemory", source_lib="IsIntentClaimed") def IsIntentClaimed( self, diff --git a/Py4GWCoreLib/GlobalCache/WhiteboardLocks.py b/Py4GWCoreLib/GlobalCache/WhiteboardLocks.py index 68fed1bf6..53232b175 100644 --- a/Py4GWCoreLib/GlobalCache/WhiteboardLocks.py +++ b/Py4GWCoreLib/GlobalCache/WhiteboardLocks.py @@ -20,6 +20,12 @@ HEX_REMOVAL_LOCK_MIN_DURATION_MS = 750 LOOT_LOCK_KEY = 0 LOOT_LOCK_MIN_DURATION_MS = 4000 +INTERACT_LOCK_KEY = 0 +# Lease must outlive the entire gated turn (FollowPath timeout 10000 + interact + +# LootItems wait(1000)+per-item pickups ~5000 + 1500ms mandatory hold + ping budget). +# The handler also passes an explicit minimum_ms derived from its own timeouts so the +# lease and the turn can never drift apart; this floor is the safe default. +INTERACT_LOCK_MIN_DURATION_MS = 20000 BUFF_TARGET_LOCK_KEY = 0 BUFF_TARGET_LOCK_MIN_DURATION_MS = 1000 BLOOD_ENERGY_BUFF_LOCK_KEY = 1 @@ -605,3 +611,131 @@ def clear_loot_lock(item_agent_id: int) -> bool: return cleared > 0 except Exception: return False + + +def get_interact_lock_owner(gadget_agent_id: int, now_tick: int | None = None) -> tuple[str, int]: + """Effective owner (email, slot) of a gadget interact lock: earliest poster wins + (PostLock is not CAS), so a claimant can verify it won the race.""" + if gadget_agent_id <= 0: + return "", -1 + try: + from Py4GWCoreLib import GLOBAL_CACHE + + email, group_id = _owner_context() + if not email: + return "", -1 + if now_tick is None: + now_tick = int(Py4GW.Game.get_tick_count64()) + + winner = None + for slot_index, intent in GLOBAL_CACHE.ShMem.GetAllAccounts().GetAllIntents(): + if int(intent.KindID) != int(WhiteboardLockKind.INTERACT_AGENT): + continue + if int(intent.SkillID) != int(INTERACT_LOCK_KEY): + continue + if int(intent.TargetAgentID) != int(gadget_agent_id): + continue + if int(intent.IsolationGroupID) != int(group_id): + continue + if int(intent.ExpiresAtTick) <= int(now_tick): + continue + candidate = (int(intent.PostedAtTick), int(slot_index), intent.OwnerEmail or "") + if winner is None or candidate < winner: + winner = candidate + + if winner is None: + return "", -1 + return winner[2], winner[1] + except Exception: + return "", -1 + + +def is_interact_lock_blocked(gadget_agent_id: int, now_tick: int | None = None) -> bool: + """True when another account already holds the interact lock for this gadget.""" + if gadget_agent_id <= 0: + return False + try: + from Py4GWCoreLib import GLOBAL_CACHE + + email, group_id = _owner_context() + if not email: + return False + if now_tick is None: + now_tick = int(Py4GW.Game.get_tick_count64()) + return bool(GLOBAL_CACHE.ShMem.IsLockBlocked( + int(WhiteboardLockKind.INTERACT_AGENT), + INTERACT_LOCK_KEY, + int(gadget_agent_id), + int(group_id), + email, + int(now_tick), + int(WhiteboardLockMode.EXCLUSIVE), + 1, + int(WhiteboardReentryPolicy.OWNER_REENTRANT), + int(WhiteboardClaimStrength.HARD), + )) + except Exception: + return False + + +def post_interact_lock(gadget_agent_id: int, minimum_ms: int = INTERACT_LOCK_MIN_DURATION_MS) -> int: + """Reserve a gadget for exclusive interaction. Returns slot index or -1.""" + if gadget_agent_id <= 0: + return -1 + try: + from Py4GWCoreLib import GLOBAL_CACHE + + email, group_id = _owner_context() + if not email: + return -1 + now = int(Py4GW.Game.get_tick_count64()) + + for slot_index, intent in GLOBAL_CACHE.ShMem.GetAllAccounts().GetAllIntents(): + if intent.OwnerEmail != email: + continue + if int(intent.KindID) != int(WhiteboardLockKind.INTERACT_AGENT): + continue + if int(intent.TargetAgentID) != int(gadget_agent_id): + continue + if int(intent.IsolationGroupID) != int(group_id): + continue + if int(intent.ExpiresAtTick) <= now: + continue + return int(slot_index) + + expires_at = now + max(int(minimum_ms), INTERACT_LOCK_MIN_DURATION_MS) + return int(GLOBAL_CACHE.ShMem.PostLock( + email, + int(WhiteboardLockKind.INTERACT_AGENT), + INTERACT_LOCK_KEY, + int(gadget_agent_id), + int(expires_at), + int(group_id), + int(WhiteboardLockMode.EXCLUSIVE), + 1, + int(WhiteboardReentryPolicy.OWNER_REENTRANT), + int(WhiteboardClaimStrength.HARD), + )) + except Exception: + return -1 + + +def clear_interact_lock(gadget_agent_id: int) -> bool: + """Release the local interact lock early after a successful interaction or skip.""" + if gadget_agent_id <= 0: + return False + try: + from Py4GWCoreLib import GLOBAL_CACHE + + email, group_id = _owner_context() + if not email: + return False + cleared = int(GLOBAL_CACHE.ShMem.ClearLockByOwnerKindTarget( + email, + int(WhiteboardLockKind.INTERACT_AGENT), + int(gadget_agent_id), + int(group_id), + )) + return cleared > 0 + except Exception: + return False diff --git a/Py4GWCoreLib/enums_src/Multiboxing_enums.py b/Py4GWCoreLib/enums_src/Multiboxing_enums.py index 664913153..d1d5c20d7 100644 --- a/Py4GWCoreLib/enums_src/Multiboxing_enums.py +++ b/Py4GWCoreLib/enums_src/Multiboxing_enums.py @@ -6,6 +6,7 @@ class SharedCommandType(IntEnum): TravelToMap = auto() InviteToParty = auto() InteractWithTarget = auto() + InteractGadgetWithLock = auto() TakeDialogWithTarget = auto() GetBlessing = auto() OpenChest = auto() diff --git a/Sources/ApoSource/ApoBottingLib/helpers.py b/Sources/ApoSource/ApoBottingLib/helpers.py index 4d90599d2..4062cdba2 100644 --- a/Sources/ApoSource/ApoBottingLib/helpers.py +++ b/Sources/ApoSource/ApoBottingLib/helpers.py @@ -317,80 +317,98 @@ def _send_multibox_get_blessing_with_target( ) -def _send_multibox_interact_with_target( +def _broadcast_gadget_interact_with_lock( + x: float, + y: float, + distance: float, *, - target_blackboard_key: str = _MULTIBOX_DIALOG_TARGET_KEY, - alt_delay_ms: int = 2000, - timeout_ms: int = 30000, + refs_blackboard_key: str = 'apo_gadget_interact_refs', poll_interval_ms: int = 100, log: bool = False, ) -> BehaviorTree: - + """Broadcast InteractGadgetWithLock to all in-group same-map accounts (incl. self) and + wait for all to finish. The per-gadget lock serialises turns; the wait timeout scales + with the participant count.""" from Py4GWCoreLib.GlobalCache import GLOBAL_CACHE from Py4GWCoreLib.Player import Player - alt_delay_ms = max(0, int(alt_delay_ms)) - - def _build(node: BehaviorTree.Node) -> BehaviorTree: - target_id = int(node.blackboard.get(target_blackboard_key, 0) or 0) - sender_email = str(Player.GetAccountEmail() or '') - - children: list[BehaviorTree | BehaviorTree.Node] = [] - if target_id > 0: - for idx, account in enumerate(GLOBAL_CACHE.ShMem.GetAllAccountData() or []): + px = float(x) + py = float(y) + pdistance = float(distance) + + def _prepare(node: BehaviorTree.Node) -> BehaviorTree.NodeState: + own_email = str(Player.GetAccountEmail() or '') + sender_data = GLOBAL_CACHE.ShMem.GetAccountDataFromEmail(own_email) if own_email else None + + recipients: list[str] = [] + if sender_data is not None: + try: + own_group = int(GLOBAL_CACHE.ShMem.GetAccountGroupByEmail(own_email)) + except Exception: + own_group = 0 + own_map = ( + sender_data.AgentData.Map.MapID, + sender_data.AgentData.Map.Region, + sender_data.AgentData.Map.District, + sender_data.AgentData.Map.Language, + ) + for account in GLOBAL_CACHE.ShMem.GetAllAccountData() or []: receiver_email = str(getattr(account, 'AccountEmail', '') or '') - if not receiver_email or receiver_email == sender_email: + if not receiver_email: continue - - alt_step = RoutinesBT.Composite.Sequence( - RoutinesBT.Player.Wait(duration_ms=alt_delay_ms, log=log), - RoutinesBT.Shared.SendAndWait( - command=SharedCommandType.InteractWithTarget, - params=(float(target_id), 0.0, 0.0, 0.0), - recipients=[receiver_email], - timeout_ms=timeout_ms, - poll_interval_ms=poll_interval_ms, - log=log, - ), - name=f'MultiboxInteractAltStep_{idx}', + try: + account_group = int(GLOBAL_CACHE.ShMem.GetAccountGroupByEmail(receiver_email)) + except Exception: + account_group = 0 + if account_group != own_group: + continue + account_map = ( + account.AgentData.Map.MapID, + account.AgentData.Map.Region, + account.AgentData.Map.District, + account.AgentData.Map.Language, ) + if account_map != own_map: + continue + recipients.append(receiver_email) - # A single slow/stuck alt must not abort the remaining accounts. The fallback - # logs (and then succeeds) so a swallowed account is visible rather than silent. - children.append( - BehaviorTree( - BehaviorTree.SelectorNode( - name=f'MultiboxInteractAltGuard_{idx}', - children=[ - alt_step, - RoutinesBT.Player.LogMessage( - source='ApoBottingLib', - to_console=True, - to_blackboard=False, - message=( - f'MoveAndInteractWithGadget multibox: alt account {receiver_email} ' - f'did not finish interacting within {timeout_ms} ms; skipping it.' - ), - ), - ], - ) - ) - ) + if own_email and own_email not in recipients: + recipients.append(own_email) - if not children: - children.append( - BehaviorTree(BehaviorTree.SucceederNode(name='MultiboxInteractNoAltAccounts')) - ) + node.blackboard[f'{refs_blackboard_key}_recipient_emails'] = recipients + node.blackboard[f'{refs_blackboard_key}_include_self'] = bool(own_email) + return BehaviorTree.NodeState.SUCCESS - return RoutinesBT.Composite.Sequence( - *children, - name='MultiboxInteractWithTargetSequence', + def _dispatch(node: BehaviorTree.Node) -> BehaviorTree: + recipients = list(node.blackboard.get(f'{refs_blackboard_key}_recipient_emails', [])) + # ~20s worst case per serialised turn; budget must outwait all followers. + timeout_ms = max(60000, 20000 * max(1, len(recipients))) + return RoutinesBT.Shared.SendAndWait( + command=SharedCommandType.InteractGadgetWithLock, + params=(px, py, pdistance, 0.0), + recipients=recipients, + include_self=bool(node.blackboard.get(f'{refs_blackboard_key}_include_self', False)), + refs_blackboard_key=refs_blackboard_key, + timeout_ms=timeout_ms, + poll_interval_ms=poll_interval_ms, + log=log, ) return BehaviorTree( - BehaviorTree.SubtreeNode( - name='SendMultiboxInteractWithTarget', - subtree_fn=_build, + BehaviorTree.SequenceNode( + name='BroadcastGadgetInteractWithLock', + children=[ + BehaviorTree.ActionNode( + name='PrepareGadgetInteractRecipients', + action_fn=_prepare, + ), + BehaviorTree( + BehaviorTree.SubtreeNode( + name='DispatchGadgetInteractWithLock', + subtree_fn=_dispatch, + ) + ), + ], ) ) diff --git a/Sources/ApoSource/ApoBottingLib/wrappers.py b/Sources/ApoSource/ApoBottingLib/wrappers.py index 066dbb76e..812b9cbbd 100644 --- a/Sources/ApoSource/ApoBottingLib/wrappers.py +++ b/Sources/ApoSource/ApoBottingLib/wrappers.py @@ -11,7 +11,7 @@ from .helpers import _POST_MOVEMENT_SETTLE_MS from .helpers import _send_multibox_auto_dialog from .helpers import _send_multibox_get_blessing_with_target -from .helpers import _send_multibox_interact_with_target +from .helpers import _broadcast_gadget_interact_with_lock from .helpers import _send_multibox_dialog_to_target from .helpers import _send_multibox_manual_dialog from .helpers import _send_multibox_take_dialog_with_target @@ -701,13 +701,15 @@ def TargetNearestGadgetAndInteract( ) -> BehaviorTree: point = _final_point(pos) if multi_account: + # Broadcast-once + per-gadget EXCLUSIVE whiteboard lock. Every in-group same-map + # account INCLUDING self runs the same lock-gated follower handler; the leader is + # just another peer, so there is no leader-side interact and no double-interact. return _pause_heroai_for_action( - RoutinesBT.Composite.Sequence( - RoutinesBT.Agents.TargetNearestGadgetXY(x=point.x, y=point.y, distance=target_distance, log=log), - RoutinesBT.Player.InteractTarget(log=log), - _capture_current_target(), - _send_multibox_interact_with_target(log=log), - name="TargetNearestGadgetAndInteractMultiboxSequence", + _broadcast_gadget_interact_with_lock( + x=point.x, + y=point.y, + distance=target_distance, + log=log, ) ) return _pause_heroai_for_action( diff --git a/Widgets/System/Messaging.py b/Widgets/System/Messaging.py index fe1bc34bb..dea1efb4f 100644 --- a/Widgets/System/Messaging.py +++ b/Widgets/System/Messaging.py @@ -21,6 +21,8 @@ from Py4GWCoreLib import AutoPathing from Py4GWCoreLib import IniHandler from Py4GWCoreLib.GlobalCache.WhiteboardLocks import post_loot_lock, clear_loot_lock +from Py4GWCoreLib.GlobalCache.WhiteboardLocks import is_interact_lock_blocked, post_interact_lock, clear_interact_lock, get_interact_lock_owner +from Py4GWCoreLib.routines_src.Agents import Agents from Py4GWCoreLib.Py4GWcorelib import Keystroke from Py4GWCoreLib.Quest import Quest from Py4GWCoreLib.enums_src.Model_enums import ModelID @@ -330,6 +332,7 @@ def RestoreHeroAISnapshot(account_email: str): SharedCommandType.PixelStack, SharedCommandType.BruteForceUnstuck, SharedCommandType.InteractWithTarget, + SharedCommandType.InteractGadgetWithLock, SharedCommandType.TakeDialogWithTarget, SharedCommandType.SendDialogToTarget, SharedCommandType.SendDialog, @@ -674,6 +677,99 @@ def InteractWithTarget(index: int, message: SharedMessageStruct): GLOBAL_CACHE.ShMem.MarkMessageAsFinished(message.ReceiverEmail, index) +# endregion +# region InteractGadgetWithLock + + +def InteractGadgetWithLock(index: int, message: SharedMessageStruct): + # Multibox gadget interaction: every recipient runs this; the per-gadget INTERACT_AGENT + # lock serialises turns. Mark running before any wait so it is not re-dispatched. + ConsoleLog(MODULE_NAME, f"Processing InteractGadgetWithLock message: {message}", Console.MessageType.Info, False) + GLOBAL_CACHE.ShMem.MarkMessageAsRunning(message.ReceiverEmail, index) + + x = float(message.Params[0]) + y = float(message.Params[1]) + distance = float(message.Params[2]) + + POLL_MS = 100 + STAGGER_MS = 200 + LOCK_WAIT_TIMEOUT_MS = 60000 + HOLD_MS = 1500 + FOLLOWPATH_TIMEOUT_MS = 10000 + LOOT_BUDGET_MS = 6000 + PING_MARGIN_MS = 2000 + # Lease must outlive the whole turn (move + interact + loot + hold) or a peer races in. + LOCK_LEASE_MS = FOLLOWPATH_TIMEOUT_MS + LOOT_BUDGET_MS + HOLD_MS + PING_MARGIN_MS + + SnapshotHeroAIOptions(message.ReceiverEmail) + gadget_id = 0 + try: + DisableHeroAIOptions(message.ReceiverEmail) + yield from Routines.Yield.wait(100) + + gadget_id = Agents.GetNearestGadgetXY(x, y, distance) + if gadget_id == 0: + ConsoleLog(MODULE_NAME, "InteractGadgetWithLock: no gadget found near point.", Console.MessageType.Warning, False) + return + + # Stagger by party position so peers do not poll/post on the same frame. + account_data = GLOBAL_CACHE.ShMem.GetAccountDataFromEmail(message.ReceiverEmail) + party_position = int(account_data.AgentPartyData.PartyPosition or 0) if account_data is not None else 0 + if party_position > 0: + yield from Routines.Yield.wait(party_position * STAGGER_MS) + + # PostLock is check-then-post (not CAS): post, then confirm we are the effective owner; + # if a peer beat us, release our slot and keep waiting. Bail on despawn or timeout. + wait_start = int(Py4GW.Game.get_tick_count64()) + claimed = False + while not claimed: + while is_interact_lock_blocked(gadget_id): + if int(Py4GW.Game.get_tick_count64()) - wait_start > LOCK_WAIT_TIMEOUT_MS: + ConsoleLog(MODULE_NAME, "InteractGadgetWithLock: timed out waiting for gadget lock.", Console.MessageType.Warning, False) + return + gadget_id = Agents.GetNearestGadgetXY(x, y, distance) + if gadget_id == 0: + ConsoleLog(MODULE_NAME, "InteractGadgetWithLock: gadget disappeared while waiting.", Console.MessageType.Warning, False) + return + yield from Routines.Yield.wait(POLL_MS) + + slot = post_interact_lock(gadget_id, minimum_ms=LOCK_LEASE_MS) + if slot < 0: + # No lock held (ShMem failure / full table): never interact unlocked. + ConsoleLog(MODULE_NAME, "InteractGadgetWithLock: failed to post gadget lock.", Console.MessageType.Warning, False) + clear_interact_lock(gadget_id) + return + + owner_email, owner_slot = get_interact_lock_owner(gadget_id) + if owner_email == message.ReceiverEmail and int(owner_slot) == int(slot): + claimed = True + break + clear_interact_lock(gadget_id) + if int(Py4GW.Game.get_tick_count64()) - wait_start > LOCK_WAIT_TIMEOUT_MS: + ConsoleLog(MODULE_NAME, "InteractGadgetWithLock: timed out contending for gadget lock.", Console.MessageType.Warning, False) + return + yield from Routines.Yield.wait(POLL_MS) + + gx, gy = Agent.GetXY(gadget_id) + yield from Routines.Yield.Movement.FollowPath([(gx, gy)], timeout=FOLLOWPATH_TIMEOUT_MS) + yield from Routines.Yield.wait(100) + yield from Routines.Yield.Player.InteractAgent(gadget_id) + yield from Routines.Yield.wait(100) + + loot_array = LootConfig().GetfilteredLootArray(Range.Earshot.value, multibox_loot=True) + if loot_array: + yield from Routines.Yield.Items.LootItems(loot_array) + + yield from Routines.Yield.wait(HOLD_MS) # let loot/interaction settle before release + + ConsoleLog(MODULE_NAME, "InteractGadgetWithLock message processed and finished.", Console.MessageType.Info, False) + finally: + if gadget_id: + clear_interact_lock(gadget_id) + RestoreHeroAISnapshot(message.ReceiverEmail) + GLOBAL_CACHE.ShMem.MarkMessageAsFinished(message.ReceiverEmail, index) + + # endregion # region TakeDialogWithTarget def TakeDialogWithTarget(index: int, message: SharedMessageStruct): @@ -2761,6 +2857,8 @@ def ProcessMessages(): GLOBAL_CACHE.Coroutines.append(LeaveParty(index, message)) case SharedCommandType.InteractWithTarget: GLOBAL_CACHE.Coroutines.append(InteractWithTarget(index, message)) + case SharedCommandType.InteractGadgetWithLock: + GLOBAL_CACHE.Coroutines.append(InteractGadgetWithLock(index, message)) case SharedCommandType.TakeDialogWithTarget: GLOBAL_CACHE.Coroutines.append(TakeDialogWithTarget(index, message)) case SharedCommandType.SendDialogToTarget: