Add PlayerbotV2 module (standalone, no core history)
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
Reference in New Issue
Block a user