From 7b4994400b754b3078a6cddb9fbd19cf4a0143eb Mon Sep 17 00:00:00 2001 From: nico Date: Sat, 31 Oct 2026 22:13:39 +0100 Subject: [PATCH] Stabilize grouped instance intent transitions --- doc/clientless/SESSION-RESUME.md | 30 +++++++++++++ .../game/World/AdventureBehaviorMgr.cpp | 42 ++++++++++++++++++- src/server/game/World/AdventureBehaviorMgr.h | 1 + .../game/World/AdventureInteractionMgr.cpp | 5 ++- .../test_adventure_intent_fallbacks.py | 38 +++++++++++++++++ .../clientless/test_adventure_interactions.py | 6 ++- 6 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 tests/clientless/test_adventure_intent_fallbacks.py diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 38b99bc676..69c79a3d8a 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -3653,3 +3653,33 @@ Console-Beispiele ohne führenden Punkt: Der Nutzer möchte vor allem konkrete, überprüfte Ergebnisse und wenig Bestätigungsfragen. Die Hauptberufs-Rückfrage ist bereits BEANTWORTET; nicht noch einmal stellen. Testservermanagement und lokale Commits sind autorisiert. + +## Checkpoint 2026-10-31 – Gruppen-/Instanz-Resilienz + +Auf Nutzerwunsch wurden die ersten drei Befunde des Gruppentests bearbeitet: + +- `AdventureBehaviorMgr::Fallback` verwirft bei einem fehlgeschlagenen + Anreiseversuch nicht mehr den aktiven Instanzbesuch. Derselbe Eingang wird + nach 30 Sekunden erneut versucht; dadurch kann ein Stundenblock nicht mehr + nacheinander verschiedene Instanzen auswählen. +- Ein Mitglied einer botgeführten Gruppe wählt bei einem lokal nicht + erreichbaren, vom Anführer geerbten Ziel kein eigenes Ersatzziel mehr. Es + wartet auf Gruppenreise, Browseraufnahme oder Neuplanung des Anführers. Dies + beseitigt die bei Fizzira (GUID 43) beobachtete Endlosschleife, bei der + `AdoptGroup` das gerade gewählte Fallbackziel sofort wieder überschrieb. +- Die 10-Minuten-AFK-/Intentwechsel-Austrittslogik ist nun während der gesamten + Gruppenbildung, Sammelphase, vorgezogenen Abreise und des sicheren + Intent-Handoffs gesperrt, nicht nur solange `GroupPlan.Forming` wahr ist. + +Regressionstests: +`tests/clientless/test_adventure_interactions.py` und +`tests/clientless/test_adventure_intent_fallbacks.py` bestanden. Vollbuild mit +`cmake --build /home/wow_admin/wow_cata_server/build/debug -j10` und Installation +waren erfolgreich. Der Server startete frisch bis `(worldserver-daemon) ready`, +PID 56787, und lauscht auf 8085/8087. Loader und Testbots waren beim Start +deaktiviert und wurden danach wieder auf die vorherigen Werte gestellt; die +Population loggt regulär ein. Fizzira erreichte Online; im frischen Log trat +ein einzelner normaler Fallback vom Gruppenbildungsziel `poi_tele_1377` auf, +aber keine wiederholte A/B-Schleife und kein Instanzbesuchswechsel. Die vom +Nutzer ausdrücklich zurückgestellten Befunde 4/5 +(Gruppenkonsolidierung und Raid-Browser/Intent-Auswertung) wurden nicht geändert. diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index bae1bcf5aa..211fe62979 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -531,7 +531,38 @@ void AdventureBehaviorMgr::Begin(Player * player, Record & r) void AdventureBehaviorMgr::Fallback(Player * player, Record & r) { - if (r.Instance.Active && r.Instance.Stage==AdventureInstancePolicy::Phase::Travel) FinishInstance(player,r); + if (r.Instance.Active && r.Instance.Stage==AdventureInstancePolicy::Phase::Travel) + { + // An instance/raid block owns one persistent visit. A failed approach + // may be retried, but must never delete that visit and roll another + // dungeon during the same block. UpdateInstance will turn a visit that + // can no longer arrive in time into its normal final rest phase. + r.State=Phase::Idle; + r.Dirty=true; + r.Queued=false; + r.RetryTimer=30000; + r.Detail="instance entrance travel failed; retrying the same visit"; + TC_LOG_WARN("server.adventurebot","INSTANCE_TRAVEL_RETRY guid=%u location='%s' retryMs=%u", + player->GetGUID().GetCounter(),r.Instance.Location.c_str(),r.RetryTimer); + return; + } + if (auto group=player->GetGroup(); group && group->GetLeaderGUID()!=player->GetGUID() && + sAdventureGroupMgr->BotLed(player) && r.GroupOverride) + { + // The leader owns a bot-led expedition target. A member-local fallback + // would be overwritten by AdoptGroup on the next tick, producing an + // endless A->B->A loop with Tried reset to one. Hold the inherited + // target and let group travel, browser admission or leader replanning + // decide the next transition. + r.State=Phase::Idle; + r.Dirty=false; + r.Queued=false; + r.RetryTimer=30000; + r.Detail="group destination unavailable locally; waiting for leader"; + TC_LOG_WARN("server.adventurebot","GROUP_MEMBER_FALLBACK_HOLD guid=%u leader=%u location='%s' retryMs=%u", + player->GetGUID().GetCounter(),group->GetLeaderGUID().GetCounter(),r.Location.c_str(),r.RetryTimer); + return; + } r.Tried.insert(r.Location); AdventureLocation const * nearest = nullptr; double best = std::numeric_limits < double >::infinity(); @@ -2143,6 +2174,15 @@ std::string AdventureBehaviorMgr::InteractionSchedule(Player* player) auto const& slot=r.Plan[Local(GameTime::GetGameTime()).tm_hour]; return slot.Intent+":"+slot.Location; } +bool AdventureBehaviorMgr::ProtectGroupLeave(Player* player) const +{ + auto it=_records.find(player->GetGUID().GetCounter()); + if(it==_records.end())return false; + auto const& r=it->second;auto now=GameTime::GetGameTime(); + return r.Intent=="groupformation" || r.WantedIntent=="groupformation" || + r.Gathering || r.Departure.Active(now) || r.State==Phase::Switching || + r.State==Phase::Handover; +} void AdventureBehaviorMgr::InterruptForInteraction(Player* player) { diff --git a/src/server/game/World/AdventureBehaviorMgr.h b/src/server/game/World/AdventureBehaviorMgr.h index a0e4210e97..ff9d1e447c 100644 --- a/src/server/game/World/AdventureBehaviorMgr.h +++ b/src/server/game/World/AdventureBehaviorMgr.h @@ -42,6 +42,7 @@ public: std::string CombatIntent(Player* player) const; std::string InteractionIntent(Player* player) const; std::string InteractionSchedule(Player* player); + bool ProtectGroupLeave(Player* player) const; private: bool EditPlan(ChatHandler* handler, std::string const& args); enum class Phase {Idle, Travel, OnSite, Switching, Handover, WaitingGroup}; diff --git a/src/server/game/World/AdventureInteractionMgr.cpp b/src/server/game/World/AdventureInteractionMgr.cpp index 3cca84a273..48cf13cadf 100644 --- a/src/server/game/World/AdventureInteractionMgr.cpp +++ b/src/server/game/World/AdventureInteractionMgr.cpp @@ -175,10 +175,11 @@ bool AdventureInteractionMgr::Update(Player* p,uint32 diff) if(leader->isAFK()){if(!r.AfkSince)r.AfkSince=now;}else r.AfkSince=0; } bool forming=sAdventureGroupMgr->Forming(p); - if(forming){r.LastActivity=now;r.AfkSince=0;r.Schedule=sAdventureBehaviorMgr->InteractionSchedule(p);} + bool protectedFormation=forming || sAdventureBehaviorMgr->ProtectGroupLeave(p); + if(protectedFormation){r.LastActivity=now;r.AfkSince=0;r.Schedule=sAdventureBehaviorMgr->InteractionSchedule(p);} bool protectedTravel=sAdventureGroupTravelMgr->Protected(p) || p->isUsingLfg() || p->InBattlegroundQueue() || p->InBattleground(); if(protectedTravel){r.LastActivity=now;r.AfkSince=0;} - if(!forming && !protectedTravel && AdventureInteractionPolicy::CanLeave(p->IsAlive(),p->IsInCombat(),p->GetMap()->IsDungeon(),p->GetTransport() || p->IsInFlight() || p->IsFlying())) + if(!protectedFormation && !protectedTravel && AdventureInteractionPolicy::CanLeave(p->IsAlive(),p->IsInCombat(),p->GetMap()->IsDungeon(),p->GetTransport() || p->IsInFlight() || p->IsFlying())) { if(r.Schedule!=sAdventureBehaviorMgr->InteractionSchedule(p)) { diff --git a/tests/clientless/test_adventure_intent_fallbacks.py b/tests/clientless/test_adventure_intent_fallbacks.py new file mode 100644 index 0000000000..69810c684e --- /dev/null +++ b/tests/clientless/test_adventure_intent_fallbacks.py @@ -0,0 +1,38 @@ +"""Guard the two fallback invariants that previously caused live intent loops.""" +from pathlib import Path + +root = Path(__file__).resolve().parents[2] +source = (root / "src/server/game/World/AdventureBehaviorMgr.cpp").read_text() + +start = source.index("void AdventureBehaviorMgr::Fallback(") +end = source.index("void AdventureBehaviorMgr::ResetMicro(", start) +fallback = source[start:end] + +instance_guard = fallback.index("r.Instance.Stage==AdventureInstancePolicy::Phase::Travel") +ordinary_reselection = fallback.index("r.Tried.insert(r.Location)") +assert instance_guard < ordinary_reselection +assert "FinishInstance(player,r)" not in fallback[instance_guard:ordinary_reselection] +assert "INSTANCE_TRAVEL_RETRY" in fallback[instance_guard:ordinary_reselection] +assert "r.Instance.Location" in fallback[instance_guard:ordinary_reselection] + +follower_guard = fallback.index("sAdventureGroupMgr->BotLed(player)") +assert follower_guard < ordinary_reselection +follower_hold = fallback[follower_guard:ordinary_reselection] +assert "r.GroupOverride" in follower_hold +assert "r.Dirty=false" in follower_hold +assert "GROUP_MEMBER_FALLBACK_HOLD" in follower_hold + +protect_start = source.index("bool AdventureBehaviorMgr::ProtectGroupLeave(") +protect_end = source.index("void AdventureBehaviorMgr::InterruptForInteraction(", protect_start) +protection = source[protect_start:protect_end] +for invariant in ( + 'r.Intent=="groupformation"', + 'r.WantedIntent=="groupformation"', + "r.Gathering", + "r.Departure.Active(now)", + "r.State==Phase::Switching", + "r.State==Phase::Handover", +): + assert invariant in protection + +print("Instance visit retention, inherited group-target hold and formation leave protection passed.") diff --git a/tests/clientless/test_adventure_interactions.py b/tests/clientless/test_adventure_interactions.py index 2285b43a21..df5b1fd180 100644 --- a/tests/clientless/test_adventure_interactions.py +++ b/tests/clientless/test_adventure_interactions.py @@ -58,7 +58,7 @@ unsigned urand(unsigned a,unsigned){return a;}float roll=0;float frand(float,flo namespace ObjectAccessor {std::unordered_map players;Player*FindPlayer(ObjectGuid id){auto it=players.find(id);return it==players.end()?nullptr:it->second;}} struct Profile {std::string Locale="deDE";std::unordered_map Personality;}; struct Lua {std::unordered_map profiles;Profile const*GetAdventureProfile(unsigned id){auto it=profiles.find(id);return it==profiles.end()?nullptr:&it->second;}}lua;auto sClientlessLuaEngine=&lua; -struct Behavior {int interrupts=0;std::string intent="social",schedule="social:inn";std::string InteractionIntent(Player*){return intent;}std::string InteractionSchedule(Player*){return schedule;}void ResumePersonalPlan(Player*){}void InterruptForInteraction(Player*){++interrupts;}}behavior;auto sAdventureBehaviorMgr=&behavior; +struct Behavior {int interrupts=0;bool protectGroupLeave=false;std::string intent="social",schedule="social:inn";std::string InteractionIntent(Player*){return intent;}std::string InteractionSchedule(Player*){return schedule;}bool ProtectGroupLeave(Player*)const{return protectGroupLeave;}void ResumePersonalPlan(Player*){}void InterruptForInteraction(Player*){++interrupts;}}behavior;auto sAdventureBehaviorMgr=&behavior; struct Combat {bool busy=false;int assists=0;bool Busy(Player*){return busy;}bool Assist(Player*,Unit*){++assists;return true;}}combat;auto sAdventureCombatMgr=&combat; struct ClientlessAction{std::string Name;std::unordered_map Parameters;}; struct Core {time_t activity=0;int queued=0;time_t LastActivity(ObjectGuid){return activity;}bool QueueAction(unsigned,ClientlessAction){++queued;return true;}}core;auto sClientlessPlayerMgr=&core; @@ -129,6 +129,10 @@ int main(){ bot.lfg=false;bot.bgqueue=true;tick(6000);assert(bot.group); bot.bgqueue=false;bot.battleground=true;tick(7000);assert(bot.group); bot.battleground=false;tick(7001);assert(!bot.group);bot.group=&group; + // Formation, gathering and departure handover suppress both schedule and AFK exits. + mgr._records.clear();behavior.protectGroupLeave=true;behavior.schedule="groupformation:meeting";leader.afk=true; + tick(8000);behavior.schedule="instance:dungeon";tick(9000);assert(bot.group); + behavior.protectGroupLeave=false;leader.afk=false;behavior.schedule="adventure:road";tick(9001);assert(!bot.group);bot.group=&group; // Explicit diagnostic leave uses ordinary safe-state guards. bot.combat=true;assert(!mgr.RequestLeave(&bot)&&bot.group);bot.combat=false; assert(mgr.RequestLeave(&bot)&&!bot.group);