Finish capital waiting travel and adaptive flight entry up to 10,000 yards. Preserve checked vertical climbs, recover blocked ground departures and find validated dry landings. Retain group routes and Undercity arrival constraints. Restore defense from native combat references, coordinate instance rendezvous and browser reentry, finalize clientless sessions before map destruction and transfer Halaa capture-point ownership safely. Include the associated regression tests, investigation reports and reusable diagnostic scripts. Keep generated runtime evidence and server binaries local through scoped ignore rules. Validation/build/live evidence is recorded in the session checkpoint and individual reports.
775 lines
31 KiB
C++
775 lines
31 KiB
C++
#include "AdventureProfessionPolicy.h"
|
|
#include "ClientlessLuaEngine.h"
|
|
#include "AdventureRotationPolicy.h"
|
|
#include "ClientlessPlayerMgr.h"
|
|
#include "ClientlessLoginRecovery.h"
|
|
#include "ClientlessActionExecutorMgr.h"
|
|
#include "ClientlessTravelMgr.h"
|
|
#include "ClientlessTransportMgr.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:
|
|
bool PrepareOnline(Player* player, uint32 diff) override
|
|
{
|
|
if (!_profileLoaded)
|
|
{
|
|
sClientlessLuaEngine->LoadAdventureProfile(player);
|
|
_profileLoaded=true;
|
|
}
|
|
return ClientlessLoginRecovery::Update(player,_recovery,diff);
|
|
}
|
|
void OnSafeLogoutRequested(Player* player) override
|
|
{
|
|
_resumeEnabled=sAdventureBehaviorMgr->PauseForLogout(player);
|
|
}
|
|
void OnSafeLogoutCancelled(Player* player) override
|
|
{
|
|
sAdventureBehaviorMgr->ResumeAfterLogoutPause(player,_resumeEnabled);
|
|
}
|
|
void UpdateForSafeLogout(Player* player, uint32 diff) override
|
|
{
|
|
if (sClientlessTransportMgr->ResumePassenger(player)) return;
|
|
if (player->IsInCombat() || sAdventureCombatMgr->Busy(player))
|
|
sAdventureCombatMgr->Update(player,diff);
|
|
}
|
|
void OnPlayerOnline(Player* player) override
|
|
{
|
|
if (!_profileLoaded) 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));
|
|
// Browser responses remain independent of combat and supply animations.
|
|
sAdventureGroupMgr->UpdateBrowser(player);
|
|
sAdventureGroupTravelMgr->Observe(player,diff);
|
|
if(sAdventureGroupTravelMgr->HoldForInstanceLeader(player))return;
|
|
sAdventurePopulationMgr->Maintain(player,diff);
|
|
// Restart restores native ship attachment, but CPC ride execution is
|
|
// transient. Own its safe recorded exit before starting another action.
|
|
if(sClientlessTransportMgr->ResumePassenger(player))return;
|
|
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 OnResurrection(Player* player,time_t when) override
|
|
{sAdventureCombatMgr->Revived(player,when);}
|
|
void OnDamageTaken(Player* player, Unit* attacker, uint32 damage) override
|
|
{ sAdventureCombatMgr->DamageTaken(player, attacker, damage); }
|
|
private:
|
|
bool _profileLoaded=false, _resumeEnabled=false;
|
|
ClientlessLoginRecovery::State _recovery;
|
|
public:
|
|
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);
|
|
// All autonomous level60+ characters can fly. Legacy/manual profiles get
|
|
// the same minimum capability as generated profiles, retaining preferences.
|
|
if(profile.AutonomousBehavior && (profile.Level?profile.Level:player->getLevel())>=60)
|
|
{
|
|
profile.CanFreeFly=true;
|
|
if(profile.FlightMounts.empty())
|
|
profile.FlightMounts.push_back({player->GetTeamId()==TEAM_ALLIANCE?32235u:32243u,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=="pvp" || 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>();
|
|
}
|