Files
Viano-Core/src/server/game/World/ClientlessTestBotMgr.cpp
T
Nico a6572242c6 Fix flight departure recovery and Wintergrasp mount permissions
Keep takeoff clearance until actual ascent, blacklist rejected dynamic bridges, and diagnose group ground-path rejection. Synchronize native Wintergrasp client mount permission with battle state and fix duplicate Zangarmarsh graveyard ownership. Add regression tests and generic testbot aura inspection.
2026-11-03 18:56:16 +01:00

537 lines
34 KiB
C++

#include "ClientlessTestBotMgr.h"
#include "ClientlessCharacterFactory.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessTravelMgr.h"
#include "Chat.h"
#include "Base64.h"
#include "Config.h"
#include "CombatPackets.h"
#include "Creature.h"
#include "GameObject.h"
#include "GossipDef.h"
#include "NPCPackets.h"
#include "Bag.h"
#include "DBCStores.h"
#include "Item.h"
#include "Log.h"
#include "Map.h"
#include "MotionMaster.h"
#include "MovementPackets.h"
#include "GameClient.h"
#include "PathGenerator.h"
#include "ObjectAccessor.h"
#include "ObjectMgr.h"
#include "Opcodes.h"
#include "Player.h"
#include "QuestPackets.h"
#include "SpellInfo.h"
#include "SpellMgr.h"
#include "SpellPackets.h"
#include "Stats.h"
#include "World.h"
#include "WorldPacket.h"
#include "WorldSession.h"
#include <lua.hpp>
#include <chrono>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <map>
#include <memory>
#include <set>
#include <sstream>
namespace
{
namespace fs=std::filesystem;
using Clock=std::chrono::steady_clock;
struct TestController final:ClientlessPlayerController
{
static void ClaimMover(Player* player)
{
auto client=player->GetGameClient();if(!client)return;
Unit* mover=player;
if(auto vehicle=player->GetVehicleBase())if(client->IsAllowedToMove(vehicle))mover=vehicle;
if(!client->IsAllowedToMove(mover) || client->GetActivelyMovedUnit()==mover)return;
// Emulate the client's normal control acknowledgement. The native
// handler enforces the server's allowed-mover list, including vehicles.
WorldPackets::Movement::SetActiveMover packet{WorldPacket(CMSG_SET_ACTIVE_MOVER)};packet.ActiveMover=mover->GetGUID();
player->GetSession()->HandleSetActiveMoverOpcode(packet);
}
void OnPlayerOnline(Player* player)override{ClaimMover(player);}
void Update(Player* player,uint32)override{ClaimMover(player);}
};
struct Run
{
std::string Id, State="running", Error;
lua_State* Lua=nullptr; lua_State* Thread=nullptr;
Clock::time_point Started=Clock::now(), Wake=Started;
uint32 TimeoutSeconds=300;
uint64 StartedUnixMs=uint64(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count());
uint32 Checks=0, Failures=0, Created=0; bool Creating=false;
size_t Memory=0;
std::string CreateError;
std::set<uint32> Bots;
std::vector<std::string> Events;
~Run(){if(Lua)lua_close(Lua);}
};
std::map<std::string,std::shared_ptr<Run>> runs;
std::map<uint32,std::string> leases;
bool accepting=true;
fs::path Root(){return sConfigMgr->GetStringDefault("Clientless.TestBot.Root","testbots");}
bool Key(std::string const& s)
{
return !s.empty() && s.size()<=80 && s.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")==std::string::npos;
}
std::string Quote(std::string const& s)
{
std::string out="\"";char const* hex="0123456789abcdef";
for(unsigned char c:s){if(c=='"'||c=='\\'){out+='\\';out+=char(c);}else if(c<32){out+="\\u00";out+=hex[c>>4];out+=hex[c&15];}else out+=char(c);}
return out+'"';
}
bool Write(fs::path const& path,std::string const& value)
{
fs::create_directories(path.parent_path());auto tmp=path;tmp+=".tmp";
std::ofstream out(tmp,std::ios::binary|std::ios::trunc);out<<value;out.close();
if(!out)return false;
fs::rename(tmp,path);return true;
}
void Publish(Run const& r)
{
std::ostringstream out;out<<"{\"schema\":1,\"run_id\":"<<Quote(r.Id)<<",\"state\":"<<Quote(r.State)
<<",\"started_unix_ms\":"<<r.StartedUnixMs<<",\"elapsed_ms\":"<<std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now()-r.Started).count()
<<",\"checks\":"<<r.Checks<<",\"failures\":"<<r.Failures<<",\"error\":"<<Quote(r.Error)<<",\"bots\":[";
bool first=true;for(auto g:r.Bots){if(!first)out<<',';first=false;out<<g;}out<<"],\"events\":[";first=true;
for(auto const& e:r.Events){if(!first)out<<',';first=false;out<<e;}out<<"]}\n";
if(!Write(Root()/"results"/(r.Id+".json"),out.str()))TC_LOG_ERROR("server.worldserver","TESTBOT result write failed run=%s",r.Id.c_str());
}
void Finish(Run& r,std::string state,std::string error={})
{
r.State=std::move(state);r.Error=std::move(error);
for(auto guid:r.Bots)
{
// Test teardown is intentionally immediate, independent of Adventure policies.
sClientlessTravelMgr->CancelTravel(guid);
sClientlessPlayerMgr->StopPlayer(guid);
leases.erase(guid);
}
Publish(r);
if(r.Lua){lua_close(r.Lua);r.Lua=nullptr;r.Thread=nullptr;}
r.Events.clear();r.Events.shrink_to_fit();
TC_LOG_INFO("server.worldserver","TESTBOT_RESULT run=%s state=%s checks=%u failures=%u error='%s'",r.Id.c_str(),r.State.c_str(),r.Checks,r.Failures,r.Error.c_str());
}
Run& Context(lua_State* L){return *static_cast<Run*>(lua_touserdata(L,lua_upvalueindex(1)));}
uint32 Number(lua_State* L,int index,uint32 low=1,uint32 high=0xFFFFFFFFu)
{
auto v=luaL_checkinteger(L,index);luaL_argcheck(L,v>=low && uint64(v)<=high,index,"integer out of range");return uint32(v);
}
float Coordinate(lua_State* L,int i)
{
auto v=luaL_checknumber(L,i);luaL_argcheck(L,std::isfinite(v) && std::fabs(v)<100000,i,"invalid coordinate");return float(v);
}
uint32 Owned(lua_State* L)
{
auto guid=Number(L,1);luaL_argcheck(L,Context(L).Bots.count(guid),1,"bot does not belong to this run");return guid;
}
Player* Actor(lua_State* L)
{
auto session=sWorld->FindClientlessSession(Owned(L));auto player=session?session->GetPlayer():nullptr;
luaL_argcheck(L,player && player->IsInWorld() && !player->IsBeingTeleported(),1,"bot not online or teleport pending");return player;
}
ObjectGuid Target(lua_State* L,int index=2)
{
auto s=luaL_checkstring(L,index);char* end=nullptr;auto raw=std::strtoull(s,&end,10);
luaL_argcheck(L,*s && end && !*end && raw,index,"expected full GUID string from creature/object lookup");return ObjectGuid(uint64(raw));
}
int Spawn(lua_State* L)
{
auto guid=Number(L,1);auto& r=Context(L);
if(leases.count(guid) && leases.at(guid)!=r.Id)return luaL_error(L,"bot leased by another run");
std::ifstream file(Root()/"registry"/(std::to_string(guid)+".bot"));uint32 account=0;file>>account;
if(!account)return luaL_error(L,"not a registered test character; create it with testbot.create first");
if(sClientlessPlayerMgr->IsManaged(guid) || !sClientlessPlayerMgr->StartPlayer(account,guid,std::make_unique<TestController>()))
return luaL_error(L,"test character is already managed or login failed");
r.Bots.insert(guid);leases[guid]=r.Id;lua_pushinteger(L,guid);return 1;
}
int Online(lua_State* L)
{
auto session=sWorld->FindClientlessSession(Owned(L));auto player=session?session->GetPlayer():nullptr;
lua_pushboolean(L,player && player->IsInWorld() && !player->IsBeingTeleported());return 1;
}
int Create(lua_State* L)
{
auto& r=Context(L);if(r.Creating)return luaL_error(L,"creation already pending");
auto account=Number(L,1);ClientlessCharacterFactory::Definition d;
d.Name=luaL_checkstring(L,2);d.Race=uint8(Number(L,3,1,255));d.Class=uint8(Number(L,4,1,11));
d.Level=uint8(Number(L,5,1,85));d.Gender=uint8(luaL_optinteger(L,6,0));d.Seed=uint32(r.Bots.size()+1);
r.Creating=true;r.Created=0;r.CreateError.clear();std::weak_ptr<Run> weak=runs.at(r.Id);
ClientlessCharacterFactory::CreateAsync(account,d,{}, {},[weak,account](ClientlessCharacterFactory::Result result)
{
try
{
if(result.Guid && !Write(Root()/"registry"/(std::to_string(result.Guid)+".bot"),std::to_string(account)))
result.Error="Cannot persist test character registry";
}
catch(std::exception const& e){result.Error=e.what();}
if(auto run=weak.lock()){run->Creating=false;run->Created=result.Guid;run->CreateError=result.Error;if(result.Guid)run->Bots.insert(result.Guid);if(run->State!="running")Publish(*run);}
});
return 0;
}
int CreateResult(lua_State* L)
{
auto& r=Context(L);lua_pushboolean(L,r.Creating);lua_pushinteger(L,r.Created);lua_pushstring(L,r.CreateError.c_str());
return 3;
}
int Wait(lua_State* L)
{
auto ms=Number(L,1,0,60000);Context(L).Wake=Clock::now()+std::chrono::milliseconds(ms);return lua_yield(L,0);
}
int Snapshot(lua_State* L)
{
auto p=Actor(L);lua_newtable(L);
auto n=[&](char const* k,lua_Integer v){lua_pushinteger(L,v);lua_setfield(L,-2,k);};
auto f=[&](char const* k,float v){lua_pushnumber(L,v);lua_setfield(L,-2,k);};
n("strength",p->GetStat(StatType::Strength));n("agility",p->GetStat(StatType::Agility));n("stamina",p->GetStat(StatType::Stamina));n("intellect",p->GetStat(StatType::Intellect));n("spirit",p->GetStat(StatType::Spirit));
auto mover=p->GetGameClient()?p->GetGameClient()->GetActivelyMovedUnit():nullptr;lua_pushstring(L,mover?std::to_string(mover->GetGUID().GetRawValue()).c_str():"");lua_setfield(L,-2,"mover_guid");
n("area",p->GetAreaId());n("zone",p->GetZoneId());lua_pushstring(L,p->GetLootGUID()?std::to_string(p->GetLootGUID().GetRawValue()).c_str():"");lua_setfield(L,-2,"loot_guid");
n("guid",p->GetGUID().GetCounter());n("level",p->getLevel());n("map",p->GetMapId());n("health",p->GetHealth());n("max_health",p->GetMaxHealth());n("power",p->GetPower(p->GetPowerType()));n("max_power",p->GetMaxPower(p->GetPowerType()));
auto vehicle=p->GetVehicleCreatureBase();n("vehicle_entry",vehicle?vehicle->GetEntry():0);lua_pushstring(L,vehicle?std::to_string(vehicle->GetGUID().GetRawValue()).c_str():"");lua_setfield(L,-2,"vehicle_guid");
n("moving",p->IsSplineEnabled());n("alive",p->IsAlive());n("combat",p->IsInCombat());n("transport",bool(p->GetTransport()));f("x",p->GetPositionX());f("y",p->GetPositionY());f("z",p->GetPositionZ());return 1;
}
int Teleport(lua_State* L)
{
auto p=Actor(L);auto map=Number(L,2,0,65535);auto x=Coordinate(L,3),y=Coordinate(L,4),z=Coordinate(L,5),o=Coordinate(L,6);
lua_pushboolean(L,p->TeleportTo(map,x,y,z,o));return 1;
}
int Level(lua_State* L){auto p=Actor(L);p->GiveLevel(uint8(Number(L,2,1,85)));p->InitTalentForLevel();p->SetFullHealth();return 0;}
int Stats(lua_State* L)
{
auto p=Actor(L);p->SetMaxHealth(Number(L,2,1,100000000));p->SetFullHealth();
if(lua_gettop(L)>=3){auto power=Number(L,3,0,100000000);p->SetMaxPower(p->GetPowerType(),power);p->SetPower(p->GetPowerType(),power);}return 0;
}
int Stat(lua_State* L)
{
auto p=Actor(L);auto stat=Number(L,2,0,4);p->GetStats().SetBaseStatValue(StatType(stat),int32(Number(L,3,0,1000000)));return 0;
}
int Learn(lua_State* L){auto p=Actor(L);auto id=Number(L,2);if(!sSpellMgr->GetSpellInfo(id))return luaL_error(L,"unknown spell");p->LearnSpell(id,false);return 0;}
int Item(lua_State* L){auto p=Actor(L);lua_pushboolean(L,p->StoreNewItemInBestSlots(Number(L,2),Number(L,3,1,1000)));return 1;}
int Equip(lua_State* L)
{
auto p=Actor(L);auto slot=uint8(Number(L,2,0,22));uint16 dest=0;auto item=Number(L,3);
if(p->GetItemByPos(INVENTORY_SLOT_BAG_0,slot))return luaL_error(L,"equipment slot occupied; clear it explicitly first");
lua_pushboolean(L,p->CanEquipNewItem(slot,dest,item,false)==EQUIP_ERR_OK && p->EquipNewItem(dest,item,true));return 1;
}
int ClearSlot(lua_State* L){auto p=Actor(L);p->DestroyItem(INVENTORY_SLOT_BAG_0,uint8(Number(L,2,0,22)),true);return 0;}
int RemoveItem(lua_State* L){auto p=Actor(L);p->DestroyItemCount(Number(L,2),Number(L,3,1,10000),true);return 0;}
int Skill(lua_State* L)
{
auto p=Actor(L);auto skill=Number(L,2,1,65535),value=Number(L,3,0,600),maximum=Number(L,4,0,600);
if(!sSkillLineStore.LookupEntry(skill) || value>maximum)return luaL_error(L,"invalid skill or skill value exceeds maximum");
p->SetSkill(uint16(skill),p->GetSkillStep(uint16(skill)),uint16(value),uint16(maximum));lua_pushboolean(L,p->GetPureSkillValue(skill)==value);return 1;
}
int HasAura(lua_State* L){auto p=Actor(L);lua_pushboolean(L,p->HasAura(Number(L,2)));return 1;}
int RemoveAura(lua_State* L){auto p=Actor(L);p->RemoveAurasDueToSpell(Number(L,2));return 0;}
int Count(lua_State* L){auto p=Actor(L);lua_pushinteger(L,p->GetItemCount(Number(L,2),false));return 1;}
int Quest(lua_State* L){auto p=Actor(L);auto id=Number(L,2);lua_pushinteger(L,p->GetQuestStatus(id));lua_pushboolean(L,p->GetQuestRewardStatus(id));return 2;}
int Find(lua_State* L)
{
auto p=Actor(L);auto spawn=Number(L,2);bool object=lua_toboolean(L,lua_upvalueindex(2));
ObjectGuid found;
if(object){auto const& store=p->GetMap()->GetGameObjectBySpawnIdStore();auto range=store.equal_range(spawn);for(auto i=range.first;i!=range.second;++i)if(p->CanSeeOrDetect(i->second)){found=i->second->GetGUID();break;}}
else {auto const& store=p->GetMap()->GetCreatureBySpawnIdStore();auto range=store.equal_range(spawn);for(auto i=range.first;i!=range.second;++i)if(p->CanSeeOrDetect(i->second)){found=i->second->GetGUID();break;}}
if(found)lua_pushstring(L,std::to_string(found.GetRawValue()).c_str());else lua_pushnil(L);return 1;
}
int Nearest(lua_State* L)
{
auto p=Actor(L);auto entry=Number(L,2);auto radius=luaL_optnumber(L,3,40);
luaL_argcheck(L,std::isfinite(radius) && radius>0 && radius<=100,3,"radius must be 0..100 yards");
WorldObject* object=lua_toboolean(L,lua_upvalueindex(2))?static_cast<WorldObject*>(p->FindNearestGameObject(entry,float(radius))):static_cast<WorldObject*>(p->FindNearestCreature(entry,float(radius),true));
if(object && p->CanSeeOrDetect(object))lua_pushstring(L,std::to_string(object->GetGUID().GetRawValue()).c_str());else lua_pushnil(L);return 1;
}
int NpcHello(lua_State* L)
{
auto p=Actor(L);WorldPackets::NPC::Hello packet{WorldPacket(CMSG_GOSSIP_HELLO)};packet.Unit=Target(L);p->GetSession()->HandleGossipHelloOpcode(packet);return 0;
}
int Menu(lua_State* L)
{
auto p=Actor(L);auto& menu=p->PlayerTalkClass->GetGossipMenu();lua_newtable(L);
lua_pushinteger(L,menu.GetMenuId());lua_setfield(L,-2,"id");lua_pushstring(L,std::to_string(p->PlayerTalkClass->GetInteractionData().SourceGuid.GetRawValue()).c_str());lua_setfield(L,-2,"source_guid");lua_newtable(L);unsigned index=1;
for(auto const& option:menu.GetMenuItems())
{
lua_newtable(L);lua_pushinteger(L,option.first);lua_setfield(L,-2,"id");lua_pushstring(L,option.second.Message.c_str());lua_setfield(L,-2,"text");
lua_pushboolean(L,option.second.IsCoded);lua_setfield(L,-2,"coded");lua_pushinteger(L,option.second.OptionType);lua_setfield(L,-2,"action");lua_rawseti(L,-2,index++);
}
lua_setfield(L,-2,"options");return 1;
}
int SpellClick(lua_State* L){auto p=Actor(L);WorldPacket packet(CMSG_SPELLCLICK);packet<<Target(L);p->GetSession()->HandleSpellClick(packet);return 0;}
int ExitVehicle(lua_State* L){auto p=Actor(L);WorldPacket packet(CMSG_REQUEST_VEHICLE_EXIT);p->GetSession()->HandleRequestVehicleExit(packet);return 0;}
int RequestCast(lua_State* L)
{
auto p=Actor(L);WorldPackets::Spells::CastSpell packet{WorldPacket(CMSG_CAST_SPELL)};packet.Cast.SpellID=int32(Number(L,2));
auto target=lua_gettop(L)>=3?Target(L,3):p->GetGUID();packet.Cast.Target.Flags=target.IsGameObject()?TARGET_FLAG_GAMEOBJECT:TARGET_FLAG_UNIT;packet.Cast.Target.Unit=target;p->GetSession()->HandleCastSpellOpcode(packet);return 0;
}
int RequestCastAt(lua_State* L)
{
auto p=Actor(L);WorldPackets::Spells::CastSpell packet{WorldPacket(CMSG_CAST_SPELL)};packet.Cast.SpellID=int32(Number(L,2));
WorldPackets::Spells::TargetLocation location;location.Location.Relocate(Coordinate(L,3),Coordinate(L,4),Coordinate(L,5));
packet.Cast.Target.Flags=TARGET_FLAG_DEST_LOCATION;packet.Cast.Target.DstLocation=location;p->GetSession()->HandleCastSpellOpcode(packet);return 0;
}
int UseItemAt(lua_State* L)
{
auto p=Actor(L);auto bag=uint8(Number(L,2,0,255)),slot=uint8(Number(L,3,0,255));auto item=p->GetItemByPos(bag,slot);if(!item)return luaL_error(L,"item slot empty");
WorldPackets::Spells::UseItem packet{WorldPacket(CMSG_USE_ITEM)};packet.PackSlot=bag;packet.Slot=slot;packet.CastItem=item->GetGUID();packet.Cast.SpellID=int32(Number(L,4));
WorldPackets::Spells::TargetLocation location;location.Location.Relocate(Coordinate(L,5),Coordinate(L,6),Coordinate(L,7));
packet.Cast.Target.Flags=TARGET_FLAG_DEST_LOCATION;packet.Cast.Target.DstLocation=location;p->GetSession()->HandleUseItemOpcode(packet);return 0;
}
int AreaTrigger(lua_State* L){auto p=Actor(L);WorldPacket packet(CMSG_AREATRIGGER);packet<<Number(L,2);p->GetSession()->HandleAreaTriggerOpcode(packet);return 0;}
int Inventory(lua_State* L)
{
auto p=Actor(L);lua_newtable(L);unsigned index=1;
auto add=[&](uint8 bag,uint8 slot)
{
auto item=p->GetItemByPos(bag,slot);if(!item)return;
lua_newtable(L);lua_pushinteger(L,bag);lua_setfield(L,-2,"bag");lua_pushinteger(L,slot);lua_setfield(L,-2,"slot");
lua_pushinteger(L,item->GetEntry());lua_setfield(L,-2,"item");lua_pushinteger(L,item->GetCount());lua_setfield(L,-2,"count");lua_pushstring(L,std::to_string(item->GetGUID().GetRawValue()).c_str());lua_setfield(L,-2,"guid");
lua_newtable(L);unsigned effectIndex=1;
for(auto const& effect:item->GetTemplate()->Effects)if(effect.SpellID)
{
lua_newtable(L);lua_pushinteger(L,effect.SpellID);lua_setfield(L,-2,"id");lua_pushinteger(L,effect.Trigger);lua_setfield(L,-2,"trigger");lua_rawseti(L,-2,effectIndex++);
}
lua_setfield(L,-2,"spells");lua_rawseti(L,-2,index++);
};
for(uint8 slot=0;slot<INVENTORY_SLOT_ITEM_END;++slot)add(INVENTORY_SLOT_BAG_0,slot);
for(uint8 bag=INVENTORY_SLOT_BAG_START;bag<INVENTORY_SLOT_BAG_END;++bag)
if(auto item=p->GetItemByPos(INVENTORY_SLOT_BAG_0,bag))if(auto container=item->ToBag())for(uint8 slot=0;slot<container->GetBagSize();++slot)add(bag,slot);
return 1;
}
int Use(lua_State* L){auto p=Actor(L);WorldPacket packet(CMSG_GAMEOBJ_USE,8);packet<<Target(L);p->GetSession()->HandleGameObjectUseOpcode(packet);return 0;}
int Hello(lua_State* L){auto p=Actor(L);WorldPackets::Quest::QuestGiverHello packet{WorldPacket(CMSG_QUEST_GIVER_HELLO)};packet.QuestGiverGUID=Target(L);p->GetSession()->HandleQuestgiverHelloOpcode(packet);return 0;}
int Accept(lua_State* L){auto p=Actor(L);WorldPackets::Quest::QuestGiverAcceptQuest packet{WorldPacket(CMSG_QUEST_GIVER_ACCEPT_QUEST)};packet.QuestGiverGUID=Target(L);packet.QuestID=Number(L,3);p->GetSession()->HandleQuestgiverAcceptQuestOpcode(packet);return 0;}
int Abandon(lua_State* L)
{
auto p=Actor(L);auto slot=p->FindQuestSlot(Number(L,2));if(slot>=MAX_QUEST_LOG_SIZE)return luaL_error(L,"quest not in log");
WorldPackets::Quest::QuestLogRemoveQuest packet{WorldPacket(CMSG_QUEST_LOG_REMOVE_QUEST)};packet.Entry=uint8(slot);p->GetSession()->HandleQuestLogRemoveQuest(packet);return 0;
}
int Reward(lua_State* L){auto p=Actor(L);WorldPackets::Quest::QuestGiverChooseReward packet{WorldPacket(CMSG_QUEST_GIVER_CHOOSE_REWARD)};packet.QuestGiverGUID=Target(L);packet.QuestID=int32(Number(L,3));packet.ItemChoiceID=int32(Number(L,4,0,5));p->GetSession()->HandleQuestgiverChooseRewardOpcode(packet);return 0;}
int Gossip(lua_State* L)
{
auto p=Actor(L);auto guid=Target(L);auto menu=Number(L,3,0),option=Number(L,4,0);WorldPacket packet(CMSG_GOSSIP_SELECT_OPTION);packet<<guid<<menu<<option;
if(p->PlayerTalkClass->IsGossipOptionCoded(option))packet<<std::string(luaL_optstring(L,5,""));
p->GetSession()->HandleGossipSelectOptionOpcode(packet);return 0;
}
int Cast(lua_State* L)
{
auto p=Actor(L);auto id=Number(L,2);auto target=lua_gettop(L)>=3?ObjectAccessor::GetUnit(*p,Target(L,3)):p;
if(!p->HasSpell(id)||!target)return luaL_error(L,"spell not learned or target not loaded");
lua_pushinteger(L,p->CastSpell(target,id,false));return 1;
}
int TargetState(lua_State* L)
{
auto p=Actor(L);auto target=ObjectAccessor::GetUnit(*p,Target(L));if(!target){lua_pushnil(L);return 1;}
lua_newtable(L);lua_pushinteger(L,target->GetMaxHealth());lua_setfield(L,-2,"max_health");lua_pushinteger(L,target->getLevel());lua_setfield(L,-2,"level");lua_pushinteger(L,target->GetEntry());lua_setfield(L,-2,"entry");lua_pushinteger(L,target->GetHealth());lua_setfield(L,-2,"health");lua_pushboolean(L,target->IsAlive());lua_setfield(L,-2,"alive");lua_pushnumber(L,p->GetDistance(target));lua_setfield(L,-2,"distance");
lua_pushnumber(L,target->GetPositionX());lua_setfield(L,-2,"x");lua_pushnumber(L,target->GetPositionY());lua_setfield(L,-2,"y");lua_pushnumber(L,target->GetPositionZ());lua_setfield(L,-2,"z");return 1;
}
int Objectives(lua_State* L)
{
auto p=Actor(L);auto slot=p->FindQuestSlot(Number(L,2));lua_newtable(L);
if(slot<MAX_QUEST_LOG_SIZE)
for(uint8 i=0;i<4;++i){lua_pushinteger(L,p->GetQuestSlotCounter(slot,i));lua_rawseti(L,-2,i+1);}
return 1;
}
int RequestReward(lua_State* L)
{
auto p=Actor(L);WorldPackets::Quest::QuestGiverCompleteQuest packet{WorldPacket(CMSG_QUEST_GIVER_COMPLETE_QUEST)};
packet.QuestGiverGUID=Target(L);packet.QuestID=int32(Number(L,3));p->GetSession()->HandleQuestgiverCompleteQuest(packet);return 0;
}
int Loot(lua_State* L){auto p=Actor(L);WorldPacket packet(CMSG_LOOT);packet<<Target(L);p->GetSession()->HandleLootOpcode(packet);return 0;}
int LootItem(lua_State* L){auto p=Actor(L);WorldPacket packet(CMSG_AUTOSTORE_LOOT_ITEM);packet<<uint8(Number(L,2,0,255));p->GetSession()->HandleAutostoreLootItemOpcode(packet);return 0;}
int LootRelease(lua_State* L){auto p=Actor(L);WorldPacket packet(CMSG_LOOT_RELEASE);packet<<Target(L);p->GetSession()->HandleLootReleaseOpcode(packet);return 0;}
int UseItem(lua_State* L)
{
auto p=Actor(L);auto bag=uint8(Number(L,2,0,255)),slot=uint8(Number(L,3,0,255));auto item=p->GetItemByPos(bag,slot);if(!item)return luaL_error(L,"item slot empty");
WorldPackets::Spells::UseItem packet{WorldPacket(CMSG_USE_ITEM)};packet.PackSlot=bag;packet.Slot=slot;packet.CastItem=item->GetGUID();packet.Cast.SpellID=int32(Number(L,4,0));
auto target=lua_gettop(L)>=5?Target(L,5):p->GetGUID();packet.Cast.Target.Flags=target.IsGameObject()?TARGET_FLAG_GAMEOBJECT:TARGET_FLAG_UNIT;packet.Cast.Target.Unit=target;p->GetSession()->HandleUseItemOpcode(packet);return 0;
}
int Move(lua_State* L)
{
auto p=Actor(L);auto x=Coordinate(L,2),y=Coordinate(L,3),z=Coordinate(L,4);
// Use native local navigation directly: no Adventure area/travel policy.
PathGenerator path(p);bool valid=path.CalculatePath(x,y,z) && (path.GetPathType()&PATHFIND_NORMAL);
if(valid)p->GetMotionMaster()->MovePoint(0,x,y,z,true);
lua_pushboolean(L,valid);return 1;
}
int Stop(lua_State* L){auto p=Actor(L);p->AttackStop();p->GetMotionMaster()->Clear();p->StopMoving();return 0;}
int Now(lua_State* L){lua_pushinteger(L,std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now()-Context(L).Started).count());return 1;}
int Face(lua_State* L){auto p=Actor(L);auto target=ObjectAccessor::GetUnit(*p,Target(L));if(!target)return luaL_error(L,"target not loaded");p->SetFacingToObject(target);return 0;}
int Attack(lua_State* L)
{
auto p=Actor(L);WorldPackets::Combat::AttackSwing packet{WorldPacket(CMSG_ATTACK_SWING)};packet.Victim=Target(L);p->GetSession()->HandleAttackSwingOpcode(packet);
lua_pushboolean(L,p->GetVictim() && p->GetVictim()->GetGUID()==packet.Victim);return 1;
}
std::string Json(lua_State* L,int i,unsigned depth=0)
{
if(depth>5)return "null";
switch(lua_type(L,i))
{
case LUA_TBOOLEAN:return lua_toboolean(L,i)?"true":"false";
case LUA_TNUMBER:{auto n=lua_tonumber(L,i);if(!std::isfinite(n))return "null";std::ostringstream o;o.precision(16);o<<n;return o.str();}
case LUA_TSTRING:return Quote(std::string(lua_tostring(L,i)).substr(0,8192));
case LUA_TTABLE:{i=lua_absindex(L,i);std::string out="{";unsigned count=0;lua_pushnil(L);while(lua_next(L,i))
{
if(count>=100 || out.size()>=8192){lua_pop(L,2);break;}
std::string key;
if(lua_type(L,-2)==LUA_TSTRING)key=std::string(lua_tostring(L,-2)).substr(0,512);
else if(lua_isinteger(L,-2))key=std::to_string(lua_tointeger(L,-2));
if(!key.empty()){++count;if(out.size()>1)out+=',';out+=Quote(key)+":"+Json(L,-1,depth+1);}
lua_pop(L,1);
}return out+"}";}
default:return "null";
}
}
int Record(lua_State* L)
{
auto& r=Context(L);if(r.Events.size()>=1000)return luaL_error(L,"event limit exceeded");
auto label=std::string(luaL_checkstring(L,1)).substr(0,512);r.Events.push_back("{\"label\":"+Quote(label)+",\"value\":"+Json(L,2)+"}");return 0;
}
int Check(lua_State* L)
{
auto& r=Context(L);++r.Checks;bool ok=lua_toboolean(L,1);if(!ok)++r.Failures;
if(r.Events.size()>=1000)return luaL_error(L,"event limit exceeded");
r.Events.push_back("{\"check\":"+Quote(luaL_checkstring(L,2))+",\"passed\":"+(ok?"true":"false")+"}");lua_pushboolean(L,ok);return 1;
}
void* Allocate(void* user,void* ptr,size_t oldSize,size_t newSize)
{
auto& run=*static_cast<Run*>(user);if(!ptr)oldSize=0;
if(!newSize){std::free(ptr);run.Memory-=oldSize;return nullptr;}
if(newSize>8*1024*1024 || run.Memory-oldSize>8*1024*1024-newSize)return nullptr;
void* next=std::realloc(ptr,newSize);if(next)run.Memory=run.Memory-oldSize+newSize;return next;
}
void Hook(lua_State* L,lua_Debug*){luaL_error(L,"Lua instruction budget exceeded; yield with testbot.wait/await");}
void Bind(lua_State* L,Run* r,char const* name,lua_CFunction fn,int flag=0)
{
lua_pushlightuserdata(L,r);lua_pushboolean(L,flag);lua_pushcclosure(L,fn,2);lua_setfield(L,-2,name);
}
bool Start(std::string const& id,fs::path path,std::string& error)
{
if(!accepting){error="test job admission paused";return false;}
if(!Key(id)){error="invalid run id";return false;}
if(runs.size()>=128)for(auto it=runs.begin();it!=runs.end();) {if(it->second->State!="running")it=runs.erase(it);else ++it;}
if(runs.count(id)||fs::exists(Root()/"results"/(id+".json"))){error="run id already exists";return false;}
for(auto const& entry:runs)if(entry.second->State=="running"){error="another test is running";return false;}
if(!fs::exists(path)||fs::file_size(path)>1024*1024){error="script missing or exceeds 1MiB";return false;}
auto r=std::make_shared<Run>();r->Id=id;r->TimeoutSeconds=uint32(std::clamp(sConfigMgr->GetIntDefault("Clientless.TestBot.TimeoutSeconds",300),1,86400));r->Lua=lua_newstate(Allocate,r.get());if(!r->Lua){error="Lua allocation failed";return false;}
auto L=r->Lua;luaL_openlibs(L);
for(auto name:{"io","os","package","debug","dofile","loadfile","require","load","collectgarbage","coroutine","pcall","xpcall"}){lua_pushnil(L);lua_setglobal(L,name);}
lua_newtable(L);
for(auto const& fn:std::initializer_list<std::pair<char const*,lua_CFunction>>{
{"npc_hello",NpcHello},{"menu",Menu},{"spellclick",SpellClick},{"exit_vehicle",ExitVehicle},{"request_cast",RequestCast},{"request_cast_at",RequestCastAt},{"use_item_at",UseItemAt},{"area_trigger",AreaTrigger},{"inventory",Inventory},{"stop",Stop},{"now_ms",Now},{"spawn",Spawn},{"online",Online},{"_create",Create},{"_create_result",CreateResult},{"wait",Wait},{"state",Snapshot},{"teleport",Teleport},{"level",Level},{"stats",Stats},{"stat",Stat},{"learn",Learn},{"item",Item},{"equip",Equip},{"clear_slot",ClearSlot},{"remove_item",RemoveItem},{"skill",Skill},{"has_aura",HasAura},{"remove_aura",RemoveAura},{"item_count",Count},{"quest",Quest},{"use",Use},{"hello",Hello},{"accept",Accept},{"abandon",Abandon},{"reward",Reward},{"gossip",Gossip},{"cast",Cast},{"move",Move},{"face",Face},{"attack",Attack},{"target_state",TargetState},{"objectives",Objectives},{"request_reward",RequestReward},{"loot",Loot},{"loot_item",LootItem},{"loot_release",LootRelease},{"use_item",UseItem},{"record",Record},{"check",Check}})Bind(L,r.get(),fn.first,fn.second);
Bind(L,r.get(),"nearest_creature",Nearest);Bind(L,r.get(),"nearest_object",Nearest,1);
Bind(L,r.get(),"creature",Find);Bind(L,r.get(),"object",Find,1);lua_setglobal(L,"testbot");
char const* helpers=R"lua(
function testbot.await(predicate, timeout_ms, label)
local deadline=testbot.now_ms()+(timeout_ms or 10000)
while not predicate() do
assert(testbot.now_ms()<deadline, label or 'await timed out')
testbot.wait(100)
end
end
function testbot.login(guid)
testbot.spawn(guid)
testbot.await(function() return testbot.online(guid) end,30000,'login timed out')
return guid
end
function testbot.create(...)
testbot._create(...)
local guid,err
testbot.await(function() local pending; pending,guid,err=testbot._create_result(); return not pending end,30000,'creation timed out')
assert(guid~=0 and err=='',err)
return testbot.login(guid)
end
)lua";
if(luaL_dostring(L,helpers)!=LUA_OK){error=lua_tostring(L,-1);return false;}
r->Thread=lua_newthread(L);if(luaL_loadfile(r->Thread,path.string().c_str())!=LUA_OK){error=lua_tostring(r->Thread,-1);return false;}
runs[id]=r;Publish(*r);return true;
}
}
ClientlessTestBotMgr* ClientlessTestBotMgr::instance(){static ClientlessTestBotMgr m;return &m;}
void ClientlessTestBotMgr::Update(uint32 diff)
{
try
{
if(!sConfigMgr->GetBoolDefault("Clientless.TestBot.Enabled",false))
{for(auto const& e:runs)if(e.second->State=="running")Finish(*e.second,"cancelled","test controller disabled");return;}
static uint32 poll=0;poll+=diff;
if(poll>=1000)
{
poll=0;for(auto dir:{"requests","scripts","results","registry","archive"})fs::create_directories(Root()/dir);
// A claimed request is never replayed after a worldserver restart.
static bool recovered=false;if(!recovered)
{
recovered=true;
for(auto const& e:fs::directory_iterator(Root()/"results"))if(e.path().extension()==".json" && Key(e.path().stem().string()))
{
std::ifstream input(e.path());std::string previous((std::istreambuf_iterator<char>(input)),{});
if(previous.find("\"state\":\"running\"")!=std::string::npos)
{Run r;r.Id=e.path().stem().string();r.Events.push_back("{\"previous_result\":"+Quote(previous)+"}");Finish(r,"interrupted","worldserver restarted during test");}
}
for(auto const& e:fs::directory_iterator(Root()/"archive"))if(e.path().extension()==".lua")
{auto id=e.path().stem().string();if(Key(id)&&!fs::exists(Root()/"results"/(id+".json"))){Run r;r.Id=id;Finish(r,"interrupted","worldserver restarted before result publication");}}
}
bool busy=false;for(auto const& e:runs)busy|=e.second->State=="running";
if(accepting && !busy)for(auto const& e:fs::directory_iterator(Root()/"requests"))if(e.is_regular_file() && e.path().extension()==".lua" && Key(e.path().stem().string()))
{
auto id=e.path().stem().string();auto archive=Root()/"archive"/e.path().filename();
if(fs::exists(archive)||fs::exists(Root()/"results"/(id+".json")))continue;
fs::rename(e.path(),archive);std::string error;if(!Start(id,archive,error)){Run r;r.Id=id;Finish(r,"failed",error);}break;
}
}
for(auto const& entry:runs)
{
auto& r=*entry.second;if(r.State!="running")continue;
auto now=Clock::now();if(now-r.Started>std::chrono::seconds(r.TimeoutSeconds)){Finish(r,"timed_out","configured run deadline exceeded");continue;}
if(now<r.Wake)continue;
lua_sethook(r.Thread,Hook,LUA_MASKCOUNT,100000);int count=0;int status=lua_resume(r.Thread,nullptr,0,&count);
if(status==LUA_OK)Finish(r,r.Failures?"failed":"passed");
else if(status!=LUA_YIELD)Finish(r,"failed",lua_tostring(r.Thread,-1)?lua_tostring(r.Thread,-1):"unknown Lua error");
else Publish(r);
}
}
catch(std::exception const& e){TC_LOG_ERROR("server.worldserver","TESTBOT error: %s",e.what());}
}
bool ClientlessTestBotMgr::Command(ChatHandler* h,char const* args)
{
try
{
if(!sConfigMgr->GetBoolDefault("Clientless.TestBot.Enabled",false)){h->SendSysMessage("Testbot controller disabled (Clientless.TestBot.Enabled).");return true;}
std::istringstream in(args?args:"");std::string op,id,script,extra;in>>op>>id;
if(op=="pause" || op=="resume"){accepting=op=="resume";h->PSendSysMessage("Testbot job admission %s; active runs remain controlled separately.",accepting?"resumed":"paused");return true;}
if(op=="run" && (in>>script) && !(in>>extra) && Key(script))
{std::string error;bool ok=Start(id,Root()/"scripts"/(script+".lua"),error);h->PSendSysMessage("Testbot run %s: %s",id.c_str(),ok?"accepted":error.c_str());return true;}
if(op=="submit" && Key(id) && (in>>script) && !(in>>extra))
{
if(script.size()>87384){h->SendSysMessage("Encoded script exceeds 64KiB payload limit.");return true;}
auto decoded=Trinity::Encoding::Base64::Decode(script);
if(!decoded || decoded->size()>65536 || decoded->empty()){h->SendSysMessage("Invalid base64 or script exceeds 64KiB.");return true;}
if(runs.count(id)||fs::exists(Root()/"requests"/(id+".lua"))||fs::exists(Root()/"archive"/(id+".lua"))||fs::exists(Root()/"results"/(id+".json")))
{h->SendSysMessage("Run id already exists.");return true;}
if(!Write(Root()/"requests"/(id+".lua"),std::string(decoded->begin(),decoded->end())))
{h->SendSysMessage("Cannot persist test request.");return true;}
h->PSendSysMessage("Testbot run %s: queued",id.c_str());return true;
}
if(op=="result" && Key(id))
{
std::ifstream file(Root()/"results"/(id+".json"));
if(!file){h->SendSysMessage("Testbot result not available yet.");return true;}
std::string text((std::istreambuf_iterator<char>(file)),{});h->SendSysMessage(text.c_str());return true;
}
auto it=runs.find(id);
if(op=="cancel" && it!=runs.end() && it->second->State=="running"){Finish(*it->second,"cancelled","cancelled by operator");return true;}
if(op=="status" && it!=runs.end()){auto const& r=*it->second;h->PSendSysMessage("Testbot run %s: state=%s checks=%u failures=%u error='%s'",id.c_str(),r.State.c_str(),r.Checks,r.Failures,r.Error.c_str());return true;}
h->SendSysMessage("Usage: clientless test pause | resume | run <unique-run-id> <script-name> | status <run-id> | cancel <run-id> | submit <run-id> <base64-lua> | result <run-id>. Requests: <TestBot.Root>/requests/<run-id>.lua; results: results/<run-id>.json");return true;
}
catch(std::exception const& e){h->PSendSysMessage("Testbot command failed: %s",e.what());return true;}
}