Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 149 additions & 149 deletions HeroAI/ui_base.py

Large diffs are not rendered by default.

48 changes: 3 additions & 45 deletions Py4GWCoreLib/Builds/Skills/any/NoAttribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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))
Expand All @@ -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
5 changes: 1 addition & 4 deletions Py4GWCoreLib/Builds/Skills/any/PvE.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
26 changes: 22 additions & 4 deletions Py4GWCoreLib/Builds/Skills/mesmer/DominationMagic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions Py4GWCoreLib/GlobalCache/SharedMemory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
134 changes: 134 additions & 0 deletions Py4GWCoreLib/GlobalCache/WhiteboardLocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions Py4GWCoreLib/enums_src/Multiboxing_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class SharedCommandType(IntEnum):
TravelToMap = auto()
InviteToParty = auto()
InteractWithTarget = auto()
InteractGadgetWithLock = auto()
TakeDialogWithTarget = auto()
GetBlessing = auto()
OpenChest = auto()
Expand Down
47 changes: 44 additions & 3 deletions Py4GWCoreLib/py4gwcorelib_src/IniHandler.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
Expand Down
Loading