From aba5aeba2c4cd589b49a1953e1e7b2108058dbfb Mon Sep 17 00:00:00 2001 From: nico Date: Wed, 4 Nov 2026 14:19:38 +0100 Subject: [PATCH] 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. --- doc/clientless/2026-11-04-browser-wait.md | 76 +++++++++ doc/clientless/SESSION-RESUME.md | 132 +++++++++++++++ src/server/game/DungeonFinding/LFGMgr.cpp | 19 ++- src/server/game/DungeonFinding/LFGQueue.cpp | 10 +- .../game/World/AdventureBehaviorMgr.cpp | 150 +++++++++++------- src/server/game/World/AdventureBehaviorMgr.h | 3 +- src/server/game/World/AdventureGroupMgr.cpp | 27 +++- src/server/game/World/AdventureGroupMgr.h | 1 + .../game/World/AdventureInteractionMgr.cpp | 1 + tests/clientless/test_adventure_groups.py | 34 +++- .../clientless/test_adventure_interactions.py | 2 +- tests/clientless/test_adventure_leisure.py | 4 +- .../clientless/test_browser_group_teleport.py | 37 +++++ .../test_browser_low_population_wait.py | 45 ++++++ .../test_browser_wait_activities.py | 52 ++++++ 15 files changed, 517 insertions(+), 76 deletions(-) create mode 100644 doc/clientless/2026-11-04-browser-wait.md create mode 100644 tests/clientless/test_browser_group_teleport.py create mode 100644 tests/clientless/test_browser_low_population_wait.py create mode 100644 tests/clientless/test_browser_wait_activities.py diff --git a/doc/clientless/2026-11-04-browser-wait.md b/doc/clientless/2026-11-04-browser-wait.md new file mode 100644 index 0000000000..85ccb4bb57 --- /dev/null +++ b/doc/clientless/2026-11-04-browser-wait.md @@ -0,0 +1,76 @@ +# Dungeonbrowser: Warteaktivitäten, Einladungen und Gruppenbeitritt + +Bots im Dungeon-/Raidintent führen während der Gruppenwartephase in der +Hauptstadt die lokalen performativen Aktionen der Gruppenbildung aus. +Aktionsauswahl und lokale Umgebung sind vom tatsächlichen Instanzziel getrennt; +Intent, Tagesplan und Dungeonziel werden dafür nicht umgeschrieben. Die +bestehenden Szenen-/Spaziergangsexecutoren werden wiederverwendet. Ein nativer +Instanztransfer beendet die alte Stadtaktivität. + +Ein solo in der Hauptstadt wartender und nativ eingereihter Bot rekrutiert +nicht selbst. Ein Bot in der Gruppenbildung kann ihn für den passenden +Folgeintent einladen. Annahme prüft weiter Fraktion, Level, Instanzzugang, +Rollenbedarf und Kapazität. Eine passende solche Einladung hängt nicht erneut +vom Geselligkeitswurf ab; die persönliche Browserqueue wird vor der nativen +Annahme verlassen. Laufende Vorschläge werden nicht durch diesen Sonderfall +abgebrochen. + +Der Low-Pop-Fallback prüft nun die Wartezeit aller beteiligten Queueeinträge. +Ein alter Botqueueeintrag verkürzt die 60 Sekunden eines frisch eingereihten +Spielers oder einer Gruppe nicht. Vollständige normale Gruppen behalten den +normalen sofortigen Matchpfad. Browserbeitritt prüft weiterhin native +Zugangsbeschränkungen und Clientless-Zulassung. + +Die native Vorschlagsportierung verwendet verbundene Spieler auch während +eines kurzzeitigen Out-of-World-Transfers und bestätigt Clientless-Teleports +unmittelbar. Clientlose Mitglieder werden vor den vom echten Client zu +bestätigenden Menschen portiert; menschliche Bestätigungen bleiben erhalten. +Dies ist eine gezielte Absicherung des Übergangs; die vom Nutzer beobachtete +konkrete menschliche Browsergruppe wurde nicht nachträglich rekonstruiert. + +Regressionen: native Low-Pop-Frist mit altem Bot/neuem Spieler bei 0/59/60s, +Native-Teleportbatch einschließlich Out-of-World/Offline-Fällen, performative +Aktionsauswahl bei unverändertem Dungeonintent, passive Wartebots und Annahme +kompatibler Formationseinladungen einschließlich Level-/Zugang-/Intentablehnung. +Bestehende Gruppenmanager-, Browser-, Interaktions-, Mikro-, Freizeit- und +Sozialtests wurden zusätzlich ausgeführt. Live-Abnahme eines echten +Spieler-Browserbeitritts bleibt erforderlich. + +Artefakte: checkpoints/2026-11-04-browser-wait/. + +## Live-Stand + +Installiert, frisches Ready und Ports8085/8087 geprüft. Alle ursprünglichen118 +Bots online, Loader wieder aktiv, keine Logoutanforderungen ausstehend. +Yarumak44 bleibt in Phase WaitingGroup mit intent instance und Zielat_4363; +LEISURE_WALK_PLAN mit70.11ydSpannweite und8Wegpunkten sowie MICRO_START +für group_search_walk live belegt. Kein Intent-/Zielwechsel für Aktivitäten. + +Beim Abschalten der vorherigen Version trat nach automatischem60s-Restbot- +Audit eine andere Assertion auf: Unit299 noch in Owner-ControlledSet, +Unit::RemoveFromWorld9519, Pet::RemoveFromWorld, Map::~Map. Dump und passende +alte Binärdatei gesichert; kein Unit/Pet-Code für diesen Befund geändert. +Der Testserver wurde nach vollständigem Prozessende installiert und unter +passivem GDB wieder gestartet. Dieser Shutdownbefund ist noch nicht behoben. + +## Erweiterung auf PvP + +Die performativen Warteaktivitäten gelten auch für PvP-Intents in der +Hauptstadt. Solo in der Schlachtfeldwarteschlange wartende Bots können passende +Einladungen aus einer PvP-Gruppenbildung annehmen, ohne selbst zu rekrutieren. +Native Fraktions-, Level-/Bracket- und Zugangsprüfungen bleiben wirksam. +Ein bereits vorliegendes Schlachtfeldangebot wird nicht für eine solche +Einladung abgebrochen. Beim Schlachtfeldeintritt wird die alte Stadtaktivität +beendet. QueueEnabled/JoinEnabled werden dadurch nicht geändert; +Battleground.JoinEnabled bleibt in dieser Testkonfiguration 0. + +Acht betroffene Regressionstests bestanden, einschließlich passiver PvP- +Kandidatenwahl, Annahme kompatibler Einladungen, Schutz aktiver BG-Angebote, +PvP-Aktionsauswahl aus dem Gruppenbildungskatalog und nativer BG-Brackets. +Artefakte: checkpoints/2026-11-04-pvp-wait/. + +PvP-Erweiterung installiert und live bestätigt: Seraellin79 führt bei weiterhin +aktivem PvP-Intent die performative Aktion profession_tools aus. Server und +118Bots wieder aktiv. Beim vorherigen Shutdown trat nach60sAudit ein separater +Transportdestruktor-Assert (_passengers.empty(), Transport.cpp94) auf; Dump und +passendes altes Binary gesichert. Dieser Shutdownfehler ist noch offen. diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 1d255ed9b9..9d2509016b 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,135 @@ +# 2026-11-04 – Lokaler Commit der Browser- und PvP-Erweiterungen + +Nutzer hat den Commit der letzten Änderungen angefordert. Umfang: performative +Warteaktivitäten für Dungeon/Raid/PvP, kompatible passive Gruppeneinladungen, +Low-Pop-Frist für alle beteiligten Queueeinträge und Clientless-Gruppenportierung, +samt Tests und Dokumentation. Bereits gebaut, installiert und wie unten +beschrieben geprüft. Die beiden offenen Shutdownbefunde bleiben dokumentiert; +für sie wurde in diesem Änderungssatz kein Fix implementiert. Dumps, gesicherte +Binärdateien und Laufzeitlogs bleiben außerhalb des Commits. Kein Push. +Die vorherigen Hinweise „kein Commit angefordert“ sind damit überholt. + +--- + +# 2026-11-04 14:16 – PvP-Erweiterung installiert, Seraellin live geprüft + +PvP-Wartephase nutzt nun ebenfalls performative Gruppenbildungsaktionen. +Passivwartende soloBG-Kandidaten können passende Formationseinladungen +annehmen, aktive BG-Angebote werden dabei nicht abgebrochen. Bei BG-Eintritt +alteStadtaktivitätresetten; Queue-/JoinConfigunverändert. AchtbetroffeneTests +PASS, -j10Build/Install erfolgreich. FrischesReady+Ports8085/8087 geprüft, +jetztPID187536 unterpassivemGDB. AlleOriginal118Bots online, LoaderDatei+nativ1, +shutdownPaused0, safeLogoutpending0. KeinindividuellesRecoverychecking/Teleport. +Seraellin79 liveintentpvp/PhaseWaitingGroup erhalten, MICRO_START profession_tools +undSocialscene imLog. Battleground.JoinEnabledweiter0; nichtfreigeschaltet. +Testartefakte checkpoints/2026-11-04-pvp-wait/ mit8Tests,build/install, +seraellin-live.log,native-final.log,result.json,operation.json,roster-after.json. +KeinCommit/Pushangefordert; vorherigesBrowser-WIPbewahrt. + +WICHTIGneuerShutdownbefund: nachautomatischem60sAudit (16Restbots,60265ms) +SIGSEGVviaAssert Transport::~Transport Transport.cpp94 _passengers.empty(). +NichtvorschnellmitvorigemPetassertgleichsetzen. Core20261104-135325 pluspassendes +Binary checkpoints/2026-11-04-pvp-wait/worldserver-before-pvp-wait bewahrt. +GDB/alterWorld184307vollständigbeendet VORInstall. Userinformiert; Transport- +Cleanup-Code NICHTgeändert. DieserBefundUNDvorigerUnit299/PetControlledSet- +ShutdownfehlerweiterOFFEN. AktuellerServerläuft, letztePvPänderungwarnichtim +abgestürztenProzessenthalten. NativeBacktracefileunterserver/debug/crash/ +worldserver-20261104-135325.backtrace.log, keinevollenConnInfosausgeben. +KeineweitereaktiveBeobachtungnötig; echteGruppeneinladung/BG-PortweiterIngame. + +--- + +# 2026-11-04 14:10 – PvP-Warteaktivitäten in Umsetzung + +User beauftragt Erweiterung des letzten Browser-Wartefixes auf PvP. Bekannte +Browser-WIP bewahrt (nichtcommitted). WaitingGroup-Performanz fürpvp ergänzt, +kompatible Formationseinladungen an solo BG-Wartende erlaubt; aktive BG-Angebote +nicht unterbrechen, native Bracket-/Zugangregeln bleiben. Cityaktivität bei +BG-Eintritt beenden. Battleground.JoinEnabled bleibt0, nichtfreischalten. +Zwei gezielte Tests bereitsPASS (PvP-Selektor, passiveAnnahme/aktivesAngebot). +Loadervorher1/118Bots, beiBuildbeginnmanuell0 undsafeall; -j10Buildparallel. +Erst nachBuildende normalShutdown10 (automatisches60sAudit), dannProzessende, +Install/passiverGDB-Neustart/Ready/Ports, LoaderundOriginalbetriebwiederherstellen. +KeineeinzelnenRestbotprüfungen. BekannterPet-ShutdowncrashnochOFFEN ausvoriger +Runde, matchingBinary/124032Corebewahrt. AktuelleArtefakte2026-11-04-pvp-wait/. +KeinCommit/Pushangefordert. Seraellin79 hatPvP14Uhrbis15Uhr, liveprüfenwennbereit. + +--- + +# 2026-11-04 13:56 – Browser-Warteaktivitäten installiert, Yarumak live aktiv + +Alle14betroffenenRegressionstestsPASS; finaler-j10Build/Install erfolgreich. +NeustartPID184307 unterpassivemGDB, frischesReady undPorts8085/8087geprüft. +Original118Bots online; Loader Datei+nativ1, shutdownPaused0, logoutpending0. +Userwunschumgesetzt: WaitingGroupinHauptstadt nutzt lokale Gruppenbildungs- +Aktionen bei unverändertem Dungeon-/Raidintent; kein eigenes Rekrutieren in +nativerQueue, kompatible Formationseinladungen werden unterCanInvite/Rollen/ +Level/Zugang/Kapazität angenommen. PersönlicheQueue vorAnnahmebeenden. +Lowpop wartet volleFristfürALLE beteiligtenQueueeinträge, alterBotkannneuem +SpielerkeineFristverkürzen. NativeGruppenportierung überConnectedStore, Bots +zuerst/sofortigesACK, menschlicheClientbestätigungbewahrt. +Yarumak44 liveweiterinstanceat_4363/WaitingGroup, group_search_walk gestartet: +70.11ydStadtspaziergang/8Wegpunkte. KeinmanuellerIntent/TeleportzumTestnötig. +EchteSpielerbrowsergruppe/volleLowpop60sundkompatibleEinladunglive bleiben +Ingameabnahme; EntscheidungslogikdurchneueProduktionscodeTestsabgedeckt. + +WICHTIG: vorInstallationalterServer172527 bei normalemShutdown nach60sAudit +(17Restbots) andererAbsturz: Unit299 inOwnerControlledSet, Unit.cpp9519ABORT; +StackPet::RemoveFromWorld -> Map::~Map. Core20261104-124032 pluspassendes +Binary checkpoints/2026-11-04-browser-wait/worldserver-before-browser-wait +bewahrt; Unit/Pet-Code NICHTgeändert, konkreterShutdownfehlernochOFFEN. +Nutzerinformiert. KeinZusammenhangmitBrowseränderungenbehaupten: alterProzess +liefnochohneaktuellenBrowserfix. SourceWIPpreserved,keinCommit/Pushangefordert. + +Artefakte checkpoints/2026-11-04-browser-wait/{build*.log,install.log, +startup.log,native-final.log,yarumak-live.log,result.json,operation.json, +shutdown*.jsonl,shutdown-crash-frames.log,test*.log}. NeueTests: +browser_low_population_wait, browser_group_teleport, browser_wait_activities; +groups-TestumqueuedKandidaten/Annahme/Fremdintent-/Level-/Zugangablehnungergänzt, +Interaktions-/Freizeitadapterminimalangepasst. Doc2026-11-04-browser-wait.md. +KeineweiteraktiveBeobachtungnötig; finalerIngameBrowserbeitrittvomNutzer. + +--- + +# 2026-11-04 13:53 – Browserfix gebaut, alter Server beim Shutdown abgebrochen + +Build-final/installable -j10 erfolgreich, alle14betroffenenTestsPASS. Source +noch nicht installiert. NormalShutdown10 nach Buildende ausgelöst, integrierte +60s-Frist mit17Restbots/Audit ausgeführt. Danach anderer Shutdownfehler: +Unit299 noch in controlled list von Besitzerentry0, Unit::RemoveFromWorld9519 +ABORT/SIGSEGV. Passiver GDB schreibt core worldserver-20261104-124032.core. +Passendes Binary checkpoints/2026-11-04-browser-wait/worldserver-before-browser- +wait bewahrt. Warten bis Prozess172527/GDB beendet, erst dann cmakeinstall. +Keinen Unit-Code/Assertion geändert. Nutzer informiert. Danach frischerpassiver +Start/Ready/Ports, Originalroster und Loader1 wiederherstellen; automatischer +Recovery vertrauen. Yarumak44 noch im13UhrDungeonintent liveprüfen (14Uhrbeginnt +Gruppenbildung, ggfvorherbeobachten). EchteSpielerbrowsergruppe bleibtIngame- +Abnahme. AktuellerBuild enthält Warteaktivitäten/Einladungen/LowpopalleQueue- +fristen/verbundeneBots+sofortigeTeleportACK. Kein Commit angefordert. + +--- + +# 2026-11-04 – Browser-Warteaktivitäten und Dungeonbeitritt in Arbeit + +Nutzer beauftragt performative Gruppenbildungsaktionen beim Dungeonbrowser- +Warten, ohne eigene Rekrutierung; kompatible Einladungen aus Gruppenbildung +mit folgendem Dungeonintent annehmen. Außerdem Low-Pop60s-Frist beachten und +Bots bei nativem Browserbeitritt vollständig mitteleportieren. +Source-WIP auf autorisiertem Branch, vorher tracked clean (d1aceaf583). +Änderungen: WaitingGroup lokale Aktionsumgebung/OnSite-Executor wiederverwenden; +Browserwartende Kandidaten akzeptieren passende Formationseinladung nach +CanInvite/Level/Rollen/Zugang, eigene Queue vor Annahme verlassen; Low-Pop +prüft Wartezeit aller Queueeinträge statt nur Seed; native Gruppenportierung +bestätigt CPC-Teleports sofort. Diagnose Yarumak44 zuvor WaitingGroup, Boden +korrekt, kein Travel. Neue Artefakte checkpoints/2026-11-04-browser-wait/. +Loader war1, nun0; sicherer Logout bei Buildbeginn angefordert, Build-j10 +läuft parallel. Erst nach erfolgreichem Build normaler Shutdown10, dann +Prozessende, Install, passiver GDB-Neustart/Ready/Ports, Loader wieder1. +Keine separate5min-Frist/manuelle Restbotliste mehr. Tests/Live noch ausstehend. +Kein Commit/Push für aktuelle Änderung angefordert. + +--- + # 2026-11-04 – Lokaler Commit auf Nutzerwunsch Nutzer hat nun den lokalen Commit angefordert. Zusammengefasst werden die diff --git a/src/server/game/DungeonFinding/LFGMgr.cpp b/src/server/game/DungeonFinding/LFGMgr.cpp index f1f35056e6..18e38da5a8 100644 --- a/src/server/game/DungeonFinding/LFGMgr.cpp +++ b/src/server/game/DungeonFinding/LFGMgr.cpp @@ -1174,10 +1174,23 @@ void LFGMgr::MakeNewGroup(LfgProposal const& proposal) // Update group info grp->SendUpdate(); - // Teleport Player - for (GuidList::const_iterator it = playersToTeleport.begin(); it != playersToTeleport.end(); ++it) - if (Player* player = ObjectAccessor::FindPlayer(*it)) + // Complete clientless entries before waiting for human transfer acknowledgements. + // Resolve connected players even during the brief out-of-world transfer state. + GuidList botsToTeleport; + for (auto it = playersToTeleport.begin(); it != playersToTeleport.end(); ) + { + auto current = it++; + if (ClientlessBrowserPolicy::Bot(ObjectAccessor::FindConnectedPlayer(*current))) + botsToTeleport.splice(botsToTeleport.end(), playersToTeleport, current); + } + playersToTeleport.splice(playersToTeleport.begin(), botsToTeleport); + for (auto guid : playersToTeleport) + if (Player* player = ObjectAccessor::FindConnectedPlayer(guid)) + { TeleportPlayer(player, false); + if (player->GetSession()->IsClientless() && player->IsBeingTeleported()) + player->GetSession()->CompleteClientlessTeleport(); + } } uint32 LFGMgr::AddProposal(LfgProposal& proposal) diff --git a/src/server/game/DungeonFinding/LFGQueue.cpp b/src/server/game/DungeonFinding/LFGQueue.cpp index 3a67abb092..12539a60f4 100644 --- a/src/server/game/DungeonFinding/LFGQueue.cpp +++ b/src/server/game/DungeonFinding/LFGQueue.cpp @@ -424,7 +424,15 @@ uint8 LFGQueue::FindLowPopulationGroups() playerCount += uint32(itrData->second.roles.size()); } - if (playerCount < minPlayers) + // Every participating queue entry must have received the full + // fallback window, including a human joining long-waiting bots. + bool waited = std::all_of(check.begin(), check.end(), [&](ObjectGuid guid) + { + auto entry = QueueDataStore.find(guid); + return entry != QueueDataStore.end() && now >= entry->second.joinTime && + uint64(now - entry->second.joinTime) >= fallbackDelay; + }); + if (playerCount < minPlayers || !waited) continue; TC_LOG_DEBUG("lfg.queue.lowpopulation", diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index 3225450f9f..55250b5ac6 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -503,6 +503,14 @@ void AdventureBehaviorMgr::Begin(Player * player, Record & r) (!r.ExpeditionStarted && !sAdventureGroupMgr->Complete(player)))) { r.State=Phase::WaitingGroup;r.Detail="waiting for a suitable group / native browser queue"; + r.Anchor.Relocate(player);r.AnchorMap=player->GetMapId();r.WaitingLocation.clear(); + float nearest=std::numeric_limits::max(); + for (auto const& site:sAdventureLocationMgr->Locations()) + if (site.Point.MapId==r.AnchorMap && Allowed(player,site,"groupformation")) + { + float distance=player->GetExactDist(site.Point.X,site.Point.Y,site.Point.Z); + if (distanceQueueBrowser(player,r.Intent); return; } @@ -624,7 +632,7 @@ void AdventureBehaviorMgr::ResetMicro(Player * player, Record & r) bool AdventureBehaviorMgr::LocalMove(Player * player, Record & r, Position const & target, float maxPath, bool riding) { - auto l = sAdventureLocationMgr->Find(r.Location); + auto l = sAdventureLocationMgr->Find(r.State==Phase::WaitingGroup?r.WaitingLocation:r.Location); if (! l || player->GetMapId() != r.AnchorMap || r.Anchor.GetExactDist(target) > (r.Leisure.PlannedWalk ? 250.0f : (r.Intent=="economy" || r.Intent=="adventure" || r.Intent=="profession") ? 200.0f : r.Intent=="leisure" ? 120.0f : 60.0f)) return false; uint32 const boundary=l->IsArea?l->AreaId:((r.Intent=="leisure" || r.Intent=="economy" || r.Intent=="adventure" || r.Intent=="profession")?l->ZoneId:0); if (boundary) @@ -701,10 +709,10 @@ bool AdventureBehaviorMgr::LeisureAvailable(Player* p,Record const& r,AdventureM auto rod=p->GetWeaponForAttack(BASE_ATTACK,true);float direction=0; return rod && rod->GetTemplate()->GetSubClass()==ITEM_SUBCLASS_WEAPON_FISHING_POLE && FishingDirection(p,direction); } - if (r.Intent=="groupformation") return a.Requirement=="walk"; + if ((r.Intent=="groupformation" || r.State==Phase::WaitingGroup)) return a.Requirement=="walk"; if (r.Intent!="leisure") return false; auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); - auto location=sAdventureLocationMgr->Find(r.Location); + auto location=sAdventureLocationMgr->Find(r.State==Phase::WaitingGroup?r.WaitingLocation:r.Location); if (!profile || !location) return false; if (a.Requirement=="training") return sAdventureCombatMgr->TrainingAvailable(p); if (a.Requirement=="fish") @@ -726,23 +734,23 @@ bool AdventureBehaviorMgr::LeisureAvailable(Player* p,Record const& r,AdventureM bool AdventureBehaviorMgr::PlanLeisureWalk(Player* p, Record& r) { auto& s=r.Leisure; - auto location=sAdventureLocationMgr->Find(r.Location); + auto location=sAdventureLocationMgr->Find(r.State==Phase::WaitingGroup?r.WaitingLocation:r.Location); uint32 const boundary=location->IsArea?location->AreaId:location->ZoneId; // Choose one substantial destination, then follow its actual ground corridor. // Randomness selects the outing, never the next individual footstep. float const offset=std::uniform_real_distribution(0,6.2831853f)(r.Random); - float const budget=r.Intent=="groupformation"?220.0f:std::min(220.0f,std::max(100.0f,float(r.MicroTimer)*0.001f*1.0f)); + float const budget=(r.Intent=="groupformation" || r.State==Phase::WaitingGroup)?220.0f:std::min(220.0f,std::max(100.0f,float(r.MicroTimer)*0.001f*1.0f)); float best=0; for (unsigned i=0;i<16;++i) { float const angle=offset+float(i%8)*6.2831853f/8; - float const radius=r.Intent=="groupformation"?(i<8?110.0f:70.0f):(i<8?std::min(150.0f,budget*0.7f):80.0f); + float const radius=(r.Intent=="groupformation" || r.State==Phase::WaitingGroup)?(i<8?110.0f:70.0f):(i<8?std::min(150.0f,budget*0.7f):80.0f); Position goal; goal.Relocate(s.Origin.GetPositionX()+std::cos(angle)*radius, s.Origin.GetPositionY()+std::sin(angle)*radius,s.Origin.GetPositionZ()); float const z=p->GetMap()->GetHeight(p->GetPhaseShift(),goal.GetPositionX(),goal.GetPositionY(), s.Origin.GetPositionZ()+2,true,15); - if (!std::isfinite(z) || z<=INVALID_HEIGHT+1 || std::fabs(z-s.Origin.GetPositionZ())>(r.Intent=="groupformation"?20.0f:3.0f)) continue; + if (!std::isfinite(z) || z<=INVALID_HEIGHT+1 || std::fabs(z-s.Origin.GetPositionZ())>((r.Intent=="groupformation" || r.State==Phase::WaitingGroup)?20.0f:3.0f)) continue; goal.m_positionZ=z; PathGenerator navigation(p); navigation.SetPathLengthLimit(240); @@ -763,13 +771,13 @@ bool AdventureBehaviorMgr::PlanLeisureWalk(Player* p, Record& r) p->GetMap()->GetTerrain()->GetFullTerrainStatusForPosition(p->GetPhaseShift(),p->GetMapId(),point.x,point.y,point.z,terrain); uint32 zone=0,area=0; p->GetMap()->GetTerrain()->GetZoneAndAreaId(p->GetPhaseShift(),p->GetMapId(),zone,area,target); - if ((r.Intent!="groupformation" && !terrain.outdoors) || (boundary && area!=boundary && zone!=boundary)) {valid=false;break;} + if (((r.Intent!="groupformation" && r.State!=Phase::WaitingGroup) && !terrain.outdoors) || (boundary && area!=boundary && zone!=boundary)) {valid=false;break;} endsOutdoors=terrain.outdoors; length+=step;section+=step;last=target; if (section>=20) {outbound.push_back(target);section=0;} } float const span=s.Origin.GetExactDist(last); - if (!valid || length>budget || span<65.0f || (r.Intent=="groupformation" && !endsOutdoors) || length>span*2.5f || last.GetExactDist(goal)>1.5f) continue; + if (!valid || length>budget || span<65.0f || ((r.Intent=="groupformation" || r.State==Phase::WaitingGroup) && !endsOutdoors) || length>span*2.5f || last.GetExactDist(goal)>1.5f) continue; if (outbound.empty() || outbound.back().GetExactDist(last)>2) outbound.push_back(last); else outbound.back()=last; if (span>best) @@ -1159,9 +1167,9 @@ bool AdventureBehaviorMgr::UpdateRecovery(Player* player, Record& r, uint32 diff AdventureSocialContext AdventureBehaviorMgr::SocialContext(Player* player,Record& r) { auto profile=sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter()); - auto location=sAdventureLocationMgr->Find(r.Location); + auto location=sAdventureLocationMgr->Find(r.State==Phase::WaitingGroup?r.WaitingLocation:r.Location); return {r.Anchor,r.AnchorMap,location&&location->Point.Category=="festival", - profile?profile->Locale:"enUS",r.Intent,r.Random,r.TargetHistory,r.VariantHistory, + profile?profile->Locale:"enUS",r.State==Phase::WaitingGroup?"groupformation":r.Intent,r.Random,r.TargetHistory,r.VariantHistory, [this,player,&r](Position const& target,float maxPath){return LocalMove(player,r,target,maxPath);}, r.Intent != "rest" || (location && location->Point.Category == "rest_ground"), location && r.Intent=="economy"?AdventureEconomyPolicy::LocationRequirement(location->Point.Category):"", @@ -1189,7 +1197,7 @@ void AdventureBehaviorMgr::Micro(Player * player, Record & r) auto profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter()); if (! profile) return; - auto l = sAdventureLocationMgr->Find(r.Location); + auto l = sAdventureLocationMgr->Find(r.State==Phase::WaitingGroup?r.WaitingLocation:r.Location); if (! l) return; auto social=SocialContext(player,r); if (r.Intent=="rest") @@ -1259,7 +1267,7 @@ void AdventureBehaviorMgr::Micro(Player * player, Record & r) { auto a=ResolvedAction(catalog); if(a.Kind=="unsupported")continue; - auto w = a.Intents.find(r.Intent=="pvp"?"adventure":r.Intent); + auto w = a.Intents.find(r.State==Phase::WaitingGroup?"groupformation":r.Intent=="pvp"?"adventure":r.Intent); if (w == a.Intents.end()) continue; if ((a.Kind=="leisure" || a.Kind=="outing") && !LeisureAvailable(player,r,a)) continue; if (a.Kind=="recovery" && !RecoveryAvailable(player,r,a)) continue; @@ -1479,7 +1487,18 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) if (entry == _records.end() || ! entry->second.Enabled || ! player->IsInWorld()) return; auto & r = entry->second; ClientlessTravelTelemetry::Tick(player); - if(player->InBattleground())return; + if(player->InBattleground()) + { + if(r.State==Phase::WaitingGroup)ResetMicro(player,r); + return; + } + if (r.State==Phase::WaitingGroup && (player->GetMap()->IsDungeon() || player->GetMapId()!=r.AnchorMap)) + { + // Native transfer owns movement; discard the old city activity. + ResetMicro(player,r);r.WaitingLocation.clear(); + if (player->GetMap()->IsDungeon()) return; + r.Anchor.Relocate(player);r.AnchorMap=player->GetMapId(); + } if(UpdateNavigationRescue(player,r))return; if(r.AreaRevision!=sAdventureAreaMgr->Revision()) { @@ -1612,8 +1631,13 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) // Once a native browser owns this group, never race it with physical travel. auto group=player->GetGroup(); if(world && (r.ExpeditionStarted || sAdventureGroupMgr->Complete(player)) && (!group || !group->isLFGGroup()) && !player->isUsingLfg() && !player->InBattlegroundQueue()) - {r.Dirty=true;r.State=Phase::Idle;} - else sAdventureGroupMgr->QueueBrowser(player,r.Intent); + {ResetMicro(player,r);r.Dirty=true;r.State=Phase::Idle;} + else + { + sAdventureGroupMgr->QueueBrowser(player,r.Intent); + if (AdventureGroupPolicy::Capital(player->GetZoneId()) && !r.WaitingLocation.empty() && + (r.Intent=="instance" || r.Intent=="raid" || r.Intent=="pvp")) UpdateActivities(player,r); + } return; } if (r.State == Phase::Travel) @@ -1667,53 +1691,59 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) { if ((r.Intent=="instance" || r.Intent=="raid")) {UpdateInstance(player,r,now);return;} - if(sAdventureGroupMgr->FollowMember(player))return; - if (r.Recovery.Active) - { - if (UpdateRecovery(player,r,1000)) return; - ResetMicro(player,r);r.MicroTimer=3000;return; - } - if(r.Social.Active) - { - if(UpdateSocial(player,r,1000))return; - ResetMicro(player,r);r.MicroTimer=3000;return; - } - if (r.Micro.empty() && r.MicroTimer) return; - if (player->GetMapId() != r.AnchorMap) - { - Fallback(player, r); - return; - } - auto travel = sClientlessTravelMgr->GetStatus(guid); - bool moving = travel && travel->State == ClientlessTravelState::Moving; - if (! r.Micro.empty()) - { - if (! r.GestureTimer && r.Micro == "drink") - { - player->SendPlaySpellVisualKit(SPELL_VISUAL_KIT_DRINK, 0, 0); - r.GestureTimer = 7000; - } - if (! moving && r.WorkPending) - { - auto object = player->GetMap()->GetGameObject(r.WorkObject); - if (! object || player->GetExactDist(object) > 4 || ! player->IsWithinLOSInMap(object)) - { - ResetMicro(player, r); - r.MicroTimer = 3000; - return; - } - player->SetFacingToObject(object); - player->SetUInt32Value(UNIT_NPC_EMOTESTATE, r.WorkEmote); - r.WorkPending = false; - r.Detail = "performing work demonstration"; - } - if (r.MicroTimer || moving) return; - ResetMicro(player, r); - } - Micro(player, r); + UpdateActivities(player,r); } } +void AdventureBehaviorMgr::UpdateActivities(Player* player, Record& r) +{ + uint32 guid=player->GetGUID().GetCounter(); + if(sAdventureGroupMgr->FollowMember(player))return; + if (r.Recovery.Active) + { + if (UpdateRecovery(player,r,1000)) return; + ResetMicro(player,r);r.MicroTimer=3000;return; + } + if(r.Social.Active) + { + if(UpdateSocial(player,r,1000))return; + ResetMicro(player,r);r.MicroTimer=3000;return; + } + if (r.Micro.empty() && r.MicroTimer) return; + if (player->GetMapId() != r.AnchorMap) + { + Fallback(player, r); + return; + } + auto travel = sClientlessTravelMgr->GetStatus(guid); + bool moving = travel && travel->State == ClientlessTravelState::Moving; + if (! r.Micro.empty()) + { + if (! r.GestureTimer && r.Micro == "drink") + { + player->SendPlaySpellVisualKit(SPELL_VISUAL_KIT_DRINK, 0, 0); + r.GestureTimer = 7000; + } + if (! moving && r.WorkPending) + { + auto object = player->GetMap()->GetGameObject(r.WorkObject); + if (! object || player->GetExactDist(object) > 4 || ! player->IsWithinLOSInMap(object)) + { + ResetMicro(player, r); + r.MicroTimer = 3000; + return; + } + player->SetFacingToObject(object); + player->SetUInt32Value(UNIT_NPC_EMOTESTATE, r.WorkEmote); + r.WorkPending = false; + r.Detail = "performing work demonstration"; + } + if (r.MicroTimer || moving) return; + ResetMicro(player, r); + } + Micro(player, r); +} + bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args) { std::istringstream in(args ? args : ""); diff --git a/src/server/game/World/AdventureBehaviorMgr.h b/src/server/game/World/AdventureBehaviorMgr.h index f69cded1f6..ec35200046 100644 --- a/src/server/game/World/AdventureBehaviorMgr.h +++ b/src/server/game/World/AdventureBehaviorMgr.h @@ -63,7 +63,7 @@ private: bool Enabled=false, Schedule=false, Publishing=false, Queued=false, Dirty=true, GroupOverride=false; Phase State=Phase::Idle; std::string Intent, Location, WantedIntent, WantedLocation, Detail, Micro, ManualNextIntent, ManualNextLocation; - std::string PersonalIntent,PersonalLocation; + std::string PersonalIntent,PersonalLocation,WaitingLocation; time_t PersonalDeadline=0; time_t Deadline=0; AdventureGroupPolicy::Departure Departure; @@ -120,6 +120,7 @@ private: void Queue(Player* player, Record& record, std::string const& action, std::string const& parameter, std::string const& value, bool hard = false); void ResetMicro(Player* player, Record& record); void Micro(Player* player, Record& record); + void UpdateActivities(Player* player, Record& record); AdventureSocialContext SocialContext(Player* player, Record& record); bool UpdateSocial(Player* player, Record& record, uint32 diff); bool LocalMove(Player* player, Record& record, Position const& target, float maxPath, bool riding=false); diff --git a/src/server/game/World/AdventureGroupMgr.cpp b/src/server/game/World/AdventureGroupMgr.cpp index d57e624790..f752ab4934 100644 --- a/src/server/game/World/AdventureGroupMgr.cpp +++ b/src/server/game/World/AdventureGroupMgr.cpp @@ -148,6 +148,21 @@ void AdventureGroupMgr::Offline(Player* p) CancelBrowser(p); _records.erase(p->GetGUID()); } +bool AdventureGroupMgr::WaitingForBrowserGroup(Player* player) const +{ + if (!Ready(player) || player->GetGroup() || !AdventureGroupPolicy::Capital(player->GetZoneId())) return false; + auto plan=sAdventureBehaviorMgr->GroupPlan(player); + if (!plan.Enabled || plan.Forming) return false; + if (plan.Intent=="pvp") + { + if (!player->InBattlegroundQueue() || player->InBattleground()) return false; + for (uint32 slot=0;slotIsInvitedForBattlegroundQueueType(player->GetBattlegroundQueueTypeId(slot))) return false; + return true; + } + return (plan.Intent=="instance" || plan.Intent=="raid") && + sLFGMgr->GetState(player->GetGUID())==lfg::LFG_STATE_QUEUED; +} bool AdventureGroupMgr::Accept(Player* invited,Player* inviter) { if(!CanInvite(inviter,invited))return false; @@ -157,10 +172,11 @@ bool AdventureGroupMgr::Accept(Player* invited,Player* inviter) if(it==_records.end() || it->second.Inviter!=inviter->GetGUID() || it->second.InviteUntilGroupPlan(inviter),b=sAdventureBehaviorMgr->GroupPlan(invited); auto intent=Activity(a); - if(!a.Enabled || !b.Enabled || a.Forming!=b.Forming || + bool waiting=WaitingForBrowserGroup(invited); + if(!a.Enabled || !b.Enabled || (a.Forming!=b.Forming && !(a.Forming && waiting)) || !AdventureGroupPolicy::Compatible(intent,Activity(b),inviter->getLevel(),invited->getLevel()))return false; auto group=inviter->GetGroup(); - return (!group || (group->GetLeaderGUID()==inviter->GetGUID() && group->GetMembersCount()GetLeaderGUID()==inviter->GetGUID() && group->GetMembersCount()isUsingLfg() || p->InBattlegroundQueue())return; + if(!Ready(p) || p->isUsingLfg() || p->InBattlegroundQueue() || WaitingForBrowserGroup(p))return; if(EnforceCapacity(p))return; auto plan=sAdventureBehaviorMgr->GroupPlan(p);if(!plan.Enabled)return; auto intent=Activity(plan);if(!AdventureGroupPolicy::Eligible(intent))return; @@ -592,9 +608,10 @@ void AdventureGroupMgr::Update(Player* p) { if(guid==p->GetGUID())continue; auto other=ObjectAccessor::FindPlayer(guid); - if(!Ready(other) || other->isUsingLfg() || other->InBattlegroundQueue() || other->GetGroupInvite() || other->GetTeam()!=p->GetTeam())continue; + bool waiting=WaitingForBrowserGroup(other); + if(!Ready(other) || (other->isUsingLfg() && !(plan.Forming && waiting)) || (other->InBattlegroundQueue() && !(plan.Forming && waiting)) || other->GetGroupInvite() || other->GetTeam()!=p->GetTeam())continue; auto peerPlan=sAdventureBehaviorMgr->GroupPlan(other); - if(!peerPlan.Enabled || peerPlan.Forming!=plan.Forming || + if(!peerPlan.Enabled || (peerPlan.Forming!=plan.Forming && !(plan.Forming && waiting)) || !AdventureGroupPolicy::Compatible(intent,Activity(peerPlan),p->getLevel(),other->getLevel()))continue; if(!ProfessionCompatible(p,other,intent))continue; if(auto existing=other->GetGroup()) diff --git a/src/server/game/World/AdventureGroupMgr.h b/src/server/game/World/AdventureGroupMgr.h index 7d565ee75e..a584a96d34 100644 --- a/src/server/game/World/AdventureGroupMgr.h +++ b/src/server/game/World/AdventureGroupMgr.h @@ -21,6 +21,7 @@ public: static AdventureGroupMgr* instance(); void Update(Player* player); bool Complete(Player* player); + bool WaitingForBrowserGroup(Player* player) const; bool NeedsRole(Player* leader,Player* member); void UpdateBrowser(Player* player); void QueueBrowser(Player* player,std::string const& intent); diff --git a/src/server/game/World/AdventureInteractionMgr.cpp b/src/server/game/World/AdventureInteractionMgr.cpp index 27cad8be50..cf483147f9 100644 --- a/src/server/game/World/AdventureInteractionMgr.cpp +++ b/src/server/game/World/AdventureInteractionMgr.cpp @@ -132,6 +132,7 @@ bool AdventureInteractionMgr::Update(Player* p,uint32 diff) (inviter->GetSession()->IsClientless() || AdventureGroupPolicy::AcceptPlayerLevel(p->getLevel(),inviter->getLevel())) && (inviter->GetSession()->IsClientless()?sAdventureGroupMgr->Accept(p,inviter): frand(0,1)InteractionIntent(p),Sociability(p))); + if (accept) sAdventureGroupMgr->CancelBrowser(p); WorldPackets::Party::PartyInviteResponse response{WorldPacket(CMSG_PARTY_INVITE_RESPONSE)};response.Accept=accept; p->GetSession()->HandlePartyInviteResponseOpcode(response);r.Invite.Clear(); TC_LOG_INFO("server.adventurebot","INTERACTION_INVITE guid=%u leader=%u accepted=%u",p->GetGUID().GetCounter(),leader.GetCounter(),accept); diff --git a/tests/clientless/test_adventure_groups.py b/tests/clientless/test_adventure_groups.py index 9b9250f6ab..a0fa9803ae 100644 --- a/tests/clientless/test_adventure_groups.py +++ b/tests/clientless/test_adventure_groups.py @@ -27,14 +27,16 @@ namespace std{template<>struct hash{size_t operator()(ObjectGuid g)c 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 int GROUP_REMOVEMETHOD_LEAVE=1; +constexpr unsigned PLAYER_MAX_BATTLEGROUND_QUEUES=3;constexpr int GROUP_REMOVEMETHOD_LEAVE=1; struct Group{struct Slot{ObjectGuid guid;};std::vectorslots;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 isUsingLfg(){return false;}bool InBattlegroundQueue(){return false;} + 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 ↦} 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);} @@ -72,8 +74,9 @@ 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::Accept(', 'void AdventureGroupMgr::CheckFullGroup(', 'void AdventureGroupMgr::Update(', 'void AdventureGroupMgr::Invite(', 'void AdventureGroupMgr::RequestPlayerAdmission(', 'bool AdventureGroupMgr::PlayerLeadership(', 'bool AdventureGroupMgr::EnforceCapacity(']:code+='\n'+fun(name) +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; @@ -143,6 +146,31 @@ int main(){ 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); diff --git a/tests/clientless/test_adventure_interactions.py b/tests/clientless/test_adventure_interactions.py index 06c8cf6070..27ff96eb1a 100644 --- a/tests/clientless/test_adventure_interactions.py +++ b/tests/clientless/test_adventure_interactions.py @@ -68,7 +68,7 @@ struct Travel {Nav nav;bool active=false,valid=true;int starts=0,cancels=0;Nav*G enum class ClientlessActionExecutionState{Moving,SelectingTarget,Completed};struct Execution {ClientlessActionExecutionState State=ClientlessActionExecutionState::Completed;}; struct Executor {Execution status;Execution*GetStatus(unsigned){return &status;}}executor;auto sClientlessActionExecutorMgr=&executor; struct Graph {void EnsureLoaded(){}bool FindNearestNodeToPosition(unsigned,float,float,float,std::string&node,float&distance){node="entrance";distance=10;return true;}}graph;auto sClientlessTravelGraphMgr=&graph; -struct BotGroups{void Offline(Player*){}void Update(Player*){}bool Accept(Player*,Player*){return false;}bool botLed=false;bool BotLed(Player*){return botLed;}bool Forming(Player*){return false;}bool PlannedTransition(Player*){return false;}void Report(ChatHandler*,Player*){}}botGroups;auto sAdventureGroupMgr=&botGroups; +struct BotGroups{unsigned browserCancels=0;void CancelBrowser(Player*){++browserCancels;}void Offline(Player*){}void Update(Player*){}bool Accept(Player*,Player*){return false;}bool botLed=false;bool BotLed(Player*){return botLed;}bool Forming(Player*){return false;}bool PlannedTransition(Player*){return false;}void Report(ChatHandler*,Player*){}}botGroups;auto sAdventureGroupMgr=&botGroups; struct GroupTravel{bool protectedTravel=false,separated=false;int follows=0;bool Follow(Player*p){++follows;return p->GetGroup()!=nullptr;}bool Protected(Player*){return protectedTravel;}bool Separated(Player*){return separated;}void Clear(Player*){}void Offline(Player*){}void Report(ChatHandler*,Player*){}}groupTravel;auto sAdventureGroupTravelMgr=&groupTravel; '''+header+'\n'+source+r''' int main(){ diff --git a/tests/clientless/test_adventure_leisure.py b/tests/clientless/test_adventure_leisure.py index e1831ec8d0..6a53a41b30 100644 --- a/tests/clientless/test_adventure_leisure.py +++ b/tests/clientless/test_adventure_leisure.py @@ -89,10 +89,10 @@ struct Manager{void ClearQueuedActions(uint32){}}manager;auto sClientlessPlayerM struct Groups{unsigned cancellations=0;void CancelBrowser(Player*){++cancellations;}}groups;auto sAdventureGroupMgr=&groups; struct Executor{bool RequestStopAtNode(uint32){return false;}}executor;auto sClientlessActionExecutorMgr=&executor; class AdventureBehaviorMgr{public: - enum class Phase{Idle,OnSite,Switching}; + enum class Phase{Idle,OnSite,Switching,WaitingGroup}; struct Record{ AdventureInstancePolicy::Visit Instance; - std::string Intent="leisure",Location="city",WantedIntent,WantedLocation,Detail;Position Anchor;uint32 AnchorMap=0,MicroTimer=240000,RetryTimer=0;std::mt19937 Random{26}; + std::string Intent="leisure",Location="city",WaitingLocation="city",WantedIntent,WantedLocation,Detail;Position Anchor;uint32 AnchorMap=0,MicroTimer=240000,RetryTimer=0;std::mt19937 Random{26}; time_t Deadline=0,ExpeditionUntil=0;bool Dirty=false,ExpeditionStarted=false;std::string ExpeditionIntent;std::unordered_set Tried;Phase State=Phase::OnSite; struct LeisureScene{bool Active=false,Flying=false,Moving=false,StopRequested=false,Returning=false,PlannedWalk=false,Progressive=false;std::string Kind;Position Origin;std::vector Route,WalkTrace;std::size_t Step=0,OutboundSteps=0;uint32 Elapsed=0,LegElapsed=0,PauseMs=0,CompanionWait=0,CompletedLegs=0;ClientlessCompanionScene Companion;}Leisure; }; diff --git a/tests/clientless/test_browser_group_teleport.py b/tests/clientless/test_browser_group_teleport.py new file mode 100644 index 0000000000..c1fdf05229 --- /dev/null +++ b/tests/clientless/test_browser_group_teleport.py @@ -0,0 +1,37 @@ +"""Execute the native proposal teleport batch with transfer/session adapters.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/DungeonFinding/LFGMgr.cpp').read_text() +a=s.index(' // Complete clientless entries');z=s.index('\n}\n\nuint32 LFGMgr::AddProposal',a) +code=r''' +#include +#include +#include +#include +using ObjectGuid=unsigned;using GuidList=std::list; +std::vector order; +struct Player; +struct Session{bool bot;Player*player;bool IsClientless(){return bot;}void CompleteClientlessTeleport();}; +struct Player{unsigned id;bool world=true,pending=false;unsigned ack=0;Session session;Player(unsigned id,bool bot):id(id),session{bot,this}{}Session*GetSession(){return &session;}bool IsBeingTeleported(){return pending;}}; +void Session::CompleteClientlessTeleport(){++player->ack;player->pending=false;player->world=true;} +namespace ObjectAccessor{std::mapplayers;Player*FindConnectedPlayer(unsigned id){return players[id];}} +namespace ClientlessBrowserPolicy{bool Bot(Player*p){return p&&p->session.bot;}} +struct LFGMgr{void Run(GuidList playersToTeleport);void TeleportPlayer(Player*p,bool out){assert(!out);order.push_back(p->id);p->world=false;p->pending=true;}}; +void LFGMgr::Run(GuidList playersToTeleport){ +'''+s[a:z]+r''' +} +int main(){ + Player human(1,false),bot(2,true),transitioning(3,true);transitioning.world=false; + ObjectAccessor::players={{1,&human},{2,&bot},{3,&transitioning},{4,nullptr}}; + LFGMgr mgr;mgr.Run({1,2,3,4}); + assert((order==std::vector{2,3,1})); + assert(bot.ack==1&&transitioning.ack==1&&bot.world&&transitioning.world); + assert(human.ack==0&&human.pending&&!human.world); +} +''' +with tempfile.TemporaryDirectory() as folder: + p=Path(folder);(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('Native group teleport: all connected bots included and acknowledged, human client acknowledgement preserved, offline members skipped.') diff --git a/tests/clientless/test_browser_low_population_wait.py b/tests/clientless/test_browser_low_population_wait.py new file mode 100644 index 0000000000..096f40ef0a --- /dev/null +++ b/tests/clientless/test_browser_low_population_wait.py @@ -0,0 +1,45 @@ +"""Run the native fallback against old bot / freshly queued human combinations.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/DungeonFinding/LFGQueue.cpp').read_text() +a=s.index('uint8 LFGQueue::FindLowPopulationGroups()');z=s.index('\n/**',a) +code=r''' +#include +#include +#include +#include +#include +#include +#include +using uint8=uint8_t;using uint32=uint32_t;using uint64=uint64_t;using ObjectGuid=unsigned;using GuidList=std::list; +#define TC_LOG_DEBUG(...) ((void)0) +constexpr unsigned CONFIG_DUNGEON_FINDER_LOW_POPULATION_FALLBACK_DELAY=0,CONFIG_DUNGEON_FINDER_LOW_POPULATION_MIN_PLAYERS=1; +struct World{unsigned getIntConfig(unsigned id){return id?1:60;}}world;auto sWorld=&world; +namespace GameTime{time_t now=1000;time_t GetGameTime(){return now;}} +enum{LFG_COMPATIBLES_MATCH}; +struct Data{time_t joinTime;std::maproles{{1,8}};std::string bestCompatible="both";}; +using LfgQueueData=Data;using LfgQueueDataContainer=std::map; +struct LFGQueue{ + LfgQueueDataContainer QueueDataStore;GuidList currentQueueStore{1,2};unsigned matches=0; + uint8 FindLowPopulationGroups();void FindBestCompatibleInQueue(LfgQueueDataContainer::iterator){} + GuidList GetQueueGuidsFromKey(std::string){return currentQueueStore;} + int CheckCompatibility(GuidList const&,bool incomplete){assert(incomplete);++matches;currentQueueStore.clear();return LFG_COMPATIBLES_MATCH;} +}; +'''+s[a:z]+r''' +int main(){ + LFGQueue q;q.QueueDataStore[1]={0};q.QueueDataStore[2]={1000}; + assert(q.FindLowPopulationGroups()==0&&q.matches==0); + GameTime::now=1059;assert(q.FindLowPopulationGroups()==0&&q.matches==0); + GameTime::now=1060;assert(q.FindLowPopulationGroups()==1&&q.matches==1); + LFGQueue future;future.QueueDataStore[1]={0};future.QueueDataStore[2]={2000}; + assert(future.FindLowPopulationGroups()==0); + LFGQueue same;same.QueueDataStore[1]={1000};same.QueueDataStore[2]={1000}; + assert(same.FindLowPopulationGroups()==1); +} +''' +with tempfile.TemporaryDirectory() as folder: + p=Path(folder);(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('Native low-pop fallback: old bots cannot bypass a new human/group 60-second wait; 59/60-second and future timestamps passed.') diff --git a/tests/clientless/test_browser_wait_activities.py b/tests/clientless/test_browser_wait_activities.py new file mode 100644 index 0000000000..5d018ae580 --- /dev/null +++ b/tests/clientless/test_browser_wait_activities.py @@ -0,0 +1,52 @@ +"""Run the shared performance executor with a real waiting-state action selector.""" +from pathlib import Path +import subprocess,tempfile,re +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() +a=s.index('void AdventureBehaviorMgr::UpdateActivities(');z=s.index('\nbool AdventureBehaviorMgr::Command',a) +selector=re.search(r'auto w = a.Intents.find\([^;]+;',s).group() +code=r''' +#include +#include +#include +using uint32=unsigned; +constexpr int SPELL_VISUAL_KIT_DRINK=1,UNIT_NPC_EMOTESTATE=1; +struct Object{bool IsWithinLOSInMap(Object*){return true;}}object; +struct Map{unsigned id=1;Object*GetGameObject(unsigned){return &object;}}map; +struct Guid{unsigned GetCounter(){return 44;}}; +struct Player{Guid GetGUID(){return {};}unsigned mapid=1;unsigned GetMapId(){return mapid;}Map*GetMap(){return ↦}float GetExactDist(Object*){return 1;}bool IsWithinLOSInMap(Object*){return true;}void SetFacingToObject(Object*){}void SetUInt32Value(int,unsigned){}void SendPlaySpellVisualKit(int,int,int){}}; +enum class ClientlessTravelState{Moving,Arrived}; +struct Travel{struct Status{ClientlessTravelState State=ClientlessTravelState::Arrived;}status;Status*GetStatus(unsigned){return &status;}}travel;auto sClientlessTravelMgr=&travel; +struct Group{bool FollowMember(Player*){return false;}}group;auto sAdventureGroupMgr=&group; +struct AdventureBehaviorMgr{ + enum class Phase{WaitingGroup,OnSite};struct Record{Phase State=Phase::WaitingGroup;std::string Intent="instance",Location="remote-dungeon",Micro,Detail;unsigned AnchorMap=1,MicroTimer=0,GestureTimer=0,WorkObject=0,WorkEmote=0;bool WorkPending=false;struct{bool Active=false;}Recovery,Social;}; + unsigned calls=0,resets=0;std::string action; + void UpdateActivities(Player*,Record&); + void ResetMicro(Player*,Record&r){++resets;r.Micro.clear();r.Recovery.Active=r.Social.Active=false;} + bool UpdateRecovery(Player*,Record&,unsigned){return false;}bool UpdateSocial(Player*,Record&,unsigned){return true;} + void Fallback(Player*,Record&){assert(false);} + void Micro(Player*,Record&r){ + struct{std::mapIntents{{"groupformation",1},{"pvp",2},{"adventure",3},{"instance",4}};}a; +'''+selector+r''' + action=w->first;++calls;r.Micro="performative";r.MicroTimer=5000; + } +}; +'''+s[a:z]+r''' +int main(){ + AdventureBehaviorMgr mgr;AdventureBehaviorMgr::Record r;Player p; + mgr.UpdateActivities(&p,r);assert(mgr.calls==1&&mgr.action=="groupformation"&&r.Intent=="instance"&&r.Location=="remote-dungeon"); + mgr.UpdateActivities(&p,r);assert(mgr.calls==1); // ongoing action is not restarted + r.MicroTimer=0;travel.status.State=ClientlessTravelState::Moving;mgr.UpdateActivities(&p,r);assert(mgr.calls==1); + travel.status.State=ClientlessTravelState::Arrived;mgr.UpdateActivities(&p,r);assert(mgr.calls==2&&mgr.resets==1); + r.Social.Active=true;mgr.UpdateActivities(&p,r);assert(mgr.calls==2); + r.Social.Active=false;r.Micro.clear();r.MicroTimer=0;r.Intent="pvp"; + mgr.UpdateActivities(&p,r);assert(mgr.action=="groupformation"&&r.Intent=="pvp"); + r.Micro.clear();r.MicroTimer=0;r.State=AdventureBehaviorMgr::Phase::OnSite;r.Intent="pvp"; + mgr.UpdateActivities(&p,r);assert(mgr.action=="adventure"); +} +''' +with tempfile.TemporaryDirectory() as folder: + p=Path(folder);(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('Waiting activities: formation catalog selected without changing dungeon intent/destination; action, movement and social lifetimes preserved.')