From be049baaccd6475cfe189bc8ebcd9eab0534f3da Mon Sep 17 00:00:00 2001 From: nico Date: Sun, 1 Nov 2026 01:20:37 +0100 Subject: [PATCH] Add deterministic AdventureBot group formation manager --- doc/clientless/SESSION-RESUME.md | 50 +++++ doc/clientless/bot-groups.md | 25 +++ .../recovery-before-final-install.tsv | 26 +++ .../recovery-before-install.tsv | 21 ++ .../game/World/AdventureBehaviorMgr.cpp | 4 + src/server/game/World/AdventureGroupMgr.cpp | 185 ++++++++++++++++++ src/server/game/World/AdventureGroupMgr.h | 7 + src/server/game/World/AdventureGroupPolicy.h | 7 + .../scripts/Custom/viano_bot_commands.cpp | 2 + .../test_adventure_group_manager.py | 58 ++++++ tests/clientless/test_adventure_groups.py | 1 + 11 files changed, 386 insertions(+) create mode 100644 doc/clientless/checkpoints/2026-11-01-group-manager-build/recovery-before-final-install.tsv create mode 100644 doc/clientless/checkpoints/2026-11-01-group-manager-build/recovery-before-install.tsv create mode 100644 tests/clientless/test_adventure_group_manager.py diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 69c79a3d8a..cecd01f2d6 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,53 @@ +# Aktuell: finaler Gruppenbildungsmanager implementiert und installiert + +Nutzerauftrag: Ab zehn Minuten vor Ende von `groupformation` sollen Botgruppen +deterministisch zu möglichst vielen vollständigen Gruppen konsolidiert werden; +Zielzugang/Rollen müssen passen, Stufe85 bleibt eine eigene Kategorie, menschlich +geführte Gruppen unberührt, expliziter Testfallback erwünscht. Implementiert in +AdventureGroupMgr/Policy: 30-Sekunden-Lauf im letzten 600-Sekunden-Fenster, +größte kompatible Zielgruppe zuerst, rollenbewusster Transfer aus kleineren +unvollständigen botgeführten Gruppen, vollständige Revalidierung von Fraktion, ++/-5-Levelspanne, Level85-Isolation, Beruf, Ziel-Level/Zugang, Gebietspolicy, +Kapazität und Rolle. LFG/Browser-, Menschen-, Transit-/Kampfgruppen sowie bereits +vollständige Gruppen werden nicht umgebaut. Fehlerhafte native Einladung versucht +die ursprüngliche Gruppe begrenzt wiederherzustellen. Tagespläne/Ziel des Leaders +werden nicht geändert; Fünf-Minuten-Sammelregel und Browserfallback bleiben. + +Testschnittstelle: `clientless groupmanager preview|run|status`; `run` umgeht nur +das Zeitfenster, nicht Sicherheits-/Zugangsregeln. Dokumentiert in bot-groups.md. +Neue Regression test_adventure_group_manager.py plus aktualisierter Fixture-Stub. +Gezielt PASS: group_manager, adventure_groups, group_role_composition; diff-check +PASS. Voller CMake-Build ausschließlich-j10 PASS, installiert. Bestehende +uncommittete Flug-WIP wurde erhalten und mitgebaut, nicht als Managerarbeit +beansprucht. + +Buildbetrieb: Loader/Testaufnahme pausiert, safe logout angefordert. Busy-Loader +nahm Config-Reload nicht rechtzeitig an und hielt durch Nachladen 16/17 Sitzungen; +nach vollen fünf Minuten Recoveryliste/Saveall unter checkpoints/2026-11-01- +group-manager-build/recovery-before-install.tsv. Beim danach angeforderten sauberen +Shutdown SIGSEGV im alten, unter GDB laufenden Prozess; ausschließlich abgestürzten +Testserver/GDB per TERM beendet, Prozessende vor Installation verifiziert. Frischer +Prozess108590 erreichte ready, Ports8085/8087. Loader/Testaufnahme wieder1. + +Live: Zehn vorbereitete Level85-Raidbots plus Karggan geladen; aktuelle Formation +und nächster Raid Bastion des Zwielichts gesetzt. Preview: 11 Kandidaten, gültiger +Transfer. Erster erzwungener Apply während ihrer Anreise: null Transfers, korrekt +wegen Ready-Schutz. Nach Ankunft wurden Brynda165 und Ragnorli167 regelkonform aus +der kleineren Gruppe in die7er-Gruppe verschoben. Dabei zeigte sich, dass der noch +benötigte Tank Ashaelri176 als Leiter der verbliebenen Zweiergruppe gesperrt war. +Fix: Botleiter darf Führung an ein anderes CPC-Botmitglied abgeben; niemals wird +ein Mensch erzwungener Nachfolger. Rollback bewahrt den ursprünglichen Leiter. +Erneut Tests/Build-j10 PASS, zweite Fünf-Minuten-Restliste unter +recovery-before-final-install.tsv, sauberer Shutdown, installiert, fresh ready +Prozess113452/Ports8085+8087. Nach Neustart Preview11/moves1, Apply verschob +Ashaelri176; DB bestätigt Gruppe2 exakt10: Renren, Marndra, Marnda, Aruendas, +Winienwi, Taliaarnora, Brynda, Corric, Ragnorli, Ashaelri. Karggan bleibt einzeln. +Alle20 finalen Recoverybots wurden erneut geladen und zeigen neue normale Intent-, +Travel-, Taxi- oder Kampfzustände statt alter Restart-Transitstates. Loader/Test +wieder1; Status enabled1/schema1/busy0, gestaffeltes Laden läuft. Kein Commit/Push. + +--- + # Aktuell: Routenplaner-Last behoben und volle Population live geprüft Nutzer meldete Serverlags und bat nach Prüfung bestehender Navigationsprobleme diff --git a/doc/clientless/bot-groups.md b/doc/clientless/bot-groups.md index 5735f33286..49f34ae6ca 100644 --- a/doc/clientless/bot-groups.md +++ b/doc/clientless/bot-groups.md @@ -56,6 +56,31 @@ planned formation-to-activity transition is exempt from intent-change departure. Later changes retain the existing loyalty-based departure behavior, deferred in combat/instances/transit. Native removal handles leadership and party dissolution. +### Final formation manager + +During the final ten minutes of `groupformation`, a deterministic fallback runs +every 30 seconds. It prefers the largest compatible bot-led group, fills only its +currently missing roles, and may move a bot out of a smaller incomplete bot-led +group. Human-led and browser/LFG groups are never reorganized. Every transfer is +revalidated against faction, the inclusive five-level range, profession rules, +the leader's chosen destination, that destination's level/access restrictions, +area policy and native capacity. Level 85 is an isolated matching category: a +level-85 bot is only consolidated with other level-85 bots. This is a greedy +fallback intended to maximize complete groups without replacing normal social +formation; it does not claim a globally optimal partition. + +The ordinary five-minute gathering/departure rule remains authoritative. A +complete group can therefore finish consolidation by minute 50 and gather from +minute 55. At the block boundary, groups still incomplete use the configured +browser behavior and do not begin an overland dungeon or raid journey. The +manager never changes stored day-plan slots or a group's leader target. + +GM diagnostics and explicit test fallback are available as +`clientless groupmanager preview`, `clientless groupmanager run`, and +`clientless groupmanager status`. `run` uses the same access, role, faction and +transfer checks but deliberately ignores the ten-minute time gate; it does not +override human leadership or native admission rules. + ## Shared travel and instances Followers run their AdventureBehavior controller with the leader's effective diff --git a/doc/clientless/checkpoints/2026-11-01-group-manager-build/recovery-before-final-install.tsv b/doc/clientless/checkpoints/2026-11-01-group-manager-build/recovery-before-final-install.tsv new file mode 100644 index 0000000000..2666e9db43 --- /dev/null +++ b/doc/clientless/checkpoints/2026-11-01-group-manager-build/recovery-before-final-install.tsv @@ -0,0 +1,26 @@ +# safe_logout_requested approximately 2026-11-01 01:08:20 CET +# five_minute_threshold approximately 2026-11-01 01:13:20 CET +# snapshot_saved 2026-11-01 01:13:32 CET, after saveall +# aggregate_nearest_status pending=20 sessions=20 combat=2 transport=0 flight=15 loading=0 +# note Loader was confirmed enabled=0/busy=0 and did not replenish sessions. Rows are generated bots online in the final saved DB snapshot. Per-bot combat/flight flags are not exposed by the console command; aggregate flags above are the available evidence. All had a pending global safe-logout request. +guid name faction map zone x y z +43 Fizzira Alliance 0 4 -11788.8 -3176.69 -29.7341 +51 Tordra Alliance 530 3479 -3013.51 -13204.1 59.6283 +53 Noranda Alliance 0 45 -2059.88 -2585.36 108.528 +70 Renaisia Alliance 1 148 7854.57 73.7393 78.0483 +72 Sorana Alliance 530 3479 -3013.51 -13204.1 59.6283 +81 Ashara Horde 1 14 1389.54 -4370.19 25.8776 +83 Mahuwa Horde 0 4922 -3557.36 -4377.18 268.208 +84 Brynli Alliance 1 331 2779.05 -58.9212 112.253 +85 Gixni Horde 1 357 -4300.55 -646.328 25.2518 +88 Pibwin Alliance 530 3479 -3013.51 -13204.1 59.6283 +99 Shadra Horde 1 331 2540.73 -1526.06 225.275 +102 Corren Alliance 0 12 -9484.66 443.337 68.0268 +119 Aethilrin Horde 1 400 -4763.22 -1180.05 16.4765 +121 Braneven Horde 0 139 2074.61 -4543.65 151.667 +125 Koruhon Horde 1 17 1259.58 -3802.96 122.248 +132 Braneron Horde 0 85 2539.96 -53.7843 28.1074 +134 Renven Horde 1 14 763.235 -4611.83 -1.08043 +136 Maraannora Horde 1 14 749.172 -4635.61 -3.2926 +146 Loraria Horde 1 1637 1589.99 -4212.98 82.4014 +153 Ragnli Alliance 1 141 7990.23 1296.75 59.6283 diff --git a/doc/clientless/checkpoints/2026-11-01-group-manager-build/recovery-before-install.tsv b/doc/clientless/checkpoints/2026-11-01-group-manager-build/recovery-before-install.tsv new file mode 100644 index 0000000000..326ae4412f --- /dev/null +++ b/doc/clientless/checkpoints/2026-11-01-group-manager-build/recovery-before-install.tsv @@ -0,0 +1,21 @@ +# safe_logout_requested 2026-11-01 00:55:57 CET (approximately; console request followed config edit at 00:55:52) +# five_minute_threshold 2026-11-01 01:00:57 CET +# snapshot_saved 2026-11-01 01:01 CET, immediately after saveall +# aggregate_nearest_status pending=17 sessions=17 combat=2 transport=0 flight=14 loading=0 +# note The enabled loader was busy and rejected runtime reload, so it continued transient login/safe-logout cycles. Rows below are the generated bots online in the final saved DB snapshot. Per-bot combat/flight flags were not exposed by the console command; aggregate flags above are the available diagnostic evidence. All had a pending global safe-logout request. +guid name faction map zone x y z +43 Fizzira Alliance 0 4 -11778.6 -3179.17 -28.9872 +47 Nimibin Alliance 1 141 7990.23 1296.75 59.6283 +49 Lunna Alliance 1 141 10041.9 1838.98 1367.35 +54 Falaeriel Horde 0 2037 3538.87 -4506.39 152.38 +88 Pibwin Alliance 1 141 7990.23 1296.75 59.6283 +104 Lillini Alliance 0 1 -5066.67 -818.772 497.329 +106 Nibelin Alliance 1 141 7990.23 1296.75 59.6283 +118 Lillina Alliance 1 141 7990.23 1296.75 59.6283 +132 Braneron Horde 0 85 2539.96 -53.7843 28.1074 +135 Ragnhild Alliance 1 141 9797.77 998 1313.46 +153 Ragnli Alliance 1 141 9552.06 992.021 1366.62 +160 Marndra Alliance 530 3519 -1471.88 3873.29 268.262 +161 Marnda Alliance 530 3483 -980.07 2999.83 87.3079 +163 Winienwi Alliance 530 3519 -1666.02 4773.16 85.4449 +164 Taliaarnora Alliance 530 3519 -1673.09 4869.15 137.132 diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index 211fe62979..208757c457 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -1695,6 +1695,10 @@ bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args) { std::string args;std::getline(in,args);return EditPlan(handler,args); } + if(op=="groupmanager") + { + std::string args;std::getline(in,args);return sAdventureGroupMgr->Command(handler,args.c_str()); + } if (op == "import") return sAdventureLocationMgr->Import(handler); if (op == "repairimport") return sAdventureLocationMgr->Import(handler,true); if (op == "reload") diff --git a/src/server/game/World/AdventureGroupMgr.cpp b/src/server/game/World/AdventureGroupMgr.cpp index 99885842cf..d57e624790 100644 --- a/src/server/game/World/AdventureGroupMgr.cpp +++ b/src/server/game/World/AdventureGroupMgr.cpp @@ -31,6 +31,7 @@ #include "GroupMgr.h" #include "LFGPackets.h" #include +#include namespace { @@ -339,6 +340,189 @@ bool AdventureGroupMgr::Merge(Player* p,AdventureGroupPlan const& plan) } return true; // A mutation occurred; caller must not reuse its old Group pointer. } + +bool AdventureGroupMgr::ManagedTransfer(Player* leader,Player* member) +{ + if(!leader || !member || leader==member || !Ready(leader) || !Ready(member) || + leader->GetTeam()!=member->GetTeam() || !CanInvite(leader,member) || !NeedsRole(leader,member))return false; + auto target=leader->GetGroup(),source=member->GetGroup(); + ObjectGuid originalLeader=source?source->GetLeaderGUID():ObjectGuid::Empty; + if(target && target->GetLeaderGUID()!=leader->GetGUID())return false; + if(source==target)return false; + if(source) + { + if(!BotLed(member) || source->isLFGGroup())return false; + if(source->GetLeaderGUID()==member->GetGUID() && source->GetMembersCount()>1) + { + Player* successor=nullptr; + for(auto const& slot:source->GetMemberSlots())if(slot.guid!=member->GetGUID()) + if(auto candidate=ObjectAccessor::FindPlayer(slot.guid)) + if(candidate->GetSession() && candidate->GetSession()->IsClientless()){successor=candidate;break;} + if(!successor)return false; // Never hand forced leadership to a real player. + source->ChangeLeader(successor->GetGUID());source->SendUpdate(); + } + } + CancelBrowser(member); + ObjectGuid old=source?source->GetGUID():ObjectGuid::Empty; + ObjectGuid oldLeader=originalLeader; + bool oldRaid=source && source->isRaidGroup(); + auto oldDungeonMode=source?source->GetDungeonDifficulty():DUNGEON_DIFFICULTY_NORMAL; + auto oldRaidMode=source?source->GetRaidDifficulty():RAID_DIFFICULTY_10MAN_NORMAL; + std::vector oldMembers; + if(source)for(auto const& slot:source->GetMemberSlots())oldMembers.push_back(slot.guid); + if(source)source->RemoveMember(member->GetGUID(),GROUP_REMOVEMETHOD_LEAVE); + Invite(leader,member,Activity(sAdventureBehaviorMgr->GroupPlan(leader)),true); + if(member->GetGroupInvite()) + { + WorldPackets::Party::PartyInviteResponse response{WorldPacket(CMSG_PARTY_INVITE_RESPONSE)}; + response.Accept=true;member->GetSession()->HandlePartyInviteResponseOpcode(response); + } + if(member->GetGroup() && member->GetGroup()==leader->GetGroup()) + { + _records[member->GetGUID()].MergeAt=GameTime::GetGameTime()+120; + TC_LOG_INFO("server.adventurebot","GROUP_MANAGER_TRANSFER member=%u from=%s leader=%u target='%s'", + member->GetGUID().GetCounter(),old.ToString().c_str(),leader->GetGUID().GetCounter(), + sAdventureBehaviorMgr->GroupPlan(leader).NextLocation.c_str()); + return true; + } + // Native invitation rejection must not strand a member removed from a + // still-existing source group. Reuse the same bounded rollback as organic + // merging, including recreation of a disbanded two-bot source. + if(old) + { + if(auto previous=sGroupMgr->GetGroupByGUID(old.GetCounter())) + { + previous->AddMember(member); + if(oldLeader==member->GetGUID())previous->ChangeLeader(member->GetGUID()); + previous->SendUpdate(); + } + else if(oldMembers.size()==2) + { + auto peer=ObjectAccessor::FindPlayer(oldMembers[0]==member->GetGUID()?oldMembers[1]:oldMembers[0]); + if(peer && !peer->GetGroup() && !peer->GetGroupInvite()) + { + auto restored=new Group();auto owner=oldLeader==member->GetGUID()?member:peer; + if(restored->Create(owner)) + { + sGroupMgr->AddGroup(restored);if(oldRaid)restored->ConvertToRaid(); + restored->SetDungeonDifficulty(oldDungeonMode);restored->SetRaidDifficulty(oldRaidMode); + restored->AddMember(owner==member?peer:member);restored->SendUpdate(); + } + else delete restored; + } + } + } + return false; +} + +unsigned AdventureGroupMgr::RunFormationManager(ChatHandler* handler,bool apply,bool forced) +{ + auto now=GameTime::GetGameTime();unsigned candidates=0,moves=0; + auto forming=[&](Player* p) + { + if(!Ready(p) || !p->GetSession()->IsClientless() || p->isUsingLfg() || p->InBattlegroundQueue() || p->GetGroupInvite())return false; + auto plan=sAdventureBehaviorMgr->GroupPlan(p); + if(!plan.Enabled || !plan.Forming || !AdventureGroupPolicy::Eligible(plan.NextIntent) || (!forced && !AdventureGroupPolicy::ManagerWindow(plan.Deadline,now)))return false; + if(auto group=p->GetGroup()) + { + if(!BotLed(p) || group->isLFGGroup())return false; + if(Complete(p))return false; + } + return true; + }; + std::vector pool; + for(auto const& entry:_records)if(auto p=ObjectAccessor::FindPlayer(entry.first))if(forming(p))pool.push_back(p); + candidates=pool.size(); + auto groupSize=[](Player* p){return p->GetGroup()?p->GetGroup()->GetMembersCount():1u;}; + auto leaderOf=[](Player* p)->Player* + { + if(auto group=p->GetGroup())return ObjectAccessor::FindPlayer(group->GetLeaderGUID()); + return p; + }; + for(unsigned pass=0;pass leaders; + for(auto p:pool)if(forming(p)) + { + auto leader=leaderOf(p);if(!leader || !forming(leader))continue; + if(std::find(leaders.begin(),leaders.end(),leader)==leaders.end())leaders.push_back(leader); + } + std::sort(leaders.begin(),leaders.end(),[&](Player* a,Player* b) + {auto sa=groupSize(a),sb=groupSize(b);return sa!=sb?sa>sb:a->GetGUID()GetGUID();}); + bool changed=false; + for(auto leader:leaders) + { + if(!forming(leader))continue; + auto targetPlan=sAdventureBehaviorMgr->GroupPlan(leader);auto targetGroup=leader->GetGroup(); + if((targetGroup?targetGroup->GetMembersCount():1)>=Capacity(targetPlan))continue; + Player* best=nullptr;unsigned bestSource=999; + for(auto member:pool) + { + if(member==leader || !forming(member) || member->GetGroup()==targetGroup)continue; + auto memberPlan=sAdventureBehaviorMgr->GroupPlan(member); + if(Activity(memberPlan)!=Activity(targetPlan) || member->GetTeam()!=leader->GetTeam() || + !AdventureGroupPolicy::ManagerLevelCompatible(member->getLevel(),leader->getLevel()))continue; + bool levels=true;if(targetGroup)for(auto const& slot:targetGroup->GetMemberSlots()) + if(auto present=ObjectAccessor::FindPlayer(slot.guid)) + if(!AdventureGroupPolicy::ManagerLevelCompatible(member->getLevel(),present->getLevel())){levels=false;break;} + if(!levels || !ProfessionCompatible(leader,member,Activity(targetPlan)) || !CanInvite(leader,member) || !NeedsRole(leader,member))continue; + auto source=member->GetGroup();unsigned sourceSize=source?source->GetMembersCount():1; + if(source && source->GetLeaderGUID()==member->GetGUID() && sourceSize>1) + { + bool botSuccessor=false; + for(auto const& slot:source->GetMemberSlots())if(slot.guid!=member->GetGUID()) + if(auto successor=ObjectAccessor::FindPlayer(slot.guid)) + if(successor->GetSession() && successor->GetSession()->IsClientless()){botSuccessor=true;break;} + if(!botSuccessor)continue; + } + if(targetGroup && sourceSize>=targetGroup->GetMembersCount())continue; + if(sourceSizeGetGUID()GetGUID()))) + {best=member;bestSource=sourceSize;} + } + if(!best)continue; + if(handler)handler->PSendSysMessage("Group manager %s: member=%u '%s' -> leader=%u '%s' target='%s' role=%u", + apply?"move":"proposal",best->GetGUID().GetCounter(),best->GetName().c_str(),leader->GetGUID().GetCounter(),leader->GetName().c_str(), + targetPlan.NextLocation.c_str(),unsigned(BrowserRole(best))); + if(!apply){++moves;pool.erase(std::remove(pool.begin(),pool.end(),best),pool.end());changed=true;break;} + if(ManagedTransfer(leader,best)){++moves;changed=true;break;} + } + if(!changed)break; + } + _formationManagerLast=now;_formationManagerMoves=moves;_formationManagerCandidates=candidates; + _formationManagerDetail=apply?"applied":"preview"; + TC_LOG_INFO("server.adventurebot","GROUP_MANAGER_RUN apply=%u forced=%u candidates=%u moves=%u",apply?1:0,forced?1:0,candidates,moves); + if(handler)handler->PSendSysMessage("Group manager %s: candidates=%u moves=%u.",apply?"applied":"preview",candidates,moves); + return moves; +} + +void AdventureGroupMgr::MaybeRunFormationManager(time_t now) +{ + if(now<_formationManagerAt)return; + _formationManagerAt=now+30; + bool due=false; + for(auto const& entry:_records)if(auto player=ObjectAccessor::FindPlayer(entry.first)) + { + auto plan=sAdventureBehaviorMgr->GroupPlan(player); + if(plan.Enabled && plan.Forming && AdventureGroupPolicy::ManagerWindow(plan.Deadline,now)) + {due=true;break;} + } + if(!due)return; + RunFormationManager(nullptr,true,false); +} + +bool AdventureGroupMgr::Command(ChatHandler* handler,char const* args) +{ + std::istringstream in(args?args:"");std::string op;in>>op; + if(op=="preview"){RunFormationManager(handler,false,true);return true;} + if(op=="run"){RunFormationManager(handler,true,true);return true;} + if(op=="status") + { + handler->PSendSysMessage("Group manager last=%llu candidates=%u moves=%u mode='%s' nextAutomatic=%llu", + static_cast(_formationManagerLast),_formationManagerCandidates,_formationManagerMoves,_formationManagerDetail.c_str(), + static_cast(_formationManagerAt));return true; + } + handler->SendSysMessage("Usage: clientless groupmanager preview|run|status");return false; +} void AdventureGroupMgr::CheckFullGroup(Player* p) { auto group=p->GetGroup();if(!group)return; @@ -361,6 +545,7 @@ void AdventureGroupMgr::CheckFullGroup(Player* p) void AdventureGroupMgr::Update(Player* p) { auto now=GameTime::GetGameTime();auto& r=_records[p->GetGUID()]; + MaybeRunFormationManager(now); CheckFullGroup(p); if(PlayerLeadership(p))return; if(!Ready(p) || p->isUsingLfg() || p->InBattlegroundQueue())return; diff --git a/src/server/game/World/AdventureGroupMgr.h b/src/server/game/World/AdventureGroupMgr.h index 5a573d5670..7d565ee75e 100644 --- a/src/server/game/World/AdventureGroupMgr.h +++ b/src/server/game/World/AdventureGroupMgr.h @@ -36,6 +36,7 @@ public: bool Forming(Player* player) const; bool PlannedTransition(Player* player); void Report(ChatHandler* handler,Player* player) const; + bool Command(ChatHandler* handler,char const* args); private: struct Record { @@ -50,6 +51,9 @@ private: unsigned PendingInvites(Player* leader,Player* except=nullptr) const; bool SurplusRole(Player* member); bool Merge(Player* player,AdventureGroupPlan const& plan); + bool ManagedTransfer(Player* leader,Player* member); + unsigned RunFormationManager(ChatHandler* handler,bool apply,bool forced); + void MaybeRunFormationManager(time_t now); void CheckFullGroup(Player* player); void Invite(Player* leader,Player* member,std::string const& intent,bool forming); void Speak(Player* player,std::string const& message); @@ -57,6 +61,9 @@ private: bool RecruitPlayer(Player* player,std::string const& intent); std::unordered_map _playerInvites; time_t _nextChannelMessage=0; + time_t _formationManagerAt=0,_formationManagerLast=0; + unsigned _formationManagerMoves=0,_formationManagerCandidates=0; + std::string _formationManagerDetail="not run"; std::unordered_map _records; }; #define sAdventureGroupMgr AdventureGroupMgr::instance() diff --git a/src/server/game/World/AdventureGroupPolicy.h b/src/server/game/World/AdventureGroupPolicy.h index 0554884ce8..ab93b14b90 100644 --- a/src/server/game/World/AdventureGroupPolicy.h +++ b/src/server/game/World/AdventureGroupPolicy.h @@ -40,6 +40,13 @@ inline bool AcceptPlayerLevel(unsigned bot,unsigned player) {return bot<=player || bot-player<=5;} inline bool Compatible(std::string const& a,std::string const& b,unsigned level,unsigned other) {return Eligible(a) && a==b && (level>other?level-other<=5:other-level<=5);} +inline bool ManagerLevelCompatible(unsigned level,unsigned other) +{ + if(level==85 || other==85)return level==85 && other==85; + return level>other?level-other<=5:other-level<=5; +} +inline bool ManagerWindow(long long deadline,long long now) +{return deadline>now && deadline-now<=600;} template void Prepare(Day& plan,std::mt19937& random,float sociability,Meeting meeting) { diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index 0467951ffb..252816a73c 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -19,6 +19,7 @@ #include "ClientlessActionExecutorMgr.h" #include "ClientlessWorldPointMgr.h" #include "AdventureBehaviorMgr.h" +#include "AdventureGroupMgr.h" #include "AdventureCombatMgr.h" #include "AdventureInteractionMgr.h" #include "AdventureGeneratorMgr.h" @@ -2617,6 +2618,7 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg { "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiInfo, "" }, } }, { "adventure", rbac::RBAC_PERM_COMMAND_GM, true, +[](ChatHandler* handler, char const* args) { return sAdventureBehaviorMgr->Command(handler,args); }, "" }, + { "groupmanager", rbac::RBAC_PERM_COMMAND_GM, true, +[](ChatHandler* handler, char const* args) { return sAdventureGroupMgr->Command(handler,args); }, "" }, { "flight", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessFlight, "" }, { "portal", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", { { "object", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", { diff --git a/tests/clientless/test_adventure_group_manager.py b/tests/clientless/test_adventure_group_manager.py new file mode 100644 index 0000000000..7a29d30d4f --- /dev/null +++ b/tests/clientless/test_adventure_group_manager.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +from pathlib import Path +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[2] +source = (root / "src/server/game/World/AdventureGroupMgr.cpp").read_text() + +# The integration-heavy manager remains native C++, but these assertions keep +# its safety boundaries visible when the surrounding group code is refactored. +for required in ( + "ManagerWindow(plan.Deadline,now)", + "ManagerLevelCompatible(member->getLevel(),present->getLevel())", + "!BotLed(p)", + "Complete(p)", + "ProfessionCompatible(leader,member,Activity(targetPlan))", + "CanInvite(leader,member)", + "NeedsRole(leader,member)", + "sourceSize>=targetGroup->GetMembersCount()", + "Never hand forced leadership to a real player", + "GROUP_MANAGER_TRANSFER", + 'op=="preview"', + 'op=="run"', +): + assert required in source, required + +code = r''' +#include "AdventureGroupPolicy.h" +#include +int main() +{ + using namespace AdventureGroupPolicy; + assert(ManagerLevelCompatible(60, 55)); + assert(!ManagerLevelCompatible(60, 54)); + assert(ManagerLevelCompatible(84, 79)); + assert(!ManagerLevelCompatible(84, 78)); + assert(ManagerLevelCompatible(85, 85)); + assert(!ManagerLevelCompatible(85, 84)); + assert(!ManagerLevelCompatible(80, 85)); + assert(ManagerWindow(1600, 1000)); + assert(ManagerWindow(1300, 1000)); + assert(!ManagerWindow(1601, 1000)); + assert(!ManagerWindow(1000, 1000)); + assert(!ManagerWindow(999, 1000)); +} +''' +with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + cpp = directory / "test.cpp" + exe = directory / "test" + cpp.write_text(code) + subprocess.run([ + "c++", "-std=c++17", "-I" + str(root / "src/server/game/World"), + str(cpp), "-o", str(exe) + ], check=True) + subprocess.run([str(exe)], check=True) + +print("Formation manager window, level-85 isolation and native safety gates passed.") diff --git a/tests/clientless/test_adventure_groups.py b/tests/clientless/test_adventure_groups.py index 3adf4c2911..9b9250f6ab 100644 --- a/tests/clientless/test_adventure_groups.py +++ b/tests/clientless/test_adventure_groups.py @@ -69,6 +69,7 @@ code+=r''' void AdventureGroupMgr::UpdateBrowser(Player*){} bool AdventureGroupMgr::Merge(Player*,AdventureGroupPlan const&){return false;} 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+=fun('float Sociability(')+'\n'+fun('bool ProfessionCompatible(')+'\n'+fun('bool Ready(')+'\n'+fun('std::string Activity(')+'\n'+fun('unsigned Capacity(')