From b3d2d69251f8bf58fbdd6a374ee2d16fdabbdb71 Mon Sep 17 00:00:00 2001 From: nico Date: Thu, 5 Nov 2026 13:06:25 +0100 Subject: [PATCH] Fix stuck group followers and druid mount preparation --- .../2026-11-05-druid-mount-preparation.md | 31 ++++ .../2026-11-05-group-follow-recovery.md | 47 ++++++ doc/clientless/SESSION-RESUME.md | 128 ++++++++++++++++ .../game/World/AdventureGroupTravelMgr.cpp | 137 +++++++++++++++++- .../game/World/AdventureGroupTravelMgr.h | 12 +- .../game/World/AdventureGroupTravelPolicy.h | 6 + src/server/game/World/ClientlessFlightMgr.cpp | 13 +- src/server/game/World/ClientlessLuaEngine.cpp | 3 + .../game/World/ClientlessMountPreparation.h | 26 ++++ .../clientless/test_adventure_group_travel.py | 92 ++++++++++-- .../test_adventure_world_presence.py | 16 +- tests/clientless/test_group_flight_mount.py | 20 ++- 12 files changed, 502 insertions(+), 29 deletions(-) create mode 100644 doc/clientless/2026-11-05-druid-mount-preparation.md create mode 100644 doc/clientless/2026-11-05-group-follow-recovery.md create mode 100644 src/server/game/World/ClientlessMountPreparation.h diff --git a/doc/clientless/2026-11-05-druid-mount-preparation.md b/doc/clientless/2026-11-05-druid-mount-preparation.md new file mode 100644 index 0000000000..c91ab5819a --- /dev/null +++ b/doc/clientless/2026-11-05-druid-mount-preparation.md @@ -0,0 +1,31 @@ +# Druidengestalten vor dem Aufsitzen + +Talzul (97, Druide Level 66) konnte in der Höllenfeuerhalbinsel seinen +Flugmountzauber 32245 nicht starten. Die gespeicherte Katzengestalt (Aura 768) +wurde von der bisherigen Aufsitzvorbereitung nicht entfernt. + +`ClientlessMountPreparation::LeaveDruidForm` entfernt bei Druiden sämtliche +`SPELL_AURA_MOD_SHAPESHIFT`-Auren vor dem Aufsitzen. Es gibt keine Liste einzelner +Gestaltzauber: Katze, Bär und frühere Ränge, Reise-, Wasser-, Mondkin-, Baum- und +beide Flugformen fallen unter dieselbe Vorbereitung. Andere Klassen und +gewöhnliche Verwandlungs-/Begleitauren werden nicht verändert. + +Die Vorbereitung ist bei Bodenmounts, freien Reiseflügen und dem Flugmount zum +Gruppenfolgen eingebunden. Eine Druidenform wird bei Kampf, Tod, Flug, Sturz, +Wasser, Taxi, Transport, Fahrzeug oder laufendem Teleport nicht entfernt. +Die zuständige Bewegungslogik muss zunächst einen sicheren Zustand herstellen. +Ein bereits aktives Flugmount wird wie bisher beibehalten. Flugberechtigung, +gelernte Mountzauber, Gebietsfreigaben und native Zauberprüfungen bleiben aktiv. +Mountablehnungen beim freien Flug und Gruppenflug protokollieren jetzt zusätzlich +den konkreten `SpellCastResult`, die GUID und den Mountzauber. + +Die Tests `test_group_flight_mount.py` und `test_adventure_world_presence.py` +führen die gemeinsame Produktionsvorbereitung und die tatsächlichen Aufsitz- +methoden aus. Sie decken die genannten Gestalten, sicheren Formwechsel, +verweigerten Wechsel in Kampf/Flug sowie bestehende Mount-, Worgen- und +Flugberechtigungsregeln ab. Zusätzlich bestanden +`test_flight_departure.py` und `test_adventure_group_travel.py`. + +Build, Installation, Neustart und Livebefund stehen im aktuellen +`SESSION-RESUME.md`; lokale Belege liegen unter +`checkpoints/2026-11-05-druid-mount/`. diff --git a/doc/clientless/2026-11-05-group-follow-recovery.md b/doc/clientless/2026-11-05-group-follow-recovery.md new file mode 100644 index 0000000000..61cc431dc9 --- /dev/null +++ b/doc/clientless/2026-11-05-group-follow-recovery.md @@ -0,0 +1,47 @@ +# Gruppenfolge: MMAP-Korridore und Wiederherstellung festhängender Bots + +Der zusätzliche Sichtlinientest entfällt experimentell für vom lokalen +MMAP-Navigator akzeptierte Korridore. Boden-/Steigungsprüfung und Gebietsregeln +bleiben aktiv. Aufgezeichnete oder seitlich versetzte Graphensplines behalten +ihren Sichtlinientest, da sie keinen entsprechenden MMAP-Nachweis besitzen. + +`AdventureGroupTravelMgr::Observe` erkennt fehlgeschlagene Folgenavigation +oder mindestens 60 Sekunden ohne mehr als einen Yard Positionsfortschritt bei +tatsächlich angeforderter Bewegung. Ein bestätigter Fehler bleibt bis zur +Bewegung, zum Wechsel des relevanten Zustands oder zur erreichten Formation +gespeichert. Normales Warten, Gruppenbildung und Halten einer Flugformation +lösen keine Wiederherstellung aus. + +Die Teleportfreigabe setzt diesen Befund voraus: + +- Spieler als Anführer, offene Welt: mindestens 100 Yards Abstand oder + mindestens 60 Sekunden bestätigtes Festhängen. +- Bot als Anführer, offene Welt: mindestens 300 Sekunden Festhängen. +- Dungeon/Raid: beide müssen bereits in derselben konkreten Instanz und Phase + sein, der lokale Instanzeintritt muss verarbeitet sein und der Abstand muss + mindestens 100 Yards betragen. Dann entfällt die zusätzliche Wartefrist. + Der Eintritt und das normale Warten auf den Anführer werden nicht ersetzt. + +Vor jeder Wiederherstellung prüft die vorhandene Gebietsverwaltung Zielposition, +Gebiet und Zone auf Erlaubnis für diesen Bot. Instanzwechsel, BG-/Arenaeintritt, +laufende Teleports, Taxi, Transportmittel, Fahrzeuge, Sturz, Tod, Kampf des Bots +und sicherer Logout sperren die Wiederherstellung. Laufende Zauber werden nicht +für einen Teleport unterbrochen. + +Zu einem Anführer in der Luft darf der Bot erst mit aktivem Flugmount und +Flugberechtigung am Ziel teleportieren. Er darf vorher einen regulären +Aufsitzversuch starten. Bei Ablehnung oder nach zehn Sekunden ohne Mount wartet +er auf Bodenkontakt des Anführers. Nach einem erlaubten Luftteleport übernimmt +wieder die bestehende Flugfolgenavigation. Der Teleport wird als +`GROUP_FOLLOW_RECOVERY` mit Anführer, Instanz, Abstand und Fehlerdauer protokolliert. + +Automatisierte Prüfung: `test_adventure_group_travel.py` kompiliert und führt die +Produktionsmethoden einschließlich der Wiederherstellung aus. Neue Fälle decken +MMAP/LOS und beibehaltene Bodensicherheit, Distanz-/Zeitgrenzen, echte Bewegung, +absichtliches Warten, Gebietsablehnung, Transit, Logout, Instanzkopien, +Eintrittsgrenze und Mount-/Flugberechtigungen ab. Bestehende Tests für +Instanzgruppenschutz, Flugfolgen, Aufsitzen, gemeinsame Routen, Intentzustand und +Dungeonbrowser-Teleports wurden ebenfalls ausgeführt. + +Build-/Installations- und Live-Ergebnis: siehe aktuellen SESSION-RESUME.md. +Die vollständige Akzeptanzprüfung mit Spieler-Anführer erfolgt im Spiel. diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index a90778677a..07da8a1fbf 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,131 @@ +# 2026-11-05 – Commit des gesamten offenen Korrekturstands beauftragt + +Nutzer hat den lokalen Commit der Änderungen angefordert. Zusammengefasst sind +Gruppenfolge-Recovery inklusive experimenteller MMAP-LOS-Ausnahme sowie die +Aufsitzvorbereitung für alle Druidengestalten, zugehörige Tests und Berichte. +Beide Änderungen sind gebaut, installiert und nach Neustart geprüft; Livebefunde +und Grenzen stehen unten. Keine neuen Codeänderungen für den Commit, kein Push. + +--- + +# 2026-11-05 – Druidengestalten-Mountfix installiert, Talzul startet wieder + +Aktueller Nutzerauftrag: Talzul-Mountproblem für ALLE Druidengestalten fixen. +Neue gemeinsame ClientlessMountPreparation.h LeaveDruidForm entfernt Kategorie +SPELL_AURA_MOD_SHAPESHIFT nur bei Druiden am sicheren Boden, nicht in Kampf/ +Flug/Sturz/Wasser/Taxi/Transport/Fahrzeug/Teleport. Hook in GroundMount, +PrepareFollowingMount und Begin-Freiflug vor normalem Cast. Native Castchecks +und Berechtigungen unverändert; FLIGHT_MOUNT_REJECT loggt jetzt CastResult. +Keine Recoveryretryänderung in diesem Auftrag. Vorherige Gruppen-Recovery-WIP +ist installiert und bleibt erhalten. Kein Commit/Push beauftragt. +Tests group_flight_mount, adventure_world_presence (Produktionhelper+Methoden, +Katze/Bärenränge/Reise/Wasser/Mondkin/Baum/beideFlugformen), flight_departure, +adventure_group_travel PASS. Vollbuild-j10 PASS, Installation PASS nach vollständigem Prozess- UND tmux-Ende. +Loaderconf+nativ0+safeall beiBuildbeginn. AltPID25378 normaler shutdown10 erstnach +Buildende; 60.842s grace, 11 restlicheBots integriertFINALIZE,remaining0, +GDBexitednormally. Neuer passiverPID31410 frischesREADY+8085/8087 geprüft. +Loader vorher1 wiederhergestellt, alle vorher118Bots direktDBonlinebestätigt. +LiveTalzul97 zuerstnormal eingeloggt, direktangegriffen; Kampf regulärbeendet, +danach 12:23:20 FLIGHT_START spell32245 und FLIGHT_SEGMENT1 diagonalclimb. +Keine Operator-Form-/Intent-/Positionsänderung. Damit ursprüngliche Aufsitz- +blockade livebeseitigt; übrigeDruidengestaltenautomatisiert geprüft. +Belege checkpoints/2026-11-05-druid-mount/{before.json,build.log,shutdown.log, +install.log,startup-ready.log,startup.log,talzul-first.log,talzul-navigation.log, +roster-after.json,result.json}. Bericht2026-11-05-druid-mount-preparation.md. +Keine Commits/Push fürdiesen Auftrag; vorherige Gruppen-Recovery-WIP erhalten. + +--- + +# 2026-11-05 – Talzul97 untersucht, keine Code-/Zustandsänderung + +Nutzerauftrag nur untersuchen. Talzul Troll-Druide Lv66, solo, Map530 +Höllenfeuerhalbinsel(346.7,2235.6,118.998), Freizeitintent leisure, mehrere +Fallbackziele poi_tele_1370->1162->1037. Native Flight status: enabled1,riding225, +outdoors1, mountSpell32245, Failed "native mount spell rejected", segments0. +Probe: nearFeet118.998=Z, mounted0,canFly0,flying0,falling0,keinWasser. Damit +kein Flugkorridor-/Bodenabstandsproblem: scheitert schon beim normalen Cast. +Character-Auras gespeichert:768(Katzengestalt),24932,47179; letzteKämpfe nutzten +1082. Wahrscheinliche Ursache: Katzenform nach Kampf bleibt, Mountbegin entfernt +nur GroundMount/MOUNTED, nicht SHAPESHIFT. Native Spell CheckShapeshift blockiert +solche Mountcasts. Tatsächlicher SpellCastResult wird bei ClientlessFlightMgr +Begin umZeile564 NICHT geloggt, daher konkrete Ablehnungsnummer noch offen. +12:12:30 Start+5Recoveries selben Ort/Route innerhalb1Sekunde, blockedEdges0, +RECOVERY_FAILED danachIntentFallback30s. Fehler besteht schon vor letztemFix. +Fix bei Auftrag: normale Reisezustandsvorbereitung verlässt unvereinbare Form +vor Mountcast (keine getriggerten Spells/umgangene Berechtigung), exakten Cast- +Result loggen und deterministische Castablehnung nicht fünfmal sofort wiederholen. +Keine Teleports/Entzauberung/Intentänderung durchgeführt. Bestehende Gruppen- +Recovery Source-WIP und Doc-WIP sind erwartet und unverändert erhalten. + +--- + +# 2026-11-05 – Gruppenfolge-Recovery installiert und geprüft + +Aktueller Auftrag: experimentell LOS bei validiertem MMAP deaktivieren und +nachgewiesen festhängende Gruppenfolger bergen. Nutzer bestätigte außerhalb +von Instanzen Spielerführer 100 Yards ODER 60 Sekunden; Botführer 300 Sekunden. +Bereits gleiche konkrete Dungeon-/Raidinstanz: 100 Yards ohne Zusatzfrist, +keinen Instanzeintritt und kein normales Eingangswarten ersetzen. + +Implementiert in AdventureGroupTravelMgr/Policy: Positionsfortschritt >1 Yard, +expliziter Navigationsfehler oder 60 Sekunden angeforderte Bewegung ohne +Fortschritt; bestätigten Fehler bis echter Bewegung/Zustandswechsel erhalten. +Gebiet/Zone/Position über AdventureAreaMgr zwingend zulässig. Transit/Taxi/ +Fahrzeug/Portaltransfer, Sturz, Kampf, Tod, Casting und sicherer Logout sperren. +Luftziel nur mit aktivem Flugmount UND Ziel-Flugrecht, optional regulärer Mount- +Versuch (10s), sonst auf Leader-Boden warten. MMAP-Validator überspringt nur +zusätzliche LOS; Bodenprüfung und aufgezeichnete/verschobene Straßen-LOS bleiben. +Details doc/clientless/2026-11-05-group-follow-recovery.md. + +8 Regressionstests bestanden, letzter zusätzlicher Recoverytest ebenfalls. +Vollbuild -j10 und letzte Sourcefassung build-verified.log PASS, installiert. +Loader config/runtime0 und safe all bei jedem Buildstart; kein Shutdown vor +Buildende. Normaler Shutdown AltPID13497: grace60881ms, 16 restliche Bots durch +integriertes FINALIZE gesichert/ausgeloggt, remaining0, GDB exited normally. +Installation erst nach Worldserver-Prozessende; tmux/GDB endete unmittelbar +danach ebenfalls. Neuer passiver Start PID25378: frisches READY + beide Ports8085/8087, +Prozess lebt. Loader vorher1 wiederhergestellt; sämtliche118 vorherigen Bots +laut direktem DB-Abgleich wiederonline. Filterroster zählt117:155Renana online, +aber Loaderrotation state=replacing, nicht Logoutfehler. +Live: Torukan57 und Moggra87 haben vorher blockierten Unterstadtweg passiert, +beide jetzt um(1633,230,-43), vorher(1663,256,-62). Torukan NAV_COMPLETE +11:54:44, remaining0,repaths0,segments3. Keine Recoveryteleports dafür nötig. +Die Gruppe hat sich nach Neustart regulär neugebildet:87Leader statt130. +Teleportregeln vollständig automatisiert geprüft; gezielte Spieler-/Flug- und +Instanz-Akzeptanzprüfung durch Nutzer noch offen. +Belege checkpoints/2026-11-05-follow-recovery/{before.json,tests.log, +tests-recovery-final.log,build-verified.log,install.log,shutdown.log,startup.log}. +Kein Commit/Push für diesen Auftrag beauftragt; bestehende Diagnose-WIP erhalten. + +--- + +# 2026-11-05 11:09 – Summonfix committed; Torukan/Moggra read-only untersucht + +Nutzerauftragzuerstcommit,dannBotsprüfen:7f10f724f2 "Reject ownerless minion +summons before allocation" (2Cpp+Test+Bericht+Checkpoint5Dateien),keinPush. +DanachTorukan57Lv52undMoggra87Lv47nativuntersucht. Gruppe3LeaderDarlan130Lv48. +BeideinUnterstadtMap0(1663.455,256.192,-62.178), Leader(1632.014,347.887,-61.270). +AlleübernehmenRestintentLeader, nichtdiepersönlichenInstanzpläne; Zielwechselt +wegenLeaderFallbackzwischenGasthäusern(WestlPestlandeu.a328015/328422/328550). +KeinfreierFlugmöglichbeidenFollowern(riding150). KompletteMMAPpfadeberechnet, +aberAdventureGroupTravelMgr::ValidateGroundPathblockiertLOS: FollowerSegment7 +(1647.873,274.032,-60.197)->(1644.342,275.909,-61.183), LeaderSegment8eigene +Stelle. BeideAdaptRoadVersuche(auchSide0)abgewiesen. Alternativpfadzusätzlich +GroundSampleheightError3.102/floorStep3.463überLimits1.75/2.458. 5Recoveries, +failedEdgeDbId0/blockedEdges0=gleicherEinstiegwirdwiederholt; Followersdanach +"group destination unavailable locally; waiting for leader". Leaderauchblockiert. + +GroundPointändertbeiAdaptRoadalleWegpunktZaufGetHeight(z+3,dist18), danach +Ground+LOSCheckZ+1. VerdachtNativeMMAPwegwirddurchBodensnap/engeGeometrie +unbrauchbar, aberursprünglichePunkte/hitGeometrynochNICHTverglichen. Keine +pauschaleBehauptungLOSseiFalsePositive, keineGuardabschaltung. BeiFixauftrag +OriginalMMAP-/angepasstePunktevergleichenundlokaleAlternativenbehandeln. +NurDiagnose, keineTeleport/Gruppe/Intent/Codeänderung. Belegeignored +checkpoints/2026-11-05-torukan-moggra/{native-status.log,navigation.log,diagnosis.json}. +ServerPID13497weiteronline. AktuellerCheckpointnachCommitnaturgemäßneuesDoc-WIP. + +--- + # 2026-11-05 11:03 – Summon-Besitzerprüfung FERTIG installiert Nutzerauftragguard/loggingvorMinionerzeugungumgesetzt. Object.cppMapFactory+ diff --git a/src/server/game/World/AdventureGroupTravelMgr.cpp b/src/server/game/World/AdventureGroupTravelMgr.cpp index c2ca8e6c21..2c0d955a98 100644 --- a/src/server/game/World/AdventureGroupTravelMgr.cpp +++ b/src/server/game/World/AdventureGroupTravelMgr.cpp @@ -1,5 +1,6 @@ #include "AdventureGroupTravelMgr.h" #include "AdventureAreaMgr.h" +#include "AdventureGroupMgr.h" #include "AdventureGroupTravelPolicy.h" #include "ClientlessTravelMgr.h" #include "ClientlessTravelGraphMgr.h" @@ -143,11 +144,12 @@ void AdventureGroupTravelMgr::Observe(Player* player,uint32 diff) if(inserted.second) { sClientlessActionExecutorMgr->SetGroundPathAdapter(player->GetGUID().GetCounter(),[](Player* member,std::vector& path){return sAdventureGroupTravelMgr->AdaptRoad(member,path);}); - sClientlessTravelMgr->SetPathValidator(player->GetGUID().GetCounter(),[](Player* member,std::vector const& path){return sAdventureGroupTravelMgr->ValidateGroundPath(member,path);}); + sClientlessTravelMgr->SetPathValidator(player->GetGUID().GetCounter(),[](Player* member,std::vector const& path){return sAdventureGroupTravelMgr->ValidateGroundPath(member,path,true);}); } if(auto flight=sClientlessFlightMgr->GetStatus(player->GetGUID().GetCounter())) if(flight->State==ClientlessFlightState::Mounting || flight->State==ClientlessFlightState::Flying)record.FlightOwned=true; if(group->GetLeaderGUID()==player->GetGUID())return; + RecoverFollower(player,record); if(player->GetMap()->IsDungeon()) { record.Pending.clear();record.SeparationMs=0;record.ProtectedTravel=true; @@ -197,6 +199,123 @@ void AdventureGroupTravelMgr::Observe(Player* player,uint32 diff) record.SeparationMs=AdventureGroupTravelPolicy::Separation(record.SeparationMs,elapsed,!near,record.ProtectedTravel || !player->IsAlive()); if(leader->GetSession()->IsClientless())record.SeparationMs=0; // late bot recruits retain their agreed rendezvous behavior } +// This is a last-resort recovery of a failed follow, never a fast-travel rule. +void AdventureGroupTravelMgr::RecoverFollower(Player* player,Record& record) +{ + auto group=player->GetGroup(); + auto leader=group?ObjectAccessor::FindPlayer(group->GetLeaderGUID()):nullptr; + auto& recovery=record.RecoveryState; + auto now=GameTime::GetGameTime(); + uint32 guid=player->GetGUID().GetCounter(); + auto reset=[&]() + { + recovery={}; + if(leader)recovery.Leader=leader->GetGUID(); + recovery.Map=player->GetMapId();recovery.Instance=player->GetInstanceId(); + recovery.PositionAtProgress.Relocate(player);recovery.ProgressAt=now; + }; + if(!leader || leader==player){reset();return;} + if(recovery.Leader!=leader->GetGUID() || recovery.Map!=player->GetMapId() || + recovery.Instance!=player->GetInstanceId() || !recovery.ProgressAt) + {reset();return;} // discard failures from outside/another instance or group + if(recovery.SampleAt==now)return; + recovery.SampleAt=now; + bool botLeader=leader->GetSession()->IsClientless(); + auto inTransit=[](Player* member) + { + if(member->IsBeingTeleported() || member->IsInFlight() || member->GetTransport() || member->GetVehicle())return true; + auto ride=sClientlessTransportMgr->GetStatus(member->GetGUID().GetCounter()); + return ride && ride->Phase!=ClientlessTransportPhase::Failed && ride->Phase!=ClientlessTransportPhase::Arrived; + }; + if(!player->IsAlive() || !leader->IsAlive() || player->IsInCombat() || player->IsCharmed() || + player->IsFalling() || leader->IsFalling() || inTransit(player) || inTransit(leader) || + leader->HasUnitState(UNIT_STATE_CASTING) || (player->HasUnitState(UNIT_STATE_CASTING) && !recovery.MountUntil) || + sClientlessPlayerMgr->IsShutdownDraining() || sClientlessPlayerMgr->IsSafeLogoutPending(guid) || + (botLeader && sAdventureGroupMgr->Forming(player))) + {reset();return;} + bool instance=player->GetMap()->IsDungeon() || leader->GetMap()->IsDungeon(); + // Require membership in the same live instance, not merely the same map ID. + // Normal rendezvous at the entrance has no navigation failure and stays put. + if(!player->IsInPhase(leader) || (instance && (record.EntryLeader!=leader->GetGUID() || + record.EntryMap!=player->GetMapId() || record.EntryInstance!=player->GetInstanceId() || + player->GetMap()!=leader->GetMap() || + player->GetInstanceId()!=leader->GetInstanceId())) || + (!instance && (player->GetMap()->Instanceable() || leader->GetMap()->Instanceable()))) + {reset();return;} + if(recovery.PositionAtProgress.GetExactDist(player)>1.0f) + {reset();return;} + auto nav=sClientlessTravelMgr->GetStatus(guid); + auto action=sClientlessActionExecutorMgr->GetStatus(guid); + bool travelAction=action && (action->ActionName=="travel_to_node" || action->ActionName=="travel_to_location"); + auto flight=sClientlessFlightMgr->GetStatus(guid); + if(flight && flight->FollowingPlayer && flight->State==ClientlessFlightState::Flying && + player->GetExactDist(flight->Target)<2) + {reset();return;} // holding an assigned aerial formation is deliberate + bool failed=(flight && flight->FollowingPlayer && flight->State==ClientlessFlightState::Failed) || recovery.BlockedAt || (recovery.FailedAt && now-recovery.FailedAt<=5) || + (nav && (nav->State==ClientlessTravelState::Stuck || nav->State==ClientlessTravelState::PathRejected)) || + (travelAction && action->State==ClientlessActionExecutionState::Failed); + bool requested=(recovery.RequestedAt && now-recovery.RequestedAt<=5) || + (nav && nav->State==ClientlessTravelState::Moving) || + (flight && flight->State==ClientlessFlightState::Flying && + (player->IsSplineEnabled() || (flight->FollowingPlayer && player->GetExactDist(flight->Target)>=2))); + if(!failed && !(requested && now-recovery.ProgressAt>=60) && !recovery.MountUntil) + { + // Retain a confirmed failure while mounting for this recovery. + if(!recovery.MountUntil){recovery.BlockedAt=0;if(!requested)recovery.ProgressAt=now;} + return; + } + if(!recovery.BlockedAt)recovery.BlockedAt=failed?now:recovery.ProgressAt; + float distance=player->GetMapId()==leader->GetMapId()?player->GetExactDist(leader):100000.0f; + if(!AdventureGroupTravelPolicy::Recover(instance,botLeader,distance,now-recovery.BlockedAt))return; + Position target;target.Relocate(leader); + // Check both the resolved destination and the leader's native area/zone. + // In particular, a long-distance recovery must never bypass faction/level rules. + if(!sAdventureAreaMgr->EnsureLoaded() || !sAdventureAreaMgr->AllowsArea(player,leader->GetAreaId()) || + !sAdventureAreaMgr->AllowsArea(player,leader->GetZoneId()) || + !sAdventureAreaMgr->AllowsPosition(player,leader->GetMapId(),target)) + {record.Detail="stuck follow recovery denied by destination area rules";return;} + float ground=leader->GetMap()->GetHeight(leader->GetPhaseShift(),target.GetPositionX(),target.GetPositionY(),target.GetPositionZ()+2,true,6); + bool airborne=leader->IsFlying() || !std::isfinite(ground) || ground<=INVALID_HEIGHT || std::fabs(ground-target.GetPositionZ())>3; + if(airborne) + { + if(recovery.WaitForGround)return; + if(!sClientlessFlightMgr->Enabled(player) || !std::isfinite(sClientlessFlightMgr->Estimate(player,leader->GetMapId(),target,target,true))) + {record.Detail="stuck follow recovery waiting for permitted ground destination";return;} + if(!player->IsMounted() || !player->CanFly()) + { + if(recovery.MountUntil) + { + if(now>=recovery.MountUntil){recovery.WaitForGround=true;record.Detail="recovery mount failed; waiting for leader to land";} + return; + } + if(!sClientlessFlightMgr->PrepareFollowingMount(player)) + {recovery.WaitForGround=true;record.Detail="recovery mount unavailable; waiting for leader to land";return;} + recovery.MountUntil=now+10;return; + } + } + else {recovery.WaitForGround=false;recovery.MountUntil=0;} + auto blocked=now-recovery.BlockedAt; + sClientlessPlayerMgr->ClearQueuedActions(guid); + sClientlessActionExecutorMgr->Clear(guid); + sClientlessFlightMgr->Clear(guid); + player->StopMoving(); + bool teleported=player->TeleportTo(leader->GetMapId(),target.GetPositionX(),target.GetPositionY(),target.GetPositionZ(), + target.GetOrientation(),TELE_TO_NONE,leader->GetInstanceId()); + if(!teleported){record.Detail="stuck follow recovery teleport rejected";reset();return;} + if(player->IsBeingTeleported())player->GetSession()->CompleteClientlessTeleport(); + record.Pending.clear();record.RouteOwned=false;record.FlightOwned=false;record.SeparationMs=0; + if(airborne && player->IsMounted() && player->CanFly()) + { + player->AddUnitMovementFlag(MOVEMENTFLAG_FLYING); + std::string error; + record.FlightOwned=sClientlessFlightMgr->Follow(player,target,error); + } + record.Detail="recovered genuinely stuck follower to group leader"; + TC_LOG_INFO("server.adventurebot.navigation", + "GROUP_FOLLOW_RECOVERY guid=%u leader=%u botLeader=%u map=%u instance=%u distance=%.1f blockedSeconds=%u airborne=%u", + guid,leader->GetGUID().GetCounter(),botLeader,player->GetMapId(),player->GetInstanceId(),distance,uint32(blocked),airborne); + reset(); +} bool AdventureGroupTravelMgr::Airborne(Player* player) const { if(!player->IsAlive())return false; @@ -449,16 +568,17 @@ bool AdventureGroupTravelMgr::Follow(Player* player) } if(!leader->IsMounted() && player->IsMounted() && player->GetExactDist(leader)<12)sClientlessLuaEngine->DismountGroundMount(player,"group leader on foot"); if(player->GetExactDist(target)<1.2f) - {sClientlessTravelMgr->CancelTravel(guid);record.Detail="holding assigned formation position";return true;} + {record.RecoveryState.BlockedAt=0;record.RecoveryState.RequestedAt=0;record.RecoveryState.FailedAt=0;sClientlessTravelMgr->CancelTravel(guid);record.Detail="holding assigned formation position";return true;} auto nav=sClientlessTravelMgr->GetStatus(guid); if(nav && nav->State==ClientlessTravelState::Moving && nav->Target.GetExactDist(target)<0.75f) { - if(auto active=sClientlessTravelMgr->GetActivePath(guid))if(!ValidateGroundPath(player,*active)) - {sClientlessTravelMgr->CancelTravel(guid);record.Detail="active follow corridor became unsafe";} + if(auto active=sClientlessTravelMgr->GetActivePath(guid))if(!ValidateGroundPath(player,*active,true)) + {record.RecoveryState.FailedAt=GameTime::GetGameTime();sClientlessTravelMgr->CancelTravel(guid);record.Detail="active follow corridor became unsafe";} return true; } + record.RecoveryState.RequestedAt=GameTime::GetGameTime(); if(!SafePath(player,target)) - {sClientlessTravelMgr->CancelTravel(guid);record.Detail="no safe complete follow path; waiting";return true;} + {record.RecoveryState.FailedAt=GameTime::GetGameTime();sClientlessTravelMgr->CancelTravel(guid);record.Detail="no safe complete follow path; waiting";return true;} if(leader->IsMounted() && !(flightLeader && player->CanFly())) { auto profile=sClientlessLuaEngine->GetAdventureProfile(guid); @@ -466,6 +586,7 @@ bool AdventureGroupTravelMgr::Follow(Player* player) } else if(!leader->IsMounted() && player->IsMounted() && player->GetExactDist(leader)<12)sClientlessLuaEngine->DismountGroundMount(player,"group leader on foot"); bool started=sClientlessTravelMgr->StartTravel(player,target,true); + if(!started)record.RecoveryState.FailedAt=GameTime::GetGameTime(); record.Detail=started?"safe ground formation":"follow spline rejected; waiting";return true; } bool AdventureGroupTravelMgr::AdaptRoad(Player* player,std::vector& path) @@ -515,7 +636,7 @@ bool AdventureGroupTravelMgr::AdaptRoad(Player* player,std::vector& pa } return false; } -bool AdventureGroupTravelMgr::ValidateGroundPath(Player* player,std::vector const& path) const +bool AdventureGroupTravelMgr::ValidateGroundPath(Player* player,std::vector const& path,bool mmapValidated) const { for(size_t i=1;iGetMap()->isInLineOfSight(player->GetPhaseShift(),a.GetPositionX(),a.GetPositionY(),a.GetPositionZ()+1,b.GetPositionX(),b.GetPositionY(),b.GetPositionZ()+1,LINEOFSIGHT_ALL_CHECKS,VMAP::ModelIgnoreFlags(0))) + // Native validated MMAP corridors already establish traversability. + // Recorded/formation-shifted splines retain the additional geometry guard. + if(!mmapValidated && !player->GetMap()->isInLineOfSight(player->GetPhaseShift(),a.GetPositionX(),a.GetPositionY(),a.GetPositionZ()+1,b.GetPositionX(),b.GetPositionY(),b.GetPositionZ()+1,LINEOFSIGHT_ALL_CHECKS,VMAP::ModelIgnoreFlags(0))) { TC_LOG_WARN("server.adventurebot.navigation", "GROUP_GROUND_REJECT guid=%u map=%u check='line-of-sight' segment=%u from=(%.3f %.3f %.3f) to=(%.3f %.3f %.3f)", diff --git a/src/server/game/World/AdventureGroupTravelMgr.h b/src/server/game/World/AdventureGroupTravelMgr.h index bc7f25d5c4..ed87f06fe0 100644 --- a/src/server/game/World/AdventureGroupTravelMgr.h +++ b/src/server/game/World/AdventureGroupTravelMgr.h @@ -18,7 +18,7 @@ public: bool Follow(Player* player); bool HoldForInstanceLeader(Player* player); bool AdaptRoad(Player* player, std::vector& path); - bool ValidateGroundPath(Player* player, std::vector const& path) const; + bool ValidateGroundPath(Player* player, std::vector const& path, bool mmapValidated = false) const; bool Protected(Player* player) const; bool Airborne(Player* player) const; bool Separated(Player* player) const; @@ -41,8 +41,17 @@ private: time_t SampleAt = 0, Began = 0, Arrived = 0; uint32 Generation = 0; }; + struct Recovery + { + ObjectGuid Leader; + uint32 Map = 0, Instance = 0; + Position PositionAtProgress; + time_t ProgressAt = 0, BlockedAt = 0, SampleAt = 0, RequestedAt = 0, FailedAt = 0, MountUntil = 0; + bool WaitForGround = false; + }; struct Record { + Recovery RecoveryState; ObjectGuid LeaderGuid; uint32 Timer = 0, SeparationMs = 0, Generation = 0, BotMap = 0; Position ProgressPosition; @@ -55,6 +64,7 @@ private: std::deque Pending; }; struct Width { time_t At = 0; Position Center; unsigned Columns = 1; }; + void RecoverFollower(Player* player, Record& record); void SampleLeader(Player* player, Leader& state, time_t now); bool GroundPoint(Player* player, Position& point) const; bool SafePath(Player* player, Position const& point) const; diff --git a/src/server/game/World/AdventureGroupTravelPolicy.h b/src/server/game/World/AdventureGroupTravelPolicy.h index 44c72a4ba1..dd5c648a12 100644 --- a/src/server/game/World/AdventureGroupTravelPolicy.h +++ b/src/server/game/World/AdventureGroupTravelPolicy.h @@ -12,6 +12,12 @@ namespace AdventureGroupTravelPolicy constexpr float LocalFollowRange = 100; constexpr float MaxGroundStep = 1.75f; constexpr float MaxGroundSlope = 0.8f; + inline bool Recover(bool instance, bool botLeader, float distance, std::int64_t blockedSeconds) + { + if (distance < 2 || blockedSeconds < 0) return false; + if (instance) return distance >= 100; + return botLeader ? blockedSeconds >= 300 : distance >= 100 || blockedSeconds >= 60; + } struct Offset { float Behind, Side, Height; }; inline Offset Formation(unsigned slot, unsigned columns, bool moving, bool mounted, bool flying) { diff --git a/src/server/game/World/ClientlessFlightMgr.cpp b/src/server/game/World/ClientlessFlightMgr.cpp index 5bfd2d8fa6..ed4e3a4095 100644 --- a/src/server/game/World/ClientlessFlightMgr.cpp +++ b/src/server/game/World/ClientlessFlightMgr.cpp @@ -1,6 +1,7 @@ #include "AdventureAreaMgr.h" #include "ClientlessTravelTelemetry.h" #include "ClientlessFlightMgr.h" +#include "ClientlessMountPreparation.h" #include #include "ClientlessLuaEngine.h" #include "ClientlessTravelMgr.h" @@ -248,10 +249,14 @@ bool ClientlessFlightMgr::PrepareFollowingMount(Player* player) total+=weight;if(urand(1,total)<=weight)spell=mount.DisplayId; } if(!spell)return false; + if(!ClientlessMountPreparation::LeaveDruidForm(player))return false; sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter());player->StopMoving(); sClientlessLuaEngine->DismountGroundMount(player,"group flying mount"); player->RemoveAurasByType(SPELL_AURA_MOUNTED); - return player->CastSpell(player,spell,false)==SPELL_CAST_OK; + auto result=player->CastSpell(player,spell,false); + if(result!=SPELL_CAST_OK) + TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_MOUNT_REJECT guid=%u spell=%u result=%u context='group-follow'",player->GetGUID().GetCounter(),spell,uint32(result)); + return result==SPELL_CAST_OK; } void ClientlessFlightMgr::HoldFollowing(Player* player,Record& record) @@ -521,6 +526,8 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s ClientlessTravelTelemetry::Note(player,"flight-landing-rejected",error); return false; } + if(!existingMount && !ClientlessMountPreparation::LeaveDruidForm(player)) + {error="druid form cannot be left safely before mounting";return false;} sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter()); player->StopMoving(); if(!existingMount) @@ -561,9 +568,11 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s Store(player->GetGUID().GetCounter(),std::move(record));return true; } Store(player->GetGUID().GetCounter(),std::move(record)); - if (!existingMount && player->CastSpell(player, spell, false) != SPELL_CAST_OK) + auto mountResult=existingMount?SPELL_CAST_OK:player->CastSpell(player,spell,false); + if (mountResult != SPELL_CAST_OK) { Fail(player, _records.at(player->GetGUID().GetCounter()), "native mount spell rejected"); + TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_MOUNT_REJECT guid=%u spell=%u result=%u context='free-flight'",player->GetGUID().GetCounter(),spell,uint32(mountResult)); error = "native mount spell rejected"; return false; } diff --git a/src/server/game/World/ClientlessLuaEngine.cpp b/src/server/game/World/ClientlessLuaEngine.cpp index cd796b7835..8b259cf01c 100644 --- a/src/server/game/World/ClientlessLuaEngine.cpp +++ b/src/server/game/World/ClientlessLuaEngine.cpp @@ -1,5 +1,6 @@ #include "AdventureProfessionPolicy.h" #include "ClientlessLuaEngine.h" +#include "ClientlessMountPreparation.h" #include "AdventureRotationPolicy.h" #include "ClientlessPlayerMgr.h" #include "ClientlessLoginRecovery.h" @@ -647,6 +648,8 @@ bool ClientlessLuaEngine::PrepareGroundMount(Player* player, float travelDistanc return false; if (travelDistance < profile->GroundMountMinDistance || !player->IsOutdoors()) return false; + if (!ClientlessMountPreparation::LeaveDruidForm(player)) + return false; if (player->IsMounted()) return true; diff --git a/src/server/game/World/ClientlessMountPreparation.h b/src/server/game/World/ClientlessMountPreparation.h new file mode 100644 index 0000000000..994bde30c2 --- /dev/null +++ b/src/server/game/World/ClientlessMountPreparation.h @@ -0,0 +1,26 @@ +#ifndef VCORE_CLIENTLESS_MOUNT_PREPARATION_H +#define VCORE_CLIENTLESS_MOUNT_PREPARATION_H + +#include "Player.h" + +namespace ClientlessMountPreparation +{ + // Cancel the aura category, not individual spell IDs: covers every druid + // form and its ranks, including travel, aquatic, flight and talent forms. + inline bool LeaveDruidForm(Player* player) + { + if (player->getClass() != CLASS_DRUID || !player->HasAuraType(SPELL_AURA_MOD_SHAPESHIFT)) + return true; + + // Never drop a flying/swimming druid out of a form in mid-transition. + // The normal landing/transport owner must finish before mounting. + if (!player->IsAlive() || player->IsInCombat() || player->IsFlying() || player->IsFalling() || + player->IsInWater() || player->IsInFlight() || player->GetTransport() || player->GetVehicle() || + player->IsBeingTeleported()) + return false; + + player->RemoveAurasByType(SPELL_AURA_MOD_SHAPESHIFT); + return !player->HasAuraType(SPELL_AURA_MOD_SHAPESHIFT); + } +} +#endif diff --git a/tests/clientless/test_adventure_group_travel.py b/tests/clientless/test_adventure_group_travel.py index 5fd566ece9..8f9738a69d 100644 --- a/tests/clientless/test_adventure_group_travel.py +++ b/tests/clientless/test_adventure_group_travel.py @@ -26,7 +26,7 @@ using uint32=unsigned; #define TC_LOG_WARN(...) ((void)0) #define TC_LOG_INFO(...) ((void)0) constexpr float INVALID_HEIGHT=-100000; -constexpr int LINEOFSIGHT_ALL_CHECKS=0,CMSG_AREATRIGGER=0; +constexpr int LINEOFSIGHT_ALL_CHECKS=0,CMSG_AREATRIGGER=0,TELE_TO_NONE=0,MOVEMENTFLAG_FLYING=1,UNIT_STATE_CASTING=2; 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 idstruct hash{size_t operator()(ObjectGuid g)const{return g.id;}};} @@ -38,17 +38,20 @@ struct Position{ 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,dungeon=false;bool IsDungeon(){return dungeon;} +struct Map{bool cliff=false,los=true,narrow=false,dungeon=false;bool IsDungeon(){return dungeon;}bool Instanceable(){return dungeon;} 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 Session{bool clientless=false;bool IsClientless(){return clientless;}void CompleteClientlessTeleport(){}void HandleAreaTriggerOpcode(struct WorldPacket&){}}; struct Group{ObjectGuid leader{21};struct Slot{ObjectGuid guid;};std::vectorslots{{{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,instance=0,stops=0;Group*g=&group;Session session; + 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,instance=0,stops=0;Group*g=&group;Session session;bool vehicle=false,areaAllowed=true,casting=false;bool HasUnitState(unsigned){return casting;}unsigned teleports=0; Player(unsigned id):guid{id}{}ObjectGuid GetGUID(){return guid;}Group*GetGroup(){return g;}Map*GetMap(){return ↦}unsigned GetMapId(){return mapid;}unsigned GetInstanceId(){return instance;}bool IsSplineEnabled(){return moving;}void StopMoving(){moving=false;++stops;}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 GetVehicle(){return vehicle;}unsigned GetAreaId(){return areaAllowed?1:2;}unsigned GetZoneId(){return 1;} + bool TeleportTo(unsigned m,float x,float y,float z,float o,unsigned,unsigned inst){++teleports;mapid=m;instance=inst;Relocate(x,y,z,o);return true;} + void AddUnitMovementFlag(unsigned){flying=true;} 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){}templateWorldPacket&operator<<(T){return *this;}}; @@ -57,9 +60,9 @@ namespace ObjectAccessor{std::unordered_mapplayers;Player*Fi 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&)> adapter){adapters+=adapter?1:-1;}}executor;auto sClientlessActionExecutorMgr=&executor; +struct Executor{struct Status{ClientlessActionExecutionState State=ClientlessActionExecutionState::Completed;std::string ActionName;}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&)> adapter){adapters+=adapter?1:-1;}}executor;auto sClientlessActionExecutorMgr=&executor; struct ClientlessAction{std::string Name;std::unordered_mapParameters;}; -struct Core{unsigned queued=0;std::vectornodes;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; +struct Core{bool draining=true,safeLogout=false;bool IsShutdownDraining(){return draining;}bool IsSafeLogoutPending(unsigned){return safeLogout;}unsigned queued=0;std::vectornodes;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::vectortargets; @@ -73,17 +76,18 @@ enum class ClientlessTransportPhase{Waiting,Riding,Arrived,Failed}; struct TransportStatus{ClientlessTransportPhase Phase=ClientlessTransportPhase::Failed;};struct Stop{unsigned Map=0;struct Point{Position Pos;};std::vectorPoints{{{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;} +struct Flight{struct Status{ClientlessFlightState State=ClientlessFlightState::Failed;bool FollowingPlayer=false;Position Target;}status;bool enabled=false,mountSucceeds=false,permitted=true;unsigned follows=0,lands=0,mountAttempts=0;float Estimate(Player*,unsigned,Position const&,Position const&,bool){return permitted?0:INFINITY;}void Clear(unsigned){status.State=ClientlessFlightState::Failed;}auto GetStatus(unsigned){return &status;}bool Enabled(Player*){return enabled;}bool PrepareFollowingMount(Player*){++mountAttempts;return mountSucceeds;} 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 AreaAccess{bool allowed=true;bool EnsureLoaded(){return true;}bool AllowsArea(Player*,unsigned a){return a==1;}bool AllowsPosition(Player*,unsigned,Position const&){return allowed;}}access;auto sAdventureAreaMgr=&access; struct ChatHandler{void SendSysMessage(char const*){}templatevoid PSendSysMessage(char const*,T...){}}; +struct GroupMgr{bool forming=false;bool Forming(Player*){return forming;}}groupMgr;auto sAdventureGroupMgr=&groupMgr; '''+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::HoldForInstanceLeader(', 'bool AdventureGroupTravelMgr::Follow(', 'bool AdventureGroupTravelMgr::AdaptRoad(', 'bool AdventureGroupTravelMgr::ValidateGroundPath(', 'void AdventureGroupTravelMgr::Clear(']:code+=method(sig)+'\n' +for sig in ['void AdventureGroupTravelMgr::RecoverFollower(', '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::HoldForInstanceLeader(', 'bool AdventureGroupTravelMgr::Follow(', 'bool AdventureGroupTravelMgr::AdaptRoad(', 'bool AdventureGroupTravelMgr::ValidateGroundPath(', 'void AdventureGroupTravelMgr::Clear(']:code+=method(sig)+'\n' code+=r''' int main(){ using namespace AdventureGroupTravelPolicy; @@ -195,10 +199,78 @@ int main(){ 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); + // MMAP-proven corridors may round a wall; arbitrary recorded paths still need LOS. + a=Player(26);leader=Player(21);map={};map.los=false; + std::vector aroundWall{{0,0,0},{10,0,0}}; + assert(!mgr.ValidateGroundPath(&a,aroundWall)); + assert(mgr.ValidateGroundPath(&a,aroundWall,true)); + map.cliff=true;assert(!mgr.ValidateGroundPath(&a,aroundWall,true));map={}; + // Exercise the production recovery implementation with native-state substitutes. + core.draining=false;core.safeLogout=false;flight.enabled=false;flight.status={}; + executor.status={};travel.status.State=ClientlessTravelState::Arrived; + AdventureGroupTravelMgr::Record rec; + auto begin=[&](float distance,bool bot=false){ + a=Player(26);leader=Player(21);leader.m_positionX=distance;leader.session.clientless=bot; + rec={};map={};core.safeLogout=false;groupMgr.forming=false;access.allowed=true; + executor.status={};travel.status.State=ClientlessTravelState::Arrived;flight.status={}; + flight.enabled=false;flight.mountSucceeds=false;flight.permitted=true; + GameTime::now+=1000;mgr.RecoverFollower(&a,rec); + }; + auto step=[&](unsigned seconds=1){GameTime::now+=seconds;mgr.RecoverFollower(&a,rec);}; + auto failed=[&](){rec.RecoveryState.FailedAt=GameTime::now;}; + begin(120);step(600);assert(a.teleports==0); // idle is never stuck + begin(120);failed();step();assert(a.teleports==1); // human: >=100 and failed + begin(99);failed();step();assert(a.teleports==0); + step(59);assert(a.teleports==0); + failed();step();assert(a.teleports==1); // human: 60s confirmed failure + begin(120,true);failed();step(); + step(299);assert(a.teleports==0);step();assert(a.teleports==1); // bot: 300 seconds + begin(120);flight.status.FollowingPlayer=true;flight.status.State=ClientlessFlightState::Flying;flight.status.Target=a; + step(600);assert(a.teleports==0); // deliberate aerial formation hold + begin(120);flight.status.FollowingPlayer=true;flight.status.State=ClientlessFlightState::Failed;step();assert(a.teleports==1); + begin(120);travel.status.State=ClientlessTravelState::Moving; + step(59);assert(a.teleports==0);step();assert(a.teleports==1); // silent movement stall + begin(120);travel.status.State=ClientlessTravelState::Moving; + for(int i=0;i<10;++i){a.m_positionX+=2;step(20);}assert(a.teleports==0); // real progress + begin(120);access.allowed=false;failed();step();assert(a.teleports==0); + begin(120);leader.areaAllowed=false;failed();step();assert(a.teleports==0); + begin(120);leader.taxi=true;failed();step();assert(a.teleports==0); + begin(120);a.transport=true;failed();step();assert(a.teleports==0); + begin(120);executor.status.ActionName="travel_to_location";executor.status.State=ClientlessActionExecutionState::Failed;step();assert(a.teleports==1); + begin(120);a.phase=false;failed();step();assert(a.teleports==0); + begin(120);a.combat=true;failed();step();assert(a.teleports==0); + begin(120);a.alive=false;failed();step();assert(a.teleports==0); + begin(120);leader.teleport=true;failed();step();assert(a.teleports==0); + begin(120);leader.casting=true;failed();step();assert(a.teleports==0); + begin(120);leader.vehicle=true;failed();step();assert(a.teleports==0); + begin(120);transport.status.Phase=ClientlessTransportPhase::Waiting;failed();step();assert(a.teleports==0);transport.status.Phase=ClientlessTransportPhase::Failed; + begin(120);core.safeLogout=true;failed();step();assert(a.teleports==0); + begin(120,true);groupMgr.forming=true;failed();step(600);assert(a.teleports==0); + begin(120);map.dungeon=true;a.instance=1;leader.instance=2;failed();step();assert(a.teleports==0); // different copies + begin(120);map.dungeon=true;a.instance=leader.instance=1; + step();rec.EntryLeader=leader.guid;rec.EntryMap=a.mapid;rec.EntryInstance=a.instance;failed();step();assert(a.teleports==1); // already in same copy + begin(120,true);map.dungeon=true;a.instance=leader.instance=1;step(); + rec.EntryLeader=leader.guid;rec.EntryMap=a.mapid;rec.EntryInstance=a.instance;failed();step();assert(a.teleports==1); + begin(99);map.dungeon=true;a.instance=leader.instance=1;step();failed();step(600);assert(a.teleports==0); + begin(120);map.dungeon=true;a.instance=leader.instance=1;step();step(600);assert(a.teleports==0); // entrance wait + begin(120);failed();a.instance=1;map.dungeon=true;leader.instance=1;step();assert(a.teleports==0); // discard pre-entry failure + begin(120);leader.flying=true;leader.m_positionZ=50;failed();step();assert(a.teleports==0); // ground bot, no flying skill + begin(120);leader.flying=true;leader.m_positionZ=50;flight.enabled=true;failed();step();assert(a.teleports==0); + assert(rec.RecoveryState.WaitForGround); // mount failed => await landing + leader.flying=false;leader.m_positionZ=0;failed();step();assert(a.teleports==1); + begin(120);leader.flying=true;leader.m_positionZ=50;flight.enabled=true;flight.mountSucceeds=true; + failed();step();assert(a.teleports==0&&rec.RecoveryState.MountUntil); + a.mounted=a.flightCapable=true;failed();step();assert(a.teleports==1&&a.flying); + begin(120);leader.flying=true;leader.m_positionZ=50;flight.enabled=true;flight.mountSucceeds=true;failed();step(); + a.mounted=a.flightCapable=true;access.allowed=false;failed();step();assert(a.teleports==0); // recheck after mounting + begin(120);leader.flying=true;leader.m_positionZ=50;flight.enabled=true;flight.permitted=false;a.mounted=a.flightCapable=true; + failed();step();assert(a.teleports==0); // destination flight permission + begin(120);leader.flying=true;leader.m_positionZ=50;flight.enabled=true;flight.mountSucceeds=true; + failed();step();failed();step(10);assert(a.teleports==0&&rec.RecoveryState.WaitForGround); // cast timeout } ''' 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') +print('Production group travel: distinct slots, phase169 cliff safety, width, mounts, flight/kick landing, taxi grace, portal queue, bounded separation, recorded-road validation, MMAP LOS bypass and guarded stuck-follower recovery passed') diff --git a/tests/clientless/test_adventure_world_presence.py b/tests/clientless/test_adventure_world_presence.py index 8310f7a65d..f6f11e91b9 100644 --- a/tests/clientless/test_adventure_world_presence.py +++ b/tests/clientless/test_adventure_world_presence.py @@ -16,6 +16,7 @@ code=r''' #include using uint32=unsigned; #define TC_LOG_INFO(...) ((void)0) +constexpr int CLASS_DRUID=11,SPELL_AURA_MOD_SHAPESHIFT=9; constexpr int CHARACTER_FLAG_PVP_DESIRED=65536,PLAYER_FLAGS=1,PLAYER_FLAGS_IN_PVP=512,RACE_WORGEN=22, SPELL_TWO_FORMS_RACIAL=68996,SPELL_ALTERED_FORM_RACIAL=97709,SPELL_AURA_WORGEN_ALTERED_FORM=1,UNIT_STATE_CASTING=2,MOVE_RUN=3; namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}} @@ -23,6 +24,7 @@ unsigned roll=0;unsigned urand(unsigned,unsigned){return roll;} struct Guid{unsigned GetCounter(){return 26;}}; struct Map{bool dungeon=false;bool IsDungeon(){return dungeon;}}map; struct Player{ + unsigned klass=1,form=0;unsigned getClass(){return klass;}bool IsFalling(){return false;}bool IsInWater(){return false;}bool GetVehicle(){return false;}void RemoveAurasByType(int type){assert(type==SPELL_AURA_MOD_SHAPESHIFT);form=0;} unsigned race=22,level=60,flags=0,characterFlags=0,display=0,casts=0,updates=0; bool world=true,alive=true,combat=false,mounted=false,transport=false,taxi=false,flying=false,teleport=false,casting=false,outdoors=true,pvp=false,fail=false; float speed=1;std::unordered_setspells,auras;struct{unsigned EndTimer=10;}pvpInfo; @@ -30,7 +32,7 @@ struct Player{ bool IsPvP(){return pvp;}void UpdatePvP(bool on,bool){pvp=on;pvpInfo.EndTimer=0;++updates;} unsigned getRace(){return race;}unsigned getLevel(){return level;}bool IsAlive(){return alive;}bool IsInCombat(){return combat;} bool IsMounted(){return mounted;}bool GetTransport(){return transport;}bool IsInFlight(){return taxi;}bool IsFlying(){return flying;} - bool IsBeingTeleported(){return teleport;}bool HasUnitState(int){return casting;}bool HasAuraType(int){return auras.count(97709);} + bool IsBeingTeleported(){return teleport;}bool HasUnitState(int){return casting;}bool HasAuraType(int type){return type==SPELL_AURA_MOD_SHAPESHIFT?form!=0:auras.count(97709);} bool HasSpell(unsigned id){return spells.count(id);}void LearnSpell(unsigned id,bool){spells.insert(id);} bool HasAura(unsigned id){return auras.count(id);}bool IsOutdoors(){return outdoors;} void CastSpell(Player*,unsigned id,bool){++casts;if(fail)return;if(id==97709)auras.insert(id);if(id==68996)auras.erase(97709);if(id==87840){auras.insert(id);mounted=true;display=29422;speed=2;}} @@ -43,7 +45,7 @@ struct ClientlessLuaEngine{ ClientlessAdventureProfile*GetAdventureProfile(unsigned){return &profile;}unsigned SelectGroundMountDisplayId(unsigned){return 2404;} void UpdateWorldPresence(Player*,std::string const&,bool=false);bool PrepareGroundMount(Player*,float,char const*);void DismountGroundMount(Player*,char const*); }; -'''+fn('void ClientlessLuaEngine::UpdateWorldPresence(')+fn('bool ClientlessLuaEngine::PrepareGroundMount(')+fn('void ClientlessLuaEngine::DismountGroundMount(')+r''' +'''+'\n'.join(v for v in (root/'src/server/game/World/ClientlessMountPreparation.h').read_text().splitlines() if not v.startswith('#'))+fn('void ClientlessLuaEngine::UpdateWorldPresence(')+fn('bool ClientlessLuaEngine::PrepareGroundMount(')+fn('void ClientlessLuaEngine::DismountGroundMount(')+r''' int main(){ ClientlessLuaEngine e;Player p;e.UpdateWorldPresence(&p,"adventure"); assert(p.pvp&&p.pvpInfo.EndTimer==0&&(p.flags&512)&&(p.characterFlags&65536)&&p.HasAura(97709)); @@ -63,6 +65,14 @@ int main(){ // Failed native casts are throttled, not spammed every world tick. p.fail=true;GameTime::now+=6;e.UpdateWorldPresence(&p,"instance");auto casts=p.casts; e.UpdateWorldPresence(&p,"instance");assert(p.casts==casts); + p.klass=CLASS_DRUID;p.race=8;p.fail=false; + for(unsigned spell:{768u,5487u,9634u,783u,1066u,24858u,33891u,33943u,40120u}){ + p.form=spell;p.mounted=false;assert(e.PrepareGroundMount(&p,100,"druid")&&!p.form&&p.mounted); + } + p.form=5487;p.mounted=false;p.combat=true; + assert(!e.PrepareGroundMount(&p,100,"combat")&&p.form==5487&&!p.mounted);p.combat=false; + p.form=33943;p.flying=true;assert(!e.PrepareGroundMount(&p,100,"airborne")&&p.form==33943);p.flying=false; + p.form=0; p.race=1;p.pvp=false;p.pvpInfo.EndTimer=5;e.UpdateWorldPresence(&p,"rest");assert(p.pvp&&!p.pvpInfo.EndTimer); } ''' @@ -70,4 +80,4 @@ with tempfile.TemporaryDirectory() as d: p=Path(d);(p/'test.cpp').write_text(code) subprocess.run(['g++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) subprocess.run([str(p/'test')],check=True) -print('Production world presence: persistent PvP, Worgen intent/arrival decisions, travel deferral, native Running Wild, human horse and aura cleanup passed') +print('Production world presence: all druid mount forms, combat/airborne guards, persistent PvP, Worgen intent/arrival decisions, travel deferral, native Running Wild, human horse and aura cleanup passed') diff --git a/tests/clientless/test_group_flight_mount.py b/tests/clientless/test_group_flight_mount.py index c4956e5cbc..54e78be69a 100644 --- a/tests/clientless/test_group_flight_mount.py +++ b/tests/clientless/test_group_flight_mount.py @@ -14,7 +14,8 @@ code=r''' #include #include using uint32=unsigned;using uint8=unsigned char; -constexpr int MAP_COMMON=0,UNIT_STATE_CASTING=1,MOUNT_CAPABILITY_FLAG_FLYING=1,SPELL_AURA_MOUNTED=1,SPELL_CAST_OK=0; +#define TC_LOG_WARN(...) ((void)0) +constexpr int CLASS_DRUID=11,SPELL_AURA_MOD_SHAPESHIFT=2,MAP_COMMON=0,UNIT_STATE_CASTING=1,MOUNT_CAPABILITY_FLAG_FLYING=1,SPELL_AURA_MOUNTED=1,SPELL_CAST_OK=0; constexpr int MOVEMENTFLAG_FLYING=1,UNIT_FIELD_BYTES_1=1,UNIT_BYTES_1_OFFSET_ANIM_TIER=1,SMSG_MOVE_UPDATE=1; enum class AnimTier:uint8{Ground=0,Fly=3}; struct Position{void Relocate(Position const&){}}; @@ -24,15 +25,16 @@ struct WorldPacket{explicit WorldPacket(int){}}; namespace GameTime{unsigned GetGameTimeMS(){return 100;}} struct Player{ bool alive=true,combat=false,teleport=false,transport=false,taxi=false,falling=false,water=false,outdoors=true,mounted=true,fly=false,casting=false,learned=true; + unsigned klass=1,form=0;unsigned getClass(){return klass;}bool HasAuraType(int){return form!=0;}bool GetVehicle(){return false;} unsigned flags=0,tier=0,stops=0,updates=0,animations=0,removals=0,casts=0;int castResult=SPELL_CAST_OK; struct Capability{unsigned Flags=MOUNT_CAPABILITY_FLAG_FLYING;}capability; struct{Guid guid;Position pos;unsigned time=0;}m_movementInfo; bool IsAlive(){return alive;}bool IsInCombat(){return combat;}bool IsBeingTeleported(){return teleport;}bool GetTransport(){return transport;}bool IsInFlight(){return taxi;} - bool IsFalling(){return falling;}bool IsInWater(){return water;}bool IsOutdoors(){return outdoors;}Map*GetMap(){return ↦}bool IsMounted(){return mounted;}bool CanFly(){return fly;} + bool IsFalling(){return falling;}bool IsInWater(){return water;}bool IsOutdoors(){return outdoors;}Map*GetMap(){return ↦}bool IsMounted(){return mounted;}bool CanFly(){return fly;}bool IsFlying(){return fly;} bool HasUnitState(int){return casting;}unsigned GetZoneId(){return 1519;}Guid GetGUID(){return {};} bool HasSpell(unsigned){return learned;}Capability*GetMountCapability(unsigned){return &capability;} - void StopMoving(){++stops;}void RemoveAurasByType(int){++removals;mounted=false;} - int CastSpell(Player*,unsigned spell,bool triggered){assert(spell==123&&!triggered);++casts;return castResult;} + void StopMoving(){++stops;}void RemoveAurasByType(int aura){++removals;if(aura==SPELL_AURA_MOD_SHAPESHIFT)form=0;else mounted=false;} + int CastSpell(Player*,unsigned spell,bool triggered){assert(spell==123&&!triggered);++casts;assert(!form);return castResult;} bool HasUnitMovementFlag(int flag){return flags&flag;}void AddUnitMovementFlag(int flag){flags|=flag;} unsigned GetByteValue(int,int){return tier;}void SetAnimTier(AnimTier t){tier=unsigned(t);++animations;} Position GetPosition(){return {};}void WriteMovementInfo(WorldPacket&){assert(flags&MOVEMENTFLAG_FLYING);} @@ -48,7 +50,7 @@ unsigned FlightType(void*){return 1;}unsigned urand(unsigned low,unsigned){retur struct ClientlessFlightMgr{struct Record{bool Moving=false;};bool enabled=true; bool Enabled(Player*){return enabled;}bool PrepareFollowingMount(Player*);void HoldFollowing(Player*,Record&); }; -'''+method('bool ClientlessFlightMgr::PrepareFollowingMount(')+method('void ClientlessFlightMgr::HoldFollowing(')+r''' +'''+'\n'.join(v for v in (root/'src/server/game/World/ClientlessMountPreparation.h').read_text().splitlines() if not v.startswith('#'))+method('bool ClientlessFlightMgr::PrepareFollowingMount(')+method('void ClientlessFlightMgr::HoldFollowing(')+r''' int main(){ ClientlessFlightMgr m;Player p; assert(m.PrepareFollowingMount(&p)&&p.casts==1&&p.removals==1&&travel.cancels==1); @@ -65,6 +67,12 @@ int main(){ p.capability.Flags=0;assert(!m.PrepareFollowingMount(&p));p.capability.Flags=1; profile.FlightMounts[0].Weight=0;assert(!m.PrepareFollowingMount(&p));profile.FlightMounts[0].Weight=1; p.castResult=1;assert(!m.PrepareFollowingMount(&p)); + p.castResult=SPELL_CAST_OK;p.klass=CLASS_DRUID; + // Every shapeshift aura uses the same native cancellation path, including ranks. + for(unsigned spell:{768u,5487u,9634u,783u,1066u,24858u,33891u,33943u,40120u}){ + p.form=spell;auto casts=p.casts;assert(m.PrepareFollowingMount(&p)&&!p.form&&p.casts==casts+1); + } + p.form=33943;p.fly=true;assert(!m.PrepareFollowingMount(&p)&&p.form==33943);p.fly=false; ClientlessFlightMgr::Record r;r.Moving=true; auto stops=p.stops;m.HoldFollowing(&p,r); assert(!r.Moving&&p.stops==stops+1&&p.flags==MOVEMENTFLAG_FLYING&&p.tier==unsigned(AnimTier::Fly)&&p.updates==1); @@ -76,4 +84,4 @@ with tempfile.TemporaryDirectory() as d: p=Path(d);(p/'test.cpp').write_text(code) subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) subprocess.run([str(p/'test')],check=True) -print('Group flight mount: native admission, mount retention, casting wait, rejected capabilities, stationary flight animation and bounded publication passed') +print('Group flight mount: all druid forms, safe form cancellation, native admission, mount retention, casting wait, rejected capabilities, stationary flight animation and bounded publication passed')