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.')