diff --git a/HeroAI/ui_base.py b/HeroAI/ui_base.py index 17db965e0..f916c1e11 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) 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 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, ) 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: 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/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) 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 diff --git a/Sources/ApoSource/ApoBottingLib/helpers.py b/Sources/ApoSource/ApoBottingLib/helpers.py index 16a4ab15b..4062cdba2 100644 --- a/Sources/ApoSource/ApoBottingLib/helpers.py +++ b/Sources/ApoSource/ApoBottingLib/helpers.py @@ -317,6 +317,102 @@ def _send_multibox_get_blessing_with_target( ) +def _broadcast_gadget_interact_with_lock( + x: float, + y: float, + distance: float, + *, + 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 + + 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: + continue + 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) + + if own_email and own_email not in recipients: + recipients.append(own_email) + + node.blackboard[f'{refs_blackboard_key}_recipient_emails'] = recipients + node.blackboard[f'{refs_blackboard_key}_include_self'] = bool(own_email) + return BehaviorTree.NodeState.SUCCESS + + 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.SequenceNode( + name='BroadcastGadgetInteractWithLock', + children=[ + BehaviorTree.ActionNode( + name='PrepareGadgetInteractRecipients', + action_fn=_prepare, + ), + BehaviorTree( + BehaviorTree.SubtreeNode( + name='DispatchGadgetInteractWithLock', + subtree_fn=_dispatch, + ) + ), + ], + ) + ) + + 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..812b9cbbd 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 _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 @@ -696,8 +697,21 @@ 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: + # 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( + _broadcast_gadget_interact_with_lock( + x=point.x, + y=point.y, + distance=target_distance, + log=log, + ) + ) return _pause_heroai_for_action( RoutinesBT.Composite.Sequence( RoutinesBT.Agents.TargetNearestGadgetXY(x=point.x, y=point.y, distance=target_distance, log=log), @@ -838,8 +852,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 +1296,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 +1311,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, 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() diff --git a/Widgets/System/Messaging.py b/Widgets/System/Messaging.py index 526f5f89a..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: @@ -2768,7 +2866,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