diff --git a/bot/ai_advanced_test.go b/bot/ai_advanced_test.go new file mode 100644 index 0000000..3122543 --- /dev/null +++ b/bot/ai_advanced_test.go @@ -0,0 +1,127 @@ +package bot + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/walkline/AzerothGhost/ai/luaengine" +) + +// TestAdvancedAIGrindPipeline validates the production advanced AI path: +// load_for_bot / enable_default_strategies, single-spec warrior, survive, +// rest OOC, rend when in melee with a live target. +func TestAdvancedAIGrindPipeline(t *testing.T) { + _, thisFile, _, _ := runtime.Caller(0) + oldWD, _ := os.Getwd() + _ = os.Chdir(filepath.Join(filepath.Dir(thisFile), "..")) + defer os.Chdir(oldWD) + + mock := &aiLogicMock{ + class: 1, + alive: true, + hpCur: 90, + hpMax: 100, + inCombat: true, + target: 555, + auras: map[uint64]map[uint32]bool{}, + // Lowbie arms-like: no MS/BT/slam — force rend/shout/engage path. + spellReady: map[uint32]bool{ + 6673: true, // battle shout + 772: true, // rend + 78: true, // HS + 5308: true, // execute + 34428: true, // VR + 100: true, // charge + }, + } + + e := luaengine.NewEngine(mock) + if err := e.DoString(` +local boot = dofile("scripts/ai/init.lua") +ai = boot.load_for_bot() +assert(ai, "load_for_bot nil") +assert(ai.active_strategies["survive"], "survive not enabled") +assert(ai.active_strategies["rest"], "rest not enabled") +assert(ai.active_strategies["grind"], "grind not enabled") +assert(ai.active_strategies["melee"], "melee not enabled") +assert(ai.active_strategies["generic_warrior"], "generic_warrior not enabled") +assert(ai.active_strategies["arms"], "arms not enabled") +assert(not ai.active_strategies["fury"], "fury must NOT be enabled by default") +assert(not ai.active_strategies["prot"], "prot must NOT be enabled by default") +`); err != nil { + t.Fatalf("load: %v", err) + } + + // --- Rend: target without rend aura, spells gated --- + mock.casts = nil + mock.logs = nil + mock.auras[555] = map[uint32]bool{} // no rend + // Self already has shout so shout doesn't steal every tick + mock.auras[123456] = map[uint32]bool{6673: true} + mock.auras[0] = map[uint32]bool{6673: true} + + if err := e.DoString(`for i=1,8 do ai:Tick() end`); err != nil { + t.Fatalf("tick: %v", err) + } + + rend := false + for _, c := range mock.casts { + if c == "772@555" { + rend = true + break + } + } + for _, l := range mock.logs { + if contains(l, "rend") { + rend = true + break + } + } + if !rend { + t.Fatalf("expected rend cast with live target; casts=%v logs=%v", mock.casts, firstN(mock.logs, 12)) + } + t.Log("✓ arms rend fired with single primary spec") + + // --- Death -> revive --- + mock.alive = false + mock.commands = nil + if err := e.DoString(`ai:Tick()`); err != nil { + t.Fatalf("dead tick: %v", err) + } + gotRevive := false + for _, c := range mock.commands { + if contains(c, ".revive") { + gotRevive = true + break + } + } + if !gotRevive { + t.Fatalf("expected .revive when dead; commands=%v", mock.commands) + } + t.Log("✓ survive revive on death") + + // --- Low HP OOC rest (must log + not pull) --- + mock.alive = true + mock.inCombat = false + mock.hpCur = 20 + mock.target = 0 + mock.logs = nil + mock.casts = nil + mock.moves = 0 + if err := e.DoString(`for i=1,3 do ai:Tick() end`); err != nil { + t.Fatalf("lowhp: %v", err) + } + low := false + for _, l := range mock.logs { + if contains(l, "low health") || contains(l, "rest:") { + low = true + break + } + } + if !low { + t.Fatalf("expected rest/survive low-HP decision OOC; logs=%v", firstN(mock.logs, 10)) + } + t.Log("✓ OOC low HP rest/survive") +} diff --git a/bot/bot.go b/bot/bot.go index 7fbc29b..e16ab18 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -161,6 +161,11 @@ type Bot struct { teleportMu sync.Mutex teleportPending bool teleportReason string + + // After CAST_FAILED NO_POWER (85), treat spell as not ready briefly so AI + // does not re-spam the same cast every tick while rage/mana regenerates. + noPowerMu sync.Mutex + noPowerUntil map[uint32]time.Time } // myPos returns current player position (convenience to avoid direct field access on client). @@ -236,6 +241,7 @@ func NewHeadlessBot(wc *client.WorldClient, cfg Config) *Bot { b.initNavigation() b.wireValidationInstrumentation() b.wireTeleportHandling() + b.wireServerRelocateHandling() // Lua engine + AIBundle/LuaCode loading (same rules as full Run path) b.lua = luaengine.NewEngine(b) @@ -410,12 +416,20 @@ func (b *Bot) Run() BotResult { // Target/combat already cleared in WorldClient for terminal. b.stopCurrentMove() case client.RejectTransient: - // Keep target; stop path only if we're not already closing in. - // Face correction helps BAD_FACING; move_to/pursue handles range. + // Keep target; close the gap. Lua distance can be stale so we repath + // on NOT_IN_RANGE even if AI thinks we are already in melee. b.logDecision("Server reject TRANSIENT %s GUID=%d (keep target, reapproach/face)", r.Reason, r.GUID) if r.GUID != 0 && r.Reason == client.RejectReasonBadFacing { _ = b.FaceTarget(r.GUID) } + if r.GUID != 0 && r.Reason == client.RejectReasonNotInRange { + if t := b.world.GetObject(r.GUID); t != nil { + tx, ty, tz := t.InterpolatedPosition() + // Force a fresh path (clear throttle). + b.lastMoveCommandTime = time.Time{} + b.moveToPoint(tx, ty, tz) + } + } default: // ATTACK_STOP / unknown: do not markKnownDead (ambiguous). b.logDecision("Server reject %s GUID=%d class=%s (no dead mark)", r.Reason, r.GUID, r.Class) @@ -441,6 +455,8 @@ func (b *Bot) Run() BotResult { b.wireValidationInstrumentation() // Summon / .go / portal: interrupt movement+combat and flag Lua to restart AI. b.wireTeleportHandling() + // Charge / blink / server-forced player splines: snap movement controller. + b.wireServerRelocateHandling() // Start world client worldErrCh := make(chan error, 1) @@ -1386,14 +1402,11 @@ func (b *Bot) buildHoggerBehavior() behaviortree.Node { return false }), behaviortree.NewAction("attack_hogger", func(bb *behaviortree.Blackboard) behaviortree.Status { - // Pre-combat: use Battle Shout before engaging + // Pre-combat: Battle Shout (6673). Never cast 2457 here — that is Battle Stance. if !battleShoutUsed { if b.world.IsSpellReady(6673) { b.log("Pre-combat: casting Battle Shout") b.world.CastSpell(6673, 0) - } else if b.world.IsSpellReady(2457) { - b.log("Pre-combat: casting Battle Shout (Rank 1)") - b.world.CastSpell(2457, 0) } battleShoutUsed = true } @@ -2411,8 +2424,11 @@ func (b *Bot) moveToPoint(x, y, z float32) { for j := 1; j < len(pts); j++ { plen += pts[j-1].DistanceTo2D(pts[j]) } - if plen > straight*2.5 || len(pts) > 80 { - b.logDecision("crazy path in Durotar-like terrain, using direct") + // Reject huge detours (navmesh artifacts): more than 2× straight line + // or absurdly long paths send the bot running across the zone. + if straight > 1.0 && (plen > straight*2.0 || plen > straight+40 || len(pts) > 60) { + b.log("crazy path rejected (straight=%.1f path=%.1f n=%d) — direct line", + straight, plen, len(pts)) pts = simplifyAndDensifyPath([]navigation.Point3D{current, {X: x, Y: y, Z: z}}, 3.0, 1.0) } } else { @@ -2666,6 +2682,60 @@ func (b *Bot) ConsumeTeleport() bool { return true } +// Spells that forcibly relocate the caster (Charge, Intercept, Blink, …). +// On SPELL_GO we abort local pathing; the final pose comes from MONSTER_MOVE. +var relocateOnCastSpells = map[uint32]struct{}{ + 100: {}, // Charge + 20252: {}, // Intercept + 3411: {}, // Intervene + 1953: {}, // Blink +} + +// wireServerRelocateHandling snaps the movement controller when the server +// relocates the player (Charge is the common case: without this the controller +// keeps simulating the pre-charge path and rubber-bands the bot home). +func (b *Bot) wireServerRelocateHandling() { + if b == nil || b.world == nil { + return + } + prevReloc := b.world.OnServerRelocate + b.world.OnServerRelocate = func(x, y, z, o float32, reason string) { + b.log("Server relocate (%s): pos=(%.1f,%.1f,%.1f) — abort local path", reason, x, y, z) + b.movementMu.Lock() + b.isMoving = false + b.lastMoveCommandTime = time.Time{} + b.lastMoveCommandPos = [3]float32{} + b.ensureMovementControllerLocked() + if b.moveController != nil { + b.moveController.AbortAndSnap(x, y, z, o) + } + b.movementMu.Unlock() + // Clear sticky pursuit dest so we re-path from the new pose. + b.lastPursuitUpdate = time.Time{} + b.lastMoveToTargetTime = time.Time{} + if prevReloc != nil { + prevReloc(x, y, z, o, reason) + } + } + + prevSpell := b.world.OnSpellCastResult + b.world.OnSpellCastResult = func(spellID uint32, success bool, failReason uint8) { + if success { + if _, ok := relocateOnCastSpells[spellID]; ok { + // Drop local path immediately; MONSTER_MOVE will snap pose. + b.abortMovementForTeleport() + b.logDecision("RELOCATE_SPELL id=%d — abort path pending server pose", spellID) + } + } else if failReason == 85 { // SPELL_FAILED_NO_POWER + b.noteSpellNoPower(spellID) + b.logDecision("NO_POWER spell=%d — block re-cast 1.5s", spellID) + } + if prevSpell != nil { + prevSpell(spellID, success, failReason) + } + } +} + func (b *Bot) updateMovement() { b.movementMu.Lock() defer b.movementMu.Unlock() @@ -2851,13 +2921,45 @@ func (b *Bot) IsMoving() bool { func (b *Bot) CastSpell(spellID uint32, targetGUID uint64) error { b.logDecision("CAST_SPELL id=%d target=%d", spellID, targetGUID) + // Do NOT abort the chase path on cast attempt. Charge often CAST_FAILs + // (stance/range/path); aborting here froze bots mid-pull so they only + // swung NOT_IN_RANGE. Path is dropped on SPELL_GO success (relocate spells) + // and on self MONSTER_MOVE (OnServerRelocate). return b.world.CastSpell(spellID, targetGUID) } func (b *Bot) IsSpellReady(spellID uint32) bool { + b.noPowerMu.Lock() + if b.noPowerUntil != nil { + if until, ok := b.noPowerUntil[spellID]; ok { + if time.Now().Before(until) { + b.noPowerMu.Unlock() + return false + } + delete(b.noPowerUntil, spellID) + } + } + b.noPowerMu.Unlock() + if b.world == nil { + return false + } return b.world.IsSpellReady(spellID) } +// noteSpellNoPower blocks a spell in IsSpellReady for a short window after +// SMSG_CAST_FAILED reason 85 (SPELL_FAILED_NO_POWER). +func (b *Bot) noteSpellNoPower(spellID uint32) { + if spellID == 0 { + return + } + b.noPowerMu.Lock() + if b.noPowerUntil == nil { + b.noPowerUntil = make(map[uint32]time.Time) + } + b.noPowerUntil[spellID] = time.Now().Add(1500 * time.Millisecond) + b.noPowerMu.Unlock() +} + func (b *Bot) GetHealth() (current, max uint32) { return b.world.Health(), b.world.MaxHealth() } @@ -3058,11 +3160,45 @@ func (b *Bot) HasAuraOn(guid uint64, spellID uint32) bool { return obj.HasAura(spellID) } +// warriorRageCost is a minimal 3.3.5 base-cost map so CanCast rejects +// NO_POWER spam when IsSpellReady is still true with 0 rage. +var warriorRageCost = map[uint32]uint32{ + 6673: 10, // Battle Shout + 772: 10, // Rend + 78: 15, // Heroic Strike + 7386: 15, // Sunder + 5308: 15, // Execute + 6343: 20, // Thunder Clap + 845: 20, // Cleave + 1680: 25, // Whirlwind + 12294: 30, // Mortal Strike + 23881: 20, // Bloodthirst + 23922: 20, // Shield Slam + 6572: 5, // Revenge + 7384: 5, // Overpower + 1715: 10, // Hamstring + 1160: 10, // Demoralizing Shout +} + func (b *Bot) CanCast(spellID uint32, targetGUID uint64) bool { if b.world == nil { return true // optimistic for headless tests } - return b.world.IsSpellReady(spellID) + // Use Bot.IsSpellReady so noPowerUntil (CAST_FAILED NO_POWER) is honored + // for callers that only check can_cast without a prior is_spell_ready. + if !b.IsSpellReady(spellID) { + return false + } + // Power precheck for rage users (warrior). Mana/energy classes skip this map. + if b.config.Class == 1 { // warrior + if need, ok := warriorRageCost[spellID]; ok && need > 0 { + cur, _ := b.world.Power() + if cur < need { + return false + } + } + } + return true } func (b *Bot) GetPetGUID() uint64 { diff --git a/bot/bot_test.go b/bot/bot_test.go index a9df2a4..96073e9 100644 --- a/bot/bot_test.go +++ b/bot/bot_test.go @@ -113,6 +113,8 @@ type aiLogicMock struct { inCombat bool auras map[uint64]map[uint32]bool nearby []luaengine.UnitInfo + // If non-nil, only these spell IDs report ready (others false). nil = all ready. + spellReady map[uint32]bool // Recorded side effects for assertions casts []string // "spell@target" @@ -132,7 +134,12 @@ func (m *aiLogicMock) CastSpell(id uint32, t uint64) error { m.casts = append(m.casts, fmt.Sprintf("%d@%d", id, t)) return nil } -func (m *aiLogicMock) IsSpellReady(uint32) bool { return true } +func (m *aiLogicMock) IsSpellReady(id uint32) bool { + if m.spellReady == nil { + return true + } + return m.spellReady[id] +} func (m *aiLogicMock) GetHealth() (uint32, uint32) { if m.hpMax == 0 { m.hpMax = 100 @@ -145,9 +152,15 @@ func (m *aiLogicMock) SetLevel(uint32) {} func (m *aiLogicMock) InCombat() bool { return m.inCombat } func (m *aiLogicMock) IsAlive() bool { return m.alive } func (m *aiLogicMock) GetTargetGUID() uint64 { return m.target } +func (m *aiLogicMock) defaultUnit(g uint64) luaengine.UnitInfo { + return luaengine.UnitInfo{ + GUID: g, Entry: 6, IsAlive: true, Distance: 6, Level: 12, + Health: 70, MaxHealth: 100, IsPlayer: false, PosX: 10, PosY: 10, PosZ: 5, + } +} func (m *aiLogicMock) GetNearbyUnits(float32) []luaengine.UnitInfo { if len(m.nearby) == 0 { - return []luaengine.UnitInfo{{GUID: 555, IsAlive: true, Distance: 6, Level: 12, Health: 70, MaxHealth: 100, IsPlayer: false}} + return []luaengine.UnitInfo{m.defaultUnit(555)} } return m.nearby } @@ -158,6 +171,11 @@ func (m *aiLogicMock) GetUnitInfo(g uint64) *luaengine.UnitInfo { return &m.nearby[i] } } + // Always resolve current target / default dummy so select_grind does not thrash. + if g != 0 && (g == m.target || g == 555) { + u := m.defaultUnit(g) + return &u + } return nil } func (m *aiLogicMock) SendChat(string) error { return nil } @@ -183,7 +201,7 @@ func (m *aiLogicMock) HasAuraOn(g uint64, sp uint32) bool { } return m.auras[g][sp] } -func (m *aiLogicMock) CanCast(uint32, uint64) bool { return true } +func (m *aiLogicMock) CanCast(id uint32, _ uint64) bool { return m.IsSpellReady(id) } func (m *aiLogicMock) GetPetGUID() uint64 { return 0 } func (m *aiLogicMock) PetAttack(uint64) {} func (m *aiLogicMock) GetStance() int { return 0 } diff --git a/bot/teleport_test.go b/bot/teleport_test.go index fc0b100..7b0dca1 100644 --- a/bot/teleport_test.go +++ b/bot/teleport_test.go @@ -82,3 +82,100 @@ func TestSummonNearTeleport_InterruptsAndFlagsLua(t *testing.T) { t.Fatalf("world pose corrupted by updateMovement: (%v,%v,%v)", wx, wy, wz) } } + +// TestChargeServerRelocate_DoesNotRubberBand: after a Charge-like server relocate, +// updateMovement must not write the pre-charge path pose back over the new coords. +func TestChargeServerRelocate_DoesNotRubberBand(t *testing.T) { + w := client.NewWorldClient("u", nil, func(string, ...interface{}) {}) + w.UpdatePosition(10, 20, 30, 1.0) + + b := NewHeadlessBot(w, Config{Mode: "lua", AITickMs: 200}) + b.movementMu.Lock() + b.ensureMovementControllerLocked() + if b.moveController != nil { + // Simulate an active chase path from the charge cast position. + b.moveController.InitPositionFromWorld(10, 20, 30, 1.0) + b.isMoving = true + } + b.movementMu.Unlock() + + // Server relocates player to charge destination (as MONSTER_MOVE would). + if b.world.OnServerRelocate == nil { + t.Fatal("OnServerRelocate not wired") + } + b.world.OnServerRelocate(100, 200, 40, 1.5, "monster_move_charge") + w.UpdatePosition(100, 200, 40, 1.5) + + if b.movementActive() { + t.Fatal("expected path aborted after server relocate") + } + b.movementMu.Lock() + cx, cy, cz, _ := b.moveController.CurrentPosition() + b.movementMu.Unlock() + if cx != 100 || cy != 200 || cz != 40 { + t.Fatalf("controller pose=(%v,%v,%v) want post-charge", cx, cy, cz) + } + + b.updateMovement() + wx, wy, wz, _, _ := w.Position() + if wx != 100 || wy != 200 || wz != 40 { + t.Fatalf("rubber-band: world pose became (%v,%v,%v)", wx, wy, wz) + } +} + +// TestChargeRubberBand_WithActivePathSimulatesPreFix: while a local path is +// active, a server relocate must AbortAndSnap so updateMovement cannot write the +// pre-charge path pose over the charge landing (the live rubber-band bug). +func TestChargeRubberBand_WithActivePathSimulatesPreFix(t *testing.T) { + w := client.NewWorldClient("u", nil, func(string, ...interface{}) {}) + w.UpdatePosition(0, 0, 10, 0) + + b := NewHeadlessBot(w, Config{Mode: "lua", AITickMs: 200}) + // Active chase path from cast origin toward a far waypoint. + b.moveToPoint(200, 0, 10) + if !b.movementActive() { + t.Fatal("expected active path before charge") + } + + // Charge landing (server + world pose). + const cx, cy, cz float32 = 50, 80, 12 + b.world.OnServerRelocate(cx, cy, cz, 0.5, "monster_move_charge") + w.UpdatePosition(cx, cy, cz, 0.5) + + if b.movementActive() { + t.Fatal("path should be aborted after relocate") + } + + // Pre-fix: isMoving stayed true → updateMovement rewrote world to ~path start. + // Post-fix: path aborted → world pose stays at charge destination. + for i := 0; i < 10; i++ { + b.updateMovement() + x, y, z, _, _ := w.Position() + if abs32(x-cx) > 0.01 || abs32(y-cy) > 0.01 || abs32(z-cz) > 0.01 { + t.Fatalf("tick %d rubber-band: pos=(%v,%v,%v) want (%v,%v,%v)", i, x, y, z, cx, cy, cz) + } + } +} + +func abs32(v float32) float32 { + if v < 0 { + return -v + } + return v +} + +// TestCastChargeDoesNotAbortPathOnAttempt: a failed/pending Charge must not +// cancel chase (that froze lvl-1 bots staring at mobs from 12 yards). +func TestCastChargeDoesNotAbortPathOnAttempt(t *testing.T) { + w := client.NewWorldClient("u", nil, func(string, ...interface{}) {}) + w.UpdatePosition(0, 0, 10, 0) + b := NewHeadlessBot(w, Config{Mode: "lua", AITickMs: 200}) + b.moveToPoint(100, 0, 10) + if !b.movementActive() { + t.Fatal("need active path") + } + _ = b.CastSpell(100, 999) + if !b.movementActive() { + t.Fatal("Charge cast attempt must not abort chase path") + } +} diff --git a/client/world.go b/client/world.go index 430cc51..57678c4 100644 --- a/client/world.go +++ b/client/world.go @@ -757,6 +757,10 @@ type WorldClient struct { // OnSpellCastResult reports SPELL_GO (success) or SPELL_FAILURE / CAST_FAILED. // failReason is 0 on success; otherwise the server reason byte when available. OnSpellCastResult func(spellID uint32, success bool, failReason uint8) + // OnServerRelocate fires when the server forcibly moves the player (charge, + // blink, knockback, monster-move spline on self). Bot must abort local paths + // or updateMovement will write pre-relocate coords back over the new pose. + OnServerRelocate func(x, y, z, o float32, reason string) // OnSessionPhase fires on every session phase transition. OnSessionPhase func(c SessionPhaseChange) // OnProtocolWarning fires when we send gameplay opcodes outside PhaseInWorld. @@ -3653,6 +3657,33 @@ func (w *WorldClient) handleDestroyObject(data []byte) { w.removeObject(guid) } +func (w *WorldClient) isSelfGUID(guid uint64) bool { + if w.charGUID == 0 || guid == 0 { + return false + } + return guid == w.charGUID || (guid&0xFFFFFFFF) == (w.charGUID&0xFFFFFFFF) +} + +// applySelfServerRelocate updates local player pose and notifies the bot so the +// movement controller cannot keep heartbeating pre-relocate coordinates (Charge +// would otherwise "rubber-band" back to the cast position). +// Pose writes go through moveMu (same path as UpdatePosition). After the +// OnServerRelocate callback aborts the controller we re-assert pose so a +// concurrent updateMovement cannot leave pre-relocate coords published. +func (w *WorldClient) applySelfServerRelocate(x, y, z float32, reason string) { + w.moveMu.Lock() + o := w.orientation + w.setPositionLocked(x, y, z, o) + cb := w.OnServerRelocate + w.moveMu.Unlock() + if cb != nil { + cb(x, y, z, o, reason) + } + w.moveMu.Lock() + w.setPositionLocked(x, y, z, o) + w.moveMu.Unlock() +} + func (w *WorldClient) handleMonsterMove(data []byte) { if len(data) < 16 { return @@ -3675,6 +3706,8 @@ func (w *WorldClient) handleMonsterMove(data []byte) { binary.Read(r, binary.LittleEndian, &posY) binary.Read(r, binary.LittleEndian, &posZ) + selfMove := w.isSelfGUID(guid) + // Accept MONSTER_MOVE before CREATE_OBJECT so delayed creates do not leave us // stuck with a later create's older spawn/start pose only. obj := w.getOrCreateObject(guid) @@ -3712,6 +3745,9 @@ func (w *WorldClient) handleMonsterMove(data []byte) { obj.DestX = posX obj.DestY = posY obj.DestZ = posZ + if selfMove { + w.applySelfServerRelocate(posX, posY, posZ, "monster_move_stop") + } return } @@ -3765,45 +3801,48 @@ func (w *WorldClient) handleMonsterMove(data []byte) { } if waypointCount == 0 { + if selfMove { + w.applySelfServerRelocate(posX, posY, posZ, "monster_move_empty") + } return } // For CatmullRom (flag 0x00000008), waypoints are full Vector3 positions // For linear paths, first point after count is the destination, rest are packed + var destX, destY, destZ float32 if splineFlags&0x00000008 != 0 { // CatmullRom: read all waypoints, last one is destination - var lastX, lastY, lastZ float32 for i := uint32(0); i < waypointCount; i++ { - binary.Read(r, binary.LittleEndian, &lastX) - binary.Read(r, binary.LittleEndian, &lastY) - binary.Read(r, binary.LittleEndian, &lastZ) + binary.Read(r, binary.LittleEndian, &destX) + binary.Read(r, binary.LittleEndian, &destY) + binary.Read(r, binary.LittleEndian, &destZ) } - obj.StartX = obj.PosX - obj.StartY = obj.PosY - obj.StartZ = obj.PosZ - obj.DestX = lastX - obj.DestY = lastY - obj.DestZ = lastZ - obj.IsMoving = true - obj.MoveStartTime = time.Now() - obj.MoveDuration = time.Duration(duration) * time.Millisecond } else { // Linear: destination is the first Vector3 after the count - var destX, destY, destZ float32 binary.Read(r, binary.LittleEndian, &destX) binary.Read(r, binary.LittleEndian, &destY) binary.Read(r, binary.LittleEndian, &destZ) - obj.StartX = obj.PosX - obj.StartY = obj.PosY - obj.StartZ = obj.PosZ - obj.DestX = destX - obj.DestY = destY - obj.DestZ = destZ - obj.IsMoving = true - obj.MoveStartTime = time.Now() - obj.MoveDuration = time.Duration(duration) * time.Millisecond } + obj.StartX = obj.PosX + obj.StartY = obj.PosY + obj.StartZ = obj.PosZ + obj.DestX = destX + obj.DestY = destY + obj.DestZ = destZ + obj.IsMoving = true + obj.MoveStartTime = time.Now() + obj.MoveDuration = time.Duration(duration) * time.Millisecond + if selfMove { + // Charge/intercept/etc.: server owns the spline. Snap to the destination so + // local path following cannot rubber-band us back to the cast origin. + // Short-duration forced moves should land immediately for AI purposes. + if duration <= 1500 || duration == 0 { + w.applySelfServerRelocate(destX, destY, destZ, "monster_move_charge") + } else { + w.applySelfServerRelocate(posX, posY, posZ, "monster_move_self") + } + } } func (w *WorldClient) handleMonsterMoveTransport(data []byte) { diff --git a/cmd/azghost/main.go b/cmd/azghost/main.go index 98ddbda..b2ac610 100644 --- a/cmd/azghost/main.go +++ b/cmd/azghost/main.go @@ -146,6 +146,8 @@ foundVerb: cliCfg.PathfindingAddress = *pathfindingAddr case "lua-script": cliCfg.LuaScript = *luaScript + // Passing a script always selects Lua AI (profile default is often "grind"). + cliCfg.BotMode = "lua" case "delete-existing-chars": cliCfg.DeleteExistingChars = *deleteExistingChars case "log-decisions-to-chat": diff --git a/config/config.go b/config/config.go index 615c517..7016491 100644 --- a/config/config.go +++ b/config/config.go @@ -173,44 +173,86 @@ func findProfile(name string) string { } func loadYAML(path string, out *CLIConfig) error { - // Minimal loader for now (profiles can be supported via env or future expansion). - // Avoids external dep for initial build after restore. + // Minimal key: value parser (no external YAML dep). Supports multiline + // blocks only as "key: |" start markers (value ignored for multi-line + // lua_code — pass --lua-script instead for real scripts). b, err := os.ReadFile(path) if err != nil { return err } - // Very basic key: value parser for common fields (sufficient for E2E profile use). s := string(b) - for _, line := range strings.Split(s, "\n") { - line = strings.TrimSpace(line) + lines := strings.Split(s, "\n") + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) if line == "" || strings.HasPrefix(line, "#") { continue } - if idx := strings.Index(line, ":"); idx > 0 { - k := strings.TrimSpace(line[:idx]) - v := strings.Trim(strings.TrimSpace(line[idx+1:]), `"'`) - switch k { - case "auth_server", "auth-server": - out.AuthServer = v - case "data_dir", "data-dir": - out.DataDir = v - case "username": - out.Username = v - case "password": - out.Password = v - case "char_name", "char-name", "character_name": - out.CharName = v - case "account_prefix", "account-prefix": - out.AccountPrefix = v - case "account_password", "account-password": - out.AccountPassword = v - case "num_bots", "num-bots": - if n, err := strconv.Atoi(v); err == nil { - out.NumBots = n + idx := strings.Index(line, ":") + if idx <= 0 { + continue + } + k := strings.TrimSpace(line[:idx]) + v := strings.TrimSpace(line[idx+1:]) + // Skip multi-line YAML block scalars (lua_code: | ...) + if v == "|" || v == ">" || v == "|-" || v == ">-" { + for i+1 < len(lines) { + next := lines[i+1] + if len(next) > 0 && (next[0] == ' ' || next[0] == '\t') { + i++ + continue } - case "nodes": - out.Nodes = v + break + } + continue + } + v = strings.Trim(v, `"'`) + switch k { + case "auth_server", "auth-server": + out.AuthServer = v + case "data_dir", "data-dir": + out.DataDir = v + case "username": + out.Username = v + case "password": + out.Password = v + case "char_name", "char-name", "character_name": + out.CharName = v + case "bot_mode", "bot-mode", "mode": + if v != "" { + out.BotMode = v + } + case "lua_script", "lua-script": + out.LuaScript = v + case "race": + if n, err := strconv.Atoi(v); err == nil { + out.Race = n + } + case "class": + if n, err := strconv.Atoi(v); err == nil { + out.Class = n + } + case "delete_existing_chars", "delete-existing-chars": + out.DeleteExistingChars = v == "true" || v == "yes" || v == "1" + case "log_decisions_to_chat", "log-decisions-to-chat": + out.LogDecisionsToChat = v == "true" || v == "yes" || v == "1" + case "realm_index", "realm-index": + if n, err := strconv.Atoi(v); err == nil { + out.RealmIndex = n + } + case "account_prefix", "account-prefix": + out.AccountPrefix = v + case "account_password", "account-password": + out.AccountPassword = v + case "num_bots", "num-bots": + if n, err := strconv.Atoi(v); err == nil { + out.NumBots = n } + case "nodes": + out.Nodes = v + case "validation_mode", "validation-mode": + out.ValidationMode = v == "true" || v == "yes" || v == "1" + case "validation_log", "validation-log": + out.ValidationLogPath = v } } return nil diff --git a/scripts/ai/README.md b/scripts/ai/README.md index 42cf589..a2e037f 100644 --- a/scripts/ai/README.md +++ b/scripts/ai/README.md @@ -4,24 +4,24 @@ This directory contains the Lua-native Strategy + Trigger + Action + Value AI fr inspired by playerbots but implemented 100% in idiomatic Lua for extensibility via AIBundles. Note on style: ai/ files (core/generic) use 2-space indentation to match the library-style -in scripts/lib/behaviors.lua (and setup.lua when present). User on_tick examples (grind.lua, hogger.lua) -use 4 spaces. +in scripts/lib/behaviors.lua (and setup.lua when present). + +**Default grind entry:** `scripts/grind.lua` loads this framework (survive / rest / class +rotations). The thin sticky-melee loop lives in `scripts/lib/melee_grind.lua` for reuse. ## Usage (from on_tick or bundle main) ```lua local ai = dofile("scripts/ai/init.lua") -ai:enable_default_strategies() +ai:enable_default_strategies() -- survive, rest, grind, loot, melee, ranged, class function on_tick() - if not bot.is_alive() then - if bot.send_guild_command then bot.send_guild_command(".revive") else bot.send_command(".revive") end - return - end - ai:Tick() + ai:Tick() -- death revive + low-HP rest are inside survive/rest strategies end ``` +Or simply: `--lua-script scripts/grind.lua` + ## Structure - core/: base classes and the engine (Tick loop modeled on playerbots ProcessTriggers + relevance selection) diff --git a/scripts/ai/class/warrior.lua b/scripts/ai/class/warrior.lua index b64d7f4..1b5389e 100644 --- a/scripts/ai/class/warrior.lua +++ b/scripts/ai/class/warrior.lua @@ -28,7 +28,7 @@ local SPELLS = (data_ok and data.SPELLS) or { SLAM = 1464, EXECUTE = 5308, WHIRLWIND = 1680, - BATTLE_SHOUT = 2457, + BATTLE_SHOUT = 6673, DEMORALIZING_SHOUT = 1160, INTIMIDATING_SHOUT = 5246, BERSERKER_RAGE = 18499, @@ -60,18 +60,44 @@ function GenericWarrior:getType() return {"combat", "dps", "tank", "melee", "war function GenericWarrior:getDefaultActions() return { {name = "warrior_battle_shout", relevance = 9}, + {name = "warrior_charge", relevance = 16}, {name = "warrior_execute", relevance = 8.5}, {name = "warrior_victory_rush", relevance = 8}, + {name = "cast_heroic_strike", relevance = 5}, -- rage dump only after higher prio fail {name = "warrior_auto", relevance = 0.5}, } end function GenericWarrior:getTriggers() return { + { + name = "charge_gap", + IsActive = function(ctx) + local t = bot.get_target and bot.get_target() or 0 + if t == 0 or t == "0" then return false end + local u = bot.get_unit and bot.get_unit(t) or nil + if not u then return false end + local d = tonumber(u.distance) or 99 + if bot.get_position and u.x ~= nil then + local px, py = bot.get_position() + local dx = (tonumber(u.x) or 0) - (px or 0) + local dy = (tonumber(u.y) or 0) - (py or 0) + local d2 = math.sqrt(dx * dx + dy * dy) + if d2 > 0.5 then d = d2 end + end + -- Openers and mid-chase gaps (ignore sticky in_combat flag after kills). + return d >= 8 and d <= 24 + end, + getHandlers = function() return {{ name = "warrior_charge", relevance = 28 }} end, + }, { name = "missing_battle_shout", IsActive = function(ctx) - return not bot.has_aura_on(0, SPELLS.BATTLE_SHOUT) + local aura = (data_ok and data.AURAS and data.AURAS.BATTLE_SHOUT) or SPELLS.BATTLE_SHOUT + local own = bot.get_own_guid and bot.get_own_guid() or 0 + if bot.has_aura_on(own, aura) then return false end + if own ~= 0 and bot.has_aura_on(0, aura) then return false end + return true end, getHandlers = function() return {{name="warrior_battle_shout", relevance=20}} end, }, @@ -82,7 +108,15 @@ function GenericWarrior:getTriggers() if t == 0 or t == "0" then return false end local u = bot.get_unit(t) local hp = (u and u.health or 0) / math.max((u and u.max_health or 1), 1) * 100 - return hp < 20 and bot.is_spell_ready(SPELLS.EXECUTE) + if hp >= 20 then return false end + -- Need rage or we only spam CAST_FAILED NO_POWER. + local rage = 0 + if bot.get_power then + local cur = bot.get_power() + rage = tonumber(cur) or 0 + end + if rage < 15 then return false end + return bot.is_spell_ready and bot.is_spell_ready(SPELLS.EXECUTE) end, getHandlers = function() -- actually use spec/mainhand for relevance boost (talent/gear awareness) @@ -107,9 +141,9 @@ function ArmsStrategy:getType() return {"combat", "dps", "melee", "arms"} end function ArmsStrategy:getDefaultActions() return { {name = "cast_mortal_strike", relevance = 18}, - {name = "cast_sunder_armor", relevance = 12}, - {name = "cast_rend", relevance = 11}, - {name = "cast_whirlwind", relevance = 10}, + {name = "cast_rend", relevance = 14}, + {name = "cast_sunder_armor", relevance = 9}, -- below engage(7)? no 9>7; range-gated so OK + {name = "cast_whirlwind", relevance = 8}, } end @@ -192,121 +226,220 @@ function M.register(ctx) ctx:register_strategy("fury", FuryStrategy) ctx:register_strategy("prot", ProtStrategy) - -- actions (thin, use can_cast + is_spell_ready for safety) + -- WotLK base rage costs (rank-1). is_spell_ready is NOT enough: AC still + -- returns ready with 0 rage and then CAST_FAILED NO_POWER (85). + local RAGE_COST = { + [SPELLS.BATTLE_SHOUT] = 10, + [SPELLS.REND] = 10, + [SPELLS.HEROIC_STRIKE] = 15, + [SPELLS.SUNDER_ARMOR] = 15, + [SPELLS.EXECUTE] = 15, + [SPELLS.THUNDER_CLAP] = 20, + [SPELLS.CLEAVE] = 20, + [SPELLS.WHIRLWIND] = 25, + [SPELLS.MORTAL_STRIKE] = 30, + [SPELLS.BLOODTHIRST] = 20, + [SPELLS.SHIELD_SLAM] = 20, + [SPELLS.REVENGE] = 5, + [SPELLS.OVERPOWER] = 5, + [SPELLS.HAMSTRING] = 10, + [SPELLS.DEMORALIZING_SHOUT] = 10, + [SPELLS.CHARGE] = 0, + [SPELLS.VICTORY_RUSH] = 0, + [SPELLS.TAUNT] = 0, + } + + local function rage_now() + if not bot.get_power then return 0 end + local cur = bot.get_power() + return tonumber(cur) or 0 + end + + local function can_afford(spell_id) + local need = RAGE_COST[spell_id] + if need == nil then need = 0 end + return rage_now() >= need + end + + -- After NO_POWER, back off that spell briefly (server power updates lag). + local function power_blocked(ctx2, spell_id) + if not ctx2 or not ctx2.get_blackboard then return false end + local until_t = ctx2:get_blackboard("nopower_" .. tostring(spell_id)) + if not until_t then return false end + local now = (bot.now_ms and bot.now_ms() / 1000) or os.time() + return now < until_t + end + + local function melee_target(max_dist) + max_dist = max_dist or 5 + local t = bot.get_target() or 0 + if t == 0 or t == "0" then return nil, nil end + local u = bot.get_unit and bot.get_unit(t) or nil + if not u or u.is_alive == false then return nil, nil end + if (u.distance or 99) > max_dist then return nil, nil end + return t, u + end + + -- Single gate for all rage abilities. Never cast without enough power. + local function try_rage_cast(ctx2, spell_id, target, label, opts) + opts = opts or {} + if not spell_id or spell_id == 0 then return false end + if power_blocked(ctx2, spell_id) then return false end + if not can_afford(spell_id) then return false end + if bot.is_spell_ready and not bot.is_spell_ready(spell_id) then return false end + if bot.can_cast and target and target ~= 0 and target ~= "0" then + if not bot.can_cast(spell_id, target) then return false end + end + if opts.face and target and bot.face_target then + pcall(function() bot.face_target(target) end) + end + local r = rage_now() + utils.log_decision(string.format("%s (rage=%.0f need=%d)", label, r, RAGE_COST[spell_id] or 0)) + bot.cast_spell(spell_id, target or 0) + -- Do not optimistically mark_nopower here: a range/facing/LOS fail would + -- mis-block retries. Confirmed NO_POWER is handled server-side + -- (noteSpellNoPower → is_spell_ready / can_cast). + return true + end + ctx:register_action("warrior_battle_shout", function(ctx2) - if bot.is_spell_ready(SPELLS.BATTLE_SHOUT) then - utils.log_decision("warrior: battle shout") - return bot.cast_spell(SPELLS.BATTLE_SHOUT, 0) + local aura = (data_ok and data.AURAS and data.AURAS.BATTLE_SHOUT) or SPELLS.BATTLE_SHOUT + local own = bot.get_own_guid and bot.get_own_guid() or 0 + if bot.has_aura_on then + if own ~= 0 and bot.has_aura_on(own, aura) then return false end + if bot.has_aura_on(0, aura) then return false end + end + local now = (bot.now_ms and bot.now_ms() / 1000) or os.time() + local last = ctx2.get_blackboard and ctx2:get_blackboard("shout_try_at") + if last and (now - last) < 20 then return false end + if try_rage_cast(ctx2, SPELLS.BATTLE_SHOUT, 0, "warrior: battle shout") then + if ctx2.set_blackboard then ctx2:set_blackboard("shout_try_at", now) end + return true end return false end) - ctx:register_action("warrior_execute", function(ctx2) + ctx:register_action("warrior_charge", function(ctx2) local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.can_cast(SPELLS.EXECUTE, t) then - utils.log_decision("warrior: execute") - return bot.cast_spell(SPELLS.EXECUTE, t) + if t == 0 or t == "0" then return false end + local u = bot.get_unit and bot.get_unit(t) or nil + if not u or u.is_alive == false then return false end + + -- Prefer geometric distance (u.distance can lag). + local d = tonumber(u.distance) or 99 + if bot.get_position and u.x ~= nil then + local px, py = bot.get_position() + local dx = (tonumber(u.x) or 0) - (px or 0) + local dy = (tonumber(u.y) or 0) - (py or 0) + local d2 = math.sqrt(dx * dx + dy * dy) + if d2 > 0.5 then d = d2 end end - return false + -- Charge range ~8–25 yd. + if d < 8 or d > 24 then return false end + + -- Skip if already in melee brawl (close + fighting) — Charge is for openers. + if d < 10 and ctx2:get_value("in_combat") then return false end + + local now = (bot.now_ms and bot.now_ms() / 1000) or os.time() + local key = "charge_try_" .. tostring(t) + local last = ctx2.get_blackboard and ctx2:get_blackboard(key) + if last and (now - last) < 2.5 then return false end + + -- Do not hard-require is_spell_ready: after .learn it can lag a few ticks. + -- Still skip if we know it's on a long CD via no-power/block map. + if power_blocked(ctx2, SPELLS.CHARGE) then return false end + + -- Stop path + face so AC accepts the cast (moving/pathing often fails Charge). + if bot.stop_moving then pcall(function() bot.stop_moving() end) end + if bot.face_target then pcall(function() bot.face_target(t) end) end + if bot.set_sheath then pcall(function() bot.set_sheath(0) end) end + if bot.set_target then pcall(function() bot.set_target(t) end) end + + utils.log_decision(string.format("warrior: charge d=%.1f", d)) + if ctx2.set_blackboard then ctx2:set_blackboard(key, now) end + bot.cast_spell(SPELLS.CHARGE, t) + -- Consume this tick so we do not repath over the cast. + return true end) - ctx:register_action("warrior_victory_rush", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.is_spell_ready(SPELLS.VICTORY_RUSH) then - utils.log_decision("warrior: victory rush") - return bot.cast_spell(SPELLS.VICTORY_RUSH, t) + ctx:register_action("warrior_execute", function(ctx2) + local t, u = melee_target(5) + if not t then return false end + local hp = 100 + if (u.max_health or 0) > 0 then + hp = ((u.health or 0) / u.max_health) * 100 end - return false + if hp >= 20 then return false end + return try_rage_cast(ctx2, SPELLS.EXECUTE, t, "warrior: execute", { face = true }) + end) + + ctx:register_action("warrior_victory_rush", function(ctx2) + local t = melee_target(5) + if not t then return false end + return try_rage_cast(ctx2, SPELLS.VICTORY_RUSH, t, "warrior: victory rush", { face = true }) end) ctx:register_action("cast_rend", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.can_cast(SPELLS.REND, t) then - utils.log_decision("warrior: rend") - return bot.cast_spell(SPELLS.REND, t) - end - return false + local t = melee_target(8) + if not t then return false end + if bot.has_aura_on and bot.has_aura_on(t, SPELLS.REND) then return false end + return try_rage_cast(ctx2, SPELLS.REND, t, "warrior: rend", { face = true }) end) ctx:register_action("cast_mortal_strike", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.can_cast(SPELLS.MORTAL_STRIKE, t) then - utils.log_decision("warrior(arms): mortal strike") - return bot.cast_spell(SPELLS.MORTAL_STRIKE, t) - end - return false + local t = melee_target(5) + if not t then return false end + return try_rage_cast(ctx2, SPELLS.MORTAL_STRIKE, t, "warrior(arms): mortal strike", { face = true }) end) ctx:register_action("cast_sunder_armor", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.can_cast(SPELLS.SUNDER_ARMOR, t) then - utils.log_decision("warrior: sunder") - return bot.cast_spell(SPELLS.SUNDER_ARMOR, t) - end - return false + local t = melee_target(5) + if not t then return false end + return try_rage_cast(ctx2, SPELLS.SUNDER_ARMOR, t, "warrior: sunder", { face = true }) end) ctx:register_action("cast_whirlwind", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.is_spell_ready(SPELLS.WHIRLWIND) then - utils.log_decision("warrior: whirlwind") - return bot.cast_spell(SPELLS.WHIRLWIND, t) - end - return false + local t = melee_target(6) + if not t then return false end + return try_rage_cast(ctx2, SPELLS.WHIRLWIND, t, "warrior: whirlwind") end) ctx:register_action("cast_bloodthirst", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.can_cast(SPELLS.BLOODTHIRST, t) then - utils.log_decision("warrior(fury): bloodthirst") - return bot.cast_spell(SPELLS.BLOODTHIRST, t) - end - return false + local t = melee_target(5) + if not t then return false end + return try_rage_cast(ctx2, SPELLS.BLOODTHIRST, t, "warrior(fury): bloodthirst", { face = true }) end) ctx:register_action("cast_shield_slam", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.can_cast(SPELLS.SHIELD_SLAM, t) then - utils.log_decision("warrior(prot): shield slam") - return bot.cast_spell(SPELLS.SHIELD_SLAM, t) - end - return false + local t = melee_target(5) + if not t then return false end + return try_rage_cast(ctx2, SPELLS.SHIELD_SLAM, t, "warrior(prot): shield slam", { face = true }) end) ctx:register_action("cast_revenge", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.is_spell_ready(SPELLS.REVENGE) then - utils.log_decision("warrior(prot): revenge") - return bot.cast_spell(SPELLS.REVENGE, t) - end - return false + local t = melee_target(5) + if not t then return false end + return try_rage_cast(ctx2, SPELLS.REVENGE, t, "warrior(prot): revenge", { face = true }) end) ctx:register_action("cast_heroic_strike", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.is_spell_ready(SPELLS.HEROIC_STRIKE) then - utils.log_decision("warrior: heroic strike") - return bot.cast_spell(SPELLS.HEROIC_STRIKE, t) - end - return false + local t = melee_target(5) + if not t then return false end + -- Dump only with surplus rage (next-swing; still fails with NO_POWER). + if rage_now() < 40 then return false end + return try_rage_cast(ctx2, SPELLS.HEROIC_STRIKE, t, "warrior: heroic strike") end) ctx:register_action("cast_overpower", function(ctx2) - local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.can_cast(SPELLS.OVERPOWER, t) then - utils.log_decision("warrior(arms): overpower") - return bot.cast_spell(SPELLS.OVERPOWER, t) - end - return false + local t = melee_target(5) + if not t then return false end + return try_rage_cast(ctx2, SPELLS.OVERPOWER, t, "warrior(arms): overpower", { face = true }) end) ctx:register_action("cast_taunt", function(ctx2) local t = bot.get_target() or 0 - if (t ~= 0 and t ~= "0") and bot.is_spell_ready(SPELLS.TAUNT) then - utils.log_decision("warrior(prot): taunt") - return bot.cast_spell(SPELLS.TAUNT, t) - end - return false + if t == 0 or t == "0" then return false end + return try_rage_cast(ctx2, SPELLS.TAUNT, t, "warrior(prot): taunt") end) ctx:register_action("warrior_auto", function(ctx2) diff --git a/scripts/ai/core/engine.lua b/scripts/ai/core/engine.lua index a730794..05c6ed6 100644 --- a/scripts/ai/core/engine.lua +++ b/scripts/ai/core/engine.lua @@ -71,7 +71,7 @@ function M.Engine:Tick() if not bot then return end -- Summon / near-teleport / worldport: Go already interrupted movement+attack. - -- Drop sticky target state so strategies re-acquire at the new position. + -- Drop sticky target/rest state so strategies re-acquire at the new position. if bot.consume_teleport and bot.consume_teleport() then if bot.stop_moving then pcall(bot.stop_moving) end if bot.stop_attack then pcall(bot.stop_attack) end @@ -79,12 +79,20 @@ function M.Engine:Tick() if self.set_blackboard then self:set_blackboard("teleported", true) self:set_blackboard("current_target", nil) + self:set_blackboard("rest_until", nil) end return end values.update_cache(self._tick, self) + -- Expose power on ctx for strategies that only check is_spell_ready. + -- (Cached each tick; cheap.) + if bot.get_power then + local cur = bot.get_power() + self._power = tonumber(cur) or 0 + end + local candidates = {} -- 1. Collect from active strategies' default actions (always) diff --git a/scripts/ai/data/warrior_spells.lua b/scripts/ai/data/warrior_spells.lua index 10db15f..fda34f8 100644 --- a/scripts/ai/data/warrior_spells.lua +++ b/scripts/ai/data/warrior_spells.lua @@ -22,7 +22,7 @@ M.SPELLS = { SLAM = 1464, EXECUTE = 5308, WHIRLWIND = 1680, - BATTLE_SHOUT = 2457, + BATTLE_SHOUT = 6673, -- Battle Shout (2457 is Battle Stance — do not cast as shout) DEMORALIZING_SHOUT = 1160, INTIMIDATING_SHOUT = 5246, BERSERKER_RAGE = 18499, diff --git a/scripts/ai/examples/advanced_warrior_grind.lua b/scripts/ai/examples/advanced_warrior_grind.lua index 758fb2d..b6f5c23 100644 --- a/scripts/ai/examples/advanced_warrior_grind.lua +++ b/scripts/ai/examples/advanced_warrior_grind.lua @@ -12,11 +12,8 @@ local ai = dofile("scripts/ai/init.lua") --- enable core + class (init already wires warrior if class==1, but explicit for clarity) -ai:enable("survive") -ai:enable("grind") -ai:enable("loot") -ai:enable("melee") +-- Core defaults include survive/rest/grind/loot/melee/ranged + class generics. +ai:enable_default_strategies() ai:enable("generic_warrior") ai:enable("arms") -- or "fury" / "prot" ; user can switch dynamically -- local spec = ai.detect_spec and ai.detect_spec() or nil; if spec then ai:enable(spec) end diff --git a/scripts/ai/generic/grind.lua b/scripts/ai/generic/grind.lua index ca7bd89..75ca827 100644 --- a/scripts/ai/generic/grind.lua +++ b/scripts/ai/generic/grind.lua @@ -22,6 +22,8 @@ end function GrindStrategy:getDefaultActions() return { {name = "select_grind_target", relevance = 25}, + -- Very low: only when no target / not resting / not casting + {name = "wander_idle", relevance = 2}, } end diff --git a/scripts/ai/generic/melee.lua b/scripts/ai/generic/melee.lua index 4e24aff..8e5c0cd 100644 --- a/scripts/ai/generic/melee.lua +++ b/scripts/ai/generic/melee.lua @@ -18,8 +18,10 @@ function MeleeStrategy:getType() end function MeleeStrategy:getDefaultActions() + -- Below class ability defaults (MS/rend/execute ~11–25) so rotation casts try first; + -- still above wander so we keep auto-attack and sticky chase when spells are not ready. return { - {name = "engage_melee", relevance = 10}, + {name = "engage_melee", relevance = 7}, } end diff --git a/scripts/ai/generic/rest.lua b/scripts/ai/generic/rest.lua index e2e72e3..3ca8033 100644 --- a/scripts/ai/generic/rest.lua +++ b/scripts/ai/generic/rest.lua @@ -18,8 +18,9 @@ function RestStrategy:getType() end function RestStrategy:getDefaultActions() + -- Above grind select (25) so low-HP OOC pauses win over new pulls. return { - {name = "rest_if_low", relevance = 6}, + {name = "rest_if_low", relevance = 32}, } end diff --git a/scripts/ai/init.lua b/scripts/ai/init.lua index 483a7ea..742aa57 100644 --- a/scripts/ai/init.lua +++ b/scripts/ai/init.lua @@ -33,76 +33,99 @@ local values = dofile("scripts/ai/core/values.lua") -- for enemy/ally finders local M = {} +-- Spec strategy names that must not all run at once (only one primary per class). +local CLASS_SPEC_NAMES = { + "arms", "fury", "prot", + "retribution", "protection", "holy", + "beast_mastery", "marksmanship", "survival", + "assassination", "combat", "subtlety", + "shadow", "holy_priest", "discipline", + "blood", "frost_dk", "unholy", + "elemental", "enhancement", "resto_shaman", + "fire", "frost", "arcane", + "destruction", "affliction", "demonology", + "balance", "feral", "resto_druid", +} + +-- Forward declare: detect_spec is defined below enable_class_defaults. +local detect_spec + +-- Class generic + single primary spec only. Enabling every spec at once made +-- the bot try shield slam / bloodthirst / mortal strike every tick and starve +-- the real rotation (this is why the "advanced" grind felt broken). local function enable_class_defaults(ai, cls) cls = cls or (bot and bot.get_class and bot.get_class()) or 0 - -- registered-based (for enable_default after wiring) - if ai.registered_strategies["generic_warrior"] then - ai:enable("generic_warrior"); ai:enable("arms"); ai:enable("fury"); ai:enable("prot") - end - if ai.registered_strategies["hunter_generic"] then - ai:enable("hunter_generic"); ai:enable("beast_mastery"); ai:enable("pet_management") - end - if ai.registered_strategies["mage_generic"] then - ai:enable("mage_generic"); ai:enable("fire") + local primary = detect_spec and detect_spec(cls) or nil + + local function enable_if(name) + if name and ai.registered_strategies[name] then + ai:enable(name) + return true + end + return false end - -- remaining classes (enable main + some declared variants) - if ai.registered_strategies["generic_paladin"] then ai:enable("generic_paladin"); ai:enable("retribution"); ai:enable("protection"); ai:enable("holy") end - if ai.registered_strategies["rogue_generic"] then ai:enable("rogue_generic"); ai:enable("assassination"); ai:enable("combat"); ai:enable("subtlety") end - if ai.registered_strategies["priest_generic"] then ai:enable("priest_generic"); ai:enable("shadow"); ai:enable("holy_priest"); ai:enable("discipline") end - if ai.registered_strategies["dk_generic"] then ai:enable("dk_generic"); ai:enable("blood"); ai:enable("frost_dk"); ai:enable("unholy") end - if ai.registered_strategies["shaman_generic"] then ai:enable("shaman_generic"); ai:enable("elemental"); ai:enable("enhancement"); ai:enable("resto_shaman") end - if ai.registered_strategies["warlock_generic"] then ai:enable("warlock_generic"); ai:enable("destruction"); ai:enable("affliction"); ai:enable("demonology"); ai:enable("pet_management") end - if ai.registered_strategies["druid_generic"] then ai:enable("druid_generic"); ai:enable("balance"); ai:enable("feral"); ai:enable("resto_druid") end - - -- cls-based extras (for load_for_bot) + if cls == 1 then - ai:enable("generic_warrior") - ai:enable("arms") + enable_if("generic_warrior") + enable_if(primary or "arms") elseif cls == 2 then - ai:enable("generic_paladin") - ai:enable("retribution") + enable_if("generic_paladin") + enable_if(primary or "retribution") elseif cls == 3 then - ai:enable("hunter_generic") - ai:enable("beast_mastery") - ai:enable("ranged") - ai:enable("pet_management") + enable_if("hunter_generic") + enable_if(primary or "beast_mastery") + enable_if("pet_management") + enable_if("ranged") elseif cls == 4 then - ai:enable("rogue_generic") - ai:enable("assassination") + enable_if("rogue_generic") + enable_if(primary or "assassination") elseif cls == 5 then - ai:enable("priest_generic") - ai:enable("shadow") + enable_if("priest_generic") + enable_if(primary or "shadow") elseif cls == 6 then - ai:enable("dk_generic") - ai:enable("blood") + enable_if("dk_generic") + enable_if(primary or "blood") elseif cls == 7 then - ai:enable("shaman_generic") - ai:enable("elemental") + enable_if("shaman_generic") + enable_if(primary or "elemental") elseif cls == 8 then - ai:enable("mage_generic") - ai:enable("fire") + enable_if("mage_generic") + enable_if(primary or "fire") elseif cls == 9 then - ai:enable("warlock_generic") - ai:enable("destruction") - ai:enable("pet_management") + enable_if("warlock_generic") + enable_if(primary or "destruction") + enable_if("pet_management") elseif cls == 11 then - ai:enable("druid_generic") - ai:enable("balance") + enable_if("druid_generic") + enable_if(primary or "balance") + else + -- Class not known yet: enable whatever generics were registered (rare). + enable_if("generic_warrior") + enable_if("arms") + end + + if primary then + utils.log_decision("class defaults: primary spec='" .. tostring(primary) .. "' cls=" .. tostring(cls)) end - -- also ensure some additional declared specs are enabled for coverage (user can disable) - if cls == 1 then ai:enable("fury"); ai:enable("prot") end - if cls == 2 then ai:enable("protection"); ai:enable("holy") end - if cls == 4 then ai:enable("combat"); ai:enable("subtlety") end - if cls == 5 then ai:enable("holy_priest"); ai:enable("discipline") end - if cls == 6 then ai:enable("frost_dk"); ai:enable("unholy") end - if cls == 7 then ai:enable("enhancement"); ai:enable("resto_shaman") end - if cls == 9 then ai:enable("affliction"); ai:enable("demonology") end - if cls == 11 then ai:enable("feral"); ai:enable("resto_druid") end +end +-- Switch primary combat spec (disables sibling specs, enables `name`). +local function set_primary_spec(ai, name) + if not name or not ai.registered_strategies[name] then + return false + end + for _, s in ipairs(CLASS_SPEC_NAMES) do + if s ~= name then + ai:disable(s) + end + end + ai:enable(name) + utils.log_decision("set_primary_spec: " .. tostring(name)) + return true end -- spec detection heuristics (called optionally from examples or after load; uses known high spells, auras, power) -local function detect_spec(cls) +detect_spec = function(cls) cls = cls or (bot and bot.get_class and bot.get_class()) or 0 if not bot then return nil end -- wrap bot.* calls with pcall for safety (per review; avoids tick crashes if API throws) @@ -253,79 +276,110 @@ local function create_ai_engine() ai:register_action("survive_low_health", function(ctx) local hp = ctx:get_value("health_pct") or 100 - if hp < 25 then - utils.log_decision("low health (" .. math.floor(hp) .. "%) - attempting flee/revive logic") - -- basic: stop and perhaps move back or just log; potions require items not in basic API - bot.stop_moving() - -- could send .cooldown or eat but keep minimal and non-breaking - return true + if hp >= 25 then + return false end - return false + local in_combat = ctx:get_value("in_combat") + -- In combat: do not consume the tick (would starve rotation/melee) and do + -- not stop_moving — that thrash-repaths with engage_melee every ~200ms. + if in_combat then + return false + end + -- Out of combat + critical HP: force rest window, drop target, no new pulls. + utils.log_decision("low health (" .. math.floor(hp) .. "%) OOC — rest before next pull") + if bot.stop_moving then bot.stop_moving() end + if bot.stop_attack then pcall(function() bot.stop_attack() end) end + if bot.set_target then pcall(function() bot.set_target(0) end) end + local now = (bot.now_ms and (bot.now_ms() / 1000)) or os.time() + if ctx.set_blackboard then + ctx:set_blackboard("rest_until", now + 10) + end + return true end) - -- grind: improved target selection using values + -- Shared sticky chase for grind/melee (avoids repath thrash; uses interpolated unit.x/y/z). + local movement_lib = nil + local grind_chase = nil + do + local okm, mod = pcall(dofile, "scripts/lib/movement.lua") + if okm and mod then + movement_lib = mod + grind_chase = mod.new_chase({ repath_period = 1.0, dest_slack = 3.5, min_gap = 0.35 }) + end + end + local targeting_lib = nil + do + local okt, mod = pcall(dofile, "scripts/lib/targeting.lua") + if okt and mod then targeting_lib = mod end + end + + -- grind: prefer scripts/lib/targeting (permissive + blacklist); fall back to legacy scan ai:register_action("select_grind_target", function(ctx) + -- Rest window after low-HP: do not pull until rest_until expires. + local rest_until = ctx.get_blackboard and ctx:get_blackboard("rest_until") + if rest_until then + local now = (bot.now_ms and (bot.now_ms() / 1000)) or os.time() + if now < rest_until then + return false + end + if ctx.set_blackboard then ctx:set_blackboard("rest_until", nil) end + end + -- gate: if already have live target, don't consume (let melee/ranged/loot participate) local tg = bot and bot.get_target and bot.get_target() or 0 if tg ~= 0 and tg ~= "0" then local u = bot and bot.get_unit and bot.get_unit(tg) or nil - if u and u.is_alive and (u.health or 0) > 0 then return false end - end - if not bot.get_nearby_units then return false end - local units = bot.get_nearby_units(30) - local best = nil - local best_score = 999999 - local my_level = bot.get_level and bot.get_level() or 1 - - for _, u in ipairs(units) do - local flags = u.flags or 0 - local npc = u.npc_flags or 0 - local non_attack = (flags % 4 >= 2) -- NON_ATTACKABLE (0x2) - or (flags % 2097152 >= 1048576) -- TAXI_FLIGHT (0x100000) from AC _IsValidAttackTarget - or (flags % 256 >= 128) -- NOT_ATTACKABLE_1 (0x80) - or (flags % 512 >= 256) -- IMMUNE_TO_PC (0x100) - or (flags % 131072 >= 65536) -- NON_ATTACKABLE_2 (0x10000) - or (flags % 33554432 >= 16777216) -- NOT_SELECTABLE (0x2000000) - local fac = u.faction or 0 - local friendlyFacs = { [35]=true, [11]=true, [12]=true, [13]=true, [55]=true, [57]=true, [59]=true, [60]=true, - [4]=true, [5]=true, [6]=true, [161]=true, [162]=true } - if friendlyFacs[fac] then - non_attack = true + local live = u and u.is_alive ~= false and ((u.max_health or 0) == 0 or (u.health or 0) > 0) + if live then + return false end - if u.is_alive and not u.is_player and not non_attack and npc == 0 then - -- extra: health 0 means dead even if is_alive flag lags (prevents attacking corpses) - if (u.health or 0) > 0 then - local dist = u.distance or 999 - local lvl = u.level or 1 - local lvl_diff = math.abs(lvl - my_level) - -- improved scoring: favor close + similar level (better than basic grind) - local score = dist + (lvl_diff * 3) - if score < best_score and dist > 1 and dist < 30 then - best = u - best_score = score - end - end + -- Dead / missing: short blacklist so we don't re-stick to the corpse. + if targeting_lib and targeting_lib.blacklist then + targeting_lib.blacklist(tg, 12) end + if bot.set_target then pcall(function() bot.set_target(0) end) end + if bot.stop_attack then pcall(function() bot.stop_attack() end) end end - if best then - bot.set_target(best.guid) - utils.log_decision("grind target: " .. tostring(best.guid) .. " dist=" .. math.floor(best.distance or 0) .. " fac=" .. tostring(best.faction or 0)) - -- Face before committing to attack (critical: incorrect target/facing -> server sends SMSG_ATTACKSWING_* notifying packet) - if bot.set_sheath then pcall(function() bot.set_sheath(0) end) end - if bot.face_target then pcall(function() bot.face_target(best.guid) end) end - -- initiate attack/move here too for responsiveness - if (best.distance or 0) > 3.5 then - bot.move_to(best.x, best.y, best.z) - else - bot.stop_moving() - -- Set target immediately before attack so external observers can see the bot's current attack target - if bot.set_target then pcall(function() bot.set_target(best.guid) end) end - bot.attack(best.guid) - end - return true + -- Always use shared targeting (critter / vendor / dead filters). Never fall + -- back to a permissive scan that picks 1-HP rabbits and then path-fails. + local best = nil + if targeting_lib and targeting_lib.find_best_hostile then + best = targeting_lib.find_best_hostile({ max_dist = 40 }) end - return false + if not best then + return false + end + -- Hard reject tiny critters even if a filter was bypassed. + if (best.max_health or 0) > 0 and (best.max_health or 0) <= 5 then + if targeting_lib.blacklist then targeting_lib.blacklist(best.guid, 60) end + return false + end + + bot.set_target(best.guid) + utils.log_decision( + string.format( + "grind target entry=%s dist=%.1f fac=%s hp=%s/%s pos=(%.1f,%.1f,%.1f)", + tostring(best.entry), + best.distance or -1, + tostring(best.faction or 0), + tostring(best.health or "?"), + tostring(best.max_health or "?"), + best.x or 0, + best.y or 0, + best.z or 0 + ) + ) + if bot.set_sheath then pcall(function() bot.set_sheath(0) end) end + -- Always path toward the unit first; engage_melee will swing when close. + -- Never open with ATTACKSWING at range (causes "stare" + NOT_IN_RANGE spam). + if grind_chase then + grind_chase:to_unit(best) + elseif best.x ~= nil and best.y ~= nil and bot.move_to then + bot.move_to(tonumber(best.x) or 0, tonumber(best.y) or 0, tonumber(best.z) or 0) + end + if bot.face_target then pcall(function() bot.face_target(best.guid) end) end + return true end) -- Siege / PvP actions (registered always so scenario can enable("siege")) @@ -349,49 +403,90 @@ local function create_ai_engine() return false end) - -- melee basics + -- melee basics (sticky chase via grind_chase when available) ai:register_action("engage_melee", function(ctx) local tg = bot.get_target and bot.get_target() or 0 if tg == 0 or tg == "0" then return false end local u = bot.get_unit and bot.get_unit(tg) or nil - if not u or not u.is_alive or (u.health or 0) <= 0 then return false end - -- Face the target before attacking (server will notify via SMSG_ATTACKSWING_BADFACING etc if bad facing) - if bot.set_sheath then pcall(function() bot.set_sheath(0) end) end -- unsheathe for melee + if not u or u.is_alive == false or ((u.max_health or 0) > 0 and (u.health or 0) <= 0) then + return false + end + -- Skip 1-HP ambient units that slip past filters. + if (u.max_health or 0) > 0 and (u.max_health or 0) <= 5 then + if targeting_lib and targeting_lib.blacklist then targeting_lib.blacklist(tg, 30) end + if bot.set_target then pcall(function() bot.set_target(0) end) end + return false + end + if bot.set_sheath then pcall(function() bot.set_sheath(0) end) end + + -- Prefer geometric 3D distance (Z matters on hills; 2D can look "in melee" + -- while the server rejects with NOT_IN_RANGE). + local d = tonumber(u.distance) or 99 + if bot.get_position and u.x ~= nil and u.y ~= nil then + local px, py, pz = bot.get_position() + px, py, pz = px or 0, py or 0, pz or 0 + local dx = (tonumber(u.x) or 0) - px + local dy = (tonumber(u.y) or 0) - py + local dz = (tonumber(u.z) or pz) - pz + local d3 = math.sqrt(dx * dx + dy * dy + dz * dz) + if d3 > d then d = d3 end + end + + if d > 3.2 then + if grind_chase then + grind_chase:to_unit(u) + elseif bot.move_to and u.x ~= nil and u.y ~= nil then + bot.move_to(tonumber(u.x) or 0, tonumber(u.y) or 0, tonumber(u.z) or 0) + end + return true + end if bot.face_target then pcall(function() bot.face_target(tg) end) end - local d = u.distance or 0 - if d > 3.5 then - bot.move_to(u.x, u.y, u.z) - else + if grind_chase then grind_chase:reset() end + if movement_lib and movement_lib.stop_if_moving then + movement_lib.stop_if_moving() + elseif bot.stop_moving then bot.stop_moving() - -- Set target immediately before attack so external observers can see the bot's current attack target - if bot.set_target then pcall(function() bot.set_target(tg) end) end - bot.attack(tg) end + if bot.set_target then pcall(function() bot.set_target(tg) end) end + bot.attack(tg) return true end) - -- ranged basics (same engage logic; future will use cast at range) + -- ranged basics: only for true ranged classes (enabled selectively). + -- Never white-swing from 8–25y — that freezes melee bots in "look at mob" pose. ai:register_action("engage_ranged", function(ctx) local tg = bot.get_target and bot.get_target() or 0 if tg == 0 or tg == "0" then return false end local u = bot.get_unit and bot.get_unit(tg) or nil - if not u or not u.is_alive or (u.health or 0) <= 0 then return false end + if not u or u.is_alive == false or ((u.max_health or 0) > 0 and (u.health or 0) <= 0) then + return false + end + local d = u.distance or 99 + if bot.get_position and u.x ~= nil then + local px, py, pz = bot.get_position() + local dx = (tonumber(u.x) or 0) - (px or 0) + local dy = (tonumber(u.y) or 0) - (py or 0) + local dz = (tonumber(u.z) or 0) - (pz or 0) + local d3 = math.sqrt(dx * dx + dy * dy + dz * dz) + if d3 > d then d = d3 end + end if bot.face_target then pcall(function() bot.face_target(tg) end) end - local d = u.distance or 0 - if d > 25 then - bot.move_to(u.x, u.y, u.z) - elseif d > 8 then - -- try keep range but basic: stop and attack (auto shot equiv via attack) - bot.stop_moving() - -- Set target immediately before attack so external observers can see the bot's current attack target - if bot.set_target then pcall(function() bot.set_target(tg) end) end - bot.attack(tg) - else - bot.stop_moving() - -- Set target immediately before attack so external observers can see the bot's current attack target + -- Close into shoot range (≤30); only stop+auto when actually in range. + if d > 30 then + if bot.move_to and u.x ~= nil then + bot.move_to(tonumber(u.x) or 0, tonumber(u.y) or 0, tonumber(u.z) or 0) + end + return true + end + if d > 5 and d <= 30 then + if bot.stop_moving then bot.stop_moving() end if bot.set_target then pcall(function() bot.set_target(tg) end) end bot.attack(tg) + return true end + -- Too close for comfort: step out slightly for hunters; still attack. + if bot.set_target then pcall(function() bot.set_target(tg) end) end + bot.attack(tg) return true end) @@ -473,14 +568,97 @@ local function create_ai_engine() return false end) - -- rest + -- rest: OOC heal-up between pulls (beats grind select when HP/power low). + -- Yields to lootable corpses so we do not skip loot while recovering, but + -- still arms rest_until so select_grind_target (relevance 25–30) cannot + -- steal the tick from loot_nearby (15–20) while HP/power is low. ai:register_action("rest_if_low", function(ctx) - if ctx:get_value("in_combat") then return false end + if ctx:get_value("in_combat") then + if ctx.set_blackboard then ctx:set_blackboard("rest_until", nil) end + return false + end + local now = (bot.now_ms and (bot.now_ms() / 1000)) or os.time() local hp = ctx:get_value("health_pct") or 100 local pp = ctx:get_value("power_pct") or 100 - if hp < 40 or pp < 30 then - utils.log_decision("rest: low resources, pausing") - bot.stop_moving() + -- Rage/runic/energy recover in combat; only mana casters rest on low power. + local power_type = bot.get_power_type and bot.get_power_type() or 0 + local low_power = (power_type == 0) and pp < 25 -- 0 = mana + local needs_rest = (hp < 40) or low_power + + local function arm_rest_until() + if not needs_rest then return end + local secs = (hp < 25) and 10 or 6 + if ctx.set_blackboard then ctx:set_blackboard("rest_until", now + secs) end + end + + -- Prefer looting first: return false so loot_nearby can run, but arm the + -- rest window first so grind cannot pull while we recover. + if bot.get_nearby_units then + for _, u in ipairs(bot.get_nearby_units(12) or {}) do + if not u.is_player and (u.lootable or u.is_alive == false) and (u.distance or 99) < 10 then + arm_rest_until() + return false + end + end + end + local rest_until = ctx.get_blackboard and ctx:get_blackboard("rest_until") + if rest_until and now < rest_until then + if bot.stop_moving then bot.stop_moving() end + return true + end + -- Resume grinding once mostly healthy (don't stick in rest at 50% forever). + if needs_rest then + local secs = (hp < 25) and 10 or 6 + if ctx.set_blackboard then ctx:set_blackboard("rest_until", now + secs) end + utils.log_decision( + "rest: low resources (hp=" + .. math.floor(hp) + .. "% power=" + .. math.floor(pp) + .. "%) — wait " + .. secs + .. "s" + ) + if bot.stop_moving then bot.stop_moving() end + if bot.stop_attack then pcall(function() bot.stop_attack() end) end + return true + end + return false + end) + + -- wander when idle (no target, not resting) — small steps only (avoid huge detours) + local wander_lib = nil + do + local okw, wmod = pcall(dofile, "scripts/lib/movement.lua") + if okw and wmod then wander_lib = wmod.new_wander({ period = 3.0, radius = 12 }) end + end + ai:register_action("wander_idle", function(ctx) + if ctx:get_value("in_combat") then return false end + local rest_until = ctx.get_blackboard and ctx:get_blackboard("rest_until") + if rest_until then + local now = (bot.now_ms and (bot.now_ms() / 1000)) or os.time() + if now < rest_until then return false end + end + local tg = bot.get_target and bot.get_target() or 0 + if tg ~= 0 and tg ~= "0" then + local u = bot.get_unit and bot.get_unit(tg) or nil + if u and u.is_alive ~= false and ((u.max_health or 0) == 0 or (u.health or 0) > 0) then + return false + end + end + -- Prefer another hostile nearby over wandering off into the distance. + if targeting_lib and targeting_lib.find_best_hostile then + local b = targeting_lib.find_best_hostile({ max_dist = 40 }) + if b then return false end + end + if wander_lib then + return wander_lib:step() + end + if bot.get_position and bot.move_to then + local x, y, z = bot.get_position() + local t = (bot.now_ms and bot.now_ms() or 0) / 1000 + local a = (t * 11.3) % (2 * math.pi) + bot.move_to((x or 0) + math.cos(a) * 8, (y or 0) + math.sin(a) * 8, z or 0) return true end return false @@ -542,16 +720,25 @@ local function create_ai_engine() -- attach convenience for default enable (supports ai = dofile(); ai:enable_default_strategies()) function ai:enable_default_strategies() self:enable("survive") + self:enable("rest") self:enable("grind") self:enable("loot") self:enable("melee") - self:enable("ranged") - -- optionals (users can enable("follow") etc explicitly for simple rpg modes) - -- if class strats were registered, enable a basic set for the class (shared logic) + -- Ranged engage must NOT be on for melee classes: its relevance (9) beats + -- engage_melee (7) and it stop_moving+swing at 8–25y → "stare from range". + local cls = (bot and bot.get_class and bot.get_class()) or 0 + if cls == 3 or cls == 5 or cls == 8 or cls == 9 then -- hunter, priest, mage, warlock + self:enable("ranged") + end + -- Single primary class spec only (see enable_class_defaults). enable_class_defaults(self) return self end + function ai:set_primary_spec(name) + return set_primary_spec(self, name) + end + -- helper for enabling simple rpg/noncombat generics (addresses review) function ai:enable_rpg_mode() self:enable("follow") diff --git a/scripts/grind.lua b/scripts/grind.lua index f246d88..5b316ad 100644 --- a/scripts/grind.lua +++ b/scripts/grind.lua @@ -1,83 +1,98 @@ --- grind.lua — thin warrior entrypoint for melee grind. --- Core logic lives in scripts/lib/* so other scripts can reuse it. +-- grind.lua — production grind entry using the advanced strategy AI. +-- +-- Behaviour (scripts/ai/*): +-- survive death revive; critical HP OOC rests (no new pulls) +-- rest OOC heal-up when HP is low (mana classes also rest on low mana) +-- grind hostile pick (scripts/lib/targeting) + idle wander +-- loot corpses when safe +-- melee sticky chase + auto-attack (below class ability priority) +-- class one primary spec rotation (not all specs at once) +-- +-- Teleport/summon: engine polls bot.consume_teleport() and clears sticky state. -- -- ./azghost --profile local-ac cli --bot-mode lua --lua-script scripts/grind.lua +-- +-- Thin sticky-melee only: scripts/lib/melee_grind.lua -local melee_grind = dofile("scripts/lib/melee_grind.lua") +local boot = dofile("scripts/ai/init.lua") --- Prefer shared spell table when present -local SPELLS = { - HEROIC_STRIKE = 78, - REND = 772, - CHARGE = 100, - EXECUTE = 5308, - BATTLE_SHOUT = 6673, -- real shout (not 2457 stance) - VICTORY_RUSH = 34428, - SUNDER_ARMOR = 7386, -} -local ok, data = pcall(dofile, "scripts/ai/data/warrior_spells.lua") -if ok and data and data.SPELLS then - for k, v in pairs(data.SPELLS) do - if SPELLS[k] == nil then - SPELLS[k] = v - end +-- Rebuild after bot.get_class() is valid; enables survive/rest/grind/loot/melee + one spec. +local ai +if boot.load_for_bot then + ai = boot.load_for_bot() +else + ai = boot + if ai.enable_default_strategies then ai:enable_default_strategies() end +end + +local boot_at = (bot and bot.now_ms and bot.now_ms() / 1000) or os.time() +local SETTLE = 0.8 +local prepped = false + +-- GM/dev accounts: brand-new level-1 toons have no weapon/spells and will only +-- "stare" at mobs. Best-effort prep (commands no-op if not GM). +local function ensure_combat_ready() + if prepped or not bot or not bot.send_command then return end + prepped = true + local cls = bot.get_class and bot.get_class() or 0 + local level = bot.get_level and bot.get_level() or 1 + + -- Always equip a white weapon and unsheathe so auto-attack can land. + bot.send_command(".additem 25 1") -- Worn Shortsword + bot.send_command(".equip 25") + if bot.set_sheath then pcall(bot.set_sheath, 0) end + + if level < 6 then + bot.send_command(".level 6") + if bot.set_level then pcall(bot.set_level, 6) end end - -- Prefer researched aura id for shout when provided - if data.AURAS and data.AURAS.BATTLE_SHOUT then - SPELLS.BATTLE_SHOUT_AURA = data.AURAS.BATTLE_SHOUT + + if cls == 1 then + -- Learn Battle Stance once (2457). Do NOT re-cast stance every tick — it + -- wastes GCD, can dump rage on some cores, and does nothing if already in it. + bot.send_command(".learn 2457") + bot.send_command(".cast 2457") -- enter stance once at prep + -- Real Battle Shout is 6673 (not 2457). + local spells = { 6673, 772, 100, 78, 5308, 34428, 7386, 6343, 2687 } + for _, id in ipairs(spells) do + bot.send_command(".learn " .. tostring(id)) + end + if bot.log then bot.log("grind: warrior combat prep (stance once + kit + weapon)") end + elseif bot.log then + bot.log("grind: generic combat prep class=" .. tostring(cls) .. " (weapon+level)") end end -local COSTS = { - [SPELLS.HEROIC_STRIKE] = 15, - [SPELLS.REND] = 10, - [SPELLS.EXECUTE] = 15, - [SPELLS.BATTLE_SHOUT] = 10, - [SPELLS.SUNDER_ARMOR] = 15, - [SPELLS.CHARGE] = 0, - [SPELLS.VICTORY_RUSH] = 0, -} +if bot and bot.log then + local cls = bot.get_class and bot.get_class() or "?" + local spec = ai.detect_spec and ai.detect_spec() or nil + bot.log(string.format( + "grind: advanced AI ready class=%s primary_spec=%s", + tostring(cls), + tostring(spec or "default") + )) +end -local controller = melee_grind.new({ - spells = SPELLS, - costs = COSTS, - charge_spell = SPELLS.CHARGE, - shout_spell = SPELLS.BATTLE_SHOUT, - scan_range = 40, - melee_stop = 2.8, - melee_chase = 4.5, - settle = 0.5, - wander = { period = 2.0, radius = 24 }, - chase = { repath_period = 1.2, dest_slack = 5.0, min_gap = 0.5 }, - rotation = function(ctx) - local S = ctx.spells - local c = ctx.caster - local r = ctx.rage - if S.EXECUTE and ctx.hp_pct < 20 and r >= 15 then - if c:try_cast(S.EXECUTE, ctx.guid) then - return - end - end - if S.VICTORY_RUSH and c:try_cast(S.VICTORY_RUSH, ctx.guid) then - return - end - if S.REND and bot.has_aura_on and not bot.has_aura_on(ctx.guid, S.REND) and r >= 10 then - if c:try_cast(S.REND, ctx.guid) then - return - end - end - -- Surplus rage only — avoids NO_POWER spam on HS - if S.HEROIC_STRIKE and r >= 45 then - if c:try_cast(S.HEROIC_STRIKE, ctx.guid) then - return - end - end - if S.SUNDER_ARMOR and r >= 30 then - c:try_cast(S.SUNDER_ARMOR, ctx.guid) +function on_tick() + local now = (bot and bot.now_ms and bot.now_ms() / 1000) or os.time() + if (now - boot_at) < SETTLE then + return + end + + -- Teleport first (also handled inside ai:Tick; consume once here for settle reset). + if bot and bot.consume_teleport and bot.consume_teleport() then + boot_at = now + if ai.set_blackboard then + ai:set_blackboard("rest_until", nil) + ai:set_blackboard("teleported", true) end - end, -}) + if bot.stop_moving then pcall(bot.stop_moving) end + if bot.stop_attack then pcall(bot.stop_attack) end + if bot.set_target then pcall(bot.set_target, 0) end + if bot.log then bot.log("grind: teleport interrupt — settle + restart AI") end + return + end -function on_tick() - controller:tick() + ensure_combat_ready() + ai:Tick() end diff --git a/scripts/lib/combat.lua b/scripts/lib/combat.lua index f17ba4c..547b70c 100644 --- a/scripts/lib/combat.lua +++ b/scripts/lib/combat.lua @@ -38,10 +38,17 @@ function M.new_caster(opts) if bot.is_spell_ready and not bot.is_spell_ready(spell_id) then return false end - local need = self.costs[spell_id] or extra.rage or 0 - if util.rage() < need then + -- Power first: is_spell_ready is true with 0 rage/mana and yields CAST_FAILED 85. + local need = self.costs[spell_id] or extra.rage or extra.cost or 0 + local have = util.rage() -- name is historical; returns current power for any type + if type(have) == "number" and have < need then return false end + if bot.can_cast and target_guid and not util.is_zero_guid(target_guid) then + if not bot.can_cast(spell_id, target_guid) then + return false + end + end local t = util.now() if not extra.ignore_gcd and (t - self.last_cast_at) < self.gcd then return false diff --git a/scripts/lib/targeting.lua b/scripts/lib/targeting.lua index 6de44b4..506dc1a 100644 --- a/scripts/lib/targeting.lua +++ b/scripts/lib/targeting.lua @@ -57,6 +57,21 @@ function M.is_attackable_mob(u, opts) if mhp == 0 and u.is_alive == false then return false, "dead_flag" end + -- Critters / ambient (rabbits entry 721, etc.): tiny HP — never grind these. + if mhp > 0 and mhp <= 10 then + return false, "critter" + end + if mhp == 0 and (hp or 0) > 0 and (hp or 0) <= 10 then + return false, "critter_hp" + end + -- Known ambient critter entries (Elwynn / start zones). + local entry = tonumber(u.entry) or 0 + if entry == 721 or entry == 883 or entry == 2620 or entry == 4075 then + -- 4075 is rat — actually attackable; only skip true critters + if entry ~= 4075 then + return false, "critter_entry" + end + end local npc = u.npc_flags or 0 -- Skip clear civilians only (not every non-zero npc flag)