diff --git a/internal/action/move.go b/internal/action/move.go index edcbaa04..ce0c9fd4 100644 --- a/internal/action/move.go +++ b/internal/action/move.go @@ -2,8 +2,11 @@ package action import ( "fmt" + "github.com/hectorgimenez/koolo/internal/pather" + "github.com/hectorgimenez/koolo/internal/utils" "log/slog" "sort" + "time" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" @@ -13,9 +16,39 @@ import ( "github.com/hectorgimenez/koolo/internal/context" "github.com/hectorgimenez/koolo/internal/event" "github.com/hectorgimenez/koolo/internal/game" - "github.com/hectorgimenez/koolo/internal/pather" ) +const ( + maxAreaSyncAttempts = 10 + areaSyncDelay = 100 * time.Millisecond +) + +func ensureAreaSync(ctx *context.Status, expectedArea area.ID) error { + // Skip sync check if we're already in the expected area and have valid area data + if ctx.Data.PlayerUnit.Area == expectedArea { + if areaData, ok := ctx.Data.Areas[expectedArea]; ok && areaData.IsInside(ctx.Data.PlayerUnit.Position) { + return nil + } + } + + // Wait for area data to sync + for attempts := 0; attempts < maxAreaSyncAttempts; attempts++ { + ctx.RefreshGameData() + + if ctx.Data.PlayerUnit.Area == expectedArea { + if areaData, ok := ctx.Data.Areas[expectedArea]; ok { + if areaData.IsInside(ctx.Data.PlayerUnit.Position) { + return nil + } + } + } + + time.Sleep(areaSyncDelay) + } + + return fmt.Errorf("area sync timeout - expected: %v, current: %v", expectedArea, ctx.Data.PlayerUnit.Area) +} + func MoveToArea(dst area.ID) error { ctx := context.Get() ctx.SetLastAction("MoveToArea") @@ -25,7 +58,11 @@ func MoveToArea(dst area.ID) error { ctx.CurrentGame.AreaCorrection.Enabled = true }() - // Exception for Arcane Sanctuary, we need to find the portal first + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return err + } + + // Exception for Arcane Sanctuary if dst == area.ArcaneSanctuary && ctx.Data.PlayerUnit.Area == area.PalaceCellarLevel3 { ctx.Logger.Debug("Arcane Sanctuary detected, finding the Portal") portal, _ := ctx.Data.Objects.FindOne(object.ArcaneSanctuaryPortal) @@ -104,14 +141,58 @@ func MoveToArea(dst area.ID) error { } if lvl.IsEntrance { - err = step.InteractEntrance(dst) + maxAttempts := 3 + for attempt := 0; attempt < maxAttempts; attempt++ { + // Check current distance + currentDistance := ctx.PathFinder.DistanceFromMe(lvl.Position) + // If we're too far, try to get closer using direct clicks + if currentDistance > 4 { + ctx.Logger.Debug("Attempting to move closer to entrance using direct movement") + + // Calculate screen coordinates for a position closer to the entrance + screenX, screenY := ctx.PathFinder.GameCoordsToScreenCords( + lvl.Position.X-2, + lvl.Position.Y-2, + ) + + // Use direct click to move closer + ctx.HID.Click(game.LeftButton, screenX, screenY) + + // Give time for movement + utils.Sleep(800) + ctx.RefreshGameData() + + // Verify new position + newDistance := ctx.PathFinder.DistanceFromMe(lvl.Position) + ctx.Logger.Debug("New distance after move attempt", + slog.Int("distance", newDistance), + slog.Int("attempt", attempt+1)) + } + + err = step.InteractEntrance(dst) + if err == nil { + break + } + + if attempt < maxAttempts-1 { + ctx.Logger.Debug("Entrance interaction failed, retrying...", + slog.Int("attempt", attempt+1), + slog.String("error", err.Error())) + utils.Sleep(1000) + } + } + if err != nil { + return fmt.Errorf("failed to interact with area %s after %d attempts: %v", dst.Area().Name, maxAttempts, err) + } + + // Wait for area transition to complete + if err := ensureAreaSync(ctx, dst); err != nil { return err } } event.Send(event.InteractedTo(event.Text(ctx.Name, ""), int(dst), event.InteractionTypeEntrance)) - return nil } @@ -123,6 +204,10 @@ func MoveToCoords(to data.Position) error { ctx.CurrentGame.AreaCorrection.Enabled = true }() + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return err + } + return MoveTo(func() (data.Position, bool) { return to, true }) @@ -135,6 +220,12 @@ func MoveTo(toFunc func() (data.Position, bool)) error { openedDoors := make(map[object.Name]data.Position) previousIterationPosition := data.Position{} lastMovement := false + + // Initial sync check + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return err + } + for { ctx.RefreshGameData() to, found := toFunc() @@ -142,12 +233,12 @@ func MoveTo(toFunc func() (data.Position, bool)) error { return nil } - // If we can teleport, don't bother with the rest, stop here + // If we can teleport, don't bother with the rest if ctx.Data.CanTeleport() { return step.MoveTo(to) } - // Check if there is a door blocking our path + // Check for doors blocking path for _, o := range ctx.Data.Objects { if o.IsDoor() && ctx.PathFinder.DistanceFromMe(o.Position) < 10 && openedDoors[o.Name] != o.Position { if o.Selectable { @@ -155,7 +246,6 @@ func MoveTo(toFunc func() (data.Position, bool)) error { openedDoors[o.Name] = o.Position err := step.InteractObject(o, func() bool { obj, found := ctx.Data.Objects.FindByID(o.ID) - return found && !obj.Selectable }) if err != nil { @@ -164,7 +254,6 @@ func MoveTo(toFunc func() (data.Position, bool)) error { } } } - // Check if there is any object blocking our path for _, o := range ctx.Data.Objects { if o.Name == object.Barrel && ctx.PathFinder.DistanceFromMe(o.Position) < 3 { @@ -181,7 +270,7 @@ func MoveTo(toFunc func() (data.Position, bool)) error { } } - // Detect if there are monsters close to the player + // Check for monsters close to player closestMonster := data.Monster{} closestMonsterDistance := 9999999 targetedNormalEnemies := make([]data.Monster, 0) @@ -189,6 +278,7 @@ func MoveTo(toFunc func() (data.Position, bool)) error { minDistance := 6 minDistanceForElites := 20 // This will make the character to kill elites even if they are far away, ONLY during leveling stuck := ctx.PathFinder.DistanceFromMe(previousIterationPosition) < 5 // Detect if character was not able to move from last iteration + for _, m := range ctx.Data.Monsters.Enemies() { // Skip if monster is already dead if m.Stats[stat.Life] <= 0 { @@ -207,11 +297,9 @@ func MoveTo(toFunc func() (data.Position, bool)) error { appended = true } - if appended { - if dist < closestMonsterDistance { - closestMonsterDistance = dist - closestMonster = m - } + if appended && dist < closestMonsterDistance { + closestMonsterDistance = dist + closestMonster = m } } diff --git a/internal/action/step/interact_entrance.go b/internal/action/step/interact_entrance.go index 097a4ef0..6935a12d 100644 --- a/internal/action/step/interact_entrance.go +++ b/internal/action/step/interact_entrance.go @@ -1,15 +1,18 @@ package step import ( - "errors" "fmt" - "time" - "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/area" "github.com/hectorgimenez/koolo/internal/context" "github.com/hectorgimenez/koolo/internal/game" "github.com/hectorgimenez/koolo/internal/utils" + "time" +) + +const ( + maxEntranceDistance = 6 + maxMoveRetries = 3 ) func InteractEntrance(area area.ID) error { @@ -23,12 +26,9 @@ func InteractEntrance(area area.ID) error { ctx.SetLastStep("InteractEntrance") for { - // Pause the execution if the priority is not the same as the execution priority ctx.PauseIfNotPriority() - // Give some extra time to render the UI if ctx.Data.AreaData.Area == area && time.Since(lastRun) > time.Millisecond*500 && ctx.Data.AreaData.IsInside(ctx.Data.PlayerUnit.Position) { - // We've successfully entered the new area return nil } @@ -44,21 +44,47 @@ func InteractEntrance(area area.ID) error { for _, l := range ctx.Data.AdjacentLevels { if l.Area == area { distance := ctx.PathFinder.DistanceFromMe(l.Position) - if distance > 10 { - return errors.New("entrance too far away") + if distance > maxEntranceDistance { + // Try to move closer with retries + for retry := 0; retry < maxMoveRetries; retry++ { + if err := MoveTo(l.Position); err != nil { + // If MoveTo fails, try direct movement + screenX, screenY := ctx.PathFinder.GameCoordsToScreenCords( + l.Position.X-2, + l.Position.Y-2, + ) + ctx.HID.Click(game.LeftButton, screenX, screenY) + utils.Sleep(800) + ctx.RefreshGameData() + } + + // Check if we're close enough now + newDistance := ctx.PathFinder.DistanceFromMe(l.Position) + if newDistance <= maxEntranceDistance { + break + } + + if retry == maxMoveRetries-1 { + return fmt.Errorf("entrance too far away (distance: %d)", distance) + } + } } if l.IsEntrance { - lx, ly := ctx.PathFinder.GameCoordsToScreenCords(l.Position.X-2, l.Position.Y-2) + lx, ly := ctx.PathFinder.GameCoordsToScreenCords(l.Position.X-1, l.Position.Y-1) if ctx.Data.HoverData.UnitType == 5 || ctx.Data.HoverData.UnitType == 2 && ctx.Data.HoverData.IsHovered { ctx.HID.Click(game.LeftButton, currentMouseCoords.X, currentMouseCoords.Y) waitingForInteraction = true + utils.Sleep(200) } x, y := utils.Spiral(interactionAttempts) + x = x / 3 + y = y / 3 currentMouseCoords = data.Position{X: lx + x, Y: ly + y} ctx.HID.MovePointer(lx+x, ly+y) interactionAttempts++ + utils.Sleep(100) continue } diff --git a/internal/action/step/interact_object.go b/internal/action/step/interact_object.go index df9be8a2..798ff2b9 100644 --- a/internal/action/step/interact_object.go +++ b/internal/action/step/interact_object.go @@ -1,47 +1,76 @@ package step import ( - "errors" "fmt" - "time" - "github.com/hectorgimenez/d2go/pkg/data" + "github.com/hectorgimenez/d2go/pkg/data/area" + "github.com/hectorgimenez/d2go/pkg/data/mode" "github.com/hectorgimenez/d2go/pkg/data/object" "github.com/hectorgimenez/koolo/internal/context" "github.com/hectorgimenez/koolo/internal/game" + "github.com/hectorgimenez/koolo/internal/town" "github.com/hectorgimenez/koolo/internal/ui" "github.com/hectorgimenez/koolo/internal/utils" + "time" +) + +const ( + maxInteractionAttempts = 5 + portalSyncDelay = 200 + maxPortalSyncAttempts = 15 ) func InteractObject(obj data.Object, isCompletedFn func() bool) error { - maxInteractionAttempts := 10 interactionAttempts := 0 - maxMouseOverAttempts := 20 mouseOverAttempts := 0 waitingForInteraction := false currentMouseCoords := data.Position{} lastRun := time.Time{} - // If there is no check, just assume the interaction is completed after clicking + ctx := context.Get() + ctx.SetLastStep("InteractObject") + + // If there is no completion check, just assume the interaction is completed after clicking if isCompletedFn == nil { isCompletedFn = func() bool { return waitingForInteraction } } - ctx := context.Get() - ctx.SetLastStep("InteractObject") + // For portals, we need to ensure proper area sync + expectedArea := area.ID(0) + if obj.IsRedPortal() { + // For red portals, we need to determine the expected destination + switch { + case obj.Name == object.PermanentTownPortal && ctx.Data.PlayerUnit.Area == area.StonyField: + expectedArea = area.Tristram + case obj.Name == object.PermanentTownPortal && ctx.Data.PlayerUnit.Area == area.RogueEncampment: + expectedArea = area.MooMooFarm + case obj.Name == object.PermanentTownPortal && ctx.Data.PlayerUnit.Area == area.Harrogath: + expectedArea = area.NihlathaksTemple + case obj.Name == object.PermanentTownPortal && ctx.Data.PlayerUnit.Area == area.ArcaneSanctuary: + expectedArea = area.CanyonOfTheMagi + } + } else if obj.IsPortal() { + // For blue town portals, determine the town area based on current area + fromArea := ctx.Data.PlayerUnit.Area + if !fromArea.IsTown() { + expectedArea = town.GetTownByArea(fromArea).TownArea() + } else { + // When using portal from town, we need to wait for any non-town area + isCompletedFn = func() bool { + return !ctx.Data.PlayerUnit.Area.IsTown() && + ctx.Data.AreaData.IsInside(ctx.Data.PlayerUnit.Position) && + len(ctx.Data.Objects) > 0 + } + } + } for !isCompletedFn() { - // Pause the execution if the priority is not the same as the execution priority ctx.PauseIfNotPriority() - if time.Since(lastRun) < time.Millisecond*100 { - continue - } - - if interactionAttempts >= maxInteractionAttempts || mouseOverAttempts >= maxMouseOverAttempts { - return errors.New("failed interacting with object") + if interactionAttempts >= maxInteractionAttempts || mouseOverAttempts >= 20 { + return fmt.Errorf("failed interacting with object") } ctx.RefreshGameData() @@ -66,10 +95,49 @@ func InteractObject(obj data.Object, isCompletedFn func() bool) error { } lastRun = time.Now() + + // Check portal states + if o.IsPortal() || o.IsRedPortal() { + // If portal is still being created, wait + if o.Mode == mode.ObjectModeOperating { + utils.Sleep(100) + continue + } + + // Only interact when portal is fully opened + if o.Mode != mode.ObjectModeOpened { + utils.Sleep(100) + continue + } + } + if o.IsHovered { ctx.HID.Click(game.LeftButton, currentMouseCoords.X, currentMouseCoords.Y) waitingForInteraction = true interactionAttempts++ + + // For portals with expected area, we need to wait for proper area sync + if expectedArea != 0 { + utils.Sleep(500) // Initial delay for area transition + for attempts := 0; attempts < maxPortalSyncAttempts; attempts++ { + ctx.RefreshGameData() + if ctx.Data.PlayerUnit.Area == expectedArea { + if areaData, ok := ctx.Data.Areas[expectedArea]; ok { + if areaData.IsInside(ctx.Data.PlayerUnit.Position) { + if expectedArea.IsTown() { + return nil // For town areas, we can return immediately + } + // For special areas, ensure we have proper object data loaded + if len(ctx.Data.Objects) > 0 { + return nil + } + } + } + } + utils.Sleep(portalSyncDelay) + } + return fmt.Errorf("portal sync timeout - expected area: %v, current: %v", expectedArea, ctx.Data.PlayerUnit.Area) + } continue } else { objectX := o.Position.X - 2 @@ -81,7 +149,7 @@ func InteractObject(obj data.Object, isCompletedFn func() bool) error { mX, mY := ui.GameCoordsToScreenCords(objectX, objectY) // In order to avoid the spiral (super slow and shitty) let's try to point the mouse to the top of the portal directly - if mouseOverAttempts == 2 && o.Name == object.TownPortal { + if mouseOverAttempts == 2 && o.IsPortal() { mX, mY = ui.GameCoordsToScreenCords(objectX-4, objectY-4) } diff --git a/internal/action/tp_actions.go b/internal/action/tp_actions.go index 6c0699c6..75486782 100644 --- a/internal/action/tp_actions.go +++ b/internal/action/tp_actions.go @@ -3,6 +3,7 @@ package action import ( "errors" "fmt" + "time" "github.com/hectorgimenez/d2go/pkg/data" "github.com/hectorgimenez/d2go/pkg/data/object" @@ -35,9 +36,33 @@ func ReturnTown() error { } // Now that it is safe, interact with portal - return InteractObject(portal, func() bool { + err = InteractObject(portal, func() bool { return ctx.Data.PlayerUnit.Area.IsTown() }) + if err != nil { + return err + } + + // Wait for area transition and data sync + utils.Sleep(1000) + ctx.RefreshGameData() + + // Wait for town area data to be fully loaded + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if ctx.Data.PlayerUnit.Area.IsTown() { + // Verify area data exists and is loaded + if townData, ok := ctx.Data.Areas[ctx.Data.PlayerUnit.Area]; ok { + if townData.IsInside(ctx.Data.PlayerUnit.Position) { + return nil + } + } + } + utils.Sleep(100) + ctx.RefreshGameData() + } + + return fmt.Errorf("failed to verify town area data after portal transition") } func UsePortalInTown() error { @@ -52,11 +77,12 @@ func UsePortalInTown() error { return err } - // Wait for the game to fully load after using the portal - ctx.WaitForGameToLoad() - - // Refresh game data to ensure we have the latest information + // Wait for area sync before attempting any movement + utils.Sleep(500) ctx.RefreshGameData() + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return err + } // Ensure we're not in town if ctx.Data.PlayerUnit.Area.IsTown() { @@ -84,7 +110,13 @@ func UsePortalFrom(owner string) error { if obj.IsPortal() && obj.Owner == owner { return InteractObjectByID(obj.ID, func() bool { if !ctx.Data.PlayerUnit.Area.IsTown() { + // Ensure area data is synced after portal transition utils.Sleep(500) + ctx.RefreshGameData() + + if err := ensureAreaSync(ctx, ctx.Data.PlayerUnit.Area); err != nil { + return false + } return true } return false