Manage Arthoran as a persistent level-60 tank profile, add Worgen forms and mounts, enable PvP and recruit through native LFG with preparation actions and player invitations. Reduce repeated route and flight-planning work, bound failed-travel retries and add runtime timing diagnostics. Include 86 passing tests and measurements with 110 online bots.
730 lines
29 KiB
C++
730 lines
29 KiB
C++
#include "AdventureProfessionPolicy.h"
|
|
#include "ClientlessLuaEngine.h"
|
|
#include "AdventureRotationPolicy.h"
|
|
#include "ClientlessPlayerMgr.h"
|
|
#include "ClientlessActionExecutorMgr.h"
|
|
#include "ClientlessTravelMgr.h"
|
|
#include "AdventureBehaviorMgr.h"
|
|
#include "AdventureCombatMgr.h"
|
|
#include "AdventureInteractionMgr.h"
|
|
#include "AdventureGroupMgr.h"
|
|
#include "AdventureGroupTravelMgr.h"
|
|
#include "AdventureGeneratorMgr.h"
|
|
#include "AdventurePopulationMgr.h"
|
|
#include "AdventureSupplyMgr.h"
|
|
#include "AdventureSocialActions.h"
|
|
#include "Config.h"
|
|
#include "Log.h"
|
|
#include "Player.h"
|
|
#include "Random.h"
|
|
#include "GameTime.h"
|
|
#include "Map.h"
|
|
#include <lua.hpp>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cctype>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
|
|
namespace
|
|
{
|
|
// World-thread only. Script effects are committed only after a successful reload.
|
|
struct ReloadEffects
|
|
{
|
|
std::vector<ClientlessAction> Actions;
|
|
ClientlessIntent Intent;
|
|
bool IntentChanged = false;
|
|
};
|
|
ReloadEffects* pendingReload = nullptr;
|
|
|
|
uint32 BoundGuid(lua_State* L) { return uint32(lua_tointeger(L, lua_upvalueindex(1))); }
|
|
|
|
std::unordered_map<std::string, std::string> ReadMap(lua_State* L, int index)
|
|
{
|
|
std::unordered_map<std::string, std::string> out;
|
|
if (!lua_istable(L, index)) return out;
|
|
index = lua_absindex(L, index);
|
|
lua_pushnil(L);
|
|
while (lua_next(L, index) != 0)
|
|
{
|
|
if (lua_type(L, -2) == LUA_TSTRING)
|
|
{
|
|
std::string key = lua_tostring(L, -2), value;
|
|
switch (lua_type(L, -1))
|
|
{
|
|
case LUA_TSTRING: value = lua_tostring(L, -1); break;
|
|
case LUA_TNUMBER:
|
|
value = lua_isinteger(L, -1) ? std::to_string(lua_tointeger(L, -1)) : std::to_string(lua_tonumber(L, -1));
|
|
break;
|
|
case LUA_TBOOLEAN: value = lua_toboolean(L, -1) ? "true" : "false"; break;
|
|
default: break;
|
|
}
|
|
if (!value.empty()) out.emplace(std::move(key), std::move(value));
|
|
}
|
|
lua_pop(L, 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
int LuaIntend(lua_State* L)
|
|
{
|
|
ClientlessIntent intent;
|
|
intent.Name = luaL_checkstring(L, 1);
|
|
if (lua_gettop(L) >= 2) intent.Parameters = ReadMap(L, 2);
|
|
if (pendingReload)
|
|
{
|
|
bool valid = !intent.Empty();
|
|
if (valid)
|
|
{
|
|
pendingReload->Intent = std::move(intent);
|
|
pendingReload->IntentChanged = true;
|
|
}
|
|
lua_pushboolean(L, valid);
|
|
return 1;
|
|
}
|
|
lua_pushboolean(L, sClientlessPlayerMgr->SetIntent(BoundGuid(L), std::move(intent)));
|
|
return 1;
|
|
}
|
|
|
|
int LuaClearIntent(lua_State* L)
|
|
{
|
|
if (pendingReload)
|
|
{
|
|
pendingReload->Intent = ClientlessIntent();
|
|
pendingReload->IntentChanged = true;
|
|
lua_pushboolean(L, true);
|
|
return 1;
|
|
}
|
|
lua_pushboolean(L, sClientlessPlayerMgr->ClearIntent(BoundGuid(L)));
|
|
return 1;
|
|
}
|
|
|
|
int LuaQueueAction(lua_State* L)
|
|
{
|
|
ClientlessAction action;
|
|
action.Name = luaL_checkstring(L, 1);
|
|
if (lua_gettop(L) >= 2) action.Parameters = ReadMap(L, 2);
|
|
if (pendingReload)
|
|
{
|
|
bool valid = !action.Name.empty();
|
|
if (valid) pendingReload->Actions.push_back(std::move(action));
|
|
lua_pushboolean(L, valid);
|
|
return 1;
|
|
}
|
|
lua_pushboolean(L, sClientlessPlayerMgr->QueueAction(BoundGuid(L), std::move(action)));
|
|
return 1;
|
|
}
|
|
|
|
int LuaCharacterGuid(lua_State* L)
|
|
{
|
|
lua_pushinteger(L, BoundGuid(L));
|
|
return 1;
|
|
}
|
|
|
|
int LuaLog(lua_State* L)
|
|
{
|
|
TC_LOG_INFO("server.worldserver", "ClientlessLua[%u]: %s", BoundGuid(L), luaL_checkstring(L, 1));
|
|
return 0;
|
|
}
|
|
|
|
void BoundFn(lua_State* L, int env, char const* name, lua_CFunction fn, uint32 guid)
|
|
{
|
|
env = lua_absindex(L, env);
|
|
lua_pushinteger(L, guid);
|
|
lua_pushcclosure(L, fn, 1);
|
|
lua_setfield(L, env, name);
|
|
}
|
|
|
|
std::string ReadStringField(lua_State* L, int table, char const* key, std::string const& fallback = {})
|
|
{
|
|
table = lua_absindex(L, table);
|
|
lua_getfield(L, table, key);
|
|
std::string value = lua_isstring(L, -1) ? lua_tostring(L, -1) : fallback;
|
|
lua_pop(L, 1);
|
|
return value;
|
|
}
|
|
|
|
bool ReadBoolField(lua_State* L, int table, char const* key, bool fallback)
|
|
{
|
|
table = lua_absindex(L, table);
|
|
lua_getfield(L, table, key);
|
|
bool value = lua_isboolean(L, -1) ? lua_toboolean(L, -1) != 0 : fallback;
|
|
lua_pop(L, 1);
|
|
return value;
|
|
}
|
|
|
|
float ReadFloatField(lua_State* L, int table, char const* key, float fallback)
|
|
{
|
|
table = lua_absindex(L, table);
|
|
lua_getfield(L, table, key);
|
|
float value = lua_isnumber(L, -1) ? float(lua_tonumber(L, -1)) : fallback;
|
|
lua_pop(L, 1);
|
|
return value;
|
|
}
|
|
|
|
std::unordered_map<std::string, float> ReadFloatDictionary(lua_State* L, int table, char const* key)
|
|
{
|
|
std::unordered_map<std::string, float> out;
|
|
table = lua_absindex(L, table);
|
|
lua_getfield(L, table, key);
|
|
if (!lua_istable(L, -1))
|
|
{
|
|
lua_pop(L, 1);
|
|
return out;
|
|
}
|
|
|
|
int dict = lua_absindex(L, -1);
|
|
lua_pushnil(L);
|
|
while (lua_next(L, dict) != 0)
|
|
{
|
|
if (lua_type(L, -2) == LUA_TSTRING && lua_isnumber(L, -1))
|
|
out[lua_tostring(L, -2)] = float(lua_tonumber(L, -1));
|
|
lua_pop(L, 1);
|
|
}
|
|
lua_pop(L, 1);
|
|
return out;
|
|
}
|
|
|
|
std::vector<ClientlessWeightedMount> ReadMountDictionary(lua_State* L, int movementTable, char const* field = "ground_mounts")
|
|
{
|
|
std::vector<ClientlessWeightedMount> mounts;
|
|
movementTable = lua_absindex(L, movementTable);
|
|
lua_getfield(L, movementTable, field);
|
|
if (!lua_istable(L, -1))
|
|
{
|
|
lua_pop(L, 1);
|
|
return mounts;
|
|
}
|
|
|
|
int dict = lua_absindex(L, -1);
|
|
lua_pushnil(L);
|
|
while (lua_next(L, dict) != 0)
|
|
{
|
|
if (lua_isinteger(L, -2) && lua_isnumber(L, -1))
|
|
{
|
|
lua_Integer id = lua_tointeger(L, -2);
|
|
lua_Number weight = lua_tonumber(L, -1);
|
|
if (id > 0 && id <= 0xFFFFFFFFll && std::isfinite(weight) && weight > 0.0 && weight <= 1000000.0)
|
|
mounts.push_back({ uint32(id), uint32(std::max<lua_Number>(1.0, weight)) });
|
|
}
|
|
lua_pop(L, 1);
|
|
}
|
|
lua_pop(L, 1);
|
|
return mounts;
|
|
}
|
|
|
|
class ClientlessAdventureLuaController final : public ClientlessPlayerController
|
|
{
|
|
public:
|
|
void OnPlayerOnline(Player* player) override
|
|
{
|
|
sClientlessLuaEngine->LoadAdventureProfile(player);
|
|
AdventureSocialActions::Prepare(player);
|
|
sClientlessLuaEngine->ExecuteAdventureScript(player);
|
|
sAdventureBehaviorMgr->Online(player);
|
|
sAdventureSupplyMgr->Online(player);
|
|
sAdventurePopulationMgr->Online(player);
|
|
}
|
|
void OnIntentChanged(Player* player, ClientlessIntent const* intent) override
|
|
{ sAdventureBehaviorMgr->IntentChanged(player, intent); }
|
|
void Update(Player* player, uint32 diff) override
|
|
{
|
|
sClientlessLuaEngine->UpdateWorldPresence(player,sAdventureBehaviorMgr->InteractionIntent(player));
|
|
sAdventureGroupTravelMgr->Observe(player,diff);
|
|
sAdventurePopulationMgr->Maintain(player,diff);
|
|
if(sAdventureGroupTravelMgr->Airborne(player)){sAdventureGroupTravelMgr->Follow(player);return;}
|
|
if(sAdventureSupplyMgr->Update(player,diff,sAdventureCombatMgr->Busy(player)))return;
|
|
if(!sAdventureCombatMgr->Update(player,diff) && !sAdventureInteractionMgr->Update(player,diff))
|
|
sAdventureBehaviorMgr->Update(player,diff);
|
|
}
|
|
void OnSharedTargetDamage(Player* player,ObjectGuid helper,ObjectGuid target) override
|
|
{ sAdventureCombatMgr->SharedTargetDamage(player,helper,target); }
|
|
void OnGroupJoinRequest(Player* player,Player* requester) override
|
|
{sAdventureGroupMgr->RequestPlayerAdmission(player,requester);}
|
|
void OnTextEmote(Player* player, Player* source, uint32 emote) override
|
|
{ sAdventureInteractionMgr->Emote(player,source,emote); }
|
|
void OnDamageTaken(Player* player, Unit* attacker, uint32 damage) override
|
|
{ sAdventureCombatMgr->DamageTaken(player, attacker, damage); }
|
|
void OnPlayerLogout(Player* player) override
|
|
{ sClientlessLuaEngine->ForgetWorldPresence(player->GetGUID().GetCounter()); sAdventurePopulationMgr->Offline(player); sAdventureInteractionMgr->Offline(player); sAdventureSupplyMgr->Offline(player); sAdventureCombatMgr->Offline(player); sAdventureBehaviorMgr->Offline(player); }
|
|
};
|
|
}
|
|
|
|
ClientlessLuaEngine* ClientlessLuaEngine::instance()
|
|
{
|
|
static ClientlessLuaEngine instance;
|
|
return &instance;
|
|
}
|
|
|
|
ClientlessLuaEngine::ClientlessLuaEngine()
|
|
{
|
|
_scriptRoot = sConfigMgr->GetStringDefault("Clientless.ScriptRoot", "scripts/clientless");
|
|
if (char const* overrideRoot = std::getenv("VCORE_CLIENTLESS_SCRIPT_DIR"))
|
|
if (*overrideRoot) _scriptRoot = overrideRoot;
|
|
_scriptRoot = std::filesystem::absolute(_scriptRoot).lexically_normal().string();
|
|
_state = luaL_newstate();
|
|
if (!_state)
|
|
{
|
|
TC_LOG_ERROR("server.worldserver", "ClientlessLua: failed to create Lua state.");
|
|
return;
|
|
}
|
|
luaL_openlibs(_state);
|
|
for (char const* g : { "io", "os", "package", "debug", "dofile", "loadfile", "require" })
|
|
{
|
|
lua_pushnil(_state);
|
|
lua_setglobal(_state, g);
|
|
}
|
|
TC_LOG_INFO("server.worldserver", "ClientlessLua initialized, script root '%s'.", _scriptRoot.c_str());
|
|
}
|
|
|
|
ClientlessLuaEngine::~ClientlessLuaEngine()
|
|
{
|
|
if (_state) lua_close(_state);
|
|
}
|
|
|
|
void ClientlessLuaEngine::PushSandboxEnvironment(Player* player)
|
|
{
|
|
lua_newtable(_state);
|
|
int env = lua_gettop(_state);
|
|
lua_newtable(_state);
|
|
lua_pushglobaltable(_state);
|
|
lua_setfield(_state, -2, "__index");
|
|
lua_setmetatable(_state, env);
|
|
|
|
uint32 guid = player->GetGUID().GetCounter();
|
|
BoundFn(_state, env, "intend", LuaIntend, guid);
|
|
BoundFn(_state, env, "clear_intent", LuaClearIntent, guid);
|
|
BoundFn(_state, env, "queue_action", LuaQueueAction, guid);
|
|
BoundFn(_state, env, "character_guid", LuaCharacterGuid, guid);
|
|
BoundFn(_state, env, "log", LuaLog, guid);
|
|
lua_pushstring(_state, player->GetName().c_str());
|
|
lua_setfield(_state, env, "character_name");
|
|
}
|
|
|
|
bool ClientlessLuaEngine::ExecuteScript(Player* player, std::string const& path, std::string const& kind)
|
|
{
|
|
if (!_state || !player) return false;
|
|
int base = lua_gettop(_state);
|
|
if (luaL_loadfile(_state, path.c_str()) != LUA_OK)
|
|
{
|
|
TC_LOG_ERROR("server.worldserver", "ClientlessLua[%u]: load failed for %s '%s': %s",
|
|
player->GetGUID().GetCounter(), kind.c_str(), path.c_str(), lua_tostring(_state, -1));
|
|
lua_settop(_state, base);
|
|
return false;
|
|
}
|
|
PushSandboxEnvironment(player);
|
|
if (!lua_setupvalue(_state, base + 1, 1))
|
|
{
|
|
TC_LOG_ERROR("server.worldserver", "ClientlessLua[%u]: '%s' has no _ENV upvalue.", player->GetGUID().GetCounter(), path.c_str());
|
|
lua_settop(_state, base);
|
|
return false;
|
|
}
|
|
if (lua_pcall(_state, 0, 0, 0) != LUA_OK)
|
|
{
|
|
TC_LOG_ERROR("server.worldserver", "ClientlessLua[%u]: runtime error in '%s': %s",
|
|
player->GetGUID().GetCounter(), path.c_str(), lua_tostring(_state, -1));
|
|
lua_settop(_state, base);
|
|
return false;
|
|
}
|
|
TC_LOG_INFO("server.worldserver", "ClientlessLua[%u]: executed %s script '%s'.",
|
|
player->GetGUID().GetCounter(), kind.c_str(), path.c_str());
|
|
lua_settop(_state, base);
|
|
return true;
|
|
}
|
|
|
|
bool ClientlessLuaEngine::LoadAdventureProfile(Player* player)
|
|
{
|
|
if (!_state || !player)
|
|
return false;
|
|
|
|
namespace fs = std::filesystem;
|
|
std::string lowerName = player->GetName();
|
|
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(),
|
|
[](unsigned char c) { return char(std::tolower(c)); });
|
|
|
|
fs::path root(_scriptRoot);
|
|
fs::path specific = root / "adventure" / "profiles" / (lowerName + ".lua");
|
|
fs::path generic = root / "adventure" / "profiles" / "generic.lua";
|
|
fs::path chosen = fs::exists(specific) ? specific : generic;
|
|
|
|
std::string generated;
|
|
bool databaseProfile=!fs::exists(specific) && AdventureGeneratorMgr::Profile(player->GetGUID().GetCounter(),generated);
|
|
if(databaseProfile)chosen=fs::path("generated:"+std::to_string(player->GetGUID().GetCounter()));
|
|
int base = lua_gettop(_state);
|
|
int loaded=databaseProfile?luaL_loadbuffer(_state,generated.data(),generated.size(),chosen.string().c_str()):luaL_loadfile(_state,chosen.string().c_str());
|
|
if (loaded != LUA_OK)
|
|
{
|
|
TC_LOG_ERROR("server.adventurebot",
|
|
"[AdventureBot][Profile] guid=%u name='%s' load failed path='%s': %s",
|
|
player->GetGUID().GetCounter(), player->GetName().c_str(),
|
|
chosen.string().c_str(), lua_tostring(_state, -1));
|
|
lua_settop(_state, base);
|
|
return false;
|
|
}
|
|
|
|
PushSandboxEnvironment(player);
|
|
if (!lua_setupvalue(_state, base + 1, 1))
|
|
{
|
|
TC_LOG_ERROR("server.adventurebot",
|
|
"[AdventureBot][Profile] guid=%u path='%s' has no _ENV upvalue",
|
|
player->GetGUID().GetCounter(), chosen.string().c_str());
|
|
lua_settop(_state, base);
|
|
return false;
|
|
}
|
|
|
|
if (lua_pcall(_state, 0, 1, 0) != LUA_OK)
|
|
{
|
|
TC_LOG_ERROR("server.adventurebot",
|
|
"[AdventureBot][Profile] guid=%u runtime error path='%s': %s",
|
|
player->GetGUID().GetCounter(), chosen.string().c_str(), lua_tostring(_state, -1));
|
|
lua_settop(_state, base);
|
|
return false;
|
|
}
|
|
|
|
if (!lua_istable(_state, -1))
|
|
{
|
|
TC_LOG_ERROR("server.adventurebot",
|
|
"[AdventureBot][Profile] guid=%u path='%s' must return a table",
|
|
player->GetGUID().GetCounter(), chosen.string().c_str());
|
|
lua_settop(_state, base);
|
|
return false;
|
|
}
|
|
|
|
int rootTable = lua_absindex(_state, -1);
|
|
ClientlessAdventureProfile profile;
|
|
profile.CharacterName = player->GetName();
|
|
profile.SourcePath = chosen.string();
|
|
|
|
lua_getfield(_state, rootTable, "identity");
|
|
if (lua_istable(_state, -1))
|
|
{
|
|
profile.CharacterName = ReadStringField(_state, -1, "name", profile.CharacterName);
|
|
profile.HomeRegion = ReadStringField(_state, -1, "home_region");
|
|
profile.Background = ReadStringField(_state, -1, "background");
|
|
profile.Vocation = ReadStringField(_state, -1, "vocation");
|
|
profile.ProfileVersion = uint32(std::clamp(ReadFloatField(_state,-1,"profile_version",0),0.0f,100.0f));
|
|
profile.Level = uint32(std::clamp(ReadFloatField(_state,-1,"level",0),0.0f,85.0f));
|
|
profile.Class = uint32(std::clamp(ReadFloatField(_state,-1,"class",0),0.0f,11.0f));
|
|
profile.Specialization = uint32(std::clamp(ReadFloatField(_state,-1,"specialization",0),0.0f,2.0f));
|
|
lua_getfield(_state,-1,"seed");
|
|
if(lua_isinteger(_state,-1)){auto value=lua_tointeger(_state,-1);if(value>=0 && value<=0xFFFFFFFFll)profile.Seed=uint32(value);}
|
|
lua_pop(_state,1);
|
|
profile.SoloSpecialization=uint32(std::clamp(ReadFloatField(_state,-1,"solo_specialization",
|
|
float(AdventureRotationPolicy::SoloSpec(profile.Class,profile.Seed))),0.0f,2.0f));
|
|
profile.Role = ReadStringField(_state,-1,"role","damage");
|
|
if(profile.Role!="tank" && profile.Role!="healer" && profile.Role!="damage")profile.Role="damage";
|
|
profile.HomeZone = uint32(std::max(0.0f,ReadFloatField(_state,-1,"home_zone",0)));
|
|
profile.HomeCapital = uint32(std::max(0.0f,ReadFloatField(_state,-1,"home_capital",0)));
|
|
profile.FavoriteZone = uint32(std::max(0.0f,ReadFloatField(_state,-1,"favorite_zone",0)));
|
|
profile.FavoriteRegion = ReadStringField(_state,-1,"favorite_region");
|
|
}
|
|
lua_pop(_state, 1);
|
|
|
|
lua_getfield(_state, rootTable, "movement");
|
|
if (lua_istable(_state, -1))
|
|
{
|
|
profile.CanGroundMount = ReadBoolField(_state, -1, "can_ground_mount", false);
|
|
profile.GroundMountMinDistance = std::max(0.0f,
|
|
ReadFloatField(_state, -1, "ground_mount_min_distance", 60.0f));
|
|
profile.GroundMountSpeedRate = std::max(1.0f,
|
|
ReadFloatField(_state, -1, "ground_mount_speed_rate", 1.6f));
|
|
profile.GroundMounts = ReadMountDictionary(_state, -1);
|
|
profile.CanFreeFly = ReadBoolField(_state, -1, "can_free_fly", false);
|
|
profile.FlightMounts = ReadMountDictionary(_state, -1, "flight_mounts");
|
|
profile.Companions = ReadMountDictionary(_state,-1,"companions");
|
|
profile.CompanionSpell = uint32(std::max(0.0f, ReadFloatField(_state, -1, "companion_spell", 0)));
|
|
profile.CompanionLeashItem = uint32(std::max(0.0f, ReadFloatField(_state, -1, "companion_leash_item", 0)));
|
|
}
|
|
lua_pop(_state, 1);
|
|
|
|
lua_getfield(_state,rootTable,"inventory");
|
|
if(lua_istable(_state,-1))
|
|
{
|
|
lua_getfield(_state,-1,"keep_items");
|
|
if(lua_istable(_state,-1))for(size_t i=1;i<=lua_rawlen(_state,-1);++i)
|
|
{
|
|
lua_rawgeti(_state,-1,i);auto id=lua_tointeger(_state,-1);
|
|
if(id>0 && id<=0xFFFFFFFFll)profile.KeepItems.insert(uint32(id));
|
|
lua_pop(_state,1);
|
|
}
|
|
lua_pop(_state,1);
|
|
}
|
|
lua_pop(_state,1);
|
|
lua_getfield(_state,rootTable,"zone_affinity");
|
|
if(lua_istable(_state,-1))
|
|
{
|
|
lua_pushnil(_state);
|
|
while(lua_next(_state,-2))
|
|
{
|
|
auto id=lua_tointeger(_state,-2);auto weight=lua_tonumber(_state,-1);
|
|
if(lua_isinteger(_state,-2) && lua_isnumber(_state,-1) && id>0 && id<=0xFFFFFFFFll && std::isfinite(weight))
|
|
profile.ZoneAffinity[uint32(id)]=float(std::clamp(weight,0.0,100.0));
|
|
lua_pop(_state,1);
|
|
}
|
|
}
|
|
lua_pop(_state,1);
|
|
profile.Personality = ReadFloatDictionary(_state, rootTable, "personality");
|
|
profile.RegionAffinity = ReadFloatDictionary(_state, rootTable, "region_affinity");
|
|
profile.ActivityAffinity = ReadFloatDictionary(_state, rootTable, "activity_affinity");
|
|
profile.MicroActionAffinity = ReadFloatDictionary(_state, rootTable, "micro_action_affinity");
|
|
lua_getfield(_state, rootTable, "behavior");
|
|
if (lua_istable(_state, -1))
|
|
{
|
|
profile.AutonomousBehavior = ReadBoolField(_state, -1, "enabled", false);
|
|
profile.DailySchedule = ReadBoolField(_state, -1, "daily_schedule", false);
|
|
profile.AllowRetreat = ReadBoolField(_state, -1, "allow_retreat", true);
|
|
profile.InitialIntent = ReadStringField(_state, -1, "initial_intent");
|
|
profile.InitialLocation = ReadStringField(_state, -1, "initial_location");
|
|
profile.Locale = ReadStringField(_state, -1, "locale", "enUS");
|
|
profile.InitialDurationSeconds = uint32(std::clamp(ReadFloatField(_state, -1, "initial_duration_seconds", 3600), 30.0f, 86400.0f));
|
|
}
|
|
lua_pop(_state, 1);
|
|
|
|
lua_getfield(_state,rootTable,"professions");
|
|
if(lua_istable(_state,-1))
|
|
{
|
|
lua_getfield(_state,-1,"primary");
|
|
if(lua_istable(_state,-1))
|
|
for(size_t index=1;index<=lua_rawlen(_state,-1);++index)
|
|
{
|
|
lua_rawgeti(_state,-1,index);
|
|
if(lua_type(_state,-1)==LUA_TSTRING)
|
|
{
|
|
std::string name=lua_tostring(_state,-1);
|
|
if(AdventureProfessionPolicy::PrimaryName(name))profile.PrimaryProfessions.insert(name);
|
|
else TC_LOG_WARN("server.adventurebot","PROFILE_UNKNOWN_PROFESSION guid=%u name='%s'",player->GetGUID().GetCounter(),name.c_str());
|
|
}
|
|
lua_pop(_state,1);
|
|
}
|
|
lua_pop(_state,1);
|
|
}
|
|
lua_pop(_state,1);
|
|
uint32 const guid = player->GetGUID().GetCounter();
|
|
_profiles[guid] = std::move(profile);
|
|
ClientlessAdventureProfile const& stored = _profiles.at(guid);
|
|
|
|
TC_LOG_INFO("server.adventurebot",
|
|
"[AdventureBot][Profile] loaded guid=%u name='%s' source='%s' home='%s' "
|
|
"canGroundMount=%u mounts=%u mountMinDistance=%.1f mountSpeedRate=%.2f "
|
|
"personality=%u regionAffinity=%u activityAffinity=%u",
|
|
guid, player->GetName().c_str(), stored.SourcePath.c_str(), stored.HomeRegion.c_str(),
|
|
stored.CanGroundMount ? 1u : 0u, uint32(stored.GroundMounts.size()),
|
|
stored.GroundMountMinDistance, stored.GroundMountSpeedRate,
|
|
uint32(stored.Personality.size()), uint32(stored.RegionAffinity.size()),
|
|
uint32(stored.ActivityAffinity.size()));
|
|
|
|
TC_LOG_INFO("server.adventurebot","PROFILE_PROFESSIONS guid=%u primary=%u",guid,uint32(stored.PrimaryProfessions.size()));
|
|
|
|
if (stored.CanGroundMount && stored.GroundMounts.empty())
|
|
TC_LOG_WARN("server.adventurebot",
|
|
"[AdventureBot][Profile] guid=%u enables ground mounts but defines no ground_mounts", guid);
|
|
|
|
lua_settop(_state, base);
|
|
return true;
|
|
}
|
|
|
|
ClientlessAdventureProfile const* ClientlessLuaEngine::GetAdventureProfile(uint32 characterGuid) const
|
|
{
|
|
auto itr = _profiles.find(characterGuid);
|
|
return itr != _profiles.end() ? &itr->second : nullptr;
|
|
}
|
|
|
|
uint32 ClientlessLuaEngine::SelectGroundMountDisplayId(uint32 characterGuid) const
|
|
{
|
|
ClientlessAdventureProfile const* profile = GetAdventureProfile(characterGuid);
|
|
if (!profile || profile->GroundMounts.empty())
|
|
return 0;
|
|
|
|
uint64 total = 0;
|
|
for (ClientlessWeightedMount const& mount : profile->GroundMounts)
|
|
total += mount.Weight;
|
|
if (!total)
|
|
return 0;
|
|
|
|
uint32 const cappedTotal = uint32(std::min<uint64>(total, 0xFFFFFFFFull));
|
|
uint32 roll = urand(1u, cappedTotal);
|
|
for (ClientlessWeightedMount const& mount : profile->GroundMounts)
|
|
{
|
|
if (roll <= mount.Weight)
|
|
return mount.DisplayId;
|
|
roll -= std::min(roll, mount.Weight);
|
|
}
|
|
return profile->GroundMounts.back().DisplayId;
|
|
}
|
|
|
|
uint32 ClientlessLuaEngine::SelectCompanionSpell(uint32 guid) const
|
|
{
|
|
auto profile=GetAdventureProfile(guid);
|
|
if(!profile)return 0;
|
|
uint32 total=0;
|
|
for(auto const& pet:profile->Companions)total+=pet.Weight;
|
|
if(!total)return profile->CompanionSpell;
|
|
auto roll=urand(1,total);
|
|
for(auto const& pet:profile->Companions){if(roll<=pet.Weight)return pet.DisplayId;roll-=pet.Weight;}
|
|
return 0;
|
|
}
|
|
|
|
void ClientlessLuaEngine::UpdateWorldPresence(Player* player, std::string const& intent, bool arrived)
|
|
{
|
|
if (!player || !player->IsInWorld()) return;
|
|
player->AddCharacterFlag(CHARACTER_FLAG_PVP_DESIRED);
|
|
player->SetFlag(PLAYER_FLAGS, PLAYER_FLAGS_IN_PVP);
|
|
if (!player->IsPvP() || player->pvpInfo.EndTimer) player->UpdatePvP(true,true);
|
|
if (player->getRace()!=RACE_WORGEN) return;
|
|
if (!player->HasSpell(SPELL_TWO_FORMS_RACIAL)) player->LearnSpell(SPELL_TWO_FORMS_RACIAL,false);
|
|
if (player->getLevel()>=20 && !player->HasSpell(458)) player->LearnSpell(458,false);
|
|
bool active=intent=="adventure" || intent=="instance" || intent=="raid" || player->IsInCombat() || player->GetMap()->IsDungeon();
|
|
auto guid=player->GetGUID().GetCounter();
|
|
auto wanted=_worgenForms.find(guid);
|
|
if (arrived && !active) wanted=_worgenForms.insert_or_assign(guid,urand(0,1)!=0).first;
|
|
if (active) wanted=_worgenForms.insert_or_assign(guid,true).first;
|
|
if (wanted==_worgenForms.end() || !player->IsAlive() || player->IsMounted() ||
|
|
player->GetTransport() || player->IsInFlight() || player->IsFlying() || player->IsBeingTeleported() ||
|
|
player->HasUnitState(UNIT_STATE_CASTING)) return;
|
|
bool altered=player->HasAuraType(SPELL_AURA_WORGEN_ALTERED_FORM);
|
|
if (altered==wanted->second || _worgenNextAttempt[guid]>GameTime::GetGameTime()) return;
|
|
_worgenNextAttempt[guid]=GameTime::GetGameTime()+5;
|
|
if (wanted->second) player->CastSpell(player,SPELL_ALTERED_FORM_RACIAL,true);
|
|
else if (player->HasSpell(SPELL_TWO_FORMS_RACIAL)) player->CastSpell(player,SPELL_TWO_FORMS_RACIAL,true);
|
|
TC_LOG_INFO("server.adventurebot","WORGEN_FORM guid=%u intent='%s' desired=%u arrived=%u",guid,intent.c_str(),wanted->second,arrived);
|
|
}
|
|
|
|
bool ClientlessLuaEngine::PrepareGroundMount(Player* player, float travelDistance, char const* context)
|
|
{
|
|
if (!player || !player->IsInWorld())
|
|
return false;
|
|
|
|
ClientlessAdventureProfile const* profile = GetAdventureProfile(player->GetGUID().GetCounter());
|
|
if (!profile || !profile->CanGroundMount || (profile->GroundMounts.empty() && player->getRace()!=RACE_WORGEN))
|
|
return false;
|
|
if (travelDistance < profile->GroundMountMinDistance || !player->IsOutdoors())
|
|
return false;
|
|
if (player->IsMounted())
|
|
return true;
|
|
|
|
if (player->getRace()==RACE_WORGEN && player->HasAuraType(SPELL_AURA_WORGEN_ALTERED_FORM) && player->getLevel()>=20)
|
|
{
|
|
if (!player->HasSpell(87840)) player->LearnSpell(87840,false);
|
|
player->CastSpell(player,87840,true);
|
|
if (player->HasAura(87840) && player->IsMounted())
|
|
{
|
|
TC_LOG_INFO("server.adventurebot.navigation","GROUND_RUNNING_WILD guid=%u",player->GetGUID().GetCounter());
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
uint32 const displayId = player->getRace()==RACE_WORGEN?2404:SelectGroundMountDisplayId(player->GetGUID().GetCounter());
|
|
if (!displayId)
|
|
return false;
|
|
|
|
// The AdventureBot profile is authoritative for this capability. Intentionally
|
|
// no level, riding-skill, spellbook, inventory or economy eligibility check.
|
|
player->Mount(displayId);
|
|
player->SetSpeedRate(MOVE_RUN, profile->GroundMountSpeedRate);
|
|
|
|
TC_LOG_INFO("server.adventurebot.navigation",
|
|
"GROUND_MOUNT guid=%u name='%s' displayId=%u distance=%.2f speedRate=%.2f context='%s'",
|
|
player->GetGUID().GetCounter(), player->GetName().c_str(), displayId,
|
|
travelDistance, profile->GroundMountSpeedRate, context ? context : "");
|
|
return true;
|
|
}
|
|
|
|
void ClientlessLuaEngine::DismountGroundMount(Player* player, char const* reason)
|
|
{
|
|
if (!player || !player->IsMounted())
|
|
return;
|
|
|
|
uint32 const oldDisplay = player->GetMountID();
|
|
player->RemoveAurasDueToSpell(87840);
|
|
player->Dismount();
|
|
player->UpdateSpeed(MOVE_RUN);
|
|
|
|
TC_LOG_INFO("server.adventurebot.navigation",
|
|
"GROUND_DISMOUNT guid=%u name='%s' displayId=%u reason='%s'",
|
|
player->GetGUID().GetCounter(), player->GetName().c_str(), oldDisplay,
|
|
reason ? reason : "");
|
|
}
|
|
|
|
bool ClientlessLuaEngine::ExecuteAdventureScript(Player* player)
|
|
{
|
|
if (!player) return false;
|
|
namespace fs = std::filesystem;
|
|
std::string name = player->GetName();
|
|
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return char(std::tolower(c)); });
|
|
fs::path root(_scriptRoot);
|
|
fs::path custom = root / "adventure" / "overrides" / (name + ".lua");
|
|
if (fs::exists(custom)) return ExecuteScript(player, custom.string(), "adventure override");
|
|
return ExecuteScript(player, (root / "adventure" / "generic.lua").string(), "generic adventure");
|
|
}
|
|
|
|
bool ClientlessLuaEngine::ReloadAdventureScript(Player* player, std::string& detail)
|
|
{
|
|
if (!player || !player->IsInWorld() || !sClientlessPlayerMgr->IsManaged(player->GetGUID().GetCounter()))
|
|
{
|
|
detail = "Reload requires an online managed clientless player.";
|
|
return false;
|
|
}
|
|
uint32 guid = player->GetGUID().GetCounter();
|
|
auto action = sClientlessActionExecutorMgr->GetStatus(guid);
|
|
auto travel = sClientlessTravelMgr->GetStatus(guid);
|
|
if (pendingReload || sClientlessPlayerMgr->GetQueuedActionCount(guid) ||
|
|
(action && (action->State == ClientlessActionExecutionState::SelectingTarget ||
|
|
action->State == ClientlessActionExecutionState::Moving)) ||
|
|
(travel && travel->State == ClientlessTravelState::Moving) ||
|
|
player->GetTransport() || player->IsInFlight() || player->IsBeingTeleported())
|
|
{
|
|
detail = "Reload refused: bot has queued actions, is travelling or is aboard a transport. Wait until idle.";
|
|
return false;
|
|
}
|
|
|
|
auto previous = _profiles.find(guid);
|
|
bool hadProfile = previous != _profiles.end();
|
|
ClientlessAdventureProfile backup = hadProfile ? previous->second : ClientlessAdventureProfile();
|
|
ReloadEffects effects;
|
|
bool success;
|
|
{
|
|
struct Capture
|
|
{
|
|
explicit Capture(ReloadEffects& effects) { pendingReload = &effects; }
|
|
~Capture() { pendingReload = nullptr; }
|
|
} capture(effects);
|
|
success = LoadAdventureProfile(player) && ExecuteAdventureScript(player);
|
|
}
|
|
if (!success)
|
|
{
|
|
if (hadProfile) _profiles[guid] = std::move(backup);
|
|
else _profiles.erase(guid);
|
|
detail = "Lua reload failed. Previous profile retained; no new actions or intent applied. See server log.";
|
|
}
|
|
else
|
|
{
|
|
if (effects.IntentChanged)
|
|
{
|
|
if (effects.Intent.Empty()) sClientlessPlayerMgr->ClearIntent(guid);
|
|
else sClientlessPlayerMgr->SetIntent(guid, std::move(effects.Intent));
|
|
}
|
|
for (auto& queued : effects.Actions)
|
|
sClientlessPlayerMgr->QueueAction(guid, std::move(queued));
|
|
detail = "Profile and adventure script reloaded; queued actions=" + std::to_string(effects.Actions.size()) + ".";
|
|
}
|
|
TC_LOG_INFO("server.adventurebot.navigation", "LUA_RELOAD guid=%u success=%u root='%s' detail='%s'",
|
|
guid, success ? 1u : 0u, _scriptRoot.c_str(), detail.c_str());
|
|
return success;
|
|
}
|
|
|
|
bool ClientlessLuaEngine::ExecuteTestScript(Player* player, std::string const& scriptName)
|
|
{
|
|
if (!player || scriptName.empty() || scriptName.find("..") != std::string::npos || scriptName.find('/') != std::string::npos || scriptName.find('\\') != std::string::npos)
|
|
return false;
|
|
std::filesystem::path path = std::filesystem::path(_scriptRoot) / "tests" / (scriptName + ".lua");
|
|
return ExecuteScript(player, path.string(), "test");
|
|
}
|
|
|
|
std::unique_ptr<ClientlessPlayerController> CreateClientlessAdventureLuaController()
|
|
{
|
|
return std::make_unique<ClientlessAdventureLuaController>();
|
|
}
|