Files
Viano-Core/tests/clientless/test_adventure_groups.py
T
Nico aba5aeba2c Keep queued adventure bots active and preserve browser admission rules
Reuse formation performances while waiting for dungeon, raid or PvP admission, and accept compatible formation invitations without active recruitment or interrupting battleground offers.

Require the full low-population wait for every participating queue entry and acknowledge connected clientless members during native group teleportation.

Add regression coverage and document builds, live Yarumak/Seraellin checks and separately preserved shutdown crash findings.
2026-11-04 14:19:38 +01:00

217 lines
18 KiB
Python

"""Execute production bot recruitment with native-party/channel adapters."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/AdventureGroupMgr.cpp').read_text()
def fun(sig):
a=s.index(sig);b=s.index('{',a);e=b+1;n=1
while n:n+=(s[e]=='{')-(s[e]=='}');e+=1
return s[a:e]
header=(root/'src/server/game/World/AdventureGroupMgr.h').read_text()
header='\n'.join(l for l in header.splitlines() if not l.startswith('#include')).replace('private:','public:')
code=r'''
#include "AdventureGroupPolicy.h"
#include "AdventureBehaviorPolicy.h"
#include <algorithm>
#include <cassert>
#include <ctime>
#include <string>
#include <vector>
#include <unordered_map>
#include <unordered_set>
using uint32=unsigned;
#define TC_GAME_API
#define TC_LOG_INFO(...) ((void)0)
struct ObjectGuid{unsigned id=0;unsigned GetCounter()const{return id;}bool operator==(ObjectGuid b)const{return id==b.id;}bool operator!=(ObjectGuid b)const{return id!=b.id;}bool operator<(ObjectGuid b)const{return id<b.id;}};
namespace std{template<>struct hash<ObjectGuid>{size_t operator()(ObjectGuid g)const{return g.id;}};}
struct Player;struct ChatHandler;
struct Map{bool IsDungeon(){return false;}}map;
struct Member{Player*player=nullptr;Member*nextMember=nullptr;Player*GetSource(){return player;}Member*next(){return nextMember;}};
constexpr unsigned PLAYER_MAX_BATTLEGROUND_QUEUES=3;constexpr int GROUP_REMOVEMETHOD_LEAVE=1;
struct Group{struct Slot{ObjectGuid guid;};std::vector<Slot>slots;auto const&GetMemberSlots(){return slots;}void RemoveMember(ObjectGuid,int);Member*members=nullptr;Member*GetFirstMember(){return members;}void ChangeLeader(ObjectGuid g){leader=g;}void SendUpdate(){}ObjectGuid leader;unsigned count=2;bool raid=false;ObjectGuid GetLeaderGUID(){return leader;}unsigned GetMembersCount(){return count;}bool isRaidGroup(){return raid;}void ConvertToRaid(){raid=true;}void ConvertToGroup(){raid=false;}};
struct WorldPacket{unsigned value=0;WorldPacket(int,int=0){}WorldPacket&operator<<(unsigned n){value=n;return *this;}};using Difficulty=unsigned;constexpr int MSG_SET_DUNGEON_DIFFICULTY=3;constexpr int MSG_SET_RAID_DIFFICULTY=2,RAID_DIFFICULTY_25MAN_NORMAL=1;constexpr int ALLIANCE=0,LANG_COMMON=7,LANG_ORCISH=1;constexpr int CMSG_PARTY_INVITE=1,EMOTE_ONESHOT_WAVE=1;
namespace WorldPackets{namespace Party{struct PartyInviteClient{PartyInviteClient(WorldPacket&&){}std::string TargetName;ObjectGuid TargetGUID;};}}
struct Session{Player*owner;bool clientless=true;bool IsClientless(){return clientless;}void HandlePartyInviteOpcode(WorldPackets::Party::PartyInviteClient&);void HandleSetRaidDifficultyOpcode(WorldPacket&);void HandleSetDungeonDifficultyOpcode(WorldPacket&p){HandleSetRaidDifficultyOpcode(p);}};
struct Player{ObjectGuid guid;Group*group=nullptr,*invite=nullptr;Session session{this};bool combat=false;unsigned zone=1519,level=60,team=0;float distance=10;
unsigned difficulty=0;unsigned GetRaidDifficulty(){return difficulty;}unsigned GetDifficulty(bool){return difficulty;}ObjectGuid GetGUID(){return guid;}bool IsInWorld(){return true;}bool IsAlive(){return true;}bool IsInCombat(){return combat;}
bool queued=false;bool isUsingLfg(){return queued;}bool bgqueued=false,bg=false,bgoffer=false;
bool InBattlegroundQueue(){return bgqueued;}bool InBattleground(){return bg;}
unsigned GetBattlegroundQueueTypeId(unsigned){return 1;}bool IsInvitedForBattlegroundQueueType(unsigned){return bgoffer;}
bool IsBeingTeleported(){return false;}bool GetTransport(){return false;}bool IsFlying(){return false;}bool IsInFlight(){return false;}bool IsCharmed(){return false;}Map*GetMap(){return &map;}
unsigned GetZoneId(){return zone;}unsigned getLevel(){return level;}unsigned GetTeam(){return team;}
Group*GetGroup(){return group;}Group*GetGroupInvite(){return invite;}Session*GetSession(){return &session;}std::string GetName(){return std::to_string(guid.id);}
bool IsInMap(Player*o){return zone==o->zone;}bool IsInPhase(Player*){return true;}bool CanSeeOrDetect(Player*){return true;}
float GetExactDist(Player*){return distance;}bool IsWithinLOSInMap(Player*){return true;}
std::vector<Player*> people;bool afk=false;std::string whisper;bool isAFK(){return afk;}void GetPlayerListInGrid(std::vector<Player*>& out,int){out=people;}void Whisper(std::string const& s,int,Player*){whisper=s;}
void SetFacingToObject(Player*){}void HandleEmoteCommand(int){}
};
namespace ClientlessBrowserPolicy{bool Human(Player*p){return p&&!p->GetSession()->IsClientless();}}
namespace ObjectAccessor{std::unordered_map<ObjectGuid,Player*> players;Player*FindPlayer(ObjectGuid g){auto it=players.find(g);return it==players.end()?nullptr:it->second;}}
void Session::HandleSetRaidDifficultyOpcode(WorldPacket&p){owner->difficulty=p.value;}
void Group::RemoveMember(ObjectGuid g,int){--count;ObjectAccessor::FindPlayer(g)->group=nullptr;}
Group pending;int invites=0;
void Session::HandlePartyInviteOpcode(WorldPackets::Party::PartyInviteClient&p){++invites;pending.leader=owner->guid;ObjectAccessor::FindPlayer(p.TargetGUID)->invite=owner->group?owner->group:&pending;}
struct Profile{std::unordered_map<std::string,float> Personality{{"sociability",1}};std::unordered_set<std::string>PrimaryProfessions{"skinning"};}profile;
struct Lua{Profile*GetAdventureProfile(unsigned){return &profile;}}lua;auto sClientlessLuaEngine=&lua;
struct Combat{bool Busy(Player*){return false;}}combat;auto sAdventureCombatMgr=&combat;
namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}}
float frand(float,float){return 0;}unsigned urand(unsigned a,unsigned){return a;}
'''+header+r'''
struct Position{void Relocate(float,float,float){}};
struct Location{unsigned ZoneId=1519,AreaId=1519;std::string Name="Stormwind";struct{unsigned MapId=0;float X=0,Y=0,Z=0;}Point;}location;
struct Locations{bool levelEligible=true;Location*Find(std::string const&){return &location;}bool LevelEligible(Player*,Location const&,std::string const&,bool){return levelEligible;}}locations;auto sAdventureLocationMgr=&locations;
struct Areas{bool allowed=true;bool AllowsPosition(Player*,unsigned,Position const&){return allowed;}}areas;auto sAdventureAreaMgr=&areas;
struct Behavior{std::unordered_map<unsigned,AdventureGroupPlan> plans;AdventureGroupPlan GroupPlan(Player*p){return plans[p->guid.id];}unsigned arms=0;void ArmGroupDeparture(Player*,AdventureGroupPlan const&){++arms;}}behavior;auto sAdventureBehaviorMgr=&behavior;
struct GroupTravel {unsigned follows=0;bool Follow(Player*){++follows;return true;}}groupTravel;auto sAdventureGroupTravelMgr=&groupTravel;
int messages=0;
void AdventureGroupMgr::Speak(Player*,std::string const&){++messages;}
bool BattlegroundCompatible(Player*,Player*){return true;}
'''
code+=r'''
void AdventureGroupMgr::UpdateBrowser(Player*){}
bool AdventureGroupMgr::Merge(Player*,AdventureGroupPlan const&){return false;}
bool AdventureGroupMgr::NeedsRole(Player*,Player*){return true;}
void AdventureGroupMgr::MaybeRunFormationManager(time_t){}
bool AdventureGroupMgr::Complete(Player*p){auto group=p->GetGroup();if(!group)return false;auto plan=sAdventureBehaviorMgr->GroupPlan(p);return group->GetMembersCount()==(plan.Capacity?plan.Capacity:AdventureGroupPolicy::Capacity(plan.Forming?plan.NextIntent:plan.Intent));}
'''
code+=r'namespace lfg{constexpr int LFG_STATE_QUEUED=1;} struct LfgMgr{int GetState(ObjectGuid guid){auto p=ObjectAccessor::FindPlayer(guid);return p&&p->queued?1:0;}}lfgMgr;auto sLFGMgr=&lfgMgr;'
code+=fun('float Sociability(')+'\n'+fun('bool ProfessionCompatible(')+'\n'+fun('bool Ready(')+'\n'+fun('std::string Activity(')+'\n'+fun('unsigned Capacity(')
for name in ['std::string AdventureGroupMgr::RecruitmentText(', 'bool AdventureGroupMgr::RecruitPlayer(', 'bool AdventureGroupMgr::BotLed(', 'bool AdventureGroupMgr::FollowMember(', 'bool AdventureGroupMgr::Forming(', 'bool AdventureGroupMgr::PlannedTransition(', 'bool AdventureGroupMgr::CanInvite(', 'bool AdventureGroupMgr::WaitingForBrowserGroup(', 'bool AdventureGroupMgr::Accept(', 'void AdventureGroupMgr::CheckFullGroup(', 'void AdventureGroupMgr::Update(', 'void AdventureGroupMgr::Invite(', 'void AdventureGroupMgr::RequestPlayerAdmission(', 'bool AdventureGroupMgr::PlayerLeadership(', 'bool AdventureGroupMgr::EnforceCapacity(']:code+='\n'+fun(name)
code+=r'''
int main(){
using namespace AdventureGroupPolicy;
// Joined membership triggers gathering; pending invitations alone do not.
{AdventureGroupMgr check;Player one,two,human;one.guid={801};two.guid={802};human.guid={803};human.session.clientless=false;
Group g;g.leader=one.guid;g.count=4;one.group=two.group=&g;
Member second{&two},first{&one,&second};g.members=&first;
ObjectAccessor::players={{one.guid,&one},{two.guid,&two},{human.guid,&human}};
behavior.plans[801]={true,true,"groupformation","city","instance","destination",3600};behavior.plans[802]=behavior.plans[801];
unsigned before=behavior.arms;check._records[two.guid].Inviter=one.guid;check._records[two.guid].InviteUntil=200;
check.CheckFullGroup(&one);assert(behavior.arms==before);
g.count=5;check.CheckFullGroup(&one);assert(behavior.arms==before+2);
// A real player may lead the full group; forming and one-time departure exemption still work.
g.leader=human.guid;human.group=&g;before=behavior.arms;
assert(check.Forming(&one));check.CheckFullGroup(&one);assert(behavior.arms==before+2);
behavior.plans[801].Intent="instance";behavior.plans[801].Forming=false;behavior.plans[801].EarlyDeparture=true;
assert(check.PlannedTransition(&one)&&!check.PlannedTransition(&one));
// Ten-player native raids fill at ten; the twenty-bot cap still applies to larger raids.
behavior.plans[801]={true,true,"groupformation","city","raid","destination",3600};behavior.plans[801].Capacity=10;
g.count=9;before=behavior.arms;check.CheckFullGroup(&one);assert(behavior.arms==before);
g.count=10;check.CheckFullGroup(&one);assert(behavior.arms==before+2);
g.leader=one.guid;behavior.plans[801].Mode=2;check._records[one.guid].SearchAt=10000;
check.Update(&one);assert(one.difficulty==2&&g.raid); // native heroic10, no hardcoded25-normal reset
ObjectAccessor::players.clear();behavior.plans.clear();}
{AdventureGroupMgr check;Player leader,member;leader.guid={901};member.guid={902};Group party;party.leader=leader.guid;leader.group=member.group=&party;
ObjectAccessor::players[leader.guid]=&leader;ObjectAccessor::players[member.guid]=&member;
behavior.plans[leader.guid.id]={true,true,"groupformation","capital","instance","target"};
assert(!check.FollowMember(&member)&&groupTravel.follows==0);
behavior.plans[leader.guid.id]={true,false,"instance","target"};
assert(check.FollowMember(&member)&&groupTravel.follows==1);
ObjectAccessor::players.clear();behavior.plans.clear();}
AdventureBehaviorPolicy::Day day;std::mt19937 random(42);
for(unsigned i=0;i<24;++i)day[i]={i%2?"instance":"social",std::to_string(i)};
Prepare(day,random,1,[]{return std::string("capital");});
for(unsigned h=0;h<24;++h)if(day[h].Intent=="groupformation")assert(h<23&&Eligible(day[h+1].Intent)&&day[h].Location=="capital");
assert(Capacity("instance")==5&&Capacity("raid")==20&&Capacity("social")>5);
assert(Compatible("instance","instance",60,65)&&!Compatible("instance","raid",60,60));
assert(!Compatible("raid","raid",60,66));
AdventureGroupMgr mgr;Player a,b;a.guid={29};b.guid={31};b.zone=1537;b.distance=10000;
ObjectAccessor::players={{a.guid,&a},{b.guid,&b}};
behavior.plans[29]={true,true,"groupformation","stormwind","instance","deadmines",3600};
behavior.plans[31]={true,true,"groupformation","ironforge","instance","shadowfang",3600};
mgr._records[a.guid]={};mgr._records[b.guid]={};
// Remote capital recruitment accepts different original targets/meeting points.
mgr.Update(&a);assert(invites==1&&b.invite&&b.invite->leader==a.guid&&messages>=1);
assert(mgr.Accept(&b,&a));
behavior.plans[31].NextIntent="raid";assert(!mgr.Accept(&b,&a));behavior.plans[31].NextIntent="instance";
b.team=1;assert(!mgr.Accept(&b,&a));b.team=0;
b.combat=true;assert(!mgr.Accept(&b,&a));b.combat=false;
// Native party cap and pending reservations prevent sixth members.
Group party;party.leader=a.guid;party.count=5;a.group=&party;
assert(!mgr.Accept(&b,&a));b.invite=nullptr;mgr._records[b.guid].InviteUntil=0;
mgr._records[a.guid].SearchAt=0;mgr.Update(&a);assert(invites==1);
// Existing group recruits and lone bot requests membership without replacing leader.
party.count=3;mgr._records[a.guid].Partners.clear();mgr._records[b.guid].Partners.clear();
mgr._records[b.guid].SearchAt=0;mgr.Update(&b);assert(invites==2&&b.invite==&party&&party.leader==a.guid);
assert(mgr.Accept(&b,&a));
// Intent-hour transition is exempt exactly once, no permanent exit exemption.
b.group=&party;behavior.plans[31].Intent="instance";behavior.plans[31].Forming=false;
assert(mgr.PlannedTransition(&b));assert(!mgr.PlannedTransition(&b));
// Raid native conversion and twentieth-member boundary.
b.group=nullptr;b.invite=nullptr;a.group=&party;party.count=19;behavior.plans[29].NextIntent="raid";behavior.plans[29].Mode=1;
behavior.plans[31]={true,true,"groupformation","ironforge","raid","otherraid",3600};
mgr._records[a.guid].SearchAt=0;mgr._records[a.guid].Partners.clear();mgr._records[b.guid].Partners.clear();mgr._records[b.guid].InviteUntil=0;
mgr.Update(&a);assert(a.difficulty==RAID_DIFFICULTY_25MAN_NORMAL&&party.raid&&invites==3&&mgr.Accept(&b,&a));
party.count=20;assert(!mgr.Accept(&b,&a));
// Queued bots remain passive but accept compatible formation invitations.
{AdventureGroupMgr waiting;Player leader,member;leader.guid={950};member.guid={951};
ObjectAccessor::players[leader.guid]=&leader;ObjectAccessor::players[member.guid]=&member;
behavior.plans[950]={true,true,"groupformation","capital","instance","target",3600};
behavior.plans[951]={true,false,"instance","target",{}, {},3600};member.queued=true;
waiting._records[leader.guid]={};waiting._records[member.guid]={};int before=invites;
waiting.Update(&member);assert(invites==before);
waiting.Update(&leader);assert(invites==before+1&&member.GetGroupInvite());
profile.Personality["sociability"]=0;assert(waiting.Accept(&member,&leader));
locations.levelEligible=false;assert(!waiting.Accept(&member,&leader));locations.levelEligible=true;
member.level=30;assert(!waiting.Accept(&member,&leader));member.level=60;
behavior.plans[950].NextIntent="raid";assert(!waiting.Accept(&member,&leader));
// The same passive admission applies to PvP queues, but never steals an active BG offer.
behavior.plans[950].NextIntent="pvp";behavior.plans[951].Intent="pvp";
member.queued=false;member.bgqueued=true;assert(waiting.WaitingForBrowserGroup(&member));
member.invite=nullptr;waiting._records.clear();waiting._records[leader.guid]={};waiting._records[member.guid]={};
profile.Personality["sociability"]=1;int bgBefore=invites;
waiting.Update(&member);assert(invites==bgBefore);
waiting.Update(&leader);assert(invites==bgBefore+1&&member.GetGroupInvite());
profile.Personality["sociability"]=0;assert(waiting.Accept(&member,&leader));
member.bgoffer=true;assert(!waiting.WaitingForBrowserGroup(&member)&&!waiting.Accept(&member,&leader));
member.bgoffer=false;member.bg=true;assert(!waiting.WaitingForBrowserGroup(&member));member.bg=false;
member.bgqueued=false;assert(!waiting.WaitingForBrowserGroup(&member));
profile.Personality["sociability"]=1;ObjectAccessor::players.erase(leader.guid);ObjectAccessor::players.erase(member.guid);
invites=before;}
// Solo recruitment outside formation requires actual local contact.
a.group=nullptr;b.invite=nullptr;behavior.plans[29]={true,false,"social","city",{}, {},3600};behavior.plans[31]=behavior.plans[29];
mgr._records.clear();mgr._records[a.guid]={};mgr._records[b.guid]={};mgr.Update(&a);assert(invites==3);
b.zone=a.zone;b.distance=10;mgr._records[a.guid].SearchAt=0;mgr.Update(&a);assert(invites==4);
// When a large raid changes to a smaller activity, excess bots leave safely.
party.count=5;party.slots={{{29}},{{40}},{{41}},{{31}},{{42}}};party.leader=a.guid;b.group=&party;
behavior.plans[29].Intent="adventure";assert(mgr.EnforceCapacity(&b)&&!b.group&&party.count==4);
behavior.plans[29].Intent="social";
// Explicit human invite to a grouped bot offers admission, then native leadership.
Player human;human.guid={21};human.session.clientless=false;ObjectAccessor::players[human.guid]=&human;
a.group=&party;party.leader=a.guid;party.count=2;
mgr.RequestPlayerAdmission(&a,&human);assert(invites==5&&human.invite==&party);
human.group=&party;Member humanSlot;humanSlot.player=&human;party.members=&humanSlot;
mgr.Update(&a);assert(party.leader==human.guid&&!mgr.BotLed(&a));
// Content checks and level ranges apply before any native invitation.
human.group=nullptr;human.invite=nullptr;a.level=60;human.level=54;
assert(!mgr.CanInvite(&a,&human));human.level=55;locations.levelEligible=false;
assert(!mgr.CanInvite(&a,&human));locations.levelEligible=true;areas.allowed=false;
assert(!mgr.CanInvite(&a,&human));areas.allowed=true;human.level=60;
// A player must not bypass the level limit by targeting another bot in a group.
party.leader=a.guid;a.group=&party;a.level=80;b.level=85;b.group=&party;human.level=75;
party.slots={{{29}},{{31}}};
assert(!mgr.CanInvite(&a,&human));int beforeAdmission=invites;
mgr.RequestPlayerAdmission(&b,&human);assert(invites==beforeAdmission);
a.level=b.level=human.level=60;party.slots.clear();
// Inform nearby humans of the actual NEXT destination before invitation.
{mgr={};Player human;human.guid={999};human.session.clientless=false;
ObjectAccessor::players[human.guid]=&human;a.group=nullptr;a.people={&human};
behavior.plans[a.guid.id]={true,true,"groupformation","capital","raid","target"};
int before=invites;assert(mgr.RecruitPlayer(&a,"raid"));
assert(invites==before+1&&human.invite&&a.whisper.find("Schlachtzug")!=std::string::npos&&a.whisper.find("Stormwind")!=std::string::npos);
human.invite=nullptr;assert(!mgr.RecruitPlayer(&a,"raid")); // shared cooldown even after decline
GameTime::now+=601;human.afk=true;assert(!mgr.RecruitPlayer(&a,"raid"));
human.afk=false;human.team=1;assert(!mgr.RecruitPlayer(&a,"raid"));
human.team=0;human.group=&pending;assert(!mgr.RecruitPlayer(&a,"raid"));}
}
'''
with tempfile.TemporaryDirectory() as d:
p=Path(d);(p/'groups.cpp').write_text(code)
subprocess.run(['g++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'groups.cpp'),'-o',str(p/'groups')],check=True)
subprocess.run([str(p/'groups')],check=True)
print('Production recruitment: capitals, different goals, native inviter leadership, joining existing groups, capacity/reservations, raid conversion, planned transition and local contact passed.')