Add PlayerbotV2 module (standalone, no core history)

This commit is contained in:
josh
2026-08-10 09:10:23 +10:00
commit 7a8377c7f6
482 changed files with 168854 additions and 0 deletions
@@ -0,0 +1,244 @@
--[[
BotGear.lua — Standalone altbot gear inspector.
Uses PBC addon protocol, no dependency on PlayerbotControl.
/botgear — opens panel for targeted bot.
Right-click bag slot → equip. Right-click equipment slot → unequip.
]]
local ADDON = "BotGear"
local PREFIX = "PBC"
-- State
local frame, botGuid, botName
local bagButtons = {} -- [slot] = button
local equipButtons = {} -- [slot] = button
local bagData = {} -- [slot] = {entry, count, quality}
local equipData = {} -- [slot] = {entry, quality}
local SLOT_SIZE = 36
local EQUIP_SLOT_NAMES = {
[0]="Head",[1]="Neck",[2]="Shoulder",[3]="Shirt",[4]="Chest",
[5]="Waist",[6]="Legs",[7]="Feet",[8]="Wrist",[9]="Hands",
[10]="Finger1",[11]="Finger2",[12]="Trinket1",[13]="Trinket2",
[14]="Back",[15]="MainHand",[16]="OffHand",[17]="Ranged",[18]="Tabard",
}
-- Send addon message
local function Send(mtype, ...)
local args = {...}
local body = table.concat(args, "|")
local frame = string.format("1|00000000|1|1|%s|%s", mtype, body)
C_ChatInfo.SendAddonMessage(PREFIX, frame, "WHISPER",
botName or UnitName("player"))
end
-- Resolve target
local function ResolveTarget()
if not UnitExists("target") then return nil, nil end
local guid = UnitGUID("target")
if not guid then return nil, nil end
-- Format: Player-0-XXXX-YYYYYY
local low = tonumber(string.match(guid, "-(%d+)-%x+$"))
local name = UnitName("target")
return low, name
end
-- Slot rendering
local function RefreshSlot(btn, entry, count, quality)
if not btn then return end
if entry and entry > 0 then
local _, _, _, _, _, _, _, _, _, tex = GetItemInfo(entry)
btn.itemEntry = entry
btn.icon:SetTexture(tex or "Interface\\Icons\\INV_Misc_QuestionMark")
btn.countText:SetText(count and count > 1 and count or "")
btn:SetAlpha(1.0)
else
btn.itemEntry = 0
btn.icon:SetTexture(nil)
btn.countText:SetText("")
btn:SetAlpha(0.3)
end
end
local function RequestBags()
if not botGuid then return end
Send("GEAR_BAGS_REQ", tostring(botGuid))
end
local function RequestEquip()
if not botGuid then return end
Send("GEAR_EQUIP_REQ", tostring(botGuid))
end
-- Reset to targeted bot
local function RefreshTarget()
local g, n = ResolveTarget()
if g then
botGuid = g
botName = n
if frame and frame.title then
frame.title:SetText("Gear - " .. (n or "?"))
end
RequestBags()
C_Timer.After(0.15, RequestEquip)
end
end
-- Create a slot button
local function CreateSlot(parent, x, y, slotType, slotIdx)
local btn = CreateFrame("Button", nil, parent)
btn:SetSize(SLOT_SIZE, SLOT_SIZE)
btn:SetPoint("TOPLEFT", x, -y)
btn:SetNormalTexture("Interface\\Buttons\\UI-Quickslot2")
btn:SetHighlightTexture("Interface\\Buttons\\ButtonHilight-Square")
btn.slotType = slotType
btn.slotIdx = slotIdx
btn.itemEntry = 0
btn.icon = btn:CreateTexture(nil, "ARTWORK")
btn.icon:SetAllPoints()
btn.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
btn.countText = btn:CreateFontString(nil, "OVERLAY", "NumberFontNormal")
btn.countText:SetPoint("BOTTOMRIGHT", -1, 2)
-- Tooltip
btn:SetScript("OnEnter", function(self)
if self.itemEntry > 0 then
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:SetItemByID(self.itemEntry)
GameTooltip:Show()
end
end)
btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
-- Right-click action
btn:SetScript("OnClick", function(self, button)
if button ~= "RightButton" or self.itemEntry == 0 then return end
if slotType == "bag" then
Send("GEAR_EQUIP_ITEM", tostring(botGuid), tostring(slotIdx))
C_Timer.After(0.3, RequestBags)
C_Timer.After(0.4, RequestEquip)
elseif slotType == "equip" then
Send("GEAR_UNEQUIP_ITEM", tostring(botGuid), tostring(slotIdx))
C_Timer.After(0.3, RequestBags)
C_Timer.After(0.4, RequestEquip)
end
end)
return btn
end
-- Build UI
local function BuildFrame()
if frame then return end
frame = CreateFrame("Frame", "BotGearFrame", UIParent)
frame:SetSize(330, 500)
frame:SetPoint("CENTER")
frame:SetBackdrop({bgFile="Interface\\DialogFrame\\Background-DialogFrame",
edgeFile="Interface\\Tooltips\\UI-Tooltip-Border", tile=1,tileSize=16,edgeSize=16,
insets={left=3,right=3,top=3,bottom=3}})
frame:SetMovable(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", frame.StartMoving)
frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
tinsert(UISpecialFrames, "BotGearFrame")
-- Close button
local close = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
close:SetPoint("TOPRIGHT", -5, -5)
-- Title
frame.title = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalLarge")
frame.title:SetPoint("TOP", 0, -12)
frame.title:SetText("Gear - /target a bot")
-- Refresh button
local refresh = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
refresh:SetSize(80, 22)
refresh:SetPoint("BOTTOM", 0, 8)
refresh:SetText("Refresh")
refresh:SetScript("OnClick", function()
RefreshTarget()
end)
-- Equip slots (left column)
for i = 0, 18 do
local col = i < 10 and 10 or 160
local row = (i < 10 and i or (i - 10)) * (SLOT_SIZE + 4)
equipButtons[i] = CreateSlot(frame, col, row + 25, "equip", i)
end
-- Slot labels
for i = 0, 18 do
local lbl = frame:CreateFontString(nil, "ARTWORK", "GameFontNormalSmall")
local col2 = i < 10 and 48 or 198
local row2 = (i < 10 and i or (i - 10)) * (SLOT_SIZE + 4) + 35
lbl:SetPoint("TOPLEFT", col2, -row2)
lbl:SetText(EQUIP_SLOT_NAMES[i] or ("Slot"..i))
end
-- Bag slots (4x10 grid, right side starts at row 25)
for i = 0, 39 do
local col = 10 + (i % 4) * (SLOT_SIZE + 4)
local row = 25 + math.floor(i / 4) * (SLOT_SIZE + 4) + 210
bagButtons[i] = CreateSlot(frame, col, row, "bag", i)
end
end
-- Handle inbound PBC messages
local f = CreateFrame("Frame")
f:RegisterEvent("CHAT_MSG_ADDON")
f:SetScript("OnEvent", function(_, _, prefix, msg, channel, sender)
if prefix ~= PREFIX then return end
-- Parse: 1|seq|idx|tot|MTYPE|body
local mtype, body = msg:match("^1|[^|]+|%d+|%d+|([^|]+)|(.+)$")
if not mtype then return end
if mtype == "GEAR_BAGS_RESP" then
local guidStr, rest = body:match("^([^|]+)|(.*)$")
if not guidStr or tonumber(guidStr) ~= botGuid then return end
bagData = {}
if rest then
for part in rest:gmatch("[^|]+") do
local slot, entry, count, qual = part:match("([^,]+),([^,]+),([^,]+),([^,]*)")
if slot then
bagData[tonumber(slot)] = {
entry = tonumber(entry),
count = tonumber(count),
quality = tonumber(qual) or 1
}
end
end
end
for i = 0, 39 do
local d = bagData[i]
RefreshSlot(bagButtons[i], d and d.entry, d and d.count, d and d.quality)
end
elseif mtype == "GEAR_EQUIP_RESP" then
local guidStr, rest = body:match("^([^|]+)|(.*)$")
if not guidStr or tonumber(guidStr) ~= botGuid then return end
equipData = {}
if rest then
for part in rest:gmatch("[^|]+") do
local slot, entry, qual = part:match("([^,]+),([^,]+),([^,]*)")
if slot then
equipData[tonumber(slot)] = {
entry = tonumber(entry),
quality = tonumber(qual) or 1
}
end
end
end
for i = 0, 18 do
local d = equipData[i]
RefreshSlot(equipButtons[i], d and d.entry, 1, d and d.quality)
end
end
end)
-- Slash command
SLASH_BOTGEAR1 = "/botgear"
SlashCmdList["BOTGEAR"] = function()
BuildFrame()
if frame:IsVisible() then
frame:Hide()
else
frame:Show()
RefreshTarget()
end
end
@@ -0,0 +1,8 @@
## Interface: 120000
## Title: BotGear
## Notes: Altbot gear manager — inspect and modify alt bot equipment
## Version: 0.1.0
## DefaultState: enabled
## LoadOnDemand: 0
BotGear.lua
@@ -0,0 +1,334 @@
--[[============================================================================
BotAlts.lua — "Spawn one of my alts as a bot" picker.
Look:
┌──────────────────────────────────────────────────┐
│ Spawn an alt [X]│
├──────────────────────────────────────────────────┤
│ ⚔ Areon L80 Warrior Human [Spawn]│ ← not a bot, offline
│ ✚ Healme L80 Priest Dwarf [Log In] │ ← marked bot, offline
│ ☠ Botty L70 Rogue Gnome [Logout] │ ← in world as a bot
│ ☣ Lockyou L74 Warlock Gnome [In World] │ ← real client session
│ - Yourself L80 Mage Human [/self] │ ← caller's char
│ … │
└──────────────────────────────────────────────────┘
Row states (decided from online/isBot/isHeadless/isSelf in the record):
* Spawn — offline + not marked: SUMMON marks + binds + headless-login.
* Log In — offline + marked bot: LOGIN_REQ re-enters world headless.
* Logout — online as a HEADLESS bot session: LOGOUT_REQ kicks it.
* In World— online as a REAL client (locked; never logout-able here).
* /self — the caller's own character (locked; use /pbc self on).
Data flow:
* Show() → Comms.AltsReq().
* ALTS_RESP handler parses records, sorts (actionable rows first:
spawnable, then log-in-able, then logout-able; locked rows last),
repopulates rows.
* Action click → Comms.Summon / LoginReq / LogoutReq. The server
replies with EVENT_PUSH (info/warn) which the core handler already
pipes to chat — no per-modal feedback wiring needed.
Design notes:
* Picker is INTENTIONALLY modal-ish (single instance, top strata) so a
misclick on a row doesn't accidentally race with a roster click.
* We refresh the alts list 1s after every action click so the row
flips state (Spawn→Logout, Log In→Logout, …) without manual /reload.
============================================================================]]
local PBC = _G.PlayerbotControl
local M = {}
PBC.BotAlts = M
M.frame = nil
M.rows = {}
M.MAX_ROWS = 14
M.ROW_H = 24
M.alts = {}
M.scroll = 0
M._pendingRefresh = nil
------------------------------------------------------------------ helpers
local CLASS_COLORS = RAID_CLASS_COLORS or {}
local function classColor(class)
local c = CLASS_COLORS[class and class:upper() or ""]
if c then return c.r, c.g, c.b end
return 0.85, 0.85, 0.85
end
-- Row state machine. Returns label, clickable, action where action is one
-- of "spawn" / "login" / "logout" / nil (locked row). Colors follow the
-- shared palette semantics: green = safe create/spawn, blue = bot session
-- control, red = locked by a real player session, gray = self.
local function statusText(rec)
if rec.isSelf then
return "|cffaaaaaa/self|r", false, nil
elseif rec.online and rec.isHeadless then
return "|cff66ccffLogout|r", true, "logout"
elseif rec.online then
return "|cffff6655In World|r", false, nil
elseif rec.isBot then
return "|cff66ccffLog In|r", true, "login"
else
return "|cff66ff66Spawn|r", true, "spawn"
end
end
local function raceLabel(r)
if not r then return "?" end
-- Lower-case for readability; first letter capitalized.
local s = r:lower():gsub("_", " ")
return s:sub(1, 1):upper() .. s:sub(2)
end
local function classLabel(c)
if not c then return "?" end
local s = c:lower():gsub("_", " ")
return s:sub(1, 1):upper() .. s:sub(2)
end
------------------------------------------------------------------ sort
-- Actionable rows come first so the user's eye lands on choices that do
-- something: spawnable, then log-in-able bots, then logout-able live bots;
-- locked rows (real client sessions, self) sink to the bottom. Inside each
-- bucket, sort by descending level then name — matches the server ORDER BY.
local function sortAlts(alts)
local function bucket(rec)
if rec.isSelf then return 5 end
if rec.online and rec.isHeadless then return 3 end
if rec.online then return 4 end
if rec.isBot then return 2 end
return 1
end
table.sort(alts, function(a, b)
local ba, bb = bucket(a), bucket(b)
if ba ~= bb then return ba < bb end
if (a.level or 0) ~= (b.level or 0) then
return (a.level or 0) > (b.level or 0)
end
return (a.name or "") < (b.name or "")
end)
end
------------------------------------------------------------------ row factory
local function buildRow(parent, index)
local f = CreateFrame("Frame", "PBCAltsRow" .. index, parent)
f:SetHeight(M.ROW_H)
f.bg = f:CreateTexture(nil, "BACKGROUND")
f.bg:SetAllPoints()
f.bg:SetColorTexture(0.06, 0.07, 0.09, 0.55)
f.name = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
f.name:SetPoint("LEFT", 6, 0)
f.name:SetJustifyH("LEFT")
f.meta = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
f.meta:SetPoint("LEFT", 170, 0)
f.meta:SetJustifyH("LEFT")
f.action = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
f.action:SetSize(78, 20)
f.action:SetPoint("RIGHT", -6, 0)
f.action:SetScript("OnClick", function(self)
local rec = self:GetParent().record
if not rec then return end
local _, _, action = statusText(rec)
if action == "spawn" then
PBC.Comms.Summon(rec.name)
PBC.Print("→ summon %s", rec.name)
elseif action == "login" then
PBC.Comms.LoginReq(rec.name)
PBC.Print("→ login %s", rec.name)
elseif action == "logout" then
PBC.Comms.LogoutReq(rec.name)
PBC.Print("→ logout %s", rec.name)
elseif rec.isSelf then
PBC.Print("That's your current character. Try /pbc self on.")
return
else
PBC.Warn("'%s' is online as a real player — locked.", rec.name)
return
end
-- Refresh shortly so the row flips state. The roster shows the
-- same character set, so nudge it too if it's open.
M._pendingRefresh = GetTime() + 1.0
if PBC.BotRoster and PBC.BotRoster.RequestRefresh then
PBC.BotRoster.RequestRefresh(1.5)
end
end)
return f
end
local function populateRow(rowFrame, rec)
rowFrame.record = rec
local r, g, b = classColor(rec.class)
rowFrame.name:SetText(string.format(
"|cff%02x%02x%02x%s|r L%d",
r * 255, g * 255, b * 255, rec.name or "?", rec.level or 0))
rowFrame.meta:SetText(string.format("%s · %s",
classLabel(rec.class), raceLabel(rec.race)))
local txt, clickable = statusText(rec)
rowFrame.action:SetText(txt)
rowFrame.action:SetEnabled(clickable)
rowFrame:Show()
end
------------------------------------------------------------------ layout
local function relayout()
if not M.frame or not M.frame:IsShown() then return end
-- Clamp scroll.
local maxScroll = math.max(0, #M.alts - M.MAX_ROWS)
if M.scroll > maxScroll then M.scroll = maxScroll end
if M.scroll < 0 then M.scroll = 0 end
for i = 1, M.MAX_ROWS do
local row = M.rows[i]
if not row then
row = buildRow(M.frame.list, i)
row:SetPoint("TOPLEFT", M.frame.list, "TOPLEFT", 0,
-(i - 1) * M.ROW_H)
row:SetPoint("TOPRIGHT", M.frame.list, "TOPRIGHT", 0,
-(i - 1) * M.ROW_H)
M.rows[i] = row
end
local rec = M.alts[i + M.scroll]
if rec then populateRow(row, rec)
else row.record = nil; row:Hide() end
end
M.frame.count:SetText(string.format("%d alt%s",
#M.alts, (#M.alts == 1) and "" or "s"))
end
M.Relayout = relayout
------------------------------------------------------------------ build
function M.Build()
if M.frame then return M.frame end
local f = CreateFrame("Frame", "PlayerbotControlAlts", UIParent,
"BackdropTemplate")
f:SetSize(420, 24 + M.MAX_ROWS * M.ROW_H + 8)
f:SetPoint("CENTER")
f:SetMovable(true)
f:EnableMouse(true)
f:SetClampedToScreen(true)
f:SetFrameStrata("DIALOG")
f:Hide()
if f.SetBackdrop then
f:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 14,
insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
f:SetBackdropColor(0.05, 0.05, 0.07, 0.96)
end
-- Titlebar
f.titlebar = CreateFrame("Frame", nil, f)
f.titlebar:SetPoint("TOPLEFT", 0, 0)
f.titlebar:SetPoint("TOPRIGHT", 0, 0)
f.titlebar:SetHeight(22)
local tb = f.titlebar:CreateTexture(nil, "BACKGROUND")
tb:SetAllPoints()
tb:SetColorTexture(0.10, 0.12, 0.18, 0.92)
f.title = f.titlebar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
f.title:SetPoint("LEFT", 8, 0)
f.title:SetText("Spawn an alt")
f.count = f.titlebar:CreateFontString(nil, "OVERLAY",
"GameFontDisableSmall")
f.count:SetPoint("RIGHT", -32, 0)
f.closeBtn = CreateFrame("Button", nil, f.titlebar,
"UIPanelCloseButton")
f.closeBtn:SetPoint("TOPRIGHT", 0, 0)
f.closeBtn:SetScript("OnClick", function() M.Hide() end)
f.titlebar:EnableMouse(true)
f.titlebar:RegisterForDrag("LeftButton")
f.titlebar:SetScript("OnDragStart", function() f:StartMoving() end)
f.titlebar:SetScript("OnDragStop", function() f:StopMovingOrSizing() end)
-- List body
f.list = CreateFrame("Frame", nil, f)
f.list:SetPoint("TOPLEFT", 4, -24)
f.list:SetPoint("BOTTOMRIGHT", -4, 22)
f.list:EnableMouseWheel(true)
f.list:SetScript("OnMouseWheel", function(_, delta)
M.scroll = M.scroll - delta; relayout()
end)
-- Footer (refresh + helper text)
f.footer = CreateFrame("Frame", nil, f)
f.footer:SetPoint("BOTTOMLEFT", 0, 0)
f.footer:SetPoint("BOTTOMRIGHT", 0, 0)
f.footer:SetHeight(22)
local fb = f.footer:CreateTexture(nil, "BACKGROUND")
fb:SetAllPoints()
fb:SetColorTexture(0.08, 0.09, 0.13, 0.92)
f.hint = f.footer:CreateFontString(nil, "OVERLAY",
"GameFontDisableSmall")
f.hint:SetPoint("LEFT", 6, 0)
f.hint:SetText("Bots: Log In / Logout. Real-client sessions are locked.")
f.refreshBtn = CreateFrame("Button", nil, f.footer,
"UIPanelButtonTemplate")
f.refreshBtn:SetSize(70, 18)
f.refreshBtn:SetPoint("RIGHT", -4, 0)
f.refreshBtn:SetText("Refresh")
f.refreshBtn:SetScript("OnClick", function() PBC.Comms.AltsReq() end)
M.frame = f
M.RegisterHandlers()
return f
end
------------------------------------------------------------------ protocol
function M.RegisterHandlers()
PBC.Comms.RegisterHandler("ALTS_RESP", function(fields, _sender)
local count = tonumber(fields[1]) or 0
local newAlts = {}
for i = 1, count do
local rec = fields[1 + i]
if rec and rec ~= "" then
newAlts[#newAlts + 1] = PBC.Comms.DecodeAltRecord(rec)
end
end
sortAlts(newAlts)
M.alts = newAlts
relayout()
end)
end
------------------------------------------------------------------ tick
function M.OnTick(_elapsed)
if M._pendingRefresh and GetTime() >= M._pendingRefresh then
M._pendingRefresh = nil
if M.frame and M.frame:IsShown() then PBC.Comms.AltsReq() end
end
end
------------------------------------------------------------------ public
function M.Show()
if not M.frame then M.Build() end
M.frame:Show()
PBC.Comms.AltsReq()
end
function M.Hide()
if M.frame then M.frame:Hide() end
end
function M.Toggle()
if M.frame and M.frame:IsShown() then M.Hide() else M.Show() end
end
-- vim: ts=4 sw=4 et
@@ -0,0 +1,372 @@
--[[============================================================================
BotCommands.lua — bottom-screen command bar with autocomplete + history.
Look:
┌──────────────────────────────────────────────────────────────┐
│ > all follow Areon │
├──────────────────────────────────────────────────────────────┤
│ all follow Areon · all stop · tank engage_focus │ ← suggestions
└──────────────────────────────────────────────────────────────┘
Keybindings (while editbox focused):
TAB / Shift-TAB cycle suggestions
↑ / ↓ walk history (most recent first)
ENTER dispatch
ESC hide
Command grammar:
<addr> <verb> <args…>
all|squad|tank follow|stop| bot names / coordinates / role
|healer|dps engage|engage_ (depending on verb)
|<botName> focus|hold|squad
|role|mark|login
|logout|promote
|whisper|pause|resume
Server is the source of truth on addressing rules. We *don't* attempt to
dispatch verbs locally — every command goes out as a CMD frame.
============================================================================]]
local PBC = _G.PlayerbotControl
local M = {}
PBC.BotCommands = M
M.frame = nil
M.editbox = nil
------------------------------------------------------------------ vocab
M.ADDRESSES = { "all", "squad", "tank", "healer", "dps",
"warrior", "paladin", "hunter", "rogue", "priest",
"death-knight", "shaman", "mage", "warlock", "monk",
"druid", "demon-hunter", "evoker" }
M.VERBS = {
"follow", "stop", "engage", "engage_focus", "hold", "squad",
"role", "mark", "login", "logout", "promote", "whisper",
"pause", "resume", "form", "spread", "tight", "ghost_res",
"use_hearth", "mount", "dismount", "loot_roll",
"bg_queue", "bg_leave", "lfg_queue", "lfg_leave",
}
-- Verb-specific arg hints to surface in placeholder text.
M.VERB_HELP = {
follow = "<target?>",
engage_focus = "<target>",
squad = "<botName…>",
role = "tank|healer|dps",
mark = "skull|cross|star|circle|moon|diamond|square|triangle",
form = "tight|spread|line|wedge",
whisper = "<botName> <text…>",
login = "(uses addr: all|role|class|name — offline bots)",
logout = "(uses addr: all|role|class|name — headless bots)",
bg_queue = "wsg|ab|av|eots|sota|ioc|bg|tp|kotmogu|dg|ss|tk|ashran|wint|seething",
lfg_queue = "<dungeonName?>",
}
------------------------------------------------------------------ suggestion logic
local function inferRoleNames(role)
local out = {}
if PBC.DB and PBC.DB.knownBots then
for name, r in pairs(PBC.DB.knownBots) do
if (r or ""):upper() == role:upper() then out[#out + 1] = name end
end
end
return out
end
local function botNames()
if PBC.BotRoster and PBC.BotRoster.GetNames then
return PBC.BotRoster.GetNames()
end
return {}
end
local function prefixMatch(list, pfx)
pfx = (pfx or ""):lower()
local out = {}
for _, item in ipairs(list) do
if item:lower():sub(1, #pfx) == pfx then out[#out + 1] = item end
end
return out
end
local function buildCandidates(text, cursorPos)
-- Tokenize the text up to cursor; the last partial token is what we
-- autocomplete on.
local pre = text:sub(1, cursorPos)
local toks = {}
for t in pre:gmatch("%S+") do toks[#toks + 1] = t end
local partial = ""
if pre:sub(-1) ~= " " and #toks > 0 then
partial = toks[#toks]
toks[#toks] = nil
end
local pos = #toks + 1
if pos == 1 then
return prefixMatch(M.ADDRESSES, partial), 1
elseif pos == 2 then
return prefixMatch(M.VERBS, partial), 2
else
-- For arg positions, suggest bot names primarily.
local addr = toks[1]
local verb = toks[2]
if verb == "role" then
return prefixMatch({ "tank", "healer", "dps" }, partial), pos
elseif verb == "mark" then
return prefixMatch({ "skull","cross","star","circle","moon","diamond","square","triangle" }, partial), pos
elseif verb == "form" then
return prefixMatch({ "tight","spread","line","wedge" }, partial), pos
elseif verb == "bg_queue" then
return prefixMatch({ "wsg","ab","av","eots","sota","ioc","bg","tp","kotmogu","dg","ss","tk","ashran","wint","seething" }, partial), pos
else
local pool = botNames()
if #pool == 0 and (addr == "tank" or addr == "healer" or addr == "dps") then
pool = inferRoleNames(addr)
end
return prefixMatch(pool, partial), pos
end
end
end
------------------------------------------------------------------ history walk
local histIdx = 0
local function pushHistory(s)
local h = PBC.DB.commands.history
-- de-dupe: don't add same as most-recent
if h[#h] == s then histIdx = 0; return end
h[#h + 1] = s
while #h > (PBC.DB.commands.maxHistory or 40) do
table.remove(h, 1)
end
histIdx = 0
end
local function historyAt(idx)
local h = PBC.DB.commands.history
if idx <= 0 or idx > #h then return nil end
return h[#h - idx + 1] -- 1 = most recent
end
------------------------------------------------------------------ dispatch
local function dispatch(text)
local toks = {}
for t in text:gmatch("%S+") do toks[#toks + 1] = t end
if #toks < 2 then
PBC.Warn("usage: <addr> <verb> [args…]")
return
end
local addr = toks[1]
local verb = toks[2]
-- login/logout aren't CMD verbs (an offline bot has no session for a
-- CMD to reach) — they ride dedicated LOGIN_REQ / LOGOUT_REQ frames.
if verb == "login" or verb == "logout" then
if verb == "login" then PBC.RequestLogin(addr)
else PBC.RequestLogout(addr) end
pushHistory(text)
return
end
local args = {}
for i = 3, #toks do args[#args + 1] = toks[i] end
PBC.Comms.Cmd(addr, verb, args)
PBC.Print("→ %s %s%s%s", addr, verb,
(#args > 0) and " " or "",
table.concat(args, " "))
pushHistory(text)
end
------------------------------------------------------------------ frame
local SUG_MAX = 6
local function rebuildSuggestions()
local f = M.frame
if not f then return end
local eb = M.editbox
local txt = eb:GetText() or ""
local cur = eb.GetUTF8CursorPosition and eb:GetUTF8CursorPosition() or eb:GetCursorPosition() or #txt
local cands, _pos = buildCandidates(txt, cur)
f.suggestionList = cands
f.suggestionIdx = 0
for i = 1, SUG_MAX do
local btn = f.sugBtns[i]
local label = cands[i]
if label then
btn:SetText(label)
btn:Show()
else
btn:Hide()
end
end
-- Update placeholder hint for verbs.
local toks = {}
for t in txt:gmatch("%S+") do toks[#toks + 1] = t end
if #toks >= 2 then
local hint = M.VERB_HELP[toks[2]] or ""
f.hint:SetText(hint)
else
f.hint:SetText("addr verb args…")
end
end
local function applySuggestion(label)
local eb = M.editbox
if not eb or not label then return end
local txt = eb:GetText() or ""
-- Replace the trailing partial token with the suggestion + trailing space.
local pre, _ = txt:match("^(.-)(%S*)$")
eb:SetText(pre .. label .. " ")
eb:SetCursorPosition(#eb:GetText())
rebuildSuggestions()
end
local function cycleSuggestion(dir)
local f = M.frame
if not f or not f.suggestionList or #f.suggestionList == 0 then return end
f.suggestionIdx = ((f.suggestionIdx or 0) + dir - 1) % #f.suggestionList + 1
applySuggestion(f.suggestionList[f.suggestionIdx])
end
local function styleEditbox(eb)
eb:SetAutoFocus(false)
eb:SetFontObject("ChatFontNormal")
eb:SetTextInsets(8, 8, 4, 4)
eb:SetMaxLetters(220)
eb:SetScript("OnTextChanged", function() rebuildSuggestions() end)
eb:SetScript("OnTabPressed", function()
if IsShiftKeyDown() then cycleSuggestion(-1) else cycleSuggestion(1) end
end)
eb:SetScript("OnEnterPressed", function(self)
local txt = self:GetText() or ""
if #txt > 0 then dispatch(txt); self:SetText("") end
self:ClearFocus()
end)
eb:SetScript("OnEscapePressed", function(self) self:ClearFocus(); M.Hide() end)
-- Retail WoW EditBox doesn't expose OnUpPressed/OnDownPressed (those
-- are Classic-era scripts and throw "Doesn't have a script" on retail).
-- We observe UP/DOWN via OnKeyDown, which fires for every key while
-- the box has focus. Printable characters still flow into the text
-- normally — OnKeyDown is observation-only — so typing isn't broken.
eb:SetScript("OnKeyDown", function(self, key)
if key == "UP" then
histIdx = histIdx + 1
local h = historyAt(histIdx)
if h then self:SetText(h); self:SetCursorPosition(#h)
else histIdx = histIdx - 1 end
elseif key == "DOWN" then
if histIdx <= 1 then histIdx = 0; self:SetText(""); return end
histIdx = histIdx - 1
local h = historyAt(histIdx)
if h then self:SetText(h); self:SetCursorPosition(#h) end
end
end)
end
function M.Build()
if M.frame then return M.frame end
local f = CreateFrame("Frame", "PlayerbotControlCommands", UIParent, "BackdropTemplate")
f:SetSize(PBC.DB.commands.w or 520, PBC.DB.commands.h or 36)
f:SetPoint(PBC.DB.commands.point or "BOTTOM",
UIParent,
PBC.DB.commands.relPoint or "BOTTOM",
PBC.DB.commands.x or 0,
PBC.DB.commands.y or 200)
f:SetMovable(true)
f:SetClampedToScreen(true)
f:EnableMouse(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function() f:StartMoving() end)
f:SetScript("OnDragStop", function()
f:StopMovingOrSizing()
local p, _, rel, x, y = f:GetPoint()
PBC.DB.commands.point = p
PBC.DB.commands.relPoint = rel
PBC.DB.commands.x = x
PBC.DB.commands.y = y
end)
f:Hide()
if f.SetBackdrop then
f:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
})
f:SetBackdropColor(0.04, 0.04, 0.06, 0.92)
end
-- Prompt > label
f.prompt = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
f.prompt:SetPoint("LEFT", 8, 0)
f.prompt:SetText("|cff66ccff>|r")
-- Editbox
local eb = CreateFrame("EditBox", nil, f)
eb:SetPoint("LEFT", f.prompt, "RIGHT", 4, 0)
eb:SetPoint("RIGHT", -8, 0)
eb:SetHeight(20)
styleEditbox(eb)
M.editbox = eb
-- Hint text overlay (right-aligned, faded)
f.hint = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
f.hint:SetPoint("RIGHT", -10, 0)
f.hint:SetText("addr verb args…")
-- Suggestion strip below the bar.
f.sug = CreateFrame("Frame", nil, f)
f.sug:SetPoint("TOPLEFT", f, "BOTTOMLEFT", 0, -2)
f.sug:SetPoint("TOPRIGHT", f, "BOTTOMRIGHT", 0, -2)
f.sug:SetHeight(22)
local sb = f.sug:CreateTexture(nil, "BACKGROUND")
sb:SetAllPoints()
sb:SetColorTexture(0.08, 0.09, 0.13, 0.85)
f.sugBtns = {}
for i = 1, SUG_MAX do
local b = CreateFrame("Button", nil, f.sug)
b:SetSize(80, 18)
b:SetPoint("LEFT", 4 + (i - 1) * 82, 0)
b:SetNormalFontObject("GameFontHighlightSmall")
b:SetText("…")
b:SetScript("OnClick", function(self) applySuggestion(self:GetText()); eb:SetFocus() end)
local bg = b:CreateTexture(nil, "BACKGROUND")
bg:SetAllPoints()
bg:SetColorTexture(0.15, 0.18, 0.25, 0.7)
b:Hide()
f.sugBtns[i] = b
end
M.frame = f
return f
end
------------------------------------------------------------------ public API
function M.Show()
if not M.frame then M.Build() end
M.frame:Show()
M.editbox:SetFocus()
PBC.DB.commands.shown = true
rebuildSuggestions()
end
function M.Hide()
if M.frame then M.frame:Hide() end
if M.editbox then M.editbox:ClearFocus() end
PBC.DB.commands.shown = false
end
function M.Toggle()
if M.frame and M.frame:IsShown() then M.Hide() else M.Show() end
end
function M.SetText(s)
if not M.editbox then return end
M.editbox:SetText(s or "")
if s then M.editbox:SetCursorPosition(#s) end
end
-- vim: ts=4 sw=4 et
@@ -0,0 +1,450 @@
--[[============================================================================
BotDebug.lua — per-bot detail panel.
Tabs:
[Snapshot] [Intents] [Logs] [Perf]
Snapshot: relevant fields from the server's BOT_DETAIL_RESP (selected, not
the full 800-field snapshot — see V2 snapshot struct refactor).
Intents: last 10 intents fired by this bot with timestamps and outcomes
(via EVENT_PUSH stream filtered by guidLow).
Logs: EVENT_PUSH events for this bot (rolling 50-entry buffer).
Perf: tickperf_ms + recent_intents histogram.
Header has [Pause] [Resume] [Skip Intent] toggle buttons.
============================================================================]]
local PBC = _G.PlayerbotControl
local M = {}
PBC.BotDebug = M
M.frame = nil
M.focus = { guidLow = nil, name = nil, detail = nil }
M.events = {} -- ring buffer; per-guid filtered on render
M.intents = {} -- [guidLow] = { {ts, name, outcome}, ... }
M.MAX_EVENTS_PER_BOT = 50
M.MAX_INTENTS = 10
local TABS = { "Snapshot", "Intents", "Logs", "Perf" }
------------------------------------------------------------------ event ingest
function M.PushEvent(guid, evt, detail, severity)
M.events[#M.events + 1] = {
ts = GetTime(), guid = guid, evt = evt, detail = detail, severity = severity or "info",
}
if #M.events > 4000 then
-- prune oldest 1000 to keep memory bounded for marathon sessions
local keep = {}
for i = 1001, #M.events do keep[#keep + 1] = M.events[i] end
M.events = keep
end
-- Mirror intent_fired/intent_failed into the per-bot intent ring.
if evt == "intent_fired" or evt == "intent_failed" then
M.intents[guid] = M.intents[guid] or {}
local list = M.intents[guid]
list[#list + 1] = { ts = GetTime(), name = detail, outcome = evt }
while #list > M.MAX_INTENTS do table.remove(list, 1) end
end
if M.focus.guidLow == guid then
M.RefreshActiveTab()
end
end
------------------------------------------------------------------ helpers
local function fmtNum(v)
if type(v) ~= "number" then return tostring(v or "-") end
if math.abs(v) >= 1000 then return string.format("%.0f", v) end
if math.abs(v) >= 10 then return string.format("%.1f", v) end
return string.format("%.2f", v)
end
local function fmtAge(ts)
local d = GetTime() - ts
if d < 1 then return "now" end
if d < 60 then return string.format("%ds", math.floor(d)) end
if d < 3600 then return string.format("%dm", math.floor(d/60)) end
return string.format("%dh", math.floor(d/3600))
end
------------------------------------------------------------------ snapshot tab
local SNAPSHOT_ROWS = {
-- displayLabel, snapshot field key (server-side wire field name)
{ "Name", "name" },
{ "Level", "level" },
{ "Class", "class" },
{ "Race", "race" },
{ "Spec", "_spec" }, -- composed (spec_id → name via lookup)
{ "Role", "role" },
{ "Zone", "_zone" }, -- composed (zone_id → name)
{ "Subzone", "_area" }, -- composed (area_id → name)
{ "Indoors", "indoors" },
{ "Map", "map" },
{ "Position", "_pos" }, -- composed
{ "HP", "_hp" }, -- composed
{ "Mana", "_mp" }, -- composed
{ "XP", "_xp" }, -- composed (xp / xp_for_level + rest)
{ "Last Rule", "last_rule"},
{ "Intent Q", "intent_depth" },
}
local function renderSnapshot(detail)
local lines = {}
if not detail then
lines[#lines + 1] = "(no detail yet — request pending)"
return table.concat(lines, "\n")
end
local function composed(key)
if key == "_pos" then
return string.format("(%s, %s, %s)",
fmtNum(detail.pos_x), fmtNum(detail.pos_y), fmtNum(detail.pos_z))
elseif key == "_hp" then
return string.format("%s / %s",
fmtNum(detail.hp), fmtNum(detail.hp_max))
elseif key == "_mp" then
return string.format("%s / %s",
fmtNum(detail.mana), fmtNum(detail.mana_max))
elseif key == "_zone" then
-- C_Map.GetMapInfo expects a UI map ID, not a zone_id; the
-- server sends WoW's zone_id (AreaTable rows whose ParentArea
-- is 0). The client-side C_Map.GetAreaInfo resolves to a
-- name from the same AreaTable table.
local zid = tonumber(detail.zone_id)
if not zid or zid == 0 then return "-" end
if C_Map and C_Map.GetAreaInfo then
local name = C_Map.GetAreaInfo(zid)
if name and name ~= "" then return name end
end
return string.format("zone#%d", zid)
elseif key == "_area" then
local aid = tonumber(detail.area_id)
if not aid or aid == 0 then return "-" end
if C_Map and C_Map.GetAreaInfo then
local name = C_Map.GetAreaInfo(aid)
if name and name ~= "" then return name end
end
return string.format("area#%d", aid)
elseif key == "_spec" then
-- ChrSpecialization DB2 id → name via GetSpecializationInfoByID.
local sid = tonumber(detail.spec_id)
if not sid or sid == 0 then return "-" end
if GetSpecializationInfoByID then
local _, name = GetSpecializationInfoByID(sid)
if name and name ~= "" then return name end
end
return string.format("spec#%d", sid)
elseif key == "_xp" then
local xp = tonumber(detail.xp) or 0
local cap = tonumber(detail.xp_for_level) or 0
local rest = tonumber(detail.rest_xp) or 0
if cap == 0 then return "max-level" end
local pct = (xp * 100) / cap
if rest > 0 then
return string.format("%d / %d (%.0f%%) +%d rest",
xp, cap, pct, rest)
end
return string.format("%d / %d (%.0f%%)", xp, cap, pct)
end
end
for _, row in ipairs(SNAPSHOT_ROWS) do
local label, key = row[1], row[2]
local v
if key:sub(1,1) == "_" then v = composed(key) else v = detail[key] end
lines[#lines + 1] = string.format("|cffaaccff%-12s|r %s",
label, tostring(v or "-"))
end
return table.concat(lines, "\n")
end
------------------------------------------------------------------ intents tab
local function renderIntents()
local guid = M.focus.guidLow
if not guid then return "(no bot focused)" end
local list = M.intents[guid] or {}
if #list == 0 then return "(no intents observed yet)" end
local lines = { "|cffaaccffts outcome intent|r" }
for i = #list, 1, -1 do
local e = list[i]
local color = (e.outcome == "intent_failed") and "|cffff5555" or "|cff66cc66"
lines[#lines + 1] = string.format("%6s %s%-15s|r %s",
fmtAge(e.ts), color, e.outcome, e.name or "?")
end
return table.concat(lines, "\n")
end
------------------------------------------------------------------ logs tab
local function renderLogs()
local guid = M.focus.guidLow
if not guid then return "(no bot focused)" end
local lines = { "|cffaaccffts sev event detail|r" }
local count = 0
for i = #M.events, 1, -1 do
local e = M.events[i]
if e.guid == guid then
local color = "|cffaaaaaa"
if e.severity == "warn" then color = "|cffffcc33" end
if e.severity == "error" then color = "|cffff5050" end
lines[#lines + 1] = string.format("%6s %s%-5s|r %-16s %s",
fmtAge(e.ts), color, e.severity, e.evt, e.detail or "")
count = count + 1
if count >= M.MAX_EVENTS_PER_BOT then break end
end
end
if count == 0 then lines[#lines + 1] = "(no events for this bot)" end
return table.concat(lines, "\n")
end
------------------------------------------------------------------ perf tab
local function renderPerf()
local d = M.focus.detail
if not d then return "(no detail yet)" end
local lines = {}
lines[#lines + 1] = string.format("tick avg %s ms", fmtNum(d.tickperf_ms))
lines[#lines + 1] = string.format("intent %s", tostring(d.intent))
lines[#lines + 1] = string.format("intent_age %s s", fmtNum(d.intent_age))
lines[#lines + 1] = string.format("paused %s", tostring(d.paused == 1 or d.paused == "1"))
if d.recent_intents then
lines[#lines + 1] = ""
lines[#lines + 1] = "|cffaaccffRecent (server view)|r"
lines[#lines + 1] = tostring(d.recent_intents)
end
return table.concat(lines, "\n")
end
------------------------------------------------------------------ tab body refresh
function M.RefreshActiveTab()
if not M.frame or not M.frame:IsShown() then return end
local active = PBC.DB.debugPanel.activeTab or "Snapshot"
local body
if active == "Snapshot" then body = renderSnapshot(M.focus.detail)
elseif active == "Intents" then body = renderIntents()
elseif active == "Logs" then body = renderLogs()
elseif active == "Perf" then body = renderPerf()
end
if M.frame.body then M.frame.body:SetText(body or "") end
-- Header
local f = M.frame
if M.focus.name then
f.header:SetText(string.format("|cffffcc33%s|r (guid=%s)",
M.focus.name, tostring(M.focus.guidLow or "?")))
else
f.header:SetText("|cff999999(no bot focused — click one in the roster)|r")
end
end
------------------------------------------------------------------ tabs
local function activateTab(name)
PBC.DB.debugPanel.activeTab = name
for _, btn in ipairs(M.frame.tabBtns) do
if btn.tabName == name then
btn:LockHighlight()
else
btn:UnlockHighlight()
end
end
M.RefreshActiveTab()
end
------------------------------------------------------------------ build
function M.Build()
if M.frame then return M.frame end
local f = CreateFrame("Frame", "PlayerbotControlDebug", UIParent, "BackdropTemplate")
f:SetSize(PBC.DB.debugPanel.w or 460, PBC.DB.debugPanel.h or 520)
f:SetPoint(PBC.DB.debugPanel.point or "CENTER",
UIParent,
PBC.DB.debugPanel.relPoint or "CENTER",
PBC.DB.debugPanel.x or 200,
PBC.DB.debugPanel.y or 0)
f:SetMovable(true)
f:SetResizable(true)
if f.SetResizeBounds then f:SetResizeBounds(360, 320, 700, 800) end
f:SetClampedToScreen(true)
f:SetFrameStrata("MEDIUM")
f:Hide()
if f.SetBackdrop then
f:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 14,
insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
f:SetBackdropColor(0.05, 0.05, 0.07, 0.96)
end
-- titlebar
f.titlebar = CreateFrame("Frame", nil, f)
f.titlebar:SetPoint("TOPLEFT", 0, 0)
f.titlebar:SetPoint("TOPRIGHT", 0, 0)
f.titlebar:SetHeight(22)
local tb = f.titlebar:CreateTexture(nil, "BACKGROUND")
tb:SetAllPoints()
tb:SetColorTexture(0.10, 0.12, 0.18, 0.92)
f.title = f.titlebar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
f.title:SetPoint("LEFT", 8, 0)
f.title:SetText("Playerbot Debug")
f.closeBtn = CreateFrame("Button", nil, f.titlebar, "UIPanelCloseButton")
f.closeBtn:SetPoint("TOPRIGHT", 0, 0)
f.closeBtn:SetScript("OnClick", function() f:Hide() end)
f.titlebar:EnableMouse(true)
f.titlebar:RegisterForDrag("LeftButton")
f.titlebar:SetScript("OnDragStart", function() f:StartMoving() end)
f.titlebar:SetScript("OnDragStop", function()
f:StopMovingOrSizing()
local p, _, rel, x, y = f:GetPoint()
PBC.DB.debugPanel.point = p
PBC.DB.debugPanel.relPoint = rel
PBC.DB.debugPanel.x = x
PBC.DB.debugPanel.y = y
end)
-- header (active bot indicator)
f.header = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
f.header:SetPoint("TOPLEFT", 10, -28)
f.header:SetPoint("RIGHT", -10, 0)
f.header:SetJustifyH("LEFT")
f.header:SetText("|cff999999(no bot focused)|r")
-- toolbar (pause/resume/skip)
f.toolbar = CreateFrame("Frame", nil, f)
f.toolbar:SetPoint("TOPLEFT", 6, -44)
f.toolbar:SetPoint("TOPRIGHT", -6, -44)
f.toolbar:SetHeight(22)
local function makeBtn(parent, x, text, fn)
local b = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
b:SetSize(70, 20)
b:SetPoint("LEFT", x, 0)
b:SetText(text)
b:SetScript("OnClick", fn)
return b
end
f.pauseBtn = makeBtn(f.toolbar, 0, "Pause", function() if M.focus.name then PBC.Comms.Cmd(M.focus.name, "pause", {}) end end)
f.resumeBtn = makeBtn(f.toolbar, 74, "Resume", function() if M.focus.name then PBC.Comms.Cmd(M.focus.name, "resume", {}) end end)
f.skipBtn = makeBtn(f.toolbar, 148, "Skip", function() if M.focus.name then PBC.Comms.Cmd(M.focus.name, "skip_intent", {}) end end)
f.followBtn = makeBtn(f.toolbar, 222, "Follow Me", function()
if M.focus.name then PBC.Comms.Cmd(M.focus.name, "follow", { UnitName("player") }) end
end)
f.refreshBtn= makeBtn(f.toolbar, 300, "Refresh", function()
if M.focus.guidLow then PBC.Comms.BotDetailReq(M.focus.guidLow) end
end)
-- tab strip
f.tabBtns = {}
local x = 8
for _, name in ipairs(TABS) do
local b = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
b:SetSize(78, 20)
b:SetPoint("TOPLEFT", x, -70)
b:SetText(name)
b.tabName = name
b:SetScript("OnClick", function() activateTab(name) end)
f.tabBtns[#f.tabBtns + 1] = b
x = x + 82
end
-- body scroll
f.scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
f.scroll:SetPoint("TOPLEFT", 8, -94)
f.scroll:SetPoint("BOTTOMRIGHT", -28, 8)
f.bodyHost = CreateFrame("Frame", nil, f.scroll)
f.bodyHost:SetSize(420, 600)
f.scroll:SetScrollChild(f.bodyHost)
f.body = f.bodyHost:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
f.body:SetPoint("TOPLEFT", 4, -2)
f.body:SetPoint("TOPRIGHT", -4, -2)
f.body:SetJustifyH("LEFT")
f.body:SetJustifyV("TOP")
f.body:SetSpacing(2)
f.body:SetWordWrap(true)
f.body:SetFontObject("GameFontHighlightSmall")
-- resize grip
f.resize = CreateFrame("Button", nil, f)
f.resize:SetSize(14, 14)
f.resize:SetPoint("BOTTOMRIGHT", 0, 0)
f.resize:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
f.resize:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
f.resize:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
f.resize:SetScript("OnMouseDown", function() f:StartSizing("BOTTOMRIGHT") end)
f.resize:SetScript("OnMouseUp", function()
f:StopMovingOrSizing()
PBC.DB.debugPanel.w = f:GetWidth()
PBC.DB.debugPanel.h = f:GetHeight()
end)
M.frame = f
M.RegisterHandlers()
activateTab(PBC.DB.debugPanel.activeTab or "Snapshot")
return f
end
------------------------------------------------------------------ protocol handlers
function M.RegisterHandlers()
PBC.Comms.RegisterHandler("BOT_DETAIL_RESP", function(fields, sender)
local d = PBC.Comms.DecodeDetail(fields)
if M.focus.guidLow and tostring(M.focus.guidLow) == tostring(d.guidLow) then
M.focus.detail = d
if d.name then M.focus.name = d.name end
M.RefreshActiveTab()
end
end)
end
------------------------------------------------------------------ focus management
function M.FocusByGuid(guidLow, name)
M.focus.guidLow = tostring(guidLow)
M.focus.name = name
M.focus.detail = nil
PBC.CharDB.focusBotGuid = M.focus.guidLow
PBC.Comms.BotDetailReq(M.focus.guidLow)
M.RefreshActiveTab()
end
function M.FocusByName(name)
if not PBC.BotRoster or not PBC.BotRoster.GetRoster then return end
for _, r in ipairs(PBC.BotRoster.GetRoster()) do
if (r.name or ""):lower() == name:lower() then
M.FocusByGuid(r.guidLow, r.name)
return
end
end
PBC.Warn("bot '%s' not found in roster", name)
end
------------------------------------------------------------------ tick
local refreshAccum = 0
function M.OnTick(elapsed)
if not M.frame or not M.frame:IsShown() then return end
refreshAccum = refreshAccum + elapsed
if refreshAccum >= 2.0 then
refreshAccum = 0
if M.focus.guidLow then PBC.Comms.BotDetailReq(M.focus.guidLow) end
end
end
------------------------------------------------------------------ show/hide
function M.Show()
if not M.frame then M.Build() end
M.frame:Show()
PBC.DB.debugPanel.shown = true
M.RefreshActiveTab()
-- Restore focus from CharDB if we have one and roster is loaded.
if PBC.CharDB.focusBotGuid and not M.focus.guidLow then
M.focus.guidLow = PBC.CharDB.focusBotGuid
PBC.Comms.BotDetailReq(M.focus.guidLow)
end
end
function M.Hide()
if M.frame then M.frame:Hide() end
PBC.DB.debugPanel.shown = false
end
function M.Toggle()
if M.frame and M.frame:IsShown() then M.Hide() else M.Show() end
end
-- vim: ts=4 sw=4 et
@@ -0,0 +1,600 @@
--[[============================================================================
BotRoster.lua — vertical list of owned bots.
Each row shows:
┌────────────────────────────────────────────┐
│ ⚔ Areon L80 Warrior TANK 12y │
│ HP ████████████░░░░░░░░░░ 62% │
│ MP █████░░░░░░░░░░░░░░░░░ 21% (rage) │
│ intent=engage_boss rule=tank_pull │
└────────────────────────────────────────────┘
Right-click → context menu:
online row .... Follow Me / Stop / Engage / Promote / Whisper / Detail /
Pause / Resume / Logout (headless sessions only — the
owner's self-AI char is a real client and never shows it)
offline row ... Log In / Detail (CMD verbs can't reach an offline bot)
Offline rows swap the HP/MP bars for an inline [Log In] button.
Sort: by role (default) | by name | by class | by level | by distance
Filter: showOffline toggle in the footer (offline rows are fetched either
way — ROSTER_REQ flags bit 1 — so toggling is instant/local).
Scrollable via FauxScrollFrame for >20 visible.
Data flow:
* On Show or every 5s while shown: Comms.RosterReq(15)
* On ROSTER_RESP: parse, replace internal roster table, re-layout rows.
* Class color from Blizzard's RAID_CLASS_COLORS.
============================================================================]]
local PBC = _G.PlayerbotControl
local M = {}
PBC.BotRoster = M
M.frame = nil
M.rows = {} -- recycled row pool
M.MAX_ROWS = 14
M.ROW_H = 54
M.roster = {} -- array of bot records, sorted
M.byGuid = {} -- guidLow → record
M.scroll = 0
M.menu = nil
M._pendingRefresh = nil -- GetTime() deadline for a one-shot re-fetch
------------------------------------------------------------------ helpers
local CLASS_COLORS = RAID_CLASS_COLORS or {}
local ROLE_ORDER = { TANK = 1, HEALER = 2, DPS = 3, UNKNOWN = 4 }
local function classColor(class)
local c = CLASS_COLORS[class and class:upper() or ""]
if c then return c.r, c.g, c.b end
return 0.85, 0.85, 0.85
end
local function roleIcon(role)
role = (role or ""):upper()
if role == "TANK" then return "|TInterface\\LFGFrame\\UI-LFG-ICON-PORTRAITROLES:14:14:0:0:64:64:0:19:22:41|t" end
if role == "HEALER" then return "|TInterface\\LFGFrame\\UI-LFG-ICON-PORTRAITROLES:14:14:0:0:64:64:20:39:1:20|t" end
if role == "DPS" or role == "DAMAGE" or role == "DAMAGER" then
return "|TInterface\\LFGFrame\\UI-LFG-ICON-PORTRAITROLES:14:14:0:0:64:64:20:39:22:41|t"
end
return "·"
end
local function fmtDistance(d)
if not d or d < 0 then return "--" end
if d < 99 then return string.format("%dy", math.floor(d)) end
return ">99y"
end
------------------------------------------------------------------ context menu
local function showContextMenu(row, record)
if not M.menu then
M.menu = CreateFrame("Frame", "PBCBotRosterMenu", UIParent, "UIDropDownMenuTemplate")
end
local menu = M.menu
local function fire(addr, verb, args)
PBC.Comms.Cmd(addr, verb, args)
PBC.Print("→ %s %s", addr, verb)
end
-- OFFLINE bots get a minimal menu: CMD verbs route via the bot's live
-- session, so the only meaningful actions are headless login + detail.
if not record.online then
local items = {
{ text = record.name .. " (offline)", isTitle = true, notCheckable = true },
{ text = "Log In", notCheckable = true, func = function()
PBC.Comms.LoginReq(record.name)
PBC.Print("→ login %s", record.name)
M.RequestRefresh(1.5)
end },
{ text = "Detail", notCheckable = true, func = function()
PBC.BotDebug.Show()
PBC.BotDebug.FocusByGuid(record.guidLow, record.name)
end },
}
PBC.OpenMenu(items, row, "MENU")
return
end
local items = {
{ text = record.name, isTitle = true, notCheckable = true },
{ text = "Follow Me", notCheckable = true, func = function() fire(record.name, "follow", { UnitName("player") }) end },
{ text = "Stop", notCheckable = true, func = function() fire(record.name, "stop", {}) end },
{ text = "Engage Target",notCheckable = true, func = function()
local t = UnitName("target")
if t then fire(record.name, "engage_focus", { t })
else PBC.Warn("no target") end
end },
{ text = "Promote", notCheckable = true, hasArrow = true, menuList = {
{ text = "Tank", notCheckable = true, func = function() fire(record.name, "role", { "tank" }) end },
{ text = "Healer", notCheckable = true, func = function() fire(record.name, "role", { "healer" }) end },
{ text = "DPS", notCheckable = true, func = function() fire(record.name, "role", { "dps" }) end },
} },
{ text = "Whisper…", notCheckable = true, func = function()
ChatFrame_OpenChat("/w " .. record.name .. " ")
end },
{ text = "Detail", notCheckable = true, func = function()
PBC.BotDebug.Show()
PBC.BotDebug.FocusByGuid(record.guidLow, record.name)
end },
{ text = "Pause", notCheckable = true, func = function() fire(record.name, "pause", {}) end },
{ text = "Resume", notCheckable = true, func = function() fire(record.name, "resume", {}) end },
}
-- Logout only for HEADLESS sessions (LOGOUT_REQ; the old CMD logout
-- verb never existed server-side). The owner's own self-AI character
-- shows in the roster too — that's a real client session, no Logout.
if record.headless then
items[#items + 1] = { text = "Logout", notCheckable = true, func = function()
PBC.Comms.LogoutReq(record.name)
PBC.Print("→ logout %s", record.name)
M.RequestRefresh(1.5)
end }
end
-- Anchor on the row itself; MenuUtil rejects the "cursor" string and
-- needs a real frame. Visually equivalent — the row is right under
-- the click already so the menu still pops where the user expects.
PBC.OpenMenu(items, row, "MENU")
end
------------------------------------------------------------------ row factory
local function buildRow(parent, index)
local f = CreateFrame("Button", "PBCRosterRow" .. index, parent)
f:SetHeight(M.ROW_H)
f:RegisterForClicks("LeftButtonUp", "RightButtonUp")
f.bg = f:CreateTexture(nil, "BACKGROUND")
f.bg:SetAllPoints()
f.bg:SetColorTexture(0.06, 0.07, 0.09, 0.55)
f.hl = f:CreateTexture(nil, "HIGHLIGHT")
f.hl:SetAllPoints()
f.hl:SetColorTexture(0.2, 0.4, 0.9, 0.25)
-- Layout (54px row, top-to-bottom):
-- y=-4..-18 name + meta (16px line)
-- y=-21..-31 HP bar (10px) with HP% text centered on the bar
-- y=-33..-40 MP bar (7px) — text omitted, color codes the type
-- y=-43..-52 intent + last-rule subtext (10px)
-- 4px top + 4px bottom pads keep the row from kissing its neighbours.
f.name = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
f.name:SetPoint("TOPLEFT", 6, -4)
f.name:SetJustifyH("LEFT")
f.meta = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
f.meta:SetPoint("TOPRIGHT", -6, -4)
f.meta:SetJustifyH("RIGHT")
-- HP bar
f.hp = CreateFrame("StatusBar", nil, f)
f.hp:SetPoint("TOPLEFT", 6, -21)
f.hp:SetPoint("RIGHT", -6, 0)
f.hp:SetHeight(10)
f.hp:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
f.hp:SetStatusBarColor(0.30, 0.85, 0.35)
f.hp:SetMinMaxValues(0, 100)
f.hp.bg = f.hp:CreateTexture(nil, "BACKGROUND")
f.hp.bg:SetAllPoints()
f.hp.bg:SetColorTexture(0, 0, 0, 0.6)
f.hp.text = f.hp:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
f.hp.text:SetPoint("CENTER", 0, 0)
-- MP bar
f.mp = CreateFrame("StatusBar", nil, f)
f.mp:SetPoint("TOPLEFT", 6, -33)
f.mp:SetPoint("RIGHT", -6, 0)
f.mp:SetHeight(7)
f.mp:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
f.mp:SetStatusBarColor(0.25, 0.45, 0.95)
f.mp:SetMinMaxValues(0, 100)
f.mp.bg = f.mp:CreateTexture(nil, "BACKGROUND")
f.mp.bg:SetAllPoints()
f.mp.bg:SetColorTexture(0, 0, 0, 0.6)
-- subtext: intent + rule
f.sub = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
f.sub:SetPoint("BOTTOMLEFT", 6, 4)
f.sub:SetPoint("BOTTOMRIGHT", -6, 4)
f.sub:SetJustifyH("LEFT")
f.sub:SetHeight(11)
-- Inline action button — only visible on OFFLINE rows ("Log In"),
-- where the HP/MP bars carry no information and the context-menu CMD
-- verbs can't reach the bot anyway. Sits where the bars would be.
f.action = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
f.action:SetSize(72, 20)
f.action:SetPoint("RIGHT", -6, -3) -- below the meta line (top 18px)
f.action:SetText("Log In")
f.action:Hide()
f.action:SetScript("OnClick", function(self)
local rec = self:GetParent().record
if not rec or rec.online then return end
PBC.Comms.LoginReq(rec.name)
PBC.Print("→ login %s", rec.name)
M.RequestRefresh(1.5)
end)
f:SetScript("OnClick", function(self, button)
if not self.record then return end
if button == "RightButton" then
showContextMenu(self, self.record)
else
PBC.BotDebug.Show()
PBC.BotDebug.FocusByGuid(self.record.guidLow, self.record.name)
end
end)
f:SetScript("OnEnter", function(self)
if not self.record then return end
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
local r = self.record
GameTooltip:AddLine(r.name)
GameTooltip:AddDoubleLine("Level", tostring(r.level))
GameTooltip:AddDoubleLine("Class", tostring(r.class))
GameTooltip:AddDoubleLine("Role", tostring(r.role))
GameTooltip:AddDoubleLine("Zone", tostring(r.zone))
GameTooltip:AddDoubleLine("Distance", fmtDistance(r.dist))
GameTooltip:AddDoubleLine("Intent", tostring(r.intent))
GameTooltip:AddDoubleLine("Last rule", tostring(r.lastRule))
GameTooltip:Show()
end)
f:SetScript("OnLeave", function() GameTooltip:Hide() end)
return f
end
------------------------------------------------------------------ row population
local function populateRow(rowFrame, record)
rowFrame.record = record
local r, g, b = classColor(record.class)
rowFrame.name:SetText(string.format("%s |cff%02x%02x%02x%s|r L%d",
roleIcon(record.role), r * 255, g * 255, b * 255,
record.name or "?", record.level or 0))
local p = PBC.DB.palette
if not record.online then
local gp = p.offlineGray
rowFrame.bg:SetColorTexture(gp[1] * 0.2, gp[2] * 0.2, gp[3] * 0.2, 0.7)
elseif record.hpPct <= 0 then
local er = p.enemyRed
rowFrame.bg:SetColorTexture(er[1] * 0.25, er[2] * 0.2, er[3] * 0.2, 0.75)
else
rowFrame.bg:SetColorTexture(0.06, 0.07, 0.09, 0.55)
end
rowFrame.meta:SetText(string.format("%s · %s", record.zone or "?", fmtDistance(record.dist)))
if not record.online then
-- Offline: bars carry no information — swap them for the inline
-- "Log In" action and an explicit status subtext.
rowFrame.hp:Hide()
rowFrame.mp:Hide()
rowFrame.action:Show()
rowFrame.sub:SetText("|cff888888offline|r")
rowFrame:Show()
return
end
rowFrame.action:Hide()
rowFrame.hp:Show()
rowFrame.mp:Show()
rowFrame.hp:SetValue(record.hpPct or 0)
if record.hpPct and record.hpPct < 35 then
rowFrame.hp:SetStatusBarColor(0.95, 0.25, 0.25)
elseif record.hpPct and record.hpPct < 65 then
rowFrame.hp:SetStatusBarColor(0.95, 0.80, 0.30)
else
rowFrame.hp:SetStatusBarColor(0.30, 0.85, 0.35)
end
rowFrame.hp.text:SetText(string.format("%d%%", record.hpPct or 0))
rowFrame.mp:SetValue(record.manaPct or 0)
rowFrame.sub:SetText(string.format("|cffaaaaaa%s|r |cff888888%s|r",
record.intent or "-", record.lastRule or "-"))
rowFrame:Show()
end
------------------------------------------------------------------ sorting
local SORTERS = {
role = function(a, b)
local ra = ROLE_ORDER[(a.role or "UNKNOWN"):upper()] or 9
local rb = ROLE_ORDER[(b.role or "UNKNOWN"):upper()] or 9
if ra ~= rb then return ra < rb end
return (a.name or "") < (b.name or "")
end,
name = function(a, b) return (a.name or "") < (b.name or "") end,
class = function(a, b) return (a.class or "") < (b.class or "") end,
level = function(a, b) return (a.level or 0) > (b.level or 0) end,
distance = function(a, b)
local da = a.dist or 1e9; if da < 0 then da = 1e9 end
local db = b.dist or 1e9; if db < 0 then db = 1e9 end
return da < db
end,
}
local function applySort()
local s = SORTERS[(PBC.DB.roster.sortBy or "role")] or SORTERS.role
table.sort(M.roster, s)
end
------------------------------------------------------------------ layout
local function relayout()
if not M.frame or not M.frame:IsShown() then return end
applySort()
-- Apply filter.
local visible = {}
for _, rec in ipairs(M.roster) do
if rec.online or PBC.DB.roster.showOffline then
visible[#visible + 1] = rec
end
end
-- Scroll clamp.
local maxScroll = math.max(0, #visible - M.MAX_ROWS)
if M.scroll > maxScroll then M.scroll = maxScroll end
if M.scroll < 0 then M.scroll = 0 end
-- Place rows.
for i = 1, M.MAX_ROWS do
local row = M.rows[i]
if not row then
row = buildRow(M.frame.list, i)
row:SetPoint("TOPLEFT", M.frame.list, "TOPLEFT", 0, -(i - 1) * M.ROW_H)
row:SetPoint("TOPRIGHT", M.frame.list, "TOPRIGHT", 0, -(i - 1) * M.ROW_H)
M.rows[i] = row
end
local rec = visible[i + M.scroll]
if rec then
populateRow(row, rec)
else
row.record = nil
row:Hide()
end
end
M.frame.count:SetText(string.format("%d / %d", #visible, #M.roster))
if M.frame.scrollbar then
M.frame.scrollbar:SetMinMaxValues(0, maxScroll)
M.frame.scrollbar:SetValue(M.scroll)
end
end
M.Relayout = relayout
------------------------------------------------------------------ build frame
local function styleTitlebar(f)
f.titlebar = CreateFrame("Frame", nil, f)
f.titlebar:SetPoint("TOPLEFT", 0, 0)
f.titlebar:SetPoint("TOPRIGHT", 0, 0)
f.titlebar:SetHeight(22)
local tb = f.titlebar:CreateTexture(nil, "BACKGROUND")
tb:SetAllPoints()
tb:SetColorTexture(0.10, 0.12, 0.18, 0.92)
f.title = f.titlebar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
f.title:SetPoint("LEFT", 8, 0)
f.title:SetText("Playerbot Roster")
f.count = f.titlebar:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
f.count:SetPoint("RIGHT", -32, 0)
f.closeBtn = CreateFrame("Button", nil, f.titlebar, "UIPanelCloseButton")
f.closeBtn:SetPoint("TOPRIGHT", 0, 0)
f.closeBtn:SetScript("OnClick", function() f:Hide() end)
-- Drag handle on titlebar
f.titlebar:EnableMouse(true)
f.titlebar:RegisterForDrag("LeftButton")
f.titlebar:SetScript("OnDragStart", function() f:StartMoving() end)
f.titlebar:SetScript("OnDragStop", function()
f:StopMovingOrSizing()
local p, _, rel, x, y = f:GetPoint()
PBC.DB.roster.point = p
PBC.DB.roster.relPoint = rel
PBC.DB.roster.x = x
PBC.DB.roster.y = y
end)
end
local function styleFooter(f)
f.footer = CreateFrame("Frame", nil, f)
f.footer:SetPoint("BOTTOMLEFT", 0, 0)
f.footer:SetPoint("BOTTOMRIGHT", 0, 0)
f.footer:SetHeight(22)
local fb = f.footer:CreateTexture(nil, "BACKGROUND")
fb:SetAllPoints()
fb:SetColorTexture(0.08, 0.09, 0.13, 0.92)
-- Sort dropdown
f.sortBtn = CreateFrame("Button", nil, f.footer, "UIPanelButtonTemplate")
f.sortBtn:SetSize(80, 18)
f.sortBtn:SetPoint("LEFT", 4, 0)
f.sortBtn:SetText("Sort: role")
f.sortBtn:SetScript("OnClick", function()
local order = { "role", "name", "class", "level", "distance" }
local cur = PBC.DB.roster.sortBy or "role"
local idx = 1
for i, k in ipairs(order) do if k == cur then idx = i; break end end
idx = idx % #order + 1
PBC.DB.roster.sortBy = order[idx]
f.sortBtn:SetText("Sort: " .. order[idx])
relayout()
end)
-- Offline toggle
f.offlineBtn = CreateFrame("CheckButton", nil, f.footer, "UICheckButtonTemplate")
f.offlineBtn:SetSize(18, 18)
f.offlineBtn:SetPoint("LEFT", f.sortBtn, "RIGHT", 6, 0)
f.offlineBtn.text = f.offlineBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
f.offlineBtn.text:SetPoint("LEFT", f.offlineBtn, "RIGHT", 2, 0)
f.offlineBtn.text:SetText("Offline")
f.offlineBtn:SetChecked(PBC.DB.roster.showOffline or false)
f.offlineBtn:SetScript("OnClick", function(self)
PBC.DB.roster.showOffline = self:GetChecked() and true or false
relayout()
end)
-- Refresh
f.refreshBtn = CreateFrame("Button", nil, f.footer, "UIPanelButtonTemplate")
f.refreshBtn:SetSize(70, 18)
f.refreshBtn:SetPoint("RIGHT", -4, 0)
f.refreshBtn:SetText("Refresh")
f.refreshBtn:SetScript("OnClick", function()
PBC.Comms.RosterReq(15)
end)
-- Spawn Alt opens the alt-picker modal. Tucked left of Refresh so the
-- two together form the "manage roster contents" cluster.
f.spawnAltBtn = CreateFrame("Button", nil, f.footer, "UIPanelButtonTemplate")
f.spawnAltBtn:SetSize(86, 18)
f.spawnAltBtn:SetPoint("RIGHT", f.refreshBtn, "LEFT", -4, 0)
f.spawnAltBtn:SetText("Spawn Alt…")
f.spawnAltBtn:SetScript("OnClick", function()
if PBC.BotAlts then PBC.BotAlts.Show() end
end)
end
function M.Build()
if M.frame then return M.frame end
local f = CreateFrame("Frame", "PlayerbotControlRoster", UIParent, "BackdropTemplate")
f:SetSize(PBC.DB.roster.w or 280, PBC.DB.roster.h or 480)
f:SetPoint(PBC.DB.roster.point or "CENTER",
UIParent,
PBC.DB.roster.relPoint or "CENTER",
PBC.DB.roster.x or -300,
PBC.DB.roster.y or 0)
f:SetScale(PBC.DB.roster.scale or 1.0)
f:SetMovable(true)
f:SetResizable(true)
if f.SetResizeBounds then f:SetResizeBounds(220, 200, 480, 800) end
f:SetClampedToScreen(true)
f:SetFrameStrata("MEDIUM")
f:Hide()
if f.SetBackdrop then
f:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 14,
insets = { left = 4, right = 4, top = 4, bottom = 4 },
})
f:SetBackdropColor(0.05, 0.05, 0.07, 0.95)
end
styleTitlebar(f)
styleFooter(f)
-- list area
f.list = CreateFrame("Frame", nil, f)
f.list:SetPoint("TOPLEFT", 4, -24)
f.list:SetPoint("BOTTOMRIGHT", -24, 24)
f.list:EnableMouseWheel(true)
f.list:SetScript("OnMouseWheel", function(_, delta)
M.scroll = M.scroll - delta
relayout()
end)
-- vertical scrollbar
f.scrollbar = CreateFrame("Slider", nil, f, "UIPanelScrollBarTemplate")
f.scrollbar:SetPoint("TOPRIGHT", -6, -40)
f.scrollbar:SetPoint("BOTTOMRIGHT", -6, 40)
f.scrollbar:SetMinMaxValues(0, 0)
f.scrollbar:SetValueStep(1)
f.scrollbar:SetWidth(16)
f.scrollbar:SetScript("OnValueChanged", function(_, v)
M.scroll = math.floor(v + 0.5)
relayout()
end)
-- Resize grip
f.resize = CreateFrame("Button", nil, f)
f.resize:SetSize(14, 14)
f.resize:SetPoint("BOTTOMRIGHT", 0, 0)
f.resize:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
f.resize:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
f.resize:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
f.resize:SetScript("OnMouseDown", function() f:StartSizing("BOTTOMRIGHT") end)
f.resize:SetScript("OnMouseUp", function()
f:StopMovingOrSizing()
PBC.DB.roster.w = f:GetWidth()
PBC.DB.roster.h = f:GetHeight()
end)
M.frame = f
M.RegisterHandlers()
return f
end
------------------------------------------------------------------ protocol handlers
function M.RegisterHandlers()
PBC.Comms.RegisterHandler("ROSTER_RESP", function(fields, sender)
local count = tonumber(fields[1]) or 0
local newRoster, byGuid = {}, {}
for i = 1, count do
local rec = fields[1 + i]
if rec and rec ~= "" then
local r = PBC.Comms.DecodeRosterRecord(rec)
newRoster[#newRoster + 1] = r
byGuid[r.guidLow] = r
-- Remember role for command-bar autocomplete.
if PBC.DB and PBC.DB.knownBots and r.name then
PBC.DB.knownBots[r.name] = r.role or "?"
end
end
end
M.roster = newRoster
M.byGuid = byGuid
relayout()
end)
end
------------------------------------------------------------------ tick / show
local pollAccum = 0
function M.OnTick(elapsed)
if not M.frame or not M.frame:IsShown() then return end
-- One-shot refresh scheduled after a state-changing action
-- (login/logout/spawn) — same pattern as BotAlts._pendingRefresh.
if M._pendingRefresh and GetTime() >= M._pendingRefresh then
M._pendingRefresh = nil
pollAccum = 0
PBC.Comms.RosterReq(15)
return
end
pollAccum = pollAccum + elapsed
if pollAccum >= 3.0 then
pollAccum = 0
PBC.Comms.RosterReq(15)
end
end
-- Schedule a one-shot roster re-fetch `delay` seconds out. Public so other
-- panels (alt picker) can nudge the roster after they change bot state.
function M.RequestRefresh(delay)
M._pendingRefresh = GetTime() + (delay or 1.0)
end
function M.Show()
if not M.frame then M.Build() end
M.frame:Show()
PBC.DB.roster.shown = true
PBC.Comms.RosterReq(15)
relayout()
end
function M.Hide()
if M.frame then M.frame:Hide(); PBC.DB.roster.shown = false end
end
function M.Toggle()
if M.frame and M.frame:IsShown() then M.Hide() else M.Show() end
end
function M.GetRoster() return M.roster end
function M.GetByGuid(g) return M.byGuid[g] end
function M.GetNames()
local out = {}
for _, r in ipairs(M.roster) do out[#out + 1] = r.name end
return out
end
-- vim: ts=4 sw=4 et
@@ -0,0 +1,172 @@
--[[============================================================================
BotStats.lua — fleet dashboard, top of screen.
Layout (560 x 56):
┌──────────────────────────────────────────────────────────────────────────┐
│ Bots 412/2000 T 38 H 52 D 322 Wedged 2 intents/s 184 ▂▄▅▆▇▆▅▄▂▁ │
│ Tick 24.7/33.3 ms [████████░░░░░░░░░░] 74% budget │
└──────────────────────────────────────────────────────────────────────────┘
Sparkline samples last 30 STATS_RESP frames (over ~150s at 5s cadence).
Colors:
tick used green <70% yellow <90% red ≥90%
wedged > 0 always shown in red badge.
============================================================================]]
local PBC = _G.PlayerbotControl
local M = {}
PBC.BotStats = M
M.frame = nil
M.spark = { ips = {}, max = 1, len = 30 } -- intents-per-second history
local function pushSample(v)
table.insert(M.spark.ips, v)
while #M.spark.ips > M.spark.len do table.remove(M.spark.ips, 1) end
local mx = 1
for _, s in ipairs(M.spark.ips) do if s > mx then mx = s end end
M.spark.max = mx
end
------------------------------------------------------------------ sparkline rendering
local BARS = { "▁","▂","▃","▄","▅","▆","▇","█" }
local function sparkString()
if #M.spark.ips == 0 then return "" end
local out, mx = {}, M.spark.max
for _, s in ipairs(M.spark.ips) do
local frac = (mx > 0) and (s / mx) or 0
local idx = math.max(1, math.min(#BARS, math.floor(frac * (#BARS - 1) + 1)))
out[#out + 1] = BARS[idx]
end
return table.concat(out)
end
------------------------------------------------------------------ render
local function colorForTickPct(pct)
if pct >= 0.90 then return "|cffff5050" end
if pct >= 0.70 then return "|cffffcc33" end
return "|cff66cc66"
end
local function render(stats)
local f = M.frame; if not f then return end
f.line1:SetText(string.format(
"|cffffffffBots|r %d/%d |cff66ccffT|r %d |cffaa66ffH|r %d |cffff7766D|r %d " ..
"%s intents/s %d %s",
stats.online or 0, stats.total or 0,
stats.tanks or 0, stats.healers or 0, stats.dps or 0,
((stats.wedged or 0) > 0)
and string.format("|cffff5050Wedged %d|r", stats.wedged)
or "|cff66cc66Wedged 0|r",
stats.intents_per_sec or 0,
sparkString()))
local used = stats.tick_used_ms or 0
local budget = stats.tick_budget_ms or 33.3
local pct = (budget > 0) and (used / budget) or 0
local color = colorForTickPct(pct)
local barW = math.floor(math.max(0, math.min(1, pct)) * 20)
local bar = string.rep("█", barW) .. string.rep("░", 20 - barW)
f.line2:SetText(string.format(
"|cffffffffTick|r %s%.1f|r/%.1f ms %s[%s]|r %d%% budget %s",
color, used, budget, color, bar, math.floor(pct * 100 + 0.5),
stats.extra or ""))
end
------------------------------------------------------------------ build
function M.Build()
if M.frame then return M.frame end
local f = CreateFrame("Frame", "PlayerbotControlStats", UIParent, "BackdropTemplate")
f:SetSize(PBC.DB.stats.w or 460, PBC.DB.stats.h or 56)
f:SetPoint(PBC.DB.stats.point or "TOP",
UIParent,
PBC.DB.stats.relPoint or "TOP",
PBC.DB.stats.x or 0,
PBC.DB.stats.y or -8)
f:SetMovable(true)
f:SetClampedToScreen(true)
f:EnableMouse(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function() f:StartMoving() end)
f:SetScript("OnDragStop", function()
f:StopMovingOrSizing()
local p, _, rel, x, y = f:GetPoint()
PBC.DB.stats.point = p
PBC.DB.stats.relPoint = rel
PBC.DB.stats.x = x
PBC.DB.stats.y = y
end)
f:Hide()
if f.SetBackdrop then
f:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
})
f:SetBackdropColor(0.04, 0.04, 0.06, 0.92)
end
f.line1 = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
f.line1:SetPoint("TOPLEFT", 8, -6)
f.line1:SetPoint("RIGHT", -8, 0)
f.line1:SetJustifyH("LEFT")
f.line1:SetText("Bots …")
f.line2 = f:CreateFontString(nil, "OVERLAY", "GameFontDisableSmall")
f.line2:SetPoint("TOPLEFT", 8, -28)
f.line2:SetPoint("RIGHT", -8, 0)
f.line2:SetJustifyH("LEFT")
f.line2:SetText("Tick …")
-- Mini close button on hover
f.closeBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton")
f.closeBtn:SetSize(20, 20)
f.closeBtn:SetPoint("TOPRIGHT", 0, 0)
f.closeBtn:SetScript("OnClick", function() M.Hide() end)
f.closeBtn:SetAlpha(0.2)
f:SetScript("OnEnter", function() f.closeBtn:SetAlpha(1) end)
f:SetScript("OnLeave", function() f.closeBtn:SetAlpha(0.2) end)
M.frame = f
M.RegisterHandlers()
return f
end
------------------------------------------------------------------ protocol handlers
function M.RegisterHandlers()
PBC.Comms.RegisterHandler("STATS_RESP", function(fields, sender)
local s = PBC.Comms.DecodeStats(fields)
pushSample(s.intents_per_sec or 0)
render(s)
M.lastStats = s
end)
end
------------------------------------------------------------------ tick (idle render)
function M.OnTick(elapsed)
-- If we have no recent sample, re-render with stale to keep sparkline alive.
if M.lastStats and M.frame and M.frame:IsShown() then
-- No-op: STATS_RESP drives the redraw. We could decay here in future.
end
end
------------------------------------------------------------------ show/hide
function M.Show()
if not M.frame then M.Build() end
M.frame:Show()
PBC.DB.stats.shown = true
PBC.Comms.StatsReq()
end
function M.Hide()
if M.frame then M.frame:Hide() end
PBC.DB.stats.shown = false
end
function M.Toggle()
if M.frame and M.frame:IsShown() then M.Hide() else M.Show() end
end
-- vim: ts=4 sw=4 et
@@ -0,0 +1,428 @@
--[[============================================================================
BotToolbar.lua — click-to-fire group quick-action bar.
Layout:
┌─ Addr: [ all ▾ ] ────────────────────────────────────────────────┐
│ [Follow][Stop][Come][Hold][Engage][Assist][Rez] │
│ [Tight][Spread][Line][Wedge][Mount][Dismount][Hearth] │
│ [Repair][Sell][Loot][Buff] │
│ [Mark ▸][BG ▸][LFG ▸][Pause][Resume][Login][Logout][Dismiss] │
└──────────────────────────────────────────────────────────────────┘
Click → Comms.Cmd(<currentAddr>, <verb>, args). The address dropdown
cycles all / squad / tank / healer / dps / mage / warrior / … so a single
click fires the same intent the textual command bar produces. The right-
most submenu buttons (Mark / BG / LFG) open a small popup of icons/queues.
Verbs include both "obvious" group orders and a handful that previously
needed typed input (Hearth, Repair, Sell, Dismiss). Every verb here is
already accepted by BotCommandParser — the toolbar is pure UI sugar.
Two ways the user wins by clicking instead of typing:
1. Fewer chances to fat-finger a verb name (e.g. `engage_focus`).
2. Address picker is impossible to typo: dropdown enforces a known set.
Position is persisted via PBC.DB.toolbar (point/relPoint/x/y/shown).
============================================================================]]
local PBC = _G.PlayerbotControl
local M = {}
PBC.BotToolbar = M
M.frame = nil
M.addressBtn = nil
M.selfBtn = nil
M.selfAttached = nil -- nil = unknown, true/false once SELF_RESP arrives
------------------------------------------------------------------ vocab
M.ADDRESSES = {
"all", "squad", "tank", "healer", "dps",
"warrior", "paladin", "hunter", "rogue", "priest",
"deathknight", "shaman", "mage", "warlock", "monk",
"druid", "demonhunter", "evoker",
}
M.MARK_ICONS = { "skull", "cross", "star", "circle",
"moon", "diamond", "square", "triangle" }
M.BG_TYPES = { "wsg", "ab", "av", "eots", "sota", "ioc", "bg",
"tp", "kotmogu", "dg", "ss", "tk", "ashran",
"wint", "seething" }
-- LFG dungeon shortcuts; empty arg dispatches "any random dungeon" path
-- on the server (verb without name in BotCommandParser).
M.LFG_HINTS = { "", "deadmines", "stockades", "wailing-caverns",
"scarlet-monastery", "uldaman", "blackrock-depths",
"scholomance", "stratholme", "hellfire-ramparts",
"utgarde-keep", "halls-of-stone", "blackrock-caverns" }
-- "Quick" verbs that fire instantly on click — no submenu.
-- Each entry is { label, verb, optional argsBuilder(addr) -> table }.
M.QUICK = {
-- movement / posture
{ label = "Follow", verb = "follow",
args = function() return { UnitName("player") } end },
{ label = "Stop", verb = "stop" },
{ label = "Come", verb = "come" },
{ label = "Hold", verb = "hold" },
{ label = "Tight", verb = "form", args = function() return { "tight" } end },
{ label = "Spread", verb = "form", args = function() return { "spread" } end },
{ label = "Line", verb = "form", args = function() return { "line" } end },
{ label = "Wedge", verb = "form", args = function() return { "wedge" } end },
-- combat
{ label = "Engage", verb = "engage_focus",
args = function() return { UnitName("target") } end,
needsTarget = true },
{ label = "Assist", verb = "assist",
args = function() return { UnitName("target") } end,
needsTarget = true },
{ label = "Pull", verb = "pull" },
{ label = "Rez", verb = "ghost_res" },
-- utility
{ label = "Mount", verb = "mount" },
{ label = "Dismount", verb = "dismount" },
{ label = "Hearth", verb = "use_hearth" },
{ label = "Repair", verb = "repair" },
{ label = "Sell", verb = "sell", args = function() return { "trash" } end },
{ label = "Loot", verb = "loot_roll" },
{ label = "Buff", verb = "buff" },
-- queues
{ label = "BG Leave", verb = "bg_leave" },
{ label = "LFG Leave",verb = "lfg_leave" },
{ label = "Ready", verb = "ready" },
-- meta
{ label = "Pause", verb = "pause" },
{ label = "Resume", verb = "resume" },
-- Login/Logout are NOT CMD verbs — they ride dedicated LOGIN_REQ /
-- LOGOUT_REQ frames (offline bots have no session for CMD to reach).
-- PBC.RequestLogin/Logout expand the current address against the last
-- roster snapshot and fire one frame per matching character.
{ label = "Login", custom = function(addr) PBC.RequestLogin(addr) end },
{ label = "Logout", custom = function(addr) PBC.RequestLogout(addr) end },
{ label = "Dismiss", verb = "dismiss" },
}
------------------------------------------------------------------ DB defaults
local function db()
PBC.DB.toolbar = PBC.DB.toolbar or {
point = "BOTTOM", relPoint = "BOTTOM", x = 0, y = 240,
shown = false, address = "all",
}
return PBC.DB.toolbar
end
------------------------------------------------------------------ address selector
local function setAddress(addr)
db().address = addr
if M.addressBtn then M.addressBtn:SetText("Addr: " .. addr) end
end
local function cycleAddress(dir)
dir = dir or 1
local cur = db().address or "all"
local idx = 1
for i, a in ipairs(M.ADDRESSES) do
if a == cur then idx = i; break end
end
idx = ((idx - 1 + dir) % #M.ADDRESSES) + 1
setAddress(M.ADDRESSES[idx])
end
local function showAddressMenu(anchor)
local menu = M._addressMenu
if not menu then
menu = CreateFrame("Frame", "PBCToolbarAddrMenu", UIParent,
"UIDropDownMenuTemplate")
M._addressMenu = menu
end
local items = {}
for _, addr in ipairs(M.ADDRESSES) do
items[#items + 1] = {
text = addr, notCheckable = true,
func = function() setAddress(addr) end,
}
end
PBC.OpenMenu(items, anchor, "MENU")
end
------------------------------------------------------------------ click → CMD
local function fire(verb, args)
local addr = db().address or "all"
PBC.Comms.Cmd(addr, verb, args or {})
if args and #args > 0 then
PBC.Print("→ %s %s %s", addr, verb, table.concat(args, " "))
else
PBC.Print("→ %s %s", addr, verb)
end
end
------------------------------------------------------------------ submenus
local function showMarkMenu(anchor)
local menu = M._markMenu
if not menu then
menu = CreateFrame("Frame", "PBCToolbarMarkMenu", UIParent,
"UIDropDownMenuTemplate")
M._markMenu = menu
end
local items = { { text = "Mark target", isTitle = true,
notCheckable = true } }
for _, icon in ipairs(M.MARK_ICONS) do
items[#items + 1] = {
text = icon, notCheckable = true,
func = function()
local tgt = UnitName("target")
if not tgt then PBC.Warn("no target to mark"); return end
fire("mark", { icon, tgt })
end,
}
end
PBC.OpenMenu(items, anchor, "MENU")
end
local function showBgMenu(anchor)
local menu = M._bgMenu
if not menu then
menu = CreateFrame("Frame", "PBCToolbarBgMenu", UIParent,
"UIDropDownMenuTemplate")
M._bgMenu = menu
end
local items = { { text = "Queue BG", isTitle = true, notCheckable = true } }
for _, b in ipairs(M.BG_TYPES) do
items[#items + 1] = {
text = b, notCheckable = true,
func = function() fire("bg_queue", { b }) end,
}
end
items[#items + 1] = {
text = "Leave BG queue", notCheckable = true,
func = function() fire("bg_leave") end,
}
PBC.OpenMenu(items, anchor, "MENU")
end
local function showLfgMenu(anchor)
local menu = M._lfgMenu
if not menu then
menu = CreateFrame("Frame", "PBCToolbarLfgMenu", UIParent,
"UIDropDownMenuTemplate")
M._lfgMenu = menu
end
local items = { { text = "Queue LFG", isTitle = true, notCheckable = true } }
for _, d in ipairs(M.LFG_HINTS) do
local lbl = (d == "") and "(random)" or d
items[#items + 1] = {
text = lbl, notCheckable = true,
func = function()
if d == "" then fire("lfg_queue")
else fire("lfg_queue", { d }) end
end,
}
end
items[#items + 1] = {
text = "Leave LFG queue", notCheckable = true,
func = function() fire("lfg_leave") end,
}
PBC.OpenMenu(items, anchor, "MENU")
end
------------------------------------------------------------------ button factory
local BTN_W = 64
local BTN_H = 20
local BTN_GAP = 2
local PAD_X = 6
local PAD_TOP = 22
local ROW_GAP = 2
local function makeButton(parent, label, onClick)
local b = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
b:SetSize(BTN_W, BTN_H)
b:SetText(label)
b:SetScript("OnClick", onClick)
return b
end
------------------------------------------------------------------ layout (wrap)
local function flow(f)
-- Adjust frame width to whatever's available, then flow buttons left→
-- right top→bottom. Wrap when next button would exceed `usableW`.
local usableW = f:GetWidth() - 2 * PAD_X
local x, y = PAD_X, -PAD_TOP
for _, b in ipairs(f._buttons) do
if x + BTN_W > PAD_X + usableW then
x = PAD_X
y = y - (BTN_H + ROW_GAP)
end
b:ClearAllPoints()
b:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
x = x + BTN_W + BTN_GAP
end
-- Resize height to hold all rows.
f:SetHeight(-y + BTN_H + 6)
end
------------------------------------------------------------------ build
function M.Build()
if M.frame then return M.frame end
local f = CreateFrame("Frame", "PlayerbotControlToolbar", UIParent,
"BackdropTemplate")
f:SetSize(720, 96)
local d = db()
f:SetPoint(d.point or "BOTTOM", UIParent,
d.relPoint or "BOTTOM", d.x or 0, d.y or 240)
f:SetMovable(true)
f:EnableMouse(true)
f:SetClampedToScreen(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function() f:StartMoving() end)
f:SetScript("OnDragStop", function()
f:StopMovingOrSizing()
local p, _, rel, x, y = f:GetPoint()
d.point = p; d.relPoint = rel; d.x = x; d.y = y
end)
f:Hide()
if f.SetBackdrop then
f:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true, tileSize = 16, edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
})
f:SetBackdropColor(0.04, 0.04, 0.06, 0.92)
end
-- Titlebar with drag handle + address picker + close
f.titlebar = CreateFrame("Frame", nil, f)
f.titlebar:SetPoint("TOPLEFT", 0, 0)
f.titlebar:SetPoint("TOPRIGHT", 0, 0)
f.titlebar:SetHeight(20)
local tb = f.titlebar:CreateTexture(nil, "BACKGROUND")
tb:SetAllPoints()
tb:SetColorTexture(0.10, 0.12, 0.18, 0.92)
f.title = f.titlebar:CreateFontString(nil, "OVERLAY", "GameFontNormal")
f.title:SetPoint("LEFT", 6, 0)
f.title:SetText("Quick Orders")
M.addressBtn = CreateFrame("Button", nil, f.titlebar,
"UIPanelButtonTemplate")
M.addressBtn:SetSize(110, 18)
M.addressBtn:SetPoint("LEFT", f.title, "RIGHT", 12, 0)
M.addressBtn:SetText("Addr: " .. (d.address or "all"))
M.addressBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
M.addressBtn:SetScript("OnClick", function(self, button)
if button == "RightButton" then cycleAddress(-1)
elseif IsShiftKeyDown() then cycleAddress(1)
else showAddressMenu(self) end
end)
f.closeBtn = CreateFrame("Button", nil, f.titlebar,
"UIPanelCloseButton")
f.closeBtn:SetPoint("TOPRIGHT", 0, 0)
f.closeBtn:SetScript("OnClick", function() M.Hide() end)
-- Self-AI toggle (right of the address picker). Three visible states:
-- "Self: ?" — unknown, waiting on first SELF_RESP from server
-- "Self: OFF" — AI not attached; click flips to on
-- "Self: ON" — AI driving the caller's character; click flips off
-- The button is the only widget here that touches the caller's OWN
-- char rather than the bot fleet, so it sits next to the address
-- picker for visual continuity ("scope of effect" cluster).
M.selfBtn = CreateFrame("Button", nil, f.titlebar, "UIPanelButtonTemplate")
M.selfBtn:SetSize(96, 18)
M.selfBtn:SetPoint("LEFT", M.addressBtn, "RIGHT", 8, 0)
M.selfBtn:SetText("Self: ?")
M.selfBtn:SetScript("OnClick", function()
if M.selfAttached == nil then
-- Don't toggle blind; query state first then let user re-click.
PBC.Comms.Self("status")
PBC.Print("querying self-AI state…")
return
end
if M.selfAttached then PBC.Comms.Self("off")
else PBC.Comms.Self("on") end
end)
M.selfBtn:SetScript("OnEnter", function(self)
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
GameTooltip:AddLine("Self-AI")
GameTooltip:AddLine(
"Toggle the PlayerbotV2 AI on your own character.\n" ..
"Same as `.playerbot self on` / `off`.\n" ..
"Your client input keeps working — pressing a movement key " ..
"interrupts the AI's move intents.", 1, 1, 1, true)
GameTooltip:Show()
end)
M.selfBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
-- Buttons
f._buttons = {}
for _, q in ipairs(M.QUICK) do
local b = makeButton(f, q.label, function()
if q.custom then
q.custom(db().address or "all")
return
end
local args
if q.args then
args = q.args()
if q.needsTarget and (not args[1] or args[1] == "") then
PBC.Warn("%s needs a target", q.label); return
end
end
fire(q.verb, args)
end)
f._buttons[#f._buttons + 1] = b
end
-- Submenu buttons
local markBtn = makeButton(f, "Mark ▸", function(self) showMarkMenu(self) end)
local bgBtn = makeButton(f, "BG ▸", function(self) showBgMenu(self) end)
local lfgBtn = makeButton(f, "LFG ▸", function(self) showLfgMenu(self) end)
f._buttons[#f._buttons + 1] = markBtn
f._buttons[#f._buttons + 1] = bgBtn
f._buttons[#f._buttons + 1] = lfgBtn
flow(f)
M.frame = f
M.RegisterHandlers()
return f
end
------------------------------------------------------------------ protocol
function M.RegisterHandlers()
PBC.Comms.RegisterHandler("SELF_RESP", function(fields, _sender)
-- Fields are key=value strings; ALTS_RESP-style decode.
local kv = {}
for _, f in ipairs(fields) do
local k, v = f:match("^([^=]+)=(.*)$")
if k then kv[k] = v end
end
local attached = (kv.attached == "1")
M.selfAttached = attached
if M.selfBtn then
M.selfBtn:SetText(attached and "Self: ON" or "Self: OFF")
end
end)
end
------------------------------------------------------------------ public
function M.Show()
if not M.frame then M.Build() end
M.frame:Show()
db().shown = true
-- Refresh the Self toggle state so the button shows server truth.
PBC.Comms.Self("status")
end
function M.Hide()
if M.frame then M.frame:Hide() end
db().shown = false
end
function M.Toggle()
if M.frame and M.frame:IsShown() then M.Hide() else M.Show() end
end
function M.SetAddress(addr) setAddress(addr) end
-- vim: ts=4 sw=4 et
@@ -0,0 +1,633 @@
--[[============================================================================
Comms.lua — PlayerbotControl ↔ PlayerbotV2 server wire protocol
----------------------------------------------------------------------------
PROTOCOL OVERVIEW
----------------------------------------------------------------------------
Transport ............ CHAT_MSG_ADDON / C_ChatInfo.SendAddonMessage
Prefix ............... "PBC" (must be registered both client & server)
Channel .............. "WHISPER" for owner<->bot, "GUILD" for fleet broadcast.
We default to WHISPER → owner's main character; the
server-side handler intercepts addon-whispers whose
target is a managed bot (or the magic name "PBCFLEET")
and routes them through PlayerbotV2 plumbing.
Max payload .......... 255 bytes per chunk (Blizzard hard limit on the
message body field). We chunk over 240 bytes and
reassemble using sequence numbers.
Encoding ............. ASCII text, pipe-delimited fields. JSON was rejected
because (a) Blizzard's chat layer mangles backslashes
and quotes and (b) per-frame budget matters: 60-bot
roster broadcast is ~3.6KB of frames.
----------------------------------------------------------------------------
WIRE FRAME (every PBC payload, before chunking)
----------------------------------------------------------------------------
v|t|seq|tot|MTYPE|f1|f2|...|fN
^ ^ ^ ^ ^
| | | | +-- message type (uppercase, see table below)
| | | +------- total chunks for this logical message (1 if not chunked)
| | +----------- 1-based chunk index
| +-------------- transaction id (uint32, hex) — echoed in replies
+---------------- protocol version, currently "1"
Fields are escaped: literal "|" becomes "\p", literal "\" becomes "\\".
Use Comms.escape / Comms.unescape — never concat raw.
----------------------------------------------------------------------------
MESSAGE TYPES
----------------------------------------------------------------------------
Owner → Server:
ROSTER_REQ <flags>
flags: bitmask. 1=include offline, 2=include intents,
4=include hp/mana, 8=include distance
BOT_DETAIL_REQ <botGuidLow> uint32 low-half of GUID, decimal
CMD <addr>|<verb>|<arg1>|...|<argN>
addr: "all" | "squad" | "tank" | "healer" | "dps" |
"mage" | "warlock" | ... | "<botName>"
verb: follow | stop | engage | engage_focus | hold |
squad | role | mark | login | logout | promote |
whisper | pause | resume
STATS_REQ (no args)
ALTS_REQ (no args) — list same-account characters so the
addon can show a Spawn picker. Allowed for accounts
with zero owned bots (first-time-spawn path).
SUMMON <charName> spawn one of caller's alts as a bot;
server responds via EVENT_PUSH (info=summoned /
warn=summon_failed). Allowed for accounts with zero
owned bots — first-spawn bootstraps fleet ownership.
LOGIN_REQ <charName> headless-login an OFFLINE character the
caller is authorized for (same-account alt OR a bot
owned by the caller's account). Server responds via
EVENT_PUSH (info=login_submitted / warn=login_failed).
LOGOUT_REQ <charName> log out a HEADLESS bot session. Real
client sessions are always refused — only sessions
driven by the server's BotSessionMgr can be kicked.
EVENT_PUSH (info=logged_out / warn=logout_failed).
SELF on|off|status toggle V2 AI on caller's OWN
character. Server replies SELF_RESP|attached=…|
marked=… with the post-operation state.
ACK <originSeq> client acks a server EVENT_PUSH
Server → Owner:
ROSTER_RESP <count>|<bot1>|<bot2>|...
each bot: guidLow,name,level,class,race,role,zone,
online,hpPct,manaPct,dist,intent,lastRule,
groupId,spec,headless
comma-separated within the bot record so we can
splice safely without losing the outer pipe layout.
headless=1 ⇔ the online session is a server-side
BotSession (Logout offered); 0 for offline rows and
for the owner's own self-AI character.
BOT_DETAIL_RESP <guidLow>|<key=value>|<key=value>|...
keys: name,level,class,spec,role,zone,subzone,
pos_x,pos_y,pos_z,map,group_id,leader,
hp,hp_max,mana,mana_max,power_type,
intent,intent_age,last_rule,paused,
tickperf_ms,recent_intents (json-ish list)
STATS_RESP total|online|tanks|healers|dps|intents_per_sec|
tick_budget_ms|tick_used_ms|wedged|extra=k=v,k=v
EVENT_PUSH <severity>|<botGuid>|<event>|<detail>
severity: info|warn|error
event: died|intent_failed|wedge|level_up|loot|whisper|
aggro|bg_end|dungeon_end|charter_signed
PONG <serverTimeMs>
----------------------------------------------------------------------------
RETRY / RELIABILITY
----------------------------------------------------------------------------
* Owner → server requests carry a sequence id. If no reply arrives within
2.5s we retransmit up to 3 times before surfacing a "server stalled"
warning to the BotStats panel.
* Server → owner EVENT_PUSH frames carry a seq the owner ACKs. The server
spool keeps unacked events for 10s and resends once.
* STATS_RESP is broadcast every 5s when any PBC UI frame is visible
(the addon sends an implicit STATS_REQ every 5s while shown). Hidden
UI suppresses polling — important at 2000-bot fleet scale where every
KB of upload matters.
============================================================================]]
local _ADDON, NS = ...
NS = NS or {}
_G.PlayerbotControl = _G.PlayerbotControl or {}
local PBC = _G.PlayerbotControl
------------------------------------------------------------------ constants
PBC.PREFIX = "PBC"
PBC.PROTO_VERSION = "1"
PBC.MAX_CHUNK_BYTES = 240 -- Blizz hard cap is 255; leave envelope room.
PBC.RETRY_TIMEOUT = 2.5
PBC.RETRY_MAX = 3
PBC.STATS_INTERVAL = 5.0
PBC.FLEET_TARGET = "PBCFLEET" -- magic whisper target intercepted server-side
------------------------------------------------------------------ module state
local Comms = {}
PBC.Comms = Comms
Comms._seq = 0
Comms._pending = {} -- [seq] = { msg, when, tries, onReply, onTimeout }
Comms._rxAssembly = {} -- [seq] = { total, parts = { [i] = str } }
Comms._handlers = {} -- [MTYPE] = function(fields, sender)
Comms._eventListeners = {} -- generic { fn = fn, owner = label }
------------------------------------------------------------------ logging
local function dbg(fmt, ...)
if PBC.DB and PBC.DB.debug then
print("|cff66ccff[PBC]|r " .. string.format(fmt, ...))
end
end
Comms.dbg = dbg
------------------------------------------------------------------ escape helpers
function Comms.escape(s)
if type(s) ~= "string" then s = tostring(s or "") end
s = s:gsub("\\", "\\\\")
s = s:gsub("|", "\\p")
return s
end
function Comms.unescape(s)
if type(s) ~= "string" then return s end
-- Decode in a single pass to avoid \\p ambiguity.
local out, i, n = {}, 1, #s
while i <= n do
local c = s:sub(i, i)
if c == "\\" and i < n then
local nx = s:sub(i + 1, i + 1)
if nx == "p" then
out[#out + 1] = "|"; i = i + 2
elseif nx == "\\" then
out[#out + 1] = "\\"; i = i + 2
else
out[#out + 1] = nx; i = i + 2
end
else
out[#out + 1] = c; i = i + 1
end
end
return table.concat(out)
end
------------------------------------------------------------------ split fields
local function splitPipes(s)
local fields, last = {}, 1
local n = #s
local i = 1
while i <= n do
local c = s:sub(i, i)
if c == "\\" and i < n then
-- skip the escape sequence — they're processed during unescape
i = i + 2
elseif c == "|" then
fields[#fields + 1] = Comms.unescape(s:sub(last, i - 1))
last = i + 1
i = i + 1
else
i = i + 1
end
end
fields[#fields + 1] = Comms.unescape(s:sub(last))
return fields
end
Comms.splitPipes = splitPipes
------------------------------------------------------------------ seq + transmit
local function nextSeq()
Comms._seq = (Comms._seq + 1) % 0xFFFFFFFF
if Comms._seq == 0 then Comms._seq = 1 end
return Comms._seq
end
-- Build a logical frame body. Caller passes already-escaped fields.
local function buildFrame(seq, mtype, fields)
local body = table.concat(fields, "|")
-- We'll fill seq/total per chunk in chunkSend.
return string.format("%s|%08x|MTYPE|%s|%s",
PBC.PROTO_VERSION, seq, mtype, body)
end
-- Chunk a payload to <= MAX_CHUNK_BYTES.
local function chunkPayload(payload)
local max = PBC.MAX_CHUNK_BYTES
if #payload <= max then return { payload } end
local out, i = {}, 1
while i <= #payload do
out[#out + 1] = payload:sub(i, i + max - 1)
i = i + max
end
return out
end
-- Send a raw text payload (handles chunking transparently).
local function sendRaw(target, channel, version, seq, mtype, escapedFields)
-- Pre-build full body, then split.
local body = table.concat(escapedFields, "|")
local chunks = chunkPayload(body)
local total = #chunks
for idx, chunk in ipairs(chunks) do
local frame = string.format("%s|%08x|%d|%d|%s|%s",
version, seq, idx, total, mtype, chunk)
if C_ChatInfo and C_ChatInfo.SendAddonMessage then
C_ChatInfo.SendAddonMessage(PBC.PREFIX, frame, channel, target)
elseif SendAddonMessage then
SendAddonMessage(PBC.PREFIX, frame, channel, target)
end
end
end
------------------------------------------------------------------ public API
-- target: a bot name (whisper) or PBC.FLEET_TARGET to broadcast through fleet
-- channel: usually "WHISPER"; "GUILD" or "PARTY" supported by Blizzard
function Comms.Send(target, channel, mtype, fields, opts)
opts = opts or {}
channel = channel or "WHISPER"
fields = fields or {}
local escaped = {}
for i, v in ipairs(fields) do escaped[i] = Comms.escape(v) end
local seq = nextSeq()
sendRaw(target, channel, PBC.PROTO_VERSION, seq, mtype, escaped)
if opts.expectReply or opts.onReply then
Comms._pending[seq] = {
target = target, channel = channel, mtype = mtype,
escaped = escaped, when = GetTime(), tries = 0,
onReply = opts.onReply, onTimeout = opts.onTimeout,
}
end
dbg("→ %s [%s] seq=%08x t=%s ch=%s", mtype, tostring(target),
seq, target or "?", channel)
return seq
end
-- Convenience wrappers used across the addon.
function Comms.RosterReq(flags, target)
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "ROSTER_REQ",
{ tostring(flags or 15) }, { expectReply = true })
end
function Comms.BotDetailReq(guidLow, target)
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "BOT_DETAIL_REQ",
{ tostring(guidLow) }, { expectReply = true })
end
function Comms.Cmd(addr, verb, args, target)
local fields = { addr, verb }
if args then
for _, a in ipairs(args) do fields[#fields + 1] = a end
end
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "CMD", fields)
end
function Comms.StatsReq(target)
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "STATS_REQ",
{}, { expectReply = true })
end
function Comms.Ack(originSeq, target)
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "ACK",
{ string.format("%08x", originSeq) })
end
-- ALTS_REQ → server replies with same-account character list so we can
-- show a "Spawn one of your alts" picker. No flags yet; the server always
-- returns the full set (typical accounts have <20 chars).
function Comms.AltsReq(target)
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "ALTS_REQ",
{}, { expectReply = true })
end
-- SUMMON <charName> — addon counterpart to .playerbot summon. Server
-- replies via EVENT_PUSH (info=summoned / warn=summon_failed) rather
-- than a dedicated SUMMON_RESP so the existing toast pipeline carries
-- the message into the chat frame.
function Comms.Summon(charName, target)
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "SUMMON",
{ tostring(charName or "") })
end
-- LOGIN_REQ <charName> — headless-login an offline character the caller
-- is authorized for (same-account alt OR account-owned bot). Feedback
-- arrives via EVENT_PUSH (info=login_submitted / warn=login_failed) so
-- the existing toast pipeline carries it; no dedicated RESP frame.
function Comms.LoginReq(charName, target)
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "LOGIN_REQ",
{ tostring(charName or "") })
end
-- LOGOUT_REQ <charName> — kick a HEADLESS bot session. The server refuses
-- real client sessions unconditionally, so this is always safe to fire.
-- EVENT_PUSH (info=logged_out / warn=logout_failed) carries the outcome.
function Comms.LogoutReq(charName, target)
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "LOGOUT_REQ",
{ tostring(charName or "") })
end
-- SELF on|off|status — toggle the V2 AI on the caller's OWN character.
-- The server always replies SELF_RESP|attached=0|1|marked=0|1 so the
-- caller can update the toggle's visible state to match server truth
-- (not just whatever we guessed locally).
function Comms.Self(mode, target)
return Comms.Send(target or PBC.FLEET_TARGET, "WHISPER", "SELF",
{ tostring(mode or "status") }, { expectReply = true })
end
------------------------------------------------------------------ dispatch
function Comms.RegisterHandler(mtype, fn)
Comms._handlers[mtype] = fn
end
function Comms.AddEventListener(fn, owner)
Comms._eventListeners[#Comms._eventListeners + 1] = { fn = fn, owner = owner }
end
local function fireEventListeners(mtype, fields, sender)
for _, l in ipairs(Comms._eventListeners) do
local ok, err = pcall(l.fn, mtype, fields, sender)
if not ok then
dbg("listener %s err: %s", tostring(l.owner), tostring(err))
end
end
end
------------------------------------------------------------------ receive path
local function dispatchAssembled(seq, mtype, body, sender)
local fields = splitPipes(body)
-- If this frame matches a pending request, fulfil it.
local pend = Comms._pending[seq]
if pend then
Comms._pending[seq] = nil
if pend.onReply then
local ok, err = pcall(pend.onReply, mtype, fields, sender)
if not ok then dbg("onReply err: %s", tostring(err)) end
end
end
-- Always invoke registered handler (a server-pushed message has no pend).
local h = Comms._handlers[mtype]
if h then
local ok, err = pcall(h, fields, sender, seq)
if not ok then dbg("handler %s err: %s", mtype, tostring(err)) end
end
fireEventListeners(mtype, fields, sender)
end
function Comms.OnAddonMsg(prefix, message, channel, sender)
if prefix ~= PBC.PREFIX then return end
-- Frame layout: v|seq|idx|tot|MTYPE|payload
-- IMPORTANT: we cannot use splitPipes here because the payload itself
-- contains escaped pipes. Strip the 5 envelope fields by index.
local v, rest = message:match("^([^|]+)|(.*)$")
if not v then return end
if v ~= PBC.PROTO_VERSION then
dbg("dropping frame: proto v=%s want=%s", tostring(v), PBC.PROTO_VERSION)
return
end
local seqHex, rest2 = rest:match("^([^|]+)|(.*)$"); if not seqHex then return end
local idxStr, rest3 = rest2:match("^([^|]+)|(.*)$"); if not idxStr then return end
local totStr, rest4 = rest3:match("^([^|]+)|(.*)$"); if not totStr then return end
local mtype, body = rest4:match("^([^|]+)|(.*)$"); if not mtype then return end
local seq = tonumber(seqHex, 16)
local idx = tonumber(idxStr)
local tot = tonumber(totStr)
if not (seq and idx and tot) then return end
dbg("← %s [%s] seq=%08x %d/%d ch=%s", mtype, sender or "?", seq, idx, tot, channel)
if tot == 1 then
dispatchAssembled(seq, mtype, body, sender)
return
end
local asm = Comms._rxAssembly[seq]
if not asm then
asm = { total = tot, parts = {}, mtype = mtype, sender = sender,
started = GetTime() }
Comms._rxAssembly[seq] = asm
end
asm.parts[idx] = body
local have = 0
for _ in pairs(asm.parts) do have = have + 1 end
if have == asm.total then
local glued = {}
for i = 1, asm.total do glued[i] = asm.parts[i] or "" end
Comms._rxAssembly[seq] = nil
dispatchAssembled(seq, asm.mtype, table.concat(glued), asm.sender)
end
end
------------------------------------------------------------------ retry pump
function Comms.OnUpdate(elapsed)
local now = GetTime()
for seq, pend in pairs(Comms._pending) do
if now - pend.when > PBC.RETRY_TIMEOUT then
if pend.tries >= PBC.RETRY_MAX then
if pend.onTimeout then
pcall(pend.onTimeout, pend.mtype)
end
dbg("× timeout seq=%08x mtype=%s", seq, pend.mtype)
Comms._pending[seq] = nil
else
pend.tries = pend.tries + 1
pend.when = now
sendRaw(pend.target, pend.channel, PBC.PROTO_VERSION, seq,
pend.mtype, pend.escaped)
dbg("↻ retry %d seq=%08x mtype=%s", pend.tries, seq, pend.mtype)
end
end
end
-- Reap stale partial assemblies after 5s.
for seq, asm in pairs(Comms._rxAssembly) do
if now - asm.started > 5.0 then
dbg("× assembly drop seq=%08x mtype=%s have=%d/%d",
seq, asm.mtype, (function() local c=0 for _ in pairs(asm.parts) do c=c+1 end return c end)(),
asm.total)
Comms._rxAssembly[seq] = nil
end
end
end
------------------------------------------------------------------ encoders for
------------------------------------------------------------------ ROSTER_RESP records: comma-delimited within a pipe field.
function Comms.SplitBotRecord(s)
-- We cannot use simple gsub split because a bot name MIGHT contain a comma
-- via the escape layer. Bot names cannot, but defensive nonetheless.
local out, last = {}, 1
for i = 1, #s do
local c = s:sub(i, i)
if c == "," then
out[#out + 1] = s:sub(last, i - 1); last = i + 1
end
end
out[#out + 1] = s:sub(last)
return out
end
-- ROSTER_RESP bot record schema (16 fields, positional).
-- `headless` (16) appended 2026-06-12: 1 ⇔ online session is a server-side
-- BotSession that the owner may log out. Always 0 for offline rows and for
-- the owner's own self-AI character (a real client session).
Comms.ROSTER_FIELDS = {
"guidLow", "name", "level", "class", "race", "role", "zone",
"online", "hpPct", "manaPct", "dist", "intent", "lastRule",
"groupId", "spec", "headless",
}
function Comms.DecodeRosterRecord(s)
local parts = Comms.SplitBotRecord(s)
local out = {}
for i, key in ipairs(Comms.ROSTER_FIELDS) do
out[key] = parts[i]
end
-- Type-coerce useful numerics.
out.level = tonumber(out.level) or 0
out.hpPct = tonumber(out.hpPct) or 0
out.manaPct = tonumber(out.manaPct) or 0
out.dist = tonumber(out.dist) or -1
out.online = (out.online == "1") or (out.online == "true")
out.groupId = tonumber(out.groupId) or 0
out.headless = (out.headless == "1")
return out
end
-- BOT_DETAIL_RESP key=value fields → table.
function Comms.DecodeDetail(fields)
local out = { guidLow = fields[1] }
for i = 2, #fields do
local k, v = fields[i]:match("^([^=]+)=(.*)$")
if k then out[k] = v end
end
-- Numeric coercion for the common ones.
for _, k in ipairs({ "level", "hp", "hp_max", "mana", "mana_max",
"pos_x", "pos_y", "pos_z", "map", "intent_age",
"tickperf_ms", "group_id", "paused" }) do
if out[k] then out[k] = tonumber(out[k]) or out[k] end
end
return out
end
-- STATS_RESP positional decode.
function Comms.DecodeStats(fields)
local out = {
total = tonumber(fields[1]) or 0,
online = tonumber(fields[2]) or 0,
tanks = tonumber(fields[3]) or 0,
healers = tonumber(fields[4]) or 0,
dps = tonumber(fields[5]) or 0,
intents_per_sec = tonumber(fields[6]) or 0,
tick_budget_ms = tonumber(fields[7]) or 0,
tick_used_ms = tonumber(fields[8]) or 0,
wedged = tonumber(fields[9]) or 0,
extra = fields[10] or "",
}
return out
end
-- ALTS_RESP record schema (9 fields, positional).
-- `isHeadless` (9) appended 2026-06-12: 1 ⇔ the character is online as a
-- server-driven headless bot session (Logout offered). online=1 with
-- isHeadless=0 means a REAL client session — row is locked "In World".
Comms.ALT_FIELDS = {
"guidLow", "name", "class", "race", "level",
"online", "isBot", "isSelf", "isHeadless",
}
function Comms.DecodeAltRecord(s)
local parts = Comms.SplitBotRecord(s)
local out = {}
for i, key in ipairs(Comms.ALT_FIELDS) do
out[key] = parts[i]
end
out.level = tonumber(out.level) or 0
out.online = (out.online == "1")
out.isBot = (out.isBot == "1")
out.isSelf = (out.isSelf == "1")
out.isHeadless = (out.isHeadless == "1")
out.guidLow = tonumber(out.guidLow) or 0
return out
end
------------------------------------------------------------------ ping/diagnostics
function Comms.Ping()
return Comms.Send(PBC.FLEET_TARGET, "WHISPER", "PING",
{ tostring(GetTime() * 1000) }, { expectReply = true })
end
function Comms.PendingCount()
local c = 0; for _ in pairs(Comms._pending) do c = c + 1 end; return c
end
------------------------------------------------------------------ menu polyfill
-- Blizzard's legacy `EasyMenu` is gone in retail 12.0+. Roster/toolbar both
-- pop context menus, so we ship our own EasyMenu-shaped helper that works
-- across both menu APIs in the wild:
-- * MenuUtil.CreateContextMenu — modern (Dragonflight+) replacement
-- * UIDropDownMenu_Initialize — still present on most retail builds
-- Items are the legacy shape: { text, isTitle, notCheckable, func,
-- hasArrow, menuList }. Lives in Comms.lua so it loads before any UI file.
function PBC.OpenMenu(items, anchor, displayMode)
displayMode = displayMode or "MENU"
-- MenuUtil.CreateContextMenu requires a real frame as the owner
-- region — strings like "cursor" crash inside AcquireMenu (it tries
-- to read GetFrameStrata / parent off a non-frame). Detect and
-- skip to the legacy path in that case.
local anchorIsFrame = (type(anchor) == "table"
and type(anchor.GetObjectType) == "function")
-- Modern retail menu framework. Top-level only carries titles +
-- buttons + nested submenus. Checkable states aren't translated yet
-- because nothing in this addon uses them.
if anchorIsFrame and MenuUtil and MenuUtil.CreateContextMenu then
MenuUtil.CreateContextMenu(anchor or UIParent, function(_, root)
local function addList(parent, list)
for _, item in ipairs(list or {}) do
if item.isTitle then
parent:CreateTitle(item.text or "")
elseif item.hasArrow and item.menuList then
local sub = parent:CreateButton(item.text or "")
if sub and sub.CreateButton then
addList(sub, item.menuList)
end
elseif item.text then
parent:CreateButton(item.text, item.func)
end
end
end
addList(root, items)
end)
return
end
-- Legacy fallback. Build a singleton dropdown frame on first use so
-- repeated opens don't leak frames. Initialize takes a callback that
-- emits buttons for the requested level (sub-menu support included).
if UIDropDownMenu_Initialize and ToggleDropDownMenu then
if not PBC._menuFrame then
PBC._menuFrame = CreateFrame("Frame", "PBCSharedMenu",
UIParent, "UIDropDownMenuTemplate")
end
UIDropDownMenu_Initialize(PBC._menuFrame,
function(_self, level, menuList)
local list = (level == 1) and items or menuList
for _, item in ipairs(list or {}) do
local info = {}
for k, v in pairs(item) do info[k] = v end
UIDropDownMenu_AddButton(info, level)
end
end, displayMode)
ToggleDropDownMenu(1, nil, PBC._menuFrame,
anchor or "cursor", 0, 0)
return
end
-- Neither API found — degrade gracefully by dumping the labels.
if PBC.Warn then PBC.Warn("no menu API available; items:") end
for _, it in ipairs(items or {}) do
if it.text and not it.isTitle and PBC.Print then
PBC.Print(" · %s", it.text)
end
end
end
function Comms.AssemblyCount()
local c = 0; for _ in pairs(Comms._rxAssembly) do c = c + 1 end; return c
end
-- vim: ts=4 sw=4 et
@@ -0,0 +1,481 @@
--[[============================================================================
PlayerbotControl.lua — main entry, slash commands, event registrations.
Responsibilities:
* Initialize SavedVariables (PlayerbotControlDB, PlayerbotControlCharDB)
* Register CHAT_MSG_ADDON, ADDON_LOADED, PLAYER_LOGIN events
* Register addon prefix
* Wire /pbc, /playerbot, /playerbotcontrol slash commands
* Create a single OnUpdate driver that fans out to subsystems (avoids the
classic addon mistake of N frame-driven scripts each doing GetTime work)
* Own a global anchor frame PBC.MainFrame that holds the UI children
Slash commands (all aliases mean the same):
/pbc toggle the Roster window
/pbc roster show/focus roster
/pbc commands show command bar
/pbc debug [botName] open debug panel (focused on bot if given)
/pbc stats show fleet dashboard
/pbc follow [target] address all|squad|… follow target (default me)
/pbc stop [target] halt
/pbc engage engage owner's current target
/pbc squad <name…> set named squad members
/pbc role <name> <role> promote tank/healer/dps
/pbc reset wipe layout DB (positions/scales)
/pbc debug-comms toggle wire logging
/pbc ping ping the server (latency check)
============================================================================]]
local _ADDON, NS = ...
local PBC = _G.PlayerbotControl
------------------------------------------------------------------ DB defaults
local DB_DEFAULTS = {
debug = false,
statsPollWhileHidden = false,
roster = {
point = "CENTER", relTo = "UIParent", relPoint = "CENTER",
x = -300, y = 0, w = 280, h = 480, scale = 1.0, shown = false,
showOffline = false, sortBy = "role",
},
commands = {
point = "BOTTOM", relTo = "UIParent", relPoint = "BOTTOM",
x = 0, y = 200, w = 520, h = 36, scale = 1.0, shown = false,
history = {}, maxHistory = 40,
},
debugPanel = {
point = "CENTER", relTo = "UIParent", relPoint = "CENTER",
x = 200, y = 0, w = 460, h = 520, scale = 1.0, shown = false,
activeTab = "Snapshot",
},
stats = {
point = "TOP", relTo = "UIParent", relPoint = "TOP",
x = 0, y = -8, w = 460, h = 56, scale = 1.0, shown = false,
},
palette = {
playerBlue = { 0.30, 0.55, 1.00 },
allyGreen = { 0.30, 0.85, 0.35 },
enemyRed = { 0.90, 0.25, 0.25 },
offlineGray = { 0.50, 0.50, 0.50 },
warnYellow = { 1.00, 0.85, 0.20 },
bg1 = { 0.04, 0.04, 0.06, 0.92 },
bg2 = { 0.10, 0.10, 0.14, 0.92 },
border = { 0.25, 0.25, 0.30, 1.00 },
},
knownBots = {}, -- name → last-known role, persisted for autocomplete
}
local CHAR_DB_DEFAULTS = {
focusBotGuid = nil,
pinnedBots = {},
}
------------------------------------------------------------------ utilities
local function deepDefaults(target, defaults)
for k, v in pairs(defaults) do
if type(v) == "table" then
if type(target[k]) ~= "table" then target[k] = {} end
deepDefaults(target[k], v)
elseif target[k] == nil then
target[k] = v
end
end
end
local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end
function PBC.Print(fmt, ...)
local msg = (... == nil) and tostring(fmt) or string.format(fmt, ...)
DEFAULT_CHAT_FRAME:AddMessage("|cff66ccffPBC|r " .. msg)
end
function PBC.Warn(fmt, ...)
local msg = (... == nil) and tostring(fmt) or string.format(fmt, ...)
DEFAULT_CHAT_FRAME:AddMessage("|cffffcc33PBC|r " .. msg)
end
function PBC.Err(fmt, ...)
local msg = (... == nil) and tostring(fmt) or string.format(fmt, ...)
DEFAULT_CHAT_FRAME:AddMessage("|cffff5050PBC|r " .. msg)
end
------------------------------------------------------------------ session control fan-out
-- /pbc login|logout, the toolbar Login/Logout buttons and the command bar
-- all funnel through these. LOGIN_REQ / LOGOUT_REQ are per-character
-- frames, so multi-bot addresses (all / role / class) are expanded
-- client-side against the last ROSTER_RESP. Anything that isn't a known
-- filter token is treated as a single character name and sent verbatim —
-- the server validates ownership either way. "squad" can't be resolved
-- locally (the squad set lives server-side), so it isn't offered here.
local SESSION_FILTERS = {
all = true, tank = true, healer = true, dps = true,
warrior = true, paladin = true, hunter = true, rogue = true,
priest = true, deathknight = true, shaman = true, mage = true,
warlock = true, monk = true, druid = true, demonhunter = true,
evoker = true,
}
local function matchSessionFilter(rec, token)
if token == "all" then return true end
if token == "tank" or token == "healer" or token == "dps" then
return (rec.role or ""):lower() == token
end
-- Class token: server sends e.g. "DEATHKNIGHT"; user may type
-- "death-knight" (command-bar vocab) — both normalize the same way.
return (rec.class or ""):lower():gsub("[%-_]", "") == token
end
-- who: "all" | role | class token | single character name.
function PBC.RequestLogin(who)
who = who or "all"
local token = who:lower():gsub("[%-_]", "")
if not SESSION_FILTERS[token] then
PBC.Comms.LoginReq(who) -- single name, original case
PBC.Print("→ login %s", who)
if PBC.BotRoster then PBC.BotRoster.RequestRefresh(1.5) end
return
end
local roster = (PBC.BotRoster and PBC.BotRoster.GetRoster()) or {}
local sent = 0
for _, rec in ipairs(roster) do
if rec.name and not rec.online and matchSessionFilter(rec, token) then
PBC.Comms.LoginReq(rec.name)
sent = sent + 1
end
end
if sent > 0 then
PBC.Print("→ login requested for %d offline bot%s",
sent, sent == 1 and "" or "s")
if PBC.BotRoster then PBC.BotRoster.RequestRefresh(2.0) end
else
PBC.Warn("no offline bots match '%s' — open /pbc roster so the " ..
"list is fetched, or give a character name", who)
end
end
function PBC.RequestLogout(who)
who = who or "all"
local token = who:lower():gsub("[%-_]", "")
if not SESSION_FILTERS[token] then
PBC.Comms.LogoutReq(who) -- single name, original case
PBC.Print("→ logout %s", who)
if PBC.BotRoster then PBC.BotRoster.RequestRefresh(1.5) end
return
end
local roster = (PBC.BotRoster and PBC.BotRoster.GetRoster()) or {}
local sent = 0
for _, rec in ipairs(roster) do
-- Only headless sessions — the server refuses real clients anyway,
-- but filtering here keeps the chat free of pointless warn toasts
-- (e.g. the owner's own self-AI character).
if rec.name and rec.online and rec.headless
and matchSessionFilter(rec, token) then
PBC.Comms.LogoutReq(rec.name)
sent = sent + 1
end
end
if sent > 0 then
PBC.Print("→ logout requested for %d bot%s",
sent, sent == 1 and "" or "s")
if PBC.BotRoster then PBC.BotRoster.RequestRefresh(2.0) end
else
PBC.Warn("no online bots match '%s' — open /pbc roster so the " ..
"list is fetched, or give a character name", who)
end
end
------------------------------------------------------------------ main anchor frame
local MainFrame = CreateFrame("Frame", "PlayerbotControlMain", UIParent)
MainFrame:Hide()
PBC.MainFrame = MainFrame
------------------------------------------------------------------ OnUpdate fan-out
local accum = 0
local function OnUpdate(_, elapsed)
accum = accum + elapsed
if PBC.Comms then PBC.Comms.OnUpdate(elapsed) end
-- 5Hz panel refresh for things that aren't event-driven (bar interp etc).
if accum >= 0.2 then
if PBC.BotRoster and PBC.BotRoster.OnTick then
PBC.BotRoster.OnTick(accum)
end
if PBC.BotStats and PBC.BotStats.OnTick then
PBC.BotStats.OnTick(accum)
end
if PBC.BotDebug and PBC.BotDebug.OnTick then
PBC.BotDebug.OnTick(accum)
end
if PBC.BotAlts and PBC.BotAlts.OnTick then
PBC.BotAlts.OnTick(accum)
end
accum = 0
end
end
------------------------------------------------------------------ stats poll
local statsTimer = 0
local function PollStatsIfVisible(elapsed)
statsTimer = statsTimer + elapsed
if statsTimer < PBC.STATS_INTERVAL then return end
statsTimer = 0
if not PBC.Comms then return end
local anyVisible =
(PBC.BotStats and PBC.BotStats.frame and PBC.BotStats.frame:IsShown()) or
(PBC.BotRoster and PBC.BotRoster.frame and PBC.BotRoster.frame:IsShown()) or
(PBC.BotDebug and PBC.BotDebug.frame and PBC.BotDebug.frame:IsShown())
if anyVisible or (PBC.DB and PBC.DB.statsPollWhileHidden) then
PBC.Comms.StatsReq()
end
end
local pollFrame = CreateFrame("Frame")
pollFrame:SetScript("OnUpdate", function(_, e) PollStatsIfVisible(e) end)
MainFrame:SetScript("OnUpdate", OnUpdate)
------------------------------------------------------------------ event glue
local eventFrame = CreateFrame("Frame", "PlayerbotControlEvents")
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("CHAT_MSG_ADDON")
eventFrame:RegisterEvent("PLAYER_LOGOUT")
eventFrame:SetScript("OnEvent", function(_, event, ...)
if event == "ADDON_LOADED" then
local name = ...
if name == "PlayerbotControl" or name == _ADDON then
PlayerbotControlDB = PlayerbotControlDB or {}
PlayerbotControlCharDB = PlayerbotControlCharDB or {}
deepDefaults(PlayerbotControlDB, DB_DEFAULTS)
deepDefaults(PlayerbotControlCharDB, CHAR_DB_DEFAULTS)
PBC.DB = PlayerbotControlDB
PBC.CharDB = PlayerbotControlCharDB
if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
C_ChatInfo.RegisterAddonMessagePrefix(PBC.PREFIX)
elseif RegisterAddonMessagePrefix then
RegisterAddonMessagePrefix(PBC.PREFIX)
end
end
elseif event == "PLAYER_LOGIN" then
-- Server-side hook fires inside Player::WhisperAddon, which is only
-- invoked when the whisper target resolves to an actual connected
-- player. The fake "PBCFLEET" name never resolves and the hook
-- never fires, so we re-point the fleet target at the player's own
-- name (self-whispers DO route through WhisperAddon and trigger
-- the PlayerScript::OnChat hook the server uses to intercept PBC
-- frames). Override happens here, post-login, because UnitName
-- isn't valid until PLAYER_LOGIN fires.
local me = UnitName("player")
if me and me ~= "" then PBC.FLEET_TARGET = me end
PBC.Print("Wave G online — /pbc to begin. Server prefix: %s", PBC.PREFIX)
if PBC.BotRoster and PBC.BotRoster.Build then PBC.BotRoster.Build() end
if PBC.BotCommands and PBC.BotCommands.Build then PBC.BotCommands.Build() end
if PBC.BotDebug and PBC.BotDebug.Build then PBC.BotDebug.Build() end
if PBC.BotStats and PBC.BotStats.Build then PBC.BotStats.Build() end
if PBC.BotToolbar and PBC.BotToolbar.Build then PBC.BotToolbar.Build() end
if PBC.BotAlts and PBC.BotAlts.Build then PBC.BotAlts.Build() end
-- Restore previously shown frames.
if PBC.DB.roster.shown and PBC.BotRoster then PBC.BotRoster.Show() end
if PBC.DB.commands.shown and PBC.BotCommands then PBC.BotCommands.Show() end
if PBC.DB.stats.shown and PBC.BotStats then PBC.BotStats.Show() end
if PBC.DB.debugPanel.shown and PBC.BotDebug then PBC.BotDebug.Show() end
if PBC.DB.toolbar and PBC.DB.toolbar.shown and PBC.BotToolbar then
PBC.BotToolbar.Show()
end
MainFrame:Show()
-- Kick the first stats fetch right away.
if PBC.Comms then PBC.Comms.StatsReq() end
elseif event == "CHAT_MSG_ADDON" then
if PBC.Comms then PBC.Comms.OnAddonMsg(...) end
elseif event == "PLAYER_LOGOUT" then
-- Persist visibility flags so layouts come back on next session.
if PBC.DB then
if PBC.BotRoster and PBC.BotRoster.frame then PBC.DB.roster.shown = PBC.BotRoster.frame:IsShown() end
if PBC.BotCommands and PBC.BotCommands.frame then PBC.DB.commands.shown = PBC.BotCommands.frame:IsShown() end
if PBC.BotDebug and PBC.BotDebug.frame then PBC.DB.debugPanel.shown= PBC.BotDebug.frame:IsShown() end
if PBC.BotStats and PBC.BotStats.frame then PBC.DB.stats.shown = PBC.BotStats.frame:IsShown() end
end
end
end)
------------------------------------------------------------------ slash commands
local function usage()
PBC.Print("commands:")
PBC.Print(" /pbc | /pbc roster — toggle bot roster")
PBC.Print(" /pbc commands — toggle command bar")
PBC.Print(" /pbc toolbar — toggle group quick-action bar")
PBC.Print(" /pbc alts — open Spawn-an-Alt picker")
PBC.Print(" /pbc summon <alt> — spawn one alt directly")
PBC.Print(" /pbc self on|off|status — drive your OWN char with the AI")
PBC.Print(" /pbc debug [name] — open debug panel")
PBC.Print(" /pbc stats — toggle fleet dashboard")
PBC.Print(" /pbc follow [target] — squad follow")
PBC.Print(" /pbc stop — halt")
PBC.Print(" /pbc engage — focus engage")
PBC.Print(" /pbc squad name1 name2…")
PBC.Print(" /pbc role <bot> <role>")
PBC.Print(" /pbc login [who] — headless-login offline bots (all|role|class|name)")
PBC.Print(" /pbc logout [who] — log out headless bot sessions")
PBC.Print(" /pbc reset — wipe layout")
PBC.Print(" /pbc debug-comms — wire logging")
PBC.Print(" /pbc ping — latency check")
end
local function tokens(s)
local out = {}
for tok in s:gmatch("%S+") do out[#out + 1] = tok end
return out
end
local function slashHandler(msg)
msg = trim(msg or "")
if msg == "" then
if PBC.BotRoster then PBC.BotRoster.Toggle() end
return
end
local toks = tokens(msg)
local cmd = (toks[1] or ""):lower()
if cmd == "roster" then
PBC.BotRoster.Toggle()
elseif cmd == "commands" or cmd == "cmd" then
PBC.BotCommands.Toggle()
elseif cmd == "toolbar" or cmd == "orders" or cmd == "bar" then
if PBC.BotToolbar then PBC.BotToolbar.Toggle() end
elseif cmd == "alts" or cmd == "spawn" then
if PBC.BotAlts then PBC.BotAlts.Show() end
elseif cmd == "summon" then
local who = toks[2]
if not who then PBC.Warn("usage: /pbc summon <altname>"); return end
PBC.Comms.Summon(who)
PBC.Print("→ summon %s", who)
elseif cmd == "self" then
local mode = (toks[2] or "status"):lower()
if mode ~= "on" and mode ~= "off" and mode ~= "status" then
PBC.Warn("usage: /pbc self on|off|status"); return
end
PBC.Comms.Self(mode)
PBC.Print("→ self %s", mode)
elseif cmd == "debug" then
PBC.BotDebug.Show()
if toks[2] then PBC.BotDebug.FocusByName(toks[2]) end
elseif cmd == "stats" then
PBC.BotStats.Toggle()
elseif cmd == "follow" then
local target = toks[2] or UnitName("player")
PBC.Comms.Cmd("all", "follow", { target })
PBC.Print("→ all follow %s", target)
elseif cmd == "stop" or cmd == "halt" then
PBC.Comms.Cmd("all", "stop", {})
PBC.Print("→ all stop")
elseif cmd == "engage" then
local t = UnitName("target")
if not t then PBC.Warn("no target"); return end
PBC.Comms.Cmd("all", "engage_focus", { t })
PBC.Print("→ all engage %s", t)
elseif cmd == "squad" then
local args = {}
for i = 2, #toks do args[#args + 1] = toks[i] end
if #args == 0 then PBC.Warn("squad needs at least one bot name"); return end
PBC.Comms.Cmd("squad", "set", args)
PBC.Print("→ squad set: %s", table.concat(args, ", "))
elseif cmd == "role" then
local who, role = toks[2], toks[3]
if not who or not role then PBC.Warn("usage: /pbc role <bot> tank|healer|dps"); return end
PBC.Comms.Cmd(who, "role", { role:lower() })
PBC.Print("→ %s role=%s", who, role)
elseif cmd == "promote" then
local who = toks[2]
if not who then PBC.Warn("usage: /pbc promote <bot>"); return end
PBC.Comms.Cmd(who, "promote", {})
elseif cmd == "reset" then
PlayerbotControlDB = {}
deepDefaults(PlayerbotControlDB, DB_DEFAULTS)
PBC.DB = PlayerbotControlDB
PBC.Print("layout reset — /reload to fully reapply")
elseif cmd == "debug-comms" or cmd == "debug_comms" then
PBC.DB.debug = not PBC.DB.debug
PBC.Print("wire logging: %s", PBC.DB.debug and "ON" or "OFF")
elseif cmd == "ping" then
local t0 = GetTime()
PBC.Comms.Send(PBC.FLEET_TARGET, "WHISPER", "PING",
{ tostring(t0 * 1000) },
{ expectReply = true,
onReply = function() PBC.Print("pong (%.0f ms)", (GetTime() - t0) * 1000) end,
onTimeout= function() PBC.Warn("ping timeout") end })
elseif cmd == "pause" then
local who = toks[2] or "all"
PBC.Comms.Cmd(who, "pause", {})
PBC.Print("→ %s paused", who)
elseif cmd == "resume" then
local who = toks[2] or "all"
PBC.Comms.Cmd(who, "resume", {})
PBC.Print("→ %s resumed", who)
elseif cmd == "logout" then
-- Dedicated LOGOUT_REQ frames (the CMD "logout" verb never existed
-- server-side, and CMD can't reach a bot without a live session).
PBC.RequestLogout(toks[2] or "all")
elseif cmd == "login" then
PBC.RequestLogin(toks[2] or "all")
elseif cmd == "help" or cmd == "?" then
usage()
else
-- Fall through: treat unknown verb as a direct CMD with "all" address.
local args = {}
for i = 2, #toks do args[#args + 1] = toks[i] end
PBC.Comms.Cmd("all", cmd, args)
PBC.Print("→ all %s %s", cmd, table.concat(args, " "))
end
end
SLASH_PLAYERBOTCONTROL1 = "/pbc"
SLASH_PLAYERBOTCONTROL2 = "/playerbotcontrol"
SLASH_PLAYERBOTCONTROL3 = "/playerbot"
SlashCmdList["PLAYERBOTCONTROL"] = slashHandler
------------------------------------------------------------------ generic protocol handlers
-- These are installed before the UI modules so even un-built panels see data.
local function installCoreHandlers()
PBC.Comms.RegisterHandler("PONG", function(fields, sender)
local t0 = tonumber(fields[1]) or 0
local lat = GetTime() * 1000 - t0
if PBC.DB and PBC.DB.debug then
PBC.Print("PONG %.0fms from %s", lat, sender or "?")
end
end)
PBC.Comms.RegisterHandler("EVENT_PUSH", function(fields, sender, seq)
local severity = fields[1] or "info"
local guid = fields[2] or "?"
local evt = fields[3] or "?"
local detail = fields[4] or ""
-- Always ACK so the server can drop from its retry spool.
PBC.Comms.Ack(seq)
local fn
if severity == "error" then fn = PBC.Err
elseif severity == "warn" then fn = PBC.Warn
else fn = PBC.Print end
fn("event %s [%s] %s", evt, guid, detail)
if PBC.BotDebug and PBC.BotDebug.PushEvent then
PBC.BotDebug.PushEvent(guid, evt, detail, severity)
end
end)
end
-- Wait for ADDON_LOADED to populate DB before installing.
local installFrame = CreateFrame("Frame")
installFrame:RegisterEvent("PLAYER_LOGIN")
installFrame:SetScript("OnEvent", function() installCoreHandlers(); installFrame:UnregisterAllEvents() end)
------------------------------------------------------------------ public reset
function PBC.ResetLayout()
PlayerbotControlDB = {}
deepDefaults(PlayerbotControlDB, DB_DEFAULTS)
PBC.DB = PlayerbotControlDB
end
-- Expose a generic registration so other addons can listen in.
function PBC.OnEvent(fn, owner)
PBC.Comms.AddEventListener(fn, owner or "external")
end
-- vim: ts=4 sw=4 et
@@ -0,0 +1,26 @@
## Interface: 120000
## Title: PlayerbotControl
## Notes: Owner-side cockpit for PlayerbotV2 fleet. Roster, commands, debug, stats.
## Author: PlayerbotV2 (Wave G)
## Version: 0.1.0
## SavedVariables: PlayerbotControlDB
## SavedVariablesPerCharacter: PlayerbotControlCharDB
## DefaultState: enabled
## LoadOnDemand: 0
## OptionalDeps:
## X-Category: Combat
## X-Website: https://github.com/TrinityCore/TrinityCore
## X-License: AGPLv3
# Load order matters: Comms first (defines protocol API used by everyone else),
# then state-bearing modules, then UI frames, then the main entry that wires
# slash commands and event registrations.
Comms.lua
BotRoster.lua
BotCommands.lua
BotToolbar.lua
BotAlts.lua
BotDebug.lua
BotStats.lua
PlayerbotControl.lua
@@ -0,0 +1,233 @@
# PlayerbotControl (Wave G addon)
Owner-side WoW 12.0+ cockpit for the PlayerbotV2 server module. Surfaces
the in-server bot fleet to the live game client: roster, command bar,
per-bot debug, fleet dashboard. Communicates over Blizzard's AddonMessage
channel with prefix `PBC`.
Server-side handler is now wired in `src/modules/PlayerbotV2/Session/
AddonControl.{h,cpp}` + the PlayerScript registration at the bottom of
`src/server/scripts/Commands/cs_playerbot_v2.cpp`. The original
`SERVER_INTEGRATION_STUBS.md` describes the contract that file fulfils.
## Quickstart (live test)
1. Build worldserver after pulling these files.
2. In-game: `.playerbot summon <alt_name>` to spawn one of your alts as a
bot bound to your account.
3. `/pbc` opens the roster — it should populate with that bot.
4. `/pbc commands` opens the command bar; type `all follow` to test the
server round-trip end-to-end.
5. `/pbc debug-comms` flips wire logging so the chat frame shows every
`→` send and `←` reply with seq + MTYPE. Useful for diagnosing
"nothing arrives" — if you see `→ ROSTER_REQ` but no `←`, the server
handler didn't intercept (check that `.playerbot summon` actually
bound an owned bot — empty-owner accounts are skipped by design).
## Installation
1. Copy `src/modules/PlayerbotV2/Addon/PlayerbotControl/` into
`<wow>/_retail_/Interface/AddOns/PlayerbotControl/`.
2. Make sure the server has the `PBC` prefix handler registered (see
stubs doc). Without it, the client falls back gracefully (timeouts +
"server stalled" warnings; no crashes).
3. `/reload` in-game. You should see:
`PBC Wave G online — /pbc to begin. Server prefix: PBC`
## Slash commands
All aliases (`/pbc`, `/playerbot`, `/playerbotcontrol`) do the same thing.
| command | effect |
|-------------------------------|---------------------------------------------------|
| `/pbc` | toggle the bot roster window |
| `/pbc roster` | force-show roster |
| `/pbc commands` | toggle command bar (TAB autocomplete inside) |
| `/pbc toolbar` | toggle group quick-action toolbar (click-to-fire) |
| `/pbc alts` | open the **Spawn an alt** picker |
| `/pbc summon <alt>` | spawn one of your alts as a bot, by name |
| `/pbc self on\|off\|status` | drive your OWN character with the AI (`.self`) |
| `/pbc debug [name]` | open per-bot debug panel; focus on `name` if given|
| `/pbc stats` | toggle the fleet dashboard |
| `/pbc follow [target]` | `CMD all follow <target or me>` |
| `/pbc stop` | `CMD all stop` |
| `/pbc engage` | `CMD all engage_focus <yourTarget>` |
| `/pbc squad <names...>` | `CMD squad set <names...>` |
| `/pbc role <bot> <role>` | `CMD <bot> role tank|healer|dps` |
| `/pbc pause [who]` | pause a bot or `all` |
| `/pbc resume [who]` | resume |
| `/pbc login [who]` | headless-login offline bots (`LOGIN_REQ`); `who` = `all`\|role\|class\|name |
| `/pbc logout [who]` | log out headless bot sessions (`LOGOUT_REQ`); real clients always refused |
| `/pbc ping` | latency probe (`PING/PONG`) |
| `/pbc debug-comms` | toggle wire logging in chat |
| `/pbc reset` | wipe layout DB (positions, scales, history) |
Unknown verbs fall through as `CMD all <verb> <args...>` so the server
can evolve verbs without an addon update.
## UI tour
### Bot roster (left)
Vertical list, sorted by role (tank → healer → dps). Each row:
class-colored name + level + role icon, zone + distance, HP bar
(green → yellow → red), MP/power bar, current intent + last rule
fired. **Left-click** opens debug for that bot. **Right-click** opens a
context menu — for online rows: Follow Me / Stop / Engage Target /
Promote / Whisper / Detail / Pause / Resume, plus **Logout** when the
session is a headless bot (never for your own self-AI character, which
is a real client session); for offline rows: **Log In** / Detail.
Offline rows also swap the HP/MP bars for an inline **[Log In]** button
that headless-logins the bot in place. The roster auto-refreshes ~1.5s
after any login/logout/spawn action so rows flip state on their own.
Footer has sort cycle, offline toggle, manual refresh.
### Command bar (bottom)
`> ` prompt with hint text on the right. Type `<addr> <verb> <args…>`
and press ENTER. TAB cycles autocompletions; UP/DOWN walk history.
Suggestion strip appears below the bar with click-to-apply chips.
### Quick-action toolbar (`/pbc toolbar`)
A click-to-fire grid of the most-used group orders. Left edge has an
**Addr** picker (`all` / `squad` / `tank` / `healer` / `dps` / class
tokens) and a **Self: ON/OFF** toggle that attaches the V2 AI to your
own character (same as `.playerbot self on/off` — your client input
still works; pressing a movement key interrupts the AI's move intents).
Every button below the title bar fires `CMD <addr> <verb>` for the
selected address. Includes formation cycle (Tight/Spread/Line/Wedge), combat
(Engage/Assist/Pull/Rez), utility (Mount/Dismount/Hearth/Repair/Sell/
Loot/Buff), queue management (BG queue submenu, LFG queue submenu, Leave
buttons, Ready), and meta (Pause/Resume/Login/Logout/Dismiss). Login and
Logout are special: they expand the current address against the last
roster snapshot and fire one `LOGIN_REQ`/`LOGOUT_REQ` per matching
character (offline bots for Login, headless sessions for Logout) instead
of a CMD frame. Right-click the Addr button to cycle backwards;
Shift+click cycles forward.
### Alt picker (`/pbc alts`, or "Spawn Alt…" in the roster footer)
Modal listing every character on your account, class-colored with level
and race, sorted so actionable rows come first. Each row's button shows
the live state:
- **Spawn** — character is not a bot and not online; click to call
`.playerbot summon <name>` over the wire (marks + binds + logs in).
- **Log In** — character is a marked bot, currently offline; click
fires `LOGIN_REQ` and the bot re-enters world headless.
- **Logout** — character is in world as a **headless bot session**;
click fires `LOGOUT_REQ` to kick it.
- **In World** — character is logged in by a real player session;
locked (a real client can never be logged out through the addon).
- **/self** — your current character; use `/pbc self on` instead.
The picker refreshes 1s after each action click so rows flip state
(Spawn → Logout, Log In → Logout, …) without a manual /reload, and
nudges the roster window to refresh too.
### Debug panel (right)
Tabbed: **Snapshot / Intents / Logs / Perf**.
- *Snapshot*: 19 selected fields from the server snapshot (level, spec,
role, position, HP, MP, intent, intent age, last rule, paused,
tickperf, etc.).
- *Intents*: rolling history of last 10 intents this bot fired
(`intent_fired` / `intent_failed` from EVENT_PUSH stream).
- *Logs*: last 50 EVENT_PUSH entries scoped to this bot.
- *Perf*: tickperf + recent_intents histogram (server-supplied).
Header buttons: Pause / Resume / Skip Intent / Follow Me / Refresh.
### Fleet dashboard (top)
Two-line: bot counts (total/online, T/H/D, wedged, intents/s with a
sparkline of last 30 samples) and tick budget bar
(green/yellow/red over 70%/90%). 5s poll cadence while any UI is shown.
## Wire protocol
See the comment block at the top of [`Comms.lua`](./Comms.lua) for the
authoritative spec. TL;DR:
- AddonMessage prefix **`PBC`**.
- Transport: WHISPER to bot name or magic target `PBCFLEET`.
- Frame layout: `v|seq|chunkIdx|chunkTotal|MTYPE|payload`.
- Payload fields are pipe-delimited; `|` escapes to `\p`, `\` to `\\`.
- Max chunk payload = 240 bytes (Blizzard limit is 255).
- Chunks reassemble on the receiver by `(seq, chunkTotal)`.
### Frame examples
```
1|00000001|1|1|ROSTER_REQ|15
```
Client requests roster with flags=15 (online+offline+intents+hp+dist).
```
1|00000002|1|1|ROSTER_RESP|3|123,Areon,80,WARRIOR,HUMAN,TANK,Stormwind City,1,93,0,12,engage_boss,tank_pull,7,protection|124,Healme,80,PRIEST,DWARF,HEALER,Stormwind City,1,80,55,14,heal_party,heal_low,7,holy|125,Stabby,79,ROGUE,GNOME,DPS,Elwynn Forest,1,100,0,8,quest_kill,quest_kill,0,assassination
```
Server replies with 3 bot records, comma-delimited within each pipe field.
```
1|00000003|1|1|CMD|tank|engage_focus|Hogger
```
Client sends a CMD: address `tank`, verb `engage_focus`, arg `Hogger`.
```
1|00000004|1|1|STATS_RESP|412|2000|38|52|322|184|33.3|24.7|2|
```
Server pushes fleet stats: 412/2000 online, 38 tanks, 52 healers, 322 dps,
184 intents/s, 33.3 ms tick budget, 24.7 ms used, 2 wedged bots.
```
1|00000005|1|1|EVENT_PUSH|warn|123|wedge|stuck in mesh at 1.5234 -8.21 32.5
```
Server pushes a wedge event for bot guid 123. Client replies with
`ACK 00000005` so the server can drop it from its retry spool.
### Multi-chunk reassembly
Large `ROSTER_RESP` payloads (e.g. 60-bot fleets) split into multiple
chunks sharing the same seq:
```
1|0000000A|1|3|ROSTER_RESP|60|123,Areon,…
1|0000000A|2|3|ROSTER_RESP|…,124,Healme,…
1|0000000A|3|3|ROSTER_RESP|…,125,Stabby,…
```
The client joins chunks 1..3 in order, then runs `splitPipes` on the
glued body.
## SavedVariables
- `PlayerbotControlDB` (account-wide): window positions/sizes/scales,
command history, palette, knownBots role cache, debug toggles.
- `PlayerbotControlCharDB` (per character): last focused bot guid,
pinned bots.
## Known client-side limitations
- AddonMessage requires the addon to be loaded on the receiving side
too — but here the receiver is the server core, not another addon.
The `PBC` prefix must be **registered server-side** before any
client can send it.
- Blizzard's 255-byte per-message cap means even chunked frames have
envelope overhead (~24 bytes for version/seq/idx/total/mtype). At
60 bots × ~70 bytes/record = ~4.2KB → ~18 chunks for a full roster
push. Acceptable but informs why we picked pipe-delim over JSON.
- We do not currently encrypt or sign frames — anyone on the realm
could forge a CMD if they knew the prefix. The server-side handler
**must** validate that the sender is the bot's actual owner. See
the stubs doc for the validation hooks.
## Color palette
Mapped through `PBC.DB.palette` (overridable from `/run`):
| key | usage |
|---------------|-------------------------------------------------|
| `playerBlue` | the owner's marker; HP bar accent |
| `allyGreen` | healthy HP, online bots |
| `enemyRed` | low HP, wedge alerts, tick budget overrun |
| `offlineGray` | offline rows |
| `warnYellow` | mid-HP, mid-tick, warning EVENT_PUSH severity |
## Development notes
Aim for low CPU at 2000-bot scale: a full roster + detail refresh on a
3s/2s/5s cadence respectively. The OnUpdate fan-out in
`PlayerbotControl.lua` is single-frame to keep per-tick overhead bounded.
@@ -0,0 +1,338 @@
# PlayerbotControl — server integration stubs
What needs to land **server-side** in `src/modules/PlayerbotV2/` to make
the `PlayerbotControl` addon do anything. **Nothing here is implemented
yet** — this document is the contract.
> Scope: this addon was shipped alone for Wave G. C++ work is queued on a
> separate workstream. Do not start C++ yet without coordinating.
## 1. AddonMessage prefix handler (entry point)
Hook `WorldSession::HandleMessagechatOpcode` for `CHAT_MSG_ADDON` whispers
where:
- prefix == `"PBC"`, AND
- target name is either a managed bot **owned by this session's account**,
OR the magic constant `"PBCFLEET"`.
If matched, **intercept** the message (do NOT route to the normal whisper
codepath — there's no bot character to receive a CHAT_MSG_WHISPER). Hand
the raw payload to `PlayerbotV2::Comms::OnAddonFrame(sessionAccountId,
ownerGuid, target, payload)`.
Pseudocode location: probably `Session/SessionHooks.cpp` (new file) with
a TC hook registered from `PlayerbotV2.cpp::OnAfterConfigLoad`.
### Validation rules
1. Sender's account must own at least one bot. Else drop silently.
2. For `target = botName`: that bot's `owner_account_id` must equal
sender's account id. Reject with `EVENT_PUSH error <0> auth "not your
bot"` otherwise.
3. For `target = "PBCFLEET"`: any addressing in the CMD verb (`all`,
`squad`, `tank`, etc.) implicitly filters to **the sender's bots
only**. Server-side OwnerSquadControl already has this scoping —
reuse it.
4. Rate-limit: at most 20 frames per session per second. Drop excess
with `EVENT_PUSH warn 0 rate_limit`.
## 2. Frame parser
Parse `v|seq|idx|total|MTYPE|payload`. Important details:
- Reject `v != "1"`.
- For `total > 1`, accumulate by `(sessionAccountId, seq)` with a 5s TTL.
- `splitPipes(payload)` must mirror the LUA escape rules: `\\\\` → `\`,
`\\p` → `|`. Do not naively `boost::split` on `|`.
- Total frame size cap: 4 KB after reassembly. Drop oversized.
Bind into existing string utilities in `Util/StringUtils.h`.
## 3. MTYPE dispatch table
| MTYPE | direction | handler |
|-------------------|-----------|---------|
| `ROSTER_REQ` | C→S | `BuildRosterResponse(ownerGuid, flags)` |
| `ROSTER_RESP` | S→C | (we emit it) |
| `BOT_DETAIL_REQ` | C→S | `BuildBotDetail(ownerGuid, guidLow)` |
| `BOT_DETAIL_RESP` | S→C | (we emit it) |
| `CMD` | C→S | `DispatchOwnerCommand(...)` |
| `STATS_REQ` | C→S | `BuildStatsResponse(ownerGuid)` |
| `STATS_RESP` | S→C | (we emit it) |
| `ALTS_REQ` | C→S | same-account character list for the spawn picker |
| `ALTS_RESP` | S→C | (we emit it) |
| `SUMMON` | C→S | mark + bind + headless-login a same-account alt |
| `LOGIN_REQ` | C→S | headless-login an offline authorized character (same-account alt OR account-owned bot); EVENT_PUSH info=`login_submitted` / warn=`login_failed` |
| `LOGOUT_REQ` | C→S | kick a HEADLESS bot session only — real client sessions always refused; EVENT_PUSH info=`logged_out` / warn=`logout_failed` |
| `SELF` | C→S | toggle V2 AI on the caller's own character → `SELF_RESP` |
| `SELF_RESP` | S→C | (we emit it) |
| `EVENT_PUSH` | S→C | (we emit it, with retry spool) |
| `ACK` | C→S | drop from retry spool |
| `PING` | C→S | reply `PONG <t0>` immediately |
| `PONG` | S→C | (we emit it) |
`LOGIN_REQ`/`LOGOUT_REQ` authority is STRICTER than CMD: only same-account
characters or bots owned by the requester's account (`Services::Owners()`)
qualify — group leadership grants nothing. Both share a per-account token
bucket (burst 32, refill 4/s); excess gets `EVENT_PUSH warn rate_limit`.
## 4. Roster serializer
`BuildRosterResponse(ownerGuid, flags)` → emit `ROSTER_RESP` with N+1
fields: `count|rec1|rec2|...`. Each `rec` is comma-delimited matching
`Comms.ROSTER_FIELDS` exactly:
```
guidLow,name,level,class,race,role,zone,online,hpPct,manaPct,
dist,intent,lastRule,groupId,spec,headless
```
- `class` and `race` should be UPPERCASE Blizzard tokens (WARRIOR,
HUMAN) so the LUA `RAID_CLASS_COLORS` lookup works.
- `role` ∈ `{TANK, HEALER, DPS, UNKNOWN}`.
- `online`: `"1"` / `"0"`.
- `hpPct`, `manaPct`: integer 0–100.
- `dist`: meters from owner, `-1` if cross-map.
- `intent`: snake_case current intent name (`engage_boss`, `quest_kill`...)
- `lastRule`: idle rule that last fired (e.g. `tank_pull`).
- `groupId`: `0` if not grouped with owner, else owner's group id.
- `spec`: lowercase spec slug (`protection`, `holy`, `assassination`).
- `headless`: `"1"` when the online session is a server-side headless
BotSession (the addon offers Logout); `"0"` for offline rows and for
a real client session (e.g. the owner's own self-AI character).
**Filtering by flags** (bit field):
- 1 = include offline bots
- 2 = include intent fields (else "-")
- 4 = include HP/mana
- 8 = include distance
**Chunking**: the LUA side handles reassembly transparently — just emit
the full payload via `C_ChatInfo`'s server equivalent (whatever
`SendAddonMessage` lookalike the core ships). At ~70 bytes/record, plan
for 3–4 chunks per 60 bots.
Pull data from the existing snapshot system (see
`project_v2_snapshot_struct_refactor.md`) — most fields already exist;
no new snapshot fields needed for v1.
## 5. Bot detail serializer
`BuildBotDetail(ownerGuid, guidLow)` → `BOT_DETAIL_RESP` with `key=value`
fields:
| key | source |
|----------------|----------------------------------------------|
| name | `snapshot.identity.name` |
| level | `snapshot.identity.level` |
| class | `snapshot.identity.class_token` |
| spec | `snapshot.identity.spec_slug` |
| role | `snapshot.identity.role` |
| zone | `snapshot.location.zone_name` |
| subzone | `snapshot.location.subzone_name` |
| pos_x/y/z | `snapshot.location.position.{x,y,z}` |
| map | `snapshot.location.map_id` |
| group_id | `snapshot.group.group_id` |
| leader | `snapshot.group.leader_name` |
| hp / hp_max | `snapshot.vitals.hp_cur / hp_max` |
| mana / mana_max| `snapshot.vitals.mana_cur / mana_max` |
| power_type | `snapshot.vitals.power_type_name` |
| intent | `bot.current_intent_name()` |
| intent_age | `now - bot.intent_started` |
| last_rule | `bot.last_idle_rule_name` |
| paused | `bot.is_paused ? 1 : 0` |
| tickperf_ms | `bot.tickperf.avg_ms` (already instrumented) |
| recent_intents | `bot.intent_history.join(",")` (last 10) |
The first positional field is `guidLow` (NOT key=value) so LUA's
`DecodeDetail` can read it cleanly.
## 6. CMD verb dispatch
`DispatchOwnerCommand(ownerGuid, sender, fields)` where fields[1]=addr,
fields[2]=verb, fields[3..N]=args.
Address resolution (existing OwnerSquadControl logic):
| addr | resolves to |
|-------------|------------------------------------------------------|
| `all` | all bots owned by sender |
| `squad` | sender's current squad set |
| `tank` | tanks within sender's bots |
| `healer` | healers |
| `dps` | dps |
| classToken | bots of that class (warrior/mage/…) |
| `<botName>` | exactly one bot, must be owned |
Verb table (map each to an existing IntentBody producer or owner-control
action):
| verb | maps to |
|-----------------|--------------------------------------------------|
| `follow` | OwnerSquadControl.SetFollow(target=arg[0] or owner) |
| `stop` | OwnerSquadControl.Stop() |
| `engage` | engage owner's current target |
| `engage_focus` | engage by name (resolve via Owner's group/world) |
| `hold` | OwnerSquadControl.Hold() |
| `squad` | (subverb `set` + names) |
| `role` | RoleAssign(arg[0]) |
| `mark` | RaidTargetIcon for current target |
| `login` | ~~CMD verb~~ — superseded by the dedicated `LOGIN_REQ` MTYPE (CMD requires a live bot session to route to; an offline bot has none) |
| `logout` | ~~CMD verb~~ — superseded by `LOGOUT_REQ` (headless sessions only) |
| `promote` | promote to group leader |
| `whisper` | bot sends arg[1..] in /say |
| `pause` | bot.is_paused = true |
| `resume` | bot.is_paused = false |
| `skip_intent` | bot.abort_current_intent() |
| `form` | OwnerSquadControl.SetFormation(arg[0]) |
| `spread` | shorthand: form=spread |
| `tight` | shorthand: form=tight |
| `ghost_res` | force spirit-resurrect |
| `use_hearth` | force hearthstone use |
| `mount` | mount up |
| `dismount` | dismount |
| `loot_roll` | force a roll on the active loot |
| `bg_queue` | queue for BG arg[0] |
| `bg_leave` | leave BG queue |
| `lfg_queue` | queue for LFG (optional dungeon arg) |
| `lfg_leave` | leave LFG queue |
Unknown verbs: respond with `EVENT_PUSH warn <0> bad_verb "<verb>"` and
log at INFO level so we can extend without an addon push.
## 7. Stats serializer
`BuildStatsResponse(ownerGuid)` → `STATS_RESP` with positional fields:
```
total|online|tanks|healers|dps|intents_per_sec|tick_budget_ms|
tick_used_ms|wedged|extra
```
Sources:
- `total` = all bots in this server's PlayerbotV2 registry.
- `online` = currently has a WorldSession.
- `tanks/healers/dps` = role counts within online subset.
- `intents_per_sec` = a 1s rolling counter (already in `Diagnostics/`).
- `tick_budget_ms` = `sWorld->GetIntConfig(CONFIG_INTERVAL_MAPUPDATE)` or
hard-code 33.3.
- `tick_used_ms` = `TickPerf::last_ms` (see
`project_v2_overnight_7.md` — TickPerf instrumentation exists).
- `wedged` = count where `bot.is_wedged` is set.
- `extra` = free-form comma list of `k=v` for future expansion. Leave
empty for v1.
**Cadence**: emit on `STATS_REQ` only. The LUA side polls every 5s
while UI is visible — do not push unsolicited.
## 8. EVENT_PUSH source
Server-side event sources that should fire `EVENT_PUSH` toward the bot's
owner:
| event | severity | from |
|--------------------|----------|---------------------------------------|
| `died` | warn | bot death hook |
| `intent_failed` | warn | BotIntentExecutor failure path |
| `intent_fired` | info | (optional, only if owner opted in) |
| `wedge` | error | GlobalStuckRescue / wedge detector |
| `level_up` | info | OnLevelChanged |
| `loot` | info | epic+ drop |
| `whisper` | info | bot received an inbound whisper |
| `aggro` | warn | bot pulled extra in dungeon |
| `bg_end` | info | BG completion |
| `dungeon_end` | info | dungeon completion |
| `charter_signed` | info | guild charter signature event |
| `auth` | error | rejected unauthorized CMD |
| `bad_verb` | warn | unknown verb |
| `rate_limit` | warn | client exceeded rate limit |
Each `EVENT_PUSH` gets a fresh `seq` and goes into a retry spool keyed
by seq. On `ACK <seq>`, drop. After 10s without ack, retransmit once;
after 20s, give up.
The `intent_fired` stream is high-volume — make it opt-in via a
config flag (e.g. CMD `verb=event_subscribe arg=intent_fired`).
## 9. Send-side primitives
Add a helper in `Comms/AddonOut.cpp` (new file):
```cpp
namespace PlayerbotV2::Comms {
void SendFrame(WorldSession* ownerSession,
uint32 seq,
std::string_view mtype,
std::span<const std::string> fields);
// Chunks at 240 bytes, builds the v|seq|idx|tot|MTYPE|body envelope,
// escapes pipes/backslashes, and pushes via SMSG_MESSAGECHAT/ADDON.
}
```
The send path for addon-channel messages already exists in TC (see how
DBM or RaidComms-style addons receive them). Reuse it; do not invent a
new opcode.
## 10. Lifecycle hooks
- On `OnSessionLogin`: do nothing — wait for the client's first
`STATS_REQ` or `ROSTER_REQ`.
- On `OnSessionLogout`: flush retry spool for that owner; drop pending
reassembly buffers.
- On bot creation/deletion: no immediate push (clients poll); but if
desired, emit `EVENT_PUSH info <newGuid> bot_created "<name>"` so
the UI can refresh the roster proactively.
## 11. Config knobs (`worldserver.conf`)
```
Playerbot.AddonControl.Enable = 1
Playerbot.AddonControl.RateLimitHz = 20
Playerbot.AddonControl.EventOptOut = "" ; comma list of event names
Playerbot.AddonControl.IntentStream = 0 ; opt-in to intent_fired
Playerbot.AddonControl.MaxChunkBytes = 240
```
## 12. Open questions for implementer
1. **Owner-account vs owner-character scoping**: a player on alt A
issues a CMD; their bots belong to alt B (same account). Resolve by
account_id, not character_guid. Confirm OwnerSquadControl uses the
same scope. (It currently uses character — may need broadening.)
2. **`PBCFLEET` whisper target**: Blizzard's whisper opcode requires
a real character target. Two options:
- (a) Reserve a never-spawned NPC name and intercept any whisper to
it from a session that owns bots.
- (b) Have the LUA addon send to the **owner's own name** (whispers
to yourself are still emitted as CHAT_MSG_ADDON to the server).
This is simpler — verify with a packet capture before committing.
3. **AddonMessage prefix registration**: Blizzard requires
`C_ChatInfo.RegisterAddonMessagePrefix("PBC")` client-side (we do
this in `PlayerbotControl.lua`). Server-side TC must also accept
it; check `WorldSession::HandleMessagechatOpcode`'s prefix filter
(some 12.0 builds whitelist).
4. **Chunk reassembly memory**: 2000 owners × 5 in-flight reassemblies
× 4 KB = 40 MB worst case. Probably fine. Cap per-session to 4
concurrent reassemblies.
5. **Cross-map distance**: the roster's `dist` field — what's a sane
value when owner and bot are on different continents? Suggest `-1`
and let the LUA render "--".
## 13. Definition of done
- [ ] Prefix `PBC` registered + dispatcher routed in worldserver.
- [ ] ROSTER_REQ → ROSTER_RESP round-trip works for a 1-bot account.
- [ ] ROSTER_RESP for 60+ bots reassembles correctly on the LUA side.
- [ ] CMD verbs in the table above all dispatch to existing actions.
- [ ] STATS_REQ → STATS_RESP populated from TickPerf + diagnostics.
- [ ] EVENT_PUSH for `died`, `wedge`, `intent_failed` flows reach the
Debug panel Logs tab.
- [ ] ACK / retry spool: simulated EVENT_PUSH loss is retransmitted
exactly once.
- [ ] Auth check rejects a forged CMD from a non-owner account.
- [ ] Rate-limit: 100 frames in 1s from one session drops the excess
and emits one `EVENT_PUSH warn`.
- [ ] Pre-existing playerbot subsystems (snapshot, OwnerSquadControl,
headless login, Diagnostics) are wired with **no schema changes**
to V2 snapshot structs.
@@ -0,0 +1,127 @@
#include "AltbotRegistry.h"
#include "DatabaseEnv.h"
#include "Log.h"
#include <atomic>
namespace Playerbot {
AltbotRegistry* g_altbot_registry = nullptr;
namespace {
auto& Db() { return CharacterDatabase; }
std::atomic<bool> s_table_ensured{false};
void EnsureTableExists()
{
bool expected = false;
if (!s_table_ensured.compare_exchange_strong(expected, true))
return;
Db().DirectPExecute(
"CREATE TABLE IF NOT EXISTS `playerbot_v2_altbot` ("
" `character_guid_low` BIGINT UNSIGNED NOT NULL,"
" `owner_account_id` INT UNSIGNED NOT NULL,"
" `owner_character_guid_low` BIGINT UNSIGNED NOT NULL DEFAULT 0,"
" `class` TINYINT UNSIGNED NOT NULL DEFAULT 0,"
" `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,"
" PRIMARY KEY (`character_guid_low`),"
" INDEX `idx_owner_account` (`owner_account_id`)"
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2 Altbots] Table playerbot_v2_altbot created.");
}
} // anonymous namespace
size_t AltbotRegistry::LoadFromDb()
{
// Guard: if the table doesn't exist yet (operator hasn't run the
// migration SQL), return empty without aborting.
auto check = Db().Query(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE() AND table_name = 'playerbot_v2_altbot'");
if (!check || check->Fetch()[0].GetUInt32() == 0)
{
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2 Altbots] Table playerbot_v2_altbot not found — "
"altbot system disabled until migration is applied.");
return 0;
}
auto result = Db().Query(
"SELECT character_guid_low, owner_account_id, owner_character_guid_low "
"FROM playerbot_v2_altbot");
std::unique_lock lk(mtx_);
alts_.clear();
if (!result)
{
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2 Altbots] No altbot rows loaded.");
return 0;
}
do {
Field* f = result->Fetch();
AltEntry e{};
const BotId bot = f[0].GetUInt64();
e.account_id = f[1].GetUInt32();
e.owner_char_guid_low = f[2].GetUInt64();
alts_[bot] = e;
} while (result->NextRow());
TC_LOG_INFO("playerbot.v2",
"[PlayerbotV2 Altbots] Loaded {} altbot entries.", alts_.size());
return alts_.size();
}
void AltbotRegistry::BindAlt(BotId bot, uint32 accountId, uint64 ownerCharGuidLow)
{
EnsureTableExists();
{
std::unique_lock lk(mtx_);
alts_[bot] = AltEntry{accountId, ownerCharGuidLow};
}
Db().DirectPExecute(
"REPLACE INTO playerbot_v2_altbot "
"(character_guid_low, owner_account_id, owner_character_guid_low) "
"VALUES ({}, {}, {})",
bot, accountId, ownerCharGuidLow);
}
void AltbotRegistry::UnbindAlt(BotId bot)
{
EnsureTableExists();
{
std::unique_lock lk(mtx_);
alts_.erase(bot);
}
Db().DirectPExecute(
"DELETE FROM playerbot_v2_altbot WHERE character_guid_low = {}", bot);
}
bool AltbotRegistry::IsAltbot(BotId bot) const
{
std::shared_lock lk(mtx_);
return alts_.find(bot) != alts_.end();
}
std::vector<BotId> AltbotRegistry::AltsOfAccount(uint32 accountId) const
{
std::vector<BotId> out;
if (accountId == 0) return out;
std::shared_lock lk(mtx_);
for (auto const& [bot, e] : alts_)
if (e.account_id == accountId)
out.push_back(bot);
return out;
}
std::vector<BotId> AltbotRegistry::AltsOfPlayer(uint64 ownerCharGuidLow) const
{
std::vector<BotId> out;
if (ownerCharGuidLow == 0) return out;
std::shared_lock lk(mtx_);
for (auto const& [bot, e] : alts_)
if (e.owner_char_guid_low == ownerCharGuidLow)
out.push_back(bot);
return out;
}
} // namespace Playerbot
@@ -0,0 +1,56 @@
// AltbotRegistry — player-bound companion ownership.
// Altbots are independent of the fleet system: no population manager, no world
// AI, no autonomous questing. They exist only to follow + assist their owner.
//
// Storage: persisted in playerbot_v2_altbot. In-memory cache is a hash map
// guarded by shared_mutex. Writes are immediate.
//
// World-thread mutations (Bind/Unbind). Reads from any thread.
#pragma once
#include "Bot/BotTypes.h"
#include <cstdint>
#include <shared_mutex>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace Playerbot {
class AltbotRegistry;
extern AltbotRegistry* g_altbot_registry;
class AltbotRegistry
{
public:
size_t LoadFromDb();
void BindAlt(BotId bot, uint32 accountId, uint64 ownerCharGuidLow);
void UnbindAlt(BotId bot);
bool IsAltbot(BotId bot) const;
std::vector<BotId> AltsOfAccount(uint32 accountId) const;
std::vector<BotId> AltsOfPlayer(uint64 ownerCharGuidLow) const;
private:
struct AltEntry
{
uint32 account_id = 0;
uint64 owner_char_guid_low = 0;
};
mutable std::shared_mutex mtx_;
std::unordered_map<BotId, AltEntry> alts_;
};
inline bool IsAltbotGuid(BotId id)
{
// Fast check — callers that already have the registry pointer can
// call ->IsAltbot(id) directly. This convenience overload hits the
// static member which is safer for places without easy access.
extern AltbotRegistry* g_altbot_registry;
return g_altbot_registry && g_altbot_registry->IsAltbot(id);
}
} // namespace Playerbot
@@ -0,0 +1,41 @@
// BagSizeTable - curated table of "universally available" general-purpose
// bag entries by capacity. Used by the vendor-visit FSM's bag-upgrade phase.
//
// All entries here are class ITEM_CLASS_CONTAINER, subclass 0 (generic bag).
// Profession-specific bags (Herb / Mining / Engineering / etc) are handled
// separately and depend on the bot's professions.
//
// Prices are vendor-buy approximations; actual cost depends on faction-
// reputation discount and vendor markup. The bag-upgrade rule applies a
// 20% safety margin on bot.gold so a slight under-estimate doesn't trip
// the buy.
#pragma once
#include "Define.h"
#include <array>
namespace Playerbot {
struct BagSizeRow
{
uint8 capacity; // Slot count
uint32 item_entry; // ItemTemplate.entry; vendor-buyable in starter zones
uint32 approx_price; // Vendor copper, no discount
};
// Sorted ascending by capacity. The buy logic walks the array picking the
// largest entry whose price ≤ gold * 0.5 (keep half gold for other needs).
// Entries beyond level-30ish content require crafting / AH; not stocked
// at NPC vendors. The lookup gracefully degrades to the largest entry
// whose price the bot can afford.
inline constexpr std::array<BagSizeRow, 6> kBagSizeTable = {{
{ 6, 4496, 500 }, // Linen Bag — ~5s
{ 8, 5572, 1500 }, // Wool Bag — ~15s
{ 10, 10050, 5000 }, // Mageweave Bag — ~50s
{ 14, 14046, 30000 }, // Runecloth Bag — ~3g
{ 16, 21841, 50000 }, // Netherweave Bag — ~5g
{ 20, 41599, 100000 }, // Frostweave Bag — ~10g
}};
} // namespace Playerbot
@@ -0,0 +1,175 @@
#include "BattlegroundScript.h"
#include "../BotSnapshotView.h"
#include "../BotSnapshot.h"
#include "Log.h"
#include <shared_mutex>
#include <unordered_map>
#include <unordered_set>
namespace Playerbot {
namespace {
// Cross-bot callout-claim state. Process-global (one BG fleet per
// worldserver). Hash key combines callout-kind in high 32 bits with
// the per-callout key (node entry / FC guid bucket / etc) in low.
std::shared_mutex g_callout_mtx;
std::unordered_map<uint64_t, uint32_t> g_callout_until_ms;
inline uint64_t MakeCalloutKey(uint32_t kind, uint64_t key)
{
return (uint64_t(kind) << 32) | (key & 0x00000000FFFFFFFFull);
}
} // anonymous
bool BgCalloutCoordinator::TryClaim(uint32_t kind, uint64_t key,
uint32_t now_ms, uint32_t lockout_ms)
{
const uint64_t k = MakeCalloutKey(kind, key);
{
std::shared_lock<std::shared_mutex> lk(g_callout_mtx);
auto it = g_callout_until_ms.find(k);
if (it != g_callout_until_ms.end() && now_ms < it->second)
return false; // somebody else already shouted within window
}
std::unique_lock<std::shared_mutex> lk(g_callout_mtx);
// Re-check under write lock (TOCTOU guard — another worker may have
// claimed in the gap between our shared-read and write-acquire).
auto it = g_callout_until_ms.find(k);
if (it != g_callout_until_ms.end() && now_ms < it->second)
return false;
g_callout_until_ms[k] = now_ms + lockout_ms;
// Opportunistic GC: when the table grows past ~256 entries (way
// more than realistic callout-types × nodes), purge expired keys.
if (g_callout_until_ms.size() > 256)
{
for (auto eit = g_callout_until_ms.begin(); eit != g_callout_until_ms.end(); )
{
if (eit->second <= now_ms) eit = g_callout_until_ms.erase(eit);
else ++eit;
}
}
return true;
}
void BattlegroundScriptMgr::Register(std::unique_ptr<BattlegroundScript> script)
{
if (!script) return;
scripts_.emplace(script->bg_type_id(), std::move(script));
}
// Modern WoW exposes the same BG map under many BattlemasterList IDs:
// Domination / Comp Stomp / Brawl / CTF variants all reuse the original
// map but bind a different DBC entry. Without aliasing, GetScriptFor
// returns nullptr for the variant id and bots get no role/node advice
// — they stand in the start area doing nothing. Map each variant back
// to the base BG we already wrote a script for. IDs sourced from
// `BATTLEGROUND_*` constants in SharedDefines.h.
//
// Only listing variants that map cleanly to a base BG present in our
// 14 scripts (AV=1, WS=2, AB=3, EY=7, SA=9, IC=30, TP=108, BFG=120,
// TK=699, SM=708, DG=754, SS=894, EB_A=1020, plus catch-all 1018-style
// modern variants). Unmapped variants (Arenas / Epic BG modes / brand-
// new BGs) keep returning nullptr and the bots fall back to generic
// arena formation / no-advice behavior as before.
static uint16_t AliasToBaseBg(uint16_t variant_id)
{
switch (variant_id)
{
// Warsong Gulch family
case 1014: // BATTLEGROUND_WG_CTF
case 861: // BATTLEGROUND_BRAWL_WS
case 886: // BATTLEGROUND_BRAWL_WG
return 2; // BATTLEGROUND_WS
// Arathi Basin family
case 1018: // BATTLEGROUND_DOM_AB
case 1019: // BATTLEGROUND_AB_CS
case 847: // BATTLEGROUND_BRAWL_ABW
case 880: // BATTLEGROUND_BRAWL_AB
case 1022: // BATTLEGROUND_BRAWL_AB2
return 3; // BATTLEGROUND_AB
// Eye of the Storm family
case 859: // BATTLEGROUND_BRAWL_GL (Gravity Lapse — low-grav EotS variant)
case 862: // BATTLEGROUND_BRAWL_EH (Eye of the Horn — Outland EotS reskin)
case 882: // BATTLEGROUND_BRAWL_ES
return 7; // BATTLEGROUND_EY
// Battle for Gilneas family
case 846: // BATTLEGROUND_BRAWL_TBG (old)
case 885: // BATTLEGROUND_BRAWL_TBG2
return 120; // BATTLEGROUND_BFG
// Temple of Kotmogu family
case 858: // BATTLEGROUND_BRAWL_TH
case 884: // BATTLEGROUND_BRAWL_TK
return 699; // BATTLEGROUND_TK
// Silvershard Mines family
case 883: // BATTLEGROUND_BRAWL_SM
return 708; // BATTLEGROUND_SM
// Deepwind Gorge (754) + Ashran (1020/1021) aliases REMOVED (BG audit
// dead-code cleanup): their base scripts are no longer registered (no
// battleground_template row, never queued, never dispatched). An
// unmapped id simply falls through to GetScriptFor → no-op, same as
// before. Re-add with the registration if they become real BGs.
// Seething Shore family
case 890: // BATTLEGROUND_DOM_SS (Seething Strand variant)
return 894; // BATTLEGROUND_SS
// Alterac Valley family — Korrak's Revenge is AV-classic with extra
// questables (Black Lotus, Korrak world boss). Map + node layout
// is the live AV map; AV advice serves it correctly.
case 1033: // BATTLEGROUND_KR (Korrak's Revenge)
return 1; // BATTLEGROUND_AV
// Warfront Arathi (PvP epic mode) — same map as AB plus extra
// workshops/mercenaries. Treat as AB until a dedicated script
// models the vehicle/mercenary mechanics.
case 1036: // BATTLEGROUND_EPIC_BG_WF
return 3; // BATTLEGROUND_AB
default:
return 0; // no alias
}
}
BattlegroundScript const* BattlegroundScriptMgr::TryGetScriptFor(uint16_t bg_type_id) const
{
if (auto it = scripts_.find(bg_type_id); it != scripts_.end())
return it->second.get();
if (uint16_t base = AliasToBaseBg(bg_type_id); base != 0)
if (auto it = scripts_.find(base); it != scripts_.end())
return it->second.get();
return nullptr;
}
BattlegroundScript const* BattlegroundScriptMgr::GetScriptFor(uint16_t bg_type_id) const
{
if (BattlegroundScript const* script = TryGetScriptFor(bg_type_id))
return script;
// Surface unmapped variants once per process — if a future client patch
// introduces a new BG ID we don't handle, bots will fall back to no-advice
// (idle in start area). The warning makes that visible at the WARN level
// so a /reload-config or grep over Server.log pinpoints which alias entry
// to add.
static std::shared_mutex warned_mtx;
static std::unordered_set<uint16_t> warned;
{
std::shared_lock<std::shared_mutex> lk(warned_mtx);
if (warned.contains(bg_type_id)) return nullptr;
}
{
std::unique_lock<std::shared_mutex> lk(warned_mtx);
if (warned.insert(bg_type_id).second)
TC_LOG_WARN("playerbot.v2",
"[BgScriptMgr] no script registered for bg_type_id={}; bots will "
"have no role/node advice. Add to AliasToBaseBg() if this is a "
"variant of an existing BG.", uint32(bg_type_id));
}
return nullptr;
}
BattlegroundAdvice BattlegroundScriptMgr::GetAdvice(BotSnapshotView const& s) const
{
BattlegroundScript const* script = GetScriptFor(s.raw().bg.current_type_id);
if (!script) return {};
return script->get_advice(s);
}
} // namespace Playerbot
@@ -0,0 +1,296 @@
// BattlegroundScript — per-battleground override hooks for the
// autonomous BG-run system. Mirrors the DungeonScript pattern;
// each registered script is keyed by `bg_type_id` (BattlemasterList.dbc).
//
// Generic BG logic (fight nearest enemy, heal lowest-HP teammate)
// works for combat positioning but doesn't know objectives. Scripts
// fill in: which objective to push, who carries the flag, when to
// chase enemy carrier, role assignments per bot index.
#pragma once
#include <cstdint>
#include <memory>
#include <unordered_map>
#include <vector>
namespace Playerbot {
class BotSnapshotView;
// Role assigned to a bot for the duration of the BG. Determined by
// script + bot's class/spec/index. Drives objective-aware actions.
enum class BgRole : uint8_t
{
Free = 0, // No role — fall back to generic combat.
FlagCarrier= 1, // Pick up + carry the flag (CTF).
FCEscort = 2, // Stay with FC; intercept enemies.
Defender = 3, // Stay at home objective.
Attacker = 4, // Push enemy objective.
Roamer = 5, // Mid-map roving (interrupt enemy moves).
Healer = 6, // Healer-spec — focus FC if applicable.
// OrbCarrier — Kotmogu orb mechanic. Like FlagCarrier, picks up an
// enemy_flag-tagged GO; UNLIKE FlagCarrier, does NOT return home to
// cap. After pickup, holds at home_base (map center) to accumulate
// the SmallAura distance-from-center score multiplier. Multiple
// OrbCarrier slots are expected (ToK has up to 4 carriers per side).
OrbCarrier = 7,
};
struct BattlegroundAdvice
{
// Per-bot role assignment indexed by formation_slot.
// Empty = no role; bot uses generic logic.
std::vector<BgRole> role_by_slot;
// Should the bot chase the enemy flag carrier (CTF only)?
// Honored only when bg_enemy_flag_carrier is non-empty.
bool chase_enemy_carrier = false;
// Should the bot escort the friendly flag carrier?
bool escort_friendly_carrier = false;
// CTF-specific: the bot's own-flag pickup position (where the
// friendly faction flag spawns) and the enemy flag pickup position
// (where to go grab the cap target). {0,0,0} sentinel = no advice.
// Populated by WSG/TP/BfG scripts. Faction selection (which one is
// "friendly" vs "enemy") is the script's responsibility — it reads
// the snapshot's faction context inside get_advice.
float own_flag_x = 0.f, own_flag_y = 0.f, own_flag_z = 0.f;
float enemy_flag_x = 0.f, enemy_flag_y = 0.f, enemy_flag_z = 0.f;
// Default home-base hold position for Defender role. Same {0,0,0}
// sentinel meaning. Set by node-race scripts (AB, BfG, AV, EotS).
float home_base_x = 0.f, home_base_y = 0.f, home_base_z = 0.f;
// Score lead/deficit (my_score - enemy_score) at which the tactical
// bias flips to Turtle (>= +threshold) / AllIn (<= -threshold).
// Units are whatever the BG's score worldstates count: resource
// points for AB/BfG/EotS/Kotmogu (max 1500-2000 -- default 200 is a
// "noticeable lead"), but FLAG CAPS for WSG/TP (max 3) where 200
// could never trigger (BG audit N55/N65) -- CTF scripts set 2.
int32_t score_bias_threshold = 200;
// Endgame target — used by Attacker / Roamer roles when match is
// in "all-in" tactical state (score-bias = AllIn or BG-specific
// late-phase trigger). For AV it points at the enemy keep / boss;
// for IoC the enemy fortress door. {0,0,0} = no endgame override
// (Attackers continue with normal node-push logic).
float endgame_target_x = 0.f, endgame_target_y = 0.f, endgame_target_z = 0.f;
// When true, the Attacker endgame redirect to endgame_target fires
// UNCONDITIONALLY (not only under the Bias_AllIn score state). Used by
// round-based objective BGs whose score worldstates stay 0/0 all match
// so the score-derived bias never leaves Normal (SoTA: AddPoint is never
// called, so attackers were never driven to the breach / relic and the
// match could not end — BG audit SoTA blocker). Default false keeps the
// score-gated behavior for point-race BGs (AV/IoC).
bool endgame_unconditional = false;
// Optional NPC entry of the boss/general whose kill ends the match
// (AV Drek'thar/Vandar, IoC Halford/Agmar, Ashran Volrath/Tremblade).
// When set AND a NearbyUnit with this entry is visible to the bot,
// the Attacker endgame rule chases the live unit position instead of
// the static endgame_target coord. Handles bosses that walk around /
// path into adjacent rooms; the static coord is the fallback used
// until the bot is close enough to acquire the unit.
uint32_t endgame_creature_entry = 0;
// CTF enemy-flag-carrier chase gate. When true AND chase_enemy_carrier
// is true, only melee classes (Warrior, Rogue, DK, Monk, Feral Druid,
// Ret Pala, Enh Sham, DH, Hunter) chase — clothie casters stay on
// their objective. Without this, mages/priests sprint into a moving
// FC at 1v1 range and feed honor across the map. Default false keeps
// pre-existing chase behavior (everyone chases).
bool chase_melee_only = false;
// Preferred classes for the FlagCarrier role. When non-empty, the
// BG dispatcher applies a deterministic, per-bot role override:
// * Bots whose class IS in this list get my_role = FlagCarrier
// regardless of their hashed slot (so stealth classes always
// take the FC job in WSG/TP).
// * Bots whose class is NOT in this list AND whose hashed slot
// WOULD have given them FlagCarrier get demoted to Roamer
// (so the FC role is never wasted on a clothie that can't
// survive the kite). The acts_as_fc dynamic-handoff path
// handles the case where no preferred-class bot is present.
// Class ids match SharedDefines::CLASSMASK_* (Warrior=1 ... Evoker=13).
// Empty = no override (default behavior — slot 0 is whichever class
// happens to hash there).
std::vector<uint8_t> fc_class_preference;
// Per-node positions for node-race BGs (AB/BfG/EotS/Gorge/IoC/AV).
// When non-empty, takes precedence over `home_base_*` for Defender
// role: each Defender slot is assigned a node (round-robin over
// node count) and moves to that node's coords. Attackers and
// Roamers iterate through these positions to push undefended /
// contested nodes.
//
// `priority`: 0 = default; >0 = high-value target. The Attacker rule
// applies it as a tie-breaker when two nodes have the same
// ownership-priority bucket (neutral / contested / enemy-held).
// Used by AV to bias toward towers/bunkers (which drain more
// reinforcements per cap than graveyards). 0 for all script's
// current nodes leaves behavior unchanged.
struct Node {
float x = 0.f, y = 0.f, z = 0.f;
char const* name = "";
uint8_t priority = 0;
// When non-zero, consumer should resolve the node's LIVE position
// by scanning nearby_friends + nearby_enemies for the closest Unit
// with this creature entry and using its m_position. Falls back
// to the static x/y/z when no live unit visible. Used for moving
// objectives (Silvershard cart entry 60140) — the static coord is
// the cart's spawn point but the cart moves on rails over 30s+.
uint32_t follow_creature_entry = 0;
};
std::vector<Node> nodes;
// Auto-use any GO of these types when within ~5 yards. Lets the
// bot hit AB/EotS/BfG flagstands and AV banners on contact without
// per-script coords. Empty = generic logic only. Common values:
// * GAMEOBJECT_TYPE_FLAGSTAND (24) — capturable nodes.
// * GAMEOBJECT_TYPE_FLAGDROP (26) — dropped flags (return).
std::vector<uint32_t> auto_use_go_types;
// Auto-use any GO with one of these ENTRIES when within ~5 yards
// (audit B26): AV / IoC / SotA banners are GO type 1 (BUTTON) and
// type 10 (GOOBER) — blanket-auto-using those TYPES is unsafe (doors,
// levers, quest props share them), so these maps enumerate the exact
// DB-verified banner / relic entries instead. Without this, bots on
// the epic BGs could never assault/defend a node or cap the SotA
// Titan Relic (the literal win condition). Empty = type matching only.
std::vector<uint32_t> auto_use_go_entries;
// Creature entries the bot should mount when within range (8y).
// Lets SoTA bots hop into demolishers, IoC bots into siege engines /
// glaives / catapults. The bot picks the closest matching nearby
// friendly Creature with an empty driver seat. Empty = no auto-mount.
std::vector<uint32_t> vehicle_creature_entries;
// Default spell ID the bot fires from its current vehicle seat once
// mounted and an enemy is in range/LoS. Typically the seat 0 driver
// ability (boulder hurl, glaive throw etc). 0 = no automatic seat
// fire — the bot mounts but waits for owner direction. Used as
// fallback when no entry-specific override is registered.
uint32_t vehicle_seat_spell = 0;
// Per-vehicle-entry spell override. Looked up by the bot's current
// vehicle creature entry; falls back to vehicle_seat_spell on miss.
// Used by IoC where Demolisher / Siege Engine / Glaive Thrower /
// Catapult / Keep Cannon each fire a different primary ability.
std::unordered_map<uint32_t, uint32_t> vehicle_seat_spell_by_entry;
// DESTRUCTIBLE_BUILDING GameObject entries a SIEGE VEHICLE should fire
// its seat spell AT (cast_vehicle_at the gate's live position). These are
// the ENEMY gates the bot's team must breach — IoC enemy keep gates,
// SoTA defense-line gates on the attacker round. When non-empty and the
// bot is in a vehicle with a seat spell, the bg_vehicle_fire_gate rule
// targets the closest standing (is_destroyed==false) matching gate before
// it looks for unit targets. Empty = no gate-fire (unit fire only).
// Without this, the seat-fire rule only saw Units (nearby_enemies) and
// never the gate GOs, so gates never fell and the General was never
// reached (BG audit IoC / SoTA siege blockers).
std::vector<uint32_t> siege_target_go_entries;
// Arena tactical positions — pillars / cubbies for LoS breaks +
// hazard zones to avoid. Distinct from `nodes[]` (which feeds the
// Attacker/Defender objective-push pipeline) — arena pillars/hazards
// ONLY drive the `idle:arena_position` rule. Empty for all non-arena
// BGs (no behavioural change there).
struct ArenaPillar {
float x = 0.f, y = 0.f, z = 0.f;
char const* name = "";
// 0 = LoS pillar (ranged hides behind, healer LoS-breaks melee)
// 1 = cubby (corner cover, also a fallback when in hazard)
// 2 = high-ground (perch — preferred starting position for ranged)
uint8_t kind = 0;
};
std::vector<ArenaPillar> arena_pillars;
struct ArenaHazard {
float x = 0.f, y = 0.f, z = 0.f;
float radius = 5.f;
char const* name = "";
// Time-gated activation (ms since match start). 0 = always
// active. Ring of Valor pillar elevators: active_after_ms=60000.
uint32_t active_after_ms = 0;
uint32_t active_until_ms = 0; // 0 = forever
};
std::vector<ArenaHazard> arena_hazards;
// Where to advance when the starting gate drops. {0,0,0} sentinel =
// don't override (default arena-center movement applies).
float opening_rally_x = 0.f, opening_rally_y = 0.f, opening_rally_z = 0.f;
};
class BattlegroundScript
{
public:
virtual ~BattlegroundScript() = default;
virtual uint16_t bg_type_id() const = 0;
virtual char const* name() const = 0;
// Per-tick advice. Snapshot view is read-only.
virtual BattlegroundAdvice get_advice(BotSnapshotView const& s) const = 0;
};
class BattlegroundScriptMgr
{
public:
BattlegroundScriptMgr() = default;
void Register(std::unique_ptr<BattlegroundScript> script);
BattlegroundScript const* GetScriptFor(uint16_t bg_type_id) const;
BattlegroundAdvice GetAdvice(BotSnapshotView const& s) const;
size_t size() const { return scripts_.size(); }
// Iterate every registered base script (variants registered via
// AliasToBaseBg are not in the map). Used by the static
// `.playerbot smoketest bg` validator that walks the registry
// to verify each script's coords / GO types / vehicle entries
// resolve through ObjectMgr + a sane data range.
template <class Fn>
void for_each_script(Fn fn) const
{
for (auto const& [key, script] : scripts_)
if (script) fn(*script);
}
// Resolve a bg_type_id to its base script WITHOUT emitting the
// "no script registered" WARN. Used by alias-coverage assertions
// that intentionally probe IDs which may legitimately have no
// base (queue meta-IDs like RB/RATED/RANDOM).
BattlegroundScript const* TryGetScriptFor(uint16_t bg_type_id) const;
private:
std::unordered_map<uint16_t, std::unique_ptr<BattlegroundScript>> scripts_;
};
// Cross-bot callout coordinator. When 40 AV bots all simultaneously
// detect "FC down", a per-bot 20s lockout doesn't dedup the chat — 30
// "FC down" lines hit raid-chat in the same tick. This shared registry
// lets the FIRST bot per (callout_kind, key) claim the slot for a
// configurable window; everyone else suppresses their emit. The key
// space is callouts identified by a stable 64-bit packed value (kind
// in high bits, target/node identifier in low bits) so two distinct
// nodes can each have an active callout but the same node can't be
// re-shouted by 40 bots.
//
// Threading: callout claims happen on the AI worker thread (one bot
// per worker per tick); the registry uses a shared_mutex for read-
// dominant lookups + an upgrade write when claiming/expiring entries.
//
// Cardinality: bounded by callout-types × distinct keys (~6 × ~16
// nodes-per-BG) = O(100) entries; trivial cost.
class BgCalloutCoordinator
{
public:
// Returns true if THIS bot wins the claim for (kind, key) with the
// given lockout window. False = another bot already shouted within
// the window; suppress this emit.
static bool TryClaim(uint32_t kind, uint64_t key, uint32_t now_ms,
uint32_t lockout_ms);
};
} // namespace Playerbot
@@ -0,0 +1,797 @@
// BgTeamCoordinator implementation. See header for the design contract.
//
// Planning model
// --------------
// Every kPlanIntervalMs the coordinator rebuilds the full order map from
// scratch (registry walk -> bucket by (BG instance, team) -> per-family
// planner). Hysteresis is applied INSIDE the assignment step: a bot whose
// previous order matches a candidate (same kind, same target within
// kStickyRadius) gets a cost discount, so stable inputs reproduce the
// previous plan and bots don't thrash between equidistant objectives.
//
// The coordinator only orders V2 bots. Human teammates are observed
// indirectly (through the node pressure counts the builder harvests) but
// never directed. Bots the plan does not cover keep order.kind == None and
// run the legacy greedy role logic — the coordinator concentrates force
// where coordination beats greed and deliberately leaves the rest alone.
#include "BgTeamCoordinator.h"
#include "BattlegroundScript.h"
#include "../BotRegistry.h"
#include "../BotSnapshotView.h"
#include "../ClassTables.h"
#include "../../Services.h"
#include "../../Threading/SnapshotPublisher.h"
#include "Battleground.h"
#include "Config.h"
#include "Log.h"
#include "Map.h"
#include "ObjectAccessor.h"
#include "Player.h"
#include <algorithm>
#include <cmath>
#include <sstream>
#include <unordered_set>
namespace Playerbot {
namespace {
// Full re-plan cadence. 750ms is fast enough to chase carrier movement and
// node flips (BG state changes on second granularity) while keeping the
// cost negligible: one registry walk + a few hundred float ops per team.
constexpr uint32 kPlanIntervalMs = 750;
// A previous order "matches" a new candidate when same-kind and the target
// moved less than this — used for the hysteresis cost discount.
constexpr float kStickyRadius = 10.0f;
// Cost multiplier for a sticky match. 0.7 means a bot keeps its current
// assignment unless a competing one is >30% closer.
constexpr float kStickyDiscount = 0.7f;
float Dist2(float ax, float ay, float bx, float by)
{
const float dx = ax - bx, dy = ay - by;
return dx * dx + dy * dy;
}
bool PosSet(float x, float y) { return x != 0.f || y != 0.f; }
} // namespace
// ---------------------------------------------------------------------------
// Assignment bookkeeping
// ---------------------------------------------------------------------------
void BgTeamCoordinator::AssignOrder(uint64 guid, uint8 kind, float x, float y,
float z, ObjectGuid focus, uint8 squad,
uint32 target_entry)
{
BgOrder o;
o.kind = kind;
o.x = x;
o.y = y;
o.z = z;
o.focus = focus;
o.squad = squad;
o.target_entry = target_entry;
next_orders_[guid] = o;
}
// Pick the cheapest unassigned, alive member for a target, with the
// hysteresis discount against the PREVIOUS order map. `healer_bias` > 0
// pushes healers toward the end of the pick order (defense posts), < 0
// pulls them forward (escort slots), 0 is neutral. For focus-orders
// (escort/hunt) pass the focus guid: stickiness keys on it instead of
// position — the carrier MOVES between plans, so a positional match
// would never hold and escorts would churn every 750ms.
int BgTeamCoordinator::PickNearest(std::vector<Member>& members, uint8 kind,
float tx, float ty, int healer_bias,
bool allow_carrier, ObjectGuid focus) const
{
int best = -1;
float best_cost = 1e30f;
for (int i = 0; i < int(members.size()); ++i)
{
Member const& m = members[i];
if (m.assigned || !m.alive) continue;
if (m.is_carrier && !allow_carrier) continue;
// Order matters: the sticky discount scales DISTANCE only. Adding
// the signed healer bias first would let the multiplier magnify
// (or, on negative costs, invert) the role preference instead of
// expressing "30% closer before reassignment".
float cost = std::sqrt(Dist2(m.x, m.y, tx, ty));
auto prev = orders_.find(m.guid_low);
if (prev != orders_.end() && prev->second.kind == kind &&
(!focus.IsEmpty()
? prev->second.focus == focus
: Dist2(prev->second.x, prev->second.y, tx, ty) <
kStickyRadius * kStickyRadius))
cost *= kStickyDiscount;
if (m.healer && healer_bias > 0) cost += 200.f; // keep healers off lone posts
if (m.healer && healer_bias < 0) cost -= 150.f; // prefer healers for escort
if (cost < best_cost) { best_cost = cost; best = i; }
}
return best;
}
// ---------------------------------------------------------------------------
// CTF / carrier family (WSG, TP, Kotmogu, and the flag layer of EotS /
// Deephaul). Assigns: carriers -> CarryHome, a pickup runner when no
// friendly carrier exists, escorts on each carrier, and an EFC hunt squad.
// ---------------------------------------------------------------------------
void BgTeamCoordinator::PlanCtf(TeamPlanContext& ctx)
{
const bool kotmogu = ctx.type_id == 699;
const bool node_play = !ctx.nodes.empty(); // hybrid (EotS/Deephaul)
// -- Carriers: run it home / hold center --------------------------------
for (auto& m : ctx.members)
{
if (!m.is_carrier || !m.alive) continue;
float hx = ctx.home_x, hy = ctx.home_y, hz = ctx.home_z;
if (!kotmogu && node_play)
{
// EotS-style: cap at the nearest node we own; if we own none,
// run at the nearest node at all (capping it wins twice).
float best = 1e30f;
bool found_owned = false;
for (auto const& n : ctx.nodes)
{
if (n.is_destroyed) continue;
const bool owned = n.owner_team == ctx.team && !n.is_contested;
if (found_owned && !owned) continue;
const float d = Dist2(m.x, m.y, n.x, n.y);
if ((owned && !found_owned) || d < best)
{
best = d; hx = n.x; hy = n.y; hz = n.z;
if (owned) found_owned = true;
}
}
}
else if (!kotmogu && PosSet(ctx.own_flag_x, ctx.own_flag_y))
{
// WSG/TP: cap point is the own-flag spawn.
hx = ctx.own_flag_x; hy = ctx.own_flag_y; hz = ctx.own_flag_z;
}
// Kotmogu: home_base IS the center scoring zone — already set.
if (PosSet(hx, hy))
{
AssignOrder(m.guid_low, BgOrder::CarryHome, hx, hy, hz);
m.assigned = true;
}
}
// -- Pickup runner(s) when we have no carrier ---------------------------
// Kotmogu fields 4 orbs; send up to 2 runners for free ones. Everyone
// else fields exactly one flag: 1 runner.
int have_carriers = 0;
for (auto const& m : ctx.members)
if (m.is_carrier && m.alive) ++have_carriers;
// External (human) carriers count toward "we already have the flag"
// on single-flag maps; on Kotmogu more orbs is always better, so the
// bot-runner count ignores them there.
const int want_runners =
kotmogu ? std::max(0, 2 - have_carriers)
: ((have_carriers + ctx.external_carriers) > 0 ? 0 : 1);
std::vector<int> runner_idx;
if (kotmogu && want_runners > 0 && !ctx.advice_nodes.empty())
{
// Kotmogu: nodes[] holds the FOUR orb spawns, but the scalar
// enemy_flag is the ref bot's guid-hashed corner — sending both
// runners there means one grabs and one stares at an empty spawn.
// Assign each runner a DISTINCT orb: greedy cheapest
// (member, unclaimed-orb) pair per round.
std::vector<bool> orb_claimed(ctx.advice_nodes.size(), false);
for (int r = 0; r < want_runners; ++r)
{
int best_m = -1, best_o = -1;
float best_d = 1e30f;
for (int i = 0; i < int(ctx.members.size()); ++i)
{
Member const& m = ctx.members[i];
if (m.assigned || !m.alive || m.healer || m.is_carrier)
continue;
for (int o = 0; o < int(ctx.advice_nodes.size()); ++o)
{
if (orb_claimed[o]) continue;
const float d = Dist2(m.x, m.y, ctx.advice_nodes[o].x,
ctx.advice_nodes[o].y);
if (d < best_d) { best_d = d; best_m = i; best_o = o; }
}
}
if (best_m < 0) break;
AssignOrder(ctx.members[best_m].guid_low, BgOrder::PickupFlag,
ctx.advice_nodes[best_o].x, ctx.advice_nodes[best_o].y,
ctx.advice_nodes[best_o].z);
ctx.members[best_m].assigned = true;
orb_claimed[best_o] = true;
runner_idx.push_back(best_m);
}
}
else if (want_runners > 0 && PosSet(ctx.enemy_flag_x, ctx.enemy_flag_y))
{
for (int r = 0; r < want_runners; ++r)
{
// Prefer the script's FC classes (stealth in WSG/TP): try a
// preferred-class pick first, fall back to anyone.
int pick = -1;
if (!ctx.fc_class_preference.empty())
{
float best_cost = 1e30f;
for (int i = 0; i < int(ctx.members.size()); ++i)
{
Member const& m = ctx.members[i];
if (m.assigned || !m.alive || m.healer || m.is_carrier)
continue;
bool preferred = false;
for (uint8 c : ctx.fc_class_preference)
if (c == m.cls) { preferred = true; break; }
if (!preferred) continue;
const float cost =
std::sqrt(Dist2(m.x, m.y, ctx.enemy_flag_x, ctx.enemy_flag_y));
if (cost < best_cost) { best_cost = cost; pick = i; }
}
}
if (pick < 0)
pick = PickNearest(ctx.members, BgOrder::PickupFlag,
ctx.enemy_flag_x, ctx.enemy_flag_y,
/*healer_bias=*/+1, /*allow_carrier=*/false);
if (pick < 0) break;
AssignOrder(ctx.members[pick].guid_low, BgOrder::PickupFlag,
ctx.enemy_flag_x, ctx.enemy_flag_y, ctx.enemy_flag_z);
ctx.members[pick].assigned = true;
runner_idx.push_back(pick);
}
}
// -- Escorts -------------------------------------------------------------
// Each live carrier gets an escort detail; the pickup runner gets one
// too on pure-CTF maps (the run back is where flags die). First escort
// slot prefers a healer.
const int escorts_per_carrier = node_play ? 1 : (kotmogu ? 1 : 3);
auto escort_to = [&](float ex, float ey, float ez, ObjectGuid focus,
int count, uint8 squad)
{
for (int e = 0; e < count; ++e)
{
const int pick =
PickNearest(ctx.members, BgOrder::EscortFC, ex, ey,
/*healer_bias=*/e == 0 ? -1 : +1,
/*allow_carrier=*/false, focus);
if (pick < 0) return;
AssignOrder(ctx.members[pick].guid_low, BgOrder::EscortFC,
ex, ey, ez, focus, squad);
ctx.members[pick].assigned = true;
}
};
uint8 squad_no = 1;
for (auto const& m : ctx.members)
if (m.is_carrier && m.alive)
escort_to(m.x, m.y, m.z,
ObjectGuid::Create<HighGuid::Player>(m.guid_low),
escorts_per_carrier, squad_no++);
// A human teammate carrying the flag gets the same escort detail —
// the executor follows the live carrier guid, so the plan-time
// position only seeds the distance ranking.
if (ctx.scalar_carrier_is_external && !kotmogu &&
PosSet(ctx.friendly_carrier_x, ctx.friendly_carrier_y))
escort_to(ctx.friendly_carrier_x, ctx.friendly_carrier_y,
ctx.friendly_carrier_z, ctx.friendly_carrier,
escorts_per_carrier, squad_no++);
if (!node_play && !kotmogu)
for (int ri : runner_idx)
escort_to(ctx.members[ri].x, ctx.members[ri].y, ctx.members[ri].z,
ObjectGuid::Create<HighGuid::Player>(ctx.members[ri].guid_low),
/*count=*/1, squad_no++);
// -- EFC hunt squad (pure CTF only) --------------------------------------
// Concentrated 3-bot intercept on the enemy carrier. On hybrid maps the
// node planner owns the remainder; on Kotmogu killing carriers is the
// whole midfield game and the legacy in-combat target switch (EFC-first)
// already handles it once bodies are in the center.
if (!node_play && !kotmogu && !ctx.enemy_carrier.IsEmpty() &&
PosSet(ctx.enemy_carrier_x, ctx.enemy_carrier_y))
{
const int hunters = int(ctx.members.size()) >= 8 ? 3 : 2;
for (int h = 0; h < hunters; ++h)
{
const int pick =
PickNearest(ctx.members, BgOrder::HuntEFC,
ctx.enemy_carrier_x, ctx.enemy_carrier_y,
/*healer_bias=*/+1, /*allow_carrier=*/false,
ctx.enemy_carrier);
if (pick < 0) break;
AssignOrder(ctx.members[pick].guid_low, BgOrder::HuntEFC,
ctx.enemy_carrier_x, ctx.enemy_carrier_y,
ctx.enemy_carrier_z, ctx.enemy_carrier);
ctx.members[pick].assigned = true;
}
}
// Remainder stays unordered on pure CTF: the legacy mid-pressure /
// score-bias roles are already good there, and leaving them free keeps
// graceful degradation honest.
}
// ---------------------------------------------------------------------------
// Node-race family (AB, BfG, AV, IoC, Deephaul, EotS towers, DG).
// Quota-based defense scaled by live enemy pressure, then ONE concentrated
// attack squad on the weakest takeable node — the single biggest win over
// per-bot greed, which smears attackers across every enemy node at once.
// ---------------------------------------------------------------------------
void BgTeamCoordinator::PlanNodeRace(TeamPlanContext& ctx)
{
if (ctx.nodes.empty()) return;
const uint8 enemy_team = ctx.team == 1 ? 2 : 1;
const bool turtle = ctx.score_delta >= ctx.score_bias_threshold ||
(ctx.in_progress_ms > 18u * 60u * 1000u && ctx.score_delta > 0);
const bool all_in = ctx.score_delta <= -ctx.score_bias_threshold ||
(ctx.in_progress_ms > 18u * 60u * 1000u && ctx.score_delta < 0);
auto enemy_near = [&](BgNodeState const& n) -> int
{ return ctx.team == 1 ? n.horde_players_near : n.alliance_players_near; };
// -- Defense demands ------------------------------------------------------
// CONTESTED SEMANTICS (builder contract, BotSnapshotBuilder ~6206):
// a contested node's owner_team names the team DOING THE FLIP, not the
// team that held it. So:
// owner==enemy && contested -> the enemy is capping something
// (our node or a neutral): STOP THE CAP.
// owner==us && contested -> WE are mid-flip: that's an attack-
// reinforce candidate, NOT a defense.
// owner==us && !contested -> held node: standing garrison.
// Emergencies first, then garrisons sized to live enemy pressure.
// All-in strips garrisons to a single sentry; turtle adds one.
struct Demand { float x, y, z; int quota; bool emergency; };
std::vector<Demand> defense;
for (auto const& n : ctx.nodes)
{
if (n.is_destroyed) continue; // razed AV tower — nothing to hold
if (n.owner_team == enemy_team && n.is_contested)
defense.push_back({n.x, n.y, n.z,
std::max(2, std::min(enemy_near(n) + 1, 4)),
true});
else if (n.owner_team == ctx.team && !n.is_contested)
{
int quota = all_in ? 1 : std::clamp(enemy_near(n), 1, 3);
if (turtle) ++quota;
defense.push_back({n.x, n.y, n.z, quota, false});
}
}
std::stable_sort(defense.begin(), defense.end(),
[](Demand const& a, Demand const& b)
{ return a.emergency > b.emergency; });
// Defense BUDGET: demands must never consume the whole roster — on
// epic maps (AV: 15 nodes, ~7 held per side at start) unbudgeted
// garrisons would drain all 40 bots and the attack section would
// never run, turning the team permanently passive. Garrisons may
// take ~60% of the live roster; emergencies may borrow up to ~75%;
// the rest is the guaranteed attacker core.
int alive_free = 0;
for (auto const& m : ctx.members)
if (m.alive && !m.assigned && !m.is_carrier) ++alive_free;
const int garrison_budget = std::max(1, (alive_free * 3) / 5);
const int emergency_budget = std::max(1, (alive_free * 3) / 4);
int spent = 0;
for (auto const& d : defense)
{
const int budget = d.emergency ? emergency_budget : garrison_budget;
for (int q = 0; q < d.quota && spent < budget; ++q)
{
// Emergencies take healers if that's what's left; garrisons don't.
const int pick = PickNearest(ctx.members, BgOrder::DefendNode,
d.x, d.y,
/*healer_bias=*/d.emergency ? 0 : +1,
/*allow_carrier=*/false);
if (pick < 0) return; // out of bodies entirely
AssignOrder(ctx.members[pick].guid_low, BgOrder::DefendNode,
d.x, d.y, d.z);
ctx.members[pick].assigned = true;
++spent;
}
}
// -- Attack: one concentrated squad --------------------------------------
// Target = cheapest takeable node: neutral beats enemy-held, light
// defense beats heavy, script priority (AV towers) breaks ties, distance
// from the unassigned force breaks the rest. Endgame coords override the
// target when the all-in switch is thrown and the script has one.
float cx = 0.f, cy = 0.f;
int free_count = 0;
for (auto const& m : ctx.members)
if (!m.assigned && m.alive) { cx += m.x; cy += m.y; ++free_count; }
if (free_count == 0) return;
cx /= float(free_count); cy /= float(free_count);
float tx = 0.f, ty = 0.f, tz = 0.f;
uint8 order_kind = BgOrder::AttackNode;
uint32 push_entry = 0;
// Push the enemy boss when LOSING (all_in) OR when the script flags the
// push unconditional (AV: enemy reinforcements are low — the boss kill
// ends the match regardless of node count, so push even while winning).
// Stage the enemy CAPTAIN (priority-3 node) before the general: the
// general's room is only deliberately cleared once the captain is dead.
// Also commit a clearly-LEADING team to the captain->general push once the
// early game is past: killing the enemy general is an instant win, so the
// winning team should drive for it rather than wait for the slow AV
// reinforcement race to drain below endgame_unconditional (which stalls in
// bot-only matches around a 1-tower / +75 lead). The losing team keeps
// contesting nodes to defend.
const bool lead_push = ctx.endgame_creature_entry != 0 &&
ctx.score_delta >= 50 &&
ctx.in_progress_ms > 4u * 60u * 1000u;
// STICKY endgame commit. lead_push flips off the instant the reinforcement
// race nudges the lead below +50, which in bot-only AV happens constantly
// around the +-75 one-tower stall — so the captain push kept starting and
// stopping and never sustained long enough to burn the captain down. Once a
// team commits (lead_push fires while an endgame creature exists), latch the
// commitment and keep pushing until the endgame creature is dead (the AV
// script stops advertising it -> endgame_creature_entry==0) or the team is
// genuinely crushed (score_delta < -150, i.e. about to lose — fall back to
// defending). This is also how humans break the stall: the leading team
// drives the captain->general kill instead of trading nodes forever.
const uint64 commit_key = (uint64(ctx.instance_id) << 2) | uint64(ctx.team);
if (lead_push)
endgame_commit_[commit_key] = ctx.in_progress_ms;
const bool committed = endgame_commit_.find(commit_key) != endgame_commit_.end();
if (ctx.endgame_creature_entry == 0 || ctx.score_delta < -150)
endgame_commit_.erase(commit_key); // endgame done / team collapsing
const bool sticky_push = committed && ctx.endgame_creature_entry != 0 &&
ctx.score_delta >= -150;
const bool push_boss = all_in || ctx.endgame_unconditional || lead_push ||
sticky_push;
if (push_boss && ctx.captain_alive && PosSet(ctx.captain_x, ctx.captain_y))
{
tx = ctx.captain_x; ty = ctx.captain_y; tz = ctx.captain_z;
order_kind = BgOrder::PushEndgame;
push_entry = ctx.captain_creature_entry;
}
else if (push_boss && PosSet(ctx.endgame_x, ctx.endgame_y))
{
tx = ctx.endgame_x; ty = ctx.endgame_y; tz = ctx.endgame_z;
order_kind = BgOrder::PushEndgame;
push_entry = ctx.endgame_creature_entry;
}
else
{
float best_cost = 1e30f;
for (auto const& n : ctx.nodes)
{
// Skip razed structures, held garrison nodes and enemy-flip
// emergencies (the defense demands above own those).
// Candidates: neutral, enemy-held uncontested, and OUR
// flip-in-progress (owner==us && contested — the cheapest
// finish of all, reinforce it).
if (n.is_destroyed) continue;
if (n.owner_team == ctx.team && !n.is_contested) continue;
if (n.owner_team == enemy_team && n.is_contested) continue;
const bool neutral = n.owner_team == 0;
const bool enemy = n.owner_team == enemy_team;
float cost = std::sqrt(Dist2(cx, cy, n.x, n.y)) * 0.05f;
if (enemy) cost += 8.f;
if (neutral) cost += 2.f;
cost += float(enemy_near(n)) * 3.f;
// Find the matching advice-node priority (AV towers > GYs).
cost -= float(NodePriorityFor(ctx, n)) * 2.f;
if (cost < best_cost)
{ best_cost = cost; tx = n.x; ty = n.y; tz = n.z; }
}
if (!PosSet(tx, ty))
{
// We own everything (or no takeable node) — turtle in place.
// Leave the remainder unordered; legacy roamers patrol well.
return;
}
}
for (auto& m : ctx.members)
{
if (m.assigned || !m.alive || m.is_carrier) continue;
AssignOrder(m.guid_low, order_kind, tx, ty, tz, ObjectGuid::Empty,
/*squad=*/1, /*target_entry=*/push_entry);
m.assigned = true;
}
}
// Match a live node against the script's static node list to read its
// priority weight (position match within 25y — banner GO vs advice coords
// are a few yards apart on most maps).
uint8 BgTeamCoordinator::NodePriorityFor(TeamPlanContext const& ctx,
BgNodeState const& n)
{
for (auto const& an : ctx.advice_nodes)
if (Dist2(an.x, an.y, n.x, n.y) < 25.f * 25.f)
return an.priority;
return 0;
}
// ---------------------------------------------------------------------------
// Per-team plan entry: family selection + diagnostics.
// ---------------------------------------------------------------------------
void BgTeamCoordinator::PlanTeam(TeamPlanContext& ctx, uint32 /*now_ms*/)
{
const bool flag_play = PosSet(ctx.enemy_flag_x, ctx.enemy_flag_y) ||
!ctx.enemy_carrier.IsEmpty() ||
std::any_of(ctx.members.begin(), ctx.members.end(),
[](Member const& m) { return m.is_carrier; });
const bool node_play = !ctx.nodes.empty();
if (flag_play)
PlanCtf(ctx); // also covers the flag layer of hybrids
if (node_play)
PlanNodeRace(ctx); // remaining members
// Plan-change diagnostic: log when the team's attack focus or carrier
// detail changed since the last plan (not every 750ms tick).
uint64 sig = 1469598103934665603ull; // FNV-1a over order kinds+coords
auto mix = [&sig](uint64 v)
{ sig ^= v; sig *= 1099511628211ull; };
int ordered = 0;
for (auto const& m : ctx.members)
{
auto it = next_orders_.find(m.guid_low);
if (it == next_orders_.end()) continue;
++ordered;
mix(uint64(it->second.kind));
// Carrier-tracking orders (escort / hunt / carry) target a MOVING
// player — their coords shift every plan while a flag runs, which
// re-logged "plan changed" up to once per 750ms cycle for a whole
// carry. Only positional objective coords participate in the
// change signature; for tracking kinds the kind + focus identity
// is the stable plan content.
const bool tracks_carrier =
it->second.kind == BgOrder::EscortFC ||
it->second.kind == BgOrder::HuntEFC ||
it->second.kind == BgOrder::CarryHome;
if (tracks_carrier)
{
mix(it->second.focus.GetCounter());
}
else
{
mix(uint64(int64(it->second.x / 15.f))); // 15y grid: ignore jitter
mix(uint64(int64(it->second.y / 15.f)));
}
}
const uint64 key = (uint64(ctx.instance_id) << 2) | ctx.team;
auto sig_it = plan_sig_.find(key);
if (sig_it == plan_sig_.end() || sig_it->second != sig)
{
plan_sig_[key] = sig;
TC_LOG_INFO("playerbot.v2",
"[bgcoord] bg_type={} instance={} team={} plan changed: "
"{} bots, {} ordered, nodes={} flag_play={} score_delta={}",
ctx.type_id, ctx.instance_id, uint32(ctx.team),
uint32(ctx.members.size()), ordered, uint32(ctx.nodes.size()),
flag_play ? 1 : 0, ctx.score_delta);
}
}
// ---------------------------------------------------------------------------
// World-tick driver.
// ---------------------------------------------------------------------------
void BgTeamCoordinator::Update(uint32 now_ms)
{
if (now_ms - last_plan_ms_ < kPlanIntervalMs)
return;
last_plan_ms_ = now_ms;
if (!sConfigMgr->GetBoolDefault("Playerbot.Bg.Coordinator.Enable", true))
{
if (!orders_.empty()) orders_.clear();
last_dump_ = "coordinator disabled (Playerbot.Bg.Coordinator.Enable=0)";
return;
}
// -- Bucket every in-BG bot by (instance, team) ---------------------------
struct Bucket
{
uint32 instance_id = 0;
uint16 type_id = 0;
uint8 team = 0;
std::vector<std::pair<uint64, Player*>> bots;
};
std::unordered_map<uint64, Bucket> buckets;
Services::Registry().for_each([&](BotId id, BotRegistryEntry const& e)
{
if (!e.ai) return;
Player* p = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!p) return;
Battleground* bg = p->GetBattleground();
if (!bg || bg->isArena()) return;
if (bg->GetStatus() != STATUS_IN_PROGRESS) return;
const uint8 team_u8 = p->GetEffectiveTeam() == ALLIANCE ? 1 : 2;
const uint64 key = (uint64(bg->GetInstanceID()) << 2) | team_u8;
Bucket& b = buckets[key];
b.instance_id = bg->GetInstanceID();
b.type_id = uint16(bg->GetTypeID());
b.team = team_u8;
b.bots.emplace_back(uint64(id), p);
});
next_orders_.clear();
std::ostringstream dump;
for (auto& [key, b] : buckets)
{
// Reference snapshot: the freshest member view of map-global BG
// state (node ownership, carriers, scores). The builder harvests
// these every snapshot build; one member's view serves the team.
std::shared_ptr<BotSnapshot const> ref;
for (auto const& [glow, p] : b.bots)
{
auto s = Services::Snapshots().latest(glow);
if (s && s->bg.current_type_id != 0 &&
(!ref || s->version > ref->version))
ref = s;
}
if (!ref)
continue; // data cold (first seconds of a match) — legacy runs
// The snapshot's RESOLVED bg type id (the builder falls back to
// the queue's BattlemasterList id when Battleground::GetTypeID()
// is 0) — keep family checks consistent with the advice source.
const uint16 resolved_type_id = ref->bg.current_type_id;
// Advice is computed HERE, on the world thread, from the immutable
// reference snapshot. Do NOT borrow a member BotAI's BgAdviceCache:
// that cache is OWNED BY THE AI WORKER THREAD — BgDispatch
// reassigns its heap vectors every ≤2s, so reading it here could
// iterate freed buffers (use-after-free; adversarial review
// 2026-06-10). Scripts are stateless and registered once at boot;
// GetAdvice on a snapshot view is thread-safe and costs one call
// per (instance, team) per 750ms plan — negligible next to the
// per-bot per-tick churn the AI-side cache exists to avoid.
BattlegroundAdvice const adv =
Services::Battlegrounds().GetAdvice(BotSnapshotView(*ref));
TeamPlanContext ctx;
ctx.team = b.team;
ctx.type_id = resolved_type_id;
ctx.instance_id = b.instance_id;
ctx.nodes = ref->bg.node_states;
ctx.in_progress_ms = ref->bg.in_progress_ms;
ctx.score_delta = b.team == 1
? int32(ref->bg.score_alliance) - int32(ref->bg.score_horde)
: int32(ref->bg.score_horde) - int32(ref->bg.score_alliance);
ctx.score_bias_threshold =
adv.score_bias_threshold > 0 ? adv.score_bias_threshold : 200;
ctx.enemy_flag_x = adv.enemy_flag_x; ctx.enemy_flag_y = adv.enemy_flag_y;
ctx.enemy_flag_z = adv.enemy_flag_z;
ctx.own_flag_x = adv.own_flag_x; ctx.own_flag_y = adv.own_flag_y;
ctx.own_flag_z = adv.own_flag_z;
ctx.home_x = adv.home_base_x; ctx.home_y = adv.home_base_y;
ctx.home_z = adv.home_base_z;
ctx.endgame_x = adv.endgame_target_x; ctx.endgame_y = adv.endgame_target_y;
ctx.endgame_z = adv.endgame_target_z;
ctx.endgame_unconditional = adv.endgame_unconditional;
ctx.endgame_creature_entry = adv.endgame_creature_entry;
// Enemy captain = the ENEMY-side priority-3 node the AV script still
// advertises (it drops each captain from the list once that captain
// dies). team 1 = Alliance hunts Galvangar 11947 (Frostwolf, the
// -545,-165 spawn); team 2 = Horde hunts Balinda 11949 (Stormpike,
// the -57,-286 spawn). The script lists BOTH captains with priority
// 3, so match by spawn position — never take "the last one" or the
// team would push toward its own captain.
ctx.captain_creature_entry = (b.team == 1) ? 11947u : 11949u;
const float cap_tx = (b.team == 1) ? -545.2f : -57.8f;
const float cap_ty = (b.team == 1) ? -165.4f : -286.6f;
for (auto const& an : adv.nodes)
if (an.priority == 3 &&
Dist2(an.x, an.y, cap_tx, cap_ty) < 25.f * 25.f)
{
ctx.captain_x = an.x; ctx.captain_y = an.y; ctx.captain_z = an.z;
ctx.captain_alive = true;
}
ctx.fc_class_preference = adv.fc_class_preference;
for (auto const& an : adv.nodes)
ctx.advice_nodes.push_back({an.x, an.y, an.z, an.priority});
ctx.friendly_carrier = ref->bg.friendly_flag_carrier;
ctx.enemy_carrier = ref->bg.enemy_flag_carrier;
ctx.enemy_carrier_x = ref->bg.enemy_carrier_x;
ctx.enemy_carrier_y = ref->bg.enemy_carrier_y;
ctx.enemy_carrier_z = ref->bg.enemy_carrier_z;
ctx.members.reserve(b.bots.size());
for (auto const& [glow, p] : b.bots)
{
Member m;
m.guid_low = glow;
m.x = p->GetPositionX(); m.y = p->GetPositionY();
m.z = p->GetPositionZ();
m.cls = uint8(p->GetClass());
m.alive = p->IsAlive();
uint16 spec = 0;
if (auto s = Services::Snapshots().latest(glow))
spec = uint16(s->identity.spec);
m.healer = IsHealerSpec(m.cls, spec);
m.tank = IsTankSpec(m.cls, spec);
ObjectGuid const pg = p->GetGUID();
m.is_carrier = pg == ref->bg.friendly_flag_carrier;
if (!m.is_carrier)
for (ObjectGuid const& cg : ref->bg.all_friendly_carriers)
if (cg == pg) { m.is_carrier = true; break; }
ctx.members.push_back(m);
}
// Deterministic order: assignment must not depend on registry
// iteration order or the sticky discount loses its anchor.
std::sort(ctx.members.begin(), ctx.members.end(),
[](Member const& a, Member const& b2)
{ return a.guid_low < b2.guid_low; });
// Detect friendly carriers who are NOT bucketed bots — a human
// teammate carrying the flag. Without this, PlanCtf would think
// "no carrier" and send a pickup runner to an empty flag stand
// while leaving the human FC unescorted.
{
std::vector<ObjectGuid> carriers = ref->bg.all_friendly_carriers;
if (carriers.empty() && !ref->bg.friendly_flag_carrier.IsEmpty())
carriers.push_back(ref->bg.friendly_flag_carrier);
for (ObjectGuid const& cg : carriers)
{
bool is_member = false;
for (auto const& m : ctx.members)
if (cg.GetCounter() == m.guid_low)
{ is_member = true; break; }
if (!is_member)
{
++ctx.external_carriers;
if (cg == ref->bg.friendly_flag_carrier)
ctx.scalar_carrier_is_external = true;
}
}
ctx.friendly_carrier_x = ref->bg.friendly_carrier_x;
ctx.friendly_carrier_y = ref->bg.friendly_carrier_y;
ctx.friendly_carrier_z = ref->bg.friendly_carrier_z;
}
PlanTeam(ctx, now_ms);
// Diagnostic dump line per team.
int kinds[9] = {};
for (auto const& m : ctx.members)
{
auto it = next_orders_.find(m.guid_low);
if (it != next_orders_.end() && it->second.kind < 9)
++kinds[it->second.kind];
}
dump << "bg_type=" << b.type_id << " inst=" << b.instance_id
<< " team=" << uint32(b.team) << " bots=" << b.bots.size()
<< " atk=" << kinds[BgOrder::AttackNode]
<< " def=" << kinds[BgOrder::DefendNode]
<< " esc=" << kinds[BgOrder::EscortFC]
<< " hunt=" << kinds[BgOrder::HuntEFC]
<< " pickup=" << kinds[BgOrder::PickupFlag]
<< " carry=" << kinds[BgOrder::CarryHome]
<< " push=" << kinds[BgOrder::PushEndgame]
<< " delta=" << ctx.score_delta << "\n";
}
orders_ = std::move(next_orders_);
next_orders_.clear();
// Drop change-log signatures for teams that no longer exist (match
// ended) so the map doesn't grow for the server's whole uptime.
for (auto it = plan_sig_.begin(); it != plan_sig_.end();)
it = buckets.count(it->first) ? std::next(it) : plan_sig_.erase(it);
for (auto it = endgame_commit_.begin(); it != endgame_commit_.end();)
it = buckets.count(it->first) ? std::next(it) : endgame_commit_.erase(it);
last_dump_ = dump.str();
if (last_dump_.empty())
last_dump_ = "no active bot battleground teams";
}
std::string BgTeamCoordinator::DebugDump() const
{
return last_dump_.empty() ? std::string("coordinator has not planned yet")
: last_dump_;
}
} // namespace Playerbot
@@ -0,0 +1,171 @@
// BgTeamCoordinator — team-level battleground strategy (BG audit N60).
//
// Before this, every bot decided greedily from its own snapshot: identical
// stimuli produced identical decisions (thundering herds), nobody held a
// quota (5 attackers on one node, zero on the next), escorts/hunters were
// whoever's guid hashed right, and there was no opening split or mid-match
// rebalancing. This service computes ONE plan per (battleground instance,
// team) on the WORLD THREAD and publishes per-bot orders; the snapshot
// builder copies each bot's order into its snapshot (BotSnapshot::BgState::
// BgOrder) and the AI executes it, falling back to the legacy per-bot role
// logic whenever no order is present — graceful degradation by design.
//
// Threading: Update() is the only WRITER of orders_ and runs on the WORLD
// THREAD, inline in OnWorldUpdate BEFORE the parallel snapshot-build barrier.
// OrderFor() is a pure const find() READER and (since #5 Phase 4) is called
// concurrently from the snapshot-build WORKER threads. Safe ONLY because the
// writer and the readers are temporally disjoint — orders_ is never mutated
// during the build phase, and concurrent reads of a non-mutating unordered_map
// are well-defined. Move Update() into the parallel phase and this needs a
// shared_mutex. The AI workers only ever see orders through their snapshots.
//
// Inputs (all already maintained elsewhere — this class adds no harvest):
// * Roster: live Player objects of V2 bots in the BG (registry walk).
// * Node states / carriers / score: the freshest team member's published
// snapshot (node_states are map-global; the builder harvests them every
// snapshot build, including the per-node player-pressure counts).
// * Static script data: BattlegroundScriptMgr::GetAdvice computed HERE
// on the world thread from that same immutable snapshot. Never read a
// BotAI's BgAdviceCache from this class — it is owned by the AI worker
// thread, which reassigns its heap vectors every ≤2s (use-after-free;
// adversarial review 2026-06-10).
//
// Strategy families (selected from the advice shape):
// * CTF (enemy_flag set, no capture nodes): FC + escorts + EFC
// hunt squad + mid pressure.
// * Node race (capture nodes, no enemy_flag): per-node defense quotas
// scaled by live enemy pressure, ONE concentrated attack
// squad, stop-the-cap emergencies, score/time bias.
// * Orb/hybrid (enemy_flag AND nodes — Kotmogu, Deephaul, EotS): carrier
// play from the CTF family + node play for the remainder.
// Endgame (advice endgame target + all-in conditions) overrides attackers.
//
// Stability: orders are sticky — a bot keeps its order unless it becomes
// invalid (target flipped to us / carrier died), an emergency outranks it,
// or the periodic full re-plan finds a materially better assignment.
#pragma once
#include "../BotSnapshot.h"
#include <unordered_map>
#include <vector>
#include <string>
class Battleground;
namespace Playerbot {
class BgTeamCoordinator
{
public:
using BgOrder = BgState::BgOrder;
BgTeamCoordinator() = default;
// World tick driver. Re-plans every team of every active bot BG at
// kPlanIntervalMs. Cheap when no BGs run (single registry walk).
void Update(uint32 now_ms);
// Order lookup for the snapshot builder. Returns nullptr when no
// current plan covers the bot (consumer falls back to legacy logic).
BgOrder const* OrderFor(uint64 bot_guid_low) const
{
auto it = orders_.find(bot_guid_low);
return it == orders_.end() ? nullptr : &it->second;
}
// Human-readable plan dump for `.playerbot bgcoord`.
std::string DebugDump() const;
private:
struct Member
{
uint64 guid_low = 0;
float x = 0.f, y = 0.f, z = 0.f;
uint8 cls = 0;
bool alive = false;
bool healer = false;
bool tank = false;
bool is_carrier = false;
bool assigned = false; // set as the planner hands out orders
};
// Slimmed copy of the script's static node list — attack-target
// priority lookup by position, plus distinct-orb runner targets on
// Kotmogu (where nodes[] holds the 4 orb spawns).
struct AdviceNode { float x = 0.f, y = 0.f, z = 0.f; uint8 priority = 0; };
struct TeamPlanContext
{
std::vector<Member> members;
std::vector<BgNodeState> nodes; // live ownership (small copy)
std::vector<AdviceNode> advice_nodes; // static script nodes
std::vector<uint8> fc_class_preference;
uint8 team = 0; // 1=alliance 2=horde
uint16 type_id = 0;
uint32 instance_id = 0;
int32 score_delta = 0; // my_score - enemy_score
uint32 in_progress_ms = 0;
int32 score_bias_threshold = 200;
// From the advice cache ({0,0,0} sentinel = unset):
float enemy_flag_x = 0.f, enemy_flag_y = 0.f, enemy_flag_z = 0.f;
float own_flag_x = 0.f, own_flag_y = 0.f, own_flag_z = 0.f;
float home_x = 0.f, home_y = 0.f, home_z = 0.f;
float endgame_x = 0.f, endgame_y = 0.f, endgame_z = 0.f;
// AV endgame push: the script flags the boss push as unconditional
// (enemy reinforcements low) and names the enemy general's entry
// (Vanndar 11948 / Drek'Thar 11946); the priority-3 advice node is
// the enemy captain (Galvangar 11947 / Balinda 11949), which the
// script drops once the captain dies. Default 0/false elsewhere.
bool endgame_unconditional = false;
uint32 endgame_creature_entry = 0;
float captain_x = 0.f, captain_y = 0.f, captain_z = 0.f;
uint32 captain_creature_entry = 0;
bool captain_alive = false;
// From the reference snapshot:
ObjectGuid friendly_carrier;
ObjectGuid enemy_carrier;
float enemy_carrier_x = 0.f, enemy_carrier_y = 0.f,
enemy_carrier_z = 0.f;
// Friendly carriers who are NOT V2 bots (human teammates). The
// planner can't order them, but must not send a redundant pickup
// runner on single-flag maps, and still owes them an escort.
int external_carriers = 0;
bool scalar_carrier_is_external = false;
float friendly_carrier_x = 0.f, friendly_carrier_y = 0.f,
friendly_carrier_z = 0.f;
};
void PlanTeam(TeamPlanContext& ctx, uint32 now_ms);
void PlanCtf(TeamPlanContext& ctx);
void PlanNodeRace(TeamPlanContext& ctx);
void AssignOrder(uint64 guid, uint8 kind, float x, float y, float z,
ObjectGuid focus = ObjectGuid::Empty, uint8 squad = 0,
uint32 target_entry = 0);
int PickNearest(std::vector<Member>& members, uint8 kind,
float tx, float ty, int healer_bias,
bool allow_carrier,
ObjectGuid focus = ObjectGuid::Empty) const;
static uint8 NodePriorityFor(TeamPlanContext const& ctx,
BgNodeState const& n);
// Published plan (read by the builder via OrderFor) and the plan being
// built this Update pass. Two maps so OrderFor stays valid mid-plan and
// the sticky-discount can compare against the previous assignment.
std::unordered_map<uint64, BgOrder> orders_;
std::unordered_map<uint64, BgOrder> next_orders_;
// Per-(instance,team) plan signature for change-only logging.
std::unordered_map<uint64, uint64> plan_sig_;
// Per-(instance,team) STICKY endgame commitment. Once a team commits to the
// captain->general push (lead_push fired with the captain alive), keep
// pushing through reinforcement-lead dips until the endgame creature is dead
// or the team is genuinely crushed — otherwise the AV reinforcement race
// oscillates the lead around +-75 and the push fizzles before enough bots
// pile into the captain's courtyard to kill him. Value = commit timestamp
// (unused beyond presence); key packs (instance_id<<8 | team).
std::unordered_map<uint64, uint32> endgame_commit_;
uint32 last_plan_ms_ = 0;
std::string last_dump_;
};
} // namespace Playerbot
@@ -0,0 +1,148 @@
// AlteracValleyScript — Alterac Valley (BattlemasterList id 1 / BATTLEGROUND_AV, map 30).
// 40v40 reinforcement push. Decided by towers / captains / end-boss, not
// zerg: each tower destroyed drains 75 reinforcements; each captain kill
// gives +100 reinforcements to the killing team; killing Drek'thar /
// Vandar Stormpike ends the BG.
//
// Authoritative data from V1 (production-tested):
// src/modules/Playerbot/AI/Coordination/Battleground/Scripts/Siege/
// AlteracValleyData.h:35 (REINF_GAIN_PER_CAPTAIN), :128-130 (Vandar),
// :134-136 (Drek'Thar), :149-164 (Captains), :191+ (towers),
// :451-457 (spawns).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class AlteracValleyScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 1; } // BATTLEGROUND_AV
char const* name() const override { return "alterac_valley"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// 40 slots. AV is decided by reinforcement bleed (kills + tower
// destroys + captain kills) — not zerg. Real AV runs ~6-8
// healers per side; 8 healer slots ensure statistical coverage
// even after class-fit fallback (a non-healer-spec bot in a
// Healer slot demotes to Roamer via the class-fixup rule).
// * 18 Attackers — front-line push.
// * 6 Defenders — home keep + forward GY anchor + tower defense.
// * 8 Roamers — counter-flips + tower defense rotation.
// * 8 Healers — split across the line.
a.role_by_slot.assign(40, BgRole::Free);
for (uint8_t i = 0; i < 18; ++i) a.role_by_slot[i] = BgRole::Attacker;
for (uint8_t i = 18; i < 24; ++i) a.role_by_slot[i] = BgRole::Defender;
for (uint8_t i = 24; i < 32; ++i) a.role_by_slot[i] = BgRole::Roamer;
for (uint8_t i = 32; i < 40; ++i) a.role_by_slot[i] = BgRole::Healer;
// AV banners are GO type 1 (BUTTON) / type 10 (GOOBER) — there are
// NO type-42/24 GOs on map 30 (audit B26), so node interaction was
// impossible. Enumerate the DB-verified banner entries instead
// (every per-state Alliance/Horde/Contested variant on map 30).
a.auto_use_go_entries = {
178364, 178365, 178388, 178389, 178393, 178394, 178925, 178927,
178929, 178932, 178935, 178936, 178940, 178943, 178944, 178945,
178946, 178947, 178948, 178955, 178956, 178957, 178958, 179284,
179285, 179286, 179287, 179304, 179305, 179308, 179310, 179435,
179436, 179439, 179440, 179441, 179442, 179443, 179444, 179445,
179446, 179449, 179450, 179453, 179454, 179458, 179465, 179466,
179467, 179468, 179470, 179471, 179472, 179473, 179481, 179482,
179483, 179484, 180418, 180419, 180420,
};
// Endgame targets + node table rebuilt from DB-verified spawns
// (audit B27: the old V1-attributed coords were corrupted — the
// Horde "Vanndar" target was DREK'THAR'S OWN ROOM, Alliance
// structures sat inside the Horde base, and both captains were
// ~120-230y off; ~70% of objective movement was mis-targeted).
// world.creature: Vanndar 11948 (722.4,-11.0,50.7); Drek'Thar
// 11946 (-1370.9,-220.2,98.5); Balinda 11949 (-57.8,-286.6,15.6);
// Galvangar 11947 (-545.2,-165.4,57.8). Node coords = banner GO
// spawn clusters on map 30.
if (s.is_horde())
{
// Horde spawn — Frostwolf Stadium (V1 HORDE_SPAWNS[0]).
a.home_base_x = -1437.00f; a.home_base_y = -610.00f; a.home_base_z = 51.16f;
// Endgame: Vanndar Stormpike in Dun Baldar (NORTH end, +X).
a.endgame_target_x = 722.4f;
a.endgame_target_y = -11.0f;
a.endgame_target_z = 50.7f;
a.endgame_creature_entry = 11948; // VANNDAR_ENTRY
}
else
{
// Alliance spawn (V1 ALLIANCE_SPAWNS[0]).
a.home_base_x = 873.98f; a.home_base_y = -491.79f; a.home_base_z = 96.54f;
// Endgame: Drek'Thar in Frostwolf Keep (SOUTH end, -X).
a.endgame_target_x = -1370.9f;
a.endgame_target_y = -220.2f;
a.endgame_target_z = 98.5f;
a.endgame_creature_entry = 11946; // DREKTHAR_ENTRY
}
// Non-deficit endgame trigger (BG audit AV). The generic endgame
// redirect only fires under Bias_AllIn (own reinforcements >=200
// behind), so a LEADING or TIED team never boss-rushed and AV dragged
// to the reinforcement floor. AV reinforcements map into the bg score;
// when the ENEMY drops below ~200 of 600 its towers are mostly down
// (-75 each) so its general has shed its tower buffs and is killable —
// push the boss to SEAL the win instead of trickling reinforcements.
// (>0 guard avoids firing on an un-harvested 0 before the match warms.)
{
const uint32 enemy_reinf =
s.is_horde() ? s.bg_score_alliance() : s.bg_score_horde();
if (enemy_reinf > 0 && enemy_reinf < 200)
a.endgame_unconditional = true;
}
// 15 static nodes: 7 GYs (p=0) + 8 towers/bunkers (p=2, -75 reinf
// drain each); captains appended below (p=3, +100 reinf for the
// killer). All coords = banner-GO spawn positions from world DB.
a.nodes = {
// Graveyards — priority 0 (rez logistics only).
{ 669.0f, -294.0f, 30.0f, "Stormpike GY", 0 },
{ 638.5f, -31.5f, 46.0f, "Stormpike Aid Station", 0 },
{ 73.7f, -426.0f, 61.0f, "Stonehearth GY", 0 },
{ -202.5f, -113.0f, 78.0f, "Snowfall GY (neutral)", 0 },
{ -612.5f, -397.0f, 61.0f, "Iceblood GY", 0 },
{ -1082.5f, -347.0f, 55.0f, "Frostwolf GY", 0 },
{ -1402.0f, -307.0f, 89.0f, "Frostwolf Relief Hut", 0 },
// Alliance bunkers — priority 2 (-75 reinf on destruction).
{ 556.0f, -84.0f, 52.0f, "Dun Baldar North Bunker", 2 },
{ 678.0f, -139.0f, 64.0f, "Dun Baldar South Bunker", 2 },
{ 203.0f, -361.0f, 56.0f, "Icewing Bunker", 2 },
{ -154.0f, -446.0f, 45.0f, "Stonehearth Bunker", 2 },
// Horde towers — priority 2.
// Labels corrected (BG audit §2): the DB captain garrisons prove
// the names were reversed — Commander Dardosh (13140) garrisons
// the (-572,-263) tower = ICEBLOOD; Wing Commander Slidore (13438)
// garrisons the (-768,-363) tower = TOWER POINT.
{ -572.0f, -263.0f, 75.0f, "Iceblood Tower", 2 },
{ -768.0f, -363.0f, 91.0f, "Tower Point", 2 },
{ -1303.0f, -317.0f, 114.0f, "East Frostwolf Tower", 2 },
{ -1298.0f, -267.0f, 114.0f, "West Frostwolf Tower", 2 },
};
// Captains — priority 3 (+100 reinforcements for the killing
// team). Permanently-killable: drop from the node list once the
// snapshot reports the captain dead so bots stop pathing to a
// corpse. Coords = actual creature spawns (the old values
// duplicated bunker/GY coords ~230y away).
if (s.bg_av_balinda_alive())
a.nodes.push_back({ -57.8f, -286.6f, 15.6f, "Balinda Stonecaster (A)", 3 });
if (s.bg_av_galvangar_alive())
a.nodes.push_back({ -545.2f, -165.4f, 57.8f, "Captain Galvangar (H)", 3 });
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeAlteracValleyScript()
{
return std::make_unique<AlteracValleyScript>();
}
} // namespace Playerbot
@@ -0,0 +1,78 @@
// ArathiBasinScript — Arathi Basin (BattlemasterList id 3 / BATTLEGROUND_AB).
// Resource race. 5 nodes: Stables, Lumber Mill, Blacksmith, Mine, Farm.
// Capture 4+ to gain ticks; first to 1500 wins. Canonical strategy is
// 4-cap: defend 4, soft-attack the 5th (typically the contested mid).
//
// Authoritative coords + strategic weights from V1
// src/modules/Playerbot/AI/Coordination/Battleground/Scripts/Domination/
// ArathiBasinData.h:54-81 (positions), :110-121 (GetNodeStrategicValue),
// :159, :168 (faction spawns).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class ArathiBasinScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 3; } // BATTLEGROUND_AB
char const* name() const override { return "arathi_basin"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// 15v15. Justified per slot: 5 nodes need 5 bodies to contest
// re-caps; Healer count keeps offensive zerg + two anchor points
// alive. 3 Roamers act as the swing force on contested nodes.
a.role_by_slot = {
BgRole::Defender, BgRole::Defender, BgRole::Defender,
BgRole::Defender, BgRole::Defender,
BgRole::Attacker, BgRole::Attacker, BgRole::Attacker,
BgRole::Attacker,
BgRole::Roamer, BgRole::Roamer, BgRole::Roamer,
BgRole::Healer, BgRole::Healer, BgRole::Healer,
};
// Live AB nodes on map 2107 are 5 dynamically-spawned CAPTURE_POINT
// (42) banners (entries 227420 / 227522 / 227536 / 227538 / 227544).
// FLAGSTAND (24) dropped (BG audit §2): map 2107 has ZERO type-24 GOs,
// so it was an inert per-tick scan. (The old comment's 180087-180091
// are legacy type-10 banners, not what spawns live.)
a.auto_use_go_types = { 42 };
// Priority mirrors V1 GetNodeStrategicValue:
// Blacksmith = center pivot (most contested)
// Lumber Mill = high-ground LoS / strong vantage
// Stables / Mine / Farm = perimeter
// Attacker rule (State_Idle.cpp:3583-3596) uses priority as
// tie-breaker within ownership bucket — bots prefer BS/LM flips.
a.nodes = {
{ 1166.785f, 1200.132f, -56.70f, "Stables", 0 },
{ 856.141f, 1148.902f, 11.18f, "Lumber Mill", 1 },
{ 977.017f, 1046.534f, -44.80f, "Blacksmith", 2 },
{ 1146.923f, 848.277f, -110.52f, "Mine", 0 },
{ 806.218f, 874.217f, -55.99f, "Farm", 0 },
};
// Faction spawn (V1 ArathiBasinData.h:159,168). Defender
// fallback anchor when no node is in range / contested.
if (s.is_horde())
{
a.home_base_x = 686.57f; a.home_base_y = 683.04f; a.home_base_z = -12.59f;
}
else
{
a.home_base_x = 1285.96f; a.home_base_y = 1281.62f; a.home_base_z = -15.67f;
}
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeArathiBasinScript()
{
return std::make_unique<ArathiBasinScript>();
}
} // namespace Playerbot
@@ -0,0 +1,239 @@
// ArenaScript — generic 2v2/3v3 arena handler.
// Different from BG: no objectives, no FlagCarrier, no nodes. Pure
// deathmatch. Bot strategy: stick tight to team (3-bot pack),
// focus enemy healer, use offensive cooldowns aggressively, kite
// around pillars when low HP.
//
// Per-map pillar / hazard data is not modelled here — see
// BATTLEGROUND_PLAN arena notes. All registered arenas share this
// generic logic via the ArenaScript class parameterised by id+name.
//
// Registered arenas (BattlemasterList id → human name):
// classic / TBC / WotLK:
// 4 Nagrand Arena
// 5 Blade's Edge Arena
// 6 All Arenas (skirmish queue)
// 8 Ruins of Lordaeron
// 10 Dalaran Sewers
// 11 Ring of Valor
// MoP/WoD:
// 719 Tol'Viron Arena
// 757 The Tiger's Peak
// Legion:
// 808 Black Rook Hold Arena
// 816 Ashamane's Fall
// "v2" rebrands (Brawl / Solo Shuffle re-ids on classic maps):
// 868 Ruins of Lordaeron 2
// 869 Dalaran Sewers 2
// 870 Tol'Viron 2
// 871 Tiger's Peak 2
// 872 Black Rook Hold Arena 2
// 873 Nagrand Arena 2
// 874 Ashamane's Fall 2
// 875 Blade's Edge Arena 2
// BfA/SL/DF:
// 897 Hook Point
// 902 Tiger's Peak 3
// 903 Mugambala
// 906 Ashamane's Fall 3
// 907 Blade's Edge Arena 3
// 908 Blade's Edge (v2 mesh)
// 909 Dalaran Sewers 3
// 910 Nagrand Arena 3
// 911 Ruins of Lordaeron 3
// 912 Tol'Viron Arena 3
// 913 Black Rook Hold Arena 3
// 1025 The Robodrome
// 1041 Empyrean Domain
//
// One ArenaScript instance per id (the manager is keyed by id; the
// behavior is identical so we register the same shared logic).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
// Per-map arena geometry. Coords are APPROXIMATE from map geometry /
// V1 module history — TC stores arena spawn/object data in DB (gameobject
// / creature tables), not source, so there's no file:line citation for
// these. Anyone tightening a particular arena should sniff coords in-game
// and update the table.
struct ArenaGeometry {
std::vector<BattlegroundAdvice::ArenaPillar> pillars;
std::vector<BattlegroundAdvice::ArenaHazard> hazards;
float rally_x = 0.f, rally_y = 0.f, rally_z = 0.f;
};
static ArenaGeometry const& GeometryFor(uint16_t id)
{
static const ArenaGeometry empty;
// Ring of Valor (id 11) — 4 pillar elevators rise at ~60s. Each
// pillar footprint is roughly 5y radius. Bots that stand on one
// get dismounted to its top and become easy ranged targets. Center
// pad is the safe waiting spot.
if (id == 11) {
static const ArenaGeometry rov = {
/*pillars*/ {
{ 763.0f, -284.0f, 28.3f, "center_pad", 0 },
},
/*hazards*/ {
{ 753.0f, -294.0f, 28.3f, 5.5f, "rov_pillar_sw", 60000u, 0u },
{ 753.0f, -274.0f, 28.3f, 5.5f, "rov_pillar_nw", 60000u, 0u },
{ 773.0f, -274.0f, 28.3f, 5.5f, "rov_pillar_ne", 60000u, 0u },
{ 773.0f, -294.0f, 28.3f, 5.5f, "rov_pillar_se", 60000u, 0u },
},
/*rally*/ 763.0f, -284.0f, 28.3f
};
return rov;
}
// Dalaran Sewers (id 10) — two waterfalls along long axis push
// players off ledges. Avoid the falling-water zones.
if (id == 10) {
static const ArenaGeometry ds = {
/*pillars*/ {
{ 1262.0f, 778.0f, 7.5f, "alliance_crates", 0 },
{ 1320.0f, 802.0f, 7.5f, "horde_crates", 0 },
},
/*hazards*/ {
{ 1316.6f, 816.1f, 7.5f, 6.0f, "waterfall_north", 0u, 0u },
{ 1268.6f, 749.3f, 7.5f, 6.0f, "waterfall_south", 0u, 0u },
},
/*rally*/ 1292.0f, 790.0f, 7.5f
};
return ds;
}
// Nagrand (id 4) — dual cubbies flanking center. No hazards.
if (id == 4 || id == 873 || id == 910) {
static const ArenaGeometry na = {
/*pillars*/ {
{ 4055.0f, 2921.0f, 13.0f, "center", 0 },
{ 4024.0f, 2921.0f, 13.0f, "alliance_cubby", 1 },
{ 4086.0f, 2921.0f, 13.0f, "horde_cubby", 1 },
},
/*hazards*/ {},
/*rally*/ 4055.0f, 2921.0f, 13.0f
};
return na;
}
// Ruins of Lordaeron (id 8) — center pillar + side cubbies.
if (id == 8 || id == 868 || id == 911) {
static const ArenaGeometry rl = {
/*pillars*/ {
{ 1290.0f, 1585.0f, 32.0f, "center_pillar", 0 },
{ 1268.0f, 1605.0f, 32.0f, "alliance_cubby", 1 },
{ 1311.0f, 1565.0f, 32.0f, "horde_cubby", 1 },
},
/*hazards*/ {},
/*rally*/ 1290.0f, 1585.0f, 32.0f
};
return rl;
}
// Blade's Edge (id 5) — bridge box high-ground breaks LoS both ways.
if (id == 5 || id == 875 || id == 907 || id == 908) {
static const ArenaGeometry be = {
/*pillars*/ {
{ 6238.0f, 263.0f, 9.5f, "bridge_top", 2 },
{ 6238.0f, 263.0f, 4.0f, "bridge_under_center", 0 },
},
/*hazards*/ {},
/*rally*/ 6238.0f, 263.0f, 4.0f
};
return be;
}
return empty;
}
class ArenaScript final : public BattlegroundScript
{
public:
ArenaScript(uint16_t id, char const* nm) : id_(id), name_(nm) {}
uint16_t bg_type_id() const override { return id_; }
char const* name() const override { return name_; }
BattlegroundAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
BattlegroundAdvice a;
// 5-slot layout (max 3v3 bracket; wraps modulo for 2v2). All
// offense / healer mix; no FC role since arenas have no flag.
// Roamer drives the focus_fire / focus_healer / kite / cast-fake
// rules in State_Idle without an objective-pull from node logic.
a.role_by_slot = {
BgRole::Roamer,
BgRole::Healer,
BgRole::Roamer,
BgRole::Attacker,
BgRole::Attacker,
};
// Tight escort — healer shadows the squad.
a.escort_friendly_carrier = false; // no carrier
a.chase_enemy_carrier = false;
// Per-map pillars / hazards / rally. The `idle:arena_position`
// rule consumes these — bots avoid known dangerous footprints
// (RoV pillar elevators, Dalaran waterfalls) and move to a
// sensible advance point when the start gate opens. Maps with
// no geometry registered fall back to the prior no-op behavior.
ArenaGeometry const& geo = GeometryFor(id_);
a.arena_pillars = geo.pillars;
a.arena_hazards = geo.hazards;
a.opening_rally_x = geo.rally_x;
a.opening_rally_y = geo.rally_y;
a.opening_rally_z = geo.rally_z;
return a;
}
private:
uint16_t id_;
char const* name_;
};
} // anonymous
// Each arena map gets its own ArenaScript instance — the manager is
// keyed by bg_type_id so we need distinct instances even though the
// logic is identical. Helper macro keeps the registration list short.
#define ARENA_FACTORY(suffix, ID, NAME) \
std::unique_ptr<BattlegroundScript> Make##suffix##ArenaScript() \
{ return std::make_unique<ArenaScript>(uint16_t(ID), NAME); }
// Classic / TBC / WotLK arenas (BattlemasterList 4..11).
ARENA_FACTORY(Nagrand, 4, "arena_nagrand")
ARENA_FACTORY(BladesEdge, 5, "arena_blades_edge")
ARENA_FACTORY(AllArenas, 6, "arena_all_skirmish")
ARENA_FACTORY(RuinsOfLordaeron, 8, "arena_ruins_of_lordaeron")
ARENA_FACTORY(DalaranSewers, 10, "arena_dalaran_sewers")
ARENA_FACTORY(RingOfValor, 11, "arena_ring_of_valor")
// MoP / WoD arenas.
ARENA_FACTORY(TolViron, 719, "arena_tolviron")
ARENA_FACTORY(TigersPeak, 757, "arena_tigers_peak")
// Legion arenas.
ARENA_FACTORY(BlackRookHold, 808, "arena_black_rook_hold")
ARENA_FACTORY(AshamanesFall, 816, "arena_ashamanes_fall")
// "v2" rebrands (Brawl / Solo Shuffle re-ids on classic / Legion maps).
ARENA_FACTORY(RuinsOfLordaeron2, 868, "arena_ruins_of_lordaeron_2")
ARENA_FACTORY(DalaranSewers2, 869, "arena_dalaran_sewers_2")
ARENA_FACTORY(TolViron2, 870, "arena_tolviron_2")
ARENA_FACTORY(TigersPeak2, 871, "arena_tigers_peak_2")
ARENA_FACTORY(BlackRookHold2, 872, "arena_black_rook_hold_2")
ARENA_FACTORY(Nagrand2, 873, "arena_nagrand_2")
ARENA_FACTORY(AshamanesFall2, 874, "arena_ashamanes_fall_2")
ARENA_FACTORY(BladesEdge2, 875, "arena_blades_edge_2")
// BfA / Shadowlands / Dragonflight arenas.
ARENA_FACTORY(HookPoint, 897, "arena_hook_point")
ARENA_FACTORY(TigersPeak3, 902, "arena_tigers_peak_3")
ARENA_FACTORY(Mugambala, 903, "arena_mugambala")
ARENA_FACTORY(AshamanesFall3, 906, "arena_ashamanes_fall_3")
ARENA_FACTORY(BladesEdge3, 907, "arena_blades_edge_3")
ARENA_FACTORY(BladesEdgeV2Mesh, 908, "arena_blades_edge_v2_mesh")
ARENA_FACTORY(DalaranSewers3, 909, "arena_dalaran_sewers_3")
ARENA_FACTORY(Nagrand3, 910, "arena_nagrand_3")
ARENA_FACTORY(RuinsOfLordaeron3, 911, "arena_ruins_of_lordaeron_3")
ARENA_FACTORY(TolViron3, 912, "arena_tolviron_3")
ARENA_FACTORY(BlackRookHold3, 913, "arena_black_rook_hold_3")
ARENA_FACTORY(Robodrome, 1025, "arena_robodrome")
ARENA_FACTORY(EmpyreanDomain, 1041, "arena_empyrean_domain")
#undef ARENA_FACTORY
} // namespace Playerbot
@@ -0,0 +1,111 @@
// AshranScript — Ashran (BattlemasterList id 1020 / BATTLEGROUND_EB_A,
// map 1191). WoD 100v100 epic / world-PvP zone. Multi-front: 3 Road of
// Glory control points + 8 side events + faction commanders.
//
// Authoritative coords from V1
// src/modules/Playerbot/AI/Coordination/Battleground/Scripts/Epic/
// AshranData.h:118 (event interval), :138-149 (Volrath/Tremblade),
// :156-160 (control points), :171-173 (faction spawns).
//
// Known schema limit: the 8 side events rotate on a 5-min interval and
// are only "hot" while live — static node priority can't reflect that.
// Until a snapshot field exposes the live event id, events are listed
// at priority=1 as a tactical backdrop; control points (p=2) and the
// faction commanders (p=3) drive the primary push.
//
// TODO: Ashran event awareness. This branch's TC tree has no
// per-map Ashran source (no src/server/scripts/Battlegrounds/Ashran/
// or Zones/BattlegroundAshran.cpp); the WoD Ashran implementation is
// stub-grade. To make events truly hot the snapshot would need a
// `bg.ashran_active_event` (uint8) field populated from a worldstate
// — but the TC implementation doesn't currently emit one. Realistic
// path: extend TC's Ashran (or wait for upstream) to expose an active-
// event worldstate, then mirror the SoTA gate-state pattern here
// (BotSnapshot::bg field + builder population + advice-cache key).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class AshranScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 1020; } // BATTLEGROUND_EB_A
char const* name() const override { return "ashran"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// 100-slot raid. The framework wraps role_by_slot mod size, so
// a 20-entry table over 100 slots yields:
// ~50 Attacker / 15 Defender / 15 Roamer / 20 Healer.
// Healer share ~18% mirrors live Ashran raid comp (the prior
// 10-entry table produced only ~10 healers across the whole
// 100-slot raid — too thin for a sustained zone grind).
a.role_by_slot = {
BgRole::Attacker, BgRole::Attacker, BgRole::Attacker, BgRole::Healer,
BgRole::Attacker, BgRole::Roamer, BgRole::Healer, BgRole::Attacker,
BgRole::Defender, BgRole::Healer, BgRole::Attacker, BgRole::Attacker,
BgRole::Roamer, BgRole::Healer, BgRole::Attacker, BgRole::Defender,
BgRole::Attacker, BgRole::Roamer, BgRole::Defender, BgRole::Attacker,
};
a.auto_use_go_types = { 42, 24 };
if (s.is_horde())
{
// Warspear (Horde spawn) — V1 HORDE_SPAWN_X/Y/Z.
a.home_base_x = 3970.0f; a.home_base_y = -4100.0f; a.home_base_z = 55.0f;
// Endgame: Grand Marshal Tremblade (entry 82877) — V1 :147-149.
a.endgame_target_x = 5178.0f;
a.endgame_target_y = -4117.0f;
a.endgame_target_z = 1.0f;
a.endgame_creature_entry = 82877; // Grand Marshal Tremblade
}
else
{
// Stormshield (Alliance spawn) — V1 ALLIANCE_SPAWN_X/Y/Z.
a.home_base_x = 5200.0f; a.home_base_y = -4100.0f; a.home_base_z = 1.0f;
// Endgame: High Warlord Volrath (entry 82882) — V1 :142-144.
a.endgame_target_x = 4001.0f;
a.endgame_target_y = -4088.0f;
a.endgame_target_z = 52.0f;
a.endgame_creature_entry = 82882; // High Warlord Volrath
}
// 13 nodes: 3 control points (p=2 — capture flips Road of Glory
// progress) + 8 side-event centers (p=1 — only hot when an
// event is live; static priority is the best we can do without
// event-aware snapshot) + 2 faction commanders (p=3 — endgame).
a.nodes = {
// Control points — Road of Glory (V1 :156-160).
{ 4982.0f, -4171.0f, 15.0f, "Stormshield Stronghold (A)", 2 },
{ 4585.0f, -4117.0f, 32.0f, "Crossroads (center)", 2 },
{ 4188.0f, -4063.0f, 50.0f, "Warspear Stronghold (H)", 2 },
// Side-event centers — V1 EventPositions::*_CENTER. Coords
// approximate from V1 data; events rotate every 5 min.
{ 4700.0f, -4250.0f, 28.0f, "Race for Supremacy", 1 },
{ 4750.0f, -4300.0f, 25.0f, "Ring of Conquest", 1 },
{ 4400.0f, -4250.0f, 35.0f, "Seat of Omen (boss)", 1 },
{ 4650.0f, -3950.0f, 30.0f, "Empowered Ore", 1 },
{ 4500.0f, -4000.0f, 34.0f, "Ancient Artifact", 1 },
{ 4800.0f, -4350.0f, 22.0f, "Stadium Racing", 1 },
{ 4350.0f, -3900.0f, 42.0f, "Ogre Fires", 1 },
{ 4200.0f, -4200.0f, 48.0f, "Brute Assault", 1 },
// Faction leaders — V1 VOLRATH_*/TREMBLADE_*.
{ 5178.0f, -4117.0f, 1.0f, "Grand Marshal Tremblade (A)", 3 },
{ 4001.0f, -4088.0f, 52.0f, "High Warlord Volrath (H)", 3 },
};
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeAshranScript()
{
return std::make_unique<AshranScript>();
}
} // namespace Playerbot
@@ -0,0 +1,78 @@
// BattleForGilneasScript — Battle for Gilneas
// (BattlemasterList id 120, map 761). Cata 3-node mini-Arathi.
// Capture 2+ of 3 (Lighthouse / Waterworks / Mines) to score.
//
// Authoritative coords + weights from V1
// src/modules/Playerbot/AI/Coordination/Battleground/Scripts/Domination/
// BattleForGilneasData.h:52-67 (positions), :92-101 (GetNodeStrategicValue),
// :137, :146 (faction spawns).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class BattleForGilneasScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 120; }
char const* name() const override { return "battle_for_gilneas"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// 10v10. Canonical BfG strategy is 2-cap (defend 2, contest the
// 3rd, usually Waterworks).
// * 3 Defenders — one per node.
// * 3 Attackers — push the contested third node.
// * 2 Roamers — counter-flip patrol.
// * 2 Healers — split between offense / defense.
a.role_by_slot = {
BgRole::Defender, BgRole::Defender, BgRole::Defender,
BgRole::Attacker, BgRole::Attacker, BgRole::Attacker,
BgRole::Roamer, BgRole::Roamer,
BgRole::Healer, BgRole::Healer,
};
// BfG node banners are CAPTURE_POINT (42): Lighthouse 228050,
// Waterworks 228052, Mines 228053 (DB-verified on map 761). FLAGSTAND
// (24) dropped (BG audit §2): map 761 has ZERO type-24 GOs — it was an
// inert scan. (The old comment's 208522-208524 are type-31 portal
// doodads, not the banners.)
a.auto_use_go_types = { 42 };
// priority weights mirror V1 GetNodeStrategicValue: Waterworks
// (center, contested) = 2; homes = 0. Attacker tiebreaker biases
// toward Waterworks flips.
a.nodes = {
{ 1057.73f, 1278.29f, 3.19f, "Lighthouse", 0 },
{ 980.07f, 948.17f, 12.72f, "Waterworks", 2 },
// Mines from the live CAPTURE_POINT spawn 228053 on map 761
// (BG audit N73: the V1 coord was 122y south, off the node).
{ 1251.00f, 958.30f, 5.70f, "Mines", 0 },
};
// Defender fallback anchor. The Horde value was (1330,736) which is
// OFF the playable field (map-761 GO y-min ≈ 760.6) — a Defender that
// fell back there walked out of bounds (BG audit §2). Moved on-field
// to (1330,970,6.5), on the line between the Horde Gate (1396,977) and
// the Mines node (1251,958).
if (s.is_horde())
{
a.home_base_x = 1330.0f; a.home_base_y = 970.0f; a.home_base_z = 6.5f;
}
else
{
a.home_base_x = 1052.0f; a.home_base_y = 1396.0f; a.home_base_z = 6.0f;
}
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeBattleForGilneasScript()
{
return std::make_unique<BattleForGilneasScript>();
}
} // namespace Playerbot
@@ -0,0 +1,93 @@
// DeephaulRavineScript — Deephaul Ravine (BattlemasterList id 1110, map 2656).
// TWW 10v10 hybrid: a central Deephaul Crystal (carry to score, Kotmogu-
// style hold) + two mine carts on rails (escort to score, Silvershard-
// style control zones).
//
// Authoritative TC sources (12.0.1):
// src/server/scripts/Battlegrounds/DeephaulRavine/battleground_deephaul_ravine.cpp
// :73-74 — cart creature entries (MineCartEast 214690 / MineCartWest 217346)
// :106 — GameObjects::DeephaulCrystal 422413 (type 36 NEW_FLAG, DB-verified)
// :136-137— cart spawn positions (WestMineCartSpawn / EastMineCartSpawn)
// :139-158— Earthen cart clusters (Horde NE ~4170,-2800; Alliance SW ~3955,-3095)
// :1100 — RegisterBattlegroundMapScript(..., 2656)
// World DB (map 2656): Deephaul Crystal GO spawn at (4063, -2949, 205).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class DeephaulRavineScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 1110; } // Deephaul Ravine
char const* name() const override { return "deephaul_ravine"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// 10v10. The crystal is the swing objective. NOTE: DHR scores the
// crystal on DELIVERY, not on hold — OnCaptureFlag awards +100 only
// when the carrier reaches its faction CapturePoint AreaTrigger
// (battleground_deephaul_ravine.cpp:366-387, AT entries 30/31 at the
// faction base). It is a carry-and-deliver flag, NOT a Kotmogu hold.
// Carts are steady income. Split: 1 crystal carrier + 2 escorts mid,
// 4 cart escorts (2 per cart via the rank spread), 1 roamer, 2 healers.
a.role_by_slot = {
BgRole::FlagCarrier, // crystal runner
BgRole::FCEscort,
BgRole::FCEscort,
BgRole::Attacker, // cart pressure (nodes below)
BgRole::Attacker,
BgRole::Attacker,
BgRole::Attacker,
BgRole::Roamer,
BgRole::Healer,
BgRole::Healer,
};
a.escort_friendly_carrier = true;
a.chase_enemy_carrier = true;
// Crystal pickup: GAMEOBJECT_TYPE_NEW_FLAG (36), entry 422413 —
// visible in the snapshot since the wave-14 GO-filter fix.
a.auto_use_go_types = { 36 };
a.auto_use_go_entries = { 422413 };
// Crystal spawn (world DB gameobject row on map 2656). The
// FlagCarrier path walks here; the auto-use match performs the
// pickup. Respawns mid after each capture.
a.enemy_flag_x = 4063.0f;
a.enemy_flag_y = -2949.0f;
a.enemy_flag_z = 205.0f;
// Carts: moving objectives — follow_creature_entry resolves the
// LIVE cart position from nearby units (Silvershard mechanism);
// the static coords are the TC-script spawn points used until a
// cart is in scan range.
a.nodes = {
{ 3875.00f, -3150.00f, 240.29f, "East Cart", 0, /*follow*/ 214690u },
{ 4250.36f, -2751.07f, 239.47f, "West Cart", 0, /*follow*/ 217346u },
};
// Faction home bases = own Earthen-cart cluster (TC script
// positions). Used as the carrier hold/retreat anchor: the
// crystal scores while HELD, so the carrier hugs its own base
// under escort rather than standing mid.
if (s.is_horde())
{
a.home_base_x = 4170.0f; a.home_base_y = -2800.0f; a.home_base_z = 240.9f;
}
else
{
a.home_base_x = 3955.0f; a.home_base_y = -3095.0f; a.home_base_z = 240.9f;
}
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeDeephaulRavineScript()
{
return std::make_unique<DeephaulRavineScript>();
}
} // namespace Playerbot
@@ -0,0 +1,74 @@
// DeepwindGorgeScript — Deepwind Gorge (BattlemasterList id 754 / BATTLEGROUND_DG).
// Originally a hybrid 15v15 cart-rush; current modern variants (Domination
// 1037/1039 aliased to 754 in BattlegroundScript.cpp:113-115) ship as a
// pure 3-node 10v10 domination BG. The cart mechanic is deprecated in the
// shipped client — treat DG as 3-node domination here. Cart-hauler
// behaviour is deferred until a snapshot field exposes bg.mine_carts
// (V1 cart data preserved in src/modules/Playerbot/.../Domination/
// DeepwindGorgeData.h:159-374).
//
// Map id: 1105 (NOT 998 — 998 is Temple of Kotmogu, common confusion).
// V1 coords: src/modules/Playerbot/AI/Coordination/Battleground/Scripts/
// Domination/DeepwindGorgeData.h:50-63 (nodes), :475-483 (faction spawns).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class DeepwindGorgeScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 754; } // BATTLEGROUND_DG
char const* name() const override { return "deepwind_gorge"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// 10v10 (TEAM_SIZE=10 per V1 DeepwindGorgeData.h:31). FlagCarrier /
// FCEscort REMOVED — DG-domination is not CTF; the snapshot's
// bg.friendly/enemy_flag_carrier fields are never populated for
// this map. The prior 15-slot vector also overshot raid size:
// slots 10-14 were silently truncated, mis-tuning role density.
// Shape mirrors BfG (3 nodes, 10v10).
a.role_by_slot = {
BgRole::Defender, BgRole::Defender, BgRole::Defender,
BgRole::Attacker, BgRole::Attacker, BgRole::Attacker,
BgRole::Roamer, BgRole::Roamer,
BgRole::Healer, BgRole::Healer,
};
// CAPTURE_POINT (42) covers modern banners; FLAGSTAND (24) kept
// for legacy / brawl-variant spawn data. Removed NEW_FLAG (36)
// and FLAGDROP (26) — DG has no flag GOs.
a.auto_use_go_types = { 42, 24 };
// 3 mine nodes. Pandaren Mine sits in the contested middle, so
// priority=1 biases Attacker tie-breaks toward it.
a.nodes = {
{ 1600.53f, 945.24f, 20.0f, "Pandaren Mine", 1 },
{ 1447.27f, 1110.36f, 15.0f, "Goblin Mine", 0 },
{ 1753.79f, 780.12f, 18.0f, "Center Mine", 0 },
};
// V1 spawn coords (DeepwindGorgeData.h:475-483). Used by the
// Defender role as anchor when no node is in range / contested.
if (s.is_horde())
{
a.home_base_x = 1850.0f; a.home_base_y = 800.0f; a.home_base_z = 12.0f;
}
else
{
a.home_base_x = 1350.0f; a.home_base_y = 1100.0f; a.home_base_z = 10.0f;
}
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeDeepwindGorgeScript()
{
return std::make_unique<DeepwindGorgeScript>();
}
} // namespace Playerbot
@@ -0,0 +1,111 @@
// EyeOfTheStormScript — Eye of the Storm (BattlemasterList id 7 / BATTLEGROUND_EY).
// Hybrid: 4 control-zone towers + central Netherstorm Flag. Flag carrier
// caps at any friendly-owned tower (via AreaTrigger 33; no GO-use required).
//
// Authoritative TC sources (12.0.1 branch):
// src/server/scripts/Battlegrounds/EyeOfTheStorm/battleground_eye_of_the_storm.cpp
// :88-97 — tower / flag GO entries
// :207-216 — BattlegroundEYControlZoneHandler (towers are CONTROL_ZONE)
// :266-269 — handler bindings per tower entry
// :639-646 — GAMEOBJECT_TYPE_CONTROL_ZONE (29) dispatch
// :92 — BG_OBJECT_FLAG2_EY_ENTRY = 208977 (NEW_FLAG type, ie 36)
// :101 — AREATRIGGER_CAPTURE_FLAG = 33 (capture mechanic)
// V1 coords for towers + center flag:
// src/modules/Playerbot/AI/Coordination/Battleground/Scripts/Domination/
// EyeOfTheStormData.h:64-91
#include "../BattlegroundScript.h"
namespace Playerbot {
namespace {
class EyeOfTheStormScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 7; } // BATTLEGROUND_EY
char const* name() const override { return "eye_of_the_storm"; }
BattlegroundAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
BattlegroundAdvice a;
// 10v10 (modern TC). Slot 0 sprints to mid for the Netherstorm
// Flag; slots 1-2 escort. Attacker pushes towers; Defender pins
// a friendly-owned tower so the flag carrier can cap on contact.
// Healers split between FC and the offensive cluster.
a.role_by_slot = {
BgRole::FlagCarrier, // slot 0 — grabs Netherstorm Flag
BgRole::FCEscort,
BgRole::FCEscort,
BgRole::Attacker,
BgRole::Attacker,
BgRole::Attacker,
BgRole::Defender,
BgRole::Defender,
BgRole::Healer,
BgRole::Healer,
};
a.escort_friendly_carrier = true;
a.chase_enemy_carrier = true;
a.chase_melee_only = true; // see WarsongGulchScript.cpp
// Mobility-FC preference. EotS Netherstorm flag is a long open
// run from mid to a friendly tower — Rogue stealth + Druid
// Travel Form + DH double-jump + Hunter Disengage all clear
// the corridor faster than a clothie would. Hunter (3) added
// since EotS has more LoS than WSG's flag tunnel.
a.fc_class_preference = { 3u, 4u, 11u, 12u };
// Towers capture by PRESENCE (GAMEOBJECT_TYPE_CONTROL_ZONE 29 has
// NO case in GameObject::Use() — emitting use_game_object on a tower
// is a server no-op that just burns a dispatch slot + the 3s BgUseGo
// cooldown). The Attacker rule parks bots inside the control-zone
// radius to tick the tower over; no click is involved (BG audit S4).
// Central Netherstorm Flag pickup. NOTE 2026-06-22: the flag is BROKEN in
// this server's world data and CANNOT be carried by anyone (bot or human)
// until it's restored — entry 208977 "Netherstorm Flag" has NO spawn on
// map 566, and its gameobject_template type is 24 (FLAGSTAND) whereas the
// core flag state-machine (GameObject::GetFlagState / GetFlagCarrierGUID)
// only works for NEW_FLAG (36). EotS still scores via the 4 CONTROL_ZONE
// towers (verified). We advertise all plausible flag GO types so that the
// INSTANT the world data is fixed (spawn 208977 on 566 as type 36 with
// newflag.pickupSpell 34976), bots pick it up with no further code change.
// Flag CAPTURE proper is AreaTrigger 33 once the carrier stands inside a
// friendly tower — no GO-use call. (37 = dropped-flag re-grab.)
a.auto_use_go_types = { 24, 36, 37 };
// Netherstorm Flag pickup pedestal (mid). Authoritative from V1
// EyeOfTheStormData.h:88-90 — confirmed near-equidistant from
// all 4 towers (prior coord was a tower-top X≈2055, off by ~120y).
a.enemy_flag_x = 2174.78f;
a.enemy_flag_y = 1569.05f;
a.enemy_flag_z = 1159.96f;
// own_flag deliberately left at {0,0,0}: EotS caps at any
// friendly tower, not a fixed pedestal. The FlagCarrier path
// falls through to "closest own-team node" when own_flag is
// sentinel-zero (State_Idle.cpp:3286-3318).
//
// 4 towers at the "* Cap Pt" CONTROL_ZONE GO spawns (184080-184083,
// live world DB map 566). BG audit N70: the old V1-attributed
// "Mage Tower" (1807,1540) was the HORDE FLOATING START PLATFORM
// ~295y from any tower, and "Draenei Ruins" (2284,1577) sat 183y
// out in mid-field — half the tower rotation was mis-targeted.
// These MUST stay in sync with the EOTS_WS_NODES worldstate-
// harvest table in BotSnapshotBuilder.cpp (5y cross-reference).
// Priority=1 tilts the Attacker tiebreaker toward the mid-closer
// pair when ownership buckets tie (State_Idle.cpp:3561-3568).
a.nodes = {
{ 2024.6f, 1742.8f, 1195.2f, "Fel Reaver Ruins", 0 },
{ 2050.5f, 1372.2f, 1194.6f, "Blood Elf Tower", 0 },
{ 2301.0f, 1386.9f, 1197.2f, "Draenei Ruins", 1 },
{ 2282.1f, 1760.0f, 1189.7f, "Mage Tower", 1 },
};
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeEyeOfTheStormScript()
{
return std::make_unique<EyeOfTheStormScript>();
}
} // namespace Playerbot
@@ -0,0 +1,221 @@
// IsleOfConquestScript — Isle of Conquest (BattlemasterList id 30 / BATTLEGROUND_IC).
// 40v40. Capture Workshop / Hangar / Docks / Refinery / Quarry, drive
// siege vehicles to break the enemy keep gate, then kill the General
// (NPC_HIGH_COMMANDER_HALFORD_WYRMBANE 34924 or NPC_OVERLORD_AGMAR 34922
// — the literal win condition per TC battleground_isle_of_conquest.cpp:
// 386-395 OnUnitKilled).
//
// Authoritative TC sources:
// src/server/scripts/Battlegrounds/IsleOfConquest/isle_of_conquest.h
// :23-24 — General NPC entries
// :27-33 — vehicle creature entries
// :76-87 — gate-state worldstates
// src/server/scripts/Battlegrounds/IsleOfConquest/battleground_isle_of_conquest.cpp
// :182-191, :221-227 — node banners + keep gate entries
// :386-395 — OnUnitKilled (win-condition)
// V1 author-attested coords (NOT TC-validated; DB-driven on TC):
// src/modules/Playerbot/AI/Coordination/Battleground/Scripts/Siege/
// IsleOfConquestData.h:128-134 (nodes), :354-364 (Generals).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class IsleOfConquestScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 30; } // BATTLEGROUND_IC
char const* name() const override { return "isle_of_conquest"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// 40-slot raid layout. Workshop is the strategic key — it yields
// Siege Engines, the only vehicle that meaningfully damages keep
// gates. Defenders weighted toward it via per-node priority=3.
// * 16 Attackers — push Workshop → Siege Engine → enemy gate → General.
// * 10 Defenders — Workshop + keep gate sentinels + node holds.
// * 7 Roamers — counter-flips + intercept enemy siege.
// * 7 Healers — front line + rear / rez cycle.
a.role_by_slot.assign(40, BgRole::Free);
for (uint8_t i = 0; i < 16; ++i) a.role_by_slot[i] = BgRole::Attacker;
for (uint8_t i = 16; i < 26; ++i) a.role_by_slot[i] = BgRole::Defender;
for (uint8_t i = 26; i < 33; ++i) a.role_by_slot[i] = BgRole::Roamer;
for (uint8_t i = 33; i < 40; ++i) a.role_by_slot[i] = BgRole::Healer;
a.auto_use_go_types = { 42, 24 }; // CAPTURE_POINT + legacy FLAGSTAND
// IoC banners are GO type 1/10 — no type-42/24 GOs exist on map 628
// (audit B26). DB-verified banner/goober entries (incl. node banners
// and the Seaforium charges used to breach gates):
a.auto_use_go_entries = {
195130, 195132, 195133, 195144, 195145, 195149, 195150, 195151,
195152, 195153, 195154, 195155, 195156, 195157, 195158, 195334,
195335, 195336, 195337, 195338, 195339, 195340, 195341, 195342,
195343, 195391, 195392, 195393, 195394, 195396, 195397, 195398,
195399, 195237, 195332, 195333,
};
// Endgame target = enemy General coords. Win condition per TC
// OnUnitKilled (battleground_isle_of_conquest.cpp:386-395). The
// bot's nearby-enemy aggro takes over once the General is in
// sight; the coord just routes the AllIn push to the right keep.
// * NPC_HIGH_COMMANDER_HALFORD_WYRMBANE 34924 — Alliance Boss
// * NPC_OVERLORD_AGMAR 34922 — Horde Boss
// Coords from V1 IsleOfConquestData.h:354-364 (author-attested,
// not TC-validated; TC's General spawns are DB-driven).
// Gate-aware endgame: when any enemy gate is still standing,
// attackers push to the gate FIRST; only once all 3 gates are
// down do they push the General. Gate coords are the REAL DB
// gameobject spawn positions on map 628 (the V1 coords here were
// wrong, and the front/west/east → entry mapping is NOT contiguous):
// Alliance Front 195698 @ (413.479,-833.95,48.524)
// West 195699 @ (351.615,-762.75,48.916)
// East 195700 @ (351.024,-903.326,48.925)
// Horde Front 195494 @ (1150.90,-762.606,47.508)
// West 195495 @ (1217.90,-676.948,47.634)
// East 195496 @ (1218.74,-851.155,48.253)
// siege_target_go_entries = the ENEMY keep gates this bot's siege
// vehicle fires at (cast_vehicle_at the gate's live position via the
// bg_vehicle_fire_gate rule). Any vehicle spell that lands on the
// General then instakills it (boss SpellHit, boss_ioc_horde_alliance
// .cpp:66-74) — the unit-fire rule handles that once gates are down.
if (s.is_horde())
{
a.home_base_x = 1189.0f; a.home_base_y = -737.0f; a.home_base_z = 48.0f;
a.siege_target_go_entries = { 195698, 195699, 195700 }; // Alliance gates
// Once ANY Alliance gate is breached the keep is open — commit to the
// GENERAL (real IoC: one breach gives keep access). endgame_unconditional
// makes the coordinator issue PushEndgame so free attackers pour through
// the breach on foot (the verified AV-style push + engage-on-LoS); without
// it push_boss stays false in a balanced match (sd~0) and the General is
// never targeted. Until a breach, aim at the front gate so siege vehicles
// drive up and break it (the fire rule targets whichever gate is nearest).
const bool any_gate_down =
s.bg_ioc_gate_destroyed(BotSnapshot::IocGateFrontA) ||
s.bg_ioc_gate_destroyed(BotSnapshot::IocGateWestA) ||
s.bg_ioc_gate_destroyed(BotSnapshot::IocGateEastA);
if (any_gate_down)
{
// Push INTO the command room next to the General so foot bots
// fight him down like players do (he's a boss with Honor Guards +
// a combat kit + a 25y Rage leash from home — meant to be DPS'd,
// not cheesed). His exact pedestal (225,-832,60.9) is off-mesh, but
// the command-room FLOOR around him at z~60 IS meshed and reachable
// via the ramp up from the breach (verified: breach->room 33 polys
// OK). Aim ~10y from him at floor height so the engage-on-LoS rule
// closes to melee/caster range inside the Rage leash.
a.endgame_target_x = 235.0f; a.endgame_target_y = -832.0f;
a.endgame_target_z = 60.0f;
a.endgame_unconditional = true;
}
else
{
a.endgame_target_x = 413.479f; a.endgame_target_y = -833.95f;
a.endgame_target_z = 48.524f;
}
a.endgame_creature_entry = 34924; // NPC_HIGH_COMMANDER_HALFORD_WYRMBANE
}
else
{
a.home_base_x = 345.0f; a.home_base_y = -857.0f; a.home_base_z = 48.0f;
a.siege_target_go_entries = { 195494, 195495, 195496 }; // Horde gates
// Mirror the Alliance side: any breached Horde gate -> push the General.
const bool any_gate_down =
s.bg_ioc_gate_destroyed(BotSnapshot::IocGateFrontH) ||
s.bg_ioc_gate_destroyed(BotSnapshot::IocGateWestH) ||
s.bg_ioc_gate_destroyed(BotSnapshot::IocGateEastH);
if (any_gate_down)
{
// Push into Overlord Agmar's command room so foot bots fight him
// down. Unlike the Alliance pedestal, his exact spot snaps to a
// reachable poly (verified: breached-front-gate -> (1295,-765,70)
// 31 polys OK, ramp up from the east), so target it directly.
a.endgame_target_x = 1295.0f; a.endgame_target_y = -765.0f;
a.endgame_target_z = 70.0f;
a.endgame_unconditional = true;
}
else
{
a.endgame_target_x = 1150.90f; a.endgame_target_y = -762.606f;
a.endgame_target_z = 47.508f;
}
a.endgame_creature_entry = 34922; // NPC_OVERLORD_AGMAR
}
// 7 capturable nodes, prioritised by strategic value. Coords are
// the banner-GO spawn positions from the world DB on map 628
// (B26b: the previous V1-attributed coords were corrupted — the
// Docks/Hangar/Quarry Y values pointed at the wrong ends of the
// island, e.g. "Hangar" at y=-123 vs the real banner at y=-1000).
// These MUST stay in sync with the IOC_WS_NODES worldstate-harvest
// table in BotSnapshotBuilder.cpp — consumers cross-reference
// node_states against advice nodes within 5 yards.
// * Workshop (p=3) — yields Siege Engines (34776/35069),
// the only effective gate-breakers.
// * Hangar (p=2) — gunship cannons for gate strafing.
// * Docks (p=2) — Glaive Throwers + Catapults.
// * Refinery (p=1) — passive honor (spell 68719).
// * Quarry (p=1) — passive honor (spell 68720).
// * Keep GYs (p=1) — own = late-game defense anchor; enemy
// becomes cappable once its gates fall.
a.nodes = {
{ 776.23f, -804.28f, 6.45f, "Workshop", 3 },
{ 807.78f, -1000.07f, 132.38f, "Hangar", 2 },
{ 726.39f, -360.21f, 17.82f, "Docks", 2 },
{ 1269.50f, -400.81f, 37.63f, "Refinery", 1 },
{ 251.02f, -1159.32f, 17.24f, "Quarry", 1 },
{ 299.15f, -784.59f, 48.92f, "Alliance Keep Graveyard", 1 },
{ 1284.76f, -705.67f, 48.92f, "Horde Keep Graveyard", 1 },
};
// Vehicle entries — ALL confirmed against TC isle_of_conquest.h:27-33.
// 34775 Demolisher (NPC_DEMOLISHER)
// 34776 Siege Engine A (NPC_SIEGE_ENGINE_A)
// 35069 Siege Engine H (NPC_SIEGE_ENGINE_H)
// 34802 Glaive Thrower A (NPC_GLAIVE_THROWER_A)
// 35273 Glaive Thrower H (NPC_GLAIVE_THROWER_H)
// 34793 Catapult (NPC_CATAPULT)
// 34944 Keep Cannon (NPC_KEEP_CANNON — defender side)
// NOTE: 34944 Keep Cannon DELIBERATELY EXCLUDED from the auto-mount
// list. It is a STATIONARY defensive turret bolted to the keep wall —
// it cannot drive. Live [ioc] diag showed Workshop-attackers spawning
// at the keep mounting the nearest vehicle (always a keep cannon, ~17y
// away) and then sitting in idle:bg_vehicle_drive_to_gate forever (1756
// mounts, 0 of them a real Siege Engine), starving the Workshop assault
// and the gate breach. Mountable list is now offensive vehicles only;
// Siege Engines (34776/35069) come from capturing the Workshop.
a.vehicle_creature_entries = { 34775, 34776, 35069, 34802, 35273, 34793 };
// Per-entry seat-0 PRIMARY spells, sourced from the live world DB
// creature_template_spell action bars (index 0 = the vehicle's
// primary attack). These supersede the old 50652/66809/65775 guesses,
// none of which are this server's IoC vehicle bars:
// 34775 Demolisher → 67440 (boulder, gate-breaker)
// 34776 Siege Engine A → 67796 (ram, the prime gate-breaker)
// 35069 Siege Engine H → 67796
// 34802 Glaive Thrower A → 66456
// 35273 Glaive Thrower H → 67034
// 34944 Keep Cannon → 67452 (defender turret)
// 34793 Catapult → 66296 (anti-personnel; its index-0
// 66218 is the player-LAUNCH utility, not
// a weapon — the Catapult is NOT a gate-
// breaker so it's excluded from sieging).
a.vehicle_seat_spell_by_entry = {
{ 34775u, 67440u }, { 34776u, 67796u }, { 35069u, 67796u },
{ 34802u, 66456u }, { 35273u, 67034u }, { 34944u, 67452u },
{ 34793u, 66296u },
};
// Fallback for any unmapped vehicle: the Siege Engine ram (also the
// gate-breaker) rather than a bogus id; server drops a mismatched
// cast harmlessly.
a.vehicle_seat_spell = 67796;
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeIsleOfConquestScript()
{
return std::make_unique<IsleOfConquestScript>();
}
} // namespace Playerbot
@@ -0,0 +1,100 @@
// SeethingShoreScript — Seething Shore (BattlemasterList id 894 / BATTLEGROUND_SS).
// BfA pre-launch BG. Race to capture random Azerite extraction nodes
// that parachute in from the air. 10v10; nodes spawn in waves; score by
// holding nodes and turning in Azerite.
//
// Authoritative coords from TC source:
// src/server/scripts/Battlegrounds/SeethingShore/battleground_seething_shore.cpp
// Playable area: X≈1200..1430, Y≈2620..2940. Anywhere outside is water /
// fatigue zone — bots placed there die from fatigue.
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class SeethingShoreScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 894; } // BATTLEGROUND_SS
char const* name() const override { return "seething_shore"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// 10v10 layout. SS is a wave-spawn race: Azerite nodes drop from
// the air every ~30s in 2-3 locations selected from the 6 landing
// zones. Roamer rule (contested>neutral priority) chases the next
// wave automatically because the Builder feeds bg_node_states with
// any active capture point. No static defense — the only role
// that "holds" anything is the bot mid-cap on top of an Azerite
// node, which the Roamer already does.
//
// * 4 Roamers — chase the wave; the closest-contested rule
// organically distributes across active spawns.
// * 3 Attackers — push toward the largest contested cluster
// (where the enemy team is also stacked).
// * 2 Healers — split between the two roving subgroups.
// * 1 Defender — last-bot-standing fallback (holds home_base
// when no nodes are live so they don't drown).
a.role_by_slot = {
BgRole::Roamer, BgRole::Roamer, BgRole::Roamer, BgRole::Roamer,
BgRole::Attacker, BgRole::Attacker, BgRole::Attacker,
BgRole::Healer, BgRole::Healer,
BgRole::Defender,
};
// Azerite extraction nodes are GAMEOBJECT_TYPE_CAPTURE_POINT (42)
// — TC battleground_seething_shore.cpp:662-682 binds the capture
// assault handler via that type (60s proximity cap, gameobject_
// template Data0=60000). FLAGSTAND (24) does not appear on this map.
a.auto_use_go_types = { 42 };
// The 12 AZERITE FISSURE candidate locations (creature 125253,
// static spawns on map 1803 — battleground_seething_shore.cpp:69-72,
// :83). The BG activates 3 at a time in waves; on activation the
// controller summons a type-42 capture GO at the fissure position.
// The PREVIOUS coords here were the 6 air-supply BUFF-CRATE drops
// (creature 133542 "Air Supply Ground Dummy") — NOT capturable — so
// bots never reached a real node (BG audit SS blocker). Bots patrol
// the full fissure set; the auto-use(42) pass caps whichever is live,
// and the generic capture-point harvest routes Roamers/Attackers to
// any active node. Verified against world.creature (id 125253, map
// 1803, 12 rows). Playable area X≈1110..1465, Y≈2570..2920.
a.nodes = {
{ 1113.92f, 2886.99f, 38.456f, "Fissure NW Cliff" },
{ 1126.65f, 2781.24f, 30.861f, "Fissure West" },
{ 1243.15f, 2721.47f, 11.902f, "Fissure SW Flats" },
{ 1257.41f, 2882.73f, 27.951f, "Fissure North" },
{ 1259.16f, 2571.42f, 8.586f, "Fissure South" },
{ 1339.56f, 2785.68f, 2.559f, "Fissure Center" },
{ 1343.72f, 2919.95f, 32.870f, "Fissure NE Ridge" },
{ 1361.73f, 2643.27f, 4.468f, "Fissure SE Flats" },
{ 1390.24f, 2570.77f, 6.464f, "Fissure SE Beach" },
{ 1441.06f, 2700.15f, 9.505f, "Fissure East" },
{ 1454.09f, 2598.38f, 15.206f, "Fissure SE Cliff" },
{ 1461.36f, 2823.24f, 31.677f, "Fissure NE Cliff" },
};
// Faction "safe inland" anchors for the Defender last-bot fallback,
// placed on solid ground at real fissure positions (bots that stand
// still at the water edge drown). Horde north / Alliance south.
if (s.is_horde())
{
a.home_base_x = 1257.41f; a.home_base_y = 2882.73f; a.home_base_z = 27.951f;
}
else
{
a.home_base_x = 1361.73f; a.home_base_y = 2643.27f; a.home_base_z = 4.468f;
}
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeSeethingShoreScript()
{
return std::make_unique<SeethingShoreScript>();
}
} // namespace Playerbot
@@ -0,0 +1,90 @@
// SilvershardMinesScript — Silvershard Mines
// (BattlemasterList id 708 / BATTLEGROUND_SM, map 727). MoP 10v10
// resource-race BG: first team to 1500 points wins (battleground_silvershard_
// mines.cpp:134 ResourceValues::Max = 1500). Three mine carts
// auto-travel from a central spawn cluster along the rails toward five
// depot endpoints. Carts are CONTROLLED BY PROXIMITY — there's no
// click, no push, no ride; standing in the capture zone around a cart
// tilts it to your team and trickles points. Players can also click
// two creature-based track switches (Eastern + Northern crossroads)
// to redirect carts onto adjacent paths.
//
// Bot strategy: stack on the nearest cart and contest proximity. With
// only 3 carts among 10 players per side, the right shape is "3 small
// cart squads + healers anchored mid".
//
// Authoritative coords from TC core
// (src/server/scripts/Battlegrounds/SilvershardMines/battleground_silvershard_mines.cpp):
// MineCartSouth (739.29, 203.76, 319.54)
// MineCartEast (744.51, 183.20, 319.54)
// MineCartNorth (759.32, 198.33, 319.53)
// All three spawn in the central mine at Z≈319.5 — NOT the surface Z=380+
// the prior coords referenced (those were cosmetic-only carts visible from
// above ground).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class SilvershardMinesScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 708; } // BATTLEGROUND_SM
char const* name() const override { return "silvershard_mines"; }
BattlegroundAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
BattlegroundAdvice a;
// 10v10 — three cart squads (3 each) + one flex Healer anchored
// mid for cross-cart support. No FlagCarrier role (no flags).
// No Defender (no static base to defend — depots score for whoever
// happens to be near the cart at the moment of capture, not for a
// standing defender). Roamer rule's "closest contested node" logic
// covers cart contesting cleanly when we feed it the 3 cart spawns
// as nodes.
a.role_by_slot = {
BgRole::Roamer, BgRole::Roamer, BgRole::Roamer, // South cart squad
BgRole::Roamer, BgRole::Roamer, BgRole::Roamer, // East cart squad
BgRole::Roamer, BgRole::Roamer, BgRole::Roamer, // North cart squad
BgRole::Healer, // flex mid healer
};
// No GO auto-use: the "track switches" in Silvershard are CLICKABLE
// CREATURES (StringId bg_silvershard_mines_track_switch_east /
// _north in TC core), not GameObjects. The bot's auto_use_go path
// operates on GO type IDs; using it here would scan for GOs that
// aren't there. A creature-cast intent would be needed to flip
// switches, but bots don't currently have that wiring — proximity
// control alone is enough to play the BG passably.
a.auto_use_go_types = {};
// Three cart spawn positions (all Z≈319). The carts MOVE on
// rails after spawn; follow_creature_entry=60140 (NPC_MINE_CART
// entry, used 3 times) tells the consumer to re-resolve each
// node's position from the nearest live Creature with this entry
// — so the bot tracks the cart as it travels along the rail.
// Static x/y/z is the spawn-point fallback used before the cart
// creature is in nearby_units range.
a.nodes = {
{ 739.30f, 203.76f, 319.54f, "Cart South", /*priority*/ 0, /*follow_creature_entry*/ 60140u },
{ 744.52f, 183.20f, 319.54f, "Cart East", /*priority*/ 0, /*follow_creature_entry*/ 60140u },
{ 759.32f, 198.33f, 319.53f, "Cart North", /*priority*/ 0, /*follow_creature_entry*/ 60140u },
};
// No home_base / endgame_target — Silvershard has no static
// defendable point and no enemy boss to push toward. Carts
// travel to 5 different depots depending on switch state;
// chasing a fixed "enemy depot" coord would just walk bots
// away from the gameplay center.
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeSilvershardMinesScript()
{
return std::make_unique<SilvershardMinesScript>();
}
} // namespace Playerbot
@@ -0,0 +1,180 @@
// StrandOfAncientsScript — Strand of the Ancients
// (BattlemasterList id 9 / BATTLEGROUND_SA, map 607). Attack/defense
// alternation: attackers drive Demolishers + ram south wall, then the
// inner gates, then cap the Titan Relic. Defenders camp gates + relic.
//
// Round-aware: snapshot exposes BG_SA_ATTACKER_TEAM (worldstate 3690,
// TC battleground_strand_of_the_ancients.cpp:166). The script swaps
// role mix AND endgame target / home base by phase — on the defender
// round, the script's Attacker-role bots would otherwise march to their
// OWN relic when AllIn-biased.
//
// Authoritative TC sources:
// src/server/scripts/Battlegrounds/StrandOfTheAncients/battleground_strand_of_the_ancients.cpp
// :74-82 — gate / Titan Relic GO entries
// :141-146 — gate-state worldstates (3614/3617/3620/3623/3638/3849)
// :166 — BG_SA_ATTACKER_TEAM worldstate
// :250-251 — Demolisher + Antipersonnel Turret entries
// V1 author-attested coords (not TC-validated; DB-driven):
// src/modules/Playerbot/AI/Coordination/Battleground/Scripts/Siege/
// StrandOfTheAncientsData.h:186-188 (relic), :238-245 (gates),
// :251-257 (graveyards), :263-268 (demolisher mount points).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class StrandOfAncientsScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 9; } // BATTLEGROUND_SA
char const* name() const override { return "strand_of_ancients"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// sota_attacker_team: 0=Alliance attacks, 1=Horde attacks, -1=N/A.
const int8 atk = s.bg_sota_attacker_team();
const bool my_team_attacks =
atk >= 0 && ((s.is_horde() && atk == 1) || (!s.is_horde() && atk == 0));
if (my_team_attacks)
{
// Attacker round: 15-slot offense-heavy mix. Vehicle pilots
// (demolishers via vehicle_creature_entries auto-mount),
// foot infantry, healers riding the push.
a.role_by_slot = {
BgRole::Attacker, BgRole::Healer, BgRole::Attacker,
BgRole::Attacker, BgRole::Roamer, BgRole::Attacker,
BgRole::Attacker, BgRole::Attacker, BgRole::Healer,
BgRole::Roamer, BgRole::Attacker, BgRole::Attacker,
BgRole::Roamer, BgRole::Attacker, BgRole::Healer,
};
}
else
{
// Defender round: heavy relic camp. Roamers intercept
// vehicles + skirmish; defenders cover gates and relic.
a.role_by_slot = {
BgRole::Defender, BgRole::Healer, BgRole::Defender,
BgRole::Roamer, BgRole::Defender, BgRole::Defender,
BgRole::Roamer, BgRole::Defender, BgRole::Healer,
BgRole::Defender, BgRole::Defender, BgRole::Roamer,
BgRole::Defender, BgRole::Defender, BgRole::Healer,
};
}
// Demolishers (NPC_DEMOLISHER = 28781, TC :250). Seat-0 ram/boulder
// is spell 50652 (verified the long-standing SoTA demolisher fire
// spell). The bg_vehicle_fire_gate rule casts it AT the gate position.
a.vehicle_creature_entries = { 28781 };
a.vehicle_seat_spell = 50652;
a.auto_use_go_types = { 42, 24 }; // CAPTURE_POINT + FLAGSTAND
// SoTA banners + the TITAN RELIC (the win condition!) are GO type
// 1/10 — no type-42/24 GOs exist on map 607 (audit B26). Without
// these entries, attackers could breach every gate yet never cap.
// Relic entries 194082 (Horde-attacker) / 194083 (Alliance-attacker)
// spawn at (837.065,-107.537,127.025); they become interactable only
// after the Ancient (Last) gate falls (MakeObjectsInteractable).
a.auto_use_go_entries = {
191305, 191306, 191307, 191308, 191309, 191310,
194082, 194083, // Titan Relic (A/H) — the win object
};
// 5 graveyards flipping inward as attackers push. Priority on
// intermediate GYs biases the Attacker rule toward the next
// unflipped tier without needing per-tick gate-state telemetry.
a.nodes = {
{ 1597.0f, -106.0f, 8.0f, "Beach GY", 0 },
{ 1338.0f, -298.0f, 32.0f, "West GY", 1 }, // post-tier-1
{ 1338.0f, 245.0f, 32.0f, "East GY", 1 }, // post-tier-1
{ 1119.0f, -24.0f, 67.0f, "South GY", 2 }, // post-tier-2
{ 830.0f, -24.0f, 93.0f, "Defender Start GY", 0 },
};
// Round-aware home / endgame swap. Without this, the defender-
// round Attacker-role bots walk to their OWN relic on AllIn bias.
if (my_team_attacks)
{
// ENEMY gates this team's demolishers fire at (real DB spawn
// positions on map 607). Listed across all defense lines; the
// bg_vehicle_fire_gate rule picks the nearest STANDING one, and
// only the current line's gate is reachable (later lines are
// collision-walled until the earlier line falls). Entries:
// First : Green 190722 (1412.74,106.993,29.878),
// Blue 190724 (1431.13,-218.684,32.105)
// Second: Red 190726 (1229.67,-211.30,56.436),
// Purple190723 (1214.78,81.561,54.582)
// Third : Yellow190727 (1055.90,-107.628,83.428)
// Last : Ancient192549(878.033,-108.191,117.832)
a.siege_target_go_entries =
{ 190722, 190724, 190726, 190723, 190727, 192549 };
// On-foot fallback: SEAFORIUM bomb pickup GO (190753 Alliance /
// 194086 Horde — type-22 SPELLCASTER granting charge aura 52415).
// Footmen grab a charge then carry it to a gate. (Add the pickup
// GO so auto-use grants the charge; the place-cast 52410 is the
// demolisher's backup, not modeled per-bot yet.)
a.auto_use_go_entries.push_back(s.is_horde() ? 194086u : 190753u);
// SoTA scores 0/0 all match (no AddPoint), so the score bias
// never leaves Normal — drive attackers to the breach/relic
// UNCONDITIONALLY (BG audit SoTA blocker), not just under AllIn.
a.endgame_unconditional = true;
// Gate-state-aware tier targeting → aim endgame at the specific
// EARLIEST STANDING gate's real position (so foot attackers and
// the demolisher drive-to-gate fallback both converge there).
// bg_sota_gate_state: DESTROYED encodes as 3 (Horde-attacker) OR
// 6 (Alliance-attacker); 0 = unknown → treat as still up.
auto gate_down = [&](BotSnapshot::SotaGateId g) -> bool {
const uint32 st = s.bg_sota_gate_state(g);
return st == 3u || st == 6u;
};
float gx, gy, gz;
if (!gate_down(BotSnapshot::SotaGateGreen))
{ gx = 1412.74f; gy = 106.993f; gz = 29.878f; }
else if (!gate_down(BotSnapshot::SotaGateBlue))
{ gx = 1431.13f; gy = -218.684f; gz = 32.105f; }
else if (!gate_down(BotSnapshot::SotaGateRed))
{ gx = 1229.67f; gy = -211.30f; gz = 56.436f; }
else if (!gate_down(BotSnapshot::SotaGatePurple))
{ gx = 1214.78f; gy = 81.561f; gz = 54.582f; }
else if (!gate_down(BotSnapshot::SotaGateYellow))
{ gx = 1055.90f; gy = -107.628f; gz = 83.428f; }
else
{ gx = 878.033f; gy = -108.191f; gz = 117.832f; } // Ancient Gate / relic chamber
a.endgame_target_x = gx;
a.endgame_target_y = gy;
a.endgame_target_z = gz;
// Home = beach landing.
a.home_base_x = 1597.0f;
a.home_base_y = -106.0f;
a.home_base_z = 8.0f;
}
else
{
// Defenders camp the Titan Relic (real spawn 837.065,-107.537,
// 127.025 — the V1 836,-24,94 centerline was off by ~80y in Y
// and ~30y in Z).
a.endgame_target_x = 837.065f;
a.endgame_target_y = -107.537f;
a.endgame_target_z = 127.025f;
a.home_base_x = 837.065f;
a.home_base_y = -107.537f;
a.home_base_z = 127.025f;
}
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeStrandOfAncientsScript()
{
return std::make_unique<StrandOfAncientsScript>();
}
} // namespace Playerbot
@@ -0,0 +1,91 @@
// TempleOfKotmoguScript — Temple of Kotmogu
// (BattlemasterList id 699 / BATTLEGROUND_TK, map 998).
// MoP 10v10 orb-bearer BG. 4 orbs at corners; pick up to receive a
// stacking score-per-tick whose multiplier scales with distance from
// map center (Small/Medium/Large aura rings — center pays best).
//
// Authoritative TC sources (12.0.1):
// src/server/scripts/Battlegrounds/TempleOfKotmogu/battleground_temple_of_kotmogu.cpp
// :80-83 — orb GO entries 212091..212094 (NEW_FLAG type)
// :96-99 — orb spawn coords (PurpleOrb / GreenOrb / BlueOrb / OrangeOrb)
// :306 — OnFlagTaken pickup handler
// :640 — RegisterBattlegroundMapScript(...,998)
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class TempleOfKotmoguScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 699; } // BATTLEGROUND_TK
char const* name() const override { return "temple_of_kotmogu"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// OrbCarrier role (specific to Kotmogu) walks to the enemy_flag
// pickup, picks up the orb, then holds at home_base (map center)
// to accumulate the SmallAura distance-from-center score
// multiplier — no return-home-to-cap phase. Four OrbCarrier
// slots keep the grab race competitive even when bots respawn
// staggered.
a.role_by_slot = {
BgRole::OrbCarrier, BgRole::OrbCarrier,
BgRole::OrbCarrier, BgRole::OrbCarrier,
BgRole::FCEscort, BgRole::FCEscort,
BgRole::Healer, BgRole::Healer,
BgRole::Roamer, BgRole::Roamer,
};
a.escort_friendly_carrier = true;
a.chase_enemy_carrier = true;
// BG audit N69: the orbs are gameobject_template type 24
// (FLAGSTAND) — entries 212091-212094 ("Orb of Power"), spawned
// dynamically by the BG map script — NOT the NEW_FLAG (36) this
// script previously requested, so the auto-use rule could never
// match one. Match by entry (exact) plus type 24 for the pickup.
a.auto_use_go_types = { 24 };
a.auto_use_go_entries = { 212091, 212092, 212093, 212094 };
// 4 orb spawns at the corners (TC :96-99). Carriers spread
// across the four corners via the FlagCarrier path's
// pickup-target selection; Roamers chase enemy carriers
// (chase_enemy_carrier) so contested orbs get pressured.
a.nodes = {
{ 1850.16f, 1250.11f, 13.21f, "Orange Orb (SE)" },
{ 1716.95f, 1250.02f, 13.33f, "Blue Orb (SW)" },
{ 1716.89f, 1416.62f, 13.21f, "Green Orb (NW)" },
{ 1850.22f, 1416.82f, 13.34f, "Purple Orb (NE)" },
};
// BG audit N03/N33: the OrbCarrier movement path consumes
// enemy_flag_x/y — which this script never set, leaving 4 of 10
// slots motionless all match. Hash the bot's guid across the 4
// orb spawns so each carrier heads to a DIFFERENT corner (stable
// per bot, no per-tick ping-pong). Once adjacent, the auto-use
// entry match above performs the actual grab.
{
const uint64_t gh = s.guid().GetCounter();
auto const& orb = a.nodes[gh % a.nodes.size()];
a.enemy_flag_x = orb.x;
a.enemy_flag_y = orb.y;
a.enemy_flag_z = orb.z;
}
// Map center — midpoint of the 4 orb spawns. Carriers hold
// here for the SmallAura score multiplier (TC :121-123).
a.home_base_x = 1783.59f;
a.home_base_y = 1333.42f;
a.home_base_z = 13.25f;
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeTempleOfKotmoguScript()
{
return std::make_unique<TempleOfKotmoguScript>();
}
} // namespace Playerbot
@@ -0,0 +1,81 @@
// TwinPeaksScript — Twin Peaks (BattlemasterList id 108, map 726).
// Cataclysm CTF, mechanically identical to WSG.
//
// Authoritative TC sources:
// src/server/scripts/Battlegrounds/TwinPeaks/battleground_twin_peaks.cpp
// :50-51 — flag GO entries 227740 (Horde) / 227741 (Alliance)
// src/server/game/Miscellaneous/SharedDefines.h:3195-3196
// NEW_FLAG=36, NEW_FLAG_DROP=37
// V1 coords:
// src/modules/Playerbot/AI/Coordination/Battleground/Scripts/CTF/
// TwinPeaksData.h:80-89 (flag spawns), :165, :187 (defender posts)
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class TwinPeaksScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 108; }
char const* name() const override { return "twin_peaks"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// WSG/TP score = flag CAPS (0-3), not points: a 2-cap swing is
// decisive (BG audit N55/N65 -- the 200-point default could
// never trigger here).
a.score_bias_threshold = 2;
a.role_by_slot = {
BgRole::FlagCarrier,
BgRole::FCEscort,
BgRole::Defender,
BgRole::Defender,
BgRole::Roamer,
BgRole::Roamer,
BgRole::Attacker,
BgRole::Attacker,
BgRole::Healer,
BgRole::Healer,
};
a.escort_friendly_carrier = true;
a.chase_enemy_carrier = true;
a.chase_melee_only = true; // see WarsongGulchScript.cpp
// Stealth FC — Rogue + Druid (see WarsongGulchScript.cpp).
a.fc_class_preference = { 4u, 11u };
// NEW_FLAG (36) pedestals + NEW_FLAG_DROP (37) dropped flag.
// Legacy FLAGDROP (26) does not spawn on map 726.
a.auto_use_go_types = { 36, 37 };
// V1 TwinPeaksData.h:80-89.
constexpr float ALLY_FX = 2118.210f, ALLY_FY = 191.621f, ALLY_FZ = 44.052f;
constexpr float HORDE_FX = 1578.339f, HORDE_FY = 344.063f, HORDE_FZ = 2.419f;
if (s.is_horde())
{
a.own_flag_x = HORDE_FX; a.own_flag_y = HORDE_FY; a.own_flag_z = HORDE_FZ;
a.enemy_flag_x = ALLY_FX; a.enemy_flag_y = ALLY_FY; a.enemy_flag_z = ALLY_FZ;
// Horde fortress balcony (V1 :187) — covers main entrance + back ramp.
a.home_base_x = 1578.34f; a.home_base_y = 338.06f; a.home_base_z = 9.42f;
}
else
{
a.own_flag_x = ALLY_FX; a.own_flag_y = ALLY_FY; a.own_flag_z = ALLY_FZ;
a.enemy_flag_x = HORDE_FX; a.enemy_flag_y = HORDE_FY; a.enemy_flag_z = HORDE_FZ;
// Wildhammer balcony (V1 :165), elevated above pedestal.
a.home_base_x = 2118.21f; a.home_base_y = 185.62f; a.home_base_z = 51.05f;
}
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeTwinPeaksScript()
{
return std::make_unique<TwinPeaksScript>();
}
} // namespace Playerbot
@@ -0,0 +1,102 @@
// WarsongGulchScript — Warsong Gulch (BattlemasterList id 2 / BATTLEGROUND_WS).
// Classic CTF on the modern map (TC registers WSG on map 2106; legacy
// map 489 still referenced in some DBC paths).
//
// Authoritative TC sources (12.0.1 branch):
// src/server/scripts/Battlegrounds/WarsongGulch/battleground_warsong_gulch.cpp
// :123-124 — flag GO entries 227740 (Horde) / 227741 (Alliance)
// :43-50 — pickup spells (23333..23336)
// src/server/game/Miscellaneous/SharedDefines.h
// :3195 — GAMEOBJECT_TYPE_NEW_FLAG = 36
// :3196 — GAMEOBJECT_TYPE_NEW_FLAG_DROP = 37
// V1 coords:
// src/modules/Playerbot/AI/Coordination/Battleground/Scripts/CTF/
// WarsongGulchData.h:38-47 (flag spawns), :123-149 (defender posts)
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class WarsongGulchScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 2; } // BATTLEGROUND_WS
char const* name() const override { return "warsong_gulch"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// WSG/TP score = flag CAPS (0-3), not points: a 2-cap swing is
// decisive (BG audit N55/N65 -- the 200-point default could
// never trigger here).
a.score_bias_threshold = 2;
// 10v10. Slot 0 is FC (stealth-class bias is a deferred schema
// add — see fc_class_preference TODO). FlagCarrier and FCEscort
// are dynamic-handoff capable upstream (idle:acts_as_fc backup
// path lets Roamer/Attacker step in when the primary dies).
a.role_by_slot = {
BgRole::FlagCarrier,
BgRole::FCEscort,
BgRole::Defender, // flagroom anchor — ramp top / balcony
BgRole::Defender,
BgRole::Roamer,
BgRole::Roamer,
BgRole::Attacker,
BgRole::Attacker,
BgRole::Healer,
BgRole::Healer,
};
a.escort_friendly_carrier = true;
a.chase_enemy_carrier = true;
// Clothie casters (Priest/Mage/Warlock/Evoker) shouldn't sprint
// into a moving FC at 1v1 range — they feed honor and get peeled
// off their objectives. Melee + hybrid + Hunter can chase.
a.chase_melee_only = true;
// Stealth-FC meta: Rogue (4) and Druid (11 — feral Travel Form)
// are the canonical WSG carriers. Stealth opens a defended
// flagroom; Druid Travel keeps the run from being kited dead.
// The dispatcher promotes preferred-class bots to FlagCarrier
// regardless of slot hash; non-preferred bots that would have
// hashed into the FC slot fall back to Roamer.
a.fc_class_preference = { 4u, 11u };
// Modern WSG: flag pedestals are NEW_FLAG (36); dropped flag
// is NEW_FLAG_DROP (37). Legacy FLAGDROP (26) is NOT spawned
// on map 2106 — including it was a no-op that masked the
// missing 37 (dropped-flag auto-return).
a.auto_use_go_types = { 36, 37 };
// Flag spawn coords from V1 WarsongGulchData.h:38-47
// (Alliance 1540.423,1481.325,351.818; Horde 916.023,1433.805,346.037).
constexpr float ALLY_FX = 1540.423f, ALLY_FY = 1481.325f, ALLY_FZ = 351.818f;
constexpr float HORDE_FX = 916.023f, HORDE_FY = 1433.805f, HORDE_FZ = 346.037f;
if (s.is_horde())
{
a.own_flag_x = HORDE_FX; a.own_flag_y = HORDE_FY; a.own_flag_z = HORDE_FZ;
a.enemy_flag_x = ALLY_FX; a.enemy_flag_y = ALLY_FY; a.enemy_flag_z = ALLY_FZ;
// Defender anchor = Horde ramp top (V1 :159) — covers
// tunnel-mouth + the only ranged sightline. NOT the flag
// pedestal: planting Defenders on the pedestal eats AoE and
// hands stealth carriers a global pickup.
a.home_base_x = 948.68f; a.home_base_y = 1458.65f; a.home_base_z = 345.903f;
}
else
{
a.own_flag_x = ALLY_FX; a.own_flag_y = ALLY_FY; a.own_flag_z = ALLY_FZ;
a.enemy_flag_x = HORDE_FX; a.enemy_flag_y = HORDE_FY; a.enemy_flag_z = HORDE_FZ;
// Alliance ramp top (V1 :133).
a.home_base_x = 1507.04f; a.home_base_y = 1456.85f; a.home_base_z = 352.013f;
}
return a;
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeWarsongGulchScript()
{
return std::make_unique<WarsongGulchScript>();
}
} // namespace Playerbot
@@ -0,0 +1,129 @@
// WintergraspScript — Battle for Wintergrasp
// (BattlemasterList ids 1017 + 1030 / BATTLEGROUND_EB_BW + EB_BW2).
// Epic 40v40 push-the-fortress adapted from the live Wintergrasp
// outdoor PvP zone. Map 571 (Northrend), zone 4197.
//
// Bot strategy: attackers grab vehicles at the 4 workshops, push to
// the keep gates, then to the Titan Relic in the keep interior.
// Defenders sit on workshops + keep cannons.
//
// Important note: there is NO authoritative BattlefieldWG source in
// this branch — coords below are approximate (workshop coords match
// long-standing TC/AC conventions; gates + relic are best-effort).
// Vehicle entries are well-known retail IDs (27881 / 28094 / 28312 /
// 32627). The vehicle_seat_spell_by_entry uses Hurl Boulder (50652)
// for the Demolisher only; the Catapult/Siege Engine overrides are
// best-effort, with SpellMgr rejection as safety net (bot stays
// mounted, owner can drive).
#include "../BattlegroundScript.h"
#include "../../BotSnapshotView.h"
namespace Playerbot {
namespace {
class WintergraspScript final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 1017; } // BATTLEGROUND_EB_BW
char const* name() const override { return "wintergrasp_battle"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
BattlegroundAdvice a;
// 40-slot raid. WG-as-BG is decided by wall breach → relic.
// * 18 Attackers — vehicle pilots + gate breachers + relic push.
// * 6 Defenders — keep cannon line + workshop sentinels.
// * 8 Roamers — workshop counter-flips.
// * 8 Healers — split frontline / rear.
a.role_by_slot.assign(40, BgRole::Free);
for (uint8_t i = 0; i < 18; ++i) a.role_by_slot[i] = BgRole::Attacker;
for (uint8_t i = 18; i < 24; ++i) a.role_by_slot[i] = BgRole::Defender;
for (uint8_t i = 24; i < 32; ++i) a.role_by_slot[i] = BgRole::Roamer;
for (uint8_t i = 32; i < 40; ++i) a.role_by_slot[i] = BgRole::Healer;
a.auto_use_go_types = { 42, 24 };
// Endgame: Titan Relic in the keep interior. Approximate coord
// (no authoritative BattlefieldWG.cpp in this branch).
constexpr float RELIC_X = 5440.0f, RELIC_Y = 2840.0f, RELIC_Z = 419.0f;
if (s.is_horde())
{
a.home_base_x = 5032.454f; a.home_base_y = 3711.382f; a.home_base_z = 372.468f;
a.endgame_target_x = RELIC_X;
a.endgame_target_y = RELIC_Y;
a.endgame_target_z = RELIC_Z;
}
else
{
a.home_base_x = 5140.790f; a.home_base_y = 2179.120f; a.home_base_z = 390.950f;
a.endgame_target_x = RELIC_X;
a.endgame_target_y = RELIC_Y;
a.endgame_target_z = RELIC_Z;
}
// Nodes: workshops (p=2 — siege vehicle spawns) + keep gates
// (p=3 — Attacker primary target after vehicles built) +
// graveyards (p=0) + Titan Relic (p=4 — win condition).
a.nodes = {
// Workshops — siege vehicle spawn points.
{ 5104.750f, 2300.940f, 368.579f, "Sunken Ring Workshop", 2 },
{ 5099.120f, 3466.036f, 368.484f, "Broken Temple Workshop", 2 },
{ 4314.648f, 2408.522f, 392.642f, "Eastpark Workshop", 2 },
{ 4331.716f, 3235.695f, 390.251f, "Westpark Workshop", 2 },
// Keep gates — Attacker primary after vehicles. Coords
// approximate (no TC BattlefieldWG source).
{ 5328.0f, 2842.0f, 408.0f, "Keep South Gate (approx)", 3 },
{ 5400.0f, 3100.0f, 408.0f, "Keep East Gate (approx)", 3 },
{ 5400.0f, 2580.0f, 408.0f, "Keep West Gate (approx)", 3 },
// Graveyards — rez logistics only.
{ 5537.986f, 2897.493f, 517.057f, "Wintergrasp Keep GY", 0 },
{ 5032.454f, 3711.382f, 372.468f, "Horde GY", 0 },
{ 5140.790f, 2179.120f, 390.950f, "Alliance GY", 0 },
// Titan Relic — priority 4 (win condition; only Attacker
// on AllIn picks this up via endgame_target redirect).
{ RELIC_X, RELIC_Y, RELIC_Z, "Titan Relic (approx)", 4 },
};
// WG vehicles (retail well-known entries):
// 27881 Catapult — seat-0 ability varies; default fallback.
// 28094 Demolisher — seat-0 Hurl Boulder (50652) confirmed.
// 28312 Siege Engine A
// 32627 Siege Engine H
// Per-entry overrides are unverified for Catapult / Siege
// Engine — left empty rather than fabricated. Bot stays
// mounted under the default fallback even if the cast no-ops.
a.vehicle_creature_entries = { 27881, 28094, 28312, 32627 };
a.vehicle_seat_spell = 50652; // Hurl Boulder (Demolisher seat-0)
a.vehicle_seat_spell_by_entry.clear();
return a;
}
};
class WintergraspBattleScript2 final : public BattlegroundScript
{
public:
uint16_t bg_type_id() const override { return 1030; } // BATTLEGROUND_EB_BW2
char const* name() const override { return "wintergrasp_battle_v2"; }
BattlegroundAdvice get_advice(BotSnapshotView const& s) const override
{
// Variant 1030 shares the WG map + mechanics.
WintergraspScript primary;
return primary.get_advice(s);
}
};
} // anonymous
std::unique_ptr<BattlegroundScript> MakeWintergraspScript()
{
return std::make_unique<WintergraspScript>();
}
std::unique_ptr<BattlegroundScript> MakeWintergraspScript2()
{
return std::make_unique<WintergraspBattleScript2>();
}
} // namespace Playerbot
+911
View File
@@ -0,0 +1,911 @@
#include "BotAI.h"
#include "BotSnapshotView.h"
#include "BotIntentEmitter.h"
#include "BotActivityTier.h"
#include "Bot/HealThresholds.h"
#include "Bot/ClassTables.h"
#include "Group/GroupSnapshot.h"
#include "States/StateBase.h"
#include "fmt/format.h"
#include "Log.h" // TC_LOG_DEBUG for verbose-logging set_last_rule_fired
#include <cstring> // std::strcmp for cross-TU-safe watchdog exempt match
#include <cmath> // std::sqrt for death-blackspot deflection geometry
namespace Playerbot {
BotAI::BotAI(BotId id, BotPersonality personality, BotRng rng)
: bot_id_(id),
personality_(std::move(personality)),
rng_(rng)
{}
void BotAI::set_last_rule_fired(char const* name)
{
last_rule_fired_ = name;
if (!name) return;
// Watchdog accounting. Counts consecutive same-rule fires within a
// 30 s window; if >= 60, suppress the rule for 60 s. Captures the
// "rule fires forever because intent silently fails" wedge pattern
// generically — see kRuleWatchdogFireCount comment in BotAI.h.
// Excludes idle:wander (legitimate fast-firing fallback rule).
{
const uint32 now_ms = GameTime::GetGameTimeMS();
constexpr char const* kExempt[] = {
"idle:wander", // ambient fallback, fires every tick by design
"idle:ambient_emote", // throttled internally
"combat:opener", // pulses fast at engage
// Intentional every-tick HOLD states (R6, 2026-06-03): the bot is
// deliberately standing by — the core flies it (taxi) or carries it
// (ship/zeppelin/elevator). These re-fire every tick BY DESIGN and
// are not stuck loops; suppressing them is meaningless and made
// idle:taxi_flight the single loudest line in Watchdog.log (568).
"idle:taxi_flight", // riding a taxi flight (DispatchIdle stand-down)
"idle:on_transport_wait", // riding a ship/zeppelin/elevator
// A12 (2026-06-07): two more deliberate every-tick HOLD states the R6
// pass missed — the bot is standing AT a dock/FM node waiting for the
// ship to dock / the flight master to enter scan range. Suppressing a
// hold state for 60s is the wrong tool (it makes the bot abandon the
// wait); the genuinely-unreachable-FM case is now handled by the A3
// goal blacklist, not the watchdog.
"idle:wait_for_transport",
"idle:wait_for_flightmaster",
};
bool exempt = false;
for (char const* e : kExempt)
if (std::strcmp(e, name) == 0) { exempt = true; break; } // content, not pointer (cross-TU literals don't share an address without /OPT:ICF)
// Combat APL rule fires use spell names directly (e.g. "Frostbolt",
// "Slam", "Tiger Palm") — those legitimately re-fire many times
// per second during combat and shouldn't be watchdog-suppressed.
// We can't easily enumerate every spell name, but they all share
// the property of NOT starting with a known prefix. Watchdog only
// applies to engineered idle:/dead:/group: rule names.
if (!exempt)
{
const bool watchable =
(name[0] == 'i' && name[1] == 'd' && name[2] == 'l' && name[3] == 'e' && name[4] == ':')
|| (name[0] == 'd' && name[1] == 'e' && name[2] == 'a' && name[3] == 'd' && name[4] == ':')
|| (name[0] == 'g' && name[1] == 'r' && name[2] == 'o' && name[3] == 'u' && name[4] == 'p' && name[5] == ':');
if (!watchable) exempt = true;
}
if (!exempt)
{
if (rule_watchdog_name_ == name &&
now_ms - rule_watchdog_window_start_ms_ < kRuleWatchdogWindowMs)
{
if (++rule_watchdog_count_ == kRuleWatchdogFireCount)
{
rule_watchdog_suppress_until_[name] = now_ms + kRuleWatchdogSuppressMs;
// Dedicated 'playerbot.v2.watchdog' logger — operators
// route this to its own log file via a worldserver.conf
// Appender entry, so wedge alerts don't get buried in
// the main Playerbot.log noise (which can be 100+ MB).
// Conf snippet (add to worldserver.conf):
// Logger.playerbot.v2.watchdog=4,Console Watchdog
// Appender.Watchdog=2,4,1,Watchdog.log,w
TC_LOG_WARN("playerbot.v2.watchdog",
"bot={} rule={} fires>={} window_ms={} suppress_ms={}",
uint32(bot_id_), name, kRuleWatchdogFireCount,
kRuleWatchdogWindowMs, kRuleWatchdogSuppressMs);
}
}
else
{
rule_watchdog_name_ = name;
rule_watchdog_count_ = 1;
rule_watchdog_window_start_ms_ = now_ms;
}
}
}
// Ring-buffer the last ~16 rules for /history. Skip duplicates of the
// most recent entry (don't spam with the same rule firing every tick).
const size_t prev = (rule_history_head_ + kRuleHistoryCap - 1) % kRuleHistoryCap;
if (rule_history_[prev] == name) return;
rule_history_[rule_history_head_] = name;
rule_history_head_ = (rule_history_head_ + 1) % kRuleHistoryCap;
if (rule_history_size_ < kRuleHistoryCap) ++rule_history_size_;
if (verbose_logging_)
TC_LOG_DEBUG("playerbot.v2", "bot {} rule: {}", bot_id_, name);
}
bool BotAI::whisper_rate_check(std::string const& target,
std::string const& text,
uint32 now_ms)
{
if (text.empty()) return true;
// 64-bit FNV-1a — fast, sufficient for de-dup. We don't need
// collision-resistance against an adversary, only that two
// identical replies hash the same.
constexpr uint64_t FNV_PRIME = 1099511628211ull;
uint64_t h = 14695981039346656037ull;
for (char c : text) { h ^= static_cast<uint8_t>(c); h *= FNV_PRIME; }
constexpr uint32 kDedupWindowMs = 500;
if (last_whisper_target_ == target &&
last_whisper_hash_ == h &&
(now_ms - last_whisper_ms_) < kDedupWindowMs)
return false;
last_whisper_target_ = target;
last_whisper_hash_ = h;
last_whisper_ms_ = now_ms;
return true;
}
void BotAI::transition_to(BotState s)
{
if (s == state_) return;
prev_state_ = state_;
state_ = s;
state_entered_ = Ms{0}; // Set externally by tick when it has 'now'
}
void BotAI::tick(BotSnapshotView snapshot,
GroupSnapshotView group,
BotIntentEmitter& emit)
{
++tick_count_;
// --- Watchdog movement-progress credit ---
// The per-rule watchdog (note_rule_fired) counts same-rule re-fires and
// suppresses a rule after kRuleWatchdogFireCount, but has no position
// signal — so a bot legitimately walking a LONG chunked route (e.g.
// idle:pursue_quest_goal every tick across a 600y+ trek out of a starter
// zone) trips it after ~15s and gets yanked off-course by the high-priority
// idle:watchdog_escape. Credit real movement: if the bot has moved
// meaningfully (net) since the anchor, it's PROGRESSING, not wedged — clear
// the watchdog and re-anchor here, BEFORE the rules dispatch (so this tick's
// WatchdogGate sees the cleared suppress). Oscillation-in-place (no net
// movement from the anchor) never reaches this, so a genuinely stuck bot
// still trips the watchdog and gets its escape. 15y > snapshot position
// jitter, and a walking bot (~7y/s) re-anchors every ~2s — far inside the
// 60-fires/15s suppress threshold, so pursue keeps owning the trek.
{
float wx, wy, wz; snapshot.position(wx, wy, wz);
const uint32 wnow = snapshot.published_at_ms();
const uint32 pbc = snapshot.path_blocked_count();
// (a) NET POSITION progress: bot moved >= 15y from the anchor.
constexpr float kWatchdogProgressY = 15.0f;
const float adx = wx - watchdog_anchor_x_;
const float ady = wy - watchdog_anchor_y_;
const bool moved = !watchdog_anchor_set_ ||
(adx * adx + ady * ady) >= kWatchdogProgressY * kWatchdogProgressY;
if (moved)
{
watchdog_anchor_x_ = wx;
watchdog_anchor_y_ = wy;
watchdog_anchor_set_ = true;
}
// (b) MOVE-SUCCESS: the bot's pathing isn't failing. The escape (a 50y
// random teleport) is only appropriate for a genuinely NoPath-trapped
// bot; for a bot whose moves SUCCEED but re-fires the same rule a lot
// (long chunked trek, or two rules spline-thrashing every tick so the
// MotionMaster spline is reset before the bot traverses it — net
// displacement pinned near zero), the escape just disrupts it. Credit
// move-success: only consider the bot path-wedged when the global
// path_blocked_count grew by >= 3 within a ~10s window (the same mark
// pursue/walk rules use via check_anchor_wedge). Re-baseline the window.
if (wnow - watchdog_blocks_window_ms_ > 10000u)
{
watchdog_blocks_baseline_ = pbc;
watchdog_blocks_window_ms_ = wnow;
}
const uint32 blocks_grew =
(pbc > watchdog_blocks_baseline_) ? (pbc - watchdog_blocks_baseline_) : 0u;
const bool pathing_failing = blocks_grew >= 3u;
if (moved || !pathing_failing)
clear_rule_watchdog();
}
// Transport boarding-Z latch. The elevator exit rule needs to
// know what Z the bot was at when it boarded so the "did the
// platform carry me to a different floor" decision works. Latch
// on false→true transition; clear on true→false. Ships pass
// through this latch too — harmless since the exit rule gates
// on metadata kind, not raw Z.
// Normalise newly-summoned pets to defensive. Aggressive mode (the
// server default for hunter pets) makes the pet pull every mob in
// its 25y leash radius — breaking CC, dragging adds onto the group,
// chasing respawns out of LoS — all things that actively hinder
// bot progress. Detect any pet-guid transition (Empty→guid or
// guid→different-guid) and fire one PetSetReactStateIntent{1}.
// The intent is GCD-free so it doesn't compete with combat casts.
{
const ObjectGuid cur_pet = snapshot.pet_guid();
if (!cur_pet.IsEmpty() && cur_pet != last_known_pet_guid_)
{
emit.pet_set_react_state(1); // REACT_DEFENSIVE
}
last_known_pet_guid_ = cur_pet;
}
if (snapshot.on_transport() && !was_on_transport_)
{
float bx, by, bz; snapshot.position(bx, by, bz);
elevator_boarding_z_ = bz;
transport_prev_z_ = bz;
transport_z_stable_ms_ = 0;
was_on_transport_ = true;
}
else if (!snapshot.on_transport() && was_on_transport_)
{
elevator_boarding_z_ = 0.f;
transport_prev_z_ = 0.f;
transport_z_stable_ms_ = 0;
was_on_transport_ = false;
}
else if (snapshot.on_transport())
{
// Track Z stability while attached. The snapshot's
// transport_stopped is always-true for type-11 elevators
// (snapshot builder dynamic_casts to type-15 Transport class
// and falls back to true when that fails), so we proxy
// "platform at rest" with "world-Z hasn't moved this tick".
// Reset stable_ms on any Z delta ≥ 0.3y; otherwise accumulate
// the snapshot's tick interval. The elevator step_off rule
// requires ≥500ms stable before firing, so it cannot step off
// mid-ascent at the moment |bz - boarding_z| first crosses
// 15y — it waits until the platform has settled at the next
// stop frame.
float bx, by, bz; snapshot.position(bx, by, bz);
const float dz = std::fabs(bz - transport_prev_z_);
if (dz < 0.3f)
{
// Snapshot publishes at ~5 Hz under load; clamp the
// per-tick increment to 1000ms so a hitched snapshot
// doesn't fake long stability.
const uint32 inc = 200;
transport_z_stable_ms_ =
std::min<uint32>(transport_z_stable_ms_ + inc, 60000u);
}
else
{
transport_z_stable_ms_ = 0;
}
transport_prev_z_ = bz;
}
// Per-zone activity accumulator. Used by idle:rebind_hearth_activity
// to detect zones the bot has "settled into" (cumulative >30min) and
// rebind hearth there. Skip while in BG/dungeon (those are transient
// and shouldn't accumulate hearth-relevant time). Delta is bounded
// by the AI tick interval (typically 200-1000ms).
if (snapshot.is_alive() && !snapshot.in_battleground() && !snapshot.is_in_dungeon())
{
const uint32 now_ms = snapshot.published_at_ms();
if (last_activity_tick_ms_ != 0)
{
uint32 dt = now_ms - last_activity_tick_ms_;
// Cap dt at 60s to absorb logout/login gaps that would
// otherwise dump an hour of "activity" into the current
// zone on the post-login first tick.
if (dt > 60u * 1000u) dt = 60u * 1000u;
note_zone_activity(snapshot.zone_id(), dt);
}
last_activity_tick_ms_ = now_ms;
}
else
{
last_activity_tick_ms_ = 0; // pause accumulator
}
// Death-spiral memory clears once the gear is healthy again — a successful
// repair (or fresh upgrades) is the natural end of a broken-gear spiral.
// Cheap snapshot read; only matters while a spiral is actually armed.
if (consecutive_same_spot_deaths() != 0 && snapshot.is_alive() &&
snapshot.lowest_equipped_durability_pct() >= 90)
reset_death_spiral();
// 1. Reactive transitions driven by snapshot (cheap, no events).
// Routed through transition_to() so previous_state() tracks correctly.
const bool prev_alive = (state_ != BotState::Dead && state_ != BotState::Resurrecting);
if (!snapshot.is_alive() && state_ != BotState::Dead && state_ != BotState::Resurrecting)
{
// Death transition. When dying inside an instance, count
// toward this run's per-bot deaths tally for /diag visibility.
if (snapshot.is_in_instance())
note_dungeon_death();
// Open-world death-spiral memory: ONLY out in the world (not in a
// dungeon/raid instance, not in a BG). A normal dungeon/raid wipe is a
// hard fight, not a broken-gear spiral, so it must never arm the
// counter. Records the death position so repeated deaths in the same
// spot escalate the State_Dead / State_InCombat escape.
if (!snapshot.is_in_instance() && !snapshot.in_battleground())
{
note_open_world_death(snapshot.raw().position.x,
snapshot.raw().position.y,
snapshot.raw().position.z,
snapshot.published_at_ms());
// Owner idea (2026-06-22): after repeated deaths in the same spot, mark
// it a travel blackspot so movement routes AROUND it next time instead
// of walking back into the killing field. Quest stays valid.
if (consecutive_same_spot_deaths() >= kBlackspotDeathThreshold)
arm_death_blackspot(snapshot.raw().position.x,
snapshot.raw().position.y,
snapshot.published_at_ms());
}
// Fresh-death entry: clear the per-death recovery latches. These are
// otherwise reset ONLY in DispatchDead's revival block (State_Dead.cpp),
// which is gated on the snapshot observing is_alive==true. A bot that
// reclaims its corpse / spirit-resurrects revives at 50% HP inside the
// camp that killed it and frequently re-dies before the next snapshot —
// and dead bots tick at Hibernate cadence (~2s), so the brief alive
// frame is never observed. The latch corpse_recovery_emitted_ then stays
// true across the undetected revive->re-death, DispatchDead skips its
// release branch (guarded by !corpse_recovery_emitted()), is_ghost()
// never flips, and the bot wedges in dead:waiting_release forever
// (observed: Bramwell L4 Elwynn). The only reliable signal of a NEW
// death is this Idle/Combat->Dead transition edge, so re-baseline the
// recovery FSM here. (The revival-block reset stays as belt-and-braces.)
set_corpse_recovery_emitted(false);
set_release_pending_at_ms(0);
set_ghost_since_ms(0);
set_rez_acked(false);
set_reincarnation_attempted(false);
set_reincarnation_attempt_ms(0);
set_corpse_run_last_dist(-1.0f);
set_dead_watchdog_ms(0); // fresh bounded-recovery window
transition_to(BotState::Dead);
}
else if (snapshot.is_alive() && state_ == BotState::Dead)
transition_to(BotState::Idle); // post-revive demotion
// LoggingIn is the bootstrap state. Once we have a live, alive snapshot
// the bot is in-world and ready — promote to Idle so the regular
// dispatch chain runs. Without this, bots that login outside combat
// stay in LoggingIn (a stub dispatcher) forever.
else if (snapshot.is_alive() && state_ == BotState::LoggingIn)
transition_to(BotState::Idle);
// Treat any active attacker as "in combat" — TC's combat-flag
// propagation can lag by a tick when a mob aggro-transfers (e.g.
// tank dies, mob picks a new threat target). Without this, the
// bot's state stays Idle for that tick, the follow rule fires,
// and the bot walks away from a mob that is actively hitting it.
// Observed 2026-05-15: "tank dies, group walks away, mobs follow
// and kill them".
else if ((snapshot.in_combat() || !snapshot.raw().combat.attackers.empty()) &&
state_ != BotState::InCombat &&
state_ != BotState::Dead)
{
set_combat_entered_ms(snapshot.published_at_ms());
transition_to(BotState::InCombat);
}
else if (state_ == BotState::InCombat &&
!snapshot.in_combat() &&
snapshot.raw().combat.attackers.empty())
{
// Combat just ended without dying — record a "kill" for the
// tank-pull-pacing cooldown and increment the per-run dungeon
// contribution counter when in an instance. Heuristic: surviving
// a fight = group landed a kill. ONLY for fights that lasted
// ≥1.5s: TC's combat flag flaps (aggro transfers, brief proximity
// flags) produce InCombat→Idle transitions every few seconds, and
// each one reset the post-kill pacing timer — a tank standing in
// a flap-prone spot never saw the timer expire and tank_advance
// starved (2026-06-11 Stockades stall).
const uint32 fight_ms =
snapshot.published_at_ms() - combat_entered_ms();
if (combat_entered_ms() != 0 && fight_ms >= 1500)
{
note_kill(snapshot.published_at_ms());
if (prev_alive && snapshot.is_in_instance())
note_dungeon_kill();
}
transition_to(BotState::Idle);
}
// Map-change reset for per-run contribution counters. When the bot
// crosses a map boundary (entering or leaving an instance), the
// previous run's kill/death tally should reset so /diag reflects
// the CURRENT run rather than lifetime totals. Last-seen map_id
// primes on first observation so a fresh login doesn't reset
// before any kills accrue.
const uint32 cur_map = snapshot.map_id();
if (last_seen_map_id_ != cur_map)
{
if (last_seen_map_id_ != 0)
{
reset_dungeon_contribution();
// Waypoint index is per-run; reset on map change so the next
// dungeon entry starts at progression_waypoints[0] rather
// than continuing from wherever the previous run left off.
set_dungeon_waypoint_index(0);
// A map change means the bot left the spiralling area (or was
// graveyard-ported away) — clear the open-world death-spiral memory
// so deaths on the new map start a fresh count.
reset_death_spiral();
}
// Drop stale movement on ANY real map change — including 0 -> N, since
// map 0 is a valid map (the dungeontest staging area lives there), so
// this clear is gated by its own primed flag rather than the != 0
// contribution guard above. A cross-map teleport (LFG dungeon port, BG,
// hearth, areatrigger) does NOT clear the server-side MotionMaster, so a
// POINT/CHASE/FOLLOW generator computed on the OLD map survives and
// re-splines toward its now-meaningless target, walking the bot
// dead-straight into the void (observed: Dunghealer crawling toward the
// map-0 staging XY (-8985,511) on map 36 for 13+ min). clear_generators
// =true because StopMoving() alone leaves the generator to re-issue;
// also drop the regroup convergence sample so a pre-port baseline can't
// false-trigger a divergence re-issue on the new map.
if (map_change_primed_)
{
reset_regroup_tracking();
clear_dungeon_cross(); // a cross-target on the old map must not survive
emit.stop_movement(/*clear_generators*/ true);
}
map_change_primed_ = true;
last_seen_map_id_ = cur_map;
}
// 2. Primary state dispatch.
// Altbot / owned companion: no fleet idle quest/travel brain.
if (is_owned_ || bot_role_ == BotRole::Altbot)
{
TickAltbot(snapshot, group, emit);
return;
}
dispatch_primary(snapshot, group, emit);
// 3. Cross-cutting layers.
dispatch_layers(snapshot, group, emit);
}
void BotAI::dispatch_primary(BotSnapshotView s, GroupSnapshotView g,
BotIntentEmitter& em)
{
// Altbot: skip autonomous world AI entirely.
if (bot_role_ == BotRole::Altbot)
{
TickAltbot(s, g, em);
return;
}
// Owned companion bots: allow essential states and death recovery.
// Block Travelling and Questing (wandering off independently).
if (is_owned_)
{
if (state_ == BotState::Travelling || state_ == BotState::Questing)
return;
}
// Healers: also run combat rotation outside combat so healing/rez rules
// fire without enemy presence. BUT allow other state processing (group
// invite accept, travel, questing) to proceed as well — the heal dispatch
// is an ADDITIONAL call, not a replacement.
// Gated on is_owned_: pool healers with no owner have no one to heal/rez.
if (s.my_role() == Role::Healer && state_ != BotState::InCombat && is_owned_)
States::DispatchInCombat(*this, s, g, em);
// Fall through to normal state dispatch (no return)
switch (state_)
{
case BotState::LoggingIn: States::DispatchLoggingIn(*this, s, g, em); break;
case BotState::LoggingOut: States::DispatchLoggingOut(*this, s, g, em); break;
case BotState::Idle: States::DispatchIdle(*this, s, g, em); break;
case BotState::Travelling: States::DispatchTravelling(*this, s, g, em); break;
case BotState::Questing: States::DispatchQuesting(*this, s, g, em); break;
case BotState::InCombat: States::DispatchInCombat(*this, s, g, em); break;
case BotState::Looting: States::DispatchLooting(*this, s, g, em); break;
case BotState::Dead: States::DispatchDead(*this, s, g, em); break;
case BotState::Resurrecting: States::DispatchResurrecting(*this, s, g, em); break;
default:
States::DispatchIdle(*this, s, g, em);
break;
}
}
void BotAI::dispatch_layers(BotSnapshotView s, GroupSnapshotView g,
BotIntentEmitter& em)
{
// Always-on social auto-responses. Run before in-group / state dispatch
// so they fire regardless of whether the bot is grouped or what primary
// state it's in. Cheap: each is a single snapshot-flag check + at most
// one intent emit.
//
// Duel challenges: friendly initiators (group / guild / social-friend)
// get auto-accepted so owner sparring works without a manual whisper;
// strangers get auto-declined to avoid being trolled into PvP. The
// friend flag is resolved on the world thread by the snapshot builder
// (Player::GetSocial::HasFriend + group/guild membership), so the AI
// worker just inspects a single bool here.
if (s.has_duel_request())
{
// Context filter: never auto-accept duels inside an active
// BG / arena / dungeon / raid. Server-side duel handlers usually
// reject those anyway, but the bot's emit still costs a packet
// round-trip + clutters the duel-state machine while the
// instance is gating combat. Auto-decline regardless of friend
// status; the owner can manually accept once the bot is out.
const bool in_instance_or_pvp =
s.in_battleground() || s.is_in_dungeon() || s.is_in_raid();
if (in_instance_or_pvp)
em.duel_decline();
else if (s.duel_initiator_is_friend())
em.duel_accept();
else
em.duel_decline();
}
// Trades opened on the bot are likewise declined automatically. Window
// stays open server-side until either side cancels — without this the
// bot would sit there with the dialog up indefinitely.
if (s.has_trade_request())
em.trade_decline();
// Group invite auto-accept. Runs regardless of state so healers who
// skip idle state rules (due to OOC combat dispatch) can still join.
if (s.has_group_invite())
{
if (!invite_acked())
{
uint32 const now_ms = s.published_at_ms();
uint32 accept_at = pending_group_invite_accept_at_ms();
if (accept_at == 0)
{
// Random jitter 500-2500ms so not all bots accept in lockstep
uint32 const jitter = 500u + (uint32(s.bot_id()) * 2654435761u) % 2000u;
set_pending_group_invite_accept_at_ms(now_ms + jitter);
}
else if (now_ms >= accept_at)
{
em.group_accept();
set_invite_acked(true);
set_invite_accept_attempt_ms(now_ms);
}
}
else
{
// Already fired accept. If the invite is still up after 5s,
// the previous accept likely failed — retry.
if (s.published_at_ms() >= invite_accept_attempt_ms() + 5000)
{
set_invite_acked(false);
set_invite_accept_attempt_ms(0);
}
}
}
else
{
// No invite — clear stale state
if (invite_acked()) set_invite_acked(false);
if (pending_group_invite_accept_at_ms() != 0)
set_pending_group_invite_accept_at_ms(0);
}
// Always stand on every AI tick. Idle:ambient_sit may have put the bot
// into SIT state; once combat or any cast starts, the bot must stand.
em.stand();
if (s.in_group())
States::DispatchInGroup(*this, s, g, em);
// Level-up announcement. Compares the snapshot's current level against
// the last value we observed; on a strict increase, drops a /p chat ping
// so the rest of the group sees the ding. Suppressed on the first tick
// (last_seen_level_ == 0) to avoid announcing on every login. Only fires
// when grouped — solo bots ding silently. Single emit per ding even if
// the bot levels multiple times in the same tick (rare): the announce
// text reports the new level only.
{
const uint8 cur = s.level();
const uint8 prev = last_seen_level_;
// Personality gate (parity with group-join greet below). Silent/
// Terse bots level up silently; Normal+ announce. Without this,
// bots configured as Silent still emit a /p Ding! on every level
// — inconsistent with how the same bot stays quiet for greets.
//
// Ding stagger 2026-05-21: don't /say immediately. Capture the
// level + an emit time 1.5–5.5s in the future, jittered by
// bot_id, so a raid that levels on the same XP-grant doesn't
// stack 25 identical /p Ding! lines inside one server tick.
if (cur > 0 && prev > 0 && cur > prev && s.in_group()
&& personality_.verbosity >= Verbosity::Normal
&& pending_ding_level_ == 0)
{
const uint32 now_ms = s.published_at_ms();
const uint32 jitter = 1500u + (uint32(s.bot_id()) * 2654435761u) % 4000u;
pending_ding_level_ = cur;
pending_ding_say_at_ms_ = now_ms + jitter;
}
if (cur != prev)
last_seen_level_ = cur;
// Fire the pending ding when its scheduled time arrives. Reset the
// slot regardless of whether the announce went out (e.g. lost-group
// edge case) so a future level still queues cleanly.
if (pending_ding_level_ > 0)
{
const uint32 now_ms = s.published_at_ms();
if (now_ms >= pending_ding_say_at_ms_)
{
if (s.in_group() && personality_.verbosity >= Verbosity::Normal)
{
char const* prefix =
personality_.verbosity == Verbosity::Roleplay ? "Hark! Level " :
personality_.verbosity == Verbosity::Chatty ? "Ding! " :
"Ding ";
em.say(fmt::format("{}{}", prefix, pending_ding_level_));
}
pending_ding_level_ = 0;
pending_ding_say_at_ms_ = 0;
}
}
}
// Group-join greet. When the bot transitions Empty → non-Empty group
// (joined a new party/raid), drop a single /p chat to signal presence.
// The first observation only primes the field; without that priming step
// every bot would say "Hi!" once per server boot the moment the snapshot
// first sees its existing group. Re-greets on subsequent group changes
// so a bot that switches party-to-raid or rejoins a different group
// greets each fresh group once. Solo bots (group_guid == Empty) reset
// the greet priming so a future re-join greets cleanly.
//
// Personality-aware: Silent / Terse bots stay quiet. Roleplay bots get a
// flavour-text variant ("Hail and well met!"). The intent is to keep the
// social layer feeling individuated rather than uniform "Hi!" spam from
// every bot in a fresh raid.
{
const ObjectGuid cur_grp = s.group_guid();
if (!group_greet_primed_)
{
// First ever observation — capture and skip the greet.
last_seen_group_ = cur_grp;
group_greet_primed_ = true;
}
else if (cur_grp != last_seen_group_)
{
if (!cur_grp.IsEmpty() && personality_.verbosity >= Verbosity::Normal)
{
char const* line = "Hi!";
switch (personality_.verbosity)
{
case Verbosity::Roleplay: line = "Hail and well met!"; break;
case Verbosity::Chatty: line = "Hey everyone!"; break;
case Verbosity::Normal: line = "Hi!"; break;
default: line = nullptr; break; // Silent/Terse
}
if (line) em.say(line);
}
last_seen_group_ = cur_grp;
}
}
// Future: detect AtVendor / AtMailbox / AtAh / InInstance / Decorating
// by inspecting position + nearby NPCs / objects + active interaction.
}
void BotAI::TickAltbot(BotSnapshotView s, GroupSnapshotView g,
BotIntentEmitter& em)
{
if (!s.is_alive() && state_ != BotState::Dead && state_ != BotState::Resurrecting)
transition_to(BotState::Dead);
else if (s.is_alive() && state_ == BotState::Dead)
transition_to(BotState::Idle);
else if (s.is_alive() && state_ == BotState::LoggingIn)
transition_to(BotState::Idle);
else if ((s.in_combat()
|| !s.raw().combat.attackers.empty()
|| !s.victim().IsEmpty())
&& state_ != BotState::InCombat
&& state_ != BotState::Dead)
{
set_combat_entered_ms(s.published_at_ms());
transition_to(BotState::InCombat);
}
else if (state_ == BotState::InCombat
&& !s.in_combat()
&& s.raw().combat.attackers.empty()
&& s.victim().IsEmpty())
{
transition_to(BotState::Idle);
}
bool const isHealer =
(effective_role(s) == Role::Healer) ||
IsHealerSpec(s.cls(), static_cast<uint16>(s.spec()));
if (isHealer)
role_override_ = Role::Healer;
switch (state_)
{
case BotState::Dead:
States::DispatchDead(*this, s, g, em);
break;
case BotState::Resurrecting:
States::DispatchResurrecting(*this, s, g, em);
break;
case BotState::InCombat:
{
if (isHealer)
{
using namespace HealThresh;
Context ctx = BuildContext(s, g);
Bands const bands = Compute(ctx);
bool const allow_topup =
ctx.in_combat && ctx.healer_mana_pct > 50;
bool partyNeedsHeal = false;
if (GroupMemberSummary const* low = g.exists()
? g.lowest_hp_on_map(s.map_id())
: nullptr)
{
int32 const pct = HpPct(*low);
if (pct < bands.light || pct <= bands.emergency)
partyNeedsHeal = true;
}
// Optional: direct triage heal before full APL
if (GroupMemberSummary const* tgt =
PickHealTarget(s, g, bands, allow_topup))
{
uint32 const hspell = ClassOocHeal(s.cls(), s.spec());
if (hspell && s.knows_spell(hspell) && s.is_ready(hspell)
&& !s.is_casting())
{
em.cast(hspell, tgt->guid);
}
}
// DPS only if party is above dynamic light threshold
if (!partyNeedsHeal && !s.raw().combat.attackers.empty())
{
if (NearbyUnit const* a = s.highest_threat_attacker())
em.start_attack(a->guid);
}
// else: no start_attack — heal owns GCD
}
else
{
// DPS / tank: follow leader victim when visible
ObjectGuid prefer = s.victim();
if (g.exists())
{
ObjectGuid const lead = g.leader();
if (!lead.IsEmpty())
{
if (std::vector<GroupMemberSummary> const* mems = g.members())
{
for (GroupMemberSummary const& m : *mems)
{
if (m.guid != lead)
continue;
if (!m.victim.IsEmpty())
prefer = m.victim;
break;
}
}
}
}
if (!prefer.IsEmpty() && prefer != s.victim())
{
bool visible = false;
for (auto const& u : s.raw().combat.nearby_enemies)
if (u.guid == prefer && u.hp > 0 && !u.untargetable)
{ visible = true; break; }
if (!visible)
for (auto const& u : s.raw().combat.attackers)
if (u.guid == prefer && u.hp > 0)
{ visible = true; break; }
if (visible)
em.start_attack(prefer);
}
}
States::DispatchInCombat(*this, s, g, em);
break;
}
default:
// Idle healers: OOC party heals / rez path
if (isHealer)
{
using namespace HealThresh;
Context ctx = BuildContext(s, g);
Bands const bands = Compute(ctx);
bool const allow_topup = false; // no top-ups OOC by default
if (GroupMemberSummary const* tgt =
PickHealTarget(s, g, bands, allow_topup))
{
uint32 const hspell = ClassOocHeal(s.cls(), s.spec());
if (hspell && s.knows_spell(hspell) && s.is_ready(hspell)
&& !s.is_casting())
{
em.cast(hspell, tgt->guid);
}
}
States::DispatchInCombat(*this, s, g, em);
}
break;
}
dispatch_layers(s, g, em);
}
Role BotAI::effective_role(BotSnapshotView const& s) const
{
return role_override_ != Role::Unknown ? role_override_ : s.my_role();
}
// --- Death blackspots (owner idea, 2026-06-22) ---------------------------------
void BotAI::arm_death_blackspot(float x, float y, uint32 now_ms)
{
// Refresh an overlapping active spot; else reuse the oldest (most-expired) slot.
int oldest = 0;
uint32 oldest_exp = 0xFFFFFFFFu;
for (int i = 0; i < 4; ++i)
{
DeathBlackspot& b = death_blackspots_[i];
const float dx = x - b.x, dy = y - b.y;
if (b.expiry_ms > now_ms && (dx * dx + dy * dy) <= b.r * b.r)
{
b.expiry_ms = now_ms + kBlackspotTtlMs; // still dying here — extend
return;
}
if (b.expiry_ms < oldest_exp) { oldest_exp = b.expiry_ms; oldest = i; }
}
death_blackspots_[oldest] = DeathBlackspot{ x, y, kBlackspotRadius, now_ms + kBlackspotTtlMs };
}
bool BotAI::in_death_blackspot(float x, float y, uint32 now_ms) const
{
for (int i = 0; i < 4; ++i)
{
DeathBlackspot const& b = death_blackspots_[i];
if (b.expiry_ms <= now_ms || b.r <= 0.f) continue;
const float dx = x - b.x, dy = y - b.y;
if (dx * dx + dy * dy <= b.r * b.r) return true;
}
return false;
}
bool BotAI::deflect_for_blackspot(float fx, float fy, float tx, float ty,
uint32 now_ms, float& ox, float& oy) const
{
for (int i = 0; i < 4; ++i)
{
DeathBlackspot const& b = death_blackspots_[i];
if (b.expiry_ms <= now_ms || b.r <= 0.f) continue;
const float dgx = tx - fx, dgy = ty - fy;
const float glen = std::sqrt(dgx * dgx + dgy * dgy);
if (glen < 1.0f) continue;
const float ux = dgx / glen, uy = dgy / glen;
const float vcx = b.x - fx, vcy = b.y - fy;
const float dC = std::sqrt(vcx * vcx + vcy * vcy);
// Standing INSIDE the blackspot: head straight out, away from its center.
if (dC <= b.r)
{
const float awx = (dC > 0.01f) ? -vcx / dC : -ux;
const float awy = (dC > 0.01f) ? -vcy / dC : -uy;
ox = fx + awx * (b.r + 15.0f);
oy = fy + awy * (b.r + 15.0f);
return true;
}
const float proj = vcx * ux + vcy * uy; // distance along the path to C
if (proj <= 0.0f) continue; // blackspot is behind us
const float perpx = vcx - proj * ux, perpy = vcy - proj * uy;
const float perp = std::sqrt(perpx * perpx + perpy * perpy);
const float clear = b.r + 12.0f;
if (perp >= clear) continue; // straight path already clears it
// Deflect: offset the projected point perpendicular, AWAY from the center,
// so the bot skirts the blackspot's edge while still advancing to the goal.
float awx, awy;
if (perp > 0.01f) { awx = -perpx / perp; awy = -perpy / perp; }
else { awx = -uy; awy = ux; } // center on the line: pick a side
ox = fx + ux * proj + awx * clear;
oy = fy + uy * proj + awy * clear;
return true;
}
return false;
}
} // namespace Playerbot
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
#include "BotActivityTier.h"
#include "BotSnapshot.h"
namespace Playerbot {
ActivityTier ClassifyTier(BotSnapshot const& s)
{
// Dead bots are low-frequency: a corpse run / spirit-healer wait does
// not need 5–10 Hz re-evaluation. (Matches the world-thread classifier
// in PlayerbotV2.cpp.)
if (!s.vitals.is_alive)
return ActivityTier::Idle;
if (s.vitals.in_combat)
return ActivityTier::Combat;
// Anything that demands prompt re-evaluation keeps the fast cadence:
// active motion (walking a route / fleeing), or being in a group (a
// grouped bot follows / assists and must stay responsive to the
// leader). Owner-control and real-player proximity are decided by the
// world-thread classifier (which owns the per-tick real-player cell
// set); this snapshot-only function intentionally errs toward Active
// so the staleness guard never under-estimates a bot's freshness need.
if (s.movement.is_moving || s.movement.is_swimming || !s.group.group_guid.IsEmpty())
return ActivityTier::Active;
// Alive, out of combat, stationary, solo. Idle in a city / inn / safe
// area is the typical case.
return ActivityTier::Idle;
// NOTE: this function never returns Combat-faster-than-warranted and
// never returns the parked tier (ActivityTier::Hibernate). The parked
// tier is reached only via the scheduler after N consecutive Idle
// classifications (see PlayerbotV2.cpp + TickScheduler). It is used by
// the AiWorkerPool staleness guard, which caps max-age at 1 s anyway,
// so a parked bot is treated as Idle (500 ms × 3, capped 1 s) there.
}
} // namespace Playerbot
@@ -0,0 +1,63 @@
// BotActivityTier - Snapshot-driven tier transition logic per ARCHITECTURE.md §1.4.
#pragma once
#include "BotTypes.h"
namespace Playerbot {
struct BotSnapshot; // forward — full type in BotSnapshot.h
// Tick frequency table — lower = more reactive, more CPU. On a 5950X
// with 33% baseline CPU and ~1100 of 1237 bots sitting in Idle tier,
// halving Idle roughly doubles aggregate AI work and lifts quality
// across the fleet (faster BG pivots, smoother movement, snappier
// social timing). Active also bumped a notch since travelling/questing
// bots want quick re-evaluation. Combat unchanged — already at 10 Hz
// (network update cadence).
//
// `Cruise` (300 ms) is the middle band for the dominant long-haul
// population: a solo, open-world bot that is merely travelling or
// questing (moving OR has an objective) but is NOT human-facing
// (unowned, ungrouped-with-real, no real player nearby), NOT path-blocked,
// and NOT casting. Such a bot's movement spline is server-side; the AI
// only needs to issue the next waypoint a touch less often, so it
// tolerates 300 ms. This roughly halves snapshot-build cost for the
// questing fleet that previously pinned every moving/objective bot to
// Active (150 ms). Cruise NEVER ramps to Parked — only Idle does — so a
// travelling bot is never frozen; the moment it blocks / enters combat /
// has a player walk up, the classifier promotes it and set_tier resets
// next_snapshot=now for next-frame reactivity.
//
// `Hibernate` is the "parked" tier: a fully-AFK bot (alive, out of
// combat, stationary, unowned, not grouped-with / near any real player)
// that has classified Idle for N consecutive scheduler passes is ramped
// here so it rebuilds its snapshot at 0.5 Hz instead of 2 Hz. This is
// the single biggest snapshot-build saving at fleet scale (the long
// tail of idle city/inn bots). 2000 ms is deliberately capped at the
// AiWorkerPool staleness guard's 1000 ms ceiling × 2 — a parked bot's
// snapshot can be up to one Parked period old, which only ever causes
// the worker to *skip* that AI tick (stale_skip), never to act on bad
// data or repath; the next scheduler pass re-builds fresh. See
// AiWorkerPool.cpp staleness guard. Any wake event (combat / owner
// control / movement / a real player arriving) demotes the bot back to
// a fast tier via set_tier(), which resets next_snapshot=now so the
// rebuild happens on the very next frame (no reactivity regression).
constexpr Ms TickPeriodFor(ActivityTier t)
{
switch (t)
{
case ActivityTier::Combat: return Ms{100};
case ActivityTier::Active: return Ms{150}; // was 200
case ActivityTier::Cruise: return Ms{300}; // solo open-world traveller
case ActivityTier::Idle: return Ms{500}; // was 1000
case ActivityTier::Hibernate: return Ms{2000}; // "Parked" — long-idle AFK
}
return Ms{500};
}
// Pure function: given snapshot, return the tier the bot SHOULD be in.
// Caller decides if a transition occurs (with hysteresis to avoid thrash).
ActivityTier ClassifyTier(BotSnapshot const& s);
} // namespace Playerbot
@@ -0,0 +1,449 @@
#include "BotAddressResolver.h"
#include "BotRegistry.h"
#include "BotAI.h"
#include "BotSnapshot.h"
#include "../Services.h"
#include "../Fleet/OwnerRegistry.h"
#include "../Threading/SnapshotPublisher.h"
#include "Player.h"
#include "ObjectAccessor.h"
#include "WorldSession.h"
#include "SharedDefines.h"
#include "Group.h"
#include <algorithm>
#include <cctype>
#include <unordered_map>
namespace Playerbot {
namespace {
// Lower-case copy. The address parser is fully case-insensitive — owners
// shouldn't have to know whether `mage:` or `Mage:` works.
std::string ToLower(std::string_view s)
{
std::string out;
out.reserve(s.size());
for (char c : s) out.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
return out;
}
// Trim leading/trailing whitespace.
std::string_view Trim(std::string_view s)
{
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) s.remove_prefix(1);
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.back()))) s.remove_suffix(1);
return s;
}
// Class names ↔ Class.dbc id mapping. Lower-case keys; supports both
// canonical names and a few common aliases (DK / DH).
ResolvedAddress::Kind ClassNameToKind(std::string const& token, uint8& out_cls)
{
// Map of canonical lower-case → CLASS_*
static std::unordered_map<std::string, uint8> const kMap = {
{"warrior", CLASS_WARRIOR},
{"paladin", CLASS_PALADIN},
{"hunter", CLASS_HUNTER},
{"rogue", CLASS_ROGUE},
{"priest", CLASS_PRIEST},
{"deathknight", CLASS_DEATH_KNIGHT},
{"dk", CLASS_DEATH_KNIGHT},
{"shaman", CLASS_SHAMAN},
{"mage", CLASS_MAGE},
{"warlock", CLASS_WARLOCK},
{"monk", CLASS_MONK},
{"druid", CLASS_DRUID},
{"demonhunter", CLASS_DEMON_HUNTER},
{"dh", CLASS_DEMON_HUNTER},
{"evoker", CLASS_EVOKER},
};
auto it = kMap.find(token);
if (it == kMap.end()) return ResolvedAddress::Kind::Single;
out_cls = it->second;
return ResolvedAddress::Kind::Class;
}
// Spec name → ChrSpecialization.dbc id mapping. Supports the common short
// names players actually use ("frost" → Frost Mage AND Frost DK; the
// resolver returns ALL matching specs across classes, so commands like
// `frost: nova` apply to every frost-spec bot the owner has).
//
// Spec ids cribbed from infer_role in BotSnapshotBuilder.cpp +
// well-known retail spec ids. When a token matches multiple specs we
// store all of them (the resolver filters by membership).
struct SpecAlias { char const* token; std::initializer_list<uint32> specs; };
static SpecAlias const kSpecAliases[] = {
// Tanks (covered by role: tank too, but spec name is more specific)
{"protection", {73, 66}}, // War Prot, Paladin Prot
{"prot", {73, 66}},
{"vengeance", {581}},
{"guardian", {104}},
{"blood", {250}},
{"brewmaster", {268}},
{"brew", {268}},
// Healers
{"holy", {65, 257}}, // Pal Holy, Priest Holy
{"discipline", {256}},
{"disc", {256}},
{"shadow", {258}},
{"restoration", {105, 264}}, // Druid, Shaman
{"resto", {105, 264}},
{"mistweaver", {270}},
{"mw", {270}},
{"preservation", {1468}},
// Mage
{"arcane", {62}},
{"fire", {63}},
{"frost", {64, 251}}, // Frost Mage, Frost DK
// Warrior
{"arms", {71}},
{"fury", {72}},
// Hunter
{"beastmastery", {253}},
{"bm", {253}},
{"marksmanship", {254}},
{"mm", {254}},
{"survival", {255}},
// Rogue
{"assassination",{259}},
{"assa", {259}},
{"outlaw", {260}},
{"subtlety", {261}},
{"sub", {261}},
// Death Knight
{"unholy", {252}},
// Shaman
{"elemental", {262}},
{"ele", {262}},
{"enhancement", {263}},
{"enh", {263}},
// Demon Hunter
{"havoc", {577}},
// Druid
{"balance", {102}},
{"feral", {103}},
// Monk
{"windwalker", {269}},
{"ww", {269}},
// Paladin
{"retribution", {70}},
{"ret", {70}},
// Warlock
{"affliction", {265}},
{"affli", {265}},
{"demonology", {266}},
{"demo", {266}},
{"destruction", {267}},
{"destro", {267}},
// Evoker
{"devastation", {1467}},
{"augmentation", {1473}},
{"aug", {1473}},
};
// For Class/Spec/Role kinds, returns a predicate over snapshots that
// decides whether a bot matches. Snapshots are pulled from the
// SnapshotPublisher to read the latest published cls/spec/my_role.
template <class Pred>
void CollectOwnedMatching(uint32 owner_account, Pred pred,
std::vector<Player*>& out)
{
auto const owned = Services::Owners().BotsOwnedBy(owner_account);
out.reserve(owned.size());
for (BotId id : owned)
{
ObjectGuid guid = ObjectGuid::Create<HighGuid::Player>(id);
Player* p = ObjectAccessor::FindConnectedPlayer(guid);
if (!p) continue;
// Pull the latest snapshot — snapshot fields are the source of
// truth for cls/spec/role (the live Player can be mid-spec-swap).
std::shared_ptr<BotSnapshot const> snap = Services::Snapshots().latest(id);
if (!snap) continue;
if (pred(*snap)) out.push_back(p);
}
}
// Fallback collector: walks the sender's CURRENT GROUP and returns
// every V2-registered bot matching the predicate, regardless of
// ownership. Needed for the LFG case — the player queued solo via
// the dungeon-finder UI, TC matched them with auto-spawned (unowned)
// bots, and `;all run` would otherwise resolve to zero because
// CollectOwnedMatching only walks bots explicitly owned by the player.
// The downstream IsAuthorized() in BotCommandParser already permits
// group-member commands for unowned bots, so this matches existing
// authority semantics — players can command bots that share their
// group whether they own them or not.
template <class Pred>
void CollectGroupMatching(Player const* sender, Pred pred,
std::vector<Player*>& out)
{
if (!sender) return;
Group const* g = sender->GetGroup();
if (!g) return;
for (GroupReference const& itr : g->GetMembers())
{
Player* member = itr.GetSource();
if (!member || member == sender) continue;
const BotId mid = member->GetGUID().GetCounter();
if (!Services::Registry().has(mid)) continue;
std::shared_ptr<BotSnapshot const> snap = Services::Snapshots().latest(mid);
if (!snap) continue;
if (pred(*snap)) out.push_back(member);
}
}
} // anonymous
ResolvedAddress BotAddressResolver::ParsePrefix(std::string_view whisper_text)
{
ResolvedAddress out;
auto trimmed = Trim(whisper_text);
// Find the separator. Accept either ':' (the original "all:run" form)
// OR the first whitespace ("all run") — the latter is what users
// intuitively type from chat, and the previous colon-only parser
// dropped `;all run` style commands onto the Single path. Whichever
// separator comes first wins. No separator at all → Single.
auto colon = trimmed.find(':');
auto space = trimmed.find_first_of(" \t");
std::string_view::size_type sep_pos = std::string_view::npos;
if (colon != std::string_view::npos && space != std::string_view::npos)
sep_pos = std::min(colon, space);
else if (colon != std::string_view::npos)
sep_pos = colon;
else if (space != std::string_view::npos)
sep_pos = space;
if (sep_pos == std::string_view::npos)
{
out.kind = ResolvedAddress::Kind::Single;
out.command = std::string(trimmed);
return out;
}
auto prefix = std::string(Trim(trimmed.substr(0, sep_pos)));
auto rest = std::string(Trim(trimmed.substr(sep_pos + 1)));
// The space-separated parse must only kick in for KNOWN prefix
// tokens — otherwise an innocuous command like "fly 5" would have
// "fly" mis-parsed as a prefix. Defer the prefix-validity check to
// the switch below: if no token matches, fall back to Single with
// the original full text as command.
auto pl_check = ToLower(prefix);
bool const is_known_prefix =
pl_check == "all" || pl_check == "squad" || pl_check == "here" ||
pl_check == "tank" || pl_check == "tanks" ||
pl_check == "heal" || pl_check == "heals" ||
pl_check == "healer" || pl_check == "healers" ||
pl_check == "dps" ||
pl_check == "marked" || pl_check == "mark";
// For space-separated, also accept class / spec / bot-name as the
// prefix only if it matches one of those tables — check after the
// switch by capturing whether the original separator was a space.
bool const sep_was_space = (sep_pos == space);
if (sep_was_space && !is_known_prefix)
{
// Try class / spec alias / name. If any matches, accept as a
// prefix. Otherwise treat the whole text as a Single command
// (don't eat "fly 5" by accident).
uint8 dummy_cls = 0;
bool const is_class = ClassNameToKind(pl_check, dummy_cls) ==
ResolvedAddress::Kind::Class;
bool is_spec = false;
for (SpecAlias const& a : kSpecAliases)
if (pl_check == a.token) { is_spec = true; break; }
// Name-prefix path is ambiguous in space-form (any first word
// would match), so require the colon for name addressing.
if (!is_class && !is_spec)
{
out.kind = ResolvedAddress::Kind::Single;
out.command = std::string(trimmed);
return out;
}
}
out.command = std::move(rest);
auto pl = ToLower(prefix);
if (pl == "all") { out.kind = ResolvedAddress::Kind::All; return out; }
if (pl == "squad") { out.kind = ResolvedAddress::Kind::Squad; return out; }
if (pl == "here") { out.kind = ResolvedAddress::Kind::Here; return out; }
if (pl == "tank" || pl == "tanks")
{ out.kind = ResolvedAddress::Kind::Role_Tank; return out; }
if (pl == "heal" || pl == "heals" || pl == "healer" || pl == "healers")
{ out.kind = ResolvedAddress::Kind::Role_Healer; return out; }
if (pl == "dps") { out.kind = ResolvedAddress::Kind::Role_Dps; return out; }
if (pl == "marked" || pl == "mark")
{ out.kind = ResolvedAddress::Kind::Marked; return out; }
// Class name?
{
uint8 cls = 0;
if (ClassNameToKind(pl, cls) == ResolvedAddress::Kind::Class)
{
out.kind = ResolvedAddress::Kind::Class;
out.filter = pl;
return out;
}
}
// Spec alias?
for (SpecAlias const& a : kSpecAliases)
{
if (pl == a.token)
{
out.kind = ResolvedAddress::Kind::Spec;
out.filter = pl;
return out;
}
}
// Otherwise — treat as a name. Restore original-case prefix as
// the filter so the name match is case-insensitive but the
// diagnostic display preserves the player's typing.
out.kind = ResolvedAddress::Kind::Name;
out.filter = std::move(prefix);
return out;
}
ResolvedAddress BotAddressResolver::Resolve(
Player const* sender,
Player* primary,
std::string_view whisper_text)
{
ResolvedAddress out = ParsePrefix(whisper_text);
if (!sender)
{
if (primary) out.bots.push_back(primary);
return out;
}
const uint32 sender_account =
sender->GetSession() ? sender->GetSession()->GetAccountId() : 0;
if (out.kind == ResolvedAddress::Kind::Single)
{
if (primary) out.bots.push_back(primary);
return out;
}
if (out.kind == ResolvedAddress::Kind::All)
{
CollectOwnedMatching(sender_account,
[](BotSnapshot const&) { return true; },
out.bots);
// LFG / shared-group fallback when the sender owns no bots.
if (out.bots.empty())
CollectGroupMatching(sender,
[](BotSnapshot const&) { return true; },
out.bots);
return out;
}
if (out.kind == ResolvedAddress::Kind::Squad)
{
// Owned + currently in sender's group.
Group const* g = sender->GetGroup();
CollectOwnedMatching(sender_account,
[g](BotSnapshot const& s)
{
if (!g) return false;
return g->IsMember(s.guid);
},
out.bots);
// Same fallback as 'all' but already group-scoped — any V2 bot
// in the group counts as the player's squad in the unowned case.
if (out.bots.empty())
CollectGroupMatching(sender,
[](BotSnapshot const&) { return true; },
out.bots);
return out;
}
if (out.kind == ResolvedAddress::Kind::Here)
{
// Owned + on sender's map within 60y.
const uint32 sender_map = sender->GetMapId();
const float sx = sender->GetPositionX();
const float sy = sender->GetPositionY();
constexpr float kRadiusSq = 60.f * 60.f;
CollectOwnedMatching(sender_account,
[sender_map, sx, sy, kRadiusSq](BotSnapshot const& s)
{
if (s.position.map_id != sender_map) return false;
const float dx = s.position.x - sx, dy = s.position.y - sy;
return dx*dx + dy*dy <= kRadiusSq;
},
out.bots);
return out;
}
if (out.kind == ResolvedAddress::Kind::Role_Tank ||
out.kind == ResolvedAddress::Kind::Role_Healer ||
out.kind == ResolvedAddress::Kind::Role_Dps)
{
const Role want =
out.kind == ResolvedAddress::Kind::Role_Tank ? Role::Tank :
out.kind == ResolvedAddress::Kind::Role_Healer ? Role::Healer :
Role::Dps;
CollectOwnedMatching(sender_account,
[want](BotSnapshot const& s) { return s.group.my_role == want; },
out.bots);
if (out.bots.empty())
CollectGroupMatching(sender,
[want](BotSnapshot const& s) { return s.group.my_role == want; },
out.bots);
return out;
}
if (out.kind == ResolvedAddress::Kind::Class)
{
uint8 want_cls = 0;
ClassNameToKind(out.filter, want_cls);
CollectOwnedMatching(sender_account,
[want_cls](BotSnapshot const& s) { return s.identity.cls == want_cls; },
out.bots);
return out;
}
if (out.kind == ResolvedAddress::Kind::Spec)
{
// Look up the spec id set for this alias and match against
// bot's current spec.
std::vector<uint32> wanted;
for (SpecAlias const& a : kSpecAliases)
if (out.filter == a.token)
for (uint32 sid : a.specs)
wanted.push_back(sid);
CollectOwnedMatching(sender_account,
[&wanted](BotSnapshot const& s)
{
for (uint32 sid : wanted) if (s.identity.spec == sid) return true;
return false;
},
out.bots);
return out;
}
if (out.kind == ResolvedAddress::Kind::Marked)
{
// Owned + carries any active raid marker. Snapshot doesn't yet
// expose markers, so this is a future-fillable filter — for now
// resolve to empty and let callers know via empty bots list.
// (Marker wiring deferred to Phase E /mark.)
return out;
}
if (out.kind == ResolvedAddress::Kind::Name)
{
const std::string filter_lower = ToLower(out.filter);
CollectOwnedMatching(sender_account,
[&filter_lower](BotSnapshot const& s)
{
return ToLower(s.identity.name) == filter_lower;
},
out.bots);
return out;
}
return out;
}
} // namespace Playerbot
@@ -0,0 +1,77 @@
// BotAddressResolver — turns squad-address prefixes ("all:", "tank:",
// "Areon:", "mage:") into a vector of bot Player*s the sender is
// authorised to command. Used by the whisper command parser at the
// top of Dispatch, so a single whisper to one bot can apply to many.
//
// Resolution is bounded by the sender's OwnerRegistry binding: a
// prefix never resolves to a bot the sender doesn't own (or, for
// unowned bots, to one outside the sender's group). This means
// owners can never accidentally command someone else's bot via
// `all: <command>`, and griefers can't drive other players' fleets.
//
// Returned in resolution order: the prefix-matched set, in arbitrary
// stable order. The whispered "primary" bot is INCLUDED in the set
// when it matches the prefix (so /w Areon "all: come" still moves
// Areon if the player owns him); the broadcast helper is responsible
// for de-duplicating per-bot intent emits if the primary is also in
// the resolved set.
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
class Player;
namespace Playerbot {
struct ResolvedAddress
{
enum class Kind : uint8_t
{
Single, // No prefix — just the whispered bot.
All, // Every owned bot, online, any map.
Squad, // Owned + currently in sender's group.
Here, // Owned + on sender's map within 60y.
Role_Tank, // Owned + tank-spec.
Role_Healer, // Owned + healer-spec.
Role_Dps, // Owned + dps-spec.
Class, // Owned + matching class id.
Spec, // Owned + matching spec id.
Marked, // Owned + carries an in-game raid marker.
Name, // Owned + matching character name (case-insensitive).
};
Kind kind = Kind::Single;
// Stripped command text — prefix removed, leading whitespace trimmed.
std::string command;
// Filter parameter for Class/Spec/Name kinds. Empty otherwise.
std::string filter;
// The actual bots resolved by the prefix. Populated by Resolve().
std::vector<Player*> bots;
};
class BotAddressResolver
{
public:
// Parse the leading prefix off `whisper_text` and resolve it to bots
// the `sender` is allowed to command. The whispered "primary" bot
// is passed as fallback for the no-prefix case (Kind::Single resolves
// to {primary}).
//
// Returns Kind::Single + bots = {primary} when no prefix is found.
// Returns the resolved kind/filter/bots when a prefix matches.
static ResolvedAddress Resolve(
Player const* sender,
Player* primary,
std::string_view whisper_text);
// Lower-level: parse just the prefix without resolving. Useful for
// diagnostics / dry-runs. Returns kind / filter / stripped command;
// bots field stays empty.
static ResolvedAddress ParsePrefix(std::string_view whisper_text);
};
} // namespace Playerbot
@@ -0,0 +1,142 @@
#include "BotArchetype.h"
#include <cstdint>
namespace Playerbot {
namespace {
// Curated archetype table. Row index == archetype_id == ArchetypeId enum
// value — NEVER reorder; append only (the id is persisted in the DB).
//
// role_affinity = {Tank, Healer, Dps} (sums ~1.0)
// activity_weights = {Solo, Group, Pvp, Prof, Social} (sums ~1.0, ArchetypeActivity order)
// econ_profile = economic posture
// target_session = intended minutes online per session
//
// `weight` is the relative roll weight used by RollArchetype — it is NOT a
// field of BotArchetype, just the population distribution shape.
struct ArchetypeRow
{
BotArchetype proto;
uint32 weight;
char const* name;
};
constexpr ArchetypeRow kArchetypeTable[] = {
// CasualSolo — the fleet bulk. Mostly solos, a little grouping, some
// profession dabbling. Short-to-medium sessions. Balanced economy.
{ BotArchetype{
/*archetype_id*/ static_cast<uint8>(ArchetypeId::CasualSolo),
/*role_affinity*/ { 0.10f, 0.10f, 0.80f },
/*activity*/ { 0.55f, 0.20f, 0.05f, 0.15f, 0.05f },
/*econ*/ EconProfile::Balanced,
/*session_min*/ 60 },
/*weight*/ 34, "CasualSolo" },
// HardcoreRaider — group/raid focused, high tank+heal willingness, long
// sessions, hoards consumables/mats for raids.
{ BotArchetype{
static_cast<uint8>(ArchetypeId::HardcoreRaider),
{ 0.30f, 0.25f, 0.45f },
{ 0.10f, 0.70f, 0.05f, 0.10f, 0.05f },
EconProfile::Hoarder,
180 },
14, "HardcoreRaider" },
// SocialGuildie — chat/guild oriented, moderate grouping, balanced
// economy, medium sessions. Drives the "living guild" feel.
{ BotArchetype{
static_cast<uint8>(ArchetypeId::SocialGuildie),
{ 0.12f, 0.18f, 0.70f },
{ 0.20f, 0.30f, 0.05f, 0.10f, 0.35f },
EconProfile::Balanced,
90 },
18, "SocialGuildie" },
// GathererFlipper — profession/economy focused, active AH reseller,
// mostly solo, medium-long sessions. The economy's supply + price-setting
// engine.
{ BotArchetype{
static_cast<uint8>(ArchetypeId::GathererFlipper),
{ 0.05f, 0.05f, 0.90f },
{ 0.40f, 0.05f, 0.02f, 0.48f, 0.05f },
EconProfile::Reseller,
120 },
12, "GathererFlipper" },
// PvPer — battleground/world-PvP focused, pure DPS lean, medium sessions,
// balanced economy (buys consumables, sells PvP drops).
{ BotArchetype{
static_cast<uint8>(ArchetypeId::PvPer),
{ 0.08f, 0.12f, 0.80f },
{ 0.15f, 0.15f, 0.60f, 0.05f, 0.05f },
EconProfile::Balanced,
90 },
12, "PvPer" },
// AltoholicExplorer — wide solo exploration + profession dabbling, short
// bursty sessions (the "log in, poke around, log off" player). Hoards
// because alts squirrel away mats across characters.
{ BotArchetype{
static_cast<uint8>(ArchetypeId::AltoholicExplorer),
{ 0.10f, 0.10f, 0.80f },
{ 0.60f, 0.10f, 0.05f, 0.20f, 0.05f },
EconProfile::Hoarder,
45 },
10, "AltoholicExplorer" },
};
static_assert(sizeof(kArchetypeTable) / sizeof(kArchetypeTable[0]) == kArchetypeCount,
"kArchetypeTable row count must equal kArchetypeCount / ArchetypeId::Count");
// splitmix64 mix — same construction as BotPersonality / BotRng so the roll
// is stable and well-dispersed. Salted so the archetype roll does not
// correlate with the personality rolls drawn from the same per-bot seed.
uint64_t mix(uint64_t seed, uint64_t salt)
{
uint64_t z = (seed + salt + 0x9E3779B97F4A7C15ULL);
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
return z ^ (z >> 31);
}
} // anonymous
BotArchetype ArchetypeById(uint8 archetype_id)
{
if (archetype_id >= kArchetypeCount)
return kArchetypeTable[0].proto; // clamp to CasualSolo
return kArchetypeTable[archetype_id].proto;
}
char const* ArchetypeName(uint8 archetype_id)
{
if (archetype_id >= kArchetypeCount)
return "(unknown)";
return kArchetypeTable[archetype_id].name;
}
BotArchetype RollArchetype(uint64 rng_seed)
{
uint32 total = 0;
for (auto const& row : kArchetypeTable)
total += row.weight;
if (total == 0)
return kArchetypeTable[0].proto; // degenerate guard
// Salt 0xA17 ("ART") keeps this stream independent of the personality
// dimension salts (0x10A..0x60A in BotPersonality.cpp).
const uint32 roll = static_cast<uint32>(mix(rng_seed, 0xA17ULL) % total);
uint32 acc = 0;
for (auto const& row : kArchetypeTable)
{
acc += row.weight;
if (roll < acc)
return row.proto;
}
return kArchetypeTable[kArchetypeCount - 1].proto; // fp-safety fallback
}
} // namespace Playerbot
+122
View File
@@ -0,0 +1,122 @@
// BotArchetype - Per-bot "play archetype": WHAT a bot does (role/activity
// emphasis + economic behavior) and WHEN it intends to be online (target
// session length). Distinct from BotPersonality, which is HOW a bot plays
// (skill / aggression / verbosity / risk). Together they make the fleet
// heterogeneous: a casual soloer, a hardcore raider, a social guildie, a
// gatherer/AH-flipper, a PvPer, and an altoholic explorer all coexist —
// the basis of a believable living server and of economy / role variety.
//
// Stored in playerbot_v2_character.archetype_id (migration 0012). Rolled
// deterministically from the per-bot rng_seed on first spawn (so the same
// bot always rolls the same archetype across restarts) and written back.
//
// Data-driven: the curated archetype table lives in BotArchetype.cpp as a
// static array so role/activity/econ/session values are tunable in one
// place without touching the roll logic.
#pragma once
#include "BotPersonality.h" // ActivityPref categories — activity_weights aligns to them
#include "BotTypes.h"
#include <array>
namespace Playerbot {
// Economic posture. Drives gather/sell/AH behavior (future consumers in the
// economy subsystem). Hoarder banks mats and rarely sells; Balanced sells
// surplus at vendors / lists occasionally; Reseller actively flips on the AH.
enum class EconProfile : uint8 { Hoarder = 0, Balanced = 1, Reseller = 2 };
// Number of activity-weight slots. Aligned to the BotPersonality activity
// preference categories the fleet reasons about: Solo / Group / PvP /
// Profession / Social. (ActivityPref also defines Housing/All bits, but the
// weighted-activity model only spreads across these five primary play modes.)
inline constexpr uint8 kArchetypeActivityCount = 5;
// Stable indices into BotArchetype::activity_weights. Kept in lock-step with
// the ActivityPref bit order so a consumer can map a weight slot back to the
// matching ActivityPref bit when it needs the bitfield form.
namespace ArchetypeActivity {
constexpr uint8 Solo = 0;
constexpr uint8 Group = 1;
constexpr uint8 Pvp = 2;
constexpr uint8 Profession = 3;
constexpr uint8 Social = 4;
}
struct BotArchetype
{
// Index into the curated table (kArchetypeTable). 0 = CasualSolo, the
// default for an un-rolled / un-migrated bot (matches the SQL column
// default), so a bot that has never been rolled reads as a sensible
// casual soloer rather than garbage.
uint8 archetype_id = 0;
// Role emphasis: [Tank, Healer, Dps]. Sums to ~1.0. Used by the
// population rebalancer to pick which hybrid bots to respec into a
// starved tank/healer role (highest-affinity first) and by future
// role-selection logic. A pure-DPS archetype has {0,0,1}.
std::array<float, 3> role_affinity{ 0.f, 0.f, 1.f };
// Activity emphasis, indexed by ArchetypeActivity::*. Sums to ~1.0.
// Idle rules can bias toward the bot's dominant activity (e.g. a
// GathererFlipper spends most of its time on Profession, a PvPer on
// Pvp) without a thread crossing — the dominant slot is mirrored into
// the snapshot's ArchetypeState.
std::array<float, kArchetypeActivityCount> activity_weights{
0.6f, 0.2f, 0.05f, 0.1f, 0.05f };
EconProfile econ_profile = EconProfile::Balanced;
// Intended play-session length in minutes. The session-rhythm logout
// layer (documented followup; not yet wired) will use this with
// cumulative_session_minutes to decide when a bot "logs off for the
// night". Stored now so the data is available when that layer lands.
uint16 target_session_minutes = 90;
// Index of the highest activity_weights slot (ArchetypeActivity::*).
// Convenience for consumers that only want the dominant activity.
uint8 dominant_activity() const
{
uint8 best = 0;
for (uint8 i = 1; i < kArchetypeActivityCount; ++i)
if (activity_weights[i] > activity_weights[best]) best = i;
return best;
}
};
// Named archetype ids — these MUST match the row order of kArchetypeTable in
// BotArchetype.cpp (archetype_id == table index). Persisted in the DB, so
// never reorder existing entries; append new archetypes at the end.
enum class ArchetypeId : uint8
{
CasualSolo = 0,
HardcoreRaider = 1,
SocialGuildie = 2,
GathererFlipper = 3,
PvPer = 4,
AltoholicExplorer = 5,
Count
};
// Number of curated archetypes (table row count). Kept in sync with the
// static table via a static_assert in BotArchetype.cpp.
inline constexpr uint8 kArchetypeCount = static_cast<uint8>(ArchetypeId::Count);
// Look up a curated archetype by id. Out-of-range ids clamp to CasualSolo
// (id 0) so a corrupt / future-migrated DB value never reads garbage.
BotArchetype ArchetypeById(uint8 archetype_id);
// Human-readable name for an archetype id (diagnostics / .playerbot inspect).
char const* ArchetypeName(uint8 archetype_id);
// Deterministic weighted roll from a per-bot seed (use SeedForBot(id)). The
// distribution roughly mimics a real population: most bots are casual
// soloers / social guildies, with a smaller tail of hardcore raiders,
// gatherer-flippers, PvPers, and altoholics. Same seed always returns the
// same archetype so a bot's identity is reproducible across restarts.
// Takes the full 64-bit seed (SeedForBot returns uint64) to match
// RandomPersonality and preserve all seed entropy.
BotArchetype RollArchetype(uint64 rng_seed);
} // namespace Playerbot
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
// BotChatReactor - lightweight party/raid chat reaction layer.
//
// When a real player speaks in party/raid/instance chat, scan for common
// social keywords ("ty"/"thanks", "gz"/"grats", "lol", direct name address)
// and emit a contextual reply from at most one bot in the channel. This
// makes group chat read like a room of real players instead of silent
// auto-attackers.
//
// Scope notes:
// - Pure reactive layer. Behavioral commands ("pull", "wait", "watch FC")
// still flow through BotCommandParser via the ;-prefix path.
// - Sender must be a real player; bot-originated chat is never reacted to
// (avoids self-talk echo storms).
// - At most one bot replies per message — picked stably by guid hash so
// the same bot tends to be the "talker" of a group.
// - Per-bot throttle prevents reply spam under heavy chat.
// - Verbosity gates: Silent/Terse skip entirely; Normal+/Chatty/Roleplay
// have progressively higher fire chance.
#pragma once
#include "ObjectGuid.h"
#include <cstdint>
#include <string>
class Player;
class Group;
namespace Playerbot {
class BotChatReactor
{
public:
// Entry point. Called from PlayerbotV2::Module::OnPartyChat for every
// party/raid/instance-chat line (after the ;-prefix command path).
// No-op when sender is null / a bot / message is empty.
static void React(Player* sender, Group* group, std::string const& msg);
// Whisper variant. Called from OnWhisperReceived AFTER BotCommandParser
// returned false (command not recognized) — treats the line as a
// social cue and emits a whisper-back if the message matches a known
// pattern (hi/ty/gz/etc.). Per-bot throttle shared with party react
// path. No-op when sender is a bot or the bot isn't owned/grouped.
static void ReactWhisper(Player* sender, Player* bot, std::string const& msg);
// Phase C.3 guild-chat variant. Called from Module::OnGuildChat for
// every CHAT_MSG_GUILD/CHAT_MSG_OFFICER line. Picks ONE online
// officer of the sender's guild (oldest-by-guid hash, deterministic
// so the same officer tends to be "the talker"), and emits a
// contextual guild_chat reply 30-90s later via the bot's intent
// queue. No-op when:
// - Sender is a bot (avoids echo storms).
// - Guild has no online officer bot.
// - Reactor's per-guild throttle (60s) is hot.
static void ReactGuild(Player* sender, uint64 guild_id, std::string const& msg);
// SC-P1a /say reactor. Called from Module::OnSayChat for every /say a
// real player makes. Runs a grid-bounded range query anchored on the
// sender (radius = CONFIG_LISTEN_RANGE_SAY) for V2 bots, classifies the
// message per-bot, and lets at most ONE bot answer — picked stably by
// the same guid-hash selection used by the party path, except addressed
// bots (name mentioned) always win. Say replies are biased toward
// greetings / direct-name mentions / location questions. Enforces a
// per-bot cooldown AND a per-area cooldown so crowded hubs don't explode
// at scale. No-op when sender is a bot / message is a command prefix.
static void ReactSay(Player* sender, std::string const& msg);
// SC-P1a /yell reactor. Same machinery as ReactSay but anchored at
// CONFIG_LISTEN_RANGE_YELL and gated to a LOWER fire chance — players
// answer yells far less often than says. Shares the per-bot/per-area
// cooldown maps with ReactSay.
static void ReactYell(Player* sender, std::string const& msg);
// SC-P2c text-emote reactor. Called from Module::OnTextEmote. A nearby
// bot reciprocates: if the emote was targeted at a specific bot, THAT
// bot answers; otherwise one nearby bot is picked. Emits a human-paced
// PerformEmoteIntent with a reciprocal animation. Per-bot cooldown,
// range-gated by CONFIG_LISTEN_RANGE_TEXTEMOTE.
static void ReactEmote(Player* sender, uint32 emote_id, ObjectGuid target);
// SC-P2b guild-join welcome. Called from Module::OnGuildMemberAdded.
// Picks one online bot guildmate (deterministic by guid hash) to emit a
// welcome line a few seconds later through GuildChatIntent. Per-guild
// throttle prevents a recruiting spree from spamming chat. No-op when
// the joiner is itself a bot (auto-spawned recruits don't get welcomed).
static void ReactGuildJoin(uint64 guild_id, ObjectGuid joiner_guid, std::string const& joiner_name);
};
} // namespace Playerbot
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
// BotCommandParser - Parses chat-driven bot commands ("follow", "stay",
// "attack", "release") and translates them into intents on the bot's queue.
//
// Runs on the world thread (called from the whisper hook). Keeps it lock-free
// by pushing into the per-bot IntentQueue, which the world drain consumes.
#pragma once
#include "BotTypes.h"
#include <string>
class Player;
namespace Playerbot {
class BotCommandParser
{
public:
// Returns true if the message matched a known command (regardless of
// success). A future iteration will return rich error info; the bool
// shape is enough for "did the bot handle this?".
//
// Performs squad-address resolution at the top: if `msg` is prefixed
// with `all:`, `tank:`, `Areon:`, etc., the command body is dispatched
// to every bot the sender owns that matches the prefix, and the
// whispered `bot_player` is used only as the messenger that replies
// with a single summary line. The address resolver is in
// BotAddressResolver.h.
static bool Dispatch(Player* sender, Player* bot_player, std::string const& msg);
// Single-bot dispatch. Skips the address resolver — used internally
// by Dispatch when a prefix has already been parsed and a per-target
// command needs to run, and by tests that bypass the prefix layer.
static bool DispatchSingle(Player* sender, Player* bot_player, std::string const& msg);
};
} // namespace Playerbot
@@ -0,0 +1,9 @@
#include "BotIntent.h"
namespace Playerbot {
// Intent is a POD wrapper; nothing out-of-line. This TU exists for future
// helper functions (e.g., debug-format serialization) without forcing a
// header recompile when added.
} // namespace Playerbot
+774
View File
@@ -0,0 +1,774 @@
// BotIntent - Typed messages from AI workers to world thread.
// First iteration carries the load-bearing intent variants; the full set per
// CONTRACTS.md §2.3 lands as subsystems implement the corresponding paths.
#pragma once
#include "BotTypes.h"
#include "ObjectGuid.h"
#include <string>
#include <variant>
#include <vector>
namespace Playerbot {
// ---- Combat ----
struct CastSpellIntent { uint32 spell_id; ObjectGuid target; };
// Cast a spell on an Item (disenchant 13262 / prospect 31252 / mill 51005).
// item_guid is the bag-resident item GUID.
struct CastSpellOnItemIntent { uint32 spell_id; ObjectGuid item_guid; };
struct GroundTargetSpellIntent { uint32 spell_id; float x, y, z; };
struct CancelCastIntent {};
// Cancel a specific aura on self by spell id (RemoveAurasDueToSpell).
// Owner-driven cleanup — e.g. paladin /cancelaura 1044 (Hand of Freedom)
// when no longer needed. Self-only; for other-target dispel use /dispel.
struct CancelAuraIntent { uint32 spell_id; };
struct StartAttackIntent { ObjectGuid target; };
// clear_ghost_combat: additionally drop PvE combat REFS (Unit::CombatStop)
// when the bot has no attackers — self-heal for the stuck-combat-flag wedge
// (2026-06-12 Stockades: healer InCombat=true with no victim/attackers for
// 30+ min; tank_advance's member-in-combat gate held the whole run hostage).
// The API only clears when the attacker list is empty, so a bot in a REAL
// fight can't accidentally combat-drop.
struct StopAttackIntent { bool clear_ghost_combat = false; };
struct PetAttackIntent { ObjectGuid target; };
// Issue a pet ability — Felhunter Spell Lock (Warlock interrupt), Hunter
// pet abilities, DK pet ghoul commands. The spell must belong to the bot's
// active pet's spellbook; the API resolves the pet and casts as charm-master
// (works for warlocks/hunters/DKs alike).
struct PetCastSpellIntent { uint32 spell_id; ObjectGuid target; };
// Dismiss the bot's current pet — useful before flight paths (the pet
// can't follow), or for warlocks switching to a different demon. The API
// targets the active pet via Player::GetPet() and dispatches the standard
// Pet::Remove(PET_SAVE_DISMISS) path; warlock-style permanent demons get
// PET_SAVE_NOT_IN_SLOT so they're recallable.
struct DismissPetIntent {};
// ---- Public chat ----
struct SayChatIntent { std::string text; };
struct YellChatIntent { std::string text; };
struct EmoteChatIntent { std::string text; };
struct GuildChatIntent { std::string text; };
struct RaidChatIntent { std::string text; };
// ---- Unstuck ----
// Near-teleport the bot a few yards in their facing direction. Owner
// emergency unstuck for terrain glitches (stuck on a rock, jammed against
// a doorframe). Server-side via Player::NearTeleportTo. Distance is a
// hint — server clamps to navigable terrain.
struct UnstuckIntent { float distance; };
// Direct near-teleport to a precise (x,y,z,o). Used by the auto-unstick
// rule (Tier 2 escalation) when the bot has been wedged on geometry for
// 15+ seconds. Stays on the bot's current map; the server clamps Z to
// navmesh ground at the destination.
struct NearTeleportToIntent { float x, y, z, o; };
// ---- Stand-state (sit / stand / sleep / kneel) ----
// stand_state matches UnitStandStateType: 0=stand, 1=sit, 2=chair (low/med/high
// for inn chairs), 5=sleep, 6=kneel, 7=cower, 8=submerged. We only expose the
// owner-useful ones via whisper (sit/stand) but the intent carries the raw
// byte so future rules (auto-sit on inn furniture etc.) can use the rest.
struct SetStandStateIntent { uint8 stand_state; };
// ---- Movement ----
// direct=true executes a straight MovePoint spline WITHOUT pathfinding ("just
// move, don't think") — used ONLY for committed traversal-link crossings whose
// endpoints are human-verified ground truth (playerbot_nav_links); a pathfound
// move toward the far side of a real navmesh split would NoPath and refuse.
struct MoveToIntent { float x, y, z; bool run; bool direct = false; };
struct TeleportToIntent { uint32 map_id; float x, y, z, o; };
// clear_generators=false (default) only halts the active spline (Player::
// StopMoving). clear_generators=true pops EVERY MotionMaster generator back to
// Idle (Clear + MoveIdle). The distinction matters: a POINT/CHASE/FOLLOW
// generator left in place re-issues a fresh spline toward its STORED target on
// the next Update, so merely stopping the spline lets a stale destination keep
// driving the bot. Clearing the generator is the only way to drop a movement
// target that survived a cross-map teleport (e.g. a move computed on the prior
// map that resumes against meaningless coordinates on the new map).
struct StopMovementIntent { bool clear_generators = false; };
struct JumpIntent { float forward; };
struct MountIntent { uint32 mount_id; }; // 0 = best for context
struct DismountIntent {};
struct HearthIntent {};
// Follow with optional formation offset. `angle_radians` is the
// follow-side relative to the leader's facing (0 = directly behind,
// pi/2 = right flank, etc). 0 distance + 0 angle = legacy "behind at
// default spacing" behaviour. Formation rules in State_Idle compute
// (slot, type) → (distance, angle) so multiple bots take distinct
// flank positions. Used by every /follow, /come, /formation path.
struct FollowIntent { ObjectGuid leader; float distance; float angle_radians; };
// ---- Items / loot ----
struct UseItemIntent { uint8 bag, slot; ObjectGuid target; };
struct UseItemByEntryIntent { uint32 item_entry; ObjectGuid target; };
struct EquipItemIntent { uint8 from_bag, from_slot; uint8 to_slot; };
struct LootIntent { ObjectGuid corpse_or_object; };
struct ReleaseCorpseIntent {};
struct ReviveAtCorpseIntent {};
// Corpse-run completion: bot is ghost and has walked back into reclaim
// range. Calls API::reclaim_corpse → ResurrectPlayer + SpawnCorpseBones,
// no sickness.
struct ReclaimCorpseIntent {};
// Spirit-healer rez: instant alive at graveyard with sickness + 25%
// durability hit. Calls API::spirit_resurrect.
struct SpiritResurrectIntent {};
// Accept a pending rez popup (Resurrection / Rebirth / Soulstone). The
// caster has already cast on our corpse — this intent finalizes acceptance.
struct AcceptRezIntent {};
// ---- Vendor ----
struct VendorBuyIntent { ObjectGuid npc; uint8 vendor_slot; uint8 count; };
struct VendorSellIntent { ObjectGuid npc; uint8 bag, slot; uint8 count; };
struct VendorSellTrashIntent { ObjectGuid npc; };
struct RepairAllIntent { ObjectGuid npc; bool from_guild_bank; };
// Buy first vendor item matching item_class/item_subclass that the bot meets
// the level requirement for. Used by auto-restock rules where the AI doesn't
// know the vendor's exact slot layout. `total_count` is the desired quantity;
// the API stops once that many units have been purchased (multiple buy_slot
// calls if the vendor sells the item in stacks <total_count).
struct VendorBuyByCategoryIntent { ObjectGuid npc; uint8 item_class, item_subclass; uint8 total_count; };
// ---- Quest ----
struct QuestAcceptIntent { ObjectGuid npc; uint32 quest_id; };
// `reward_choice` selects from the quest's choice-item list (0 = first).
// Pass 0xFF to let PlayerbotAPI auto-pick (ScoreQuestReward weighs equippable
// upgrades > vendor value). Quests with only fixed rewards ignore the field.
struct QuestCompleteIntent { ObjectGuid npc; uint32 quest_id; uint8 reward_choice; };
struct QuestAbandonIntent { uint32 quest_id; };
// Accept a quest just shared by a group member. The receiver-side popup is
// driven by `Player::SetQuestSharingInfo`; the API uses GetSharedQuestID for
// the actual accept. No fields needed on the intent — the bot side already
// knows what's pending via the snapshot.
struct QuestSharedAcceptIntent {};
// Run JunkQuestResolver on the bot: force-complete auto-granted feature quests
// (55660 "Time Trials" etc. — abandon doesn't stick, they re-push at login) and
// resolve profession-spec choice quests. No fields — the resolver processes the
// whole log on the world thread. Emitted by idle:resolve_junk_quests.
struct ResolveJunkQuestsIntent {};
// ---- Loot rolls ----
// Cast vote on a group/master loot roll. vote_type: 0=Pass, 1=Need,
// 2=Greed, 3=Disenchant. Out-of-range votes fold to Pass server-side.
struct LootRollIntent { ObjectGuid loot_object; uint8 loot_list_id; uint8 vote_type; };
// ---- Talents ----
// Apply Blizzard's curated starter build to the bot's active combat
// trait config. Drives `apply_starter_talents` via the executor. Used
// by the auto-init layer on first login + by the `talents` whisper.
struct ApplyStarterTalentsIntent {};
// Apply curated per-spec talent build for the given context.
// 0 = Default, 1 = Raid, 2 = MythicPlus, 3 = PvP, 4 = Leveling.
struct ApplyTalentBuildIntent { uint8 context; };
// ---- Battleground ----
// Solo or group battleground/arena queue. `bg_type_id` is the
// BattlemasterList.dbc id — e.g. WSG=2, AB=3, AV=1, RB=32 (random) for
// battlegrounds; 4=Nagrand, 6=AllArenas, 8=RuinsOfLordaeron for arenas.
// `arena_type` selects the queue category: 0 = battleground (normal BG
// queue), 2/3/5 = arena skirmish bracket (2v2/3v3/5v5). When non-zero
// the API routes through the Arena queue id and requires the bot to be
// the leader of an arena_type-sized group. Defaulting it to 0 keeps the
// existing two-field aggregate inits (`BgQueueIntent{guid, bml}`) valid.
struct BgQueueIntent { ObjectGuid battlemaster; uint16 bg_type_id; uint8 arena_type = 0; };
struct BgLeaveIntent {};
// Port into (or decline) a pending BG invite. Bot side surfaces the queue
// type id via snapshot.bg_queues[i].bg_type_id; the API resolves the queue
// slot and dispatches to the same logic as the player-side BattlefieldPort.
struct BgPortIntent { uint16 bg_type_id; bool accept; };
// ---- Auction House ----
// Post a single non-stackable item up for auction. `min_bid` and `buyout`
// are in copper but must be silver-aligned (% 100 == 0). `run_time_minutes`
// must be one of {720, 1440, 2880} for the 12h/24h/48h auction durations.
struct AuctionSellItemIntent { ObjectGuid auctioneer; ObjectGuid item_guid;
uint64 min_bid; uint64 buyout; uint32 run_time_minutes; };
// Cancel one of the bot's own auctions. `auction_id` comes from the bot's
// own owned-auctions list (snapshot does not surface this yet — driven by
// whisper command for now).
struct AuctionCancelIntent { ObjectGuid auctioneer; uint32 auction_id; };
// Bulk-cancel every owned auction in `auctioneer`'s faction house. Cheaper
// for the AI than enumerating ids and emitting per-cancel intents — driven
// from the `/cancelall` whisper for owners who want to wipe their listings.
struct AuctionCancelAllIntent { ObjectGuid auctioneer; };
// ---- Taxi ----
// Discover the flight master's local node (auto-fires on first arrival in
// most cases, but lets bot logic explicitly trigger). Activate flies a
// known route to `to_node` (a TaxiNodes.dbc id).
struct DiscoverTaxiNodeIntent { ObjectGuid flight_master; };
struct FlyToNodeIntent { ObjectGuid flight_master; uint32 to_node; };
// ---- Hearth bind ----
// Reset the bot's hearthstone home to the innkeeper's location.
struct BindHomebindIntent { ObjectGuid innkeeper; };
// ---- Bank ----
// Move one item between inventory and bank. The bot must already be in
// interact range with the banker. `from_bag/from_slot` is the source —
// inventory side for deposit, bank side for withdraw. The API auto-stores
// to the matching destination via NULL_BAG/NULL_SLOT.
struct BankDepositItemIntent { ObjectGuid banker; uint8 bag, slot; };
struct BankWithdrawItemIntent { ObjectGuid banker; uint8 bag, slot; };
// ---- Trainer ----
// Learn one spell from `trainer_npc`. The bot picks `spell_id` from a known
// trainer-spell list (passed in via config / level-up trigger); execution
// resolves the trainer template via the creature entry.
struct TrainerBuySpellIntent { ObjectGuid trainer_npc; uint32 spell_id; };
// Buy every spell on the trainer the bot is currently eligible for —
// drives the "train" whisper command without per-spell scripting.
struct TrainerBuyAllIntent { ObjectGuid trainer_npc; };
// ---- Mail ----
// All three carry the mailbox guid (GO or NPC) so the API can re-validate
// interaction range at execution time. The bot picks `mail_id` and
// `item_guid_low` from BotSnapshot::mail (populated by the world thread).
struct MailTakeMoneyIntent { ObjectGuid mailbox; uint64 mail_id; };
struct MailTakeItemIntent { ObjectGuid mailbox; uint64 mail_id; uint64 item_guid_low; };
struct MailDeleteIntent { ObjectGuid mailbox; uint64 mail_id; };
// ---- Group / social ----
struct GroupAcceptIntent {};
struct GroupDeclineIntent {};
struct GroupLeaveIntent {};
struct GroupReadyResponseIntent { bool ready; };
struct GroupPromoteToLeaderIntent { ObjectGuid new_leader_guid; };
// Kick a member from the bot's group (party uninvite). Bot must be leader.
struct GroupKickMemberIntent { ObjectGuid member_guid; };
// Convert party to raid (Group::ConvertToRaid). Leader-only, idempotent.
struct GroupConvertToRaidIntent {};
// Initiate a ready check on the bot's group. Leader/raid-assistant only.
struct GroupStartReadyCheckIntent {};
// Toggle MEMBER_FLAG_ASSISTANT on a raid member. Leader-only, raid-only.
struct GroupSetAssistantIntent { ObjectGuid member_guid; bool assistant; };
// Reset all (non-locked) instance binds. Bot must be group leader (when grouped).
struct ResetInstancesIntent {};
// Guild membership ops (mirror HandleGuildAccept/Decline/Leave handlers).
struct GuildAcceptInviteIntent {};
struct GuildDeclineInviteIntent {};
struct GuildLeaveIntent {};
struct OfficerChatIntent { std::string text; };
struct RaidWarningIntent { std::string text; };
struct PerformEmoteIntent { uint32 emote_id; ObjectGuid target; };
struct FaceTargetIntent { ObjectGuid target; };
struct VendorBuyByEntryIntent { ObjectGuid npc; uint32 item_entry; uint32 count; };
struct TogglePvpIntent {};
struct AddFriendIntent { std::string name; std::string note; };
struct MailSendMoneyIntent { std::string recipient; uint64 copper; std::string subject; std::string body; };
// Mail an item attachment. count=0 means whole stack; copper rides along on
// the same mail (zero is fine); cod=0 means no COD. Postage 30c flat.
struct MailSendItemIntent { std::string recipient; ObjectGuid item_guid; uint32 count;
uint64 copper; uint64 cod;
std::string subject; std::string body; };
struct CalendarRsvpAllIntent { bool accept; };
// ---- Hunter pet stable management ----
// Move a stabled or active pet (by petNumber) to a new slot. Active slots
// are 0..MAX_ACTIVE_PETS-1; stable slots are 5..(5+MAX_PET_STABLES-1).
// Active<->stable swaps despawn the active pet first.
struct SwapPetToSlotIntent { uint32 pet_number; uint8 dst_slot; };
// Permanently delete a pet from the stable. The currently summoned pet
// cannot be deleted; caller must dismiss first.
struct DeleteStabledPetIntent { uint32 pet_number; };
// Summon a pet that is in an active slot. Use SwapPetToSlotIntent first
// to bring a stabled pet into an active slot if needed.
struct SummonPetByNumberIntent { uint32 pet_number; };
// Cast Feed Pet (6991) on the active hunter pet using the named food
// item from inventory. Pet diet + level checks happen API-side.
struct FeedPetIntent { uint32 food_item_entry; };
// Permanently abandon the active hunter pet (Player::RemovePet with
// PET_SAVE_AS_DELETED). Refused mid-combat.
struct AbandonPetIntent {};
// ---- Guild bank ----
// All four require the bot is in a guild and `banker` is a Guild Vault
// (GAMEOBJECT_TYPE_GUILD_BANK) in interact range.
struct GuildBankDepositMoneyIntent { ObjectGuid banker; uint64 amount; };
struct GuildBankWithdrawMoneyIntent { ObjectGuid banker; uint64 amount; };
struct GuildBankDepositItemIntent { ObjectGuid banker; uint8 tab; uint8 bank_slot;
uint8 player_bag; uint8 player_slot; uint32 count; };
struct GuildBankWithdrawItemIntent { ObjectGuid banker; uint8 tab; uint8 bank_slot;
uint8 player_bag; uint8 player_slot; uint32 count; };
// Push the bot's active quest to the party, popping the share dialog on
// each eligible receiver. Mirrors HandlePushQuestToParty.
struct ShareQuestIntent { uint32 quest_id; };
// Pet stance/command. Mirrors ReactStates / CommandStates respectively.
struct PetSetReactStateIntent { uint8 state; };
struct PetSetCommandStateIntent { uint8 command; };
struct RenamePetIntent { std::string new_name; };
struct PetToggleAutocastIntent { uint32 spell_id; bool enabled; };
struct RemoveFriendIntent { ObjectGuid friend_guid; };
struct AddIgnoreIntent { std::string name; };
struct RemoveIgnoreIntent { ObjectGuid ignore_guid; };
// Toggle the AFK / DND chat-state flag (Player::ToggleAFK / ToggleDND).
struct ToggleAfkIntent {};
struct ToggleDndIntent {};
// Change the bot's dungeon difficulty (mirrors HandleSetDungeonDifficultyOpcode).
struct SetDungeonDifficultyIntent { uint32 difficulty_id; };
// Change raid difficulty; `legacy` selects the legacy slot.
struct SetRaidDifficultyIntent { uint32 difficulty_id; bool legacy; };
// Player-summon dialog response: warlock summon ritual, meeting stone summon,
// LFG summon. Fires after the snapshot reports has_summon_pending. Auto-accept
// in State_Idle/InGroup; decline is currently only used by whisper command.
struct SummonAcceptIntent {};
struct SummonDeclineIntent {};
// Decline a pending duel request. Strangers always get declined; the
// friend-aware dispatch rule sends DuelAcceptIntent for trusted initiators
// (group / guild / social-friend), which keeps owner sparring usable.
struct DuelDeclineIntent {};
struct DuelAcceptIntent {};
// Update a raid target marker (skull/cross/etc) on `target`. `symbol` is
// 0..7 mirroring RaidTargetIcon. Empty target clears that symbol. Used by
// the auto-skull rule (in-combat group leader marks lowest-HP enemy) and
// by the upcoming /mark whisper command.
struct SetRaidTargetIconIntent { uint8 symbol; ObjectGuid target; };
// Specialization swap. spec_id is ChrSpecialization.db2 id. Combat-gated
// by API; rejected outright when the bot is fighting (Locked).
struct ActivateSpecIntent { uint32 spec_id; };
// Diagnostic: clears all spell cooldowns. Owner-driven via /cdreset; no
// auto-firing rule. Helps iterate on combat tuning.
struct ResetCooldownsIntent {};
// Decline an open trade request. Mirrors HandleCancelTradeOpcode — closes
// the trade window without committing items. Always-on auto-decline; bots
// don't accept arbitrary trades (item-theft vector).
struct TradeDeclineIntent {};
// Send a group invite to the named player. Mirrors HandlePartyInviteOpcode
// — same gating around faction/instance/level/social ignore. Used by the
// idle:invite_to_group rule so solo bots gather pickup parties at dungeon
// hubs / grindspots, and by the /invite whisper command.
struct InviteToGroupIntent { ObjectGuid target; };
struct WhisperIntent { std::string target; std::string text; };
struct PartyChatIntent { std::string text; };
// ---- LFG ----
struct LfgQueueIntent { uint32 dungeon_or_bg_id; Role role; };
struct LfgUnqueueIntent {};
// Respond to the LFG dungeon-ready proposal popup. accept=true ports into
// the dungeon; false drops back into queue (or disbands the proposal).
struct LfgProposalRespondIntent { uint32 proposal_id; bool accept; };
// Respond to the group's LFG role-check with this bot's desired role bitmask
// (PLAYER_ROLE_TANK=2, HEALER=4, DAMAGE=8, LEADER=1). Auto-fired from
// State_InGroup when role_check_pending flips true.
struct LfgRoleCheckIntent { uint8 roles; };
// ---- World interaction ----
struct UseObjectIntent { ObjectGuid object; };
struct InteractWithNpcIntent { ObjectGuid npc; };
struct GossipSelectIntent { ObjectGuid npc; uint8 option; };
// ---- Vehicles (BG siege engines, dragons, sorters etc.) ----
// EnterVehicle: target_guid is the vehicle Unit; seat_id == -1 picks the
// first free seat. Mirrors Unit::EnterVehicle which casts the hardcoded
// VEHICLE_SPELL_RIDE spell, so the vehicle's seat config validates.
struct EnterVehicleIntent { ObjectGuid vehicle; int8 seat_id; };
struct ExitVehicleIntent {};
// Cast the bot's vehicle's spell while seated (e.g., demolisher boulder
// hurl). Mirrors UnitAction's "use seat ability". target may be empty
// for ground-targeted ability that uses the AT spell flow instead.
struct VehicleSpellIntent { uint32 spell_id; ObjectGuid target; };
struct VehicleGroundSpellIntent { uint32 spell_id; float x, y, z; };
// ---- Guild subsystem intent wrapper ----
//
// All guild-related intents (Phase A.2 charter flow + future Phase B
// recruitment + Phase C chat + Phase D events + Phase E rivalry)
// nest inside ONE `GuildIntent` alternative in `IntentBody` rather
// than adding individual top-level types. This protects the master
// variant from MSVC heap-exhaustion: every Combat spec rotation .cpp
// instantiates the full visitor machinery for `IntentBody`, and at
// ~120 alternatives we're at the breaking edge. Sub-variant nesting
// keeps the master variant size constant as the guild subsystem
// grows. See feedback_intent_variant_capacity.md.
//
// Charter sub-types mirror WorldSession petition handlers (non-packet
// path through Fleet/BotGuildCharter.cpp helpers).
namespace GuildOp {
// Buy a Guild Charter (item entry 5863) from a PETITIONER NPC.
struct BuyCharter { ObjectGuid petitioner_npc; std::string guild_name; };
// Sign a petition the bot is near. `petition_item_low` is the
// founder's charter item guid_low; signer is the emitter.
struct SignCharter { uint64 petition_item_low; };
// Turn in a fully-signed petition at the petitioner NPC.
struct TurnInCharter { ObjectGuid petitioner_npc; uint64 petition_item_low; };
// Phase B: officer (recruiter = emitter) directly adds `target_low`
// to the recruiter's guild. Bypasses player-style invite popup —
// both sides are bots so there's no acceptance UI to navigate.
struct RecruitTarget { uint64 target_guid_low; };
// Phase E.1: officer (emitter) posts a recruit message to their
// current zone's Trade channel. Executor resolves the channel,
// composes the message ("<Guild Name> recruiting all levels,
// /w <officer> for invite"), and emits via Channel::Say. No
// payload — all data resolved server-side at execution.
struct RecruitChannelPost {};
}
struct GuildIntent
{
// Append new alternatives here as Phase B/C/D/E land — none of
// them widen `IntentBody`.
std::variant<
GuildOp::BuyCharter,
GuildOp::SignCharter,
GuildOp::TurnInCharter,
GuildOp::RecruitTarget,
GuildOp::RecruitChannelPost
> op;
};
// ---- Economy subsystem intent wrapper (#4B) ----
//
// Buy-side auction + (future #4B-2) craft-order economy ops nest inside ONE
// `EconomyIntent` alternative in `IntentBody`, mirroring `GuildIntent` /
// `AuctionIntent`. Today bots only SELL (post) on the AH; the buy path
// (AhBuyout / AhBid) closes the supply->demand->gold-sink loop. Wrapping
// keeps the master variant size constant as the economy subsystem grows
// (craft-orders, vendor-arbitrage, etc) — see
// feedback_intent_variant_capacity.md.
//
// All ops are EMITTED by economy rules; the executor calls the matching
// server-side PlayerbotAPI method which re-validates (auctioneer in range,
// auction still exists, not own auction, enough gold) at execution time.
namespace EconomyOp {
// Buy out an existing auction outright at its full buyout price. The
// bot picks `auction_id` + `auctioneer` + `price` from
// BotSnapshot::auction.buyable_listings (populated when the bot is at
// an auctioneer). `price` is the expected buyout (copper) carried for
// the executor's affordability pre-check; the API re-reads the live
// auction's BuyoutOrUnitPrice and rejects on mismatch/insufficient gold.
struct AhBuyout { ObjectGuid auctioneer; uint32 auction_id; uint64 price; };
// Place a bid on an existing (non-commodity) auction. `bid` is the
// copper amount to bid; must be silver-aligned and >= the auction's
// current min-increment. The API validates against the live auction.
struct AhBid { ObjectGuid auctioneer; uint32 auction_id; uint64 bid; };
// Buy a quantity of a COMMODITY (stackable trade good / craft reagent).
// Modern stackable goods are bought via the bucket-aggregated commodity
// path (GetCommodityQuote -> BuyCommodity), NOT the single-auction
// AhBuyout/AhBid above — auction_buyout rejects commodities. The bot
// picks `item_entry` + `quantity` from BotSnapshot::auction
// .buyable_commodities; `max_total_price` is the rule's slippage-guarded
// ceiling (unit_price*qty + margin). The API creates a quote, refuses if
// the live total exceeds max_total_price or the bot can't afford it, then
// commits the purchase (items mailed to the bot).
struct AhBuyCommodity { ObjectGuid auctioneer; uint32 item_entry;
uint32 quantity; uint64 max_total_price; };
// #4B-2(a): a crafter bot FULFILS a claimed craft order. The bot has
// already CLAIMED `order_id` via CraftOrderBoard (world-thread), and the
// claimed order's fields ride along in the snapshot
// (BotSnapshot::craft_orders.claimed_*). The executor calls
// PlayerbotAPI::craft_fulfill_order which casts the craft spell(s) to
// produce `qty` of `item_entry`, mails the product to `requester_low`, and
// on success calls CraftOrderBoard::MarkDelivered(order_id) to RELEASE the
// escrow to the crafter. `spell_id`/`item_entry`/`qty`/`requester_low` are
// carried (not just order_id) so the API doesn't have to reach back into
// the board for them — the board remains the escrow authority, while the
// craft mechanics are pure server-side validation. Posting an order is NOT
// an intent: it's a direct world-thread CraftOrderBoard::PostOrder call
// from the post rule's fire, to keep the escrow debit atomic with the row
// write (see CraftOrderBoard.h).
struct CraftFulfill { uint64 order_id; uint32 spell_id; uint32 item_entry;
uint32 qty; uint64 requester_low; };
// #4B-2(a) part 2: a requester bot POSTS a craft order for a crafted
// intermediate it needs but cannot make itself. The executor runs on the
// WORLD THREAD (where Player gold + the board live), so the post rule emits
// this op instead of touching CraftOrderBoard from the worker thread — the
// executor calls CraftOrderBoard::PostOrder, which debits the escrow atomically
// with the row write and re-verifies the requester is a fleet bot
// (human-firewall). `spell_id` is the PRODUCING recipe (the order's recipe key
// — only a bot that KNOWS it can claim), `item_entry` the product, `quantity`
// the shortfall, `payment` the market-derived fair payment escrowed up front.
// Fields are sourced from BotSnapshot::craft_orders.want_*.
struct CraftPost { uint32 spell_id; uint32 item_entry; uint32 quantity;
uint64 payment; };
// #4B-2(a) part 2: a crafter bot CLAIMS the oldest Open order whose recipe it
// knows. Like CraftPost this MUST run on the world thread (ClaimOpenOrder
// re-verifies the crafter's live spellbook + fleet-bot status and flips the
// row to Claimed), so the claim rule emits this op rather than calling the
// board directly. No payload — the board picks the oldest claimable order for
// the emitting bot. The claimed order then surfaces in the next snapshot's
// craft_orders.claimed_* fields, which the fulfil rule turns into CraftFulfill.
struct CraftClaim { };
}
struct EconomyIntent
{
// Append new alternatives here as #4B-2 craft-orders / further economy
// ops land — none of them widen `IntentBody`.
std::variant<
EconomyOp::AhBuyout,
EconomyOp::AhBid,
EconomyOp::AhBuyCommodity,
EconomyOp::CraftFulfill,
EconomyOp::CraftPost,
EconomyOp::CraftClaim
> op;
};
// ---- Housing (12.0+) ----
struct JoinNeighborhoodIntent { uint32 neighborhood_id; };
struct PlotPurchaseIntent { uint32 neighborhood_id; uint32 plot_id; };
struct PlaceDecorationIntent { uint32 plot_id; uint32 deco_entry; bool exterior;
float x, y, z, rot; };
struct VisitHouseIntent { uint32 plot_id; };
// ---- Subsystem-wrapper intents (variant-pressure relief) ----
//
// Each wrapper carries `variant<...>` of all its subsystem's sub-intents.
// Inner sub-intent structs keep their original names so direct callers
// only need to wrap one extra layer (`ChatIntent{SayChatIntent{...}}`).
// The shorthand emitter helpers wrap automatically. See
// feedback_intent_variant_capacity.md — MSVC's variant visitor instantiation
// hits a heap-exhaustion wall around ~120 IntentBody alternatives; bundling
// related ops into wrappers keeps the master variant small.
struct ChatIntent
{
std::variant<
SayChatIntent, YellChatIntent, EmoteChatIntent,
GuildChatIntent, RaidChatIntent, OfficerChatIntent,
RaidWarningIntent, WhisperIntent, PartyChatIntent
> op;
};
struct MailIntent
{
std::variant<
MailTakeMoneyIntent, MailTakeItemIntent, MailDeleteIntent,
MailSendMoneyIntent, MailSendItemIntent
> op;
};
struct AuctionIntent
{
std::variant<
AuctionSellItemIntent, AuctionCancelIntent, AuctionCancelAllIntent
> op;
};
struct VendorIntent
{
std::variant<
VendorBuyIntent, VendorSellIntent, VendorSellTrashIntent,
RepairAllIntent, VendorBuyByCategoryIntent, VendorBuyByEntryIntent
> op;
};
// Hunter pet stable management — keeps `PetAttackIntent` / `PetCastSpellIntent`
// / `DismissPetIntent` at top level (those are combat-tier, emitted from
// per-spec rotation files; wrapping them would force every Combat .cpp to
// include this wrapper).
struct HunterPetIntent
{
std::variant<
SwapPetToSlotIntent, DeleteStabledPetIntent, SummonPetByNumberIntent,
FeedPetIntent, AbandonPetIntent, PetSetReactStateIntent,
PetSetCommandStateIntent, RenamePetIntent, PetToggleAutocastIntent
> op;
};
struct QueueIntent
{
std::variant<
BgQueueIntent, BgLeaveIntent, BgPortIntent,
LfgQueueIntent, LfgUnqueueIntent, LfgProposalRespondIntent,
LfgRoleCheckIntent
> op;
};
// Housing (12.0+) — 4 sub-ops. Wrapped per feedback_intent_variant_capacity
// so the master IntentBody variant stays under MSVC's
// visitor-instantiation heap limit. No emitters or executors exist yet
// (housing system not wired); wrapper reserves the type slots so future
// housing code adds sub-ops here without expanding the master variant.
struct HousingIntent
{
std::variant<
JoinNeighborhoodIntent, PlotPurchaseIntent,
PlaceDecorationIntent, VisitHouseIntent
> op;
};
// The variant. Order is fixed once shipped — appending only.
// Wrapped sub-types are NOT in this top-level list; they live inside
// their subsystem wrapper. New subsystems should follow the wrapper
// pattern to keep IntentBody bounded.
using IntentBody = std::variant<
CastSpellIntent, GroundTargetSpellIntent, CancelCastIntent,
StartAttackIntent, StopAttackIntent, PetAttackIntent, PetCastSpellIntent,
DismissPetIntent, SetStandStateIntent, UnstuckIntent, CancelAuraIntent,
MoveToIntent, TeleportToIntent, StopMovementIntent, JumpIntent,
MountIntent, DismountIntent, HearthIntent, FollowIntent,
UseItemIntent, UseItemByEntryIntent, EquipItemIntent, LootIntent,
ReleaseCorpseIntent, ReviveAtCorpseIntent,
ReclaimCorpseIntent, SpiritResurrectIntent, AcceptRezIntent,
QuestAcceptIntent, QuestCompleteIntent, QuestAbandonIntent,
QuestSharedAcceptIntent, ResolveJunkQuestsIntent,
TrainerBuySpellIntent, TrainerBuyAllIntent,
BankDepositItemIntent, BankWithdrawItemIntent,
BindHomebindIntent,
DiscoverTaxiNodeIntent, FlyToNodeIntent,
ApplyStarterTalentsIntent,
LootRollIntent,
GroupAcceptIntent, GroupDeclineIntent, GroupLeaveIntent, GroupReadyResponseIntent,
GroupPromoteToLeaderIntent, GroupKickMemberIntent, GroupConvertToRaidIntent,
GroupStartReadyCheckIntent, GroupSetAssistantIntent, ResetInstancesIntent,
GuildAcceptInviteIntent, GuildDeclineInviteIntent, GuildLeaveIntent,
ToggleAfkIntent, ToggleDndIntent,
SetDungeonDifficultyIntent, SetRaidDifficultyIntent,
PerformEmoteIntent, FaceTargetIntent,
TogglePvpIntent,
AddFriendIntent, RemoveFriendIntent, AddIgnoreIntent, RemoveIgnoreIntent,
CalendarRsvpAllIntent,
GuildBankDepositMoneyIntent, GuildBankWithdrawMoneyIntent,
GuildBankDepositItemIntent, GuildBankWithdrawItemIntent,
ShareQuestIntent,
SummonAcceptIntent, SummonDeclineIntent,
DuelDeclineIntent, DuelAcceptIntent, TradeDeclineIntent,
InviteToGroupIntent,
SetRaidTargetIconIntent, ActivateSpecIntent, ResetCooldownsIntent,
NearTeleportToIntent,
UseObjectIntent, InteractWithNpcIntent, GossipSelectIntent,
EnterVehicleIntent, ExitVehicleIntent, VehicleSpellIntent, VehicleGroundSpellIntent,
ApplyTalentBuildIntent, CastSpellOnItemIntent,
GuildIntent,
// Subsystem wrappers — see comment above. Housing wrapped here
// 2026-05-21 (was 4 raw alternatives; rolled into HousingIntent
// sub-variant for IntentBody capacity discipline).
ChatIntent, MailIntent, AuctionIntent, VendorIntent,
HunterPetIntent, QueueIntent, HousingIntent,
// Economy buy-side wrapper (#4B). Nests EconomyOp::* sub-ops; one
// top-level alternative keeps IntentBody under MSVC's visitor limit.
EconomyIntent
>;
struct Intent
{
IntentId id = 0;
BotId bot_id = 0;
SnapshotVer source_snapshot = 0;
// Defer-until timestamp (GameTime::GetGameTimeMS()). When non-zero,
// the dispatch loop checks `now < defer_until_ms` and re-queues the
// intent without executing it. Used for human-pacing of social
// replies (whispers, /p chat) where the intent is composed
// immediately but the visible packet must NOT go out until 2–6s
// later to model a human reading the message and typing. Zero
// (the default) means "fire as soon as dispatch picks it up".
uint32 defer_until_ms = 0;
IntentBody body;
};
// Cheap kind-discrimination for diagnostics / metrics.
inline size_t IntentKind(Intent const& i) { return i.body.index(); }
// Human-readable name for a variant index. Lives HERE, next to the variant,
// as the single source of truth: BotInspector used to keep its own copy of
// this table, it drifted when the chat intents were rolled into ChatIntent,
// and every /diag intent label past index 10 was wrong (an EquipItem retry
// wedge read as "Dismount | ServerRefused" — diagnosed as the wrong bug on
// 2026-06-10). The static_asserts force this list to be updated whenever an
// alternative is added to / removed from IntentBody.
static_assert(std::variant_size_v<IntentBody> == 100,
"IntentBody changed — update IntentKindName() below to match.");
inline char const* IntentKindName(size_t kind)
{
static constexpr char const* kNames[] = {
"CastSpell", "GroundTargetSpell", "CancelCast",
"StartAttack", "StopAttack", "PetAttack", "PetCastSpell",
"DismissPet", "SetStandState", "Unstuck", "CancelAura",
"MoveTo", "TeleportTo", "StopMovement", "Jump",
"Mount", "Dismount", "Hearth", "Follow",
"UseItem", "UseItemByEntry", "EquipItem", "Loot",
"ReleaseCorpse", "ReviveAtCorpse",
"ReclaimCorpse", "SpiritResurrect", "AcceptRez",
"QuestAccept", "QuestComplete", "QuestAbandon",
"QuestSharedAccept", "ResolveJunkQuests",
"TrainerBuySpell", "TrainerBuyAll",
"BankDepositItem", "BankWithdrawItem",
"BindHomebind",
"DiscoverTaxiNode", "FlyToNode",
"ApplyStarterTalents",
"LootRoll",
"GroupAccept", "GroupDecline", "GroupLeave", "GroupReadyResponse",
"GroupPromoteToLeader", "GroupKickMember", "GroupConvertToRaid",
"GroupStartReadyCheck", "GroupSetAssistant", "ResetInstances",
"GuildAcceptInvite", "GuildDeclineInvite", "GuildLeave",
"ToggleAfk", "ToggleDnd",
"SetDungeonDifficulty", "SetRaidDifficulty",
"PerformEmote", "FaceTarget",
"TogglePvp",
"AddFriend", "RemoveFriend", "AddIgnore", "RemoveIgnore",
"CalendarRsvpAll",
"GuildBankDepositMoney", "GuildBankWithdrawMoney",
"GuildBankDepositItem", "GuildBankWithdrawItem",
"ShareQuest",
"SummonAccept", "SummonDecline",
"DuelDecline", "DuelAccept", "TradeDecline",
"InviteToGroup",
"SetRaidTargetIcon", "ActivateSpec", "ResetCooldowns",
"NearTeleportTo",
"UseObject", "InteractWithNpc", "GossipSelect",
"EnterVehicle", "ExitVehicle", "VehicleSpell", "VehicleGroundSpell",
"ApplyTalentBuild", "CastSpellOnItem",
"Guild",
"Chat", "Mail", "Auction", "Vendor",
"HunterPet", "Queue", "Housing",
"Economy",
};
static_assert(std::size(kNames) == std::variant_size_v<IntentBody>,
"kNames out of sync with IntentBody");
return kind < std::size(kNames) ? kNames[kind] : "?";
}
// ---- Auto-wrap helper for subsystem-wrapper intents ----
//
// Call sites can keep emitting the underlying intent type (e.g. `WhisperIntent`,
// `LfgQueueIntent`); this helper wraps it into the appropriate subsystem
// wrapper before it reaches `IntentBody`. Types that aren't wrapped pass
// through unchanged. Keeps Push/BroadcastToGroup helpers terse and prevents
// future regressions where someone adds a new emission site for a wrapped
// type but forgets to wrap it manually.
template <class T>
auto WrapForIntentBody(T&& body)
{
using U = std::decay_t<T>;
if constexpr (std::is_same_v<U, SayChatIntent> ||
std::is_same_v<U, YellChatIntent> ||
std::is_same_v<U, EmoteChatIntent> ||
std::is_same_v<U, GuildChatIntent> ||
std::is_same_v<U, RaidChatIntent> ||
std::is_same_v<U, OfficerChatIntent> ||
std::is_same_v<U, RaidWarningIntent> ||
std::is_same_v<U, WhisperIntent> ||
std::is_same_v<U, PartyChatIntent>)
return ChatIntent{std::forward<T>(body)};
else if constexpr (std::is_same_v<U, MailTakeMoneyIntent> ||
std::is_same_v<U, MailTakeItemIntent> ||
std::is_same_v<U, MailDeleteIntent> ||
std::is_same_v<U, MailSendMoneyIntent> ||
std::is_same_v<U, MailSendItemIntent>)
return MailIntent{std::forward<T>(body)};
else if constexpr (std::is_same_v<U, AuctionSellItemIntent> ||
std::is_same_v<U, AuctionCancelIntent> ||
std::is_same_v<U, AuctionCancelAllIntent>)
return AuctionIntent{std::forward<T>(body)};
else if constexpr (std::is_same_v<U, VendorBuyIntent> ||
std::is_same_v<U, VendorSellIntent> ||
std::is_same_v<U, VendorSellTrashIntent> ||
std::is_same_v<U, RepairAllIntent> ||
std::is_same_v<U, VendorBuyByCategoryIntent> ||
std::is_same_v<U, VendorBuyByEntryIntent>)
return VendorIntent{std::forward<T>(body)};
else if constexpr (std::is_same_v<U, SwapPetToSlotIntent> ||
std::is_same_v<U, DeleteStabledPetIntent> ||
std::is_same_v<U, SummonPetByNumberIntent> ||
std::is_same_v<U, FeedPetIntent> ||
std::is_same_v<U, AbandonPetIntent> ||
std::is_same_v<U, PetSetReactStateIntent> ||
std::is_same_v<U, PetSetCommandStateIntent> ||
std::is_same_v<U, RenamePetIntent> ||
std::is_same_v<U, PetToggleAutocastIntent>)
return HunterPetIntent{std::forward<T>(body)};
else if constexpr (std::is_same_v<U, BgQueueIntent> ||
std::is_same_v<U, BgLeaveIntent> ||
std::is_same_v<U, BgPortIntent> ||
std::is_same_v<U, LfgQueueIntent> ||
std::is_same_v<U, LfgUnqueueIntent> ||
std::is_same_v<U, LfgProposalRespondIntent> ||
std::is_same_v<U, LfgRoleCheckIntent>)
return QueueIntent{std::forward<T>(body)};
else
return std::forward<T>(body);
}
} // namespace Playerbot
@@ -0,0 +1,183 @@
#include "BotIntentEmitter.h"
#include "BotAI.h"
#include "Log.h"
#include "Threading/IntentQueue.h"
#include "../Services.h"
#include "../Diagnostics/PerfCounters.h"
namespace Playerbot {
bool BotIntentEmitter::push(Intent i)
{
if (!queue_) return false;
if (!queue_->push(std::move(i)))
{
// Per-bot ring overflow. The intent is silently dropped — the
// rotation will re-fire next tick. We track here so SystemStatus
// surfaces a sustained backlog as a real signal rather than
// silently degrading bot responsiveness.
if (Services::Initialized())
Services::Perf().record_intent_dropped();
return false;
}
return true;
}
bool BotIntentEmitter::start_attack(ObjectGuid target)
{
// Per-target lockout against the StartAttack ServerRefused loop. See
// BotAI::kStartAttackLockoutMs. The lockout is set by the executor
// when the API call returns ServerRefused (Player::Attack failed —
// typically immune / phased / faction-locked target). Inside the
// window the rule's emit becomes a silent no-op; outside, one retry
// is allowed in case the underlying condition cleared.
if (ai_)
{
const uint32 now_ms = GameTime::GetGameTimeMS();
if (ai_->start_attack_recently_refused(target.GetCounter(), now_ms))
return false;
}
return emit(StartAttackIntent{target});
}
bool BotIntentEmitter::move_to(float x, float y, float z, bool run, bool direct)
{
// Per-bot dedup: skip if the previous move_to went to ~the same XYZ
// within the lockout window. See BotAI::kMoveToEmitLockoutMs / the long
// comment on note_move_to_emitted for the rationale (spline thrash,
// Detour mid-curve reset, visible stutter). Applies to direct moves too
// (a committed link crossing re-asserts the same exit every tick; the
// dedup holds the running straight spline instead of restarting it).
if (ai_)
{
const uint32 now_ms = GameTime::GetGameTimeMS();
if (ai_->move_to_recently_emitted(x, y, z, now_ms))
return false;
// REFUSAL GUARD (emitter-level on purpose). PlayerbotAPI::move_to
// keeps a per-destination path-fail backoff: one failed pathfind
// poisons that dst, every later move_to to ~it returns Locked
// WITHOUT issuing a spline, and a rule that re-selects the same dst
// re-arms the backoff forever -> permanent freeze over a VALID
// navmesh (live: RFK/Gnomeregan/Shadow Labyrinth/Arcatraz/Stonecore;
// headless probes of those corridors come back COMPLETE).
// The first cut of this fix guarded 12 rule-level step sites and the
// route leapfrog scan — and the live acceptance test showed ZERO
// [step_refused] lines, because the route follower's COMMITTED steer
// emits through its own path and bypassed all of them. Placing the
// check HERE is the robust placement: every caller funnels through
// this one function, so no emit path can bypass it. Returning false
// is exactly what an emitter-deduped call already returns, so callers
// that test the result take their existing "not emitted" branch and
// fall through to their next candidate.
// Bounded: entries live only kMoveRefusedTtlMs (6s), so a dst that
// merely had a transient failure becomes selectable again.
if (!direct && ai_->move_refused_recently(x, y, z, now_ms))
{
static uint32 s_refused_dbg_ms = 0;
if (now_ms - s_refused_dbg_ms > 2000u)
{
s_refused_dbg_ms = now_ms;
TC_LOG_INFO("playerbot.v2",
"[emit_refused] bot={} dst=({:.1f},{:.1f},{:.1f}) "
"(recently refused; caller must pick another target)",
ai_->bot_id(), x, y, z);
}
return false;
}
const bool pushed = emit(MoveToIntent{x, y, z, run, direct});
if (pushed)
ai_->note_move_to_emitted(x, y, z, now_ms);
return pushed;
}
return emit(MoveToIntent{x, y, z, run, direct});
}
bool BotIntentEmitter::cast(uint32 spell_id, ObjectGuid target)
{
// Optimistic per-spell emit dedup. The snapshot's spell_cooldowns
// table reflects server-side cooldowns one world-tick after the cast
// lands; in the ~200-1000ms gap, the AI worker may re-tick on the
// same stale snapshot, see "is_ready=true" for a spell whose cast
// just landed, and re-emit. Server rejects with SPELL_FAILED_NOT_READY
// (91) — every rejection logs to Server.log and burns one intent slot.
//
// Solution: track per-spell emit timestamps in BotAI; drop duplicate
// emits within kCastEmitLockoutMs (~1.5s, > snapshot cadence). The
// next snapshot's real cooldown table picks up before the lockout
// expires, so the AI never sees a "ready" window the server disagrees
// with.
//
// Skipped when ai_ is null (test harness etc.) — the dedup is
// strictly an optimization; correctness comes from the snapshot CDs.
if (ai_)
{
const uint32 now_ms = GameTime::GetGameTimeMS();
if (ai_->cast_recently_emitted(spell_id, now_ms))
return false;
const bool pushed = emit(CastSpellIntent{spell_id, target});
if (pushed)
ai_->note_cast_emitted(spell_id, now_ms);
return pushed;
}
return emit(CastSpellIntent{spell_id, target});
}
bool BotIntentEmitter::ah_buyout(ObjectGuid auctioneer, uint32 auction_id, uint64 price)
{
// Per-auction_id dedup so the buy-side economy rule can fire every tick
// it wants the listing without double-spending: one buyout emit per
// auction per 30s window. Inside the window the emit is a silent no-op;
// once the executor settles (Result::Ok) the snapshot's on-demand AH
// scan drops the consumed listing so the rule naturally stops re-emitting.
if (ai_)
{
const uint32 now_ms = GameTime::GetGameTimeMS();
if (ai_->action_recently_tried(BotAI::ActionKind::AhBuyout, auction_id, now_ms))
return false;
const bool pushed = emit(EconomyIntent{EconomyOp::AhBuyout{auctioneer, auction_id, price}});
if (pushed)
ai_->note_action_retry(BotAI::ActionKind::AhBuyout, auction_id, now_ms);
return pushed;
}
return emit(EconomyIntent{EconomyOp::AhBuyout{auctioneer, auction_id, price}});
}
bool BotIntentEmitter::ah_bid(ObjectGuid auctioneer, uint32 auction_id, uint64 bid)
{
if (ai_)
{
const uint32 now_ms = GameTime::GetGameTimeMS();
if (ai_->action_recently_tried(BotAI::ActionKind::AhBid, auction_id, now_ms))
return false;
const bool pushed = emit(EconomyIntent{EconomyOp::AhBid{auctioneer, auction_id, bid}});
if (pushed)
ai_->note_action_retry(BotAI::ActionKind::AhBid, auction_id, now_ms);
return pushed;
}
return emit(EconomyIntent{EconomyOp::AhBid{auctioneer, auction_id, bid}});
}
bool BotIntentEmitter::ah_buy_commodity(ObjectGuid auctioneer, uint32 item_entry,
uint32 quantity, uint64 max_total)
{
// Per-item_entry dedup (AhBuyCommodity 30s). Unlike AhBuyout/AhBid the key
// is the reagent ENTRY, not an auction_id: commodity listings collapse into
// one bucket, so the buy-side rule wants one purchase per wanted reagent per
// 30s window. Inside the window the emit is a silent no-op; once the
// executor settles (Result::Ok) the on-demand AH scan re-derives the bot's
// remaining shortfall so the rule naturally stops re-emitting for a topped-
// up reagent.
if (ai_)
{
const uint32 now_ms = GameTime::GetGameTimeMS();
if (ai_->action_recently_tried(BotAI::ActionKind::AhBuyCommodity, item_entry, now_ms))
return false;
const bool pushed = emit(EconomyIntent{EconomyOp::AhBuyCommodity{auctioneer, item_entry, quantity, max_total}});
if (pushed)
ai_->note_action_retry(BotAI::ActionKind::AhBuyCommodity, item_entry, now_ms);
return pushed;
}
return emit(EconomyIntent{EconomyOp::AhBuyCommodity{auctioneer, item_entry, quantity, max_total}});
}
} // namespace Playerbot
@@ -0,0 +1,380 @@
// BotIntentEmitter - Push-only handle to a bot's intent queue. Held by AI
// workers during tick. CONTRACTS.md §2.4.
#pragma once
#include "BotIntent.h"
#include "ObjectGuid.h"
#include "GameTime.h"
#include <string>
#include <utility>
namespace Playerbot {
class IntentQueue;
class BotAI;
class BotIntentEmitter
{
public:
BotIntentEmitter(IntentQueue* queue, BotId bot_id, SnapshotVer source, IntentId* next_id_counter,
BotAI* ai = nullptr)
: queue_(queue), bot_id_(bot_id), source_(source), next_id_(next_id_counter), ai_(ai) {}
// Templated emit — the canonical path. Avoids ambiguity for callers.
// Wraps subsystem inner-intent types (`SayChatIntent`, `LfgQueueIntent`,
// etc.) into their master variant slot (`ChatIntent`, `QueueIntent`, …)
// via `WrapForIntentBody`. Top-level intent types pass through unchanged.
template <class T>
bool emit(T body)
{
Intent i;
i.id = ++(*next_id_);
i.bot_id = bot_id_;
i.source_snapshot = source_;
i.body = WrapForIntentBody(std::move(body));
const bool ok = push(std::move(i));
if (ok) ++emitted_count_;
return ok;
}
// Number of intents actually PUSHED through this emitter (dedup-dropped
// and queue-overflow emits don't count). Lets the APL engine distinguish
// "rule acted" from "rule's emit was silently dropped" — the old
// first-predicate-wins tick consumed the whole rotation tick even when
// the action emitted nothing (cast lockout, dedup), starving every
// lower-priority rule (audit B02: hunters never reached their focus
// generator; bots wedged into auto-attack-only).
uint32 emitted_count() const { return emitted_count_; }
// Convenience helpers — the most-used intents have shorthand methods so
// APL rules and state code read like prose. Add more as needed; they're
// all `emit(...)` underneath.
// cast() applies the optimistic per-spell emit cooldown (see BotAI:
// kCastEmitLockoutMs). When a recent cast intent for the same spell
// hasn't aged out yet, drop the new emit silently (returns false) to
// prevent the snapshot/server-CD race from producing 4-5 redundant
// SPELL_FAILED_NOT_READY rejections per real cooldown cycle.
// When ai_ is null (e.g. test harness with no per-bot state) the
// dedup is a no-op and the emit always pushes.
bool cast(uint32 spell_id, ObjectGuid target = ObjectGuid::Empty);
bool cast_at(uint32 spell_id, float x, float y, float z)
{ return emit(GroundTargetSpellIntent{spell_id, x, y, z}); }
// start_attack() drops the emit silently if the same target was just
// refused by Player::Attack within the last 30 s — see
// BotAI::kStartAttackLockoutMs. Without this gate, idle:quest_batch:kill
// and similar engage rules re-emit StartAttack every snapshot tick
// against unattackable targets (phased / immune / faction-locked),
// saturating the intent executor and stalling the world thread.
bool start_attack(ObjectGuid target);
bool stop_attack(bool clear_ghost_combat = false)
{ return emit(StopAttackIntent{clear_ghost_combat}); }
bool pet_attack(ObjectGuid target) { return emit(PetAttackIntent{target}); }
bool pet_cast(uint32 spell_id, ObjectGuid target = ObjectGuid::Empty)
{ return emit(PetCastSpellIntent{spell_id, target}); }
// move_to() applies a per-bot dedup against the last emitted destination
// (see BotAI::kMoveToEmitLockoutMs). Re-emitting MoveToIntent at near-
// identical XYZ every snapshot tick resets the MotionMaster spline and
// causes Detour pathfinding to re-plan, producing visible stutter and
// breaking obstacle-avoidance mid-curve. Drop the duplicate emit silently.
// When ai_ is null (test harness) the dedup is a no-op.
// direct=true = straight MovePoint spline, no pathfinding (committed
// traversal-link crossings only — see MoveToIntent).
bool move_to(float x, float y, float z, bool run = true, bool direct = false);
bool dismiss_pet() { return emit(DismissPetIntent{}); }
bool sit() { return emit(SetStandStateIntent{1}); }
bool stand(){ return emit(SetStandStateIntent{0}); }
// clear_generators=true tears down stale MotionMaster generators (not just
// the active spline) — see StopMovementIntent. Use it when a movement
// target may have outlived its validity (e.g. on a cross-map teleport).
bool stop_movement(bool clear_generators = false)
{ return emit(StopMovementIntent{clear_generators}); }
bool jump(float forward = 7.0f) { return emit(JumpIntent{forward}); }
bool near_teleport_to(float x, float y, float z, float o = 0.0f)
{ return emit(NearTeleportToIntent{x, y, z, o}); }
// Cross-map teleport (loading screen). Used by A2 to fire a CROSS-MAP
// areatrigger_teleport server-side (clientless bots can't send the CMSG),
// e.g. a Gilneas / Exile's Reach / allied-race starter-zone exit.
bool teleport_to(uint32 map_id, float x, float y, float z, float o = 0.0f)
{ return emit(TeleportToIntent{map_id, x, y, z, o}); }
bool mount_appropriate() { return emit(MountIntent{0}); }
bool dismount() { return emit(DismountIntent{}); }
bool hearth() { return emit(HearthIntent{}); }
bool follow(ObjectGuid leader, float distance, float angle_radians = 0.0f)
{ return emit(FollowIntent{leader, distance, angle_radians}); }
bool say(std::string text) { return emit(ChatIntent{PartyChatIntent{std::move(text)}}); }
bool say_world(std::string text) { return emit(ChatIntent{SayChatIntent{std::move(text)}}); }
bool yell_world(std::string text) { return emit(ChatIntent{YellChatIntent{std::move(text)}}); }
bool emote_text(std::string text) { return emit(ChatIntent{EmoteChatIntent{std::move(text)}}); }
bool whisper(std::string to, std::string text)
{ return emit(ChatIntent{WhisperIntent{std::move(to), std::move(text)}}); }
bool group_accept() { return emit(GroupAcceptIntent{}); }
bool group_decline() { return emit(GroupDeclineIntent{}); }
bool invite_to_group(ObjectGuid target)
{ return emit(InviteToGroupIntent{target}); }
bool guild_chat(std::string text)
{ return emit(ChatIntent{GuildChatIntent{std::move(text)}}); }
bool group_leave() { return emit(GroupLeaveIntent{}); }
bool group_promote_to_leader(ObjectGuid new_leader)
{ return emit(GroupPromoteToLeaderIntent{new_leader}); }
bool group_kick_member(ObjectGuid member)
{ return emit(GroupKickMemberIntent{member}); }
bool group_convert_to_raid() { return emit(GroupConvertToRaidIntent{}); }
bool group_start_ready_check(){ return emit(GroupStartReadyCheckIntent{}); }
bool group_set_assistant(ObjectGuid member, bool assistant)
{ return emit(GroupSetAssistantIntent{member, assistant}); }
bool reset_instances() { return emit(ResetInstancesIntent{}); }
bool guild_accept_invite() { return emit(GuildAcceptInviteIntent{}); }
bool guild_decline_invite() { return emit(GuildDeclineInviteIntent{}); }
bool guild_leave() { return emit(GuildLeaveIntent{}); }
bool toggle_afk() { return emit(ToggleAfkIntent{}); }
bool toggle_dnd() { return emit(ToggleDndIntent{}); }
bool set_dungeon_difficulty(uint32 d) { return emit(SetDungeonDifficultyIntent{d}); }
bool set_raid_difficulty(uint32 d, bool legacy)
{ return emit(SetRaidDifficultyIntent{d, legacy}); }
bool officer_chat(std::string text)
{ return emit(ChatIntent{OfficerChatIntent{std::move(text)}}); }
bool raid_warning(std::string text)
{ return emit(ChatIntent{RaidWarningIntent{std::move(text)}}); }
bool raid_chat(std::string text)
{ return emit(ChatIntent{RaidChatIntent{std::move(text)}}); }
bool cancel_cast() { return emit(CancelCastIntent{}); }
bool perform_emote(uint32 emote_id, ObjectGuid target = ObjectGuid::Empty)
{ return emit(PerformEmoteIntent{emote_id, target}); }
bool face_target(ObjectGuid target)
{ return emit(FaceTargetIntent{target}); }
bool vendor_buy_by_entry(ObjectGuid npc, uint32 item_entry, uint32 count = 1)
{ return emit(VendorIntent{VendorBuyByEntryIntent{npc, item_entry, count}}); }
bool toggle_pvp() { return emit(TogglePvpIntent{}); }
bool add_friend(std::string name, std::string note = {})
{ return emit(AddFriendIntent{std::move(name), std::move(note)}); }
bool remove_friend(ObjectGuid guid) { return emit(RemoveFriendIntent{guid}); }
bool add_ignore(std::string name) { return emit(AddIgnoreIntent{std::move(name)}); }
bool remove_ignore(ObjectGuid guid) { return emit(RemoveIgnoreIntent{guid}); }
bool mail_send_money(std::string recipient, uint64 copper,
std::string subject = "Bot remittance", std::string body = {})
{ return emit(MailIntent{MailSendMoneyIntent{std::move(recipient), copper,
std::move(subject), std::move(body)}}); }
bool mail_send_item(std::string recipient, ObjectGuid item_guid, uint32 count = 0,
uint64 copper = 0, uint64 cod = 0,
std::string subject = "Bot delivery", std::string body = {})
{ return emit(MailIntent{MailSendItemIntent{std::move(recipient), item_guid, count, copper, cod,
std::move(subject), std::move(body)}}); }
bool calendar_rsvp_all(bool accept) { return emit(CalendarRsvpAllIntent{accept}); }
bool swap_pet_to_slot(uint32 pet_number, uint8 dst_slot)
{ return emit(HunterPetIntent{SwapPetToSlotIntent{pet_number, dst_slot}}); }
bool delete_stabled_pet(uint32 pet_number)
{ return emit(HunterPetIntent{DeleteStabledPetIntent{pet_number}}); }
bool summon_pet_by_number(uint32 pet_number)
{ return emit(HunterPetIntent{SummonPetByNumberIntent{pet_number}}); }
bool feed_pet(uint32 food_item_entry)
{ return emit(HunterPetIntent{FeedPetIntent{food_item_entry}}); }
bool abandon_pet() { return emit(HunterPetIntent{AbandonPetIntent{}}); }
bool guild_bank_deposit_money(ObjectGuid banker, uint64 amount)
{ return emit(GuildBankDepositMoneyIntent{banker, amount}); }
bool guild_bank_withdraw_money(ObjectGuid banker, uint64 amount)
{ return emit(GuildBankWithdrawMoneyIntent{banker, amount}); }
bool guild_bank_deposit_item(ObjectGuid banker, uint8 tab, uint8 bank_slot,
uint8 player_bag, uint8 player_slot, uint32 count = 0)
{ return emit(GuildBankDepositItemIntent{banker, tab, bank_slot, player_bag, player_slot, count}); }
bool guild_bank_withdraw_item(ObjectGuid banker, uint8 tab, uint8 bank_slot,
uint8 player_bag, uint8 player_slot, uint32 count = 0)
{ return emit(GuildBankWithdrawItemIntent{banker, tab, bank_slot, player_bag, player_slot, count}); }
bool share_quest(uint32 quest_id) { return emit(ShareQuestIntent{quest_id}); }
bool pet_set_react_state(uint8 state) { return emit(HunterPetIntent{PetSetReactStateIntent{state}}); }
bool pet_set_command_state(uint8 cmd) { return emit(HunterPetIntent{PetSetCommandStateIntent{cmd}}); }
bool rename_pet(std::string name) { return emit(HunterPetIntent{RenamePetIntent{std::move(name)}}); }
bool pet_toggle_autocast(uint32 spell, bool enabled)
{ return emit(HunterPetIntent{PetToggleAutocastIntent{spell, enabled}}); }
bool summon_accept() { return emit(SummonAcceptIntent{}); }
bool summon_decline() { return emit(SummonDeclineIntent{}); }
bool duel_decline() { return emit(DuelDeclineIntent{}); }
bool duel_accept() { return emit(DuelAcceptIntent{}); }
bool set_raid_target_icon(uint8 symbol, ObjectGuid target)
{ return emit(SetRaidTargetIconIntent{symbol, target}); }
bool activate_spec(uint32 spec_id) { return emit(ActivateSpecIntent{spec_id}); }
bool trade_decline() { return emit(TradeDeclineIntent{}); }
// NPC / world-interaction shorthands. The corresponding API methods all
// expect the bot to be in interact range with the target — caller is
// responsible for moving close first (use_object/quest pickup gated by
// CanInteract checks return InvalidTarget if not).
bool interact_with_npc(ObjectGuid npc) { return emit(InteractWithNpcIntent{npc}); }
bool gossip_select(ObjectGuid npc, uint8 option)
{ return emit(GossipSelectIntent{npc, option}); }
bool use_game_object(ObjectGuid go) { return emit(UseObjectIntent{go}); }
// Cast a spell on an Item (disenchant 13262 / prospect 31252 / mill 51005).
bool cast_on_item(uint32 spell_id, ObjectGuid item_guid)
{ return emit(CastSpellOnItemIntent{spell_id, item_guid}); }
// Vehicle controls. enter_vehicle takes a Unit GUID of the vehicle
// (a Creature or other Unit with a VehicleKit) and an optional seat
// index (-1 = first free seat). cast_vehicle / cast_vehicle_at fire
// the bot's current seat ability.
// Apply curated talent build for the given context (0=Default,
// 1=Raid, 2=MythicPlus, 3=PvP, 4=Leveling). Combat-locked.
bool apply_talent_build(uint8 context)
{ return emit(ApplyTalentBuildIntent{context}); }
// Guild charter (Phase A.2, GUILD_PLAN.md). Wrapped in GuildIntent
// to keep the master IntentBody variant size bounded — see
// feedback_intent_variant_capacity.md.
bool buy_guild_charter(ObjectGuid petitioner, std::string const& guild_name)
{ return emit(GuildIntent{GuildOp::BuyCharter{petitioner, guild_name}}); }
bool sign_guild_charter(uint64 petition_item_low)
{ return emit(GuildIntent{GuildOp::SignCharter{petition_item_low}}); }
bool turnin_guild_charter(ObjectGuid petitioner, uint64 petition_item_low)
{ return emit(GuildIntent{GuildOp::TurnInCharter{petitioner, petition_item_low}}); }
// Phase B: officer (emitter) recruits `target_low` into emitter's guild.
bool recruit_to_guild(uint64 target_guid_low)
{ return emit(GuildIntent{GuildOp::RecruitTarget{target_guid_low}}); }
// Phase E.1: officer posts trade-channel recruit message.
bool guild_recruit_channel_post()
{ return emit(GuildIntent{GuildOp::RecruitChannelPost{}}); }
bool enter_vehicle(ObjectGuid vehicle, int8 seat_id = -1)
{ return emit(EnterVehicleIntent{vehicle, seat_id}); }
bool exit_vehicle() { return emit(ExitVehicleIntent{}); }
bool cast_vehicle(uint32 spell_id, ObjectGuid target = ObjectGuid::Empty)
{ return emit(VehicleSpellIntent{spell_id, target}); }
bool cast_vehicle_at(uint32 spell_id, float x, float y, float z)
{ return emit(VehicleGroundSpellIntent{spell_id, x, y, z}); }
bool accept_quest(ObjectGuid giver, uint32 quest_id)
{ return emit(QuestAcceptIntent{giver, quest_id}); }
bool complete_quest(ObjectGuid giver, uint32 quest_id, uint8 reward_choice = 0)
{ return emit(QuestCompleteIntent{giver, quest_id, reward_choice}); }
bool abandon_quest(uint32 quest_id) { return emit(QuestAbandonIntent{quest_id}); }
bool accept_shared_quest() { return emit(QuestSharedAcceptIntent{}); }
bool resolve_junk_quests() { return emit(ResolveJunkQuestsIntent{}); }
// Mail. Mailbox guid is a GameObject (mailbox) or Creature (mailbox NPC);
// bot must already be in interact range (use_object/move_to first).
bool mail_take_money(ObjectGuid mailbox, uint64 mail_id)
{ return emit(MailIntent{MailTakeMoneyIntent{mailbox, mail_id}}); }
bool mail_take_item(ObjectGuid mailbox, uint64 mail_id, uint64 item_guid_low)
{ return emit(MailIntent{MailTakeItemIntent{mailbox, mail_id, item_guid_low}}); }
bool mail_delete(ObjectGuid mailbox, uint64 mail_id)
{ return emit(MailIntent{MailDeleteIntent{mailbox, mail_id}}); }
// Trainer. Bot must be in interact range with the trainer NPC.
bool trainer_buy_spell(ObjectGuid trainer, uint32 spell_id)
{ return emit(TrainerBuySpellIntent{trainer, spell_id}); }
bool trainer_buy_all(ObjectGuid trainer)
{ return emit(TrainerBuyAllIntent{trainer}); }
// Bank. Source slot is in inventory for deposit, in bank for withdraw.
bool bank_deposit_item(ObjectGuid banker, uint8 bag, uint8 slot)
{ return emit(BankDepositItemIntent{banker, bag, slot}); }
bool bank_withdraw_item(ObjectGuid banker, uint8 bag, uint8 slot)
{ return emit(BankWithdrawItemIntent{banker, bag, slot}); }
// Hearth bind. Innkeeper must be in interact range.
bool bind_homebind(ObjectGuid innkeeper)
{ return emit(BindHomebindIntent{innkeeper}); }
// Taxi. discover triggers the "I am here" learn-this-node packet;
// fly_to triggers the multi-hop route activation. Bot must be in
// interact range with the flight master NPC.
bool discover_taxi_node(ObjectGuid flight_master)
{ return emit(DiscoverTaxiNodeIntent{flight_master}); }
bool fly_to_node(ObjectGuid flight_master, uint32 to_node)
{ return emit(FlyToNodeIntent{flight_master, to_node}); }
// Auction House. run_time_minutes is one of {720, 1440, 2880}.
bool auction_sell_item(ObjectGuid auctioneer, ObjectGuid item_guid,
uint64 min_bid, uint64 buyout, uint32 run_time_minutes = 1440)
{ return emit(AuctionIntent{AuctionSellItemIntent{auctioneer, item_guid, min_bid, buyout, run_time_minutes}}); }
bool auction_cancel(ObjectGuid auctioneer, uint32 auction_id)
{ return emit(AuctionIntent{AuctionCancelIntent{auctioneer, auction_id}}); }
bool auction_cancel_all(ObjectGuid auctioneer)
{ return emit(AuctionIntent{AuctionCancelAllIntent{auctioneer}}); }
// Auction House BUY-side (#4B). Wrapped in EconomyIntent. Both apply a
// per-auction_id dedup (BotAI::ActionKind::AhBuyout / AhBid, 30s) so an
// economy rule doesn't re-emit the same buyout/bid every snapshot tick
// before the executor settles it — that would double-spend gold and
// race the on-demand AH snapshot rebuild. Definitions live in
// BotIntentEmitter.cpp (need BotAI for the dedup, like cast/move_to).
// `price` / `bid` are copper; the server-side API re-validates against
// the live auction (still exists, not own, enough gold, silver-aligned).
bool ah_buyout(ObjectGuid auctioneer, uint32 auction_id, uint64 price);
bool ah_bid(ObjectGuid auctioneer, uint32 auction_id, uint64 bid);
// Commodity buy (stackable trade-good reagents). Dedup is per ITEM_ENTRY
// (BotAI::ActionKind::AhBuyCommodity, 30s) — commodities aggregate many
// listings into one bucket, so the rule wants one buy per reagent per
// visit, not one per underlying auction. `max_total` is the slippage-
// guarded ceiling (unit_price*qty + margin); the server-side API re-quotes
// the live bucket and refuses if the total exceeds it or the bot can't pay.
bool ah_buy_commodity(ObjectGuid auctioneer, uint32 item_entry,
uint32 quantity, uint64 max_total);
// #4B-2(a): fulfil a claimed craft order — craft the product, mail it to
// the requester, release the escrow. Wrapped in EconomyIntent. The crafter
// must already OWN the order (status Claimed via CraftOrderBoard); the
// claimed order's fields come from BotSnapshot::craft_orders.claimed_*.
bool craft_fulfill(uint64 order_id, uint32 spell_id, uint32 item_entry,
uint32 qty, uint64 requester_low)
{ return emit(EconomyIntent{EconomyOp::CraftFulfill{order_id, spell_id, item_entry, qty, requester_low}}); }
// #4B-2(a) part 2: post a craft order (escrow debited world-thread by the
// executor via CraftOrderBoard::PostOrder) / claim the oldest known-recipe
// open order (world-thread ClaimOpenOrder). Both run server-side so the
// escrow + spellbook + fleet-bot firewall checks stay on the world thread.
bool craft_post(uint32 spell_id, uint32 item_entry, uint32 quantity, uint64 payment)
{ return emit(EconomyIntent{EconomyOp::CraftPost{spell_id, item_entry, quantity, payment}}); }
bool craft_claim()
{ return emit(EconomyIntent{EconomyOp::CraftClaim{}}); }
// Battleground / arena queue. `arena_type` 0 = battleground (solo or
// group-leader), 2/3/5 = arena skirmish bracket (group-leader only).
bool bg_queue(ObjectGuid battlemaster, uint16 bg_type_id, uint8 arena_type = 0)
{ return emit(QueueIntent{BgQueueIntent{battlemaster, bg_type_id, arena_type}}); }
bool bg_leave() { return emit(QueueIntent{BgLeaveIntent{}}); }
bool bg_port(uint16 bg_type_id, bool accept = true)
{ return emit(QueueIntent{BgPortIntent{bg_type_id, accept}}); }
// Talents — apply Blizzard's curated starter build for the bot's spec.
bool apply_starter_talents() { return emit(ApplyStarterTalentsIntent{}); }
// Loot roll. vote_type: 0=Pass, 1=Need, 2=Greed, 3=Disenchant.
bool loot_roll(ObjectGuid loot_object, uint8 list_id, uint8 vote_type)
{ return emit(LootRollIntent{loot_object, list_id, vote_type}); }
// LFG / vendor / inventory shorthands
bool lfg_queue(uint32 dungeon_id, Role role)
{ return emit(QueueIntent{LfgQueueIntent{dungeon_id, role}}); }
bool lfg_unqueue() { return emit(QueueIntent{LfgUnqueueIntent{}}); }
bool lfg_proposal_respond(uint32 proposal_id, bool accept = true)
{ return emit(QueueIntent{LfgProposalRespondIntent{proposal_id, accept}}); }
bool lfg_role_check(uint8 roles)
{ return emit(QueueIntent{LfgRoleCheckIntent{roles}}); }
bool vendor_buy(ObjectGuid vendor, uint8 vendor_slot, uint8 count = 1)
{ return emit(VendorIntent{VendorBuyIntent{vendor, vendor_slot, count}}); }
bool vendor_sell(ObjectGuid vendor, uint8 bag, uint8 slot, uint8 count = 0)
{ return emit(VendorIntent{VendorSellIntent{vendor, bag, slot, count}}); }
bool vendor_sell_trash(ObjectGuid vendor)
{ return emit(VendorIntent{VendorSellTrashIntent{vendor}}); }
bool repair_all(ObjectGuid vendor, bool from_guild_bank = false)
{ return emit(VendorIntent{RepairAllIntent{vendor, from_guild_bank}}); }
bool vendor_buy_category(ObjectGuid vendor, uint8 item_class, uint8 item_subclass, uint8 total_count)
{ return emit(VendorIntent{VendorBuyByCategoryIntent{vendor, item_class, item_subclass, total_count}}); }
bool equip_item(uint8 from_bag, uint8 from_slot, uint8 to_slot)
{ return emit(EquipItemIntent{from_bag, from_slot, to_slot}); }
private:
bool push(Intent i);
IntentQueue* queue_;
BotId bot_id_;
SnapshotVer source_;
IntentId* next_id_;
BotAI* ai_ = nullptr; // optional; null = no per-bot dedup
uint32 emitted_count_ = 0; // intents actually pushed (see emitted_count())
};
} // namespace Playerbot
@@ -0,0 +1,756 @@
// BotIntentExecutor - Variant visitor that turns Intents into PlayerbotAPI
// calls on the world thread. Lives in the module-side because the variant
// shape is module-internal; the API itself stays POD.
#include "BotIntent.h"
#include "BotAI.h"
#include "BotRegistry.h"
#include "../Services.h"
#include "../Fleet/BotGuildCharter.h"
#include "../Fleet/BotGuildMgr.h"
#include "../Fleet/CraftOrderBoard.h"
#include "../Fleet/JunkQuestResolver.h"
#include "../Threading/IntentQueue.h"
#include "../PlayerbotV2.h"
#include "../Diagnostics/PerfCounters.h"
#include "PlayerbotAPI.h"
#include "Channel.h"
#include "ChannelMgr.h"
#include "Creature.h"
#include "Guild.h"
#include "GuildMgr.h"
#include "Player.h"
#include "ObjectAccessor.h"
#include "GameTime.h"
#include "Log.h"
#include <variant>
#include <fmt/format.h>
namespace Playerbot {
namespace {
// Visitor: dispatches to one of the API methods based on the active variant.
// Most intents are 1:1 with an API call; a few (Whisper / Group ops) accept
// strings.
struct IntentVisitor
{
API& api;
BotAI* bot_ai = nullptr; // optional — Combo-Strikes-aware specs read this
Result operator()(CastSpellIntent const& i)
{
Result const r = api.cast_spell(i.spell_id, i.target);
if (r == Result::Ok && bot_ai)
bot_ai->set_last_cast_spell_id(i.spell_id);
return r;
}
Result operator()(GroundTargetSpellIntent const& i) { return api.cast_spell_at_position(i.spell_id, i.x, i.y, i.z); }
Result operator()(MoveToIntent const& i)
{
if (Player* p = api.player())
{
// Optional: only block non-combat formation snaps; allow combat gap-close
// Skip this guard if combat AI relies on MoveTo for melee range.
}
return api.move_to(i.x, i.y, i.z, i.run, i.direct);
}
Result operator()(TeleportToIntent const& i) { return api.teleport_to(i.map_id, i.x, i.y, i.z, i.o); }
Result operator()(StopMovementIntent const& i) { return api.stop_movement(i.clear_generators); }
Result operator()(JumpIntent const& i) { return api.jump(i.forward); }
Result operator()(HearthIntent const&) { return api.hearth(); }
Result operator()(StartAttackIntent const& i) { return api.start_attack(i.target); }
Result operator()(StopAttackIntent const& i) { return api.stop_attack(i.clear_ghost_combat); }
Result operator()(PetAttackIntent const& i) { return api.pet_attack(i.target); }
Result operator()(PetCastSpellIntent const& i) { return api.pet_cast(i.spell_id, i.target); }
Result operator()(DismissPetIntent const&) { return api.dismiss_pet(); }
Result operator()(SetStandStateIntent const& i) { return api.set_stand_state(i.stand_state); }
// SayChat/YellChat/EmoteChat/GuildChat/RaidChat/OfficerChat/RaidWarning/
// Whisper/PartyChat are bundled into `ChatIntent`. See operator()(ChatIntent).
Result operator()(UnstuckIntent const& i) { return api.unstuck(i.distance); }
Result operator()(NearTeleportToIntent const& i) { return api.near_teleport_to(i.x, i.y, i.z, i.o); }
Result operator()(CancelAuraIntent const& i) { return api.cancel_aura(i.spell_id); }
Result operator()(CancelCastIntent const&) { return api.cancel_cast(); }
Result operator()(FollowIntent const& i)
{
// Safety net: never apply follow while the bot is fighting
if (Player* p = api.player()) // rename if your API uses player() / GetBot()
{
if (p->IsInCombat() ||
(p->GetVictim() && p->GetVictim()->IsAlive()))
{
TC_LOG_ERROR("playerbot.v2",
"[AltInterrupt] DROP FollowIntent (in combat) bot={}",
p->GetName());
return Result::Ok; // swallow — do not move
}
}
return api.follow(i.leader, i.distance, i.angle_radians);
}
Result operator()(DismountIntent const&) { return api.dismount(); }
Result operator()(MountIntent const& i) { return api.mount(i.mount_id); }
// PartyChat / Whisper bundled into ChatIntent.
Result operator()(ReleaseCorpseIntent const&) { return api.release_corpse(); }
Result operator()(ReviveAtCorpseIntent const&) { return api.revive_at_corpse(); }
Result operator()(ReclaimCorpseIntent const&) { return api.reclaim_corpse(); }
Result operator()(SpiritResurrectIntent const&) { return api.spirit_resurrect(); }
Result operator()(AcceptRezIntent const&) { return api.accept_rez(); }
Result operator()(GroupAcceptIntent const&) { return api.accept_group_invite(); }
Result operator()(GroupDeclineIntent const&) { return api.decline_group_invite(); }
Result operator()(InviteToGroupIntent const& i) { return api.invite_to_group(i.target); }
Result operator()(GroupReadyResponseIntent const& i){ return api.group_ready_response(i.ready); }
Result operator()(GroupLeaveIntent const&) { return api.leave_group(); }
Result operator()(GroupPromoteToLeaderIntent const& i) { return api.promote_to_leader(i.new_leader_guid); }
Result operator()(GroupKickMemberIntent const& i) { return api.kick_group_member(i.member_guid); }
Result operator()(GroupConvertToRaidIntent const&) { return api.convert_to_raid(); }
Result operator()(GroupStartReadyCheckIntent const&) { return api.start_ready_check(); }
Result operator()(GroupSetAssistantIntent const& i) { return api.set_assistant(i.member_guid, i.assistant); }
Result operator()(ResetInstancesIntent const&) { return api.reset_instances(); }
Result operator()(GuildAcceptInviteIntent const&) { return api.accept_guild_invite(); }
Result operator()(GuildDeclineInviteIntent const&) { return api.decline_guild_invite(); }
Result operator()(GuildLeaveIntent const&) { return api.leave_guild(); }
Result operator()(ToggleAfkIntent const&) { return api.toggle_afk(); }
Result operator()(ToggleDndIntent const&) { return api.toggle_dnd(); }
Result operator()(SetDungeonDifficultyIntent const& i) { return api.set_dungeon_difficulty(i.difficulty_id); }
Result operator()(SetRaidDifficultyIntent const& i) { return api.set_raid_difficulty(i.difficulty_id, i.legacy); }
// OfficerChat / RaidWarning bundled into ChatIntent.
Result operator()(PerformEmoteIntent const& i) { return api.perform_emote(i.emote_id, i.target); }
Result operator()(FaceTargetIntent const& i) { return api.face_target(i.target); }
// VendorBuyByEntry / VendorBuy / VendorSell / VendorSellTrash / RepairAll /
// VendorBuyByCategory bundled into VendorIntent.
Result operator()(TogglePvpIntent const&) { return api.toggle_pvp(); }
Result operator()(AddFriendIntent const& i) { return api.add_friend(i.name, i.note); }
Result operator()(RemoveFriendIntent const& i) { return api.remove_friend(i.friend_guid); }
Result operator()(AddIgnoreIntent const& i) { return api.add_ignore(i.name); }
Result operator()(RemoveIgnoreIntent const& i) { return api.remove_ignore(i.ignore_guid); }
// MailSendMoney / MailSendItem / MailTakeMoney / MailTakeItem / MailDelete
// bundled into MailIntent.
Result operator()(CalendarRsvpAllIntent const& i) { return api.calendar_rsvp_all_pending(i.accept); }
// SwapPetToSlot / DeleteStabledPet / SummonPetByNumber / FeedPet /
// AbandonPet / PetSetReactState / PetSetCommandState / RenamePet /
// PetToggleAutocast bundled into HunterPetIntent.
Result operator()(GuildBankDepositMoneyIntent const& i) { return api.guild_bank_deposit_money(i.banker, i.amount); }
Result operator()(GuildBankWithdrawMoneyIntent const& i) { return api.guild_bank_withdraw_money(i.banker, i.amount); }
Result operator()(GuildBankDepositItemIntent const& i) { return api.guild_bank_deposit_item(i.banker, i.tab, i.bank_slot, i.player_bag, i.player_slot, i.count); }
Result operator()(GuildBankWithdrawItemIntent const& i) { return api.guild_bank_withdraw_item(i.banker, i.tab, i.bank_slot, i.player_bag, i.player_slot, i.count); }
Result operator()(ShareQuestIntent const& i) { return api.share_quest_with_party(i.quest_id); }
// PetSetReactState / PetSetCommandState / RenamePet / PetToggleAutocast
// bundled into HunterPetIntent.
Result operator()(SummonAcceptIntent const&) { return api.accept_summon(); }
Result operator()(SummonDeclineIntent const&) { return api.decline_summon(); }
Result operator()(DuelDeclineIntent const&) { return api.decline_duel(); }
Result operator()(DuelAcceptIntent const&) { return api.accept_duel(); }
Result operator()(SetRaidTargetIconIntent const& i){ return api.set_raid_target_icon(i.symbol, i.target); }
Result operator()(ActivateSpecIntent const& i) { return api.activate_spec(i.spec_id); }
Result operator()(ResetCooldownsIntent const&) { return api.reset_all_cooldowns(); }
Result operator()(TradeDeclineIntent const&) { return api.decline_trade(); }
Result operator()(UseItemByEntryIntent const& i) { return api.use_item_by_entry(i.item_entry, i.target); }
Result operator()(UseItemIntent const& i) { return api.use_item_by_slot(i.bag, i.slot, i.target); }
Result operator()(LootIntent const& i) { return api.loot_corpse(i.corpse_or_object); }
Result operator()(InteractWithNpcIntent const& i) { return api.interact_with_npc(i.npc); }
Result operator()(GossipSelectIntent const& i) { return api.gossip_select_by_index(i.npc, i.option); }
Result operator()(UseObjectIntent const& i) { return api.use_game_object(i.object); }
Result operator()(CastSpellOnItemIntent const& i) { return api.cast_spell_on_item(i.spell_id, i.item_guid); }
Result operator()(ApplyTalentBuildIntent const& i) { return api.apply_talent_build(i.context); }
Result operator()(EnterVehicleIntent const& i) { return api.enter_vehicle(i.vehicle, i.seat_id); }
Result operator()(ExitVehicleIntent const&) { return api.exit_vehicle(); }
Result operator()(VehicleSpellIntent const& i) { return api.cast_vehicle_spell(i.spell_id, i.target); }
Result operator()(VehicleGroundSpellIntent const& i) { return api.cast_vehicle_spell_at(i.spell_id, i.x, i.y, i.z); }
Result operator()(QuestAcceptIntent const& i) { return api.accept_quest(i.npc, i.quest_id); }
Result operator()(QuestCompleteIntent const& i) { return api.complete_quest(i.npc, i.quest_id, i.reward_choice); }
Result operator()(QuestAbandonIntent const& i) { return api.abandon_quest(i.quest_id); }
Result operator()(QuestSharedAcceptIntent const&) { return api.accept_shared_quest(); }
Result operator()(ResolveJunkQuestsIntent const&)
{
// Force-complete auto-push feature quests + resolve profession-spec
// choices on the live Player (world thread). Policy lives in the module.
auto r = Playerbot::V2::Fleet::JunkQuestResolver::RunFor(api.player());
return (r.rewarded || r.abandoned) ? Result::Ok : Result::Other;
}
// MailTakeMoney / MailTakeItem / MailDelete bundled into MailIntent.
Result operator()(TrainerBuySpellIntent const& i) { return api.trainer_buy_spell(i.trainer_npc, i.spell_id); }
Result operator()(TrainerBuyAllIntent const& i) { return api.trainer_buy_all_available(i.trainer_npc); }
Result operator()(BankDepositItemIntent const& i) { return api.bank_deposit_item(i.banker, i.bag, i.slot); }
Result operator()(BankWithdrawItemIntent const& i) { return api.bank_withdraw_item(i.banker, i.bag, i.slot); }
Result operator()(DiscoverTaxiNodeIntent const& i) { return api.discover_taxi_node(i.flight_master); }
Result operator()(FlyToNodeIntent const& i) { return api.fly_to_node(i.flight_master, i.to_node); }
// AuctionSellItem / AuctionCancel / AuctionCancelAll bundled into AuctionIntent.
// BgQueue / BgLeave / BgPort / LfgQueue / LfgUnqueue / LfgProposalRespond /
// LfgRoleCheck bundled into QueueIntent.
Result operator()(ApplyStarterTalentsIntent const&){ return api.apply_starter_talents(); }
Result operator()(LootRollIntent const& i) { return api.loot_roll(i.loot_object, i.loot_list_id, i.vote_type); }
Result operator()(BindHomebindIntent const& i) { return api.bind_homebind(i.innkeeper); }
Result operator()(EquipItemIntent const& i) { return api.equip_item(i.from_bag, i.from_slot, i.to_slot); }
// ---- Subsystem-wrapper handlers (variant-pressure relief) ----
//
// Each wrapper dispatches its inner variant via `std::visit` +
// `if constexpr` — same pattern as `GuildIntent`. Keeps IntentBody
// bounded; see feedback_intent_variant_capacity.md.
Result operator()(ChatIntent const& ci)
{
return std::visit([&](auto const& op) -> Result {
using T = std::decay_t<decltype(op)>;
if constexpr (std::is_same_v<T, SayChatIntent>) return api.say(op.text);
else if constexpr (std::is_same_v<T, YellChatIntent>) return api.yell(op.text);
else if constexpr (std::is_same_v<T, EmoteChatIntent>) return api.emote_text(op.text);
else if constexpr (std::is_same_v<T, GuildChatIntent>) return api.guild_chat(op.text);
else if constexpr (std::is_same_v<T, RaidChatIntent>) return api.raid_chat(op.text);
else if constexpr (std::is_same_v<T, OfficerChatIntent>) return api.officer_chat(op.text);
else if constexpr (std::is_same_v<T, RaidWarningIntent>) return api.raid_warning(op.text);
else if constexpr (std::is_same_v<T, WhisperIntent>) return api.whisper(op.target, op.text);
else if constexpr (std::is_same_v<T, PartyChatIntent>) return api.party_chat(op.text);
else return Result::Other;
}, ci.op);
}
Result operator()(MailIntent const& mi)
{
return std::visit([&](auto const& op) -> Result {
using T = std::decay_t<decltype(op)>;
if constexpr (std::is_same_v<T, MailTakeMoneyIntent>) return api.mail_take_money(op.mailbox, op.mail_id);
else if constexpr (std::is_same_v<T, MailTakeItemIntent>) return api.mail_take_item(op.mailbox, op.mail_id, op.item_guid_low);
else if constexpr (std::is_same_v<T, MailDeleteIntent>) return api.mail_delete(op.mailbox, op.mail_id);
else if constexpr (std::is_same_v<T, MailSendMoneyIntent>) return api.mail_send_money(op.recipient, op.copper, op.subject, op.body);
else if constexpr (std::is_same_v<T, MailSendItemIntent>) return api.mail_send_item(op.recipient, op.item_guid, op.count, op.copper, op.cod, op.subject, op.body);
else return Result::Other;
}, mi.op);
}
Result operator()(AuctionIntent const& ai)
{
return std::visit([&](auto const& op) -> Result {
using T = std::decay_t<decltype(op)>;
if constexpr (std::is_same_v<T, AuctionSellItemIntent>) return api.auction_sell_item(op.auctioneer, op.item_guid, op.min_bid, op.buyout, op.run_time_minutes);
else if constexpr (std::is_same_v<T, AuctionCancelIntent>) return api.auction_cancel(op.auctioneer, op.auction_id);
else if constexpr (std::is_same_v<T, AuctionCancelAllIntent>) return api.auction_cancel_all(op.auctioneer);
else return Result::Other;
}, ai.op);
}
Result operator()(VendorIntent const& vi)
{
return std::visit([&](auto const& op) -> Result {
using T = std::decay_t<decltype(op)>;
if constexpr (std::is_same_v<T, VendorBuyIntent>) return api.vendor_buy_by_slot(op.npc, op.vendor_slot, op.count);
else if constexpr (std::is_same_v<T, VendorSellIntent>) return api.sell_item_by_slot(op.npc, op.bag, op.slot, op.count);
else if constexpr (std::is_same_v<T, VendorSellTrashIntent>) return api.sell_trash(op.npc);
else if constexpr (std::is_same_v<T, RepairAllIntent>) return api.repair_all(op.npc, op.from_guild_bank);
else if constexpr (std::is_same_v<T, VendorBuyByCategoryIntent>) return api.vendor_buy_by_category(op.npc, op.item_class, op.item_subclass, op.total_count);
else if constexpr (std::is_same_v<T, VendorBuyByEntryIntent>) return api.vendor_buy_by_entry(op.npc, op.item_entry, op.count);
else return Result::Other;
}, vi.op);
}
Result operator()(HunterPetIntent const& hpi)
{
return std::visit([&](auto const& op) -> Result {
using T = std::decay_t<decltype(op)>;
if constexpr (std::is_same_v<T, SwapPetToSlotIntent>) return api.swap_pet_to_slot(op.pet_number, op.dst_slot);
else if constexpr (std::is_same_v<T, DeleteStabledPetIntent>) return api.delete_stabled_pet(op.pet_number);
else if constexpr (std::is_same_v<T, SummonPetByNumberIntent>) return api.summon_pet_by_number(op.pet_number);
else if constexpr (std::is_same_v<T, FeedPetIntent>) return api.feed_pet(op.food_item_entry);
else if constexpr (std::is_same_v<T, AbandonPetIntent>) return api.abandon_pet();
else if constexpr (std::is_same_v<T, PetSetReactStateIntent>) return api.pet_set_react_state(op.state);
else if constexpr (std::is_same_v<T, PetSetCommandStateIntent>) return api.pet_set_command_state(op.command);
else if constexpr (std::is_same_v<T, RenamePetIntent>) return api.rename_pet(op.new_name);
else if constexpr (std::is_same_v<T, PetToggleAutocastIntent>) return api.pet_toggle_autocast(op.spell_id, op.enabled);
else return Result::Other;
}, hpi.op);
}
Result operator()(QueueIntent const& qi)
{
return std::visit([&](auto const& op) -> Result {
using T = std::decay_t<decltype(op)>;
if constexpr (std::is_same_v<T, BgQueueIntent>) return api.bg_queue(op.battlemaster, op.bg_type_id, op.arena_type);
else if constexpr (std::is_same_v<T, BgLeaveIntent>) return api.bg_leave();
else if constexpr (std::is_same_v<T, BgPortIntent>) return api.bg_port(op.bg_type_id, op.accept);
else if constexpr (std::is_same_v<T, LfgQueueIntent>)
{
// Role enum → LFG role bitmask. PLAYER_ROLE_LEADER (1) is OR'd
// in unconditionally — solo queueing requires the bot to be its
// own leader so role-check passes; in a group, the bit is harmless.
// Without LEADER, sLFGMgr->JoinLfg silently rejects the entry.
uint8 lfg_role = /*PLAYER_ROLE_LEADER*/ 1;
switch (op.role)
{
case Role::Tank: lfg_role |= /*TANK*/ 2; break;
case Role::Healer: lfg_role |= /*HEALER*/ 4; break;
case Role::Dps: lfg_role |= /*DAMAGE*/ 8; break;
default: break;
}
return api.lfg_queue(op.dungeon_or_bg_id, lfg_role);
}
else if constexpr (std::is_same_v<T, LfgUnqueueIntent>) return api.lfg_leave_queue();
else if constexpr (std::is_same_v<T, LfgProposalRespondIntent>) return api.lfg_proposal_respond(op.proposal_id, op.accept);
else if constexpr (std::is_same_v<T, LfgRoleCheckIntent>) return api.lfg_role_check(op.roles);
else return Result::Other;
}, qi.op);
}
// ---- Economy subsystem (#4B buy-side AH + future craft-orders) ----
//
// Single master-variant entry; the inner variant dispatches per op.
// The API methods re-validate server-side (auctioneer in range, auction
// still exists, not own auction, enough gold). See
// feedback_intent_variant_capacity.md.
Result operator()(EconomyIntent const& ei)
{
return std::visit([&](auto const& op) -> Result {
using T = std::decay_t<decltype(op)>;
if constexpr (std::is_same_v<T, EconomyOp::AhBuyout>) return api.auction_buyout(op.auctioneer, op.auction_id, op.price);
else if constexpr (std::is_same_v<T, EconomyOp::AhBid>) return api.auction_bid(op.auctioneer, op.auction_id, op.bid);
else if constexpr (std::is_same_v<T, EconomyOp::AhBuyCommodity>) return api.auction_buy_commodity(op.auctioneer, op.item_entry, op.quantity, op.max_total_price);
else if constexpr (std::is_same_v<T, EconomyOp::CraftFulfill>)
{
// #4B-2(a): craft the product + mail it to the requester via the
// (core-side) API, then RELEASE the escrow via the (module-side)
// board. The API does the craft mechanics only (no module
// dependency); the board owns the escrow. We only mark delivered
// when the craft+mail actually succeeded, so a failed craft
// leaves the order Claimed (to retry or time out) with the
// escrow still held — gold is never paid for an undelivered
// order. MarkDelivered re-verifies ownership + the human-
// firewall and is the single one-time release point.
Player* bot = api.player();
const uint64 crafter_low = bot ? bot->GetGUID().GetCounter() : 0;
Result const r = api.craft_fulfill_order(op.order_id, op.spell_id,
op.item_entry, op.qty, op.requester_low);
if (r == Result::Ok && crafter_low != 0)
Services::CraftOrders().MarkDelivered(op.order_id, crafter_low);
return r;
}
else if constexpr (std::is_same_v<T, EconomyOp::CraftPost>)
{
// #4B-2(a) part 2: POST a craft order on the world thread. The
// board debits the escrow atomically with the row write and
// re-verifies the requester is a fleet bot (human-firewall).
// PostOrder returns 0 on refusal (not a fleet bot / not in world
// / can't afford / bad args); map that to ServerRefused so the
// rule's dedup lockout still arms (no per-tick re-post spam) while
// a successful post (id != 0) reports Ok.
Player* bot = api.player();
const uint64 requester_low = bot ? bot->GetGUID().GetCounter() : 0;
if (requester_low == 0) return Result::ServerRefused;
const uint64 id = Services::CraftOrders().PostOrder(
requester_low, op.spell_id, op.item_entry, op.quantity, op.payment);
return id != 0 ? Result::Ok : Result::ServerRefused;
}
else if constexpr (std::is_same_v<T, EconomyOp::CraftClaim>)
{
// #4B-2(a) part 2: CLAIM the oldest Open order whose recipe this
// bot knows. ClaimOpenOrder re-verifies the live spellbook +
// fleet-bot status world-thread and flips the row to Claimed; the
// claimed order then surfaces in the next snapshot's claimed_*
// fields (which the fulfil rule turns into CraftFulfill). A
// returned order with id == 0 means nothing was claimable.
Player* bot = api.player();
const uint64 crafter_low = bot ? bot->GetGUID().GetCounter() : 0;
if (crafter_low == 0) return Result::ServerRefused;
V2::CraftOrder const claimed = Services::CraftOrders().ClaimOpenOrder(crafter_low);
return claimed.id != 0 ? Result::Ok : Result::ServerRefused;
}
else return Result::Other;
}, ei.op);
}
// ---- Guild subsystem (Phase A.2 charter + future B/C/D/E) ----
//
// Single master-variant entry; the inner variant dispatches per
// operation. See feedback_intent_variant_capacity.md for the
// rationale (MSVC heap exhaustion at ~120 IntentBody alternatives).
Result operator()(GuildIntent const& gi)
{
Player* bot = api.player();
if (!bot) return Result::ServerRefused;
return std::visit([&](auto const& op) -> Result {
using T = std::decay_t<decltype(op)>;
if constexpr (std::is_same_v<T, GuildOp::BuyCharter>)
{
Creature* npc = ObjectAccessor::GetCreature(*bot, op.petitioner_npc);
if (!npc) return Result::InvalidTarget;
uint64 charter_low = 0;
const auto r = V2::BotBuyGuildCharter(bot, npc, op.guild_name, charter_low);
if (r != V2::CharterBuyResult::Ok)
return Result::ServerRefused;
if (BotAI* ai = Services::Registry().ai(bot->GetGUID().GetCounter()))
ai->set_guild_charter_petition_low(charter_low);
const auto faction = (Player::TeamForRace(bot->GetRace()) == ALLIANCE)
? V2::BotGuildMgr::FACTION_ALLIANCE : V2::BotGuildMgr::FACTION_HORDE;
Services::Guilds().SetActiveFounderPetitionLow(faction, charter_low);
return Result::Ok;
}
else if constexpr (std::is_same_v<T, GuildOp::SignCharter>)
{
const auto r = V2::BotSignGuildCharter(bot, op.petition_item_low);
if (r != V2::CharterSignResult::Ok)
return Result::ServerRefused;
return Result::Ok;
}
else if constexpr (std::is_same_v<T, GuildOp::TurnInCharter>)
{
Creature* npc = ObjectAccessor::GetCreature(*bot, op.petitioner_npc);
if (!npc) return Result::InvalidTarget;
uint64 guild_id = 0;
const auto r = V2::BotTurnInGuildCharter(bot, npc, op.petition_item_low, guild_id);
if (r != V2::CharterTurnInResult::Ok)
return Result::ServerRefused;
if (BotAI* ai = Services::Registry().ai(bot->GetGUID().GetCounter()))
{
const std::string name = ai->guild_charter_name();
const auto faction = (Player::TeamForRace(bot->GetRace()) == ALLIANCE)
? V2::BotGuildMgr::FACTION_ALLIANCE : V2::BotGuildMgr::FACTION_HORDE;
Services::Guilds().OnCharterSucceeded(faction,
bot->GetGUID().GetCounter(), guild_id, name);
ai->advance_guild_charter_phase(0xFF);
}
// The founder is now a member too — track in
// bot_guild_member_meta so Phase B hygiene knows their
// join date (the founder isn't going through the normal
// recruit path).
Services::Guilds().OnBotJoinedGuild(guild_id, bot->GetGUID().GetCounter());
return Result::Ok;
}
else if constexpr (std::is_same_v<T, GuildOp::RecruitTarget>)
{
ObjectGuid target_guid = ObjectGuid::Create<HighGuid::Player>(op.target_guid_low);
Player* target = ObjectAccessor::FindConnectedPlayer(target_guid);
if (!target) return Result::InvalidTarget;
uint64 gid = 0;
const auto r = V2::BotRecruitToGuild(bot, target, gid);
if (r != V2::RecruitResult::Ok)
return Result::ServerRefused;
// Stamp join-date for hygiene.
Services::Guilds().OnBotJoinedGuild(gid, op.target_guid_low);
return Result::Ok;
}
else if constexpr (std::is_same_v<T, GuildOp::RecruitChannelPost>)
{
// Phase E.1: post "<Guild> recruiting all levels, /w
// <officer> for invite" into the bot's currently-joined
// Trade channel. Skip silently when the bot has no
// Trade channel (e.g. leveling in the open world).
const uint64 gid = bot->GetGuildId();
if (gid == 0) return Result::Other;
Guild* g = sGuildMgr->GetGuildById(gid);
if (!g) return Result::Other;
ChannelMgr* cMgr = ChannelMgr::ForTeam(bot->GetTeam());
if (!cMgr) return Result::Other;
Channel* ch = cMgr->GetChannelForPlayerByNamePart("Trade", bot);
if (!ch) return Result::Other;
std::string msg = fmt::format(
"<{}> recruiting all levels, friendly social guild — /w {} for invite",
g->GetName(), bot->GetName());
ch->Say(bot->GetGUID(), msg, /*lang*/ 0 /*LANG_UNIVERSAL — channel system overrides per-channel*/);
return Result::Ok;
}
else
{
return Result::Other;
}
}, gi.op);
}
// Until the corresponding API methods are added in subsequent iterations,
// unhandled intents silently no-op. They'll fail unit tests for whatever
// subsystem expected them, surfacing the gap there rather than here.
template <class T> Result operator()(T const&) { return Result::Other; }
};
} // anonymous
size_t V2::Module::DrainIntents()
{
if (!Services::Initialized()) return 0;
auto& reg = Services::Registry();
// Tick budgets:
// - Global cap stops the executor from monopolising the world tick
// even at 100+ bots all firing intents.
// - Per-bot cap keeps one runaway bot from draining the global cap
// before later bots in registry order get their turn. With 64/bot
// and a typical 1-3 intents per tick from a healthy bot, the cap
// only bites when a backlog has built up; the next world tick
// drains the rest.
constexpr size_t kIntentBudgetPerWorldTick = 4096;
constexpr size_t kIntentBudgetPerBotPerTick = 64;
size_t executed = 0;
// Open the per-tick bot-PATHFINDING budget window (distinct from the intent
// COUNT budgets above — those don't bound Detour wall-time, which is what hung
// the world thread 87s on 2026-06-17 when the quest funnel was raised). Each
// synchronous pathfind site (API::move_to, Charge/Leap casts, near_teleport)
// checks PathBudget::HasBudget and DEFERS (Result::Locked, retried next tick)
// once this window is spent, bounding aggregate world-thread Detour per tick.
// Closed (EndWorldTick) at function exit so the budget never leaks into
// non-DrainIntents callers. 15ms is generous vs the natural re-path rate.
constexpr uint32 kBotPathfindBudgetMs = 15;
Playerbot::PathBudget::BeginWorldTick(GameTime::GetGameTimeMS(), kBotPathfindBudgetMs);
reg.for_each([&](BotId id, BotRegistryEntry const& entry)
{
if (!entry.intents) return;
// Fast-path: cheap empty-queue probe before the expensive setup
// (GetGameTimeMS + FindConnectedPlayer + API construction). Most
// bots have an empty intents queue on any given world tick since
// DrainIntents fires at 50Hz but typical bots emit <1 intent/sec.
if (entry.intents->approximate_size() == 0)
return;
// /wait pause: skip the bot entirely until paused_until_ms <= now.
// Intents stay in the queue; they'll drain on the next world tick
// after the gate clears.
const uint32_t paused_until = entry.paused_until_ms.load(std::memory_order_relaxed);
if (paused_until && GameTime::GetGameTimeMS() < paused_until)
return;
Player* p = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!p) return;
API api(p);
Intent intent;
size_t bot_executed = 0;
const uint32_t drain_now_ms = GameTime::GetGameTimeMS();
while (executed < kIntentBudgetPerWorldTick &&
bot_executed < kIntentBudgetPerBotPerTick &&
entry.intents->pop(intent))
{
// Defer-until check. Used for human-pacing of social
// replies — the reactor composes the intent immediately
// but stamps a future-time so the visible packet doesn't
// go out until a human-like response window elapses.
// We requeue the intent so a subsequent drain catches it.
// (Slightly inefficient if many deferred intents stack up,
// but the queue is per-bot and the count is small.)
if (intent.defer_until_ms != 0 && drain_now_ms < intent.defer_until_ms)
{
entry.intents->push(std::move(intent));
break; // stop draining this bot this tick to avoid loop
}
try
{
Result r = std::visit(IntentVisitor{api, entry.ai.get()}, intent.body);
Services::Perf().record_intent_executed();
Services::Perf().record_intent_result(static_cast<size_t>(r));
if (r != Result::Ok)
Services::Perf().record_intent_failed();
// Per-target StartAttack lockout. Player::Attack returns
// false (→ Result::ServerRefused) when the target is
// immune / phased / faction-locked / vehicle-locked /
// already attacking with same melee mode etc. The AI rule
// re-emits next snapshot if we don't gate it; without this
// mark, ~5 emits/sec from a single wedged bot saturate
// the executor and stall the world thread (observed 60 s
// hang in freeze_dump_2026_05_09_17_28_14.txt, bot 87300
// L76 Paladin emitting StartAttack 32 times in 7 s).
//
// Result::InvalidTarget arms the SAME lockout. PlayerbotAPI
// rejects a StartAttack when !IsValidAttackTarget (the target
// flipped un-attackable: evading/leashed, immune, or feigned).
// The Deadmines harbor desync is exactly this: ~8 hostiles the
// tank aggroed across an off-mesh/elevated edge evade (it can't
// path to them), stay in m_attackers (contact damage holds the
// bot in combat) yet fail IsValidAttackTarget — so every tick
// the tank re-emits StartAttack, the world thread rejects
// before Player::Attack, GetVictim() stays empty and the
// in_combat flag blinds the idle boss-navigator forever. Arming
// the lockout here stops the per-tick spam AND marks the
// attacker start_attack_recently_refused, which the in-combat
// boss-advance below uses to detect "every attacker is
// unreachable → advance toward the boss instead of wedging".
if (r == Result::ServerRefused || r == Result::InvalidTarget)
{
if (auto const* sa = std::get_if<StartAttackIntent>(&intent.body))
{
if (BotAI* botai = reg.ai(id))
botai->note_start_attack_refused(
sa->target.GetCounter(),
GameTime::GetGameTimeMS());
}
}
// Path-blocked feedback for the wander rule. API::move_to
// returns Result::Locked when the navmesh refused the path
// (NoPath / FarFromPolyEnd). Bumping path_blocked_count
// shifts the wander angle bucket on the next emit so the
// bot tries a different direction immediately, rather than
// hammering the same blocked bearing for 5s. The same
// counter feeds the per-rule diagnostics surface.
if (r == Result::Ok &&
std::holds_alternative<MoveToIntent>(intent.body))
{
// Move issued a real path/spline → not wedged. Reset the
// consecutive-block tally so blocks= reflects the CURRENT
// wedge depth, not lifetime history.
if (BotAI* botai = reg.ai(id))
botai->note_move_succeeded();
}
if (r == Result::Locked &&
std::holds_alternative<MoveToIntent>(intent.body))
{
if (BotAI* botai = reg.ai(id))
{
botai->note_path_blocked(GameTime::GetGameTimeMS());
// Refusal-aware target selection: remember the EXACT
// destination the API just refused so rules skip it
// next tick instead of re-selecting the same poisoned
// spot and re-arming the API's own path-fail backoff
// forever (see BotAI::move_refused_recently header
// comment for the full failure this fixes).
if (auto const* mv_locked =
std::get_if<MoveToIntent>(&intent.body))
botai->note_move_refused(mv_locked->x, mv_locked->y,
mv_locked->z,
GameTime::GetGameTimeMS());
// Diagnostic linkage: pair the API-side path_fail
// log line with the rule that emitted the move.
// Without this we can't tell whether wander, hub-
// travel, gather-walk, or something else is the
// dominant source of off-mesh failures.
// A8 (2026-06-07): THROTTLE — this fired on EVERY Locked
// move = 27.9M lines in 4 days (top tag) and, because the
// playerbot.v2 logger also lists the Server appender, was
// double-written into the 52GB Server.log. Emit only at
// block-count milestones; the wedge depth is still legible
// and a true wedge still surfaces, without the per-tick flood.
const uint32 bc = botai->path_blocked_count();
char const* rule = botai->last_rule_fired();
// Every block at DEBUG (full per-tick detail available on
// demand via Logger.playerbot.v2=2); milestones at INFO
// (default-visible wedge signal — start + escalation —
// without the 27.9M-line per-tick flood).
if (bc == 1 || bc == 5 || bc == 20 || (bc % 100) == 0)
TC_LOG_INFO("playerbot.v2",
"[move_blocked] bot={} rule={} blocks={}",
id, rule ? rule : "(null)", bc);
else
TC_LOG_DEBUG("playerbot.v2",
"[move_blocked] bot={} rule={} blocks={}",
id, rule ? rule : "(null)", bc);
}
}
// Cast-rejected feedback. Audit 2026-05-17 found 329k
// cast rejections in 5MB Playerbot.log — top spells
// (Soulstone 20707 @ 54k, Create Healthstone 6201 @
// 47k, Revive Pet 982 @ 38k) had buff/utility rules
// emitting against a stale "target valid" view of the
// snapshot every 1.5s lockout window. The fix: when
// the rejection is PERSISTENT (broken target / unknown
// spell / anti-cheat state), back off ~10s so the rule
// picks a different action instead of hammering.
//
// CRITICAL — combat behaviour MUST NOT regress.
// * NotReady (server CD vs stale snapshot, ~200-1000ms
// resolution) → keep 1.5s lockout; the next snapshot
// picks up the real CD and the bot retries naturally.
// * OutOfRange / LoS (target stepped behind a pillar
// mid-fight, typically resolves in <1s) → keep 1.5s;
// a 10s back-off here would freeze the bot's
// follow-up casts after every minor movement.
// * NotEnoughResource (mana/power tick re-floods in
// 1-3s) → keep 1.5s; combat regen rate is fast.
//
// Long back-off ONLY for:
// * InvalidTarget — null guid / wrong target type;
// the rule's target picker is broken, retrying
// with the same selection won't help.
// * NotKnown — bot doesn't have the spell at all;
// a buff rule that fires Soulstone on a non-Warlock
// bot, etc. Permanent until learned.
// * ServerRefused — anti-cheat / state / faction reject.
// Usually a class of condition that persists (e.g.
// bot is on a vehicle, in feign-death, etc).
// * Other — uncategorized exception path. Safe to
// back off; if the underlying cause clears the
// rule re-fires after 10s.
const bool persistent_reject =
(r == Result::InvalidTarget) ||
(r == Result::NotKnown) ||
(r == Result::ServerRefused) ||
(r == Result::Other);
if (persistent_reject)
{
if (auto const* cs = std::get_if<CastSpellIntent>(&intent.body))
{
if (BotAI* botai = reg.ai(id))
botai->note_cast_rejected(cs->spell_id,
GameTime::GetGameTimeMS());
}
}
// OutOfRange feedback for the combat:opener approach logic.
// The opener cannot know each APL rule's range (melee 5y vs
// caster 40y), so it relies on the server's own verdict: a
// cast that came back OutOfRange/LoS means "you cannot reach
// your selection from here" → the opener steps toward the
// victim instead of letting the APL spam doomed casts every
// retry window forever (observed 2026-06-13: bots looping
// CastSpell|OutOfRange at ~1.6s cadence for hours). An Ok
// cast clears the counter — we're in reach again.
if (std::get_if<CastSpellIntent>(&intent.body))
{
if (BotAI* botai = reg.ai(id))
{
if (r == Result::OutOfRange)
botai->note_cast_out_of_range();
else if (r == Result::Ok)
botai->reset_cast_oor();
}
}
// Quest reward-turnin failure backoff. API::complete_quest
// returns Result::Locked when Player::CanRewardQuest fails
// (cant_reward_pre / cant_reward_post) — i.e. the reward
// can't be granted (missing/un-storable item, or full bags).
// Without a give-up path, idle:quest_turnin re-fires every
// tick on a quest stuck at QUEST_STATUS_COMPLETE (observed:
// quest 26712, 120k retries in a 300MB log window). Record
// the failure so QuestTurninFire skips it for an escalating
// back-off window. Other failure Results (InvalidTarget /
// OutOfRange) are transient — the giver moved or we drifted
// out of range — and must NOT trigger the long back-off.
if (r == Result::Locked)
{
if (auto const* qc = std::get_if<QuestCompleteIntent>(&intent.body))
{
if (BotAI* botai = reg.ai(id))
botai->note_quest_reward_failed(qc->quest_id,
GameTime::GetGameTimeMS());
}
}
// Per-bot diagnostic ring. Recorded AFTER the API call so the
// captured Result reflects what actually happened, not what
// the AI worker hoped would happen. Used by /diag <bot>.
// MoveTo destinations are captured so alternating-target
// loops show verbatim in /diag — an Ok-per-tick stream is
// ambiguous without them (the API's goal-key dedup also
// returns Ok without re-issuing the spline).
float ihx = 0.0f, ihy = 0.0f, ihz = 0.0f;
if (auto const* mv = std::get_if<MoveToIntent>(&intent.body))
{ ihx = mv->x; ihy = mv->y; ihz = mv->z; }
reg.record_intent_history(id,
GameTime::GetGameTimeMS(),
static_cast<uint32>(intent.body.index()),
static_cast<uint8>(r),
ihx, ihy, ihz);
}
catch (...)
{
Services::Perf().record_exception();
// Record the exception in the ring too so /diag surfaces
// "Other" + the kind that threw — the most useful signal
// for debugging an unhandled API path.
reg.record_intent_history(id,
GameTime::GetGameTimeMS(),
static_cast<uint32>(intent.body.index()),
static_cast<uint8>(Result::Other));
}
++executed;
++bot_executed;
}
});
// Close the pathfinding-budget window so it never throttles a pathfind issued
// outside DrainIntents (HasBudget fails open when inactive).
Playerbot::PathBudget::EndWorldTick();
return executed;
}
} // namespace Playerbot
@@ -0,0 +1,99 @@
#include "BotPersonality.h"
#include <cstdint>
namespace Playerbot {
BotPersonality DefaultPersonality()
{
return BotPersonality{}; // Defaults declared in the struct
}
namespace {
// Independent splitmix64 stream per dimension: seed mixed with a salt unique
// to each personality field so picks across dimensions don't correlate. The
// per-bot seed produces stable picks across server restarts.
uint64_t mix(uint64_t seed, uint64_t salt)
{
uint64_t z = (seed + salt + 0x9E3779B97F4A7C15ULL);
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
return z ^ (z >> 31);
}
uint32_t pick_pct(uint64_t seed, uint64_t salt) // 0..99
{
return uint32_t(mix(seed, salt) % 100ull);
}
} // anonymous
BotPersonality RandomPersonality(uint64_t seed)
{
BotPersonality p; // start from defaults
// Aggression: Passive 5 / Defensive 25 / Normal 50 / Aggressive 20.
// Drives engage HP gate, engage range, level band (see State_Idle.cpp).
{
const uint32_t r = pick_pct(seed, 0x10A);
p.aggression = r < 5 ? Aggression::Passive
: r < 30 ? Aggression::Defensive
: r < 80 ? Aggression::Normal
: Aggression::Aggressive;
}
// RiskTolerance: Cautious 10 / Careful 30 / Normal 40 / Reckless 20.
// Drives wander step distance + OoC consume thresholds.
{
const uint32_t r = pick_pct(seed, 0x20A);
p.risk_tolerance = r < 10 ? RiskTolerance::Cautious
: r < 40 ? RiskTolerance::Careful
: r < 80 ? RiskTolerance::Normal
: RiskTolerance::Reckless;
}
// Verbosity: Silent 5 / Terse 15 / Normal 60 / Chatty 15 / Roleplay 5.
// Roleplay enables ambient inn emotes; others tune chat-response length.
{
const uint32_t r = pick_pct(seed, 0x30A);
p.verbosity = r < 5 ? Verbosity::Silent
: r < 20 ? Verbosity::Terse
: r < 80 ? Verbosity::Normal
: r < 95 ? Verbosity::Chatty
: Verbosity::Roleplay;
}
// Politeness: Rude 10 / Neutral 70 / Polite 20.
{
const uint32_t r = pick_pct(seed, 0x40A);
p.politeness = r < 10 ? Politeness::Rude
: r < 80 ? Politeness::Neutral
: Politeness::Polite;
}
// Loyalty: Flighty 15 / Normal 70 / Devoted 15.
{
const uint32_t r = pick_pct(seed, 0x50A);
p.loyalty = r < 15 ? Loyalty::Flighty
: r < 85 ? Loyalty::Normal
: Loyalty::Devoted;
}
// SkillTier: Novice 15 / Competent 60 / Expert 20 / Elite 5.
// Competent is the bulk of the population; Elite is the long tail.
{
const uint32_t r = pick_pct(seed, 0x60A);
p.skill_tier = r < 15 ? SkillTier::Novice
: r < 75 ? SkillTier::Competent
: r < 95 ? SkillTier::Expert
: SkillTier::Elite;
}
// Other dims (activity_pref, response_delay_ms, response_jitter_ms,
// mistake_rate) keep the struct defaults — they tune chat/whisper
// behaviors that don't yet have downstream consumers in the V2 module.
return p;
}
} // namespace Playerbot
@@ -0,0 +1,54 @@
// BotPersonality - Static-ish per-bot tuning. Stored in playerbot_v2_personality.
#pragma once
#include "BotTypes.h"
namespace Playerbot {
enum class SkillTier : uint8 { Novice = 0, Competent = 1, Expert = 2, Elite = 3 };
enum class Verbosity : uint8 { Silent = 0, Terse = 1, Normal = 2, Chatty = 3, Roleplay = 4 };
enum class Aggression : uint8 { Passive = 0, Defensive = 1, Normal = 2, Aggressive = 3 };
enum class RiskTolerance : uint8 { Cautious = 0, Careful = 1, Normal = 2, Reckless = 3 };
enum class Politeness : uint8 { Rude = 0, Neutral = 1, Polite = 2 };
enum class Loyalty : uint8 { Flighty = 0, Normal = 1, Devoted = 2 };
// Bitfield over activity preferences.
namespace ActivityPref {
constexpr uint8 Solo = 1 << 0;
constexpr uint8 Group = 1 << 1;
constexpr uint8 Pvp = 1 << 2;
constexpr uint8 Profession = 1 << 3;
constexpr uint8 Social = 1 << 4;
constexpr uint8 Housing = 1 << 5;
constexpr uint8 All = 0x3F;
}
struct BotPersonality
{
SkillTier skill_tier = SkillTier::Competent;
Verbosity verbosity = Verbosity::Normal;
Aggression aggression = Aggression::Normal;
RiskTolerance risk_tolerance = RiskTolerance::Normal;
Politeness politeness = Politeness::Neutral;
Loyalty loyalty = Loyalty::Normal;
uint8 activity_pref = ActivityPref::All;
uint16 response_delay_ms = 300;
uint16 response_jitter_ms = 100;
uint8 mistake_rate = 2; // percent
};
// Default personality for warm-pool bots; stored personalities override.
BotPersonality DefaultPersonality();
// Per-bot deterministic personality picked from a seed (use SeedForBot(id)).
// Distributions roughly mimic a real player population: most bots are
// Normal across the board, with a long tail of Aggressive / Cautious /
// Roleplay / etc. Same seed always returns the same personality so a bot's
// behavior is reproducible across server restarts. Used by auto-spawn
// (where there's no operator to set personality manually) and by the
// .playerbot mark code path so a freshly-marked existing character also
// gets a flavored personality (operator can override later via whisper).
BotPersonality RandomPersonality(uint64_t seed);
} // namespace Playerbot
+104
View File
@@ -0,0 +1,104 @@
#include "BotRegistry.h"
namespace Playerbot {
void BotRegistry::register_bot(BotId id, BotPersonality personality, BotRng rng)
{
// BotRegistryEntry holds a std::mutex, so it's not movable. Construct in
// place via try_emplace then populate the unique_ptrs directly.
std::unique_lock lk(mtx_);
auto [it, inserted] = entries_.try_emplace(id);
if (!inserted) return;
auto& entry = it->second;
entry.ai = std::make_unique<BotAI>(id, std::move(personality), rng);
entry.intents = std::make_unique<IntentQueue>();
entry.next_intent_id = 0;
}
void BotRegistry::unregister_bot(BotId id)
{
std::unique_lock lk(mtx_);
entries_.erase(id);
}
bool BotRegistry::has(BotId id) const
{
std::shared_lock lk(mtx_);
return entries_.find(id) != entries_.end();
}
BotAI* BotRegistry::ai(BotId id)
{
std::shared_lock lk(mtx_);
auto it = entries_.find(id);
return it == entries_.end() ? nullptr : it->second.ai.get();
}
IntentQueue* BotRegistry::intents(BotId id)
{
std::shared_lock lk(mtx_);
auto it = entries_.find(id);
return it == entries_.end() ? nullptr : it->second.intents.get();
}
IntentId* BotRegistry::next_intent_id(BotId id)
{
std::shared_lock lk(mtx_);
auto it = entries_.find(id);
return it == entries_.end() ? nullptr : &it->second.next_intent_id;
}
void BotRegistry::push_loot(BotId id, ObjectGuid corpse)
{
std::shared_lock lk(mtx_);
auto it = entries_.find(id);
if (it == entries_.end()) return;
std::lock_guard pl(it->second.pending_loot_mtx);
if (it->second.pending_loot.size() < kPendingLootMax)
it->second.pending_loot.push_back(corpse);
}
bool BotRegistry::try_pop_loot(BotId id, ObjectGuid& out)
{
std::shared_lock lk(mtx_);
auto it = entries_.find(id);
if (it == entries_.end()) return false;
std::lock_guard pl(it->second.pending_loot_mtx);
if (it->second.pending_loot.empty()) return false;
out = it->second.pending_loot.front();
it->second.pending_loot.pop_front();
return true;
}
size_t BotRegistry::peek_loot_size(BotId id) const
{
std::shared_lock lk(mtx_);
auto it = entries_.find(id);
if (it == entries_.end()) return 0;
std::lock_guard pl(it->second.pending_loot_mtx);
return it->second.pending_loot.size();
}
size_t BotRegistry::size() const
{
std::shared_lock lk(mtx_);
return entries_.size();
}
void BotRegistry::record_intent_history(BotId id, uint32 ts_ms,
uint32 intent_kind, uint8 result,
float x, float y, float z)
{
std::shared_lock lk(mtx_);
auto it = entries_.find(id);
if (it == entries_.end()) return;
auto& e = it->second;
std::lock_guard hl(e.intent_history_mtx);
e.intent_history[e.intent_history_head] =
IntentHistoryEntry{ts_ms, intent_kind, result, x, y, z};
e.intent_history_head = (e.intent_history_head + 1) % kIntentHistoryCap;
if (e.intent_history_size < kIntentHistoryCap) ++e.intent_history_size;
assert(e.intent_history_size <= kIntentHistoryCap);
}
} // namespace Playerbot
+141
View File
@@ -0,0 +1,141 @@
// BotRegistry - Owns BotAI instances + per-bot IntentQueue and EventInbox.
// Pure storage; lifecycle decisions (spawn/despawn) live in Fleet.
#pragma once
#include "BotAI.h"
#include "../Threading/IntentQueue.h"
#include "ObjectGuid.h"
#include <array>
#include <atomic>
#include <cassert>
#include <deque>
#include <memory>
#include <mutex>
#include <shared_mutex>
#include <unordered_map>
namespace Playerbot {
// Per-bot intent execution history — populated by BotIntentExecutor after
// each visit() call so /diag can show the most recent N (kind, result)
// pairs in chronological order. 32 entries is one or two seconds of dense
// combat at typical intent emission rates; long enough to spot loops, short
// enough to fit in an in-memory ring keyed by every registered bot.
struct IntentHistoryEntry
{
uint32 ts_ms = 0; // GameTime::GetGameTimeMS at execution
uint32 intent_kind = 0; // IntentBody variant index (see Intent.h)
uint8 result = 0; // PlayerbotAPI::Result (Ok=0, NotReady=1, …)
// Destination for MoveToIntent (0,0,0 otherwise) — /diag prints it so
// alternating-target loops are visible verbatim (an Ok-per-150ms stream
// is ambiguous without the coords: the API's goal-key dedup also
// returns Ok without re-issuing).
float x = 0.0f, y = 0.0f, z = 0.0f;
};
inline constexpr size_t kIntentHistoryCap = 32;
struct BotRegistryEntry
{
std::unique_ptr<BotAI> ai;
std::unique_ptr<IntentQueue> intents;
IntentId next_intent_id = 0;
// Intent execution ring. Pushed by BotIntentExecutor::DrainIntents on
// the world thread; read by /diag (also world thread). Mutex bridges
// the case where a future diagnostic panel reads from another thread.
std::array<IntentHistoryEntry, kIntentHistoryCap> intent_history{};
size_t intent_history_head = 0; // next write slot
size_t intent_history_size = 0; // [0, kIntentHistoryCap]
mutable std::mutex intent_history_mtx;
// Corpses queued for looting (FIFO). Drained by State_Idle on the AI
// worker thread; written by V2::Module::OnDeath on the world thread.
// The mutex bridges those threads — without it the deque races (both
// sides do push_back/pop_front). Bounded by kPendingLootMax so a
// runaway pull can't unbound this list.
std::deque<ObjectGuid> pending_loot;
mutable std::mutex pending_loot_mtx;
// GameTimeMS at which intent execution may resume. 0 (or past) means
// run normally. Set by /wait whisper. Read-only on the world thread
// by the intent executor (it skips the bot for the tick when paused).
// Atomic because writers are the world thread (parser) and readers are
// also the world thread (executor) — same thread, but avoids a race
// when other threads peek for diagnostics.
std::atomic<uint32_t> paused_until_ms{0};
};
inline constexpr size_t kPendingLootMax = 15;
class BotRegistry
{
public:
void register_bot(BotId id, BotPersonality personality, BotRng rng);
void unregister_bot(BotId id);
bool has(BotId id) const;
// Borrows — caller holds the snapshot lock implicitly via the registry.
BotAI* ai(BotId id);
IntentQueue* intents(BotId id);
IntentId* next_intent_id(BotId id);
// Thread-safe loot queue access. Push appends if under the cap (silently
// drops otherwise). try_pop_front removes and returns the oldest entry
// (returns false if empty). peek_loot_size is a snapshot read.
void push_loot(BotId id, ObjectGuid corpse);
bool try_pop_loot(BotId id, ObjectGuid& out);
size_t peek_loot_size(BotId id) const;
// Append one intent-execution record for `id`. Silent no-op if the bot
// isn't registered. Called from the world-thread intent executor right
// after std::visit produces a Result.
void record_intent_history(BotId id, uint32 ts_ms,
uint32 intent_kind, uint8 result,
float x = 0.0f, float y = 0.0f, float z = 0.0f);
// Walk the per-bot intent history oldest-to-newest. fn takes
// (size_t i, IntentHistoryEntry const&). Empty / unregistered → no-op.
template <class F>
void for_each_intent_history(BotId id, F&& fn) const
{
std::shared_lock lk(mtx_);
auto it = entries_.find(id);
if (it == entries_.end()) return;
std::lock_guard hl(it->second.intent_history_mtx);
const size_t n = it->second.intent_history_size;
assert(n <= kIntentHistoryCap);
const size_t start = (n < kIntentHistoryCap)
? 0
: it->second.intent_history_head;
for (size_t i = 0; i < n; ++i)
{
const size_t idx = (start + i) % kIntentHistoryCap;
fn(i, it->second.intent_history[idx]);
}
}
// Apply fn(deque&) under the per-bot loot mutex. Used by drainers that
// need to inspect+pop atomically (e.g. distance check + pop).
template <class F>
void with_loot(BotId id, F&& fn)
{
std::shared_lock lk(mtx_);
auto it = entries_.find(id);
if (it == entries_.end()) return;
std::lock_guard pl(it->second.pending_loot_mtx);
fn(it->second.pending_loot);
}
size_t size() const;
// Iteration helper — invokes fn under shared lock with id. Don't hold long.
template <class F>
void for_each(F&& fn) const
{
std::shared_lock lk(mtx_);
for (auto const& [id, entry] : entries_)
fn(id, entry);
}
private:
mutable std::shared_mutex mtx_;
std::unordered_map<BotId, BotRegistryEntry> entries_;
};
} // namespace Playerbot
+58
View File
@@ -0,0 +1,58 @@
// BotRng - Per-bot deterministic RNG. Seeded from BotId so the same bot in
// the same snapshot produces the same intent (REQUIREMENTS.md §2.3).
#pragma once
#include "BotTypes.h"
#include <cstdint>
namespace Playerbot {
class BotRng
{
public:
BotRng() : state_(0xC0FFEE) {}
explicit BotRng(uint64_t seed) : state_(seed ? seed : 0xC0FFEE) {}
// splitmix64 — fast, good distribution, easy to reason about for tests.
uint64_t next()
{
uint64_t z = (state_ += 0x9E3779B97F4A7C15ULL);
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL;
return z ^ (z >> 31);
}
// Uniform integer in [lo, hi).
int32_t int_range(int32_t lo, int32_t hi)
{
if (hi <= lo) return lo;
const uint64_t span = static_cast<uint64_t>(hi - lo);
return lo + static_cast<int32_t>(next() % span);
}
// [0,1) double.
double unit()
{
return (next() >> 11) * (1.0 / 9007199254740992.0);
}
// Bernoulli — true with probability `p` in [0,1].
bool chance(double p) { return unit() < p; }
// Pct chance, 0..100.
bool chance_pct(uint8_t pct) { return int_range(0, 100) < static_cast<int32_t>(pct); }
uint64_t state() const { return state_; }
private:
uint64_t state_;
};
inline uint64_t SeedForBot(BotId id)
{
// Mixing constant from xxh3 — gives good per-bot dispersion.
return id * 0x9FB21C651E98DF25ULL ^ 0x6A09E667F3BCC908ULL;
}
} // namespace Playerbot
@@ -0,0 +1,9 @@
#include "BotSnapshot.h"
namespace Playerbot {
// BotSnapshot is currently aggregate-initialized; no out-of-line code needed.
// This TU exists so future invariant-validation helpers (e.g., debug-mode
// consistency checks) have a home without forcing a header recompile.
} // namespace Playerbot
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
// BotSnapshotBuilder - Builds a BotSnapshot from a Player*. World thread only.
//
// First-iteration scope: identity, vitals, position, simple cooldowns, current
// target. Auras / nearby units / inventory will land as the corresponding
// PlayerbotAPI accessors gain coverage.
#pragma once
#include "BotSnapshot.h"
#include <memory>
class Player;
namespace Playerbot {
class BotAI;
class BotSnapshotBuilder
{
public:
// Returns null if `p` is null or not in world. Otherwise returns a fresh
// BotSnapshot populated from `p`. Pure read of Player (no mutation).
//
// `bot_ai` MUST be the BotAI* for `p` (i.e. what Registry().ai(p's id)
// returns), pre-resolved by the caller ON THE WORLD THREAD and threaded in
// here. Build runs on a parallel build-pool worker (#5 Phase 4), where
// calling Registry().ai() is unsafe: ai() is an unlocked map lookup that
// races a concurrent rehash from bot login/logout and can return a garbage
// pointer. Resolving once on the world thread (where the registry is
// quiescent w.r.t. this tick) eliminates that race while keeping behavior
// byte-identical (same pointer, resolved once instead of ~32 times). May be
// null if the bot has no registered AI (Build tolerates a null bot_ai
// everywhere it is used).
static std::shared_ptr<BotSnapshot const> Build(Player* p, BotAI* bot_ai, SnapshotVer next_version, TickId tick);
};
} // namespace Playerbot
@@ -0,0 +1,304 @@
// BotSnapshotResetCheck - completeness guard for BotSnapshot::reset_for_reuse.
//
// The snapshot recycle pool (SNAPSHOT_PERF_BACKLOG.md Tier 3.1) reuses a prior
// BotSnapshot instead of make_shared-ing a fresh one. That is only safe if
// reset_for_reuse() returns the object to a byte-equivalent default state — a
// MISSED member leaks last tick's data into the next snapshot, a correctness
// bug that is very hard to spot in the field (stale auras, ghost quest log,
// carried-over BG node ownership, etc).
//
// VerifyResetClearsAll() is the unit-test-style guard the task mandates: it
// fills EVERY container/string in a BotSnapshot with sentinel data, sets the
// non-default scalars, runs reset_for_reuse(), and asserts every container is
// empty + the load-bearing scalar defaults are restored. It is invoked once at
// module init (PlayerbotV2.cpp Module::Init), so a future field added without a
// matching reset() line trips the assert at boot, not silently in production.
//
// Kept in its own TU (glob-collected) so the heavy fill code never weighs on
// the hot builder TU.
#include "BotSnapshot.h"
#include "Errors.h"
#include "Log.h"
namespace Playerbot {
namespace {
// Stuff one of every container + string + a representative non-default scalar
// so reset_for_reuse has something to clear in each sub-struct.
void FillEverything(BotSnapshot& s)
{
s.version = 7; s.bot_id = 7; s.world_tick = 7; s.published_at_ms = 7;
s.guid = ObjectGuid::Create<HighGuid::Player>(7);
s.owner_name = "x";
s.identity.name = "x";
s.identity.level = 1;
s.vitals.in_combat = true;
s.position.map_id = 1;
s.area.area_id = 1;
s.instance_ctx.is_in_instance = true;
s.movement.is_moving = true;
s.environment.on_transport = true;
s.travel.taxi_mask.push_back(1);
s.travel.self_teleport_spells.push_back({1, 1});
s.travel.next_hop_dest_map = 1; // must come back to kInvalidMapId
s.death.is_ghost = true;
s.social_events.has_group_invite = true;
s.guild.id = 1;
s.lfg.in_queue = true;
s.auction.ah_competing_buyout.push_back({1, 1});
s.auction.auctions_owned.push_back({});
s.auction.buyable_listings.push_back({});
s.auction.buyable_commodities.push_back({});
s.combat.victim = s.guid;
s.combat.attackers.push_back({});
s.combat.nearby_enemies.push_back({});
s.combat.nearby_friends.push_back({});
s.auras.own_auras.push_back({});
s.auras.target_auras.push_back({});
s.auras.victim_auras.push_back({});
s.auras.my_auras_on_others.push_back({});
s.auras.own_auras_index.push(1, 0);
s.auras.my_auras_on_others_index.push(1, 0);
s.auras.own_auras_index.finalize();
s.auras.my_auras_on_others_index.finalize();
s.cast.is_casting = true;
s.cooldowns.spell_cooldowns.push_back({});
s.cooldowns.spell_cooldowns_index.push(1, 0);
s.cooldowns.spell_cooldowns_index.finalize();
s.inventory.gold = 1;
s.inventory.equipped[0].entry = 1; // must clear to default
s.inventory.bag_items.push_back({});
s.inventory.bag_count_by_entry.add(1, 1);
s.inventory.bag_count_by_entry.finalize();
s.bags.equipped_bag_subclass[0] = 0; // must come back to 0xFF
s.stat_weights.spec_weapon_dps_weight = 1.f;
s.secondary_stats.crit_pct_x100 = 1;
s.vendor_visit.phases_pending = 1;
s.consumables.food_drink_count = 1;
s.spellbook.known_spells.push_back(1);
s.spellbook.known_recipes.push_back(1);
s.spellbook.active_talents.push_back(1);
s.spellbook.active_glyphs.push_back(1);
s.progression.skills.push_back({});
s.progression.currencies.push_back({});
s.progression.reputations.push_back({});
s.group.group_guid = s.guid;
s.quest_log.quests.push_back({});
s.quest_log.quests_index.push(1, 0);
s.quest_log.quests_index.finalize();
s.quest_log.current_objective.credit_alias_entries.push_back(1);
s.quest_log.current_objective.labeled_target_entries.push_back(1);
s.quest_log.current_quest_id = 1;
s.quest_log.bridge_route.push_back({});
s.quest_log.actionable_objectives.push_back({});
s.gossip.gossip_npc = s.guid;
s.gossip.gossip_options.push_back({});
s.quest_discovery.quest_turnins.push_back({});
s.quest_discovery.quest_offers.push_back({});
s.quest_discovery.quest_starting_items.push_back({});
s.quest_discovery.available_world_quests.push_back({});
s.loot.loot_rolls.push_back({});
s.mailbox.mail.push_back({});
s.mailbox.mail.back().item_guid_lows.push_back(1); // inner vector
s.mailbox.unread_mail_count = 1;
s.pve_order.active = true;
s.bg.queues.push_back({});
s.bg.node_states.push_back({});
s.bg.node_states.back().name = "x"; // inner string
s.bg.all_friendly_carriers.push_back(s.guid);
s.bg.all_enemy_carriers.push_back(s.guid);
s.bg.av_balinda_alive = false; // must come back to true
s.bg.sota_attacker_team = 1; // must come back to -1
s.bg.sota_gate_state[0] = 1;
s.bg.ioc_gate_destroyed[0] = 1;
s.vehicle.on_vehicle = true;
s.bank.bank_free_slots = 1;
s.world_objects.nearby_objects.push_back({});
s.path.path_target = s.guid;
s.path_telemetry.count = 1;
s.dungeon_exec.current_boss_entry = 1;
s.pet.pet_name = "x";
s.pet.pet_auras.push_back({});
s.pet.stable_pets.push_back({});
s.pet.stable_pets.back().name = "x"; // inner string
s.pet.pet_attackers.push_back(s.guid);
s.archetype.archetype_id = 1;
s.craft_orders.want_spell_id = 1;
}
#define PB_RESET_CHECK(cond, what) \
do { \
if (!(cond)) \
ABORT_MSG("[PlayerbotV2] reset_for_reuse() did not clear: %s", \
what); \
} while (0)
} // namespace
// Returns true if reset_for_reuse() is complete. Aborts (never returns false)
// on the first leak so the operator gets the offending member name. Returning
// bool keeps it callable from a smoketest harness too.
bool VerifyResetClearsAll()
{
BotSnapshot s;
FillEverything(s);
s.reset_for_reuse();
// Top-level scalars / key.
PB_RESET_CHECK(s.version == 0, "version");
PB_RESET_CHECK(s.bot_id == 0, "bot_id");
PB_RESET_CHECK(s.world_tick == 0, "world_tick");
PB_RESET_CHECK(s.published_at_ms == 0, "published_at_ms");
PB_RESET_CHECK(s.guid == ObjectGuid::Empty, "guid");
PB_RESET_CHECK(s.owner_name.empty(), "owner_name");
PB_RESET_CHECK(s.identity.name.empty() && s.identity.level == 0, "identity");
PB_RESET_CHECK(!s.vitals.in_combat, "vitals");
PB_RESET_CHECK(s.position.map_id == 0, "position");
PB_RESET_CHECK(s.area.area_id == 0, "area");
PB_RESET_CHECK(!s.instance_ctx.is_in_instance, "instance_ctx");
PB_RESET_CHECK(!s.movement.is_moving, "movement");
PB_RESET_CHECK(!s.environment.on_transport, "environment");
PB_RESET_CHECK(s.travel.taxi_mask.empty(), "travel.taxi_mask");
PB_RESET_CHECK(s.travel.self_teleport_spells.empty(), "travel.self_teleport_spells");
PB_RESET_CHECK(s.travel.next_hop_dest_map == kInvalidMapId, "travel.next_hop_dest_map");
PB_RESET_CHECK(!s.death.is_ghost, "death");
PB_RESET_CHECK(!s.social_events.has_group_invite, "social_events");
PB_RESET_CHECK(s.guild.id == 0, "guild");
PB_RESET_CHECK(!s.lfg.in_queue, "lfg");
PB_RESET_CHECK(s.auction.ah_competing_buyout.empty(), "auction.ah_competing_buyout");
PB_RESET_CHECK(s.auction.auctions_owned.empty(), "auction.auctions_owned");
PB_RESET_CHECK(s.auction.buyable_listings.empty(), "auction.buyable_listings");
PB_RESET_CHECK(s.auction.buyable_commodities.empty(), "auction.buyable_commodities");
PB_RESET_CHECK(s.combat.victim == ObjectGuid::Empty, "combat.victim");
PB_RESET_CHECK(s.combat.attackers.empty(), "combat.attackers");
PB_RESET_CHECK(s.combat.nearby_enemies.empty(), "combat.nearby_enemies");
PB_RESET_CHECK(s.combat.nearby_friends.empty(), "combat.nearby_friends");
PB_RESET_CHECK(s.auras.own_auras.empty(), "auras.own_auras");
PB_RESET_CHECK(s.auras.target_auras.empty(), "auras.target_auras");
PB_RESET_CHECK(s.auras.victim_auras.empty(), "auras.victim_auras");
PB_RESET_CHECK(s.auras.my_auras_on_others.empty(), "auras.my_auras_on_others");
PB_RESET_CHECK(s.auras.own_auras_index.empty(), "auras.own_auras_index");
PB_RESET_CHECK(s.auras.my_auras_on_others_index.empty(), "auras.my_auras_on_others_index");
PB_RESET_CHECK(!s.cast.is_casting, "cast");
PB_RESET_CHECK(s.cooldowns.spell_cooldowns.empty(), "cooldowns.spell_cooldowns");
PB_RESET_CHECK(s.cooldowns.spell_cooldowns_index.empty(), "cooldowns.spell_cooldowns_index");
PB_RESET_CHECK(s.inventory.gold == 0, "inventory.gold");
PB_RESET_CHECK(s.inventory.equipped[0].entry == 0, "inventory.equipped");
PB_RESET_CHECK(s.inventory.bag_items.empty(), "inventory.bag_items");
PB_RESET_CHECK(s.inventory.bag_count_by_entry.empty(), "inventory.bag_count_by_entry");
PB_RESET_CHECK(s.bags.equipped_bag_subclass[0] == 0xFF, "bags.equipped_bag_subclass");
PB_RESET_CHECK(s.stat_weights.spec_weapon_dps_weight == 0.f, "stat_weights");
PB_RESET_CHECK(s.secondary_stats.crit_pct_x100 == 0, "secondary_stats");
PB_RESET_CHECK(s.vendor_visit.phases_pending == 0, "vendor_visit");
PB_RESET_CHECK(s.consumables.food_drink_count == 0, "consumables");
PB_RESET_CHECK(s.spellbook.known_spells.empty(), "spellbook.known_spells");
PB_RESET_CHECK(s.spellbook.known_recipes.empty(), "spellbook.known_recipes");
PB_RESET_CHECK(s.spellbook.active_talents.empty(), "spellbook.active_talents");
PB_RESET_CHECK(s.spellbook.active_glyphs.empty(), "spellbook.active_glyphs");
PB_RESET_CHECK(s.progression.skills.empty(), "progression.skills");
PB_RESET_CHECK(s.progression.currencies.empty(), "progression.currencies");
PB_RESET_CHECK(s.progression.reputations.empty(), "progression.reputations");
PB_RESET_CHECK(s.group.group_guid == ObjectGuid::Empty, "group");
PB_RESET_CHECK(s.quest_log.quests.empty(), "quest_log.quests");
PB_RESET_CHECK(s.quest_log.quests_index.empty(), "quest_log.quests_index");
PB_RESET_CHECK(s.quest_log.current_objective.credit_alias_entries.empty(),
"quest_log.current_objective.credit_alias_entries");
PB_RESET_CHECK(s.quest_log.current_objective.labeled_target_entries.empty(),
"quest_log.current_objective.labeled_target_entries");
PB_RESET_CHECK(s.quest_log.current_quest_id == 0, "quest_log.current_quest_id");
PB_RESET_CHECK(s.quest_log.bridge_route.empty(), "quest_log.bridge_route");
PB_RESET_CHECK(s.quest_log.actionable_objectives.empty(), "quest_log.actionable_objectives");
PB_RESET_CHECK(s.gossip.gossip_npc == ObjectGuid::Empty, "gossip.gossip_npc");
PB_RESET_CHECK(s.gossip.gossip_options.empty(), "gossip.gossip_options");
PB_RESET_CHECK(s.quest_discovery.quest_turnins.empty(), "quest_discovery.quest_turnins");
PB_RESET_CHECK(s.quest_discovery.quest_offers.empty(), "quest_discovery.quest_offers");
PB_RESET_CHECK(s.quest_discovery.quest_starting_items.empty(), "quest_discovery.quest_starting_items");
PB_RESET_CHECK(s.quest_discovery.available_world_quests.empty(), "quest_discovery.available_world_quests");
PB_RESET_CHECK(s.loot.loot_rolls.empty(), "loot.loot_rolls");
PB_RESET_CHECK(s.mailbox.mail.empty(), "mailbox.mail");
PB_RESET_CHECK(s.mailbox.unread_mail_count == 0, "mailbox.unread_mail_count");
PB_RESET_CHECK(!s.pve_order.active, "pve_order");
PB_RESET_CHECK(s.bg.queues.empty(), "bg.queues");
PB_RESET_CHECK(s.bg.node_states.empty(), "bg.node_states");
PB_RESET_CHECK(s.bg.all_friendly_carriers.empty(), "bg.all_friendly_carriers");
PB_RESET_CHECK(s.bg.all_enemy_carriers.empty(), "bg.all_enemy_carriers");
PB_RESET_CHECK(s.bg.av_balinda_alive, "bg.av_balinda_alive");
PB_RESET_CHECK(s.bg.av_galvangar_alive, "bg.av_galvangar_alive");
PB_RESET_CHECK(s.bg.sota_attacker_team == -1, "bg.sota_attacker_team");
PB_RESET_CHECK(s.bg.sota_gate_state[0] == 0, "bg.sota_gate_state");
PB_RESET_CHECK(s.bg.ioc_gate_destroyed[0] == 0, "bg.ioc_gate_destroyed");
PB_RESET_CHECK(!s.vehicle.on_vehicle, "vehicle");
PB_RESET_CHECK(s.bank.bank_free_slots == 0, "bank");
PB_RESET_CHECK(s.world_objects.nearby_objects.empty(), "world_objects.nearby_objects");
PB_RESET_CHECK(s.path.path_target == ObjectGuid::Empty, "path");
PB_RESET_CHECK(s.path_telemetry.count == 0, "path_telemetry");
PB_RESET_CHECK(s.dungeon_exec.current_boss_entry == 0, "dungeon_exec");
PB_RESET_CHECK(s.pet.pet_name.empty(), "pet.pet_name");
PB_RESET_CHECK(s.pet.pet_auras.empty(), "pet.pet_auras");
PB_RESET_CHECK(s.pet.stable_pets.empty(), "pet.stable_pets");
PB_RESET_CHECK(s.pet.pet_attackers.empty(), "pet.pet_attackers");
PB_RESET_CHECK(s.archetype.archetype_id == 0, "archetype");
PB_RESET_CHECK(s.craft_orders.want_spell_id == 0, "craft_orders");
TC_LOG_INFO("server.loading",
"[PlayerbotV2] BotSnapshot::reset_for_reuse() completeness check passed.");
return true;
}
#undef PB_RESET_CHECK
} // namespace Playerbot
@@ -0,0 +1,738 @@
#include "BotSnapshotView.h"
#include "SpellMgr.h"
#include "SpellInfo.h"
#include "SpellDefines.h"
#include "World/WorldMetadata.h"
#include <algorithm>
#include <cfloat>
namespace Playerbot {
namespace {
// Legacy linear scan, kept as a fallback for callers that don't have a
// CooldownsState in scope. Hot-path callers (is_ready/cd_remaining/
// charges in this file) take the FindCooldownIndexed path below — O(1)
// lookup via the spell_cooldowns_index map.
CooldownEntry const* FindCooldown(std::vector<CooldownEntry> const& list, uint32 spell_id)
{
for (auto const& e : list)
if (e.spell_id == spell_id)
return &e;
return nullptr;
}
CooldownEntry const* FindCooldownIndexed(CooldownsState const& cds, uint32 spell_id)
{
// Tier 3.3: spell_cooldowns_index is a sorted flat vector; find() returns
// a pointer to the index value (or nullptr when absent).
uint32 const* pi = cds.spell_cooldowns_index.find(spell_id);
if (!pi) return nullptr;
const uint32 i = *pi;
if (i >= cds.spell_cooldowns.size()) return nullptr; // defensive
return &cds.spell_cooldowns[i];
}
AuraEntry const* FindAura(std::vector<AuraEntry> const& list, uint32 spell_id)
{
for (auto const& a : list)
if (a.spell_id == spell_id)
return &a;
return nullptr;
}
// As FindAura but only returns the entry if the bot is the caster. Damage-DoT
// refresh checks need this: in a raid with multiple priests, the bot's own
// SWP may have fallen off while another priest's SWP is up. The plain
// FindAura would short-circuit and skip the recast, costing the bot's own
// damage. Filtering by caster makes "is my X up?" queries correct per-bot.
AuraEntry const* FindMyAura(std::vector<AuraEntry> const& list, uint32 spell_id, ObjectGuid me)
{
for (auto const& a : list)
if (a.spell_id == spell_id && a.caster == me)
return &a;
return nullptr;
}
} // anonymous
bool BotSnapshotView::has_reagents(uint32 spell_id) const
{
SpellInfo const* si = sSpellMgr->GetSpellInfo(spell_id, DIFFICULTY_NONE);
if (!si) return false;
// Walk the per-spell reagent table. Reagent==0 marks an unused slot;
// ReagentCount of 0 also means no requirement. Both cases skip silently.
// For each reagent that IS required, sum bag stacks of that entry and
// bail out if any is short.
for (size_t i = 0; i < si->Reagent.size(); ++i)
{
const int32 entry = si->Reagent[i];
const int16 need = si->ReagentCount[i];
if (entry <= 0 || need <= 0) continue;
if (item_count(uint32(entry)) < uint32(need))
return false;
}
return true;
}
bool BotSnapshotView::is_ready(uint32 spell_id) const
{
// Don't even try to cast spells the bot hasn't learned. Distribution-
// leveled bots (especially DK/DH/Evoker, who skip the class starter
// quests that grant abilities like Death Coil 47541 / Death Grip 49576)
// don't have the spells the combat APL assumes. Without this gate, every
// tick produces SpellCastResult=91 (SPELL_FAILED_NOT_KNOWN) log spam.
// Hunter Call Pet (883) when the bot has no pet is the same pattern.
// Cheap (binary_search on the sorted known_spells vector) — runs many
// times per tick across all spec APLs but never measurable.
if (!knows_spell(spell_id)) return false;
// Block while we have an active cast/channel — re-emitting CastSpellIntent
// mid-cast cancels the in-progress one and restarts, costing latency and
// breaking healers/casters that should let their spell finish. Treats
// re-firing the same spell as also blocked: server-side replay produces
// wasted resource use even if it "works".
if (s_->cast.is_casting && s_->cast.current_cast_remaining.count() > 0) return false;
// Stuns block all casts. Silence only blocks magic-school spells —
// physical-ability classes (Warrior/Rogue/Hunter/DK/DH/Monk) cast through
// it. Without per-class branching, gating on is_silenced would block
// Mortal Strike / Backstab / Death Strike etc. when silenced. Let the
// server-side Spell::CheckCast reject magic-school casts the proper way
// (mapped to ServerRefused). Stuns stay gated since nothing escapes them.
if (s_->vitals.is_stunned) return false;
if (gcd_active()) return false;
auto cd = FindCooldownIndexed(s_->cooldowns, spell_id);
if (!cd) return true;
if (cd->charges > 0) return true;
// A charge-based spell with 0 charges is NOT ready, full stop — never
// fall through to the remaining-time check. The builder also populates
// `remaining` with the recharge timer for depleted charge spells now,
// but this guard makes the contract explicit (audit B01: 0-charge
// spells reported ready and generated 53% of all NOT_READY cast-reject
// spam, ~8 rejects per recharge cycle per bot, fleet-wide).
if (cd->max_charges > 0) return false;
return cd->remaining.count() == 0;
}
Ms BotSnapshotView::cd_remaining(uint32 spell_id) const
{
auto cd = FindCooldownIndexed(s_->cooldowns, spell_id);
return cd ? cd->remaining : Ms{0};
}
uint8 BotSnapshotView::charges(uint32 spell_id) const
{
// Charge-bearing spells live in the unified spell_cooldowns vector
// with charges/max_charges populated on the CooldownEntry by
// BotSnapshotBuilder::CopyCooldowns. (REFACTOR_2 removed the
// legacy item_cooldowns / charge_cooldowns side-tables since both
// were declared-but-never-populated, returning 0 for every lookup.)
auto cd = FindCooldownIndexed(s_->cooldowns, spell_id);
if (!cd) return uint8(0);
return cd->charges;
}
bool BotSnapshotView::can_cast_while_moving(uint32 spell_id) const
{
SpellInfo const* si = sSpellMgr->GetSpellInfo(spell_id, DIFFICULTY_NONE);
if (!si) return true; // unknown — let the cast attempt decide
// Spells without the Movement interrupt flag (instants, channels with
// movement allowed) pass through immediately.
if ((si->InterruptFlags & SpellInterruptFlags::Movement) == SpellInterruptFlags::None)
return true;
// Aura-based "next cast can move" / "all hard-casts can move while up"
// talents. These don't strip the Movement flag from SpellInfo; instead
// they apply a self-aura that lets the cast through. The exhaustive set
// we model in 12.0:
// - Ice Floes (108839) — Mage talent: 3 charges, each consumed by a
// hard-cast spell, removing the Movement interrupt for that one cast
// - Spiritwalker's Grace (79206) — Resto Shaman: all hard-casts free
// to move during the 15s window
// - Hover (358267) — Evoker: 6s window where everything can move-cast
// - Free Movement (366155, Bulwark of Order placeholder — not exhaustive)
constexpr uint32 ICE_FLOES_AURA = 108839;
constexpr uint32 SPIRITWALKERS_GRACE_AURA = 79206;
constexpr uint32 HOVER_AURA = 358267;
if (FindAura(s_->auras.own_auras, ICE_FLOES_AURA)) return true;
if (FindAura(s_->auras.own_auras, SPIRITWALKERS_GRACE_AURA)) return true;
if (FindAura(s_->auras.own_auras, HOVER_AURA)) return true;
return false;
}
bool BotSnapshotView::has_aura(uint32 spell_id, ObjectGuid on) const
{
return find_aura(spell_id, on) != nullptr;
}
AuraEntry const* BotSnapshotView::find_aura(uint32 spell_id, ObjectGuid on) const
{
if (on == ObjectGuid::Empty || on == s_->guid)
{
// O(1) hit via own_auras_index. Falls back to nullptr (not the
// linear scan) — the index is built every Build, so a missing
// entry means the aura really isn't on the bot.
uint32 const* pi = s_->auras.own_auras_index.find(spell_id);
if (!pi) return nullptr;
const uint32 i = *pi;
if (i >= s_->auras.own_auras.size()) return nullptr;
return &s_->auras.own_auras[i];
}
// For damage debuff refresh on enemies, filter by caster. See FindMyAura
// comment: a teammate's SWP being up doesn't mean OUR SWP is up.
if (on == s_->combat.current_target)
return FindMyAura(s_->auras.target_auras, spell_id, s_->guid);
if (on == s_->combat.victim)
return FindMyAura(s_->auras.victim_auras, spell_id, s_->guid);
// Group members / pet: O(1) composite-key lookup. Key shape matches
// BotSnapshotBuilder where the index is populated.
{
const uint64 key = (uint64(on.GetCounter()) << 32) | uint64(spell_id);
uint32 const* pi = s_->auras.my_auras_on_others_index.find(key);
if (pi)
{
const uint32 i = *pi;
if (i < s_->auras.my_auras_on_others.size())
{
auto const& o = s_->auras.my_auras_on_others[i];
// Materialise the matched outbound row into per-view storage.
// Previously this used a single thread_local stash that the
// NEXT find_aura call overwrote — a caller holding two results
// (or calling again before using the first) got aliased data.
// outbound_aura_rows_ is a std::deque, so this push_back never
// invalidates pointers handed out by earlier calls; every
// returned pointer stays valid for this view's lifetime.
AuraEntry& row = outbound_aura_rows_.emplace_back();
row.spell_id = o.spell_id;
row.stacks = o.stacks;
row.remaining = o.remaining;
row.caster = s_->guid;
return &row;
}
}
}
return nullptr;
}
uint8 BotSnapshotView::aura_stacks(uint32 spell_id, ObjectGuid on) const
{
if (AuraEntry const* a = find_aura(spell_id, on))
return a->stacks;
return 0;
}
NearbyUnit const* BotSnapshotView::enemy_without_my_aura(uint32 spell_id, float range) const
{
const float r2 = range * range;
const ObjectGuid me = s_->guid;
for (auto const& e : s_->combat.nearby_enemies)
{
if (e.hp <= 0) continue;
const float dx = e.x - s_->position.x;
const float dy = e.y - s_->position.y;
const float dz = e.z - s_->position.z;
if (dx*dx + dy*dy + dz*dz > r2) continue;
// Skip the current victim — multi-DoT logic is "OFF-target dot
// expansion"; the rotation handles the primary target separately.
if (e.guid == s_->combat.victim) continue;
// O(1) outbound check via my_auras_on_others_index. Pre-fix this
// was the hottest O(N²) loop in the view: 16 enemies × ~20
// outbound auras = 320 comparisons per call, called 2–3× per
// tick by every DoT-spec rotation. Now: one map lookup per
// enemy.
const uint64 key = (uint64(e.guid.GetCounter()) << 32) | uint64(spell_id);
if (s_->auras.my_auras_on_others_index.find(key) != nullptr)
continue;
return &e;
}
return nullptr;
}
bool BotSnapshotView::target_dispellable(DispelType type) const
{
for (auto const& a : s_->auras.target_auras)
if (a.is_harmful && a.dispel_type == type)
return true;
return false;
}
bool BotSnapshotView::self_dispellable(DispelType type) const
{
for (auto const& a : s_->auras.own_auras)
if (a.is_harmful && a.dispel_type == type)
return true;
return false;
}
bool BotSnapshotView::has_mechanic(uint32 mechanic) const
{
if (mechanic == 0) return false;
for (auto const& a : s_->auras.own_auras)
if (a.is_harmful && a.mechanic == mechanic)
return true;
return false;
}
NearbyUnit const* BotSnapshotView::target_info() const
{
if (s_->combat.current_target == ObjectGuid::Empty) return nullptr;
for (auto const& u : s_->combat.nearby_enemies)
if (u.guid == s_->combat.current_target) return &u;
for (auto const& u : s_->combat.nearby_friends)
if (u.guid == s_->combat.current_target) return &u;
return nullptr;
}
NearbyUnit const* BotSnapshotView::victim_info() const
{
if (s_->combat.victim == ObjectGuid::Empty) return nullptr;
// Attackers list is the freshest source — the bot's victim is virtually
// always also targeting the bot, so attackers is checked first to avoid
// an O(N) walk over nearby_enemies.
for (auto const& u : s_->combat.attackers)
if (u.guid == s_->combat.victim) return &u;
for (auto const& u : s_->combat.nearby_enemies)
if (u.guid == s_->combat.victim) return &u;
return nullptr;
}
NearbyUnit const* BotSnapshotView::lowest_hp_friend() const
{
NearbyUnit const* best = nullptr;
int32 best_pct = 101;
for (auto const& u : s_->combat.nearby_friends)
{
if (u.max_hp <= 0) continue;
if (u.hp <= 0) continue; // dead — heal would InvalidTarget
const int32 pct = (u.hp * 100) / u.max_hp;
if (pct < best_pct) { best_pct = pct; best = &u; }
}
return best;
}
NearbyUnit const* BotSnapshotView::highest_threat_attacker() const
{
// Snapshot stores attackers ordered by threat-to-me by the builder.
// Return the highest-threat FIGHTABLE attacker: skip untargetable trigger
// units (UNINTERACTIBLE stalkers) and pacified/dead entries so peel/taunt/
// assist consumers never get handed a unit they can't attack (the harbor
// 49521 flood would otherwise sit at attackers.front() and stall every
// threat consumer with Victim=0).
for (auto const& a : s_->combat.attackers)
{
if (a.hp <= 0) continue;
if (a.untargetable || a.is_pacified) continue;
return &a;
}
return nullptr;
}
NearbyUnit const* BotSnapshotView::interruptible_caster() const
{
// PvP-aware front: in a BG/arena, ALWAYS check enemy healers
// first via the nearby_enemies scan. Healers typically don't
// appear in `attackers` (they cast heals on their team, not on
// us), so the legacy attackers-only walk missed them entirely.
// Audit 2026-05-22 confirmed all spec APLs called
// interruptible_caster() directly without going through
// kick_target(in_pvp); routing here means every spec gets the
// healer-first behavior for free in BG/arena.
if (s_->bg.in_battleground)
if (auto const* h = enemy_healer_to_interrupt(30.0f))
return h;
// Skip casts about to finish — by the time the kick travels and lands, the
// cast would already have completed. 350ms is roughly one server tick + a
// generous net buffer, balanced against not pre-emptively skipping casts
// we could still catch on later targets.
constexpr int64_t kMinRemainingMs = 350;
// Human-pacing: also skip casts that have barely started. A human sees
// the cast bar appear, reads the spell name, then kicks at 30–80% of
// the bar — not at 50ms. We don't have total-cast-duration in the
// snapshot, but we have remaining-ms; an upper cap on remaining-ms
// approximates "wait for the cast to progress". Per-bot jitter
// (1200–2000ms upper cap) means in a group of bots, the interrupts
// stagger across the cast rather than all firing on observation.
// For short casts (≤1200ms total) the cap never blocks (cast_remaining
// is already below it on first observation), so quick casts stay
// interruptible without a hesitation hit.
const uint32 cap_jitter = 1200u + (uint32(s_->guid.GetCounter()) * 2654435761u) % 800u;
int64_t kMaxRemainingMs = int64_t(cap_jitter);
// PvE-coordinator interrupt rotation (dungeon/raid groups with an
// active plan): the guid-jitter only DECORRELATES bots — several can
// still land in overlapping windows and double-kick one cast while
// the next goes free. With an assigned rank, the window is exact:
// rank 0 keeps the human-pacing jitter cap (kick on sight), rank 1
// covers the cast's last 800ms (fires only if rank 0's kick didn't
// land — a landed kick clears is_casting first), ranks 2+ the last
// 550ms, off-rotation (0xFF, healer kicks) the last 450ms. Windows
// sit above the 350ms landing floor so every layer stays REACHABLE
// — a backstop window below the floor would silently never fire.
// This is the chokepoint every spec APL routes through, so all 39
// rotations inherit the schedule without per-spec edits.
if (s_->pve_order.active && !s_->bg.in_battleground)
{
const uint8 irank = s_->pve_order.interrupt_rank;
if (irank == 1) kMaxRemainingMs = 800;
else if (irank == 0xFF) kMaxRemainingMs = 450;
else if (irank >= 2) kMaxRemainingMs = 550;
// rank 0 keeps the jitter cap.
}
for (auto const& u : s_->combat.attackers)
if (u.is_casting && u.is_interruptible
&& u.cast_remaining.count() >= kMinRemainingMs
&& u.cast_remaining.count() <= kMaxRemainingMs)
return &u;
return nullptr;
}
NearbyUnit const* BotSnapshotView::enemy_healer_to_interrupt(float range) const
{
// Same 350ms tail-skip rationale as interruptible_caster().
constexpr int64_t kMinRemainingMs = 350;
// Same human-pacing upper cap as interruptible_caster — wait for the
// cast to actually progress before kicking. Healer interrupts in
// particular were the loudest robot tell: a 2.5s Flash Heal kicked
// at 50ms is unmistakable. Per-bot jitter here is independent from
// the trash-interrupt slot so two specs in the same bot don't sync.
const uint32 cap_jitter = 1300u + (uint32(s_->guid.GetCounter() ^ 0xA5A5u) * 2246822519u) % 900u;
const int64_t kMaxRemainingMs = int64_t(cap_jitter);
const float r2 = range * range;
NearbyUnit const* best = nullptr;
float best_dsq = r2;
for (auto const& u : s_->combat.nearby_enemies)
{
if (u.role != Role::Healer) continue;
if (!u.is_casting || !u.is_interruptible) continue;
if (u.cast_remaining.count() < kMinRemainingMs) continue;
if (u.cast_remaining.count() > kMaxRemainingMs) continue;
if (u.hp <= 0) continue;
const float dx = u.x - s_->position.x;
const float dy = u.y - s_->position.y;
const float dz = u.z - s_->position.z;
const float dsq = dx*dx + dy*dy + dz*dz;
if (dsq < best_dsq) { best_dsq = dsq; best = &u; }
}
return best;
}
NearbyUnit const* BotSnapshotView::kick_target(bool in_pvp, float range) const
{
if (in_pvp)
if (auto const* h = enemy_healer_to_interrupt(range))
return h;
return interruptible_caster();
}
NearbyUnit const* BotSnapshotView::enemy_near_friendly_carrier(float range) const
{
ObjectGuid const& fc_guid = s_->bg.friendly_flag_carrier;
if (fc_guid.IsEmpty()) return nullptr;
// Locate carrier's position from nearby_friends. If it's not in our
// snapshot the carrier is out of awareness range; nothing to peel for.
float cx = 0.f, cy = 0.f, cz = 0.f;
bool found = false;
for (auto const& f : s_->combat.nearby_friends)
{
if (f.guid == fc_guid)
{
cx = f.x; cy = f.y; cz = f.z;
found = true;
break;
}
}
if (!found) return nullptr;
const float r2 = range * range;
NearbyUnit const* best = nullptr;
float best_dsq = r2;
for (auto const& u : s_->combat.nearby_enemies)
{
if (u.hp <= 0) continue;
const float dx = u.x - cx;
const float dy = u.y - cy;
const float dz = u.z - cz;
const float dsq = dx*dx + dy*dy + dz*dz;
if (dsq < best_dsq) { best_dsq = dsq; best = &u; }
}
return best;
}
size_t BotSnapshotView::enemies_within(float range) const
{
const float r2 = range * range;
size_t n = 0;
for (auto const& u : s_->combat.nearby_enemies)
{
// Never count un-fightable trigger units (UNINTERACTIBLE stalkers /
// pacified) as "nearby enemies" — they exert no pressure and would
// mis-inflate AoE/defensive/kite density gates (harbor 49521 flood).
if (u.untargetable || u.is_pacified) continue;
const float dx = u.x - s_->position.x;
const float dy = u.y - s_->position.y;
const float dz = u.z - s_->position.z;
if (dx*dx + dy*dy + dz*dz <= r2) ++n;
}
return n;
}
NearbyUnit const* BotSnapshotView::path_threat(float tx, float ty,
float max_forward,
float half_width,
ObjectGuid exclude_guid) const
{
const float bx = s_->position.x;
const float by = s_->position.y;
const float dx = tx - bx;
const float dy = ty - by;
const float dist = std::sqrt(dx*dx + dy*dy);
if (dist < 1.0f) return nullptr; // already on target — corridor undefined
// Unit direction vector toward (tx, ty).
const float ux = dx / dist;
const float uy = dy / dist;
const float forward_cap = std::min(max_forward, dist);
const float lateral_sq = half_width * half_width;
const uint8 me_level = s_->identity.level;
NearbyUnit const* best = nullptr;
float best_forward = forward_cap;
for (auto const& u : s_->combat.nearby_enemies)
{
if (u.hp <= 0) continue; // dead / corpse
if (u.is_player) continue; // PvP path differs
// Never pull a no-XP / pacified creature as a path threat: training
// dummies and event props are immortal or grant nothing, so attacking
// one wedges the bot InCombat forever (the pull rule re-aggros it every
// tick faster than combat:disengage_no_progress can shake it). Pacified
// is the robust catch for dummies that lack the NO_XP flag in data.
if (u.no_xp_kill || u.is_pacified) continue;
if (!exclude_guid.IsEmpty() && u.guid == exclude_guid) continue;
// Grey-con mobs don't aggro and don't threaten — ignore.
// TC's aggro line: a creature with level + 5 < attacker.level
// won't initiate aggro at any range (see Creature::CanAggro).
// Mirror that exactly so we don't flag mobs the bot could walk
// past unharmed.
if (me_level >= 6 && u.level + 5 < me_level) continue;
// Symmetric UPPER cap: never flag a mob so far above the bot that pulling
// it is suicide — the caller start_attacks path threats, so without this a
// low bot whose quest corridor crosses high-level content pulls a mob it
// cannot scratch and gets one-shot (live: Morthan L9 start_attacking L50
// Tirisfal war-campaign Blighted Soldiers en route to a classic quest).
// +10 still allows pulling genuinely tough-but-fightable mobs to clear a
// path; beyond that it isn't a "pull" decision, it's a death. (NOT applied
// to path_threat_count — a lethal mob still COUNTS as corridor danger.)
if (u.level > me_level + 10) continue;
const float vx = u.x - bx;
const float vy = u.y - by;
const float fwd = vx * ux + vy * uy;
if (fwd <= 0.0f) continue; // behind us
if (fwd >= best_forward) continue; // farther than current best
// Lateral distance from corridor axis.
const float lat_x = vx - fwd * ux;
const float lat_y = vy - fwd * uy;
if (lat_x * lat_x + lat_y * lat_y > lateral_sq) continue;
best = &u;
best_forward = fwd;
}
return best;
}
size_t BotSnapshotView::path_threat_count(float tx, float ty,
float max_forward,
float half_width,
ObjectGuid exclude_guid) const
{
const float bx = s_->position.x;
const float by = s_->position.y;
const float dx = tx - bx;
const float dy = ty - by;
const float dist = std::sqrt(dx*dx + dy*dy);
if (dist < 1.0f) return 0;
const float ux = dx / dist;
const float uy = dy / dist;
const float forward_cap = std::min(max_forward, dist);
const float lateral_sq = half_width * half_width;
const uint8 me_level = s_->identity.level;
size_t count = 0;
for (auto const& u : s_->combat.nearby_enemies)
{
if (u.hp <= 0) continue;
if (u.is_player) continue;
if (!exclude_guid.IsEmpty() && u.guid == exclude_guid) continue;
if (me_level >= 6 && u.level + 5 < me_level) continue;
const float vx = u.x - bx;
const float vy = u.y - by;
const float fwd = vx * ux + vy * uy;
if (fwd <= 0.0f) continue;
if (fwd >= forward_cap) continue;
const float lat_x = vx - fwd * ux;
const float lat_y = vy - fwd * uy;
if (lat_x * lat_x + lat_y * lat_y > lateral_sq) continue;
++count;
}
return count;
}
NearbyUnit const* BotSnapshotView::untaunted_enemy(float range) const
{
const float r2 = range * range;
const ObjectGuid me = s_->guid;
for (auto const& e : s_->combat.nearby_enemies)
{
if (e.victim.IsEmpty()) continue; // not engaged
if (e.victim == me) continue; // already on us
if (e.hp <= 0) continue;
const float dx = e.x - s_->position.x;
const float dy = e.y - s_->position.y;
const float dz = e.z - s_->position.z;
if (dx*dx + dy*dy + dz*dz > r2) continue;
return &e;
}
return nullptr;
}
bool BotSnapshotView::has_item(uint32 entry) const
{
for (auto const& it : s_->inventory.bag_items)
if (it.entry == entry)
return true;
return false;
}
bool BotSnapshotView::knows_spell(uint32 spell_id) const
{
return std::binary_search(s_->spellbook.known_spells.begin(), s_->spellbook.known_spells.end(), spell_id);
}
QuestEntry const* BotSnapshotView::find_quest(uint32 quest_id) const
{
uint32 const* pi = s_->quest_log.quests_index.find(quest_id);
if (!pi) return nullptr;
const uint32 i = *pi;
if (i >= s_->quest_log.quests.size()) return nullptr;
return &s_->quest_log.quests[i];
}
bool BotSnapshotView::has_drainable_mail() const
{
return next_drainable_mail() != nullptr;
}
MailEntry const* BotSnapshotView::next_drainable_mail() const
{
for (auto const& m : s_->mailbox.mail)
{
// Pending-deliver: server hasn't released the attachments yet.
if (m.deliver_in_sec > 0) continue;
// L-P1a: skip Cash-On-Delivery mail entirely. Auto-paying COD in
// autonomous drain is a gold-drain exploit (a hostile sender mails
// a COD item; the bot would pay to take it). Leave COD mail for the
// owner to handle manually.
if (m.cod > 0) continue;
// Returned mails sit in the bot's box but can't be re-returned;
// include them — the AI may still want to delete or take items
// (returns from auctions land here too).
if (m.money > 0 || m.item_count > 0) return &m;
}
return nullptr;
}
AuraEntry const* BotSnapshotView::find_pet_aura(uint32 spell_id) const
{
for (auto const& a : s_->pet.pet_auras)
if (a.spell_id == spell_id) return &a;
return nullptr;
}
// ---------- World metadata accessors -----------------------------------
//
// All four methods do a linear scan of the metadata store filtered by
// (map_id, kind). The store is bounded at thousands of points total
// across all maps; per-bot-tick this is ~hundreds of float-compares,
// comparable to a single nearby_enemies scan. No per-snapshot caching
// needed (cache would need invalidation on `meta add/delete` and the
// linear scan is cheap enough not to be worth it).
//
// Mapping: BotSnapshotView::*_metadata APIs take a `uint32 kind`
// argument so the public view header doesn't depend on WorldMetadata.h
// (avoids dragging that include into every TU using snapshots). Callers
// cast the WorldMetadataKind enum value to uint32 at the call site.
float BotSnapshotView::metadata_dist_sq(uint32 kind) const
{
using ::Playerbot::V2::World::WorldMetadataStore;
using ::Playerbot::V2::World::WorldMetadataKind;
auto const& store = WorldMetadataStore::Instance();
if (store.Size() == 0) return FLT_MAX;
auto rows = store.RecordsForMapAndKind(
s_->position.map_id, WorldMetadataKind(kind));
if (rows.empty()) return FLT_MAX;
const float bx = s_->position.x;
const float by = s_->position.y;
float best = FLT_MAX;
for (auto const& r : rows)
{
const float dx = r.x - bx;
const float dy = r.y - by;
const float d2 = dx*dx + dy*dy;
if (d2 < best) best = d2;
}
return best;
}
bool BotSnapshotView::inside_metadata(uint32 kind) const
{
using ::Playerbot::V2::World::WorldMetadataStore;
using ::Playerbot::V2::World::WorldMetadataKind;
auto const& store = WorldMetadataStore::Instance();
if (store.Size() == 0) return false;
auto rows = store.RecordsForMapAndKind(
s_->position.map_id, WorldMetadataKind(kind));
if (rows.empty()) return false;
const float bx = s_->position.x;
const float by = s_->position.y;
for (auto const& r : rows)
{
const float dx = r.x - bx;
const float dy = r.y - by;
if (dx*dx + dy*dy <= r.radius * r.radius)
return true;
}
return false;
}
bool BotSnapshotView::in_city() const
{
return inside_metadata(uint32(::Playerbot::V2::World::WorldMetadataKind::City));
}
bool BotSnapshotView::in_village() const
{
return inside_metadata(uint32(::Playerbot::V2::World::WorldMetadataKind::Village));
}
bool BotSnapshotView::in_danger_zone() const
{
return inside_metadata(uint32(::Playerbot::V2::World::WorldMetadataKind::Danger));
}
bool BotSnapshotView::any_metadata_within(uint32 kind, float range) const
{
using ::Playerbot::V2::World::WorldMetadataStore;
using ::Playerbot::V2::World::WorldMetadataKind;
auto const& store = WorldMetadataStore::Instance();
if (store.Size() == 0) return false;
auto rows = store.RecordsForMapAndKind(
s_->position.map_id, WorldMetadataKind(kind));
if (rows.empty()) return false;
const float bx = s_->position.x;
const float by = s_->position.y;
const float r2 = range * range;
for (auto const& r : rows)
{
const float dx = r.x - bx;
const float dy = r.y - by;
if (dx*dx + dy*dy <= r2)
return true;
}
return false;
}
} // namespace Playerbot
@@ -0,0 +1,983 @@
// BotSnapshotView - Ergonomic, stateless facade over BotSnapshot const&.
// Used by APL rules and state dispatch functions. CONTRACTS.md §2.2.
#pragma once
#include "BotSnapshot.h"
#include <algorithm>
#include <deque>
namespace Playerbot {
class BotSnapshotView
{
public:
explicit BotSnapshotView(BotSnapshot const& s) : s_(&s) {}
// Identity
BotId bot_id() const { return s_->bot_id; }
std::string const& name() const { return s_->identity.name; }
uint8 level() const { return s_->identity.level; }
uint8 race() const { return s_->identity.race; }
// 0 = unknown, 1 = Alliance, 2 = Horde. Mercenary-aware.
uint8 team() const { return s_->identity.team; }
bool is_alliance() const { return s_->identity.team == 1u; }
bool is_horde() const { return s_->identity.team == 2u; }
uint8 cls() const { return s_->identity.cls; }
uint32 spec() const { return s_->identity.spec; }
uint8 gender() const { return s_->identity.gender; }
uint32 faction() const { return s_->identity.faction; }
ObjectGuid guid() const { return s_->guid; }
// Vital
bool in_combat() const { return s_->vitals.in_combat; }
bool is_alive() const { return s_->vitals.is_alive; }
bool is_stunned() const { return s_->vitals.is_stunned; }
bool is_silenced() const { return s_->vitals.is_silenced; }
bool is_rooted() const { return s_->vitals.is_rooted; }
bool is_pvp() const { return s_->vitals.is_pvp; }
// True when any state would block cast attempts (stun + silence + active
// cast). Caller should still check spell-specific gates (CD, resource).
bool can_cast() const { return !s_->vitals.is_stunned && !s_->vitals.is_silenced &&
!(s_->cast.is_casting && s_->cast.current_cast_remaining.count() > 0); }
bool has_resurrect_request() const { return s_->death.has_resurrect_request; }
bool is_ghost() const { return s_->death.is_ghost; }
bool has_corpse() const { return s_->death.has_corpse; }
uint32 corpse_map_id() const { return s_->death.corpse_map_id; }
float corpse_x() const { return s_->death.corpse_x; }
float corpse_y() const { return s_->death.corpse_y; }
float corpse_z() const { return s_->death.corpse_z; }
int64 corpse_reclaim_at_unix() const { return s_->death.corpse_reclaim_at_unix; }
float corpse_to_graveyard_dist() const { return s_->death.corpse_to_graveyard_dist; }
bool has_group_invite() const { return s_->social_events.has_group_invite; }
ObjectGuid group_invite_leader() const { return s_->social_events.group_invite_leader; }
bool has_summon_pending() const { return s_->social_events.has_summon_pending; }
bool has_guild_invite() const { return s_->guild.has_invite; }
uint64 guild_invite_id() const { return s_->guild.invite_id; }
uint64 guild_id() const { return s_->guild.id; }
bool in_guild() const { return s_->guild.id != 0; }
uint8 guild_rank_id() const { return s_->guild.rank_id; }
uint16 guild_member_count() const { return s_->guild.member_count; }
uint16 guild_online_member_count() const { return s_->guild.online_member_count; }
// SC-P3c: online guild members that are NOT bots (real humans). Used to
// gate ambient/self-initiated guild chatter so bots don't babble to an
// empty (bot-only) guild. See GuildState::online_human_member_count.
uint16 guild_online_human_member_count() const { return s_->guild.online_human_member_count; }
uint64 guild_rival_id() const { return s_->guild.rival_id; }
uint8 guild_active_event_kind() const { return s_->guild.active_event_kind; }
bool guild_has_pending_callout() const { return s_->guild.has_pending_callout; }
// #4C: bot-managed guild flag, resolved by the builder (world thread) so
// guild idle rules avoid a per-tick BotGuildMgr::IsBotManaged lookup.
bool guild_is_bot_managed() const { return s_->guild.is_bot_managed; }
// #4A/#4C archetype projection. Idle rules read the read-only slice the
// builder mirrored from BotAI::archetype() (no thread crossing).
uint8 archetype_id() const { return s_->archetype.archetype_id; }
uint8 archetype_dominant_activity() const { return s_->archetype.dominant_activity; }
uint8 archetype_econ_profile() const { return s_->archetype.econ_profile; }
// #4B-2(a) craft-order board projection. Read-only slice the post/claim
// idle rules consult so they never lock the board from a worker thread.
CraftOrderState const& craft_orders() const { return s_->craft_orders; }
// Role-affinity lean [0=Tank,1=Healer,2=Dps] mirrored from the archetype.
float archetype_role_affinity(uint8 slot) const
{ return slot < s_->archetype.role_affinity.size() ? s_->archetype.role_affinity[slot] : 0.f; }
bool lfg_in_queue() const { return s_->lfg.in_queue; }
bool lfg_in_dungeon() const { return s_->lfg.in_dungeon; }
uint16 completed_quest_count() const { return s_->quest_log.completed_quest_count; }
std::string const& owner_name() const { return s_->owner_name; }
bool has_owner_character() const { return !s_->owner_name.empty(); }
bool has_duel_request() const { return s_->social_events.has_duel_request; }
ObjectGuid duel_initiator() const { return s_->social_events.duel_initiator; }
bool duel_initiator_is_friend() const { return s_->social_events.duel_initiator_is_friend; }
bool has_quest_share() const { return s_->social_events.shared_quest_id != 0; }
bool has_trade_request() const { return s_->social_events.has_trade_request; }
uint32 lfg_proposal_id() const { return s_->lfg.proposal_id; }
bool has_lfg_proposal() const { return s_->lfg.proposal_id != 0; }
bool lfg_role_check_pending() const { return s_->lfg.role_check_pending; }
uint8 lfg_published_role() const { return s_->lfg.published_role; }
bool lfg_vote_kick_active() const { return s_->lfg.vote_kick_active; }
size_t auctions_owned_count() const { return s_->auction.auctions_owned.size(); }
// #4B buy-side: cheapest current listings for wanted reagents. Empty
// unless the bot is at an auctioneer (on-demand scan).
std::vector<BotSnapshot::BuyableListing> const& buyable_listings() const
{ return s_->auction.buyable_listings; }
// #4B-1 Part 3 buy-side: cheapest commodity unit price + available qty
// per wanted reagent. Empty unless the bot is at an auctioneer. Most
// craft reagents are commodities and land here, not in buyable_listings.
std::vector<BotSnapshot::BuyableCommodity> const& buyable_commodities() const
{ return s_->auction.buyable_commodities; }
ObjectGuid quest_share_sender() const { return s_->social_events.quest_share_sender; }
uint32 shared_quest_id() const { return s_->social_events.shared_quest_id; }
uint16 food_drink_count() const { return s_->consumables.food_drink_count; }
uint16 potion_count() const { return s_->consumables.potion_count; }
uint16 bandage_count() const { return s_->consumables.bandage_count; }
int32 hp() const { return s_->vitals.hp; }
int32 max_hp() const { return s_->vitals.max_hp; }
// int64 intermediate prevents overflow at high HP pools — modern raid
// characters can hit 50M+ max HP, and (hp * 100) overflows int32 there.
int32 hp_pct() const { return s_->vitals.max_hp > 0 ? static_cast<int32>((int64_t(s_->vitals.hp) * 100) / s_->vitals.max_hp) : 0; }
int32 power(uint8 type) const { return type < s_->vitals.power.size() ? s_->vitals.power[type] : 0; }
int32 max_power(uint8 type) const { return type < s_->vitals.max_power.size() ? s_->vitals.max_power[type] : 0; }
int32 power_pct(uint8 type) const
{
const int32 maxp = max_power(type);
return maxp > 0 ? (power(type) * 100) / maxp : 0;
}
// Position & motion
bool is_moving() const { return s_->movement.is_moving; }
bool is_indoors() const { return s_->area.is_indoors; }
bool is_swimming() const { return s_->movement.is_swimming; }
bool on_transport() const { return s_->environment.on_transport; }
bool transport_stopped() const { return s_->environment.transport_stopped; }
bool transport_is_ship() const { return s_->environment.transport_is_ship; }
bool is_underwater() const { return s_->environment.is_underwater; }
float water_surface_z() const { return s_->environment.water_surface_z; }
bool is_in_damaging_liquid() const { return s_->environment.is_in_damaging_liquid; }
// Open-world water-escape focus (FIX #12): nearest DRY footing the builder
// resolved while the bot is in swim-water; idle:water_escape drives to it.
bool water_escape_valid() const { return s_->environment.water_escape_valid; }
float water_escape_x() const { return s_->environment.water_escape_x; }
float water_escape_y() const { return s_->environment.water_escape_y; }
float water_escape_z() const { return s_->environment.water_escape_z; }
bool is_flying() const { return s_->movement.is_flying; }
bool is_mounted() const { return s_->movement.is_mounted; }
uint32 map_id() const { return s_->position.map_id; }
// BG-orphan signature helper: standing on a battleground/arena map while
// no Battleground object claims the bot (end-of-match removal missed it).
bool is_bg_orphan() const
{ return s_->position.map_is_bg_or_arena && !s_->bg.in_battleground; }
uint32 zone_id() const { return s_->area.zone_id; }
uint32 area_id() const { return s_->area.area_id; }
uint32 map_difficulty() const { return s_->instance_ctx.map_difficulty; }
bool is_in_instance() const { return s_->instance_ctx.is_in_instance; }
bool is_in_dungeon() const { return s_->instance_ctx.is_in_dungeon; }
bool is_in_raid() const { return s_->instance_ctx.is_in_raid; }
bool is_sanctuary() const { return s_->vitals.is_sanctuary; }
bool is_ffa_pvp() const { return s_->vitals.is_ffa_pvp; }
void position(float& x, float& y, float& z) const { x = s_->position.x; y = s_->position.y; z = s_->position.z; }
// Cooldowns / readiness
bool gcd_active() const { return s_->cooldowns.gcd_remaining.count() > 0; }
Ms gcd_remaining() const { return s_->cooldowns.gcd_remaining; }
bool is_casting() const { return s_->cast.is_casting; }
uint32 current_cast_spell_id() const { return s_->cast.current_cast_spell_id; }
Ms current_cast_remaining() const { return s_->cast.current_cast_remaining; }
ObjectGuid current_cast_target() const { return s_->cast.current_cast_target; }
uint32 last_cast_spell_id() const { return s_->cast.last_cast_spell_id; }
bool is_ready(uint32 spell_id) const;
Ms cd_remaining(uint32 spell_id) const;
uint8 charges(uint32 spell_id) const;
// True if `spell_id` can be cast while moving (instant cast OR no
// Movement interrupt flag). Looked up from SpellInfo via the global
// SpellMgr — that data is read-only after init, so the call is safe
// from AI worker threads. APL rules pair this with `is_moving()` to
// gate hard-cast spells: `if (s.is_moving() && !s.can_cast_while_moving(SP)) return false;`.
// Returns true when the spell is unknown (no filtering — let the cast
// attempt and its server-side gates decide).
bool can_cast_while_moving(uint32 spell_id) const;
// Auras
bool has_aura(uint32 spell_id, ObjectGuid on = ObjectGuid::Empty) const;
AuraEntry const* find_aura(uint32 spell_id, ObjectGuid on = ObjectGuid::Empty) const;
// Stack count of the bot's `spell_id` aura on `on`. Returns 0 when absent.
// Use for proc spending (Maelstrom Weapon at 5, Demonic Core at 4, etc.)
// and stack-aware refresh (Festering Wound, Vampiric Touch dot ramp).
uint8 aura_stacks(uint32 spell_id, ObjectGuid on = ObjectGuid::Empty) const;
// Find an enemy in `range` yards that the bot could DoT but doesn't yet
// carry the bot's `spell_id`. Used by multi-DoT specs (Affliction Warlock
// Agony/Corruption/UA, Boomy Moonfire/Sunfire, Shadow Priest SW:P/VT,
// Feral Rake/Rip). Returns nullptr when every visible enemy already has
// the dot, or when the spec isn't on the multi-dot scan list (the
// builder only populates outbound auras on enemies for those specs).
NearbyUnit const* enemy_without_my_aura(uint32 spell_id, float range = 40.0f) const;
bool target_dispellable(DispelType type) const;
// True if the bot itself has a harmful aura of the given dispel type.
// Solo / non-grouped healers need this — group-level dispel_candidate()
// returns nullptr when there's no group, so a solo Resto Druid couldn't
// Nature's Cure their own Curse without it.
bool self_dispellable(DispelType type) const;
// True if any harmful aura on the bot carries the given Mechanic
// (e.g. MECHANIC_FEAR=5, MECHANIC_STUN=12, MECHANIC_SILENCE=9). Used
// by reactive defensives like Tremor Totem (fear), trinkets (stun/root),
// Berserker Rage (fear/sap immunity).
bool has_mechanic(uint32 mechanic) const;
// Targets
ObjectGuid current_target() const { return s_->combat.current_target; }
// True only when current_target is a live, legally-attackable unit
// (world-thread IsValidAttackTarget). Autonomy gates use this instead
// of selection presence — a selfbot owner's friendly/self selection
// must not freeze the quest/travel/wander cascade.
bool current_target_hostile() const { return s_->combat.current_target_hostile; }
ObjectGuid victim() const { return s_->combat.victim; }
NearbyUnit const* target_info() const;
// Look up the bot's auto-attack victim() in attackers / nearby_enemies.
// Use this for predicates that should reason about the bot's actual cast
// target — execute-range checks, gap-close distance, ground-AoE
// placement. target_info() is keyed off `current_target` which can be a
// friendly unit (heal target) for healers and is also stale until tab
// updates; victim_info() always reflects the unit DPS abilities will hit.
NearbyUnit const* victim_info() const;
NearbyUnit const* lowest_hp_friend() const;
NearbyUnit const* highest_threat_attacker() const;
// First attacker in the threat list that is currently casting an
// interruptible spell. APL rules use this for kick / counterspell.
NearbyUnit const* interruptible_caster() const;
// PvP-priority interrupt target. Walks nearby_enemies (NOT just
// attackers) for the closest enemy whose role is Healer AND who is
// currently casting an interruptible spell. Returns nullptr if no
// such healer is in range.
//
// Why this is distinct from `interruptible_caster()`: the existing
// accessor only returns from `combat.attackers` — a friendly-target
// enemy healer is INVISIBLE to it because they're not attacking the
// bot. In PvP, the enemy healer hardly ever attacks; APLs that gate
// interrupts on `interruptible_caster()` alone never kick healers,
// which is the highest-impact PvP CC target.
//
// PvP APLs should use this FIRST, then fall back to
// `interruptible_caster()` if it returns nullptr.
NearbyUnit const* enemy_healer_to_interrupt(float range = 30.f) const;
// PvP-aware interrupt target selector. Prefers enemy_healer_to_interrupt
// when given an `in_pvp` hint (battleground or arena), then falls back
// to interruptible_caster(). Predicates call this with the spell's
// actual range. Returns nullptr when nothing kickable is in range.
//
// This is the canonical interrupt picker — APLs that just need "who
// should I kick" should call this rather than interruptible_caster()
// directly, so the picker can evolve (healer escalation, focus-target,
// etc.) in one place.
NearbyUnit const* kick_target(bool in_pvp, float range = 30.f) const;
// Returns closest enemy within `range` of the friendly flag carrier
// (Defense of the Ancients / WSG style). The carrier itself is located
// by matching `bg.friendly_flag_carrier` against `nearby_friends`. If
// the carrier isn't in our snapshot (out of range), returns nullptr.
//
// Use case: FC-peel rules. Warrior Hamstring, Rogue Crippling Poison,
// Druid Feral Maim should fire on enemies near the carrier — not just
// on the bot's current victim. Returns nullptr when not in a BG that
// has a friendly flag carrier.
NearbyUnit const* enemy_near_friendly_carrier(float range = 8.f) const;
// Count of units actively attacking us. Used by AoE rule predicates.
// NOTE: raw size — INCLUDES untargetable trigger units (49521 stalkers).
// For "real combat density" use fightable_attackers_count() below.
size_t attackers_count() const { return s_->combat.attackers.size(); }
// Stalker-free count of FIGHTABLE attackers (excludes UNINTERACTIBLE /
// pacified / dead) — see CombatTargetsState::fightable_attackers. Combat
// and advance density gates read THIS so the untargetable-trigger flood
// (Deadmines harbor: 8-12 no-damage "Vanessa Lightning Stalker" 49521)
// can neither jam a pull-segmentation gate nor mis-fire AoE/panic rules.
size_t fightable_attackers_count() const { return s_->combat.fightable_attackers; }
// True when any active attacker is a hostile Player (vs a creature).
// Drives the open-world-PvP awareness rules: bots flee at a higher HP
// threshold against players because real players are unpredictable
// (kiting, vanishes, escape CDs) while mobs are not. Cheap O(N≤16) walk.
bool under_player_attack() const
{
for (auto const& a : s_->combat.attackers)
if (a.is_player) return true;
return false;
}
// Count of nearby hostile units within `range` yards of the bot.
size_t enemies_within(float range) const;
// Count of units ACTIVELY ATTACKING the bot that are within `range`
// yards (i.e. real melee danger). Distinct from enemies_within, which
// counts ANY nearby hostile — including a pet-class bot's pet's own
// targets that are not attacking the owner at all. "Kite N melee"
// decisions must use THIS: a full-HP hunter whose pet pulled a camp had
// enemies_within(8) >= 2 yet attackers_count() == 0, and so leapt away
// from its pet's fight forever (Zekani, 60min in-combat, 0 XP).
size_t melee_attackers_within(float range) const
{
float bx, by, bz; position(bx, by, bz);
const float r2 = range * range;
size_t n = 0;
for (auto const& a : s_->combat.attackers)
{
if (a.hp <= 0) continue;
// Skip un-fightable trigger units (UNINTERACTIBLE stalkers /
// pacified): they exert no melee pressure, so counting them as
// "melee on me" mis-drives kite/flee decisions (the harbor
// northward-fragmentation; mirrors the pet-camp guard above).
if (a.untargetable || a.is_pacified) continue;
const float dx = a.x - bx, dy = a.y - by, dz = a.z - bz;
if (dx * dx + dy * dy + dz * dz <= r2) ++n;
}
return n;
}
// Tank helper: first nearby enemy that is in combat with someone other than
// this bot (and not already in our attackers list). Used to drive taunt /
// threat-grab behavior. Returns nullptr if every nearby enemy is on us.
NearbyUnit const* untaunted_enemy(float range = 30.f) const;
// Directional look-ahead. Returns the closest threatening hostile
// creature inside a `half_width`-yard corridor from the bot's
// current position toward (tx, ty), bounded at `max_forward` yards
// forward. Excludes `exclude_guid` (use for the bot's intended
// walk target so we don't flag the very mob we're trying to
// reach), corpses, players (PvP routing differs — see
// `under_player_attack`), and grey-conned mobs (level + 2 < bot
// level → no aggro / trivial). Returns nullptr when the corridor
// is clear.
//
// Drives the "look-ahead before moving into a mob pack" behavior
// that humans do reflexively: scan the planned path, route around
// or pull single targets before they pull the whole pack. Without
// this, bots walk in a straight line through aggro radii and die.
NearbyUnit const* path_threat(float tx, float ty,
float max_forward = 35.0f,
float half_width = 10.0f,
ObjectGuid exclude_guid = ObjectGuid::Empty) const;
// Same filters as path_threat but counts ALL hostiles in the
// corridor instead of returning the nearest. Drives the lateral
// re-route rule: when ≥3 hostiles cluster on the path, pulling
// the nearest chain-aggros the rest. Players sidestep around
// the pack — emit a perpendicular move instead.
size_t path_threat_count(float tx, float ty,
float max_forward = 35.0f,
float half_width = 10.0f,
ObjectGuid exclude_guid = ObjectGuid::Empty) const;
// Inventory
bool has_item(uint32 entry) const;
uint8 bag_free_slots() const { return s_->bags.bag_free_slots; }
int32 gold() const { return s_->inventory.gold; }
uint32 xp() const { return s_->identity.xp; }
uint32 xp_for_level() const { return s_->identity.xp_for_level; }
uint32 rest_bonus_xp() const { return s_->identity.rest_bonus_xp; }
uint32 honor_xp() const { return s_->identity.honor_xp; }
uint32 honor_xp_for_next() const { return s_->identity.honor_xp_for_next; }
uint32 honor_level() const { return s_->identity.honor_level; }
uint32 honor_kills_today() const { return s_->identity.honor_kills_today; }
uint32 honor_kills_yesterday() const { return s_->identity.honor_kills_yesterday; }
uint32 honor_kills_lifetime() const { return s_->identity.honor_kills_lifetime; }
uint8 upgrades_pending() const { return s_->bags.upgrades_pending; }
uint32 published_at_ms() const { return s_->published_at_ms; }
uint8 pet_level() const { return s_->pet.pet_level; }
uint32 pet_family() const { return s_->pet.pet_family; }
bool pet_in_combat() const { return s_->pet.pet_in_combat; }
// Combat stats — post-DR percent × 100 (so 1234 = 12.34%). Sourced from
// Player::GetRatingBonusValue at builder time. Used by the /sheet whisper
// and by gear-quality heuristics; APL doesn't currently consume them
// because spec-DPS shapes prefer to spend procs/CDs over rating thresholds.
int16 crit_pct_x100() const { return s_->secondary_stats.crit_pct_x100; }
int16 haste_pct_x100() const { return s_->secondary_stats.haste_pct_x100; }
int16 mastery_pct_x100() const { return s_->secondary_stats.mastery_pct_x100; }
int16 versatility_pct_x100() const { return s_->secondary_stats.versatility_pct_x100; }
int16 resilience_pct_x100() const { return s_->secondary_stats.resilience_pct_x100; }
int16 pvp_power_pct_x100() const { return s_->secondary_stats.pvp_power_pct_x100; }
// Equipment
uint16 average_item_level() const { return s_->inventory.average_item_level; }
EquippedItem const& equipped(uint8 slot) const { return s_->inventory.equipped[slot < 19 ? slot : 0]; }
// Per-slot equipped-bag info (index 0..3 = bag slots 30-33). capacity 0
// = empty slot; subclass 0xFF = none. See BagsState for semantics.
std::array<uint8, 4> const& equipped_bag_capacity() const { return s_->bags.equipped_bag_capacity; }
std::array<uint8, 4> const& equipped_bag_subclass() const { return s_->bags.equipped_bag_subclass; }
// Lowest durability across populated equipment slots (0 = broken,
// 100 = pristine). Returns 100 when no equipped items track durability —
// vendor-trigger callers can compare against a low threshold safely.
uint8 lowest_equipped_durability_pct() const
{
uint8 lo = 100;
bool seen = false;
for (auto const& e : s_->inventory.equipped)
{
if (e.entry == 0) continue;
seen = true;
if (e.durability_pct < lo) lo = e.durability_pct;
}
return seen ? lo : 100;
}
// First trinket on-use spell that's actually ready to fire. Returns 0
// when the bot has no equipped on-use trinket or both are on cooldown.
// Trinkets live in EQUIPMENT_SLOT_TRINKET1 (13) and TRINKET2 (14); the
// builder pre-resolved each slot's ON_USE spell so we just probe the
// ready bit. InCombat fires this on cooldown to extract trinket value
// without per-tick ItemTemplate lookups.
uint32 ready_on_use_trinket() const
{
constexpr uint8 TRINKET1 = 13, TRINKET2 = 14;
for (uint8 slot : {TRINKET1, TRINKET2})
{
const uint32 sid = s_->inventory.equipped[slot].on_use_spell_id;
if (sid != 0 && is_ready(sid)) return sid;
}
return 0;
}
// Active path. has_path_destination() is false when the bot isn't in
// CHASE / FOLLOW / POINT motion. Rules can check distance-to-destination
// to avoid re-emitting MoveTo every tick.
bool has_path_destination() const { return s_->path.path_end_map_id != 0; }
void path_destination(float& x, float& y, float& z) const
{ x = s_->path.path_end_x; y = s_->path.path_end_y; z = s_->path.path_end_z; }
ObjectGuid path_target() const { return s_->path.path_target; }
// Path-block telemetry — Builder copies BotAI's path_blocked_count
// + last_path_blocked_ms. Travel/quest rules read these to detect
// anchor wedge: snapshot the count on first emit, fall through if
// it grows by >=3 over the next ticks (anchor unreachable). Use
// `path_blocked_recently(now_ms, window)` for the common case of
// "had a block in the last N ms".
uint32 path_blocked_count() const { return s_->path_telemetry.count; }
uint32 last_path_blocked_ms() const { return s_->path_telemetry.last_ms; }
bool path_blocked_recently(uint32 now_ms, uint32 window_ms = 5000) const
{
const uint32 last = s_->path_telemetry.last_ms;
return last != 0 && (now_ms - last) < window_ms;
}
// Spellbook
bool knows_spell(uint32 spell_id) const;
// Talents (Talent.db2 ids — NOT spell ids; resolve via sTalentStore for
// the granted spell). Returns the active spec's talent picks; empty when
// the bot has none chosen yet.
std::vector<uint32> const& active_talents() const { return s_->spellbook.active_talents; }
bool has_talent(uint32 talent_id) const
{
return std::binary_search(s_->spellbook.active_talents.begin(),
s_->spellbook.active_talents.end(), talent_id);
}
// Glyphs slotted on the active spec. GlyphProperties.db2 ids.
std::vector<uint32> const& active_glyphs() const { return s_->spellbook.active_glyphs; }
// True when the active combat trait config is the StarterBuild flagged by
// TraitMgr's curated init. Drives the "auto-extend on ding" rule —
// re-fires apply_starter_talents only when starter, never wipes a custom
// build the owner picked.
bool is_starter_build() const { return s_->spellbook.is_starter_build; }
bool has_glyph(uint32 glyph_id) const
{
for (uint32 g : s_->spellbook.active_glyphs) if (g == glyph_id) return true;
return false;
}
// Quest log
QuestEntry const* find_quest(uint32 quest_id) const;
// The single objective the bot should be actively pursuing. Empty
// (current_quest_id() == 0) when the bot has nothing actionable.
QuestObjectiveEntry const& current_objective() const { return s_->quest_log.current_objective; }
uint32 current_quest_id() const { return s_->quest_log.current_quest_id; }
// True when the bot has something to walk toward: a real quest objective,
// OR a synthesized R7 leveling-zone relocation goal (current_quest_id==0
// but current_objective_poi is a far quest hub). The POI-driven travel
// rules key off this; quest-specific rules additionally gate on
// current_quest_id()/objective().type so a relocation leaves them inert.
bool has_current_objective() const { return s_->quest_log.current_quest_id != 0 || s_->quest_log.objective_is_relocation; }
// R7: the current objective is a synthesized cross-map relocation goal,
// not a real quest. Lets the few quest-assuming sites (progress observer,
// "truly idle" wander gate) stay correct.
bool objective_is_relocation() const { return s_->quest_log.objective_is_relocation; }
// Stuck on a same-map objective the navmesh can't reach but the travel graph
// can via a non-walk bridge (elevator / AT-teleport / intra-map ship) — drive
// the graph route. See BotSnapshot::objective_needs_bridge.
bool objective_needs_bridge() const { return s_->quest_log.objective_needs_bridge; }
// Builder-validated bridge route legs (world-thread navmesh-checked
// attaches). When non-empty for the current goal key, the travel-plan
// executor MUST use these instead of recomputing the route.
std::vector<QuestLogState::BridgeLeg> const& bridge_route() const { return s_->quest_log.bridge_route; }
uint64 bridge_route_goal_key() const { return s_->quest_log.bridge_route_goal_key; }
BotSnapshot::QuestObjectivePoi const& current_objective_poi() const { return s_->quest_log.current_objective_poi; }
BotSnapshot::QuestAreaTrigger const& current_objective_areatrigger() const { return s_->quest_log.current_objective_areatrigger; }
BotSnapshot::QuestTool const& current_objective_tool() const { return s_->quest_log.current_objective_tool; }
uint16 riding_skill() const { return s_->travel.riding_skill; }
uint32 best_mount_spell() const { return s_->travel.best_mount_spell; }
uint32 estimated_repair_cost() const { return s_->vendor_visit.estimated_repair_cost; }
uint8 vendor_visit_phases_pending() const { return s_->vendor_visit.phases_pending; }
uint8 smallest_bag_capacity() const { return s_->bags.smallest_bag_capacity; }
bool has_empty_bag_slot() const { return s_->bags.has_empty_bag_slot; }
uint16 health_potion_count() const { return s_->consumables.health_potion_count; }
uint16 mana_potion_count() const { return s_->consumables.mana_potion_count; }
std::array<float, 12> const& spec_stat_weights() const { return s_->stat_weights.spec_stat_weights; }
float spec_weapon_dps_weight() const { return s_->stat_weights.spec_weapon_dps_weight; }
std::vector<BotSnapshot::ActionableObjective> const& actionable_objectives() const { return s_->quest_log.actionable_objectives; }
ObjectGuid gossip_npc() const { return s_->gossip.gossip_npc; }
std::vector<BotSnapshot::GossipMenuOption> const& gossip_options() const { return s_->gossip.gossip_options; }
// First nearby NPC/GO that accepts a turn-in for one of the bot's
// complete quests, with the quest id. Lets a State_Idle rule emit
// QuestCompleteIntent without re-doing the giver/quest matching.
BotSnapshot::QuestTurnIn const* nearest_quest_turnin() const
{
return s_->quest_discovery.quest_turnins.empty() ? nullptr : &s_->quest_discovery.quest_turnins.front();
}
BotSnapshot::QuestTurnIn const* nearest_quest_offer() const
{
return s_->quest_discovery.quest_offers.empty() ? nullptr : &s_->quest_discovery.quest_offers.front();
}
bool current_objective_blacklisted() const { return s_->quest_log.current_objective_blacklisted; }
bool has_relocation_target() const { return s_->quest_log.has_relocation_target; }
// Quest-first arbitration (2026-06-16). True when the bot has a quest ACTION
// worth prioritizing over opportunistic maintenance (equip/vendor/gather/
// mail/AH/loot_chest). The single shared predicate every opportunistic idle
// gate calls so it YIELDS to questing — fixing the priority inversion where a
// bot AT its objective ran idle:equip_upgrade (the dominant GoalUnreachable
// wedge). Three arms:
// (1) a ready turn-in giver in interaction scan range,
// (2) an acceptable quest-offer giver in interaction scan range,
// (3) a REAL quest (NOT an R7 relocation — those keep using the travel
// pipeline and must NOT starve maintenance on long hauls) that is
// either currently executable (a nearby actionable objective — the
// builder pre-filters blacklisted ones out) OR has a valid same-map
// objective POI within reach_yd (+ POI radius).
// Arm (3)'s POI branch is guarded by current_objective_blacklisted(): a bot
// stranded within reach of an UNREACHABLE/wedged POI must NOT keep
// suppressing vendor/repair or it livelocks (the dominant live wedge class).
// Arms (1)/(2)/actionable are already proximity-scoped by the builder.
bool has_actionable_quest(float reach_yd = 80.0f) const
{
if (nearest_quest_turnin() != nullptr) return true;
if (nearest_quest_offer() != nullptr) return true;
// A questless bot with an R7 relocation target is doing quest WORK (going
// to where quests are) — yield maintenance so travel_to_hub can move it
// instead of equip_upgrade firing in place. Covers the directly-walkable
// same-map relocation that synthesizes no POI (see BotSnapshot.h note).
if (has_relocation_target()) return true;
if (current_quest_id() == 0 || objective_is_relocation()) return false;
if (!actionable_objectives().empty()) return true;
auto const& poi = current_objective_poi();
if (!poi.valid || poi.map_id != map_id() || current_objective_blacklisted())
return false;
float sx = 0.f, sy = 0.f, sz = 0.f;
position(sx, sy, sz);
const float dx = poi.x - sx, dy = poi.y - sy;
const float reach = reach_yd + poi.radius;
return (dx * dx + dy * dy) <= (reach * reach);
}
// Skills. skill_value(skill_id) returns 0 when the bot doesn't have
// the skill. Useful for gather rules ("Herbalism ≥ 200 to gather").
uint16 skill_value(uint16 skill_id) const
{
for (auto const& e : s_->progression.skills)
if (e.skill_id == skill_id) return e.value;
return 0;
}
bool has_skill(uint16 skill_id) const { return skill_value(skill_id) > 0; }
uint16 skill_max(uint16 skill_id) const
{
for (auto const& e : s_->progression.skills)
if (e.skill_id == skill_id) return e.max;
return 0;
}
bool is_skill_capped(uint16 skill_id) const
{
for (auto const& e : s_->progression.skills)
if (e.skill_id == skill_id)
return e.value > 0 && e.value >= e.max;
return false;
}
// Mail. has_drainable_mail() returns true when at least one mail is
// delivered AND carries something to take (money OR an item) — what an
// at-mailbox rule actually cares about. next_drainable_mail() returns
// the oldest such mail (front of the vector, since GetMails() returns
// delivery order); rules drive a take-money + take-each-item +
// (when empty, no COD) delete sequence over successive ticks.
size_t mail_count() const { return s_->mailbox.mail.size(); }
uint32 unread_mail_count() const { return s_->mailbox.unread_mail_count; }
bool has_drainable_mail() const;
MailEntry const* next_drainable_mail() const;
// Taxi — has the bot visited / been granted node `node_id`? Lets AI
// gate fly_to_node intents on pre-known endpoints rather than
// catching the Result::Locked rebound. node_id is a TaxiNodes.dbc id.
bool is_taxi_node_known(uint32 node_id) const
{
const size_t byte = node_id / 8;
const uint8 bit = uint8(1) << (node_id % 8);
return byte < s_->travel.taxi_mask.size() && (s_->travel.taxi_mask[byte] & bit) != 0;
}
// Recommended taxi route accessors — populated by the snapshot builder
// when the bot has a long-distance same-map goal and a viable known-FM
// → known-FM route exists. has_recommended_taxi_route() reflects "≥2
// hops, both endpoints known"; the AI rule walks to the start FM and
// emits fly_to_node(start_fm, dest_node) on arrival.
ObjectGuid recommended_taxi_start_fm() const { return s_->travel.recommended_taxi_start_fm; }
uint32 recommended_taxi_dest_node() const { return s_->travel.recommended_taxi_dest_node; }
uint16 recommended_taxi_hop_count() const { return s_->travel.recommended_taxi_hop_count; }
float recommended_taxi_start_x() const { return s_->travel.recommended_taxi_start_x; }
float recommended_taxi_start_y() const { return s_->travel.recommended_taxi_start_y; }
float recommended_taxi_start_z() const { return s_->travel.recommended_taxi_start_z; }
// A flight route to the goal exists (≥2 hops, both endpoints known). This is
// now PROACTIVE: it does NOT require the start FM to be in scan range — the
// bot walks to recommended_taxi_start_{x,y,z} first, then flies once the FM
// is visible. start_fm may be empty during the walk phase.
bool has_recommended_taxi_route() const
{
return s_->travel.recommended_taxi_dest_node != 0 &&
s_->travel.recommended_taxi_hop_count >= 2 &&
(s_->travel.recommended_taxi_start_x != 0.f ||
s_->travel.recommended_taxi_start_y != 0.f ||
!s_->travel.recommended_taxi_start_fm.IsEmpty());
}
// Nearest known cross-map travel anchor. Builder fills from the
// global PortalIndex when the bot has a cross-map goal; empty
// otherwise. AI uses these to walk toward the portal/dock from
// anywhere on the source map (the GO itself is invisible past 30 y
// so we can't rely on nearby_objects for the long-distance leg).
bool has_nearest_portal_anchor() const
// Use `kind` (0=none, 1=Portal, 2=Transport) as the validity sentinel,
// NOT dest_map: map 0 (Eastern Kingdoms) is a legitimate anchor
// destination, so `dest_map != 0` wrongly reported "no anchor" for every
// cross-map trip whose goal is EK (e.g. Org→Undercity zeppelin). `kind`
// is only set to a non-zero value when the snapshot actually resolves an
// anchor (BotSnapshotBuilder publishes all five fields together).
{ return s_->travel.nearest_portal_anchor_kind != 0; }
uint32 nearest_portal_anchor_dest_map() const { return s_->travel.nearest_portal_anchor_dest_map; }
uint32 next_hop_dest_map() const { return s_->travel.next_hop_dest_map; }
bool has_multi_hop_route() const { return s_->travel.next_hop_dest_map != kInvalidMapId; }
float nearest_portal_anchor_x() const { return s_->travel.nearest_portal_anchor_x; }
float nearest_portal_anchor_y() const { return s_->travel.nearest_portal_anchor_y; }
float nearest_portal_anchor_z() const { return s_->travel.nearest_portal_anchor_z; }
uint8 nearest_portal_anchor_kind() const { return s_->travel.nearest_portal_anchor_kind; }
uint32 nearest_portal_anchor_entry() const { return s_->travel.nearest_portal_anchor_entry; }
// Dungeon execution context (Phase A of GROUP_DUNGEON_PLAN.md).
// Reads pulled from snapshot; all return zero / Empty values
// when bot isn't in an instance map.
bool is_encounter_in_progress() const { return s_->dungeon_exec.is_encounter_in_progress; }
ObjectGuid current_boss_guid() const { return s_->dungeon_exec.current_boss_guid; }
uint32 current_boss_entry() const { return s_->dungeon_exec.current_boss_entry; }
int32 current_boss_hp() const { return s_->dungeon_exec.current_boss_hp; }
int32 current_boss_max_hp() const { return s_->dungeon_exec.current_boss_max_hp; }
int32 current_boss_hp_pct() const
{
return s_->dungeon_exec.current_boss_max_hp > 0
? static_cast<int32>((int64_t(s_->dungeon_exec.current_boss_hp) * 100) / s_->dungeon_exec.current_boss_max_hp)
: 0;
}
uint32 current_boss_casting_spell() const { return s_->dungeon_exec.current_boss_casting_spell; }
bool current_boss_casting_interruptible() const { return s_->dungeon_exec.current_boss_casting_interruptible; }
Ms current_boss_cast_remaining() const { return s_->dungeon_exec.current_boss_cast_remaining; }
bool has_visible_boss() const
{ return !s_->dungeon_exec.current_boss_guid.IsEmpty() && s_->dungeon_exec.current_boss_hp > 0; }
uint8 members_dead_count() const { return s_->dungeon_exec.members_dead_count; }
bool dungeon_complete() const { return s_->dungeon_exec.dungeon_complete; }
// Homebind position (Player::m_homebind copy). Used by the
// hearth-leg of the cross-map cascade.
uint32 homebind_map_id() const { return s_->travel.homebind_map_id; }
float homebind_x() const { return s_->travel.homebind_x; }
float homebind_y() const { return s_->travel.homebind_y; }
float homebind_z() const { return s_->travel.homebind_z; }
// Instance entrance position (for wipe regroup). map_id == 0 means
// not populated (open world or Map::GetEntrancePosition returned
// none). Outparam form mirrors position() for ergonomic call sites.
void instance_entrance(uint32& map, float& x, float& y, float& z) const
{
map = s_->dungeon_exec.instance_entrance_map;
x = s_->dungeon_exec.instance_entrance_x;
y = s_->dungeon_exec.instance_entrance_y;
z = s_->dungeon_exec.instance_entrance_z;
}
// Hearthstone availability. Item 6948 in inventory AND spell 8690
// off cooldown. Bot rules MUST gate every hearth emit on this —
// otherwise fresh / low-level bots that never picked up a Hearthstone
// produce a continuous "not in spellbook" log spam.
bool has_hearthstone() const { return s_->travel.has_hearthstone; }
uint32 hearthstone_cd_ms() const { return s_->travel.hearthstone_cd_ms; }
bool can_hearth() const
{ return s_->travel.has_hearthstone && s_->travel.hearthstone_cd_ms == 0; }
// Self-cast teleport spells the bot knows that have a resolvable
// destination map (Mage Teleport: City, DK Death Gate, Druid
// Teleport: Moonglade, etc). Builder pre-resolves each spell's
// dest map via spell_target_position so this is a cheap snapshot
// scan with no DB lookups on the AI worker.
std::vector<BotSnapshot::SelfTeleportSpell> const& self_teleport_spells() const
{ return s_->travel.self_teleport_spells; }
// Battleground state. queued_for(bg_type_id) gates against re-queuing.
bool in_battleground() const { return s_->bg.in_battleground; }
bool queued_for_bg() const { return !s_->bg.queues.empty(); }
bool queued_for_bg(uint16 bg_type_id) const
{
for (auto const& q : s_->bg.queues)
if (q.bg_type_id == bg_type_id) return true;
return false;
}
size_t bg_queue_count() const { return s_->bg.queues.size(); }
// Active BG state — populated only when in_battleground() is true.
// current_bg_type_id() returns the BattlemasterList.dbc id of the
// current BG (e.g., 3=WSG, 4=AB). Zero when not in a BG.
uint16 current_bg_type_id() const { return s_->bg.current_type_id; }
uint32 bg_score_alliance() const { return s_->bg.score_alliance; }
uint32 bg_score_horde() const { return s_->bg.score_horde; }
uint32 bg_time_remaining_sec() const { return s_->bg.time_remaining_sec; }
// Wall-clock ms since the BG entered IN_PROGRESS (gates dropped).
// 0 during prep / outside BGs. Drives time-gated arena hazards.
uint32 bg_in_progress_ms() const { return s_->bg.in_progress_ms; }
// BG live status helpers. bg_is_live() = gates open / objective play
// valid. bg_in_prep() = gates closed / prep phase. start_delay_ms
// counts down to gates-open during prep.
uint8 bg_status() const { return s_->bg.status; }
bool bg_is_live() const { return s_->bg.status == 3; }
bool bg_in_prep() const { return s_->bg.status == 2; }
uint32 bg_start_delay_ms() const { return s_->bg.start_delay_ms; }
// CTF flag carrier accessors. ObjectGuid is empty when there's no
// active flag carrier on the corresponding side. carrier_hp_pct
// values are unspecified when the corresponding GUID is empty.
ObjectGuid const& bg_friendly_flag_carrier() const { return s_->bg.friendly_flag_carrier; }
ObjectGuid const& bg_enemy_flag_carrier() const { return s_->bg.enemy_flag_carrier; }
int32 bg_friendly_carrier_hp_pct() const { return s_->bg.friendly_carrier_hp_pct; }
int32 bg_enemy_carrier_hp_pct() const { return s_->bg.enemy_carrier_hp_pct; }
// BG capture-point states (live ownership view across the whole BG
// map). Empty when bot isn't in a BG, or when the BG has no
// CAPTURE_POINT GOs (e.g., CTF-only BGs like WSG).
std::vector<BotSnapshot::BgNodeState> const& bg_node_states() const { return s_->bg.node_states; }
// Vehicle state. on_vehicle gates the vehicle-action rules.
bool on_vehicle() const { return s_->vehicle.on_vehicle; }
ObjectGuid const& vehicle_guid() const { return s_->vehicle.vehicle_guid; }
int8 vehicle_seat_id() const { return s_->vehicle.vehicle_seat_id; }
uint32 vehicle_seat_ability() const { return s_->vehicle.vehicle_seat_ability; }
uint32 vehicle_entry() const { return s_->vehicle.vehicle_entry; }
int8 bg_sota_attacker_team() const { return s_->bg.sota_attacker_team; }
// SoTA gate state (0=unknown, 1=OK, 2=damaged, 3=destroyed) per gate.
// Returns 0 (treat as not-on-this-map) when bot isn't in SoTA.
uint8 bg_sota_gate_state(BotSnapshot::SotaGateId g) const
{ return s_->bg.sota_gate_state[g]; }
// IoC keep gate destruction. Returns non-zero when gate is down.
// Returns 0 (treat as intact / not-in-IoC) outside IoC.
uint8 bg_ioc_gate_destroyed(BotSnapshot::IocGateId g) const
{ return s_->bg.ioc_gate_destroyed[g]; }
// AV captain alive-state. Default true outside AV — scripts gate on
// bg_type_id == 1 (AV) so the dead-default would never apply elsewhere.
bool bg_av_balinda_alive() const { return s_->bg.av_balinda_alive; }
bool bg_av_galvangar_alive() const { return s_->bg.av_galvangar_alive; }
// Multi-carrier vectors for BGs with concurrent carriers (Kotmogu).
// For single-carrier BGs these contain either 0 or 1 entries.
std::vector<ObjectGuid> const& bg_all_friendly_carriers() const
{ return s_->bg.all_friendly_carriers; }
std::vector<ObjectGuid> const& bg_all_enemy_carriers() const
{ return s_->bg.all_enemy_carriers; }
// Bank capacity. AI uses bank_free_slots() == 0 to skip deposit attempts;
// bank_tab_count() lets the "buy a bank tab" rule fire when the bot
// has gold and < 4 tabs.
uint8 bank_tab_count() const { return s_->bank.bank_tab_count; }
uint16 bank_free_slots() const { return s_->bank.bank_free_slots; }
// Nearby GameObjects. nearest_object_of_type returns the closest GO of
// the given GAMEOBJECT_TYPE_* (mailbox/chest/herb/ore/etc) within the
// snapshot's scan radius (~30yd), or nullptr if none. Caller can then
// emit use_game_object(go.guid) directly.
BotSnapshot::NearbyObject const* nearest_object_of_type(uint8 go_type) const
{
// nearby_objects is already sorted by distance from the bot.
for (auto const& o : s_->world_objects.nearby_objects)
if (o.go_type == go_type) return &o;
return nullptr;
}
std::vector<BotSnapshot::NearbyObject> const& nearby_objects() const { return s_->world_objects.nearby_objects; }
// Find the closest friendly NPC carrying any of the given npc_flag bits
// (UNIT_NPC_FLAG_VENDOR / REPAIR / BANKER / TRAINER / FLIGHTMASTER / etc).
// nearby_friends is sorted by distance, so the first match is the
// closest. Returns nullptr when no nearby NPC carries the flag.
NearbyUnit const* nearest_npc_with_flag(uint32 flag_mask) const
{
for (auto const& u : s_->combat.nearby_friends)
if ((u.npc_flags & flag_mask) != 0) return &u;
return nullptr;
}
// Group
Role my_role() const { return s_->group.my_role; }
// Auto-detected AoE situation: in combat with 3+ attackers (the
// canonical "this is an AoE pull" cutoff). Used by spec rotations to
// bias toward Multi-Shot / Whirlwind / Consecration / etc. without
// needing the owner to manually flag it. The owner-pinned aoe_preference
// on BotAI is a separate signal — together they let the rotation
// decide AoE-vs-ST per-tick. View access is read-only.
bool is_aoe_situation() const
{ return s_->vitals.in_combat && s_->combat.fightable_attackers >= 3; }
Ms combat_duration() const { return s_->vitals.combat_duration; }
int64 combat_duration_ms() const { return s_->vitals.combat_duration.count(); }
Ms ms_since_combat_exit() const { return s_->vitals.ms_since_combat_exit; }
int64 ms_since_combat_exit_ms() const { return s_->vitals.ms_since_combat_exit.count(); }
// True when combat ended within the last `ms`. 0 (= never been in
// combat, or still in combat) reads as "not recently in combat".
bool recently_in_combat(int64 ms = 5000) const
{
const int64 v = s_->vitals.ms_since_combat_exit.count();
return v > 0 && v < ms;
}
ObjectGuid group_guid() const { return s_->group.group_guid; }
bool in_group() const { return s_->group.group_guid != ObjectGuid::Empty; }
// ---- World metadata (operator-curated knowledge) -----------------
//
// Queries the singleton WorldMetadataStore loaded at server boot
// from characters.playerbot_v2_world_metadata. The store is read-only
// post-init (modifications via `.playerbot meta add/delete` go through
// the same path), so no per-snapshot caching is needed — the store
// is a stable global for the lifetime of these reads.
//
// Usage in rules:
// if (s.in_city()) { ... safer behavior, more chatty ... }
// if (s.near_metadata_kind(WorldMetadataKind::Danger, 80.f)) flee()
//
// Implemented out-of-line in BotSnapshotView.cpp so the include of
// WorldMetadata.h doesn't leak through every TU that consumes views.
// Distance² in yards (planar XY) to the nearest metadata point of
// the given kind on the same map. Returns FLT_MAX if no match.
float metadata_dist_sq(uint32 kind /*WorldMetadataKind*/) const;
// True when bot is inside the radius of ANY metadata point of the
// given kind on the current map.
bool inside_metadata(uint32 kind) const;
// Convenience predicates — common cases the rules ask about most.
bool in_city() const;
bool in_village() const;
bool in_danger_zone() const;
// "Is there a known metadata point of this kind within `range`
// yards of the bot?" Independent of the point's own radius.
// Useful for sniff-test queries like "does this zone have any
// known vendor annotation?".
bool any_metadata_within(uint32 kind, float range) const;
// Encounter
uint32 active_encounter_npc() const { return s_->dungeon_exec.active_encounter_npc_id; }
uint8 active_encounter_phase() const { return s_->dungeon_exec.active_encounter_phase; }
// True when the bot is engaged with a boss-tier target (>=5M HP). Used
// to gate raid cooldowns (Bloodlust, Time Warp, Fury of the Aspects)
// so they're not blown on trash. Checks the active encounter id (set by
// builder from the attackers list) and falls back to the current target.
bool on_boss_encounter() const
{
if (s_->dungeon_exec.active_encounter_npc_id != 0) return true;
constexpr int32 BOSS_HP_THRESHOLD = 5'000'000;
if (auto const* t = target_info())
if (t->max_hp >= BOSS_HP_THRESHOLD) return true;
return false;
}
// Pet
ObjectGuid pet_guid() const { return s_->pet.pet_guid; }
bool has_pet() const { return !s_->pet.pet_guid.IsEmpty() && s_->pet.pet_alive; }
int32 pet_hp_pct() const { return s_->pet.pet_max_hp > 0 ? static_cast<int32>((int64_t(s_->pet.pet_hp) * 100) / s_->pet.pet_max_hp) : 0; }
std::string const& pet_name() const { return s_->pet.pet_name; }
bool pet_can_bloodlust() const { return s_->pet.pet_can_bloodlust; }
ObjectGuid pet_victim() const { return s_->pet.pet_victim; }
std::vector<ObjectGuid> const& pet_attackers() const { return s_->pet.pet_attackers; }
AuraEntry const* find_pet_aura(uint32 spell_id) const;
std::vector<BotSnapshot::StablePet> const& stable_pets() const { return s_->pet.stable_pets; }
bool has_stabled_pets() const
{
for (auto const& sp : s_->pet.stable_pets)
if (sp.slot_kind != 0 /*active*/) return true;
return false;
}
// Currencies / reputations / loot rolls / nearby — raw vector accessors.
// Read-only; APL rules walk these for predicate logic without dragging the
// raw snapshot into rule code.
std::vector<BotSnapshot::CurrencyEntry> const& currencies() const { return s_->progression.currencies; }
std::vector<BotSnapshot::ReputationEntry> const& reputations() const { return s_->progression.reputations; }
std::vector<BotSnapshot::LootRollEntry> const& loot_rolls() const { return s_->loot.loot_rolls; }
std::vector<NearbyUnit> const& attackers() const { return s_->combat.attackers; }
std::vector<NearbyUnit> const& nearby_friends() const { return s_->combat.nearby_friends; }
std::vector<NearbyUnit> const& nearby_enemies() const { return s_->combat.nearby_enemies; }
std::vector<BotSnapshot::QuestTurnIn> const& quest_offers() const { return s_->quest_discovery.quest_offers; }
std::vector<BotSnapshot::QuestTurnIn> const& quest_turnins() const { return s_->quest_discovery.quest_turnins; }
std::vector<BotSnapshot::StartingItem> const& quest_starting_items() const { return s_->quest_discovery.quest_starting_items; }
// Modern WoW world quest discovery index. Vector of {giver, quest_id,
// type=0(offer)/1(turnin), pos, area, reward digest}. Empty when the
// bot has no nearby world quest givers (typical pre-Legion zones,
// or world quests just not deployed in DB).
std::vector<BotSnapshot::WorldQuestEntry> const& available_world_quests() const
{ return s_->quest_discovery.available_world_quests; }
// Scenario step tracking. scenario_id() == 0 means bot isn't in a
// scenario instance map. Read by future scenario-aware dispatch;
// for now consumed by the `wq` whisper for diagnostics.
BotSnapshot::ScenarioStepInfo const& scenario_step() const { return s_->quest_log.scenario_step; }
bool in_scenario() const { return s_->quest_log.scenario_step.scenario_id != 0; }
std::vector<MailEntry> const& mail() const { return s_->mailbox.mail; }
std::vector<BotSnapshot::BgQueueEntry> const& bg_queues() const { return s_->bg.queues; }
std::vector<QuestEntry> const& quests() const { return s_->quest_log.quests; }
std::vector<InventoryItem> const& bag_items() const { return s_->inventory.bag_items; }
// O(1) sum-of-stacks lookup via inventory.bag_count_by_entry. The
// index is built alongside bag_items in BotSnapshotBuilder so each
// entry maps to the SUM of stack counts across all bag slots.
// Pre-fix: linear walk of up to 120 items per call; called from
// has_reagents() which itself fires from many crafting / consumable
// rules per tick.
uint32 item_count(uint32 entry) const
{
// Tier 3.3: bag_count_by_entry is an accumulating sorted flat vector;
// get() returns the summed count (0 when absent), same semantics as
// the prior unordered_map lookup.
return s_->inventory.bag_count_by_entry.get(entry);
}
// Reagent check for a recipe spell. Walks SpellInfo::Reagent[] and
// compares each requirement against bag stacks. Returns false if any
// reagent is short. NOTE: pulls SpellMgr — call site must be in a TU
// that already includes SpellMgr.h. View is header-only otherwise so
// we declare here and define in BotSnapshotView.cpp.
bool has_reagents(uint32 spell_id) const;
std::vector<uint32> const& known_spells() const { return s_->spellbook.known_spells; }
std::vector<uint32> const& known_recipes() const { return s_->spellbook.known_recipes; }
std::vector<BotSnapshot::SkillEntry> const& skills() const { return s_->progression.skills; }
std::vector<CooldownEntry> const& spell_cooldowns() const { return s_->cooldowns.spell_cooldowns; }
// Currency lookup helper. Returns 0 when the bot has no balance for that id.
uint32 currency_quantity(uint32 currency_id) const
{
for (auto const& c : s_->progression.currencies)
if (c.currency_id == currency_id) return c.quantity;
return 0;
}
// Reputation rank lookup (0..7 or 8 for Paragon). 0 = Hated when present;
// returns 0 + standing 0 when the bot has no rep with that faction
// (caller can disambiguate via reputations() walk).
uint8 reputation_rank(uint32 faction_id) const
{
for (auto const& r : s_->progression.reputations)
if (r.faction_id == faction_id) return r.rank;
return 0;
}
int32 reputation_standing(uint32 faction_id) const
{
for (auto const& r : s_->progression.reputations)
if (r.faction_id == faction_id) return r.standing;
return 0;
}
// Raw access (last-resort)
BotSnapshot const& raw() const { return *s_; }
private:
BotSnapshot const* s_;
// Backing store for find_aura()'s outbound (auras-on-others) case. The
// builder stores outbound auras as compact OutboundAura rows, but
// find_aura must return an AuraEntry const*. Materialising the matched
// row here — instead of a reused thread_local — keeps every returned
// pointer valid for the whole lifetime of this view (one AI tick), so a
// caller holding two find_aura results (or calling again before using
// the first) never sees aliased/overwritten data. std::deque is chosen
// because it never invalidates existing element pointers on push_back,
// unlike std::vector. Mutable so the const find_aura accessor can append.
mutable std::deque<AuraEntry> outbound_aura_rows_;
};
} // namespace Playerbot
+78
View File
@@ -0,0 +1,78 @@
// BotTypes.h - Foundational ID and enum types per CONTRACTS.md §1.
#pragma once
#include "Define.h"
#include <chrono>
namespace Playerbot {
using BotId = uint64; // == ObjectGuid::GetCounter() of bot's character
using TickId = uint64; // Monotonic per world tick
using IntentId = uint64; // Monotonic per intent emitted (for tracing)
using SnapshotVer = uint64; // Monotonic per snapshot publication
using Ms = std::chrono::milliseconds;
// NOTE: numeric order is NOT load-bearing — these values are never
// array-indexed, ordered-compared (<,<=), persisted, or int-cast anywhere
// (verified fleet-wide); every use is an equality test or switch. Cruise is
// therefore inserted mid-ladder (between Active and Idle) so the values read
// as a descending-cadence ladder. If you add an ordered comparison later,
// re-audit before relying on these numbers.
enum class ActivityTier : uint8
{
Combat = 0, // ≥10 Hz
Active = 1, // ~6.7 Hz (150 ms) — responsiveness-critical bots
Cruise = 2, // ~3.3 Hz (300 ms) — solo open-world travel/questing
Idle = 3, // 2 Hz (500 ms) — ramps to Parked
Hibernate = 4, // 0.5 Hz (2 s) — "Parked" long-idle AFK
};
enum class BotState : uint8
{
LoggingIn,
Idle,
Travelling,
Questing,
InCombat,
Looting,
Dead,
Resurrecting,
LoggingOut,
// Cross-cutting (re-entrant, may layer over a primary state)
AtVendor,
AtMailbox,
AtAuctionHouse,
InGroup,
InInstance,
Decorating,
};
enum class Role : uint8
{
Tank,
Healer,
Dps,
Unknown,
};
enum class BotRole : uint8
{
Fleet = 0, // world bot — pool population, autonomous AI
Altbot = 1 // player-bound companion — follow, assist, pushed quests only
};
inline bool IsAltbot(BotRole r) { return r == BotRole::Altbot; }
enum class DispelType : uint8
{
Magic,
Curse,
Poison,
Disease,
Bleed,
Enrage,
None,
};
} // namespace Playerbot
+341
View File
@@ -0,0 +1,341 @@
#include "ClassTables.h"
#include "SharedDefines.h"
namespace Playerbot {
uint32 ClassSelfBuff(uint8 cls)
{
switch (cls)
{
case CLASS_MAGE: return 1459; // Arcane Intellect
case CLASS_PRIEST: return 21562; // Power Word: Fortitude
case CLASS_DRUID: return 1126; // Mark of the Wild
case CLASS_WARRIOR: return 6673; // Battle Shout
case CLASS_PALADIN: return 0; // Devotion Aura is passive in modern WoW
case CLASS_DEATH_KNIGHT: return 0; // Horn of Winter is Frost-only resource gen
case CLASS_SHAMAN: return 462854; // Skyfury (haste raid buff)
case CLASS_EVOKER: return 364342; // Blessing of the Bronze
default: return 0;
}
}
uint32 ClassOocHeal(uint8 cls, uint32 spec)
{
switch (cls)
{
case CLASS_PRIEST:
// Disc 256, Holy 257 — Flash Heal is the fast topup for both.
if (spec == 256 || spec == 257) return 2061;
break;
case CLASS_PALADIN:
if (spec == 65) return 19750; // Flash of Light
break;
case CLASS_DRUID:
if (spec == 105) return 8936; // Regrowth
break;
case CLASS_SHAMAN:
if (spec == 264) return 77472; // Healing Wave
break;
case CLASS_MONK:
if (spec == 270) return 116670; // Vivify
break;
case CLASS_EVOKER:
if (spec == 1468) return 361469; // Living Flame
break;
default: break;
}
return 0;
}
ClassDispelSpell FriendlyDispel(uint8 cls, uint32 spec)
{
constexpr uint32 SPEC_PRIEST_DISC = 256, SPEC_PRIEST_HOLY = 257;
constexpr uint32 SPEC_PALA_HOLY = 65;
constexpr uint32 SPEC_DRUID_RESTO = 105;
constexpr uint32 SPEC_SHAMAN_RESTO = 264;
constexpr uint32 SPEC_MONK_MW = 270;
constexpr uint32 SPEC_EVOKER_PRES = 1468;
switch (cls)
{
case CLASS_PRIEST:
if (spec == SPEC_PRIEST_DISC || spec == SPEC_PRIEST_HOLY)
return {527, /*magic*/ true, /*curse*/ false, /*disease*/ true, /*poison*/ false};
return {0,0,0,0,0};
case CLASS_PALADIN:
if (spec == SPEC_PALA_HOLY)
return {4987, true, false, true, true};
return {213644, false, false, true, true};
case CLASS_DRUID:
if (spec == SPEC_DRUID_RESTO)
return {88423, true, true, false, true};
return {2782, false, true, false, true};
case CLASS_SHAMAN:
if (spec == SPEC_SHAMAN_RESTO)
return {77130, true, true, false, false};
return {51886, false, true, false, false};
case CLASS_MONK:
if (spec == SPEC_MONK_MW)
return {388874, true, false, true, true};
return {218164, false, false, true, true};
case CLASS_MAGE:
return {475, false, true, false, false};
case CLASS_EVOKER:
if (spec == SPEC_EVOKER_PRES)
return {360823, true, false, false, true};
return {365585, false, false, false, true};
default:
return {0,0,0,0,0};
}
}
uint32 ClassInterrupt(uint8 cls, uint32 spec)
{
// Single canonical interrupt per (class, [spec]). Confirmed against
// Wowhead retail data; spell ids stable since DF.
switch (cls)
{
case CLASS_WARRIOR: return 6552; // Pummel
case CLASS_PALADIN: return 96231; // Rebuke
case CLASS_HUNTER:
// Counter Shot for BM/MM (253/254); Muzzle for Survival (255).
if (spec == 255) return 187707; // Muzzle
return 147362; // Counter Shot
case CLASS_ROGUE: return 1766; // Kick
case CLASS_PRIEST:
// Only Shadow has a hard interrupt (Silence 15487) — pure
// Discipline / Holy have no kick.
if (spec == 258) return 15487; // Silence
return 0;
case CLASS_DEATH_KNIGHT: return 47528; // Mind Freeze
case CLASS_SHAMAN: return 57994; // Wind Shear
case CLASS_MAGE: return 2139; // Counterspell
case CLASS_WARLOCK:
// Felhunter pet's Spell Lock — needs the pet out and the
// command spell (19647). Bot-side this becomes a pet_cast,
// but as a plain "interrupt" the player-side cast is 19647
// when felhunter is active. Fallback 0 if no Felhunter.
return 19647;
case CLASS_MONK: return 116705; // Spear Hand Strike
case CLASS_DRUID:
// Skull Bash for Feral/Guardian (103/104); Solar Beam (AoE)
// for Balance (102). Resto has no interrupt.
if (spec == 103 || spec == 104) return 106839;
if (spec == 102) return 78675;
return 0;
case CLASS_DEMON_HUNTER: return 183752; // Disrupt
case CLASS_EVOKER: return 351338; // Quell
default: return 0;
}
}
uint32 ClassCC(uint8 cls, uint32 spec)
{
// Single canonical CC per class. Many classes have multiple CCs
// (Mage Poly + Frost Nova; Hunter Trap + Sleep Dart) — we pick
// the longest reliable single-target one usable at any spec.
switch (cls)
{
case CLASS_MAGE: return 118; // Polymorph
case CLASS_WARLOCK: return 5782; // Fear
case CLASS_ROGUE: return 6770; // Sap
case CLASS_HUNTER: return 187650; // Freezing Trap
case CLASS_DRUID: return 2637; // Hibernate (beasts/dragonkin only — caller checks target type)
case CLASS_SHAMAN: return 51514; // Hex
case CLASS_PRIEST: return 605; // Mind Control (32yd)
case CLASS_PALADIN:
// Repentance (Ret talent, 20066). Holy/Prot don't have it
// baseline; fall back to Hammer of Justice (853) — short
// stun but works for any spec.
if (spec == 70) return 20066;
return 853;
case CLASS_DEATH_KNIGHT: return 47476; // Strangulate (PvP talent, but core CC)
case CLASS_MONK: return 115078; // Paralysis
case CLASS_DEMON_HUNTER: return 217832; // Imprison
case CLASS_EVOKER: return 360806; // Sleep Walk
case CLASS_WARRIOR: return 0; // No reliable single-target CC
default: return 0;
}
}
// ---- Role / spec helpers (canonical source — referenced by
// BotQueueFiller and idle:dual_spec_switch alike) ----
bool IsTankSpec(uint8 cls, uint16 spec)
{
if (cls == CLASS_WARRIOR && spec == 73) return true; // Protection
if (cls == CLASS_PALADIN && spec == 66) return true; // Protection
if (cls == CLASS_DEATH_KNIGHT && spec == 250) return true; // Blood
if (cls == CLASS_DRUID && spec == 104) return true; // Guardian
if (cls == CLASS_MONK && spec == 268) return true; // Brewmaster
if (cls == CLASS_DEMON_HUNTER && spec == 581) return true; // Vengeance
return false;
}
bool IsHealerSpec(uint8 cls, uint16 spec)
{
if (cls == CLASS_PALADIN && spec == 65) return true; // Holy
if (cls == CLASS_PRIEST && (spec == 256 || spec == 257)) return true; // Disc/Holy
if (cls == CLASS_DRUID && spec == 105) return true; // Restoration
if (cls == CLASS_SHAMAN && spec == 264) return true; // Restoration
if (cls == CLASS_MONK && spec == 270) return true; // Mistweaver
if (cls == CLASS_EVOKER && spec == 1468) return true; // Preservation
return false;
}
uint32 TankSpecForClass(uint8 cls)
{
switch (cls)
{
case CLASS_WARRIOR: return 73; // Protection
case CLASS_PALADIN: return 66; // Protection
case CLASS_DEATH_KNIGHT: return 250; // Blood
case CLASS_DRUID: return 104; // Guardian
case CLASS_MONK: return 268; // Brewmaster
case CLASS_DEMON_HUNTER: return 581; // Vengeance
default: return 0;
}
}
uint32 ClassTaunt(uint8 cls)
{
switch (cls)
{
case CLASS_WARRIOR: return 355; // Taunt
case CLASS_PALADIN: return 62124; // Hand of Reckoning (taunt on cast)
case CLASS_DEATH_KNIGHT: return 56222; // Dark Command (single-target taunt)
case CLASS_DRUID: return 6795; // Growl (bear-form required; caller checks)
case CLASS_MONK: return 115546; // Provoke
case CLASS_DEMON_HUNTER: return 185245; // Torment
default: return 0;
}
}
uint32 HealerSpecForClass(uint8 cls)
{
switch (cls)
{
case CLASS_PALADIN: return 65; // Holy
case CLASS_PRIEST: return 257; // Holy (Disc 256 also valid; we pick one canonical)
case CLASS_DRUID: return 105; // Restoration
case CLASS_SHAMAN: return 264; // Restoration
case CLASS_MONK: return 270; // Mistweaver
case CLASS_EVOKER: return 1468; // Preservation
default: return 0;
}
}
bool IsRangedSpec(uint8 cls, uint16 spec)
{
switch (cls)
{
case CLASS_MAGE: return true; // all specs
case CLASS_WARLOCK: return true; // all specs
case CLASS_PRIEST: // Shadow only
return spec == 258;
case CLASS_HUNTER: // BM(253) + MM(254);
return spec == 253 || spec == 254; // Survival(255) is melee
case CLASS_DRUID: // Balance only
return spec == 102;
case CLASS_SHAMAN: // Elemental only
return spec == 262;
case CLASS_EVOKER: // Devastation(1467) + Augmentation(1473)
return spec == 1467 || spec == 1473;
default: return false;
}
}
uint32 ClassMobilityEscape(uint8 cls, uint16 spec)
{
switch (cls)
{
case CLASS_MAGE: return 1953; // Blink — 20y forward
case CLASS_HUNTER: return 781; // Disengage — 13y back-flip
case CLASS_ROGUE: return 2983; // Sprint — 70% MS 8s
case CLASS_DEMON_HUNTER: return 198793; // Vengeful Retreat
case CLASS_DEATH_KNIGHT: return 212552; // Wraith Walk (talented)
case CLASS_DRUID: // Dash for Feral, Stampeding Roar for others
return spec == 103 ? 1850u : 77764u;
case CLASS_MONK: return 109132; // Roll
case CLASS_PALADIN: return 190784; // Divine Steed
case CLASS_WARRIOR: return 6544; // Heroic Leap
case CLASS_WARLOCK: return 48020; // Demonic Circle: Teleport
case CLASS_SHAMAN: return 58875; // Spirit Walk (Enh) / Ghost Wolf others
case CLASS_PRIEST: return spec == 258 ? 109964u : 0u; // Spirit Shell only Shadow has mobility
case CLASS_EVOKER: return 358267; // Hover (instant)
default: return 0;
}
}
uint32 ClassOffensiveBurst(uint8 cls, uint16 spec)
{
switch (cls)
{
case CLASS_WARRIOR:
// Arms 71 / Fury 72 use Recklessness; Prot 73 has it but defensive
if (spec == 73) return 0; // Prot tank — no burst CD
return 1719; // Recklessness
case CLASS_PALADIN:
if (spec == 65) return 0; // Holy — heal, no burst
if (spec == 66) return 0; // Prot — defensive cooldowns
return 31884; // Avenging Wrath (Ret 70)
case CLASS_HUNTER:
return 19574; // Bestial Wrath (BM 253) / others use it too via talent
case CLASS_ROGUE:
if (spec == 259) return 121471; // Shadow Blades (Assassination)
if (spec == 260) return 13750; // Adrenaline Rush (Outlaw)
if (spec == 261) return 185313; // Shadow Dance (Subtlety)
return 0;
case CLASS_PRIEST:
if (spec == 258) return 228260; // Void Eruption (Shadow)
return 0; // Disc/Holy — heal
case CLASS_DEATH_KNIGHT:
if (spec == 250) return 0; // Blood — defensive cooldowns
if (spec == 251) return 51271; // Pillar of Frost (Frost)
if (spec == 252) return 47568; // Empower Rune Weapon (Unholy / shared)
return 0;
case CLASS_SHAMAN:
if (spec == 264) return 0; // Resto — heal
if (spec == 262) return 191634; // Stormkeeper (Elemental)
if (spec == 263) return 51533; // Feral Spirit (Enhancement)
return 0;
case CLASS_MAGE:
if (spec == 62) return 12042; // Arcane Power
if (spec == 63) return 190319; // Combustion (Fire)
if (spec == 64) return 12472; // Icy Veins (Frost)
return 0;
case CLASS_WARLOCK:
if (spec == 265) return 205180; // Summon Darkglare (Affliction)
if (spec == 266) return 265187; // Summon Demonic Tyrant (Demonology)
if (spec == 267) return 1122; // Summon Infernal (Destruction)
return 0;
case CLASS_MONK:
if (spec == 269) return 137639; // Storm, Earth, and Fire (Windwalker)
return 0; // Brewmaster/Mistweaver — no PvP burst
case CLASS_DRUID:
if (spec == 102) return 194223; // Celestial Alignment (Balance)
if (spec == 103) return 106951; // Berserk (Feral)
return 0;
case CLASS_DEMON_HUNTER:
if (spec == 577) return 191427; // Metamorphosis (Havoc)
return 0; // Vengeance — defensive
case CLASS_EVOKER:
if (spec == 1467) return 375087; // Dragonrage (Devastation)
if (spec == 1473) return 395152; // Ebon Might (Augmentation)
return 0;
default:
return 0;
}
}
uint32 ClassLustSpell(uint8 cls)
{
switch (cls)
{
case CLASS_SHAMAN: return 2825; // Bloodlust (game routes to Heroism for Alliance via Player::CastSpell faction-aware)
case CLASS_MAGE: return 80353; // Time Warp
case CLASS_HUNTER: return 264667; // Primal Rage (pet)
case CLASS_EVOKER: return 390386; // Fury of the Aspects
default: return 0;
}
}
} // namespace Playerbot
+99
View File
@@ -0,0 +1,99 @@
// ClassTables - Per-(class,spec) lookup tables shared between auto-rules
// (State_Idle) and owner-driven whisper commands. Defines:
// - ClassSelfBuff(cls) → 1hr+ raid-frame buff spell id
// - ClassOocHeal(cls, spec) → spec basic single-target heal
// - FriendlyDispel(cls, spec) → friendly dispel spell + types
// All return 0 (or empty struct) when the (class,spec) has no offering —
// callers gate on the zero value.
#pragma once
#include "BotTypes.h"
namespace Playerbot {
uint32 ClassSelfBuff(uint8 cls);
uint32 ClassOocHeal(uint8 cls, uint32 spec);
struct ClassDispelSpell
{
uint32 spell_id;
bool magic;
bool curse;
bool disease;
bool poison;
};
ClassDispelSpell FriendlyDispel(uint8 cls, uint32 spec);
// Single-target hard interrupt per (class, spec). Used by the
// /interrupt squad command — owner targets a casting enemy and the
// addressed bot kicks. Returns 0 when the class/spec lacks a
// reliable interrupt (Holy Priest, fresh sub-level chars without
// the spell yet). Caller checks knows_spell() before emitting.
uint32 ClassInterrupt(uint8 cls, uint32 spec);
// Single-target crowd-control (CC) per class. Owner uses /cc to
// take a non-priority enemy out of the fight; the resolver picks
// the canonical CC: Polymorph for mages, Sap for rogues, Hex for
// shaman, etc. Returns 0 when the class lacks a usable CC at
// current spec.
uint32 ClassCC(uint8 cls, uint32 spec);
// Per-class tank-taunt spell id. Used by the M+ tank-swap rule
// (advice.tank_swap_on_spells) — off-tank casts this on the boss
// when the active tank's debuff stacks call for a swap. Returns 0
// for non-tank-capable classes; caller checks knows_spell()/is_ready().
// Warrior=355 (Taunt), Paladin=62124 (Hand of Reckoning),
// DeathKnight=49576 (Death Grip), Druid=6795 (Growl — bear-form
// only, caller must verify form), Monk=115546 (Provoke),
// DemonHunter=185245 (Torment).
uint32 ClassTaunt(uint8 cls);
// ---- Role ↔ spec helpers (shared between BotQueueFiller and
// State_Idle's idle:dual_spec_switch rule) ----
// Returns true when (cls, spec) IS the canonical tank/healer spec
// for that class (e.g., Warrior+73=Prot, Druid+105=Resto). Note
// Priest accepts both 256 Disc and 257 Holy for healing.
bool IsTankSpec(uint8 cls, uint16 spec);
bool IsHealerSpec(uint8 cls, uint16 spec);
// Class-side tank/healer SPEC ID for autonomous spec switching.
// Returns 0 for classes that can't fill that role (e.g., Hunter→Tank
// is 0 since Survival's tank stance was removed). Used by
// BotQueueFiller's "convert hybrid DPS to tank/healer before
// queueing" path AND idle:dual_spec_switch when owner /setrole
// pinned a role the current spec doesn't satisfy.
uint32 TankSpecForClass(uint8 cls);
uint32 HealerSpecForClass(uint8 cls);
// Returns true when (cls, spec) is a ranged DPS / ranged-style spec —
// drives the BG kiting rule: ranged classes back away from melee
// attackers; melee classes don't (they'd just lose uptime). Casters
// (Mage / Warlock / Shadow Priest / Balance Druid / Elemental Shaman /
// Hunter MM+BM / Evoker DPS specs) all return true. Survival Hunter is
// melee in modern WoW and returns false. Healers also return false —
// they kite via Healer-specific logic (LoS / instant heals).
bool IsRangedSpec(uint8 cls, uint16 spec);
// Class mobility / disengage spell — Blink / Disengage / Sprint /
// Vengeful Retreat / Wraith Walk etc. Used by the retreat-outnumbered
// rule to actually get away rather than slow-walking. Returns 0
// when the class lacks one. Spell IDs are 12.0+ canonical.
uint32 ClassMobilityEscape(uint8 cls, uint16 spec);
// Group "Bloodlust" buff. Shaman casts Bloodlust (Horde) / Heroism
// (Alliance), Mage Time Warp, Hunter Primal Rage (pet), Evoker Fury
// of the Aspects. 5-min CD, applies a 30% haste raid buff for 40s.
// Returns 0 for non-lust classes. The spell handles faction routing
// for Shaman; the call site picks based on bot's race/team if needed.
uint32 ClassLustSpell(uint8 cls);
// Class offensive burst cooldown — Recklessness / Avenging Wrath /
// Combustion / Bestial Wrath / Pillar of Frost / Stormkeeper /
// Demonic Power / Metamorphosis / Voidform / Berserk / Adrenaline
// Rush / Storm Earth Fire / Dragonrage. Fired when an enemy target
// is sub-30% HP for the kill. Returns 0 for tank/healer specs and
// classes without a clear single-button burst CD. 1.5-3min CD.
uint32 ClassOffensiveBurst(uint8 cls, uint16 spec);
} // namespace Playerbot
@@ -0,0 +1,264 @@
#include "DungeonScript.h"
#include "../BotSnapshotView.h"
#include "../BotSnapshot.h"
#include "DatabaseEnv.h"
#include "Config.h"
#include "Log.h"
#include <algorithm>
namespace Playerbot {
namespace {
inline uint64_t MakeKey(uint32_t map_id, uint32_t difficulty)
{
return (static_cast<uint64_t>(map_id) << 8) | (difficulty & 0xFFu);
}
// Dungeon route waypoints are STATIC, map-derived nav data (chain-pathfound
// from the navmesh) — identical across every realm running the same maps, just
// like the shared handcrafted_road table. They live in the shared playerbot
// schema (Playerbot.SharedDatabase, default "playerbot") so all servers share
// ONE copy instead of duplicating them in every realm's world DB, and queried
// via CharacterDatabase with a schema qualifier (mirrors BotNamePool /
// WorldMetadata — same MySQL server, cross-schema reference).
std::string const& SharedDb()
{
static std::string const db =
sConfigMgr->GetStringDefault("Playerbot.SharedDatabase", "playerbot");
return db;
}
}
void DungeonScriptMgr::Register(std::unique_ptr<DungeonScript> script)
{
if (!script) return;
const uint64_t key = MakeKey(script->map_id(), script->difficulty_id());
// Duplicate-map_id detection. The audit (2026-05-14) caught 3 scripts
// registered against the wrong map (LostCityOfTolvir 754→755,
// TempleOfSethraliss 1862→1877, Arcway/Vault sharing 1493). The first
// registration wins emplace; the second silently no-ops with no error
// unless we surface it. Log a WARN so the next time someone adds a
// dungeon they catch the collision immediately at module init.
if (auto it = scripts_.find(key); it != scripts_.end())
{
TC_LOG_WARN("playerbot.v2",
"[DungeonScriptMgr] duplicate registration: '{}' (map={}, diff={}) "
"conflicts with already-registered '{}'; new script discarded. "
"Verify map_id is correct.",
script->name(), script->map_id(), script->difficulty_id(),
it->second ? it->second->name() : "<null>");
return;
}
scripts_.emplace(key, std::move(script));
}
void DungeonScriptMgr::RegisterGlobal(std::unique_ptr<DungeonScript> script)
{
if (!script) return;
global_scripts_.push_back(std::move(script));
}
DungeonScript const* DungeonScriptMgr::GetScriptFor(uint32_t map_id, uint32_t difficulty_id) const
{
if (auto it = scripts_.find(MakeKey(map_id, difficulty_id)); it != scripts_.end())
return it->second.get();
if (difficulty_id != 0)
{
if (auto it = scripts_.find(MakeKey(map_id, 0)); it != scripts_.end())
return it->second.get();
}
return nullptr;
}
namespace {
inline void MergeAdvice(DungeonAdvice& dst, DungeonAdvice const& src)
{
auto cat = [](std::vector<uint32_t>& d, std::vector<uint32_t> const& s)
{ d.insert(d.end(), s.begin(), s.end()); };
cat(dst.high_priority_kill_entries, src.high_priority_kill_entries);
cat(dst.mandatory_interrupt_spells, src.mandatory_interrupt_spells);
cat(dst.cc_priority_entries, src.cc_priority_entries);
cat(dst.dangerous_auras, src.dangerous_auras);
cat(dst.kite_creature_entries, src.kite_creature_entries);
cat(dst.spread_on_self_auras, src.spread_on_self_auras);
cat(dst.stack_on_cast_spells, src.stack_on_cast_spells);
cat(dst.dispel_priority_spells, src.dispel_priority_spells);
cat(dst.pull_separately_entries, src.pull_separately_entries);
cat(dst.pull_separately_auras, src.pull_separately_auras);
cat(dst.dispel_enemy_priority_spells, src.dispel_enemy_priority_spells);
cat(dst.tank_swap_on_spells, src.tank_swap_on_spells);
cat(dst.soak_spells, src.soak_spells);
cat(dst.bosses, src.bosses);
dst.progression_waypoints.insert(dst.progression_waypoints.end(),
src.progression_waypoints.begin(), src.progression_waypoints.end());
}
// ORDER-PRESERVING dedup (audit B29/B35): the old sort+unique re-ordered
// every vector NUMERICALLY each tick, destroying the authored encounter
// progression that bosses[] (and high_priority_kill_entries) explicitly
// encode — tank-advance walks bosses[] in order, so any dungeon whose later
// boss had a lower creature entry got its progression scrambled. Keep first
// occurrence, preserve authored order; vectors are <=32 entries so the
// linear-scan dedup costs no more than the sort did.
inline void DedupAdvice(DungeonAdvice& a)
{
auto dedup = [](std::vector<uint32_t>& v)
{
if (v.size() < 2) return;
std::vector<uint32_t> out;
out.reserve(v.size());
for (uint32_t x : v)
if (std::find(out.begin(), out.end(), x) == out.end())
out.push_back(x);
v.swap(out);
};
dedup(a.high_priority_kill_entries);
dedup(a.mandatory_interrupt_spells);
dedup(a.cc_priority_entries);
dedup(a.dangerous_auras);
dedup(a.kite_creature_entries);
dedup(a.spread_on_self_auras);
dedup(a.stack_on_cast_spells);
dedup(a.dispel_priority_spells);
dedup(a.pull_separately_entries);
dedup(a.pull_separately_auras);
dedup(a.dispel_enemy_priority_spells);
dedup(a.tank_swap_on_spells);
dedup(a.soak_spells);
dedup(a.bosses);
// Waypoints are positional — duplicates are intentional in scripts
// that re-walk a corridor (path-and-return patterns). Don't dedup.
}
}
DungeonAdvice DungeonScriptMgr::GetAdvice(BotSnapshotView const& s) const
{
DungeonAdvice merged;
if (DungeonScript const* per_dungeon = GetScriptFor(s.map_id(), s.raw().instance_ctx.map_difficulty))
merged = per_dungeon->get_advice(s);
for (auto const& gs : global_scripts_)
MergeAdvice(merged, gs->get_advice(s));
// Per-tick dedup: cleans up 16+ scripts with copy-paste duplicate
// spell IDs in mandatory_interrupt_spells / 9 with duplicates in
// dangerous_auras / 3 in high_priority_kill_entries. The consumer
// rules scan these vectors linearly; the script-side dups never
// changed correctness (matching the same ID twice is a no-op) but
// they wasted compares and made the audit log noisier.
DedupAdvice(merged);
// Inject the DB route_waypoints (the shared playerbot DB is the single
// route source — no script authors chains anymore; the empty() guard is
// kept as a safety valve for any future script override). Copy the
// shared_ptr under a brief shared_lock so a concurrent hot-reload
// (`.playerbot reloadroutes`) swapping the table can't invalidate this
// read; the immutable snapshot stays alive via the local shared_ptr.
// Difficulty-0 rows are the "any-difficulty" default.
if (merged.route_waypoints.empty())
{
std::shared_ptr<RouteTable const> routes;
{
std::shared_lock<std::shared_mutex> lock(routes_mutex_);
routes = generated_routes_;
}
if (routes && !routes->empty())
{
const uint32_t diff = s.raw().instance_ctx.map_difficulty;
auto it = routes->find(MakeKey(s.map_id(), diff));
if (it == routes->end() && diff != 0)
it = routes->find(MakeKey(s.map_id(), 0));
if (it != routes->end())
merged.route_waypoints = it->second;
}
}
return merged;
}
size_t DungeonScriptMgr::LoadGeneratedRoutes()
{
// Hot-reload safe: build the new table completely OFF-lock (the DB query
// can take milliseconds), then publish it with one brief exclusive swap.
// Concurrent GetAdvice readers holding the old shared_ptr keep a coherent
// snapshot until their tick finishes.
auto fresh = std::make_shared<RouteTable>();
size_t count = 0;
QueryResult result = CharacterDatabase.Query(fmt::format(
"SELECT map_id, difficulty, position_x, position_y, position_z "
"FROM {}.playerbot_dungeon_routes ORDER BY map_id, difficulty, seq",
SharedDb()).c_str());
if (result)
{
do
{
Field* f = result->Fetch();
const uint32_t map = f[0].GetUInt16();
const uint32_t diff = f[1].GetUInt8();
(*fresh)[MakeKey(map, diff)].push_back(
DungeonAdvice::ProgressionPoint{ f[2].GetFloat(), f[3].GetFloat(), f[4].GetFloat() });
++count;
} while (result->NextRow());
}
const size_t dungeon_count = fresh->size();
{
std::unique_lock<std::shared_mutex> lock(routes_mutex_);
generated_routes_ = std::move(fresh);
}
if (count == 0)
TC_LOG_INFO("playerbot.v2",
"[DungeonRoutes] {}.playerbot_dungeon_routes empty/absent — no route waypoints.",
SharedDb());
else
TC_LOG_INFO("playerbot.v2",
"[DungeonRoutes] loaded {} route waypoint(s) across {} dungeon(s).",
count, dungeon_count);
return count;
}
size_t DungeonScriptMgr::LoadNavLinks()
{
// Same hot-reload discipline as the routes: build off-lock, publish with
// one brief exclusive swap. verified=0 rows are skipped — only rows a
// human (or a future validated editor flow) marked good may steer bots.
auto fresh = std::make_shared<NavLinkTable>();
size_t count = 0;
QueryResult result = CharacterDatabase.Query(fmt::format(
"SELECT id, map_id, from_x, from_y, from_z, to_x, to_y, to_z, "
"radius, bidirectional FROM {}.playerbot_nav_links WHERE verified=1 "
"ORDER BY map_id, id", SharedDb()).c_str());
if (result)
{
do
{
Field* f = result->Fetch();
NavLink l;
l.id = f[0].GetUInt32();
l.map_id = f[1].GetUInt32();
l.ax = f[2].GetFloat(); l.ay = f[3].GetFloat(); l.az = f[4].GetFloat();
l.bx = f[5].GetFloat(); l.by = f[6].GetFloat(); l.bz = f[7].GetFloat();
l.radius = f[8].GetFloat();
l.bidirectional = f[9].GetUInt8() != 0;
if (l.radius <= 0.f) l.radius = 12.0f;
(*fresh)[l.map_id].push_back(l);
++count;
} while (result->NextRow());
}
{
std::unique_lock<std::shared_mutex> lock(routes_mutex_);
nav_links_ = std::move(fresh);
link_hop_until_ms_.clear();
}
TC_LOG_INFO("playerbot.v2", "[NavLinks] loaded {} verified traversal link(s).", count);
return count;
}
bool DungeonScriptMgr::TryClaimLinkHop(uint64_t bot_guid_low, uint32_t link_id, uint32_t now_ms)
{
constexpr uint32_t kHopCooldownMs = 15000;
const uint64_t key = (bot_guid_low << 20) ^ link_id;
std::unique_lock<std::shared_mutex> lock(routes_mutex_);
auto it = link_hop_until_ms_.find(key);
if (it != link_hop_until_ms_.end() && now_ms < it->second)
return false;
link_hop_until_ms_[key] = now_ms + kHopCooldownMs;
return true;
}
} // namespace Playerbot
@@ -0,0 +1,339 @@
// DungeonScript — per-dungeon override hooks for the autonomous run
// system. Each registered script is keyed by `map_id` + (optional)
// difficulty; the Idle dungeon rules consult `DungeonScriptMgr` once
// per tick for advice on which adds to focus, which casts to always
// interrupt, which trash to CC, and which auras to step out from.
//
// Generic dungeon logic (tank pulls IsDungeonBoss, DPS assists tank,
// healer keeps tank up, all interrupt + CC marked targets) clears
// 80%+ of dungeons without any per-dungeon override. Scripts are
// pure overrides for the bespoke 20% — encounters where the optimal
// play is non-obvious from the snapshot alone (Cobrahn snake-form
// shift in WC, Greenskin's healer adds in Deadmines, etc.).
#pragma once
#include <cstdint>
#include <memory>
#include <shared_mutex>
#include <unordered_map>
#include <vector>
namespace Playerbot {
class BotSnapshotView;
// Advice bundle returned by a DungeonScript per tick. Empty fields
// mean "no opinion" — the AI uses generic logic. The advice struct
// is intentionally small and value-typed so the registry can hand
// it out by-value to the AI worker without locking.
struct DungeonAdvice
{
// Creature_template entries the bot should pull first / focus
// first when multiple targets are nearby. Higher index = higher
// priority (front of vector = top priority).
std::vector<uint32_t> high_priority_kill_entries;
// Spell ids that MUST be interrupted whenever they're seen mid-
// cast — overrides the generic "any interruptible cast" logic
// so the bot doesn't waste interrupts on weak abilities while a
// wipe-causing one goes off.
std::vector<uint32_t> mandatory_interrupt_spells;
// Creature entries that should be CCed (auto-marked moon) on
// sight when the tank's first pull starts. The CC rule still
// gates on class/spec compatibility (Mage poly = humanoid only,
// Hunter trap = beast/humanoid, etc).
std::vector<uint32_t> cc_priority_entries;
// Aura spell ids the bot should step away from when applied to
// self — typically boss ground-effect debuffs (Volcano, Death
// and Decay, Whirling Blades) where staying in melee = death.
std::vector<uint32_t> dangerous_auras;
// ---- M+ affix / advanced raid primitives (added 2026-05-13) ----
// These extend the script vocabulary so a single per-encounter or
// per-affix advice block can express the full set of common
// mechanics. Empty = "no opinion". Idle/combat rules consume only
// the fields they understand; unknown fields are harmless.
// Creature entries the DPS bots should kite at distance (M+ Spiteful
// shade, certain trash adds, raid adds with high melee threat).
std::vector<uint32_t> kite_creature_entries;
// Aura spell ids that, when applied to SELF, require the bot to
// move ≥ N yards from allies (M+ Volcanic / Quaking aftershock,
// raid debuffs that explode for AoE).
std::vector<uint32_t> spread_on_self_auras;
// Boss spell ids that, when seen mid-cast, require all bots to
// stack on the boss / a designated target (raid stack-soaks).
std::vector<uint32_t> stack_on_cast_spells;
// Dispellable debuffs that should be prioritized over normal
// dispel order (M+ Bursting tick, raid one-shot debuffs).
std::vector<uint32_t> dispel_priority_spells;
// Creature entries that gain a power-up when their allies die nearby
// (M+ Bolstering). Tanks should pull these one-at-a-time; DPS should
// even out health bars before any single kill.
std::vector<uint32_t> pull_separately_entries;
// Enemy buff aura ids that flag a mob as "pull-separately" regardless
// of its creature entry. Used for M+ Bolstering (the buff is applied
// by the affix at runtime; we can't enumerate every possible carrier
// creature). Tank-pull rule scans nearby_enemies[].auras for these
// ids and applies the same single-pull constraint.
std::vector<uint32_t> pull_separately_auras;
// Enemy buff aura ids that should be dispelled / soothed off the
// carrier (M+ Raging 228318, raid enrage buffs, demonic empower
// buffs). Consumed by class-aware enrage-dispel hooks (Hunter
// Tranq Shot 19801, Druid Soothe 2908, Mage Spellsteal 30449).
// Builder samples mob auras into NearbyUnit.affix_buffs; class APL
// checks intersection and dispatches the appropriate spell.
std::vector<uint32_t> dispel_enemy_priority_spells;
// Boss spell ids that the active tank should taunt-swap on
// (debuff stacks, fixate cast). Off-tank takes over.
std::vector<uint32_t> tank_swap_on_spells;
// Spell ids the bot should soak (intercept by standing in the AoE).
// Inverted polarity vs dangerous_auras: dangerous = move OUT;
// soak = move IN. Used for raid orb-soaks, certain M+ mechanics.
std::vector<uint32_t> soak_spells;
// Boss creature entries in encounter order. Order is encounter-
// progression so the dungeon AI can tell whether the run is complete
// (all bosses dead) and which boss is "next". Used by tank-advance
// and by diag commands. Live boss creature positions are read from
// the map; if no waypoints are provided, advancement falls back to a
// tight-radius Cell scan for these entries.
std::vector<uint32_t> bosses;
// Creature entries that bots should NEVER target for combat — purely
// environmental encounter objects (fire platters, bunny stalkers, rope
// anchors, etc.) that are alive but unkillable. Without this list,
// bots get stuck in perpetual interrupted-spell loops against these
// objects (observed: Glubtok Firewall Platter blocking advance for 90+s).
std::vector<uint32_t> ignore_entries;
// GameObject entries (levers / buttons) the tank should OPERATE during
// progression to unblock a boss or path — human-like: walk up and pull
// the lever. A boss can sit behind a CLOSED door a lever opens (SFK
// Baron Ashbury behind Cell Door 18934, opened by Lever 18900, wired by
// the lever's OWN SmartGameObjectAI — no instance script). Without this,
// bots beeline to the caged boss, get SPELL_FAILED_LINE_OF_SIGHT at
// point-blank, and stall 0/N. When the tank is near an un-used entry
// here, idle:dungeon_use_gate_lever walks to it and uses it (per-GUID
// lockout so the same lever is not toggled shut). Opt-in per dungeon:
// empty for every dungeon that has no such gating lever, so this is a
// strict no-op everywhere it is not authored.
std::vector<uint32_t> use_go_entries;
// Per-dungeon progression waypoints — ordered positions that lead a
// tank-spec bot through the canonical clear path. The tank-advance
// rule walks them in sequence; each waypoint is meant to put the
// tank within nearby_enemies range of the next mob pack / boss room.
// Path-validation (Detour) still guards against unreachable points,
// so an out-of-date coord just gets skipped. When empty, the rule
// falls back to the boss-Cell-scan path (less reliable; can pick
// a creature past geometry the navmesh has bad coverage for, which
// produced the "tank ran through a wall" bug in RFC 2026-05-13).
struct ProgressionPoint { float x; float y; float z; };
std::vector<ProgressionPoint> progression_waypoints;
// Per-dungeon INTERMEDIATE routing waypoints — on-navmesh stepping
// stones used by the boss-navigator to walk a tank toward a boss that
// is farther than the core 74-poly PathGenerator cap (MAX_PATH_LENGTH).
// Unlike progression_waypoints these are NOT boss-aligned (no 1:1 index
// mapping) and are NOT walked by the index fallback or the false-combat
// escape — they exist only so the boss-nav can chunk a cap-far approach
// through known-good navmesh points instead of string-pulling along a
// raw truncated path (which on the Deadmines foundry->harbor descent
// cuts across an off-mesh ledge and drops the tank). The navigator picks
// the farthest-forward point that is genuinely closer to the boss AND
// strictly reachable (clean <=74-poly NORMAL path), steps toward it, and
// hands back to the direct boss approach once the boss itself is
// strictly reachable. Detour validates every point, so a stale coord is
// simply skipped.
std::vector<ProgressionPoint> route_waypoints;
// ---- Tight-engagement zone (added 2026-06-29; generalized from the
// Deadmines harbor / Ripsnarl approach) ----
// Some encounters end in a dangerous final approach — a chokepoint, boss
// platform, or descent reached after the main clear — where the group must
// stay TIGHT and FOCUS-KILL casters instead of CCing/spreading, and the
// tank must not out-range its healer. When the tank descends BELOW this
// world-Z the dungeon AI switches into "tight engagement" mode:
// * tighter advance-cohesion gate (healer ≤18y, every member ≤25y, ≥85% HP)
// * tighter follower regroup radius (14y vs the default 22y) so the group
// balls up INSIDE the advance gate — eliminates the 18-22y cohesion
// dead-band that otherwise stalls the advance forever
// * smaller advance step (10y) so the tank never out-ranges heals
// * pre-emptive CC suppressed in favor of focus-killing (DPS assist the
// tank's target; high_priority_kill_entries set the kill order)
// * proactive DPS-engage on the tank's actual target even in the brief,
// leash-prone combat of event-spawned packs
// This is the reusable toolkit that beat the Deadmines harbor; future
// instances enable it by setting this Z (and the route_waypoints that lead
// into the zone). 0.0 (default) = feature OFF (the zone never triggers).
// The trigger is `tank_z < tight_engage_below_z`, so it suits a tight area
// that sits LOWER than the rest of the wing (Deadmines harbor floor = z<30,
// gauntlet = z57-62 → set 30.0). Requires route_waypoints to be set too.
float tight_engage_below_z = 0.0f;
};
// Abstract per-dungeon override. Subclasses live in
// Bot/Dungeon/Scripts/<region>/<dungeon>.cpp and self-register at
// module init via DungeonScriptMgr::Register.
class DungeonScript
{
public:
virtual ~DungeonScript() = default;
// The dungeon this script applies to. Difficulty 0 = any
// difficulty (script-applicable to Normal AND Heroic AND
// Mythic without redundant duplicates).
virtual uint32_t map_id() const = 0;
virtual uint32_t difficulty_id() const { return 0; }
// Diagnostic name (used in BotInspector "Dungeon: script=X" line).
virtual char const* name() const = 0;
// Per-tick advice. Called by the Idle dungeon rules when the
// bot is inside the dungeon. Snapshot view is read-only.
virtual DungeonAdvice get_advice(BotSnapshotView const& s) const = 0;
// Subset of this dungeon's bosses whose creature is EVENT-SUMMONED by the
// instance script (Skyriss via the Arcatraz warden consoles, Baron
// Rivendare after the Stratholme ziggurats, Urok Doomhowl from the LBRS
// ogre summoner, …) and therefore has ZERO static creature spawns. A
// clientless bot squad can never trigger the summon event, so the boss's
// InstanceScript encounter sits at NOT_STARTED forever. Full-clear
// completion (BotSnapshotBuilder) EXCLUDES these: a 5-man is "complete"
// once every *spawnable* boss is DONE. Snapshot-INDEPENDENT (a static fact
// about the dungeon design) so the completion path can read it via the
// cheap O(1) registry lookup without the per-tick GetAdvice() churn.
//
// Declared explicitly (author intent is ground truth) rather than
// DB-censused at runtime. Before adding an entry, verify
// SELECT COUNT(*) FROM creature WHERE id=<entry> AND map=<map_id> == 0
// A zero-spawn boss that is NOT event-summoned is a DATA ERROR (wrong
// entry id) and must be fixed in bosses[], never masked here.
virtual std::vector<uint32_t> event_summoned_bosses() const { return {}; }
};
// Registry of all loaded DungeonScripts, keyed by (map_id, difficulty).
// A DB-authored traversal link: "from A you can just MOVE to B" (jump a real
// geometric split, walk an unmeshed-but-walkable stretch) — the behavioral
// alternative to baking off-mesh connections into the binary mmap tiles.
// Owner-verified rows live in {SharedDb()}.playerbot_nav_links (editor-
// authored, hot-reloadable via .playerbot reloadroutes); the dungeon stepper
// consumes them when the on-mesh path dead-ends at a link mouth, committing
// the crossing through the existing set_dungeon_cross/DungeonHonorCross
// "just move, don't think" machinery. Bots are the only consumers who need
// these routes, so the core PathGenerator stays untouched and the shipped
// mmaps remain byte-identical to stock TC data.
struct NavLink
{
uint32_t id = 0;
uint32_t map_id = 0;
float ax = 0, ay = 0, az = 0;
float bx = 0, by = 0, bz = 0;
float radius = 12.0f; // how close (3D) to a mouth the consumer must be
bool bidirectional = true;
};
// Created and populated at module init via Services::Dungeons().
// Lookup is O(1) via unordered_map.
class DungeonScriptMgr
{
public:
DungeonScriptMgr() = default;
// Takes ownership; called once per script at module init. The
// registry must be assembled before any AI ticks consult it.
void Register(std::unique_ptr<DungeonScript> script);
// Register an "always-on" script whose advice is merged with the
// per-dungeon script's advice on every GetAdvice call, regardless
// of map_id. Used for M+ affix advice (Sanguine, Spiteful, etc.)
// and raid-wide patterns. map_id() / difficulty_id() are ignored.
void RegisterGlobal(std::unique_ptr<DungeonScript> script);
// Returns the script for `map_id` (and optional matching
// difficulty). Falls back to a difficulty-0 (any) script if no
// exact match. nullptr when no script is registered for this
// map — caller treats as "use generic logic".
DungeonScript const* GetScriptFor(uint32_t map_id, uint32_t difficulty_id = 0) const;
// Convenience: returns combined advice for the snapshot's
// current map. Empty advice when no script is registered.
DungeonAdvice GetAdvice(BotSnapshotView const& s) const;
size_t size() const { return scripts_.size(); }
// Load dungeon route_waypoints from the SHARED playerbot DB
// ({Playerbot.SharedDatabase}.playerbot_dungeon_routes — populated by
// gen_dungeon_routes.py and/or the world editor). Keyed by
// (map_id<<8)|difficulty; INJECTED by GetAdvice for dungeons whose script
// left route_waypoints empty (the DB is the single route source — no script
// authors chains anymore). HOT-RELOADABLE: called once at module init and
// again from `.playerbot reloadroutes` (SOAP) whenever the editor commits
// route changes — builds the new table off to the side and atomically swaps
// the shared_ptr under routes_mutex_, so concurrent GetAdvice readers on the
// AI worker threads keep a consistent (old or new) snapshot and no restart
// is needed. Returns the number of waypoints loaded.
size_t LoadGeneratedRoutes();
// Load DB-authored traversal links ({SharedDb()}.playerbot_nav_links).
// Same hot-reload pattern as the routes (build off-lock, swap under
// routes_mutex_); called at init + from `.playerbot reloadroutes`.
// Returns the number of links loaded.
size_t LoadNavLinks();
// Immutable snapshot of the per-map nav links (nullptr-safe; may be empty).
// Readers copy the shared_ptr under a brief shared_lock, then read lock-free.
using NavLinkTable = std::unordered_map<uint32_t, std::vector<NavLink>>;
std::shared_ptr<NavLinkTable const> GetNavLinks() const
{
std::shared_lock<std::shared_mutex> lock(routes_mutex_);
return nav_links_;
}
// Per-(bot,link) hop cooldown so a bot whose target is unreachable from
// BOTH sides of a bidirectional link cannot ping-pong across it: once a
// hop is claimed, the same link is refused for this bot for a few seconds
// (a successful crossing never needs the reverse hop that fast).
bool TryClaimLinkHop(uint64_t bot_guid_low, uint32_t link_id, uint32_t now_ms);
// Iterate every registered per-dungeon script (excludes globals).
// Used by the static `.playerbot smoketest dungeon` validator that
// walks the registry to verify each script's bosses / interrupts /
// creature entries resolve through the live ObjectMgr + SpellMgr.
template <class Fn>
void for_each_script(Fn fn) const
{
for (auto const& [key, script] : scripts_)
if (script) fn(*script);
}
private:
// Key encoding: (map_id << 8) | difficulty_id. Difficulty 0 is
// the "any" wildcard probed second.
std::unordered_map<uint64_t, std::unique_ptr<DungeonScript>> scripts_;
// Global scripts merged on every GetAdvice call. Order of append
// is preserved; advice is concatenated. Typical use: M+ affix
// bundles, raid-wide pattern primitives.
std::vector<std::unique_ptr<DungeonScript>> global_scripts_;
// DB-sourced route waypoints keyed by (map_id<<8)|difficulty. The map is
// IMMUTABLE once published; LoadGeneratedRoutes builds a fresh instance and
// swaps the shared_ptr under routes_mutex_ (hot-reload). GetAdvice readers
// (AI worker threads, every tick) take a brief shared_lock only to copy the
// shared_ptr, then read the immutable map lock-free — an in-flight reader
// keeps the old table alive via its shared_ptr during a swap.
using RouteTable =
std::unordered_map<uint64_t, std::vector<DungeonAdvice::ProgressionPoint>>;
std::shared_ptr<RouteTable const> generated_routes_;
// DB-authored traversal links (see NavLink). Same publish-by-swap pattern
// and mutex as the routes; hop-cooldown map guarded by the same mutex
// (exclusive) — touched only when a hop actually fires (rare).
std::shared_ptr<NavLinkTable const> nav_links_;
std::unordered_map<uint64_t, uint32_t> link_hop_until_ms_;
mutable std::shared_mutex routes_mutex_;
};
} // namespace Playerbot
@@ -0,0 +1,244 @@
// GroupWedgeWatchdog implementation. See header for the design contract.
//
// READ-ONLY: classify + log only. No bot state is mutated. Mirrors
// PveGroupCoordinator's registry bucketing + immutable-snapshot inputs, and runs
// in the same world-thread OnWorldUpdate slot as Diagnostics::WedgeWatchdog.
#include "GroupWedgeWatchdog.h"
#include "DungeonScript.h"
#include "../BotRegistry.h"
#include "../BotSnapshotView.h"
#include "../../Group/GroupSnapshot.h"
#include "../../Services.h"
#include "../../Threading/SnapshotPublisher.h"
#include "PlayerbotAPI.h" // Playerbot::PathBudget
#include "PlayerbotMovement.h" // BotMovement::SehSafeCalculatePath (tile-race guard)
#include "PathGenerator.h" // PathGenerator + PathType reachability verdict
#include "Config.h"
#include "Group.h"
#include "Log.h"
#include "Map.h"
#include "ObjectAccessor.h"
#include "Player.h"
#include <algorithm>
#include <cmath>
#include <vector>
namespace Playerbot {
namespace {
// Diagnose cadence. Much slower than the 500ms coordinator — a wedge is a
// multi-second condition and a path probe is comparatively costly.
constexpr uint32 kIntervalMs = 3000;
inline float sq(float v) { return v * v; }
} // namespace
void GroupWedgeWatchdog::Update(uint32 now_ms)
{
if (now_ms - last_tick_ms_ < kIntervalMs)
return;
last_tick_ms_ = now_ms;
if (!sConfigMgr->GetBoolDefault("PlayerbotV2.GroupWedge.Enabled", true))
{
if (!groups_.empty()) groups_.clear();
return;
}
// Tunables (read live so they are hot-reloadable; quiet defaults so the keys
// are optional). A wedge is "no forward progress (group centroid moved
// < MoveYards) AND not in real combat for WindowMs".
const uint32 windowMs = uint32(sConfigMgr->GetIntDefault("PlayerbotV2.GroupWedge.WindowMs", 20000));
const float cohereY = float(sConfigMgr->GetFloatDefault("PlayerbotV2.GroupWedge.CohereYards", 30.0));
const float splitY = float(sConfigMgr->GetFloatDefault("PlayerbotV2.GroupWedge.SplitYards", 45.0));
const float farY = float(sConfigMgr->GetFloatDefault("PlayerbotV2.GroupWedge.FarYards", 45.0));
const float moveY = float(sConfigMgr->GetFloatDefault("PlayerbotV2.GroupWedge.MoveYards", 12.0));
// -- Bucket grouped, instanced bots by (group, map) (mirror PveGroupCoordinator) --
struct Bucket
{
uint64 group_low = 0;
uint32 map_id = 0;
std::vector<std::pair<uint64, Player*>> bots;
};
std::unordered_map<uint64, Bucket> buckets;
Services::Registry().for_each([&](BotId id, BotRegistryEntry const& e)
{
if (!e.ai) return;
Player* p = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!p) return;
Group const* grp = p->GetGroup();
if (!grp) return;
Map* m = p->GetMap();
if (!m || !m->IsDungeon()) return;
const uint64 glow = grp->GetGUID().GetCounter();
const uint64 key = (glow << 16) ^ uint64(m->GetId());
Bucket& b = buckets[key];
b.group_low = glow;
b.map_id = m->GetId();
b.bots.emplace_back(uint64(id), p);
});
// Forget groups that are no longer bucketed (left instance / disbanded).
for (auto it = groups_.begin(); it != groups_.end(); )
it = (buckets.find(it->first) == buckets.end()) ? groups_.erase(it) : std::next(it);
for (auto& [key, b] : buckets)
{
std::shared_ptr<GroupSnapshot const> gs =
Services::Snapshots().latest_group(b.bots.front().first);
if (!gs || gs->members.empty())
continue;
// Alive, same-map member geometry. (Corpse-runners / cross-map laggards
// excluded — a wedge is about the live body's progress.)
struct M
{
uint64 low;
float x, y, z;
bool tank, in_combat, has_victim;
Player* p;
};
std::vector<M> mem;
mem.reserve(gs->members.size());
for (auto const& gm : gs->members)
{
if (!gm.online || !gm.is_alive || gm.map_id != b.map_id)
continue;
M e;
e.low = gm.guid.GetCounter();
e.x = gm.x; e.y = gm.y; e.z = gm.z;
e.tank = (gm.role == Role::Tank);
e.in_combat = gm.in_combat;
e.has_victim = !gm.victim.IsEmpty();
e.p = nullptr;
for (auto const& [bl, pp] : b.bots) if (bl == e.low) { e.p = pp; break; }
mem.push_back(e);
}
if (mem.size() < 2)
{
groups_.erase(key);
continue;
}
// Group centroid + spread (max member distance from centroid).
float cx = 0, cy = 0, cz = 0;
for (auto const& e : mem) { cx += e.x; cy += e.y; cz += e.z; }
cx /= mem.size(); cy /= mem.size(); cz /= mem.size();
float spread = 0.f;
const M* farthest = nullptr;
for (auto const& e : mem)
{
const float d = std::sqrt(sq(e.x - cx) + sq(e.y - cy) + sq(e.z - cz));
if (d > spread) { spread = d; farthest = &e; }
}
// Tank + non-tank "body" centroid + tank->body distance.
const M* tank = nullptr;
for (auto const& e : mem) if (e.tank) { tank = &e; }
float bx = 0, by = 0, bz = 0; int nb = 0;
for (auto const& e : mem) if (!e.tank) { bx += e.x; by += e.y; bz += e.z; ++nb; }
float tankToBody = 0.f;
if (nb > 0 && tank)
{
bx /= nb; by /= nb; bz /= nb;
tankToBody = std::sqrt(sq(tank->x - bx) + sq(tank->y - by) + sq(tank->z - bz));
}
// "Real combat" = the tank is fighting a fightable (targetable, non-
// stalker) enemy. This distinguishes a genuine boss/trash fight (which
// IS progress) from the SFK false-combat wedge (in combat, fightable=0).
int tankFightable = -1; // -1 = unknown (human tank / cold snapshot)
if (tank)
{
if (auto ts = Services::Snapshots().latest(tank->low))
tankFightable = int(BotSnapshotView(*ts).fightable_attackers_count());
}
const bool real_combat = (tankFightable > 0);
GroupState& st = groups_[key];
if (!st.anchored || st.map_id != b.map_id)
{
st.anchored = true; st.map_id = b.map_id;
st.anchor_x = cx; st.anchor_y = cy; st.anchor_z = cz;
st.progress_ms = now_ms; st.last_log_ms = 0;
continue;
}
const float moved = std::sqrt(sq(cx - st.anchor_x) + sq(cy - st.anchor_y) + sq(cz - st.anchor_z));
if (moved > moveY)
{
st.anchor_x = cx; st.anchor_y = cy; st.anchor_z = cz;
st.progress_ms = now_ms;
continue;
}
if (real_combat)
{
// Engaged with real enemies = progressing; hold the anchor (the body
// legitimately stands still during a fight) but reset the clock.
st.progress_ms = now_ms;
continue;
}
const uint32 wedged_ms = now_ms - st.progress_ms;
if (wedged_ms < windowMs)
continue; // not (yet) wedged
// Throttle: one [group_wedge] line per WindowMs per group.
if (st.last_log_ms != 0 && now_ms - st.last_log_ms < windowMs)
continue;
st.last_log_ms = now_ms;
// -- Classify the wedge ------------------------------------------------
char const* cls = "other";
char const* detail = "";
// 1. tank false-combat: in combat but no fightable enemies (SFK).
if (tank && tank->in_combat && tankFightable == 0)
{
cls = "false_combat";
}
// 2. stranded: a member far from the body whose path to it is NoPath.
else if (farthest && std::sqrt(sq(farthest->x - cx) + sq(farthest->y - cy) + sq(farthest->z - cz)) > farY)
{
bool nopath = false;
if (farthest->p && Playerbot::PathBudget::HasBudget(now_ms))
{
PathGenerator pg(farthest->p);
BotMovement::SehSafeCalculatePath(pg, cx, cy, cz);
PathType const pt = pg.GetPathType();
nopath = (pt & (PATHFIND_NOPATH | PATHFIND_FARFROMPOLY | PATHFIND_INCOMPLETE)) != 0;
}
if (nopath) { cls = "stranded"; detail = "nopath"; }
else { cls = "split_straggler"; detail = "haspath"; }
}
// 3. tank forward, body left behind (RFC).
else if (tank && nb >= 2 && tankToBody > splitY)
{
cls = "split_tank_fwd";
}
// 4. cohered but idle: tight cluster, tank OOC, no advance (WC run 2).
else if (spread < cohereY && (!tank || !tank->in_combat))
{
cls = "cohered_idle";
}
TC_LOG_INFO("playerbot.v2",
"[group_wedge] grp={} map={} cls={} {} n={} spread={:.0f} tank_d={:.0f} "
"far={:.0f} moved={:.0f} wedged_s={} tank_combat={} tank_victim={} tank_fightable={}",
b.group_low, b.map_id, cls, detail, unsigned(mem.size()), spread, tankToBody,
farthest ? std::sqrt(sq(farthest->x - cx) + sq(farthest->y - cy) + sq(farthest->z - cz)) : 0.f,
moved, wedged_ms / 1000u,
tank ? (tank->in_combat ? 1 : 0) : -1,
tank ? (tank->has_victim ? 1 : 0) : -1, tankFightable);
}
}
} // namespace Playerbot
@@ -0,0 +1,58 @@
// GroupWedgeWatchdog — read-only group-level no-progress DIAGNOSE engine.
//
// The per-bot Diagnostics::WedgeWatchdog deliberately EXEMPTS in-instance/grouped
// bots from its no-progress detector, so a dungeon group that stops making forward
// progress has NO safety net today. The 2026-06-30 multi-dungeon validation showed
// the group fails DIFFERENTLY each run — SFK tank-can't-reach false-combat / RFC
// tank-forward split / WC frozen Z-straggler + cohered-but-idle stall / Deadmines
// off-mesh — a CLUSTER of nav/advance/cohesion fragilities, not one bug. The
// robustness program is: watchdog -> diagnose {stranded|split|cohered_idle|
// false_combat} -> recover.
//
// THIS class is the DIAGNOSE stage and is intentionally READ-ONLY: it classifies a
// wedge and emits one structured [group_wedge] log line per episode. It changes NO
// behavior, so it cannot regress the working dungeons (Deadmines 5/6) and can ship
// before live verification. Remediation is a SEPARATE, config-gated follow-up
// (PlayerbotV2.GroupWedge.RemediationEnabled) enabled per-classification only once
// the live classification is confirmed — so the delicate advance/cohesion core is
// never touched blind (it has regressed twice already).
//
// Runs on the WORLD THREAD in OnWorldUpdate, same lifecycle slot as
// Diagnostics::WedgeWatchdog, internally throttled. Mirrors PveGroupCoordinator's
// per-(group, map) bucketing and immutable-snapshot inputs.
#pragma once
#include "Define.h"
#include <unordered_map>
namespace Playerbot {
class GroupWedgeWatchdog
{
public:
GroupWedgeWatchdog() = default;
// World-thread driver; internally throttled to its own cadence. Reads only
// immutable published snapshots + (for one stranded probe) a world-thread
// path calculation. Never mutates bot state.
void Update(uint32 now_ms);
private:
struct GroupState
{
uint32 map_id = 0;
float anchor_x = 0.f;
float anchor_y = 0.f;
float anchor_z = 0.f;
uint32 progress_ms = 0; // last tick the group made forward progress
uint32 last_log_ms = 0; // [group_wedge] emit throttle
bool anchored = false;
};
// Keyed by (group_low << 16) ^ map_id, same as PveGroupCoordinator.
std::unordered_map<uint64, GroupState> groups_;
uint32 last_tick_ms_ = 0;
};
} // namespace Playerbot
@@ -0,0 +1,388 @@
// PveGroupCoordinator implementation. See header for the design contract.
#include "PveGroupCoordinator.h"
#include "DungeonScript.h"
#include "../BotRegistry.h"
#include "../BotSnapshotView.h"
#include "../ClassTables.h"
#include "../../Group/GroupSnapshot.h"
#include "../../Fleet/BotIdentityRegistry.h"
#include "../../Services.h"
#include "../../Threading/SnapshotPublisher.h"
#include "Config.h"
#include "Group.h"
#include "Log.h"
#include "Map.h"
#include "ObjectAccessor.h"
#include "Player.h"
#include <algorithm>
#include <sstream>
namespace Playerbot {
namespace {
// Plan cadence. PvE duty assignments (tank roles, interrupt ranks, healer
// focus) are stable for whole pulls; the only fast-moving output is the
// synchronized kill target, and a 500ms refresh after an add dies is well
// inside human reaction time.
constexpr uint32 kPlanIntervalMs = 500;
} // namespace
void PveGroupCoordinator::Update(uint32 now_ms)
{
if (now_ms - last_plan_ms_ < kPlanIntervalMs)
return;
last_plan_ms_ = now_ms;
if (!sConfigMgr->GetBoolDefault("Playerbot.Pve.Coordinator.Enable", true))
{
if (!orders_.empty()) orders_.clear();
last_dump_ = "coordinator disabled (Playerbot.Pve.Coordinator.Enable=0)";
return;
}
// -- Bucket every grouped, instanced bot by (group, map) -----------------
// The map is part of the key: a group can momentarily span TWO dungeon
// maps (laggards still zoning out of the previous instance). One plan
// per (group, map) keeps every duty holder physically able to act —
// the review found a single per-group plan could hand main-tank /
// soaker / rank-0 duties to members in a different instance.
struct Bucket
{
uint64 group_low = 0;
uint32 map_id = 0;
std::vector<std::pair<uint64, Player*>> bots;
};
std::unordered_map<uint64, Bucket> buckets;
Services::Registry().for_each([&](BotId id, BotRegistryEntry const& e)
{
if (!e.ai) return;
Player* p = ObjectAccessor::FindConnectedPlayer(
ObjectGuid::Create<HighGuid::Player>(id));
if (!p) return;
Group const* grp = p->GetGroup();
if (!grp) return;
// Dungeons AND raids (Map::IsDungeon covers both); the open world
// keeps the legacy behavior — coordination there is the BG
// coordinator's job (battlegrounds) or unnecessary (quest mobs).
Map* m = p->GetMap();
if (!m || !m->IsDungeon()) return;
const uint64 glow = grp->GetGUID().GetCounter();
const uint64 key = (glow << 16) ^ uint64(m->GetId());
Bucket& b = buckets[key];
b.group_low = glow;
b.map_id = m->GetId();
b.bots.emplace_back(uint64(id), p);
});
next_orders_.clear();
std::ostringstream dump;
for (auto& [bkey, b] : buckets)
{
// Shared group snapshot: one per group per tick, covers EVERY
// member including humans (roles, positions, vitals).
std::shared_ptr<GroupSnapshot const> gs =
Services::Snapshots().latest_group(b.bots.front().first);
if (!gs || gs->members.empty())
continue; // group data cold — legacy runs
// Reference bot snapshot: freshest member view; provides
// nearby_enemies for the synchronized kill target and the view
// GetAdvice reads. Same world-thread-computed-advice pattern as
// the BG coordinator — never read a BotAI's advice cache here.
std::shared_ptr<BotSnapshot const> ref;
for (auto const& [blow, p] : b.bots)
{
auto s = Services::Snapshots().latest(blow);
if (s && (!ref || s->version > ref->version))
ref = s;
}
if (!ref)
continue;
DungeonAdvice const advice =
Services::Dungeons().GetAdvice(BotSnapshotView(*ref));
// -- Census ----------------------------------------------------------
// Humans first, then bots, each tier ordered by guid: a human tank
// claims main-tank duty, a human healer claims the tank-heal slot,
// and duty assignment stays deterministic across plans.
//
// Two presence semantics from the review:
// * MAP SCOPE: only members physically on the bucket's instance
// map hold duties. A tank corpse-running outside the portal
// must not keep main-tank (which gated the inside tank out of
// every pull until they zoned back in).
// * TRUE DEATH STATE: m.is_alive (Unit::IsAlive), NOT hp > 0 —
// a released ghost has hp == 1 and would otherwise keep its
// duty for the whole graveyard run (ghost rank-0 kicker =
// nobody interrupts; ghost soakers = circles unsoaked).
std::vector<Member> mem;
mem.reserve(gs->members.size());
auto& lifecycle = Services::Lifecycle();
for (auto const& m : gs->members)
{
if (!m.online) continue;
if (m.map_id != b.map_id) continue;
Member e;
e.guid_low = m.guid.GetCounter();
e.is_bot = lifecycle.is_bot(e.guid_low);
e.alive = m.is_alive;
e.cls = m.cls;
e.spec = uint16(m.spec);
e.tank = m.role == Role::Tank || IsTankSpec(m.cls, e.spec);
e.healer = m.role == Role::Healer || IsHealerSpec(m.cls, e.spec);
e.interrupter = !e.healer && ClassInterrupt(m.cls, m.spec) != 0;
mem.push_back(e);
}
std::sort(mem.begin(), mem.end(), [](Member const& a, Member const& c)
{
if (a.is_bot != c.is_bot) return !a.is_bot; // humans first
return a.guid_low < c.guid_low;
});
if (mem.empty())
continue;
// -- Duty assignment ---------------------------------------------------
// Start every BOT member with an active, otherwise-empty order:
// `active` alone already suppresses the legacy herd paths that
// need an explicit assignment to act (soak stays governed by the
// `soaker` field below).
std::unordered_map<uint64, PveOrder> plan;
for (auto const& e : mem)
if (e.is_bot)
{
PveOrder o;
o.active = true;
plan[e.guid_low] = o;
}
auto bot_order = [&](uint64 g) -> PveOrder*
{
auto it = plan.find(g);
return it == plan.end() ? nullptr : &it->second;
};
// Tanks: first (human-preferred) = main. The OFF-tank duty — a
// dedicated second tank that shadows the main and taunts on swap
// triggers — is a RAID concept ONLY. A classic 5-man runs
// 1 tank / 1 healer / 3 DPS: a second tank-spec member there is
// just a DPS, NOT a bodyguard — they keep tank_duty=0 and run
// legacy behavior (the pull gates still keep them from starting
// pulls, because they aren't the designated main tank).
// Deliberately NOT keyed on advice.tank_swap_on_spells: the
// MPlusAffix script merges Necrotic (209858) into EVERY dungeon's
// advice unconditionally, so list-presence is "always" — and a
// party tank-swap emergency is already handled by the legacy
// taunt-swap rule without a standing off-tank designation.
const bool wants_off_tank = gs->is_raid;
uint64 main_tank = 0, off_tank = 0;
for (auto const& e : mem)
{
if (!e.tank || !e.alive) continue;
if (!main_tank) { main_tank = e.guid_low; continue; }
if (!off_tank && wants_off_tank)
{ off_tank = e.guid_low; continue; }
}
for (auto const& e : mem)
{
if (!e.tank) continue;
if (PveOrder* o = bot_order(e.guid_low))
{
if (e.guid_low == main_tank) o->tank_duty = 1;
else if (e.guid_low == off_tank) o->tank_duty = 2;
}
}
// Healers: the first healer is the tank healer (focus = main
// tank); every further healer raid-triages. A human first healer
// simply leaves all bot healers on triage.
bool tank_heal_taken = false;
for (auto const& e : mem)
{
if (!e.healer || !e.alive) continue;
const bool take_focus = !tank_heal_taken && main_tank != 0;
tank_heal_taken = tank_heal_taken || take_focus;
if (PveOrder* o = bot_order(e.guid_low))
if (take_focus)
o->heal_focus =
ObjectGuid::Create<HighGuid::Player>(main_tank);
}
// Interrupt rotation: capable non-healer bots ranked 0..N-1 in
// census order (rank 0 kicks on sight, rank 1 backs up, 2+ hold
// for the NEXT cast — preserving kicks instead of dumping every
// cooldown on one cast). Human kickers can't be scheduled, so
// they are simply not part of the rotation.
uint8 irank = 0;
for (auto const& e : mem)
{
if (!e.interrupter || !e.alive || !e.is_bot) continue;
if (PveOrder* o = bot_order(e.guid_low))
o->interrupt_rank = irank < 0xFF ? irank++ : 0xFF;
}
// Soak duty: assigned UNCONDITIONALLY every plan (the fields cost
// nothing and the consumer only acts when the live advice lists
// soak mechanics — assigning here regardless removes any plan-
// time/consume-time advice disagreement window). Two designated
// DPS soakers (more bodies add nothing and feed the AoE);
// everyone else explicitly stays out — the legacy rule walked the
// ENTIRE group into the circle. Tanks and healers never soak.
// Living human DPS occupy soaker slots first (census order puts
// them ahead): a human already standing in for the mechanic means
// fewer bots need drafting.
{
int soakers = 0;
for (auto const& e : mem)
{
const bool dps = !e.tank && !e.healer;
if (!e.is_bot)
{
if (dps && e.alive && soakers < 2) ++soakers;
continue;
}
PveOrder* o = bot_order(e.guid_low);
if (!o) continue;
if (dps && e.alive && soakers < 2) { o->soaker = 1; ++soakers; }
else { o->soaker = 2; }
}
}
// Spread slots: stable per-member bearing indices so spread
// mechanics fan the group out instead of every bot stepping away
// from its (mutually nearest) neighbour in lockstep.
uint8 slot = 0;
for (auto const& e : mem)
if (PveOrder* o = bot_order(e.guid_low))
o->spread_slot = slot < 0xFF ? slot++ : 0xFF;
else
++slot; // humans consume a bearing too
// Synchronized kill target: ONE live priority add, every bot DPS
// burns it together. Scan the script's priority list in order
// (front = top priority) against the reference snapshot's live
// enemies. Sticky: keep the previous focus while it is still a
// live candidate so the focus doesn't flap between two adds of
// the same entry.
ObjectGuid kill_focus;
if (!advice.high_priority_kill_entries.empty())
{
// Stickiness is PER-GROUP coordinator state, not a read-back
// from a member's previous order (members leave groups; a
// single cold-data tick would silently drop the focus and
// re-roll it next plan).
ObjectGuid const prev_focus = [&]() -> ObjectGuid
{
auto it = last_kill_focus_.find(bkey);
return it != last_kill_focus_.end() ? it->second
: ObjectGuid::Empty;
}();
// Candidate guards (review): skip adds CC-locked by a group
// member (burning them breaks the CC — same rule the per-bot
// target pickers follow) and adds that are not engaged with
// anyone (ordering DPS onto an unpulled pack is a coordinated
// BAD pull; the tank rules own first contact).
auto cc_by_group = [&](NearbyUnit const& u) -> bool
{
if (!u.is_cc_locked || u.cc_caster.IsEmpty()) return false;
const uint64 caster_low = u.cc_caster.GetCounter();
for (auto const& e : mem)
if (e.guid_low == caster_low) return true;
return false;
};
for (uint32 want : advice.high_priority_kill_entries)
{
for (auto const& u : ref->combat.nearby_enemies)
{
if (u.hp <= 0 || u.entry != want) continue;
if (cc_by_group(u)) continue;
if (u.victim.IsEmpty()) continue; // unpulled — leave it
if (kill_focus.IsEmpty()) kill_focus = u.guid;
if (u.guid == prev_focus) { kill_focus = prev_focus; break; }
}
if (!kill_focus.IsEmpty())
break; // highest-priority entry with a live mob wins
}
}
if (kill_focus.IsEmpty())
last_kill_focus_.erase(bkey);
else
{
last_kill_focus_[bkey] = kill_focus;
for (auto const& e : mem)
{
if (e.tank || e.healer) continue; // tanks hold aggro, healers heal
if (PveOrder* o = bot_order(e.guid_low))
o->kill_focus = kill_focus;
}
}
// Publish the main tank's guid to every ordered bot — the
// off-tank's between-pull follow consumer and future assist
// logic key off it.
if (main_tank != 0)
{
ObjectGuid const mt_guid =
ObjectGuid::Create<HighGuid::Player>(main_tank);
for (auto& [pg_low, po] : plan)
po.main_tank = mt_guid;
}
// -- Publish + diagnostics --------------------------------------------
int n_bots = 0, n_soak = 0, n_kick = 0;
uint64 sig = 1469598103934665603ull;
auto mix = [&sig](uint64 v) { sig ^= v; sig *= 1099511628211ull; };
for (auto& [bg_low, o] : plan)
{
next_orders_[bg_low] = o;
++n_bots;
if (o.soaker == 1) ++n_soak;
if (o.interrupt_rank != 0xFF) ++n_kick;
mix(bg_low);
mix(uint64(o.tank_duty) | (uint64(o.interrupt_rank) << 8) |
(uint64(o.soaker) << 16));
mix(o.heal_focus.GetCounter());
mix(o.kill_focus.GetCounter());
}
auto sig_it = plan_sig_.find(bkey);
if (sig_it == plan_sig_.end() || sig_it->second != sig)
{
plan_sig_[bkey] = sig;
TC_LOG_INFO("playerbot.v2",
"[pvecoord] group={} map={} plan changed: members={} bots={} "
"main_tank={} off_tank={} kickers={} soakers={} kill_focus={}",
b.group_low, b.map_id, uint32(mem.size()), n_bots, main_tank,
off_tank, n_kick, n_soak, kill_focus.GetCounter());
}
dump << "group=" << b.group_low << " map=" << b.map_id
<< " members=" << mem.size()
<< " bots=" << n_bots
<< " main_tank=" << main_tank << " off_tank=" << off_tank
<< " kickers=" << n_kick << " soakers=" << n_soak
<< " kill_focus=" << kill_focus.GetCounter() << "\n";
}
orders_ = std::move(next_orders_);
next_orders_.clear();
for (auto it = plan_sig_.begin(); it != plan_sig_.end();)
it = buckets.count(it->first) ? std::next(it) : plan_sig_.erase(it);
for (auto it = last_kill_focus_.begin(); it != last_kill_focus_.end();)
it = buckets.count(it->first) ? std::next(it)
: last_kill_focus_.erase(it);
last_dump_ = dump.str();
if (last_dump_.empty())
last_dump_ = "no coordinated dungeon/raid groups";
}
std::string PveGroupCoordinator::DebugDump() const
{
return last_dump_.empty() ? std::string("coordinator has not planned yet")
: last_dump_;
}
} // namespace Playerbot
@@ -0,0 +1,94 @@
// PveGroupCoordinator — group-level dungeon/raid coordination.
//
// Counterpart of Bot/Battleground/BgTeamCoordinator for instanced PvE.
// Before this, group play relied on the tank hierarchy plus per-bot greed:
// every capable kicker burned its interrupt on the same mandatory cast
// (the "stagger" was a SELF-throttle, not a rotation), all healers triaged
// the same lowest-HP target, every bot walked into the same soak circle,
// DPS swapped to adds independently, and two bot tanks could pull
// different packs simultaneously. That roughly works at 5-man scale where
// the tank does the coordinating; in raids (10-30 bots) it reproduces the
// BG thundering herd — and even 5-mans waste kicks and stack soaks.
//
// This service computes ONE plan per GROUP on the WORLD THREAD every
// kPlanIntervalMs for groups with at least one V2 bot inside a dungeon or
// raid map, and publishes composable per-bot duties through the snapshot
// (BotSnapshot::PveOrder): main/off tank designation, an interrupt
// rotation (rank 0 kicks, rank 1 backs up, the rest HOLD), designated
// soakers, healer focus assignments, a synchronized kill target, and
// spread-bearing slots. `active == false` (or per-field sentinels) falls
// back to legacy behavior everywhere — graceful degradation by design.
//
// Threading contract (identical to BgTeamCoordinator): Update() is the only
// WRITER of orders_ and runs on the WORLD THREAD, inline in OnWorldUpdate
// BEFORE the parallel snapshot-build barrier (run_and_wait). OrderFor() is a
// pure const find() READER and is called concurrently from the Phase 4
// snapshot-build WORKER threads. This is safe ONLY because writer and readers
// are temporally disjoint: orders_ is never mutated during the build phase, and
// concurrent reads of a non-mutating unordered_map are well-defined. If Update()
// is ever moved to run concurrently with the build, orders_ needs a shared_mutex.
// Inputs are the published immutable GroupSnapshot / BotSnapshot shared_ptrs and
// DungeonScriptMgr::GetAdvice computed HERE from a member snapshot — never a
// BotAI-owned cache (those are AI-worker property; reading them cross-thread is a
// use-after-free, see the 2026-06-10 BG coordinator review).
//
// Human members are never ordered, but they OCCUPY duties: a human tank
// makes every bot tank an off-tank, a human healer shifts bot healers
// toward raid triage, and human DPS reduce how many bot soakers are
// drafted.
#pragma once
#include "../BotSnapshot.h"
#include <unordered_map>
#include <string>
#include <vector>
namespace Playerbot {
class PveGroupCoordinator
{
public:
PveGroupCoordinator() = default;
// World tick driver; internally throttled to the plan cadence.
void Update(uint32 now_ms);
// Order lookup for the snapshot builder. Returns nullptr when no
// current plan covers the bot (consumer publishes a default-inactive
// order and the AI runs legacy logic).
PveOrder const* OrderFor(uint64 bot_guid_low) const
{
auto it = orders_.find(bot_guid_low);
return it == orders_.end() ? nullptr : &it->second;
}
// Human-readable plan dump for `.playerbot pvecoord`.
std::string DebugDump() const;
private:
struct Member
{
uint64 guid_low = 0;
bool is_bot = false;
bool alive = false;
uint8 cls = 0;
uint16 spec = 0;
bool tank = false;
bool healer = false;
bool interrupter = false;
};
std::unordered_map<uint64, PveOrder> orders_;
std::unordered_map<uint64, PveOrder> next_orders_;
// Per-(group, map) plan signature for change-only logging.
std::unordered_map<uint64, uint64> plan_sig_;
// Per-(group, map) kill-focus stickiness — coordinator state rather
// than a read-back from member orders, which broke when the sampled
// member left the group or the data ticked cold.
std::unordered_map<uint64, ObjectGuid> last_kill_focus_;
uint32 last_plan_ms_ = 0;
std::string last_dump_;
};
} // namespace Playerbot
@@ -0,0 +1,82 @@
// AhnkahetScript — Ahn'kahet: The Old Kingdom (map 619, WotLK 73-79).
// 4 bosses: Elder Nadox, Prince Taldaram, Jedoga Shadowseeker, Herald
// Volazj.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/Northrend/AzjolNerub/Ahnkahet/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class AhnkahetScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 619; }
char const* name() const override { return "ahnkahet"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
30178, // Ahn'kahar Swarmer (Nadox add)
30176, // Ahn'kahar Guardian (Nadox add)
};
a.mandatory_interrupt_spells = {
// Volazj (final boss) — wipes the run if Insanity proceeds
57496, // Insanity
57941, // Mind Flay
57942, // Shadow Bolt Volley
// Nadox — swarm casts
56281, // Swarm Buff
56354, // Sprint
// Amanitar (mini boss) — root/disable
57094, // Bash
57095, // Entangling Roots
57088, // Venom Bolt Volley
};
a.cc_priority_entries = {
30178, // Swarmer (CC swarms before tank picks up)
};
a.dangerous_auras = {
// Volazj Insanity — phased illusions appear
57508, // Insanity target marker
57496, // Insanity main effect
// Amanitar mushrooms
57061, // Poisonous Mushroom Poison Cloud
56741, // Poisonous Mushroom Visual Aura
// Nadox area
56130, // Brood Plague
59465, // Brood Rage (boss enrage)
};
// Boss progression — NPC entries from TC's ahnkahet.h.
a.bosses = {
29309, // Elder Nadox
29308, // Prince Taldaram
29310, // Jedoga Shadowseeker
30258, // Amanitar (optional mini-boss)
29311, // Herald Volazj (final)
};
// Progression waypoints — Ahn'kahet is a 4-room nerubian dungeon.
a.progression_waypoints = {
{ 372.0f, -714.0f, -16.4f }, // entry
{ 460.0f, -769.0f, -16.0f }, // Nadox cavern
{ 374.0f, -842.0f, -27.0f }, // Amanitar tunnel
{ 226.0f, -797.0f, -16.0f }, // Taldaram blood pool
{ 250.0f, -842.0f, -16.0f }, // Jedoga altar
{ 296.0f, -752.0f, -29.0f }, // Volazj sanctum
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeAhnkahetScript()
{
return std::make_unique<AhnkahetScript>();
}
} // namespace Playerbot
@@ -0,0 +1,62 @@
// AlgetharAcademyScript — Algeth'ar Academy (map 2526, DF 60-70).
// Thaldraszus magical academy.
// * Vexamus — Rotational Drag.
// * Crawth — Tackle (charge); cone breaths.
// * Echo of Doragosa — Splice Reality.
// * Algeth'ar Echoknight — Sweeping Strikes.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class AlgetharAcademyScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 2526; }
char const* name() const override { return "algethar_academy"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.mandatory_interrupt_spells = {
376521, // Rotational Drag (Vexamus)
376631, // Splice Reality (Doragosa)
376713, // Sweeping Strikes (Echoknight)
376833, // Tackle (Crawth)
};
a.dangerous_auras = {
376521, // Rotational Drag zone
};
// Boss progression — Algeth'ar Academy has 4 encounters.
// Correct template IDs (boss-recognition); map 2526 has no boss
// spawn rows — bosses are event-summoned, navigator falls back
// to waypoints.
a.bosses = {
194181, // Vexamus
196482, // Overgrown Ancient
191736, // Crawth
190609, // Echo of Doragosa (final)
};
// Progression waypoints — Algeth'ar Academy is a Thaldraszus
// magical academy with multi-floor layout.
a.progression_waypoints = {
{ -3220.0f, -4940.0f, 100.0f }, // entry
{ -3296.0f, -4878.0f, 102.0f }, // Vexamus library
{ -3204.0f, -4801.0f, 104.0f }, // Ancient garden
{ -3128.0f, -4862.0f, 106.0f }, // Crawth aviary
{ -3066.0f, -4798.0f, 110.0f }, // Doragosa chamber
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeAlgetharAcademyScript()
{
return std::make_unique<AlgetharAcademyScript>();
}
} // namespace Playerbot
@@ -0,0 +1,70 @@
// AraKaraScript — Ara-Kara, City of Echoes (map 2660, TWW 70-80).
// Azj-Kahet nerubian temple-city dungeon.
// * Avanoxx — Insatiable Hunger (debuff stacks) + Gossamer Onslaught.
// * Anub'zekt — Eye of the Swarm (zone) + Burrow Charge.
// * Ki'katal the Harvester — Cosmic Singularity (interrupt) + Black Blood.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class AraKaraScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 2660; }
char const* name() const override { return "ara_kara"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
213937, // Anub'azal Webmage (Avanoxx pulls)
213334, // Bloodstained Webmage (Ki'katal)
217531, // Ixin (Avanoxx)
};
a.mandatory_interrupt_spells = {
434713, // Insatiable Hunger (Avanoxx)
433740, // Cosmic Singularity (Ki'katal)
436322, // Eye of the Swarm telegraph (Anub'zekt)
434589, // Black Blood Eruption (Ki'katal)
433778, // Eye of the Swarm cast
};
a.cc_priority_entries = {
213937,
213334,
};
a.dangerous_auras = {
434713, // Insatiable Hunger pool
433740, // Cosmic Singularity zone
436322, // Eye of the Swarm
434589, // Black Blood Eruption
};
// Boss progression — TWW S1 dungeon, no TC instance script.
// Entries from WoWHead — verify in-game if needed.
a.bosses = {
213179, // Avanoxx
215405, // Anub'zekt
215407, // Ki'katal the Harvester (final)
};
// Progression waypoints — Ara-Kara is an Azj-Kahet nerubian
// cave with linear chamber progression.
a.progression_waypoints = {
{ 555.0f, -1370.0f, 1413.0f }, // entry
{ 490.0f, -1310.0f, 1413.0f }, // Avanoxx chamber
{ 448.0f, -1207.0f, 1399.0f }, // Anub'zekt arena
{ 381.0f, -1067.0f, 1390.0f }, // Ki'katal sanctum
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeAraKaraScript()
{
return std::make_unique<AraKaraScript>();
}
} // namespace Playerbot
@@ -0,0 +1,84 @@
// ArcatrazScript — The Arcatraz (map 552, TBC 67-72).
// Tempest Keep wing. 4 bosses: Zereketh the Unbound, Dalliah the
// Doomsayer, Wrath-Scryer Soccothrates, Harbinger Skyriss.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/Outland/TempestKeep/arcatraz/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class ArcatrazScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 552; }
char const* name() const override { return "arcatraz"; }
// Skyriss is summoned by activating the three Warden's Shield consoles —
// a clientless bot squad can't trigger it, so his encounter never leaves
// NOT_STARTED (0 static spawns, verified). Exclude from the full-clear
// completion gate so the run reads complete after the other 3 bosses.
std::vector<uint32_t> event_summoned_bosses() const override { return { 20912 }; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
21436, // Skyriss Mirror Image
};
a.mandatory_interrupt_spells = {
// Skyriss
36924, // Mind Rend
37162, // Domination
// Dalliah
36173, // Gift of the Doomsayer
36142, // Whirlwind
36144, // Heal
39016, // Shadow Wave (heroic)
// Soccothrates
35759, // Felfire Shock
36512, // Knock Away
};
a.cc_priority_entries = {
};
a.dangerous_auras = {
// Skyriss
39415, // Fear
36929, // Mind Rend Image (illusion damage)
// Dalliah
36142, // Whirlwind zone
};
// Boss progression — TC arcatraz.h has Dalliah + Soccothrates;
// Zereketh (20870) and Skyriss (20912) entries from WoWHead /
// BossAI registration.
a.bosses = {
20870, // Zereketh the Unbound
20885, // Dalliah the Doomsayer
20886, // Wrath-Scryer Soccothrates
20912, // Harbinger Skyriss (final)
};
// Progression waypoints — Arcatraz is the Tempest Keep north
// prison satellite, a 4-floor tower with lifts. Final fight
// Skyriss includes 2 escape-pod adds.
a.progression_waypoints = {
{ 159.0f, 152.5f, -16.0f }, // entry
{ 179.0f, 97.0f, -16.0f }, // Zereketh floor
{ 227.0f, 17.0f, 0.0f }, // Dalliah level
{ 314.0f, -34.0f, 28.0f }, // Soccothrates level
{ 430.0f, -83.0f, 65.5f }, // Skyriss top
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeArcatrazScript()
{
return std::make_unique<ArcatrazScript>();
}
} // namespace Playerbot
@@ -0,0 +1,80 @@
// ArcwayScript — The Arcway (map 1516, Legion 100-110).
// Suramar arcane sewer dungeon — undead+demon mixed pulls.
// * Ivanyr — Power Overwhelming buff stacks (interrupt grants stacks).
// * Corstilax — Quarantine bubbles (containment mechanic).
// * General Xakal — Felbound Slash (cone) + Pursuing Spikes.
// * Nal'tira — Nether Venom + Tangled Web (spider webs).
// * Advisor Vandros (final) — Time Lock (interrupt) + Mana Bombs.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class ArcwayScript final : public DungeonScript
{
public:
// The Arcway (Legion/Suramar) instance map is 1516. The old 1456 was Eye of
// Azshara's map — the collision made DungeonScriptMgr discard whichever
// registered second (first-registration-wins), so Arcway bots got no boss
// callouts/pull-pacing/interrupts. (1492=Maw of Souls, 1493=Vault — both
// already taken; 1516 is unused by any other DungeonScript.)
uint32_t map_id() const override { return 1516; }
char const* name() const override { return "arcway"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
105617, // Eredar Chaosbringer (verified spawn on map 1516)
};
a.mandatory_interrupt_spells = {
195119, // Time Lock (Vandros)
195273, // Mana Bombs (Vandros)
195249, // Charged Bolt (Ivanyr)
195293, // Quarantine cast (Corstilax)
195275, // Volatile Magic (Ivanyr)
};
a.cc_priority_entries = {
105617, // Eredar Chaosbringer
};
a.dangerous_auras = {
195293, // Quarantine field
195119, // Time Lock zone
195275, // Volatile Magic ground
196158, // Belch (Naraxas)
};
// Progression waypoints — boss spawn positions from world.creature
// (map 1516), in encounter order. Vandros has no static spawn row
// (instance-script spawned), so his waypoint is omitted; the bots
// reach him via the boss-Cell-scan fallback after Nal'tira.
a.progression_waypoints = {
{ 3146.7f, 5118.5f, 623.3f }, // Ivanyr
{ 3124.2f, 4897.8f, 617.7f }, // Corstilax
{ 3318.1f, 4500.9f, 570.9f }, // General Xakal
{ 3143.9f, 4659.2f, 581.1f }, // Nal'tira
};
// Boss progression — The Arcway has 5 encounters. Entries verified
// against world.creature spawns on map 1516 (Vandros is spawned by
// the instance script, no static spawn row). The previous 909xx/910xx
// ids were Neltharion's Lair creatures (Rokmora/Ularogg/Naraxas).
a.bosses = {
98203, // Ivanyr
98205, // Corstilax
98206, // General Xakal
98207, // Nal'tira
98208, // Advisor Vandros (final)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeArcwayScript()
{
return std::make_unique<ArcwayScript>();
}
} // namespace Playerbot
@@ -0,0 +1,81 @@
// AtalDazarScript — Atal'Dazar (map 1763, BfA 110-120).
// 4 bosses: Priestess Alun'za, Vol'kaal, Rezan, Yazma.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/Zandalar/AtalDazar/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class AtalDazarScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 1763; }
char const* name() const override { return "atal_dazar"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
};
a.mandatory_interrupt_spells = {
// Priestess Alun'za
258386, // Ritual
255615, // Agitate
255583, // Molten Gold Missile
255577, // Transfusion
258703, // Corrupted Gold
// Rezan
255434, // Serrated Teeth
255421, // Devour
255371, // Terrifying Visage
257407, // Pursuit
};
a.cc_priority_entries = {
};
a.dangerous_auras = {
// Alun'za
255579, // Gilded Claws
255836, // Transfusion Damage
255558, // Tainted Blood Damage
255559, // Tainted Blood AreaTrigger
259205, // Spirit of Gold
258709, // Corrupted Gold Damage
259032, // Corrupt
259123, // Fatally Corrupted
// Rezan
255373, // Tail Damage
256608, // Pile of Bones Spawn
256606, // Pile of Bones Slow
};
// Boss progression — entries from TC's atal_dazar.h.
// Progression waypoints — Atal'Dazar is a Zandalari pyramid
// with a central plaza and 3 stairs.
a.progression_waypoints = {
{ 890.0f, 1404.0f, 36.0f }, // entry
{ 1029.0f, 1361.0f, 71.0f }, // Alun'za blood pool
{ 1191.0f, 1330.0f, 73.0f }, // Vol'kaal arena
{ 1234.0f, 1410.0f, 96.0f }, // Rezan platform
{ 1110.0f, 1483.0f, 184.0f }, // Yazma top
};
a.bosses = {
122967, // Priestess Alun'za
122965, // Vol'kaal
122963, // Rezan
122968, // Yazma (final)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeAtalDazarScript()
{
return std::make_unique<AtalDazarScript>();
}
} // namespace Playerbot
@@ -0,0 +1,77 @@
// AuchenaiCryptsScript — Auchenai Crypts (map 558, TBC 64-70).
// Auchindoun wing. 2 bosses: Shirrak the Dead Watcher, Exarch Maladaar.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/Outland/Auchindoun/AuchenaiCrypts/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class AuchenaiCryptsScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 558; }
char const* name() const override { return "auchenai_crypts"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
18371, // Avatar of the Martyred (Maladaar summons)
};
a.mandatory_interrupt_spells = {
// Exarch Maladaar
32421, // Soul Scream
32422, // Ribbon of Souls
32346, // Stolen Soul
// Stolen Soul avatar — various class casts
37328, // Moonfire (Avatar)
37329, // Fireball
37330, // Mind Flay
37331, // Hemorrhage
37332, // Frost Shock
37334, // Curse of Agony
37335, // Mortal Strike
37368, // Freezing Trap
37369, // Hammer of Justice
58839, // Plague Strike
};
a.cc_priority_entries = {
18406, // Auchenai Soulpriest
};
a.dangerous_auras = {
// Shirrak
32264, // Inhibit Magic (silence aura)
32265, // Attract Magic
32302, // Fiery Blast zone
// Maladaar
32395, // Stolen Soul Visual
};
// Boss progression — Auchenai Crypts has 2 bosses.
a.bosses = {
18371, // Shirrak the Dead Watcher
18373, // Exarch Maladaar (final)
};
// Progression waypoints — Auchenai Crypts is the northern
// Auchindoun wing: a 2-room linear path — entry corridor →
// Shirrak's antechamber → Maladaar's sanctum.
a.progression_waypoints = {
{ 31.6f, 13.0f, -19.3f }, // entry
{ 37.5f, -71.0f, -22.8f }, // Shirrak antechamber
{ 35.0f, -150.0f, -23.3f }, // Maladaar sanctum
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeAuchenaiCryptsScript()
{
return std::make_unique<AuchenaiCryptsScript>();
}
} // namespace Playerbot
@@ -0,0 +1,64 @@
// AuchindounScript — Auchindoun (map 1182, WoD 95-100).
// Modern reworked Auchindoun (separate map from TBC wings).
// * Vigilant Kaathar — Reverberating Hymn (interrupt critical).
// * Soulbinder Nyami — Soul Imbalance (random target target).
// * Azzakel — Doom Lord summons (priority kill).
// * Teron'gor (final) — Caustic Energy + Felflame.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class AuchindounScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 1182; }
char const* name() const override { return "auchindoun_wod"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
76206, // Doom Lord (Azzakel)
};
a.mandatory_interrupt_spells = {
153722, // Reverberating Hymn (Kaathar) — critical
154003, // Soul Imbalance (Nyami)
153874, // Caustic Energy (Teron'gor)
153755, // Doom Lord Summon (Azzakel)
};
a.dangerous_auras = {
154003, // Soul Imbalance debuff
153874, // Caustic Energy zone
};
// Boss progression — entries from TC's auchindoun.h.
a.bosses = {
75839, // Vigilant Kaathar
76177, // Soulbinder Nyami
87218, // Azzakel
77734, // Teron'gor (final)
};
// Progression waypoints — Auchindoun (WoD) is a linear soul
// temple: entry → Kaathar gate → soul corridor → Nyami arena →
// Azzakel sky → Teron'gor sanctum.
a.progression_waypoints = {
{ -116.0f, 4407.0f, -36.0f }, // entry
{ -75.0f, 4444.0f, -36.0f }, // Kaathar gate
{ -22.0f, 4429.0f, -36.0f }, // Nyami arena
{ 58.0f, 4404.0f, -25.0f }, // Azzakel platform
{ 121.0f, 4406.0f, -34.0f }, // Teron'gor sanctum
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeAuchindounScript()
{
return std::make_unique<AuchindounScript>();
}
} // namespace Playerbot
@@ -0,0 +1,73 @@
// AzjolNerubScript — Azjol-Nerub (map 601, WotLK 72-78).
// 3 bosses: Krik'thir the Gatewatcher, Hadronox, Anub'arak.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/Northrend/AzjolNerub/AzjolNerub/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class AzjolNerubScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 601; }
char const* name() const override { return "azjol_nerub"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
29105, // Watcher Narjil (Krik'thir gate)
29104, // Watcher Silthik (Krik'thir gate)
28922, // Crypt Fiend (Hadronox add)
};
a.mandatory_interrupt_spells = {
// Krik'thir
53030, // Leech Poison
53418, // Pierce Armor (debuff)
// Hadronox (acid)
53400, // Acid Cloud
// Anub'arak
53617, // Poison Bolt (assassin add)
53520, // Carrion Beetles (summons adds)
};
a.cc_priority_entries = {
};
a.dangerous_auras = {
// Anub'arak
59432, // Pound damage zone (frontal)
53456, // Impale Aura (burrow phase ground)
53455, // Impale Visual telegraph
53454, // Impale Damage
53467, // Leeching Swarm
// Krik'thir
57731, // Web Grab (silence/pull)
};
// Boss progression — NPC entries from TC's azjol_nerub.h.
a.bosses = {
28684, // Krik'thir the Gatewatcher
28921, // Hadronox
29120, // Anub'arak (final)
};
// Progression waypoints — AN is a vertical descent.
a.progression_waypoints = {
{ 549.6f, 254.0f, 222.2f }, // entry web
{ 415.0f, 211.0f, 224.4f }, // Krik'thir gate
{ 519.0f, 553.5f, 731.9f }, // Hadronox platform
{ 552.0f, 251.0f, 224.0f }, // Anub'arak pit
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeAzjolNerubScript()
{
return std::make_unique<AzjolNerubScript>();
}
} // namespace Playerbot
@@ -0,0 +1,88 @@
// AzureVaultScript — The Azure Vault (map 2515, DF 60-70).
// Azure Span dungeon. 4 bosses: Leymor, Azureblade, Telash Greywing,
// Umbrelskul.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/DragonIsles/AzureVault/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class AzureVaultScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 2515; }
char const* name() const override { return "azure_vault"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
193674, // Awakened Bramble (Leymor)
193791, // Telash Frost add
};
a.mandatory_interrupt_spells = {
// Leymor
375729, // Stasis
375749, // Arcane Eruption
374364, // Ley Line Sprouts
374720, // Consuming Stomp
374567, // Explosive Brand
374789, // Infused Strike
375591, // Sappy Burst
375596, // Erratic Growth Channel
375652, // Wild Eruption
// Telash Greywing
386781, // Frost Bomb Cast
387151, // Icy Devastator
387928, // Absolute Zero Cast
};
a.cc_priority_entries = {
};
a.dangerous_auras = {
// Leymor
374731, // Consuming Stomp Damage
386660, // Erupting Fissure
374570, // Explosive Brand Damage
388654, // Volatile Sapling
374161, // Ley Line Sprout AreaTrigger
375738, // Stasis Ritual Missile
375650, // Wild Eruption Missile
// Telash
386881, // Frost Bomb Aura
386910, // Frost Bomb Damage
387149, // Frozen Ground AreaTrigger
388008, // Absolute Zero Damage
388065, // Vault Rune AT Aura
};
// Boss progression — entries from TC's azure_vault.h.
a.bosses = {
186644, // Leymor
199614, // Telash Greywing
186739, // Umbrelskul (or Azureblade, depending on path)
186738, // Azureblade
};
// Progression waypoints — Azure Vault is an Azure Span dragon
// archive with vertical stair levels.
a.progression_waypoints = {
{ -3850.0f, -7170.0f, -800.0f }, // entry
{ -3700.0f, -7048.0f, -800.0f }, // Leymor nest
{ -3603.0f, -6961.0f, -780.0f }, // Telash spire
{ -3500.0f, -6890.0f, -760.0f }, // Umbrelskul vault
{ -3400.0f, -6820.0f, -740.0f }, // Azureblade chamber
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeAzureVaultScript()
{
return std::make_unique<AzureVaultScript>();
}
} // namespace Playerbot
@@ -0,0 +1,51 @@
// BastionOfTwilightScript — Bastion of Twilight raid (map 671, Cata 10/25).
// 4-5 bosses: Halfus Wyrmbreaker, Twilight Ascendant Council, Theralion + Valiona,
// Cho'gall, Sinestra (heroic).
//
// TC has only `instance_bastion_of_twilight.cpp` + the .h header; NO boss
// scripts exist. The encounters are not implemented in core. The advice
// here is intentionally minimal — generic combat carries the encounter.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BastionOfTwilightScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 671; }
char const* name() const override { return "bastion_of_twilight"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
// TC has no boss scripts for BoT; all spell IDs would be fabricated.
// Leave empty — generic combat (interrupt-any, target-lowest-HP)
// applies via the DungeonScriptMgr fallback path.
// Boss progression — entries from TC's bastion_of_twilight.h.
a.bosses = {
44600, // Halfus Wyrmbreaker
45992, // Valiona (Theralion+Valiona duo)
45993, // Theralion
43686, // Ignacious (Ascendant Council)
43687, // Feludius
43689, // Terrastra
43688, // Arion
43735, // Elementium Monstrosity
43324, // Cho'gall (final)
45213, // Sinestra (heroic-only)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBastionOfTwilightScript()
{
return std::make_unique<BastionOfTwilightScript>();
}
} // namespace Playerbot
@@ -0,0 +1,79 @@
// BlackMorassScript — The Black Morass / Caverns of Time 1 (map 269,
// TBC 66-72). 18-wave portal defense. Aeonus is final boss; Medivh NPC
// must survive.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/Kalimdor/CavernsOfTime/TheBlackMorass/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BlackMorassScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 269; }
char const* name() const override { return "black_morass"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
17839, // Rift Keeper / Rift Lord
21104, // Time Rift Add
};
a.mandatory_interrupt_spells = {
// Aeonus
40504, // Cleave
31422, // Time Stop
31473, // Sand Breath
// Chrono Lord Deja
31457, // Arcane Blast
31472, // Arcane Discharge
31467, // Time Lapse
// Temporus
31458, // Haste
31464, // Mortal Wound
31475, // Wing Buffet
};
a.cc_priority_entries = {
17839,
};
a.dangerous_auras = {
// Aeonus
37605, // Enrage
// Temporus / Chrono Lord
38540, // Attraction (heroic)
38592, // Reflect (heroic)
};
// Boss progression — NPC entries from TC's the_black_morass.h.
// Correct template IDs; bosses are event-summoned at the time
// rifts (0 spawn rows on map 269) — navigator falls back to
// waypoints.
a.bosses = {
17879, // Chrono-Lord Deja
17880, // Temporus
17881, // Aeonus (final)
};
// Progression waypoints — Black Morass is a wave-defense
// event around the Caverns of Time portal. Bots stay near
// the central portal anchor between waves.
a.progression_waypoints = {
{ -2010.0f, 7110.0f, 30.0f }, // entry
{ -2034.0f, 7104.0f, 30.0f }, // central portal anchor
{ -2055.0f, 7115.0f, 30.0f }, // alternative defense pos
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBlackMorassScript()
{
return std::make_unique<BlackMorassScript>();
}
} // namespace Playerbot
@@ -0,0 +1,71 @@
// BlackRookHoldScript — Black Rook Hold (map 1501, Legion 110).
// 4 bosses: The Amalgam of Souls, Illysanna Ravencrest, Smashspite the
// Hateful, Lord Kur'talos Ravencrest.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/BrokenIsles/BlackRookHold/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BlackRookHoldScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 1501; }
char const* name() const override { return "black_rook_hold"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
98637, // Risen Soldier (Ravencrest)
};
a.mandatory_interrupt_spells = {
// The Amalgam of Souls
194956, // Reap Soul
194966, // Soul Echoes
194981, // Soul Echoes Clone Caster
196930, // Soulgorge
196078, // Call Souls
196587, // Soul Burst
};
a.cc_priority_entries = {
};
a.dangerous_auras = {
// Amalgam
195254, // Swirling Scythe
196517, // Swirling Scythe Damage
194960, // Soul Echoes Damage
196925, // Call Souls AreaTrigger
};
// Boss progression — entries from TC's black_rook_hold.h.
// Progression waypoints — Black Rook Hold is a multi-floor
// night elf fortress in Val'sharah.
a.progression_waypoints = {
{ 2725.0f, 6175.0f, 87.0f }, // entry
{ 2723.0f, 6275.0f, 92.0f }, // Amalgam altar
{ 2718.0f, 6388.0f, 92.0f }, // Illysanna stairs
{ 2705.0f, 6541.0f, 117.0f }, // Smashspite arena
{ 2638.0f, 6608.0f, 144.0f }, // Ravencrest throne
};
a.bosses = {
98542, // The Amalgam of Souls
98696, // Illysanna Ravencrest
98949, // Smashspite the Hateful
94923, // Lord Kur'talos Ravencrest (final)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBlackRookHoldScript()
{
return std::make_unique<BlackRookHoldScript>();
}
} // namespace Playerbot
@@ -0,0 +1,165 @@
// BlackTempleScript — Black Temple raid (map 564, TBC 25-man).
// 9 bosses: Naj'entus, Supremus, Shade of Akama, Teron Gorefiend,
// Gurtogg Bloodboil, Reliquary of Souls, Mother Shahraz, Illidari Council, Illidan.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/Outland/BlackTemple/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BlackTempleScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 564; }
char const* name() const override { return "black_temple"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
22996, // Blade of Azzinoth
22997, // Flame of Azzinoth (Illidan)
23498, // Parasitic Shadowfiend (NPC_PARASITIC_SHADOWFIEND, black_temple.h)
22844, // Ashtongue Battlelord (Shade of Akama adds)
22845, // Ashtongue Mystic (Shade of Akama adds)
22846, // Ashtongue Stormcaller (Shade of Akama adds)
// Naj'entus' Impaling Spine is a GameObject (185584), not a creature.
};
a.mandatory_interrupt_spells = {
// Naj'entus
39835, // Needle Spine
39872, // Tidal Shield
39878, // Tidal Burst
// Supremus
40126, // Molten Punch
42055, // Volcanic Geyser
41581, // Charge
// Teron Gorefiend
40239, // Incinerate
40185, // Shadowbolt
// Bloodboil
42005, // Bloodboil
40508, // Fel Acid Breath
40486, // Eject
// Reliquary of Souls
41426, // Spirit Shock
41410, // Deaden
41545, // Soul Scream
41376, // Spite
41303, // Soul Drain
// Mother Shahraz
40823, // Silencing Shriek
// Illidari Council
41468, // Hammer of Justice (Veras)
41524, // Arcane Explosion (Capernian-style)
41481, // Flamestrike
41482, // Blizzard
41483, // Arcane Bolt
41478, // Dampen Magic
41471, // Empowered Smite (Reliquary-like adviser)
41475, // Reflective Shield
41541, // Consecration
41472, // Divine Wrath
41487, // Envenom
// Illidan
40017, // Eye Blast
40685, // Shadow Strike
39869, // Uncaged Wrath
40631, // Flame Blast
41268, // (Akama Door Channel — informational)
};
a.cc_priority_entries = {};
a.dangerous_auras = {
// Naj'entus
39837, // Impaling Spine
39872, // Tidal Shield
39878, // Tidal Burst
// Supremus
40980, // Molten Flame
40117, // Volcanic Eruption
40126, // Molten Punch
41922, // Snare Self (during charge)
// Teron Gorefiend
40251, // Shadow of Death
40243, // Crushing Shadows
40239, // Incinerate
// Bloodboil
42005, // Bloodboil
40594, // Fel Rage Self
40569, // Fel Geyser
40593, // Fel Geyser 2
40508, // Fel Acid Breath
40618, // Insignifigance
// Reliquary
41292, // Aura of Suffering
41350, // Aura of Desire
41337, // Aura of Anger
41303, // Soul Drain
41410, // Deaden
41376, // Spite
41545, // Soul Scream
// Mother Shahraz
41001, // Fatal Attraction
40859, // Beam Sinister
40860, // Beam Vile
40862, 40863, 40865, 40866, 40867, // Sinful/Sinister/Vile/Wicked Periodics
43690, // Saber Lash Immunity
// Council
41475, // Reflective Shield
41541, // Consecration
41452, // Devotion Aura
41453, // Chromatic Aura
// Illidan
41268, // Demon Form / Akama channel
40694, // Cage Trap
41917, // Shadow Demon (parasitic)
};
// Progression waypoints — boss spawn positions from world.creature
// (map 564), in encounter order. The Illidari Council quartet has
// no static spawn rows (instance-script spawned), so that leg is
// omitted; bots route Shahraz -> Illidan and pick the Council up
// via the boss-Cell-scan fallback.
a.progression_waypoints = {
{ 434.9f, 739.3f, 15.1f }, // High Warlord Naj'entus
{ 702.1f, 650.7f, 75.0f }, // Supremus
{ 449.6f, 401.2f, 118.6f }, // Shade of Akama
{ 606.6f, 402.2f, 187.2f }, // Teron Gorefiend
{ 744.3f, 277.1f, 63.8f }, // Gurtogg Bloodboil
{ 497.8f, 184.2f, 94.6f }, // Reliquary of Souls
{ 945.3f, 149.1f, 197.2f }, // Mother Shahraz
{ 705.7f, 305.0f, 353.9f }, // Illidan Stormrage
};
// Boss progression — NPC entries from TC's black_temple.h.
// Illidari Council is a 4-mob encounter (Gathios/Zerevor/Malande/
// Veras); listing all four. Mother Shahraz is missing from TC
// header but standard entry is 22947.
a.bosses = {
22887, // High Warlord Naj'entus
22898, // Supremus
22841, // Shade of Akama
22871, // Teron Gorefiend
22948, // Gurtogg Bloodboil
22856, // Reliquary of Souls
22947, // Mother Shahraz
22949, // Gathios the Shatterer (Illidari Council)
22950, // High Nethermancer Zerevor (Illidari Council)
22951, // Lady Malande (Illidari Council)
22952, // Veras Darkshadow (Illidari Council)
22917, // Illidan Stormrage (final)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBlackTempleScript()
{
return std::make_unique<BlackTempleScript>();
}
} // namespace Playerbot
@@ -0,0 +1,78 @@
// BlackfathomDeepsScript — Blackfathom Deeps (map 48, vanilla 24-32).
// Underwater Naga / Murloc dungeon. Bosses: Ghamoo-ra, Lady Sarevess,
// Gelihast, Lorgus Jett, Old Serra'kis, Twilight Lord Kelris, Aku'mai.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/Kalimdor/BlackfathomDeeps/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BlackfathomDeepsScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 48; }
char const* name() const override { return "blackfathom_deeps"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.mandatory_interrupt_spells = {
// Twilight Lord Kelris
15587, // Mind Blast
8399, // Sleep
8734, // Blackfathom Channeling
// Naga adds
6533, // Net
};
a.cc_priority_entries = {
74353, // Twilight Aquamancer (caster)
74380, // Twilight Storm Mender (healer)
};
a.dangerous_auras = {
// Old Serra'kis
3815, // Poison Cloud
3490, // Frenzied Rage
};
// Boss progression — world DB map 48 holds the revamped BFD
// (Twilight cult roster, 74xxx entries); classic-era entries
// (Sarevess 4831/Kelris 4832 etc.) have no spawns, except
// Aku'mai who IS spawned under his classic entry 4829.
// All entries below are spawn-verified on map 48, ordered by
// spawn-position progression (entrance pools → altar depths).
a.bosses = {
74446, // Ghamoo-Ra
74476, // Domina
74565, // Subjugator Kor'ul
74505, // Thruk
74518, // Executioner Gore
74728, // Twilight Lord Bathiel
4829, // Aku'mai (final)
};
// Progression waypoints — boss spawn positions from world.creature
// (map 48, revamped BFD), in encounter order. Coarse route
// skeleton; the pathfinder handles corridors between them.
a.progression_waypoints = {
{ -445.1f, 212.9f, -52.7f }, // Ghamoo-Ra
{ -309.3f, 407.3f, -56.6f }, // Domina
{ -422.8f, 23.2f, -48.1f }, // Subjugator Kor'ul
{ -746.1f, 8.0f, -30.0f }, // Thruk
{ -771.6f, -57.7f, -29.8f }, // Executioner Gore
{ -818.8f, -150.1f, -25.8f }, // Twilight Lord Bathiel
{ -848.6f, -462.2f, -33.9f }, // Aku'mai the Devourer (final)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBlackfathomDeepsScript()
{
return std::make_unique<BlackfathomDeepsScript>();
}
} // namespace Playerbot
@@ -0,0 +1,79 @@
// BlackrockCavernsScript — Blackrock Caverns (map 645, Cata 80-85).
// 5 bosses: Rom'ogg Bonecrusher, Corla Herald of Twilight, Karsh
// Steelbender, Beauty, Ascendant Lord Obsidius.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/EasternKingdoms/BlackrockMountain/BlackrockCaverns/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BlackrockCavernsScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 645; }
char const* name() const override { return "blackrock_caverns"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
39732, // Twilight Acolyte (Corla beam)
41227, // Shadow of Obsidius
};
a.mandatory_interrupt_spells = {
// Beauty (pet boss)
76028, // Terrifying Roar
76030, // Berserker Charge
76031, // Magma Spit
76032, // Flamebreak
// Corla
75610, // Evolution
75645, // Drain Essence
// Rom'ogg
82137, // Call for Help
75571, // Wounding Strike
75543, // Skullcracker
};
a.cc_priority_entries = {
};
a.dangerous_auras = {
// Rom'ogg
75539, // Chains of Woe (pull)
75272, // Quake
// Karsh
75842, // Quicksilver Armor
75846, // Superheated Quicksilver Armor
};
// Boss progression — Blackrock Caverns has 5 encounters.
a.bosses = {
39665, // Rom'ogg Bonecrusher
39679, // Corla, Herald of Twilight
39698, // Karsh Steelbender
39700, // Beauty
39705, // Ascendant Lord Obsidius (final)
};
// Progression waypoints — BRC is a linear Twilight cult dungeon.
a.progression_waypoints = {
{ 255.0f, 984.0f, 73.0f }, // entry
{ 280.0f, 1090.0f, 73.0f }, // Rom'ogg arena
{ 185.0f, 1245.0f, 35.5f }, // Corla zealot ramp
{ 80.0f, 1267.0f, 20.0f }, // Karsh forge
{ 146.0f, 1080.0f, 39.5f }, // Beauty's den
{ 213.0f, 974.0f, 73.6f }, // Obsidius cavern
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBlackrockCavernsScript()
{
return std::make_unique<BlackrockCavernsScript>();
}
} // namespace Playerbot
@@ -0,0 +1,134 @@
// BlackrockDepthsScript — Blackrock Depths (map 230, vanilla 50-60).
// Massive multi-wing dungeon. ~17 bosses including arena event,
// Emperor Thaurissan, Princess Moira, Lord Roccor, Bael'Gar, etc.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/EasternKingdoms/BlackrockMountain/BlackrockDepths/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BlackrockDepthsScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 230; }
char const* name() const override { return "blackrock_depths"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
8895, // Anvilrage Officer (Angerforge adds)
8901, // Anvilrage Reservist (Angerforge adds)
9032, // Hedrum the Creeper (Ring of Law gladiator, arena-spawned)
};
a.mandatory_interrupt_spells = {
// High Interrogator Gerstahn
10894, // Shadow Word: Pain
10876, // Mana Burn
8122, // Psychic Scream
22417, // Shadow Shield
// Plugger Spazzring (Black-Iron event)
15573, // Fireblast
// Emperor Thaurissan
17492, // Hand of Thaurissan
// Phalanx
14099, // Mighty Blow
9080, // Hamstring
20691, // Cleave
// Houndmaster Grebmar dogs
13900, // Fiery Burst
// Imperial Priests
10917, // Heal
10929, // Renew
10901, // Power Word: Shield
10947, // Mind Blast
10934, // Smite
// Warlock bosses
15245, // Shadow Bolt Volley
12742, // Immolate
12493, // Curse of Weakness
13787, // Demon Armor
15092, // Summon Voidwalkers
};
a.cc_priority_entries = {
8907,
8895,
8897,
};
a.dangerous_auras = {
// Bael'Gar
15636, // Avatar of Flame
// Phalanx
8269, // Frenzy (general boss enrage)
// Houndmaster's hounds
24375, // War Stomp
// Plugger
47310, // Direbrew Disarm (Coren Direbrew event)
47442, // Barreled
15529, // Gout of Flame
};
// Boss progression — BRD has 16+ encounters in a sprawling layout.
// Tank-advance picks closest alive; multi-hour clears need
// progression_waypoints to truly automate.
a.bosses = {
9018, // High Interrogator Gerstahn
9025, // Lord Roccor
9319, // Houndmaster Grebmar
// Ring of Law: random gladiator (9027-9032, script-spawned by arena event)
9024, // Pyromancer Loregrain
9017, // Lord Incendius
9041, // Warder Stilgiss
9056, // Fineous Darkvire
9016, // Bael'Gar
9033, // General Angerforge
8983, // Golem Lord Argelmach
9537, // Hurley Blackbreath (rare/event)
9502, // Phalanx
9543, // Ribbly Screwspigot
9499, // Plugger Spazzring
9156, // Ambassador Flamelash
9039, // Doom'rel (leader of The Seven / Tomb of the Seven event)
9938, // Magmus
9019, // Emperor Dagran Thaurissan (final)
8929, // Princess Moira Bronzebeard (faction-based)
};
// Progression waypoints — boss spawn positions from world.creature
// (map 230), in encounter order. Ring of Law gladiators are
// arena-spawned and Princess Moira (8929) has no creature row;
// both are omitted.
a.progression_waypoints = {
{ 310.6f, -146.3f, -70.3f }, // High Interrogator Gerstahn
{ 615.5f, -267.4f, -83.6f }, // Lord Roccor
{ 594.5f, -178.3f, -84.2f }, // Houndmaster Grebmar
{ 530.2f, -243.9f, -43.0f }, // Pyromancer Loregrain
{ 893.5f, -267.1f, -71.9f }, // Lord Incendius
{ 823.4f, -342.3f, -50.1f }, // Warder Stilgiss
{ 963.3f, -343.7f, -71.7f }, // Fineous Darkvire
{ 702.4f, 184.5f, -72.0f }, // Bael'Gar
{ 652.4f, 21.4f, -60.0f }, // General Angerforge
{ 846.8f, 16.3f, -53.6f }, // Golem Lord Argelmach
{ 878.1f, -153.1f, -49.8f }, // Hurley Blackbreath
{ 869.0f, -225.0f, -43.7f }, // Phalanx
{ 878.5f, -167.7f, -49.7f }, // Ribbly Screwspigot
{ 888.5f, -177.9f, -43.0f }, // Plugger Spazzring
{ 1009.8f, -239.0f, -61.3f }, // Ambassador Flamelash
{ 1281.1f, -282.2f, -78.1f }, // Doom'rel
{ 1380.7f, -659.3f, -92.0f }, // Magmus
{ 1380.2f, -831.6f, -87.6f }, // Emperor Dagran Thaurissan
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBlackrockDepthsScript()
{
return std::make_unique<BlackrockDepthsScript>();
}
} // namespace Playerbot
@@ -0,0 +1,48 @@
// BlackwingDescentScript — Blackwing Descent raid (map 669, Cata 10/25).
// 6 bosses canonically: Magmaw, Omnotron, Maloriak, Atramedes, Chimaeron, Nefarian.
//
// TC has only the instance script (instance_blackwing_descent.cpp); NO boss
// scripts exist. All boss combat data would be fabricated. Generic combat
// fallback carries the encounter via DungeonScriptMgr null-script path.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BlackwingDescentScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 669; }
char const* name() const override { return "blackwing_descent"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
// TC has no boss scripts for BWD; generic combat applies.
// Boss progression — NPC entries from TC's blackwing_descent.h
// (the instance script defines them even though boss AI doesn't).
// Omnotron Defense System isn't in the TC header — standard
// entries 42180-42183 (Arcanotron/Electron/Magmatron/Toxitron);
// listing the lead controller entry only.
a.bosses = {
41570, // Magmaw
42179, // Omnotron Defense System (lead Arcanotron)
41442, // Atramedes
43296, // Chimaeron
41378, // Maloriak
41376, // Nefarian (final)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBlackwingDescentScript()
{
return std::make_unique<BlackwingDescentScript>();
}
} // namespace Playerbot
@@ -0,0 +1,112 @@
// BlackwingLairScript — Blackwing Lair raid (map 469, classic 40-man).
// 8 bosses: Razorgore, Vaelastrasz, Broodlord, Firemaw, Ebonroc, Flamegor,
// Chromaggus, Nefarian.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/EasternKingdoms/BlackrockMountain/BlackwingLair/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BlackwingLairScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 469; }
char const* name() const override { return "blackwing_lair"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
12422, // Death Talon Dragonspawn
12420, // Death Talon Wyrmguard
13020, // Chromatic Drakonid
14302, // Blackwing Mage
};
a.mandatory_interrupt_spells = {
// Razorgore
22425, // Fireball Volley
23023, // Conflagration
22540, // Cleave
24375, // War Stomp
42013, // Mind Control
// Vaelastrasz
23461, // Flame Breath
23462, // Fire Nova
15847, // Tail Swipe
18173, // Burning Adrenaline
19983, // Cleave
// Broodlord
26350, // Cleave
23331, // Blast Wave
24573, // Mortal Strike
25778, // Knockback
// Drake bosses (Firemaw / Ebonroc / Flamegor)
22539, // Shadowflame
23339, // Wing Buffet
23341, // Flame Buffet
// Chromaggus
23308, // Incinerate
23310, // Time Lapse
// Nefarian
22677, // Shadow Bolt
22665, // Shadow Bolt Volley
22667, // Shadow Command
22678, // Fear
22686, // Bellowing Roar
20691, // Cleave
23364, // Tail Lash
};
a.cc_priority_entries = {};
a.dangerous_auras = {
// Razorgore (when MC'd or via egg destroy)
19873, // Egg Destroy (channeled by MC'd players)
23023, // Conflagration
// Vael
23513, // Essence of the Red (energy boost)
18173, // Burning Adrenaline
// Broodlord
23331, // Blast Wave
24573, // Mortal Strike
22247, // Suppression Device aura
// Drakes
22539, // Shadowflame
23341, // Flame Buffet
// Chromaggus
23170, 23173, 23172, 23153, // Brood Afflictions
23310, // Time Lapse
23308, // Incinerate
// Nefarian
22663, // Nefarian's Barrier
22992, // Shadowflame Initial
22686, // Bellowing Roar
7068, // Veil of Shadow
// Class Calls
23410, 23397, 23398, 23401, 23418, 23425, 23427, 23436, 23414, 49576,
};
// Boss progression — entries from TC's blackwing_lair.h.
a.bosses = {
12435, // Razorgore the Untamed
13020, // Vaelastrasz the Corrupt
12017, // Broodlord Lashlayer
11983, // Firemaw
14601, // Ebonroc
11981, // Flamegor
14020, // Chromaggus
11583, // Nefarian (final)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBlackwingLairScript()
{
return std::make_unique<BlackwingLairScript>();
}
} // namespace Playerbot
@@ -0,0 +1,84 @@
// BloodFurnaceScript — The Blood Furnace (map 542, TBC 61-67).
// 3 bosses: The Maker, Broggok, Keli'dan the Breaker.
//
// Authoritative spell IDs from TC source:
// src/server/scripts/Outland/HellfireCitadel/BloodFurnace/boss_*.cpp
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BloodFurnaceScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 542; }
char const* name() const override { return "blood_furnace"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
17000, // Channeler add (Keli'dan summons)
17068, // Broggok wave orc
};
a.mandatory_interrupt_spells = {
// The Maker
38153, // Acid Spray
30925, // Exploding Breaker
25772, // Domination (mind control)
// Broggok
30913, // Slime Spray
30917, // Poison Bolt
// Keli'dan
30938, // Corruption
30935, // Evocation (boss mana regen)
33132, // Fire Nova
28599, // Shadow Bolt Volley
12739, // Shadow Bolt (cult adds)
39123, // Channeling (cult channel)
};
a.cc_priority_entries = {
17000, // Cult Channeler
};
a.dangerous_auras = {
// Broggok
30916, // Poison Cloud (persistent zone)
30914, // Poison Cloud passive
// Keli'dan
30940, // Burning Nova (room-wide AoE)
37370, // Vortex (pull)
30937, // Mark of Shadow debuff
// The Maker
20276, // Knockdown
};
// Boss progression — NPC entries from TC's blood_furnace.h.
a.bosses = {
17381, // The Maker
17380, // Broggok
17377, // Keli'dan the Breaker (final)
};
// Progression waypoints — BF is a linear forge dungeon: entry →
// upper level (The Maker) → cellblock event (Broggok adds) →
// Broggok arena → ritual room (Keli'dan).
a.progression_waypoints = {
{ 362.6f, 53.4f, -10.0f }, // entry
{ 427.4f, 110.8f, 8.6f }, // upper hall
{ 474.8f, 93.0f, 8.7f }, // The Maker
{ 455.0f, -67.0f, 8.7f }, // cellblock event
{ 444.5f, -94.5f, -19.4f }, // Broggok arena
{ 311.3f, -84.0f, -20.7f }, // Keli'dan ritual room
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBloodFurnaceScript()
{
return std::make_unique<BloodFurnaceScript>();
}
} // namespace Playerbot
@@ -0,0 +1,71 @@
// BloodmaulSlagMinesScript — Bloodmaul Slag Mines (map 1208, WoD 90-100).
// Frostfire Ridge ogre slave-mine dungeon.
// * Slave Watcher Crushto — Frenzy + Crushing Blow.
// * Forgemaster Gog'duh — Magma Shield + Magma Eruption.
// * Roltall — Burning Slag (zone) + Magma Barrage.
// * Gug'rokk (final) — Cave-in (zone) + Magma Eruption.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BloodmaulSlagMinesScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 1175; } // Bloodmaul Slag Mines (1208 is Grimrail Depot; audit B31)
char const* name() const override { return "bloodmaul_slag_mines"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
75198, // Bloodmaul Geomancer (caster pull)
75820, // Vengeful Magma Elemental (Forgemaster)
};
a.mandatory_interrupt_spells = {
162486, // Magma Eruption (Gog'duh)
162058, // Burning Slag (Roltall)
162490, // Magma Barrage (Roltall)
165296, // Crushing Blow (Crushto)
};
a.cc_priority_entries = {
75198,
75820,
};
a.dangerous_auras = {
162486, // Magma Eruption pool
162058, // Burning Slag zone
162457, // Cave-in (Gug'rokk)
};
// Boss progression — entries from WoWHead.
a.bosses = {
74787, // Slave Watcher Crushto
74366, // Forgemaster Gog'duh
75786, // Roltall
74790, // Gug'rokk (final)
};
// Progression waypoints — BSM is an ogre slag mine: outdoor
// ramp → Crushto camp → mine descent → Roltall cliff →
// Gog'duh forge → Gug'rokk arena.
a.progression_waypoints = {
{ -290.0f, 2025.0f, 178.0f }, // entry ramp
{ -344.0f, 2079.0f, 178.0f }, // Crushto camp
{ -468.0f, 2025.0f, 155.0f }, // mine descent
{ -490.0f, 2168.0f, 124.0f }, // Roltall cliff
{ -554.0f, 2018.0f, 73.0f }, // Gog'duh forge
{ -639.0f, 1996.0f, 74.5f }, // Gug'rokk arena
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBloodmaulSlagMinesScript()
{
return std::make_unique<BloodmaulSlagMinesScript>();
}
} // namespace Playerbot
@@ -0,0 +1,69 @@
// BotanicaScript — The Botanica (map 553, TBC 67-72).
// Tempest Keep botanical wing.
// * Commander Sarannis — adds Brackenfern Sentinel (priority kill).
// * High Botanist Freywinn — phases (Plant form root, Tree form
// heals); CC priority for Tree form heal.
// * Thorngrin the Tender — Hellfire (large AoE)→ stand far.
// * Laj — phase shifts (water/fire/poison); resists swap.
// * Warp Splinter (final) — Saplings (small adds, prio kill).
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BotanicaScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 553; }
char const* name() const override { return "botanica"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
17091, // Brackenfern Sentinel (Sarannis adds)
17097, // Warp Splinter Sapling (small adds)
};
a.mandatory_interrupt_spells = {
34579, // Tranquility (Freywinn Tree form heal)
34580, // Hellfire (Thorngrin)
34587, // Summoning Lasso (Warp Splinter pulls)
};
a.dangerous_auras = {
34580, // Hellfire — step away 30y
34589, // Toxic Pool (Laj)
};
// Boss progression — NPC entries from TC's the_botanica.h.
a.bosses = {
17976, // Commander Sarannis
17975, // High Botanist Freywinn
17978, // Thorngrin the Tender
17980, // Laj
17977, // Warp Splinter (final)
};
// Progression waypoints — Botanica is the Tempest Keep east
// satellite, a 4-floor vertical greenhouse. Bots ride lifts
// (handled by use_game_object on lift GO) between floors;
// waypoints land the tank in each floor's combat area.
a.progression_waypoints = {
{ 152.4f, 17.6f, -22.5f }, // entry
{ 173.4f, -57.5f, -22.5f }, // Sarannis platform
{ 273.4f, -130.0f, 1.5f }, // Freywinn level
{ 371.0f, -176.0f, 24.5f }, // Thorngrin floor
{ 434.0f, -224.0f, 56.7f }, // Laj level
{ 493.5f, -287.0f, 86.6f }, // Warp Splinter top
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBotanicaScript()
{
return std::make_unique<BotanicaScript>();
}
} // namespace Playerbot
@@ -0,0 +1,72 @@
// BrackenhideHollowScript — Brackenhide Hollow (map 2522, DF 60-70).
// Gnoll-themed dungeon in the Ohn'ahran Plains.
// * Hackclaw's War-Band — multi-add fight, kill Tricktotem first.
// * Treemouth — captive rescue mechanic.
// * Gutshot — Disembowel cleave (move) + Hide Pile.
// * Decatriarch Wratheye (final) — Decay Curse (dispel) + Withering Burst.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class BrackenhideHollowScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 2520; } // all 4 bosses spawn on 2520, not 2522 (DB audit 2026-07-21)
char const* name() const override { return "brackenhide_hollow"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
186125, // Tricktotem (totem caster)
186122, // Rira Hackclaw (heal-buff caster)
185656, // Filth Caller add (Wratheye)
};
a.mandatory_interrupt_spells = {
373767, // Bone Bolt (Tricktotem)
373896, // Withering Burst (Wratheye)
373912, // Withering Curse (Wratheye)
386546, // Decay Aura cleanse (Wratheye)
};
a.cc_priority_entries = {
186125, // Tricktotem
185656, // Filth Caller
};
a.dangerous_auras = {
373896, // Withering Burst zone
385817, // Disembowel front cone (Gutshot)
};
// Boss progression — Brackenhide Hollow has 4 encounters.
// Hackclaw's War-Band is a multi-add fight using lead NPC entry.
// Correct template IDs; bosses are event-summoned (0 spawn rows
// on map 2522) — navigator falls back to waypoints.
a.bosses = {
186122, // Hackclaw's War-Band (Rira Hackclaw as fight-lead)
186120, // Treemouth
186116, // Gutshot
186121, // Decatriarch Wratheye (final)
};
// Progression waypoints — Brackenhide Hollow is an Ohn'ahran
// Plains gnoll camp with outdoor + cave sections.
a.progression_waypoints = {
{ 280.0f, -785.0f, -1.0f }, // entry
{ 340.0f, -707.0f, 6.0f }, // Hackclaw camp
{ 434.0f, -603.0f, 15.0f }, // Treemouth pit
{ 535.0f, -534.0f, 22.0f }, // Gutshot stairs
{ 640.0f, -464.0f, 38.0f }, // Wratheye altar
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeBrackenhideHollowScript()
{
return std::make_unique<BrackenhideHollowScript>();
}
} // namespace Playerbot
@@ -0,0 +1,63 @@
// CathedralOfEternalNightScript — Cathedral of Eternal Night (map 1677,
// Legion 7.2 100-110). Tomb of Sargeras side dungeon.
// * Agronox — Toxic Spores + Choking Pollen.
// * Thrashbite the Scornful — Boneshatter Strike + Sappy Smackdown.
// * Domatrax — Demonic Upsurge + Nether Beam.
// * Mephistroth (final) — Shadow Bolt Volley + Felblade.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class CathedralOfEternalNightScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 1677; }
char const* name() const override { return "cathedral_of_eternal_night"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
a.high_priority_kill_entries = {
119169, // Fulminating Lasher (Agronox add — kill before it empowers)
120770, // Felguard Destroyer (demon invaders)
120374, // Felguard Destroyer (portal adds variant)
};
a.mandatory_interrupt_spells = {
239006, // Choking Pollen (Agronox)
239059, // Demonic Upsurge (Domatrax)
238999, // Shadow Bolt Volley (Mephistroth)
239215, // Nether Beam (Domatrax)
238989, // Sappy Smackdown (Thrashbite)
};
a.cc_priority_entries = {
120770, // Felguard Destroyer
120374, // Felguard Destroyer (portal adds variant)
};
a.dangerous_auras = {
239006, // Choking Pollen zone
238505, // Toxic Spores ground
239006,
239215, // Nether Beam
};
// Boss progression — Cathedral of Eternal Night has 4 encounters.
a.bosses = {
117193, // Agronox
117194, // Thrashbite the Scornful
118804, // Domatrax
116944, // Mephistroth (final)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeCathedralOfEternalNightScript()
{
return std::make_unique<CathedralOfEternalNightScript>();
}
} // namespace Playerbot
@@ -0,0 +1,59 @@
// CinderbrewMeaderyScript — Cinderbrew Meadery (map 2649, TWW 70-80).
// Isle of Dorn kobold meadery — fire-themed.
// * Brew Master Aldryr — Throw Cinderbrew (zone) + Cash Cannon.
// * I'pa — Bee-stial Wrath + Honey Marinade (debuff).
// * Benk Buzzbee — Honeypot adds.
// * Goldie Baronbottom (final) — Burning Brew (zone) + Bee Swarm.
#include "../DungeonScript.h"
namespace Playerbot {
namespace {
class CinderbrewMeaderyScript final : public DungeonScript
{
public:
uint32_t map_id() const override { return 2661; } // Cinderbrew Meadery (was cross-wired to Priory 2649; audit B32, DB-verified: Brew Master Aldryr on 2661)
char const* name() const override { return "cinderbrew_meadery"; }
DungeonAdvice get_advice(BotSnapshotView const& /*s*/) const override
{
DungeonAdvice a;
// mandatory_interrupt_spells previously contained 441627 SEVEN
// times (no real encounter has one spell ID across 7 of 8 slots —
// this was placeholder fill). dangerous_auras also reused 441627
// for two distinct effects (Cinderbrew pool + Burning Brew —
// those are different spells). Cleared until upstream TC source
// exposes real IDs OR live spell sniffing captures them.
// The kill / cc priorities are plausible-looking unverified
// entries — left in but flagged. Generic interrupt rules still
// fire on visible Role::Caster enemies.
a.high_priority_kill_entries = {
210164, // Bee Hive (Goldie) — UNVERIFIED
218338, // Honey Bee (Benk) — UNVERIFIED
210264, // Cinderbrew Lackey — UNVERIFIED
};
a.cc_priority_entries = {
210264,
218338,
};
// Boss progression — Cinderbrew Meadery has 4 encounters.
a.bosses = {
210271, // Brew Master Aldryr
218671, // I'pa
218002, // Benk Buzzbee
218523, // Goldie Baronbottom (final)
};
return a;
}
};
} // anonymous
std::unique_ptr<DungeonScript> MakeCinderbrewMeaderyScript()
{
return std::make_unique<CinderbrewMeaderyScript>();
}
} // namespace Playerbot

Some files were not shown because too many files have changed in this diff Show More