Files
Viano-Core/tests/clientless/test_adventure_group_travel.py
T
Nico 65e4abb0b9 Fix shared flight trail replacement and safe follower rejoining
Publish replacement landing targets, bound orphaned trail waits, and plan safe connections from the follower position. Support shared-route regroup landings and preserve catch-up flights after the leader lands. Add geometry, lifecycle and group travel regressions.
2026-11-03 19:59:56 +01:00

172 lines
18 KiB
Python

"""Exercise production group formation, safety, transition queue and separation."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/AdventureGroupTravelMgr.cpp').read_text()
def method(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]
h=(root/'src/server/game/World/AdventureGroupTravelMgr.h').read_text()
h='\n'.join(v for v in h.splitlines() if not v.startswith('#include')).replace('private:','public:')
code=r'''
#include "AdventureGroupTravelPolicy.h"
#include <algorithm>
#include <cassert>
#include <cmath>
#include <ctime>
#include <deque>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
#include <set>
using uint32=unsigned;
#define TC_GAME_API
#define TC_LOG_WARN(...) ((void)0)
constexpr float INVALID_HEIGHT=-100000;
constexpr int LINEOFSIGHT_ALL_CHECKS=0,CMSG_AREATRIGGER=0;
namespace VMAP{enum class ModelIgnoreFlags{None};}
struct ObjectGuid{unsigned id=0;unsigned GetCounter()const{return id;}void Clear(){id=0;}explicit operator bool()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 Position{
float m_positionX=0,m_positionY=0,m_positionZ=0,o=0;Position()=default;Position(float x,float y,float z=0,float a=0):m_positionX(x),m_positionY(y),m_positionZ(z),o(a){}
void Relocate(Position const*p){*this=*p;}void Relocate(float x,float y,float z,float a=0){*this={x,y,z,a};}
float GetPositionX()const{return m_positionX;}float GetPositionY()const{return m_positionY;}float GetPositionZ()const{return m_positionZ;}float GetOrientation()const{return o;}
float GetExactDist(Position const&p)const{return std::hypot(std::hypot(m_positionX-p.m_positionX,m_positionY-p.m_positionY),m_positionZ-p.m_positionZ);}
float GetExactDist(Position const*p)const{return GetExactDist(*p);}float GetExactDist(float x,float y,float z)const{return GetExactDist(Position{x,y,z});}
float GetExactDist2d(Position const&p)const{return std::hypot(m_positionX-p.m_positionX,m_positionY-p.m_positionY);}
};
struct Map{bool cliff=false,los=true,narrow=false;bool IsDungeon(){return false;}
float GetHeight(unsigned phase,float x,float y,float,bool,float){assert(phase==169);if(cliff && x>4 && x<15)return -1000;if(narrow && std::fabs(y)>2)return -1000;return 0;}
bool isInLineOfSight(unsigned,float,float,float,float,float,float,int,VMAP::ModelIgnoreFlags){return los;}
}map;
struct Session{bool clientless=false;bool IsClientless(){return clientless;}void HandleAreaTriggerOpcode(struct WorldPacket&){}};
struct Group{ObjectGuid leader{21};struct Slot{ObjectGuid guid;};std::vector<Slot>slots{{{21}},{{26}},{{27}}};ObjectGuid GetLeaderGUID(){return leader;}auto const&GetMemberSlots(){return slots;}}group;
struct Player:Position{
ObjectGuid guid;bool alive=true,combat=false,teleport=false,charmed=false,flying=false,falling=false,mounted=false,moving=false,taxi=false,transport=false,phase=true,visible=true;unsigned mapid=0;Group*g=&group;Session session;
Player(unsigned id):guid{id}{}ObjectGuid GetGUID(){return guid;}Group*GetGroup(){return g;}Map*GetMap(){return &map;}unsigned GetMapId(){return mapid;}unsigned GetPhaseShift(){return 169;}
Session*GetSession(){return &session;}bool IsAlive(){return alive;}bool IsInCombat(){return combat;}bool IsBeingTeleported(){return teleport;}bool IsCharmed(){return charmed;}
bool CanFly(){return mounted && flightCapable;}bool flightCapable=false;bool IsFlying(){return flying;}bool IsFalling(){return falling;}bool IsMounted(){return mounted;}bool isMoving(){return moving;}bool IsInFlight(){return taxi;}bool GetTransport(){return transport;}
bool IsInMap(Player*p){return mapid==p->mapid;}bool IsInPhase(Player*p){return phase && p->phase;}bool CanSeeOrDetect(Player*p){return visible && p->visible;}bool IsInAreaTriggerRadius(void*){return false;}
};
struct WorldPacket{WorldPacket(int,int){}template<class T>WorldPacket&operator<<(T){return *this;}};
struct GameObject{bool IsInPhase(Player*){return true;}void Use(Player*){}};
namespace ObjectAccessor{std::unordered_map<ObjectGuid,Player*>players;Player*FindPlayer(ObjectGuid g){auto it=players.find(g);return it==players.end()?nullptr:it->second;}GameObject*GetGameObject(Player&,ObjectGuid){return nullptr;}}
namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}}
struct Areas{void*LookupEntry(unsigned){return nullptr;}}sAreaTriggerStore;
enum class ClientlessActionExecutionState{Moving,SelectingTarget,Completed,Failed};
struct Executor{struct Status{ClientlessActionExecutionState State=ClientlessActionExecutionState::Completed;}status;unsigned clears=0,adapters=0;auto GetStatus(unsigned){return &status;}bool RequestStopAtNode(unsigned){return true;}void Clear(unsigned){++clears;status.State=ClientlessActionExecutionState::Completed;}void SetGroundPathAdapter(unsigned,std::function<bool(Player*,std::vector<Position>&)> adapter){adapters+=adapter?1:-1;}}executor;auto sClientlessActionExecutorMgr=&executor;
struct ClientlessAction{std::string Name;std::unordered_map<std::string,std::string>Parameters;};
struct Core{unsigned queued=0;std::vector<std::string>nodes;unsigned GetQueuedActionCount(unsigned){return queued;}bool QueueAction(unsigned,ClientlessAction action){++queued;nodes.push_back(action.Parameters["node"]);return true;}void ClearQueuedActions(unsigned){queued=0;}}core;auto sClientlessPlayerMgr=&core;
enum class ClientlessTravelState{Moving,Arrived,Stuck,PathRejected};
struct ClientlessPathDiagnostics{bool Complete=true,Suspicious=false;float PathLength=20;};
struct Travel{struct Status{ClientlessTravelState State=ClientlessTravelState::Arrived;Position Target;}status;bool valid=true;unsigned starts=0,cancels=0,validators=0;std::vector<Position>targets;
auto GetStatus(unsigned){return &status;}bool AnalyzePath(Player*,Position const&,ClientlessPathDiagnostics&d){d.Complete=valid;return valid;}
bool StartTravel(Player*,Position const&target,bool=true,bool=false){++starts;targets.push_back(target);status.Target=target;status.State=ClientlessTravelState::Moving;return true;}
std::vector<Position>const*GetActivePath(unsigned){return nullptr;}void CancelTravel(unsigned){++cancels;status.State=ClientlessTravelState::Arrived;}void SetPathValidator(unsigned,std::function<bool(Player*,std::vector<Position>const&)> validator){validators+=validator?1:-1;}
}travel;auto sClientlessTravelMgr=&travel;
struct PathGenerator{struct Point{float x,y,z;};Player*p;std::vector<Point>points;PathGenerator(Player*who):p(who){}bool CalculatePath(float x,float y,float z){points={{p->GetPositionX(),p->GetPositionY(),p->GetPositionZ()},{x,y,z}};return travel.valid;}auto const&GetPath(){return points;}};
struct Graph{bool FindNearestNodeToPosition(unsigned,float,float,float,std::string&n,float&distance){n="nearest";distance=5;return true;}}graph;auto sClientlessTravelGraphMgr=&graph;
enum class ClientlessTransportPhase{Waiting,Riding,Arrived,Failed};
struct TransportStatus{ClientlessTransportPhase Phase=ClientlessTransportPhase::Failed;};struct Stop{unsigned Map=0;struct Point{Position Pos;};std::vector<Point>Points{{{0,0,0}}};};
struct Transport{TransportStatus status;auto GetStatus(unsigned){return &status;}Stop*GetStop(std::string const&){return nullptr;}bool Start(Player*,std::string const&,std::string const&,std::string&){return false;}}transport;auto sClientlessTransportMgr=&transport;
enum class ClientlessFlightState{Mounting,Flying,Arrived,Failed};
struct Flight{struct Status{ClientlessFlightState State=ClientlessFlightState::Failed;bool FollowingPlayer=false;}status;bool enabled=false;unsigned follows=0,lands=0;auto GetStatus(unsigned){return &status;}bool Enabled(Player*){return enabled;}bool PrepareFollowingMount(Player*){return false;}
bool Follow(Player*,Position const&,std::string&){++follows;status.State=ClientlessFlightState::Flying;status.FollowingPlayer=true;return true;}bool LandFollowing(Player*){++lands;status.FollowingPlayer=false;return true;}}flight;auto sClientlessFlightMgr=&flight;
struct Profile{float GroundMountMinDistance=60;};struct Lua{Profile profile;auto GetAdventureProfile(unsigned){return &profile;}void PrepareGroundMount(Player*p,float,char const*){p->mounted=true;}void DismountGroundMount(Player*p,char const*){p->mounted=false;}}lua;auto sClientlessLuaEngine=&lua;
struct AreaAccess{bool allowed=true;bool AllowsPosition(Player*,unsigned,Position const&){return allowed;}}access;auto sAdventureAreaMgr=&access;
struct ChatHandler{void SendSysMessage(char const*){}template<class...T>void PSendSysMessage(char const*,T...){}};
'''+h+r'''
AdventureGroupTravelMgr* AdventureGroupTravelMgr::instance(){static AdventureGroupTravelMgr m;return &m;}
AdventureGroupTravelMgr::Leader observed;
void AdventureGroupTravelMgr::SampleLeader(Player*,Leader&state,time_t){state=observed;}
'''+method(' bool Executing(')+method(' Position OffsetPoint(')
for sig in ['void AdventureGroupTravelMgr::Observe(', 'bool AdventureGroupTravelMgr::Airborne(', 'bool AdventureGroupTravelMgr::Protected(', 'bool AdventureGroupTravelMgr::Separated(', 'bool AdventureGroupTravelMgr::GroundPoint(', 'bool AdventureGroupTravelMgr::SafePath(', 'unsigned AdventureGroupTravelMgr::Columns(', 'Position AdventureGroupTravelMgr::Formation(', 'bool AdventureGroupTravelMgr::Route(', 'bool AdventureGroupTravelMgr::JourneyFollow(', 'bool AdventureGroupTravelMgr::Follow(', 'bool AdventureGroupTravelMgr::AdaptRoad(', 'bool AdventureGroupTravelMgr::ValidateGroundPath(', 'void AdventureGroupTravelMgr::Clear(']:code+=method(sig)+'\n'
code+=r'''
int main(){
using namespace AdventureGroupTravelPolicy;
for(unsigned columns:{1,2,3})for(bool moving:{false,true})for(bool mounted:{false,true})for(bool flying:{false,true}){
std::set<std::tuple<float,float,float>>points;for(unsigned slot=0;slot<20;++slot){auto p=Formation(slot,columns,moving,mounted,flying);assert(points.emplace(p.Behind,p.Side,p.Height).second);}
}
unsigned elapsed=0;for(unsigned i=0;i<1199;++i)elapsed=Separation(elapsed,500,true,false);assert(elapsed==599500);
assert(Separation(elapsed,500,true,true)==elapsed);assert(Separation(elapsed,500,true,false)==600000);assert(Separation(600000,500,false,false)==0);
Player leader(21),a(26),b(27);a.session.clientless=b.session.clientless=true;leader.m_positionX=20;
ObjectAccessor::players={{{21},&leader},{{26},&a},{{27},&b}};
AdventureGroupTravelMgr mgr;mgr.Observe(&a,500);mgr.Observe(&b,500);assert(mgr.Follow(&a));auto first=travel.targets.back();travel.status.State=ClientlessTravelState::Arrived;assert(mgr.Follow(&b));auto second=travel.targets.back();assert(first.GetExactDist(second)>2);
// A valid-looking MMAP endpoint beyond a hole must never authorize a shortcut.
travel.status.State=ClientlessTravelState::Arrived;map.cliff=true;unsigned starts=travel.starts;assert(mgr.Follow(&a));assert(travel.starts==starts);map.cliff=false;
// Formation compresses on narrow ground; this is checked against phased terrain.
mgr._widths.clear();map.narrow=true;assert(mgr.Columns(&a,&leader)==1);map.narrow=false;mgr._widths.clear();assert(mgr.Columns(&a,&leader)==3);
travel.status.State=ClientlessTravelState::Arrived;leader.mounted=true;mgr.Follow(&a);assert(a.mounted);leader.mounted=false;a=Player(26); // retain a clean origin
// A ground-only bot waits safely when the leader gains excessive height.
leader.flying=true;leader.m_positionZ=80;starts=travel.starts;mgr.Follow(&a);assert(travel.starts==starts);leader.flying=false;leader.m_positionZ=0;
// A shared/action-owned catch-up corridor survives a leader landing ahead.
flight.status.State=ClientlessFlightState::Flying;flight.status.FollowingPlayer=false;
mgr.Observe(&a,1);leader.flying=false;auto previousLands=flight.lands;
assert(mgr.Follow(&a)&&flight.lands==previousLands);
flight.status.State=ClientlessFlightState::Arrived;mgr.Follow(&a);
// A native-capable follower uses flight, and safely lands after a kick.
flight.enabled=true;leader.flying=true;mgr.Follow(&a);assert(flight.follows==1 && mgr.Airborne(&a));a.g=nullptr;mgr.Follow(&a);assert(flight.lands==1);a.g=&group;flight.status.State=ClientlessFlightState::Arrived;leader.flying=false;flight.enabled=false;mgr.Follow(&a);
// A stale owned ground catch-up route cannot suppress a visible-phase
// human leader taking off, even beyond normal visual detection distance.
leader.mounted=leader.flightCapable=leader.flying=true;leader.visible=false;
flight.enabled=true;flight.status.State=ClientlessFlightState::Failed;
mgr._records.at(26).RouteOwned=true;executor.status.State=ClientlessActionExecutionState::Moving;
auto cleared=executor.clears;auto flown=flight.follows;
mgr.Follow(&a);assert(executor.clears==cleared+1&&flight.follows==flown+1);
// Matching a grounded flying mount must never be immediately undone by
// the ground-follow dismount branch.
leader.flying=false;leader.visible=true;a.mounted=a.flightCapable=true;
flight.status.State=ClientlessFlightState::Arrived;leader.m_positionX=8;
mgr.Follow(&a);assert(a.mounted);
leader.mounted=leader.flightCapable=false;a.mounted=a.flightCapable=false;
flight.enabled=false;leader.m_positionX=20;
// Taxi journey and its arrival grace pause distance counting. Completion must
// not protect unrelated later ground separation.
auto& state=observed;state.Kind=AdventureGroupTravelMgr::Journey::Taxi;state.Generation=1;state.Began=GameTime::now;state.To="taxi_42";state.GoalMap=1;state.Goal={1000,0,0};leader.taxi=true;leader.m_positionX=900;
mgr.Observe(&a,500);assert(mgr.Protected(&a));mgr.Follow(&a);assert(core.nodes.back()=="taxi_42");unsigned queued=core.queued;mgr.Follow(&a);assert(core.queued==queued);
leader.taxi=false;leader.mapid=1;leader.m_positionX=1000;state.Arrived=++GameTime::now;mgr.Observe(&a,500);assert(mgr.Protected(&a));
a.mapid=1;a.m_positionX=1000;mgr.Observe(&a,500);assert(mgr._records.at(26).Pending.empty() && !mgr.Protected(&a));
// Sequential portals retain the first transition until the follower arrives.
state.Kind=AdventureGroupTravelMgr::Journey::Portal;state.Generation=2;state.SourceMap=1;state.GoalMap=2;state.Goal={100,0,0};state.Began=++GameTime::now;mgr.Observe(&a,500);
state.Generation=3;state.SourceMap=2;state.GoalMap=3;state.Goal={200,0,0};mgr.Observe(&a,500);assert(mgr._records.at(26).Pending.size()==2 && mgr._records.at(26).Pending.front().GoalMap==2);
a.mapid=2;a.m_positionX=100;mgr.Observe(&a,500);assert(mgr._records.at(26).Pending.size()==1 && mgr._records.at(26).Pending.front().GoalMap==3);
// A fixed portal destination forbidden by the area catalog cannot be used,
// even when the follower is traveling with a player group leader.
access.allowed=false;
assert(!mgr.JourneyFollow(&a,mgr._records.at(26),mgr._records.at(26).Pending.front()));
access.allowed=true;
// A blocked/unrecognized trip cannot grant unlimited separation protection.
mgr._records.at(26).Pending.clear();state.Kind=AdventureGroupTravelMgr::Journey::None;state.Generation=4;leader.mapid=0;leader.m_positionX=1000;a.mapid=0;a.m_positionX=0;executor.status.State=ClientlessActionExecutionState::Completed;
for(unsigned i=0;i<1200;++i){if(i%2==0)++GameTime::now;mgr.Observe(&a,500);}assert(mgr.Separated(&a));leader.m_positionX=10;mgr.Observe(&a,500);assert(!mgr.Separated(&a));
// Nearby targets belonging to adjacent rows are distinct: a 2yd change
// cannot reuse an old spline that ends at a different formation position.
leader.m_positionX=20;leader.m_positionZ=0;leader.moving=false;
travel.status.State=ClientlessTravelState::Arrived;mgr.Follow(&a);
auto oldTarget=travel.status.Target;leader.m_positionX+=2;auto startsBefore=travel.starts;
mgr.Follow(&a);assert(travel.starts==startsBefore+1&&travel.status.Target.GetExactDist(oldTarget)>1.9f);
// A short stop during an executing route still preserves row distance.
leader.m_positionX=6;leader.moving=false;executor.status.State=ClientlessActionExecutionState::Moving;
std::vector<Position>shortRoad{{0,0,0},{2,0,0},{4,0,0},{6,0,0},{8,0,0}};
assert(mgr.AdaptRoad(&a,shortRoad)&&shortRoad.back().GetPositionX()<6);
// Boarding is released once the leader actually starts the native taxi.
leader.taxi=true;shortRoad={{0,0,0},{2,0,0},{4,0,0},{6,0,0},{8,0,0}};
assert(mgr.AdaptRoad(&a,shortRoad)&&shortRoad.back().GetPositionX()==8);
leader.taxi=false;executor.status.State=ClientlessActionExecutionState::Completed;
// Recorded roads are bounded and validated, never a spline through a cliff.
std::vector<Position>road;for(unsigned x=0;x<=100;x+=10)road.emplace_back(x,0,0);leader.moving=false;map.cliff=true;assert(!mgr.AdaptRoad(&a,road));map.cliff=false;road.clear();for(unsigned x=0;x<=100;x+=10)road.emplace_back(x,0,0);assert(mgr.AdaptRoad(&a,road) && road.back().GetPositionX()<=60);
// Leaving a group clears formation-owned adapters and validators before the
// next autonomous route. This prevents valid MMAP paths being rejected by a
// stale group formation safety check.
assert(mgr._records.count(26)&&executor.adapters==2&&travel.validators==2);
auto cancelsBefore=travel.cancels;a.g=nullptr;mgr.Observe(&a,500);
assert(!mgr._records.count(26)&&executor.adapters==1&&travel.validators==1&&travel.cancels==cancelsBefore+1);
}
'''
with tempfile.TemporaryDirectory() as d:
p=Path(d);(p/'test.cpp').write_text(code)
subprocess.run(['g++','-std=c++17','-I',str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True)
subprocess.run([str(p/'test')],check=True)
print('Production group travel: distinct slots, phase169 cliff safety, width, mounts, flight/kick landing, taxi grace, portal queue, bounded separation and recorded-road validation passed')