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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions bot/ai_advanced_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
154 changes: 145 additions & 9 deletions bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading