diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 28243d2dc1..1f0d048a38 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,72 @@ +# Umsetzung 2026-10-29: Navigation und Verhaltensgrenzen + +Die unten festgehaltene Liste ist implementiert; lokaler Commit vom Nutzer angefordert. +Nächster besprochener Schwerpunkt: sichere Fahrzeugnavigation an Bord. +Build8 erfolgreich, installiert; finaler Neustart erfolgreich, ready und TCP8085, +PID264359, tmux vcore-worldserver, Konsole /tmp/vcore-recovery-final-20261029.log. +23 gezielte Regressionstests erfolgreich; spätere Flugstart-Blacklist- und native +rekursive Skill-Aktivierungstests ebenfalls erfolgreich. Änderungen werden lokal committed; kein Push. +Frühe Startversuche fanden einen bestehenden MAX(id)/GetUInt64-Typfehler bei +Instanz-ID-Initialisierung: Abfrage explizit nach UNSIGNED gecastet, Daten erhalten. +Live auf vorherigem Zwischenbuild: Terrainprobe Orgrimmar jetzt valid1; +Aldren32/Talzul97 Reiten225/Lizenz1; Naxx level60/78 abgelehnt,85 erlaubt. +Finaler Build behebt zusätzlich rekursive Skillpersistierung und respektiert +Blacklist für Flugstart-Anschlüsse. Endgültige DB-Persistenzprüfung erfolgreich: character_skills762 für32 und97 +beide225/225 nach nativem saveall. Loader läuft mit50/5 jeFraktion weiter. +Siehe navigation-recovery.md und checkpoints/2026-10-29-navigation-recovery/. +Lunna nicht reproduziert, gemäß Zeitlimit vertagt. Keine Verteilung an Bord: +verlässliche lokale Fahrzeugnavigation fehlt; gemeinsamer sicherer Standplatz bleibt. + +--- + +# Nächster Entwicklungsschritt: vereinbarte Anforderungen (noch KEIN Code) + +Letzter Commit321a28640c Gebietskatalog/NodeACL/Planerreiseintegration, vorher0173a434f4. +Nutzer verlangte erstCommit, dann Liste vorCode. AktuellePräzisierungen vomNutzer: + +1 Flugreisen regulärmitGraphkosten vomStandortzumNode,zwischenNodesundalsallgemeiner +Fallback,auchlokalerAbreiseknotenbeifernemZielaufandererMap. TerrainFallback aus +untenstehenderDiagnosekorrigieren,Phase169Standardnichtändern,KollisionundBounds +nichtabschalten. NichtblindfehlendePhasenkachelnmitBasisdateienkopieren. +2 LunnaDock-Flugmeister-PendelnRuttheran: nurkurznachdirekterUrsachesuchen, +sonstüberspringen; bereitsvorGebietskatalogbeobachtet. +3 BergungbeidauerhaftfestgefahrenerNavigation NICHTvoreilig. Ruhesteinanimation +verwenden; ZielnichtindividuellerHeimatortsondernFraktionshauptstadtSturmwindbzw +Orgrimmar. Zeiten/SchwellenfürfestgefahrennochmitvernünftigerkonservativerWahl +implementieren; keinNutzerwunschfürsofortigeRettungbeieinzelnenPathRejected. +4 Kettentod: nachWiederbelebunginnerhalb60swiedersterben,5malhintereinander. +DannbeimnächstenToddenbestehendenGeistheiler/FriedhofFallbackfürunerreichbare +Leichenversuchen. WeiterOHNEWiederbelebungsschwäche/Haltbarkeitsstrafe; kein +normalerNPCGeistheilerhandel. Nichtwillkürlichbereitsnach1-2Todenretten. +5 LetzterTagesplanblockGruppenbildung -> durchRuheersetzen, daFolgeintentfehlt. +6 EigeneDungeon/RaidZielwahlMindestlevelprüfen. BotlädtSpielernur±5seinesLevels +undnurwennZielpersonMindestlevelvonDungeon/Raid/Abenteuergebieterfüllt. +SpielerlädtBot: Botmehrals5LevelÜBERSpieler lehntIMMERab, nachUNTENkeineLevel- +Sperre. AlleanderenbereitsvereinbartenPersönlichkeits-/Intentregelnbleibengelten. +7 Transport: zuerstNodeerreichen, dannnaheMMAP-sichereWarteplätzeverteilen; +beiFahrzeugankunftdentatsächlichgegangenenWegzurückzumNode, normalereinsteigen. +8 AufVehikelVerteilungNURwennsicherzuverlässignavigierbarundkeinRunterfallen. +VorAusstiegRückwegzumherkömmlichengemeinsamenStandplatz, dannbisherigerAusstieg. +Gruppenstaffelung/FormationerstnachvollständigabgeschlossenemAusstiegwieder. +9 BeianderenAktivitätenAbstandzuNPC/Botssuchen,beiPlatzmangeleng/überlappenderlaubt. + +ZusätzlichAldren/Talzuljetztgeprüft:32AldrenDK60,97TalzulDruide66,beideonline. +BeidekennenHordeFlightmounts32243/44/45und90267; Aldrenkennt34090,Talzulnicht. +Beidenfehltcharacter_skills762auchNACHbestätigtemnativemsaveall(Allplayerssaved). +Skillgesamtzahlen30bzw26,nichtvoll. GeladeneProfilebeidecanGroundMount1 und +aktuelleProfilbewegungaufFlugausgelegt. RuntimeGetSkillValuenichtdirektgelesen, +fehlendepersistierteReitfertigkeitistaberreproduzierbar. NativeSetSkillhatpotenziell +fehlerhafteSlot0Sentinelprüfung(PLAYER_MAX_SKILLS-Schleife,if(!skillSlot)),OHNE +konkretenNachweisfürdiese2Bots. KeineLogsTriedtoaddskill762gefunden. Imkommenden +SchrittBereitstellung+Speicherung+Laufzeitdiagnosekontrollieren, nichtvoreiligallein +DBnullalsvollständigenBeweisfürjedenLaufzeitstatusdarstellen. + +FürdieseTurnpräzisierungennochkeineC++/Lua/DBÄnderung,vomnormalensaveallabgesehen. +SourcebisaufdieseAnforderungsnotizsauber;keineBinaries/Config/Intentsverändert. +ServerPID247021,tmuxvcore-worldserver,Console/tmp/vcore-area-start-verified-20261029.log. + +--- + # WICHTIGSTER NEUER BEFUND: Freiflug scheitert an fehlendem Terrain-Fallback Native `clientless flight probe 128 1751.49 -4389.67` (09:20CETca) ergab diff --git a/doc/clientless/checkpoints/2026-10-29-navigation-recovery/regression-results.json b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/regression-results.json new file mode 100644 index 0000000000..7301d66e8a --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/regression-results.json @@ -0,0 +1,25 @@ +{ + "test_adventure_recovery_policy": 0, + "test_adventure_destination_level": 0, + "test_native_skill_slot": 0, + "test_flight_terrain_fallback": 0, + "test_free_flight_planner": 0, + "test_graph_probe_cache": 0, + "test_flight_policy": 0, + "test_flight_handoff": 0, + "test_group_flight_follow": 0, + "test_flight_area_query": 0, + "test_transport": 0, + "test_adventure_groups": 0, + "test_adventure_native_lfg": 0, + "test_adventure_interactions": 0, + "test_adventure_group_travel": 0, + "test_adventure_combat_reactions": 0, + "test_adventure_day_persistence": 0, + "test_adventure_intent_state": 0, + "test_adventure_social": 0, + "test_adventure_economy": 0, + "test_adventure_profession_sightline": 0, + "test_adventure_exploration": 0, + "test_adventure_navigation_rescue": 0 +} diff --git a/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_adventure_combat_reactions.log b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_adventure_combat_reactions.log new file mode 100644 index 0000000000..ffcf3c9447 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_adventure_combat_reactions.log @@ -0,0 +1 @@ +Production combat focus, damage ordering, risk filters, corpse radius/delay, flight/group override, blocked escape, danger avoidance and malus-free persisted fallback passed. diff --git a/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_adventure_navigation_rescue.log b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_adventure_navigation_rescue.log new file mode 100644 index 0000000000..c01d34929a --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_adventure_navigation_rescue.log @@ -0,0 +1 @@ +Real hearth recovery: grace, animation, faction capitals, cancellation, activity gaps and long taxi grace passed diff --git a/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_free_flight_planner.log b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_free_flight_planner.log new file mode 100644 index 0000000000..f1c6539cbe --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_free_flight_planner.log @@ -0,0 +1 @@ +ground planner regressions and dynamic flight/node/location/virtual-exit integration passed diff --git a/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_native_skill_slot.log b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_native_skill_slot.log new file mode 100644 index 0000000000..a0d4159884 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_native_skill_slot.log @@ -0,0 +1,2 @@ +Native riding/profession allocation accepts slot zero and last slot, rejects a full skill array +Native recursive skill activation preserves INSERT for new placeholders and UPDATE for saved skills diff --git a/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_transport.log b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_transport.log new file mode 100644 index 0000000000..9a3cbb74be --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/test_transport.log @@ -0,0 +1 @@ +Transport dock, frame, recorder, cross-map copy and ride regressions passed diff --git a/doc/clientless/checkpoints/2026-10-29-navigation-recovery/validation.md b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/validation.md new file mode 100644 index 0000000000..712c4158bf --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-navigation-recovery/validation.md @@ -0,0 +1,24 @@ +# Validation 2026-10-29 + +- Build8 completed; 23 targeted regression programs passed. Flight-root rejection + and recursive skill activation regressions were added after the first full run + and passed separately. +- First/second startup attempts exposed an existing saved-instance ID allocation + assertion; debugger traced RegisterInstanceId(2). Corrected aggregate type; + third startup reached ready and listened on TCP8085. +- Native flight status: Aldren32 and Talzul97 both enabled=1, riding=225, + flightLicense=1. +- Native terrain probe at 1751.49/-4389.67 on map1: valid=1, ground132.703, + ceiling493.191 (previously missing child719 tile blocked it). +- Native Naxxramas eligibility: Aldren60 and Tahuima78 content/own/invitation=0; + Braneven85 content/own/invitation=1. +- Saved plan audit: 2640 slots, no hour23 groupformation. +- Final Build8 installed after a second clean shutdown. Fresh startup log + /tmp/vcore-recovery-final-20261029.log reaches ready; PID264359 remains alive, + TCP8085 listening. Native saveall followed by DB query confirms Aldren32 and + Talzul97 riding225/max225 persisted. Loader continues normal50/5 per faction. +- Collision/departure restrictions still reject unsafe/indoor flight origins; + cold graph route evaluation can stall for multiple seconds. No claim of fully + verified flight gameplay or elimination of existing navigation limitations. +- No visual gameplay acceptance claimed. Lunna not reproduced, onboard offsets + deferred for lack of validated vehicle navigation. diff --git a/doc/clientless/navigation-recovery.md b/doc/clientless/navigation-recovery.md new file mode 100644 index 0000000000..e271d40066 --- /dev/null +++ b/doc/clientless/navigation-recovery.md @@ -0,0 +1,54 @@ +# Navigation recovery and eligibility + +Implemented on feature/clientless-player-core, 2026-10-29. + +Free flight can connect the current position to a graph departure node with +normal travel costs, as well as bridge nodes. Recovery can reach an intermediate +node on the current map when the final destination is on another map. Missing +child terrain tiles use the native parent terrain fallback; existing child tiles +with missing collision remain blocked. No phase or collision checks are disabled. + +Navigation rescue requires at least ten minutes without substantial progress and +five failures. Taxi/transport/hover waiting receives thirty minutes of grace. +Unrelated activity gaps reset the watch. Rescue plays ten seconds of the native +hearthstone precast animation, then teleports to Stormwind or Orgrimmar. Movement, +combat or death interrupts preparation. The real hearthstone spell is not cast, +because its personal homebind destination would be incorrect. Cooldown is thirty +minutes. Recovery history is runtime state and resets on logout. + +Five consecutive deaths within sixty seconds of resurrection arm the NEXT death +for the existing graveyard resurrection fallback, without sickness or an added +repair penalty. Native resurrection notifications also capture rapid redeaths +between controller ticks. Existing group wipe/revival handling is retained. + +Dungeon and raid destinations use native LFG/access minimum levels, including +normal difficulty downscaling. Unknown minima are rejected. Outgoing invitations +require levels within five and the destination minimum, including adventure areas. +Incoming player invitations always reject bots more than five levels above the +player, with no lower bot-level cutoff. Personality decisions remain in force. +The final hourly groupformation slot becomes rest and is persisted. + +Dock waiting uses safe nearby local paths and returns along the actual recorded +walk to the original node before boarding. Group formations wait until complete +arrival. Onboard distribution is deferred: recorded transport transitions do not +provide validated local vehicle navigation. NPC/activity spacing prefers free +positions but permits overlap when safe space is unavailable. + +Riding provisioning teaches native riding spells and reconciles the skill. +The native Player::SetSkill empty-slot sentinel now correctly accepts slot zero. +Read-only flight status reports riding and flight eligibility. + +Lunna's Rut'theran oscillation was not reproduced in the bounded log inspection; +further investigation is deferred as requested. In-game acceptance remains +necessary for visual hearth effects, spacing and transport behaviour. + +Startup validation exposed an existing instance-ID storage bug: MAX(id) retains +the INT field type, so GetUInt64 returned zero in debug builds and registration +of saved instance 2 asserted. The aggregate now explicitly casts to UNSIGNED +(BIGINT), preserving the existing 64-bit allocation arithmetic and saved data. + +Live checks also exposed recursive rewarded-spell skill activation: an initialized +zero-rank placeholder could become CHANGED before its first INSERT. SetSkill now +marks persistence state before learning rewarded spells; the regression exercises +actual recursive activation. Failed flight-departure roots now honor the existing +executor blacklist when replanning. diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index 7bae9221cd..fa99f37113 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -1,3 +1,4 @@ +#include "ClientlessPlayerMgr.h" /* * This file is part of the TrinityCore Project. See AUTHORS file for Copyright information * @@ -4117,6 +4118,7 @@ void Player::ResurrectPlayer(float restore_percent, bool applySickness) SetFlag(UNIT_DYNAMIC_FLAGS, UNIT_DYNFLAG_REFER_A_FRIEND); setDeathState(ALIVE); + if(GetSession() && GetSession()->IsClientless())sClientlessPlayerMgr->Resurrection(GetGUID().GetCounter(),GameTime::GetGameTime()); SetWaterWalking(false); if (!HasUnitState(UNIT_STATE_STUNNED)) @@ -5497,18 +5499,7 @@ void Player::SetSkill(uint16 id, uint16 step, uint16 newVal, uint16 maxVal) SetSkillRank(itr->second.pos, newVal); SetSkillMaxRank(itr->second.pos, maxVal); - LearnSkillRewardedSpells(id, newVal); - // if skill value is going up, update enchantments after setting the new value - if (newVal > currVal) - { - UpdateSkillEnchantments(id, currVal, newVal); - if (id == SKILL_RIDING) - UpdateMountCapability(); - } - - UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_REACH_SKILL_LEVEL, id); - UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_LEARN_SKILL_LEVEL, id); - + // Reward spells may recursively update this skill; preserve NEW before recursion. // update skill state if (itr->second.uState == SKILL_UNCHANGED || itr->second.uState == SKILL_DELETED) { @@ -5532,6 +5523,18 @@ void Player::SetSkill(uint16 id, uint16 step, uint16 newVal, uint16 maxVal) itr->second.uState = SKILL_CHANGED; } + LearnSkillRewardedSpells(id, newVal); + // if skill value is going up, update enchantments after setting the new value + if (newVal > currVal) + { + UpdateSkillEnchantments(id, currVal, newVal); + if (id == SKILL_RIDING) + UpdateMountCapability(); + } + + UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_REACH_SKILL_LEVEL, id); + UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_LEARN_SKILL_LEVEL, id); + // archaeology handling if (id == SKILL_ARCHAEOLOGY) { @@ -5576,7 +5579,7 @@ void Player::SetSkill(uint16 id, uint16 step, uint16 newVal, uint16 maxVal) else { // We are about to learn a skill that has been added outside of normal circumstances (Game Master command, scripts etc.) - uint8 skillSlot = 0; + uint16 skillSlot = PLAYER_MAX_SKILLS; // Find a free skill slot for (uint32 i = 0; i < PLAYER_MAX_SKILLS; ++i) @@ -5588,7 +5591,7 @@ void Player::SetSkill(uint16 id, uint16 step, uint16 newVal, uint16 maxVal) } } - if (!skillSlot) + if (skillSlot == PLAYER_MAX_SKILLS) { TC_LOG_ERROR("misc", "Tried to add skill %u but player %s (%s) cannot have additional skills", id, GetName().c_str(), GetGUID().ToString().c_str()); return; diff --git a/src/server/game/Handlers/MovementHandler.cpp b/src/server/game/Handlers/MovementHandler.cpp index b5df00d175..919c456bf5 100644 --- a/src/server/game/Handlers/MovementHandler.cpp +++ b/src/server/game/Handlers/MovementHandler.cpp @@ -249,7 +249,7 @@ void WorldSession::HandleMoveTeleportAck(WorldPackets::Movement::MoveTeleportAck void WorldSession::CompleteClientlessTeleport() { - if (!IsClientless() || !GetPlayer()) + if (!IsClientless() || !GetPlayer() || GetPlayer()->IsHasDelayedTeleport()) return; bool const pending = GetPlayer()->IsBeingTeleported(); if (GetPlayer()->IsBeingTeleportedFar()) diff --git a/src/server/game/Maps/MapManager.cpp b/src/server/game/Maps/MapManager.cpp index b40a2ec9e7..c7d42c9d3b 100644 --- a/src/server/game/Maps/MapManager.cpp +++ b/src/server/game/Maps/MapManager.cpp @@ -431,7 +431,9 @@ void MapManager::InitInstanceIds() { _nextInstanceId = 1; - if (QueryResult result = CharacterDatabase.Query("SELECT IFNULL(MAX(id), 0) FROM instance")) + // MAX preserves the INT column type; explicitly widen it for GetUInt64. + // Debug builds reject a mismatched getter and otherwise allocate only two IDs. + if (QueryResult result = CharacterDatabase.Query("SELECT CAST(IFNULL(MAX(id), 0) AS UNSIGNED) FROM instance")) _freeInstanceIds->resize((*result)[0].GetUInt64() + 2, true); // make space for one extra to be able to access [_nextInstanceId] index in case all slots are taken else _freeInstanceIds->resize(_nextInstanceId + 1, true); diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index 5a25eecc08..380640bb98 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -1,3 +1,5 @@ +#include "ClientlessPositioning.h" +#include "MotionMaster.h" #include "AdventureAreaMgr.h" #include "ClientlessTravelGraphMgr.h" #include "AdventureBehaviorMgr.h" @@ -8,6 +10,7 @@ #include "AdventureLocationMgr.h" #include "AdventureEconomyPolicy.h" #include "AdventureRestSpace.h" +#include "AdventureRecoveryPolicy.h" #include "ClientlessLuaEngine.h" #include "ClientlessPlayerMgr.h" #include "ClientlessActionExecutorMgr.h" @@ -249,6 +252,7 @@ bool AdventureBehaviorMgr::Allowed(Player * player, AdventureLocation const & lo unsigned capital=AdventureGroupPolicy::CapitalFaction(location.ZoneId?location.ZoneId:location.AreaId); Position position; position.Relocate(location.Point.X,location.Point.Y,location.Point.Z); return map && map->MapType==MAP_COMMON && (!capital || capital==side) && + sAdventureLocationMgr->LevelEligible(player,location,intent) && sAdventureLocationMgr->Weight(location,intent)>0 && (!location.Faction || location.Faction==side) && (!location.AreaId || sAdventureAreaMgr->AllowsArea(player,location.AreaId)) && (!location.ZoneId || sAdventureAreaMgr->AllowsArea(player,location.ZoneId)) && @@ -317,10 +321,12 @@ void AdventureBehaviorMgr::Generate(Player * player, Record & record, uint32 dat for(unsigned h=0;h<24;++h) { auto& slot=record.Plan[h];auto location=sAdventureLocationMgr->Find(slot.Location); - if(location && Allowed(player,*location,slot.Intent)) continue; + bool lastFormation=h==23 && slot.Intent=="groupformation"; + if(lastFormation)slot.Intent="rest"; + if(!lastFormation && location && Allowed(player,*location,slot.Intent)) continue; slot.Location=PickLocation(player,record,slot.Intent);auto key=slot.Location; WorldDatabase.EscapeString(key); - tx->PAppend("UPDATE vcore_adventure_day SET location_key='%s' WHERE character_guid=%u AND local_date=%u AND hour_slot=%u",key.c_str(),guid,date,h); + tx->PAppend("UPDATE vcore_adventure_day SET intent='%s',location_key='%s' WHERE character_guid=%u AND local_date=%u AND hour_slot=%u",slot.Intent.c_str(),key.c_str(),guid,date,h); ++repaired; } if(repaired)WorldDatabase.CommitTransaction(tx); @@ -349,6 +355,7 @@ void AdventureBehaviorMgr::Generate(Player * player, Record & record, uint32 dat }); AdventureGroupPolicy::Prepare(record.Plan,record.Random,Affinity(profile->Personality,"sociability",0.5f), [&](){return PickLocation(player,record,"groupformation");}); + if(record.Plan[23].Intent=="groupformation")record.Plan[23]={"rest",PickLocation(player,record,"rest")}; auto tx = WorldDatabase.BeginTransaction(); tx->PAppend("DELETE FROM vcore_adventure_day WHERE character_guid=%u",guid); for (unsigned h = 0; h < 24; ++ h) @@ -1096,6 +1103,16 @@ bool AdventureBehaviorMgr::UpdateSocial(Player* player,Record& r,uint32 diff) void AdventureBehaviorMgr::Micro(Player * player, Record & r) { + auto spacingTravel=sClientlessTravelMgr->GetStatus(player->GetGUID().GetCounter()); + if(r.NextSpacing>GameTime::GetGameTime() && spacingTravel && spacingTravel->State==ClientlessTravelState::Moving)return; + if(GameTime::GetGameTime()>=r.NextSpacing && player->GetStandState()==UNIT_STAND_STATE_STAND && + !player->GetTransport() && !player->IsFlying() && ClientlessPositioning::Clearance(player,*player)<1.1f) + { + r.NextSpacing=GameTime::GetGameTime()+30; + Position spot;std::vector path; + if(ClientlessPositioning::Nearby(player,*player,3.5f,spot,path) && sClientlessTravelMgr->StartTravel(player,spot))return; + } + auto profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter()); if (! profile) return; auto l = sAdventureLocationMgr->Find(r.Location); @@ -1274,12 +1291,69 @@ void AdventureBehaviorMgr::Micro(Player * player, Record & r) TC_LOG_INFO("server.adventurebot", "MICRO_START guid=%u intent='%s' location='%s' action='%s' durationMs=%u", player->GetGUID().GetCounter(), r.Intent.c_str(), r.Location.c_str(), a.Key.c_str(), r.MicroTimer); } +bool AdventureBehaviorMgr::UpdateNavigationRescue(Player* player,Record& r) +{ + auto now=GameTime::GetGameTime();uint32 guid=player->GetGUID().GetCounter(); + if(!r.RescueAt && r.LastNavigationCheck && now-r.LastNavigationCheck>5) + {r.StalledSince=0;r.RescueFailures=0;r.LastTravelFailed=false;} + r.LastNavigationCheck=now; + if(!player->IsAlive() || player->IsInCombat() || player->IsBeingTeleported() || player->GetMap()->IsDungeon()) + { + if(r.RescueAt)player->SetUInt32Value(UNIT_NPC_EMOTESTATE,0); + r.RescueAt=0;r.StalledSince=0;return false; + } + if(r.RescueAt) + { + if(player->GetExactDist(r.RescueOrigin)>1.0f) + {r.RescueAt=0;player->SetUInt32Value(UNIT_NPC_EMOTESTATE,0);return false;} + if(nowSetUInt32Value(UNIT_NPC_EMOTESTATE,0); + auto city=sObjectMgr->GetGameTeleExactName(player->GetTeamId()==TEAM_ALLIANCE?"Stormwind":"Orgrimmar"); + if(!city)return false; + Position target;target.Relocate(city->position_x,city->position_y,city->position_z,city->orientation); + if(!sAdventureAreaMgr->AllowsPosition(player,city->mapId,target))return false; + if(r.Instance.Active){FinishInstance(player,r);if(r.Instance.Active)return false;} + if(player->IsInFlight()){player->GetMotionMaster()->MovementExpired();player->CleanupAfterTaxiFlight();} + if(!player->TeleportTo(city->mapId,target.GetPositionX(),target.GetPositionY(),target.GetPositionZ(),target.GetOrientation()))return false; + player->GetSession()->CompleteClientlessTeleport(); + r.LastRescue=now;r.StalledSince=0;r.RescueFailures=0;r.LastTravelFailed=false;r.Queued=false;r.Dirty=true;r.State=Phase::Idle; + player->SaveToDB(); + TC_LOG_INFO("server.adventurebot","NAV_HEARTH_COMPLETE guid=%u city='%s'",guid,city->name.c_str()); + return true; + } + auto action=sClientlessActionExecutorMgr->GetStatus(guid); + bool failed=action && action->State==ClientlessActionExecutionState::Failed; + bool travelling=r.State==Phase::Travel || (action && action->State==ClientlessActionExecutionState::Moving) || failed; + if(!travelling || r.State==Phase::OnSite) + {r.StalledSince=0;r.RescueFailures=0;r.LastTravelFailed=false;return false;} + if(!r.StalledSince || r.ProgressMap!=player->GetMapId() || player->GetExactDist(r.ProgressPosition)>=30) + {r.StalledSince=now;r.ProgressMap=player->GetMapId();r.ProgressPosition.Relocate(player);r.RescueFailures=0;} + if(failed && !r.LastTravelFailed)++r.RescueFailures; + r.LastTravelFailed=failed; + bool aboard=player->GetTransport() || player->IsInFlight() || player->IsFlying(); + // Normal traffic, waits and hover/flight pauses get a much longer grace. + if(aboard && nowIsFalling() || player->HasUnitState(UNIT_STATE_CASTING) || + !AdventureRecoveryPolicy::Rescue(now,r.StalledSince,r.LastRescue,r.RescueFailures))return false; + ResetMicro(player,r);sClientlessPlayerMgr->ClearQueuedActions(guid);sClientlessActionExecutorMgr->Clear(guid); + sClientlessTravelMgr->CancelTravel(guid);player->StopMoving();player->SetStandState(UNIT_STAND_STATE_STAND); + r.RescueOrigin.Relocate(player);r.RescueAt=now+10;r.Detail="using hearthstone after persistent navigation failure"; + // Native Hearthstone visual220 uses precast kit36. Animate without casting + // its bind-location teleport effect: this recovery always goes to the capital. + player->SetUInt32Value(UNIT_NPC_EMOTESTATE,EMOTE_STATE_SPELL_PRECAST); + player->SendPlaySpellVisualKit(36,0,10000); + TC_LOG_INFO("server.adventurebot","NAV_HEARTH_START guid=%u stalled=%llu failures=%u",guid,static_cast(now-r.StalledSince),r.RescueFailures); + return true; +} + void AdventureBehaviorMgr::Update(Player * player, uint32 diff) { uint32 guid = player->GetGUID().GetCounter(); auto entry = _records.find(guid); if (entry == _records.end() || ! entry->second.Enabled || ! player->IsInWorld()) return; auto & r = entry->second; + if(UpdateNavigationRescue(player,r))return; if(r.AreaRevision!=sAdventureAreaMgr->Revision()) { if(r.Schedule) {Generate(player,r,DateOf(Local(GameTime::GetGameTime())));r.Hour=-1;} @@ -1496,6 +1570,17 @@ bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args) handler->SendSysMessage("Use an online clientless character GUID."); return false; } + if(op=="eligibility") + { + std::string intent,key;in>>intent>>key; + auto location=sAdventureLocationMgr->Find(key); + if(!location){handler->SendSysMessage("Unknown adventure location.");return false;} + handler->PSendSysMessage("Eligibility GUID %u: intent='%s' location='%s' instanceMap=%u level=%u content=%u own=%u invitation=%u",guid, + intent.c_str(),key.c_str(),location->InstanceMap,player->getLevel(), + sAdventureLocationMgr->LevelEligible(player,*location,intent)?1:0,Allowed(player,*location,intent)?1:0, + sAdventureLocationMgr->LevelEligible(player,*location,intent,true)?1:0); + return true; + } if(op=="area") { if(!sAdventureAreaMgr->EnsureLoaded())return false; @@ -1879,6 +1964,8 @@ void AdventureBehaviorMgr::InterruptForCombat(Player* player) auto it=_records.find(player->GetGUID().GetCounter()); if(it==_records.end()) return; auto& r=it->second; + r.StalledSince=0;r.RescueFailures=0;r.LastTravelFailed=false; + if(r.RescueAt){r.RescueAt=0;player->SetUInt32Value(UNIT_NPC_EMOTESTATE,0);} ResetMicro(player,r); sClientlessPlayerMgr->ClearQueuedActions(player->GetGUID().GetCounter()); sClientlessActionExecutorMgr->Clear(player->GetGUID().GetCounter()); diff --git a/src/server/game/World/AdventureBehaviorMgr.h b/src/server/game/World/AdventureBehaviorMgr.h index de0d258ddc..d7dd7d7c98 100644 --- a/src/server/game/World/AdventureBehaviorMgr.h +++ b/src/server/game/World/AdventureBehaviorMgr.h @@ -60,6 +60,10 @@ private: time_t PersonalDeadline=0; time_t Deadline=0; uint32 AreaRevision=0; + time_t StalledSince=0,LastRescue=0,RescueAt=0,NextSpacing=0,LastNavigationCheck=0; + uint32 RescueFailures=0,ProgressMap=0; + bool LastTravelFailed=false; + Position ProgressPosition,RescueOrigin; uint32 Date=0, Timer=0, MicroTimer=0, GestureTimer=0, RetryTimer=0; int Hour=-1; AdventureBehaviorPolicy::Day Plan; @@ -87,6 +91,7 @@ private: bool WorkPending=false; }; bool ResolveInstance(Player* player, Record& record); + bool UpdateNavigationRescue(Player* player,Record& record); bool SaveInstance(Player* player, Record& record); bool RestoreInstance(Player* player, Record& record); bool UpdateInstance(Player* player, Record& record, time_t now); diff --git a/src/server/game/World/AdventureCombatMgr.cpp b/src/server/game/World/AdventureCombatMgr.cpp index 401dd0d706..8fa47aa563 100644 --- a/src/server/game/World/AdventureCombatMgr.cpp +++ b/src/server/game/World/AdventureCombatMgr.cpp @@ -549,10 +549,18 @@ bool AdventureCombatMgr::Flee(Player* p,Record& r) } return true; } +void AdventureCombatMgr::Revived(Player* player,time_t when) +{ + auto& r=_records[player->GetGUID().GetCounter()]; + r.DeathChain.Revived=when;r.Dead=false;r.ForceGraveyard=false;r.CemeteryPending=false; + r.Suspended=false;r.NextCorpseSearch=0;r.FailedCorpsePoints.clear();r.Cooldown=30000;r.Timer=0; +} + bool AdventureCombatMgr::Death(Player* p,Record& r) { if(!r.Dead) { + r.ForceGraveyard=r.DeathChain.Died(GameTime::GetGameTime()); if(r.AI)Finish(p,r); r.Fleeing=false;r.ThreatPositions.clear(); sAdventureBehaviorMgr->InterruptForCombat(p);r.Damagers.clear();r.Dead=true;r.Suspended=true; @@ -577,9 +585,17 @@ bool AdventureCombatMgr::Death(Player* p,Record& r) { r.CemeteryPending=false; p->ResurrectPlayer(0.5f,false);p->SpawnCorpseBones(); + r.ForceGraveyard=false; TC_LOG_INFO("server.adventurebot","DEATH_CEMETERY_REVIVE guid=%u",p->GetGUID().GetCounter()); return true; } + if(r.ForceGraveyard) + { + sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); + r.CemeteryPending=true;p->RepopAtGraveyard(); + TC_LOG_INFO("server.adventurebot","DEATH_CHAIN_GRAVEYARD guid=%u",p->GetGUID().GetCounter()); + return true; + } auto corpse=p->GetCorpse();if(!corpse)return true; auto now=GameTime::GetGameTime(); if(corpse->IsWithinDistInMap(p,CORPSE_RECLAIM_RADIUS,true)) @@ -761,6 +777,12 @@ bool AdventureCombatMgr::Update(Player* p,uint32 diff) r.Timer=500; if(p->IsAlive() && sAdventureEncounterMgr->Update(p,500))return true; if(!p->IsAlive())return Death(p,r); + if(r.Dead) + { + r.DeathChain.Revived=GameTime::GetGameTime(); + r.Dead=false;r.ForceGraveyard=false;r.Suspended=false;r.NextCorpseSearch=0;r.FailedCorpsePoints.clear();r.Cooldown=30000;r.Timer=0; + return false; + } if(p->IsResurrectRequested())p->ClearResurrectRequestData(); if(!p->IsInCombat() && !p->HasUnitState(UNIT_STATE_CASTING) && !p->IsFlying() && !p->IsFalling() && GameTime::GetGameTime()>=r.NextRole) { @@ -781,7 +803,6 @@ bool AdventureCombatMgr::Update(Player* p,uint32 diff) } if(GroupHeal(p,r))return true; if(GroupTarget(p,r))return true; - if(r.Dead){r.Dead=false;r.Suspended=false;r.NextCorpseSearch=0;r.FailedCorpsePoints.clear();r.Cooldown=30000;r.Timer=0;return false;} // First actual damager wins, including interrupting a proactive/training // target. Keep all subsequent damagers ordered for after that focus dies. if(!r.Defending && !p->GetGroup())while(!r.Damagers.empty()) diff --git a/src/server/game/World/AdventureCombatMgr.h b/src/server/game/World/AdventureCombatMgr.h index 96ca1af70f..db2a4158c3 100644 --- a/src/server/game/World/AdventureCombatMgr.h +++ b/src/server/game/World/AdventureCombatMgr.h @@ -7,6 +7,7 @@ #include #include #include "Position.h" +#include "AdventureRecoveryPolicy.h" #include class Player; class Unit; @@ -17,6 +18,7 @@ public: static AdventureCombatMgr* instance(); bool Update(Player* player,uint32 diff); void DamageTaken(Player* player,Unit* attacker,uint32 damage); + void Revived(Player* player,time_t when); void Offline(Player* player); bool TrainingAvailable(Player* player) const; bool Train(Player* player,uint32 durationMs,time_t deadline=0); @@ -37,6 +39,8 @@ private: std::vector FailedCorpsePoints,ThreatPositions; uint32 Timer=0,UnreachableMs=0,Cooldown=0; int PreparedMode=-1; + AdventureRecoveryPolicy::DeathChain DeathChain; + bool ForceGraveyard=false; }; bool Start(Player* player,Record& record,Unit* target,bool training,bool defending,time_t until); bool Valid(Player* player,Unit* target,bool training) const; diff --git a/src/server/game/World/AdventureGeneratorMgr.cpp b/src/server/game/World/AdventureGeneratorMgr.cpp index 573922e680..7efcf207c3 100644 --- a/src/server/game/World/AdventureGeneratorMgr.cpp +++ b/src/server/game/World/AdventureGeneratorMgr.cpp @@ -609,7 +609,14 @@ bool AdventureGeneratorMgr::Reconcile(Player* player,std::string& error) if(player->getLevel()>=20 && profile->CanGroundMount) { uint32 rank=player->getLevel()>=60 && profile->CanFreeFly?3:player->getLevel()>=40?2:1; + for(auto spell:{33388u,33391u,34090u}) + { + if((spell==33391 && rank<2) || (spell==34090 && rank<3))continue; + if(Learnable(player,spell) && !player->HasSpell(spell))player->LearnSpell(spell,false); + } player->SetSkill(SKILL_RIDING,rank,rank==3?225:rank==2?150:75,rank==3?225:rank==2?150:75); + if(player->GetSkillValue(SKILL_RIDING)<(rank==3?225:rank==2?150:75)) + {error="native riding skill provisioning failed";return false;} } if(player->getLevel()>=60 && Learnable(player,90267) && !player->HasSpell(90267))player->LearnSpell(90267,false); if(player->getLevel()>=68 && Learnable(player,54197) && !player->HasSpell(54197))player->LearnSpell(54197,false); diff --git a/src/server/game/World/AdventureGroupMgr.cpp b/src/server/game/World/AdventureGroupMgr.cpp index 5cd84583cd..420cf8fee0 100644 --- a/src/server/game/World/AdventureGroupMgr.cpp +++ b/src/server/game/World/AdventureGroupMgr.cpp @@ -4,6 +4,7 @@ #include "AdventureBehaviorMgr.h" #include "AdventureCombatMgr.h" #include "AdventureLocationMgr.h" +#include "AdventureAreaMgr.h" #include "ClientlessLuaEngine.h" #include "ClientlessPlayerMgr.h" #include "ClientlessTravelMgr.h" @@ -97,7 +98,7 @@ bool AdventureGroupMgr::RecruitPlayer(Player* player, std::string const& intent) if(other==player || other->GetSession()->IsClientless() || !Ready(other) || other->isAFK() || other->GetGroup() || other->GetGroupInvite() || other->GetTeam()!=player->GetTeam() || !player->IsInMap(other) || !player->IsInPhase(other) || !player->CanSeeOrDetect(other) || - !player->IsWithinLOSInMap(other) || _playerInvites.count(other->GetGUID()))continue; + !player->IsWithinLOSInMap(other) || _playerInvites.count(other->GetGUID()) || !CanInvite(player,other))continue; _playerInvites[other->GetGUID()]=now+600; Invite(player,other,intent,true);return true; } @@ -111,6 +112,7 @@ void AdventureGroupMgr::Offline(Player* p) } bool AdventureGroupMgr::Accept(Player* invited,Player* inviter) { + if(!CanInvite(inviter,invited))return false; if(!Ready(invited) || !Ready(inviter) || invited->GetGroup() || !inviter->GetSession()->IsClientless() || invited->GetTeam()!=inviter->GetTeam())return false; auto it=_records.find(invited->GetGUID()); @@ -122,6 +124,21 @@ bool AdventureGroupMgr::Accept(Player* invited,Player* inviter) auto group=inviter->GetGroup(); return (!group || (group->GetLeaderGUID()==inviter->GetGUID() && group->GetMembersCount()getLevel(),member->getLevel()))return false; + if(!member->GetSession()->IsClientless())if(auto group=leader->GetGroup()) + for(auto const& slot:group->GetMemberSlots()) + if(auto bot=ObjectAccessor::FindPlayer(slot.guid)) + if(bot->GetSession() && bot->GetSession()->IsClientless() && !AdventureGroupPolicy::AcceptPlayerLevel(bot->getLevel(),member->getLevel()))return false; + auto plan=sAdventureBehaviorMgr->GroupPlan(leader); + auto location=sAdventureLocationMgr->Find(plan.Forming?plan.NextLocation:plan.Location); + if(!location || !sAdventureLocationMgr->LevelEligible(member,*location,Activity(plan),true) || + !sAdventureLocationMgr->LevelEligible(leader,*location,Activity(plan),true))return false; + Position goal;goal.Relocate(location->Point.X,location->Point.Y,location->Point.Z); + return sAdventureAreaMgr->AllowsPosition(member,location->Point.MapId,goal); +} void AdventureGroupMgr::Update(Player* p) { auto now=GameTime::GetGameTime();auto& r=_records[p->GetGUID()]; @@ -217,6 +234,7 @@ bool AdventureGroupMgr::FollowMember(Player* player) void AdventureGroupMgr::Invite(Player* p,Player* other,std::string const& intent,bool forming) { + if(!CanInvite(p,other))return; auto now=GameTime::GetGameTime();auto& r=_records[p->GetGUID()];auto& peer=_records[other->GetGUID()]; unsigned pending=0; for(auto const& entry:_records)if(entry.second.Inviter==p->GetGUID() && entry.second.InviteUntil>now) @@ -239,6 +257,7 @@ void AdventureGroupMgr::Invite(Player* p,Player* other,std::string const& intent void AdventureGroupMgr::RequestPlayerAdmission(Player* member,Player* requester) { if(!requester || requester->GetSession()->IsClientless() || requester->GetGroup() || requester->GetGroupInvite() || !BotLed(member))return; + if(!AdventureGroupPolicy::AcceptPlayerLevel(member->getLevel(),requester->getLevel()))return; auto group=member->GetGroup();auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID()); if(!Ready(leader) || !Ready(requester) || leader->GetTeam()!=requester->GetTeam())return; auto now=GameTime::GetGameTime();auto& r=_records[leader->GetGUID()]; diff --git a/src/server/game/World/AdventureGroupMgr.h b/src/server/game/World/AdventureGroupMgr.h index ff7b2a3c4d..5ac38e3881 100644 --- a/src/server/game/World/AdventureGroupMgr.h +++ b/src/server/game/World/AdventureGroupMgr.h @@ -23,6 +23,7 @@ public: bool PlayerLeadership(Player* leader); void Offline(Player* player); bool Accept(Player* invited,Player* inviter); + bool CanInvite(Player* leader,Player* member); bool FollowMember(Player* player); bool BotLed(Player* player) const; bool Forming(Player* player) const; diff --git a/src/server/game/World/AdventureGroupPolicy.h b/src/server/game/World/AdventureGroupPolicy.h index 25e9e92c65..71c5101b04 100644 --- a/src/server/game/World/AdventureGroupPolicy.h +++ b/src/server/game/World/AdventureGroupPolicy.h @@ -12,6 +12,10 @@ inline bool Eligible(std::string const& intent) {return intent=="instance" || intent=="raid" || intent=="adventure" || intent=="profession" || intent=="social";} inline unsigned Capacity(std::string const& intent) {return intent=="raid"?20:intent=="instance"?5:intent=="social"?10:3;} +inline bool InviteLevel(unsigned bot,unsigned player) +{return bot>player?bot-player<=5:player-bot<=5;} +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);} template diff --git a/src/server/game/World/AdventureGroupTravelMgr.cpp b/src/server/game/World/AdventureGroupTravelMgr.cpp index c31ab733ed..349c232291 100644 --- a/src/server/game/World/AdventureGroupTravelMgr.cpp +++ b/src/server/game/World/AdventureGroupTravelMgr.cpp @@ -330,6 +330,8 @@ bool AdventureGroupTravelMgr::Follow(Player* player) return false; } auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID());if(!leader || !player->IsAlive() || player->IsInCombat() || player->IsBeingTeleported() || player->IsCharmed())return true; + auto ride=sClientlessTransportMgr->GetStatus(guid); + if(ride && ride->Phase!=ClientlessTransportPhase::Arrived && ride->Phase!=ClientlessTransportPhase::Failed)return true; auto& record=_records[guid]; if(record.FlightOwned) { diff --git a/src/server/game/World/AdventureInteractionMgr.cpp b/src/server/game/World/AdventureInteractionMgr.cpp index 8fb1a60785..8fc43e275d 100644 --- a/src/server/game/World/AdventureInteractionMgr.cpp +++ b/src/server/game/World/AdventureInteractionMgr.cpp @@ -1,6 +1,7 @@ #include "AdventureInteractionMgr.h" #include "AdventureInteractionPolicy.h" #include "AdventureGroupMgr.h" +#include "AdventureGroupPolicy.h" #include "AdventureGroupTravelMgr.h" #include "AdventureBehaviorMgr.h" #include "AdventureCombatMgr.h" @@ -128,6 +129,7 @@ bool AdventureInteractionMgr::Update(Player* p,uint32 diff) { auto inviter=ObjectAccessor::FindPlayer(leader); bool accept=inviter && inviter->IsAlive() && p->IsFriendlyTo(inviter) && !p->GetMap()->IsDungeon() && + (inviter->GetSession()->IsClientless() || AdventureGroupPolicy::AcceptPlayerLevel(p->getLevel(),inviter->getLevel())) && (inviter->GetSession()->IsClientless()?sAdventureGroupMgr->Accept(p,inviter): frand(0,1)InteractionIntent(p),Sociability(p))); WorldPackets::Party::PartyInviteResponse response{WorldPacket(CMSG_PARTY_INVITE_RESPONSE)};response.Accept=accept; diff --git a/src/server/game/World/AdventureLocationMgr.cpp b/src/server/game/World/AdventureLocationMgr.cpp index 31e9df82c8..440bcadcd0 100644 --- a/src/server/game/World/AdventureLocationMgr.cpp +++ b/src/server/game/World/AdventureLocationMgr.cpp @@ -1,4 +1,6 @@ #include "AdventureAreaMgr.h" +#include "ObjectMgr.h" +#include "Player.h" #include "AdventureLocationMgr.h" #include "AdventureBehaviorPolicy.h" #include "AdventureGroupPolicy.h" @@ -31,6 +33,26 @@ bool AdventureLocationMgr::Reload() _actions.clear(); _dialogs.clear(); _loaded = false; + _instanceMinimum.clear(); + for(auto entry:sLFGDungeonStore) + { + auto map=sMapStore.LookupEntry(entry->MapID); + if(!map || !map->IsDungeon() || !entry->MinLevel)continue; + Difficulty difficulty=map->IsRaid()?RAID_DIFFICULTY_25MAN_NORMAL:DUNGEON_DIFFICULTY_NORMAL; + sDBCManager.GetDownscaledMapDifficultyData(map->ID,difficulty); + if(entry->DifficultyID!=difficulty)continue; + auto& minimum=_instanceMinimum[uint32(entry->MapID)]; + minimum=minimum?std::min(minimum,entry->MinLevel):entry->MinLevel; + } + // The native access requirements are authoritative even for maps absent + // from the dungeon finder catalog (and may tighten its recommended band). + for(auto map:sMapStore)if(map->IsDungeon()) + { + Difficulty difficulty=map->IsRaid()?RAID_DIFFICULTY_25MAN_NORMAL:DUNGEON_DIFFICULTY_NORMAL; + sDBCManager.GetDownscaledMapDifficultyData(map->ID,difficulty); + if(auto access=sObjectMgr->GetAccessRequirement(map->ID,difficulty)) + if(access->levelMin)_instanceMinimum[map->ID]=std::max(_instanceMinimum[map->ID],uint32(access->levelMin)); + } if (! WorldDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_location'")) return false; if (auto rows = WorldDatabase.Query("SELECT w.id,w.point_key,w.category,w.map,w.position_x,w.position_y,w.position_z,w.orientation," "COALESCE(a.display_name,w.point_key),COALESCE(a.region_key,''),a.area_id," "a.zone_id,a.is_area,a.faction " "FROM vcore_clientless_world_point w LEFT JOIN vcore_adventure_location a ON a.point_key=w.point_key " "WHERE w.enabled=1 AND w.category NOT LIKE 'travel%' ORDER BY w.point_key")) { @@ -107,7 +129,7 @@ bool AdventureLocationMgr::Reload() auto f=rows->Fetch();_workstations[f[0].GetUInt32()].insert(f[1].GetString()); } while(rows->NextRow()); _loaded = true; - TC_LOG_INFO("server.adventurebot", "ADVENTURE_CATALOG locations=%u actions=%u lines=%u", uint32(_locations.size()), uint32(_actions.size()), uint32(_dialogs.size())); + TC_LOG_INFO("server.adventurebot", "ADVENTURE_CATALOG locations=%u actions=%u lines=%u instanceBands=%u", uint32(_locations.size()), uint32(_actions.size()), uint32(_dialogs.size()),uint32(_instanceMinimum.size())); return true; } @@ -117,6 +139,19 @@ AdventureLocation const * AdventureLocationMgr::Find(std::string const & key) co return nullptr; } +bool AdventureLocationMgr::LevelEligible(Player const* player,AdventureLocation const& location,std::string const& intent,bool strictArea) const +{ + if(!player)return false; + if(intent=="instance" || intent=="raid") + { + auto it=_instanceMinimum.find(location.InstanceMap); + return it!=_instanceMinimum.end() && it->second && player->getLevel()>=it->second; + } + if(!strictArea)return true; + auto area=sAdventureAreaMgr->Area(location.AreaId?location.AreaId:location.ZoneId); + return area.KnownLevel && player->getLevel()>=area.MinLevel; +} + bool AdventureLocationMgr::IsWorkstation(uint32 entry,std::string const& role) const { auto it=_workstations.find(entry);return it!=_workstations.end() && it->second.count(role); diff --git a/src/server/game/World/AdventureLocationMgr.h b/src/server/game/World/AdventureLocationMgr.h index ab9dce57be..cbe07e3cf9 100644 --- a/src/server/game/World/AdventureLocationMgr.h +++ b/src/server/game/World/AdventureLocationMgr.h @@ -7,6 +7,7 @@ #include #include class ChatHandler; +class Player; struct AdventureLocation { ClientlessWorldPoint Point; @@ -40,7 +41,9 @@ public: AdventureLocation const* Find(std::string const& key) const; bool IsWorkstation(uint32 entry,std::string const& role) const; float Weight(AdventureLocation const& location, std::string const& intent) const; + bool LevelEligible(Player const* player, AdventureLocation const& location, std::string const& intent, bool strictArea = false) const; private: + std::unordered_map _instanceMinimum; std::unordered_map> _workstations; bool _loaded = false; std::vector _locations; diff --git a/src/server/game/World/AdventureRecoveryPolicy.h b/src/server/game/World/AdventureRecoveryPolicy.h new file mode 100644 index 0000000000..3a9b876836 --- /dev/null +++ b/src/server/game/World/AdventureRecoveryPolicy.h @@ -0,0 +1,26 @@ +#ifndef VCORE_ADVENTURE_RECOVERY_POLICY_H +#define VCORE_ADVENTURE_RECOVERY_POLICY_H +#include +namespace AdventureRecoveryPolicy +{ +struct DeathChain +{ + std::int64_t Revived = 0; + unsigned QuickDeaths = 0; + bool NextGraveyard = false; + bool Died(std::int64_t now) + { + bool force=NextGraveyard; + if(force){QuickDeaths=0;NextGraveyard=false;} + else + { + QuickDeaths=Revived>0 && now>=Revived && now-Revived<=60?QuickDeaths+1:0; + if(QuickDeaths>=5)NextGraveyard=true; + } + Revived=0;return force; + } +}; +inline bool Rescue(std::int64_t now,std::int64_t stalled,std::int64_t last,unsigned failures) +{return stalled>0 && now>=stalled+600 && failures>=5 && (!last || now>=last+1800);} +} +#endif diff --git a/src/server/game/World/AdventureSocialActions.cpp b/src/server/game/World/AdventureSocialActions.cpp index fdf730ac6f..66c336cea6 100644 --- a/src/server/game/World/AdventureSocialActions.cpp +++ b/src/server/game/World/AdventureSocialActions.cpp @@ -1,3 +1,4 @@ +#include "ClientlessPositioning.h" #include "AdventureSocialActions.h" #include "AdventureLocationMgr.h" #include "AdventureEconomyPolicy.h" @@ -167,12 +168,14 @@ namespace bool Approach(Player* player,AdventureSocialScene& scene,WorldObject* target,AdventureSocialContext& c) { if(!InBounds(player,target,c))return false; + Position crowded;bool hasCrowded=false,already=false; if(player->GetExactDist(target)<=3.8f && (scene.Kind=="work_skinning" || AtFront(player,target)) && ((AdventureEconomyPolicy::Economic(scene.Kind) || AdventureProfessionPolicy::Recognized(scene.Kind))?TargetVisible(player,target,scene.Kind): player->IsWithinLOS(target->GetPositionX(),target->GetPositionY(),target->GetPositionZ()+1.5f))) { - scene.Approaching=false; - return true; + already=true; + if(ClientlessPositioning::Clearance(player,*player)>=1.1f) + {scene.Approaching=false;return true;} } // People are approached in front of their actual facing, not from the // bot's current bearing. Furniture may still be reached from any side. @@ -191,9 +194,15 @@ namespace if(!player->GetMap()->isInLineOfSight(player->GetPhaseShift(),p.GetPositionX(),p.GetPositionY(),floor+((AdventureEconomyPolicy::Economic(scene.Kind) || AdventureProfessionPolicy::Recognized(scene.Kind))?player->GetCollisionHeight():1.5f), target->GetPositionX(),target->GetPositionY(),target->GetPositionZ()+(AdventureProfessionPolicy::Recognized(scene.Kind) && target->ToGameObject()?std::max(1.5f,target->GetCollisionHeight()):AdventureEconomyPolicy::Economic(scene.Kind)?target->GetCollisionHeight():1.5f), LINEOFSIGHT_ALL_CHECKS,VMAP::ModelIgnoreFlags::Nothing))continue; + if(ClientlessPositioning::Clearance(player,p)<1.1f) + {if(!hasCrowded){crowded=p;hasCrowded=true;}continue;} if(!c.Move(p,(AdventureEconomyPolicy::Economic(scene.Kind) || AdventureExplorationPolicy::Inspection(scene.Kind) || AdventureProfessionPolicy::Recognized(scene.Kind))?350:55))continue; + ClientlessPositioning::Reserve(player,p); scene.Destination=p;scene.Approaching=true;return true; } + if(already){scene.Approaching=false;return true;} + if(hasCrowded && c.Move(crowded,(AdventureEconomyPolicy::Economic(scene.Kind) || AdventureExplorationPolicy::Inspection(scene.Kind) || AdventureProfessionPolicy::Recognized(scene.Kind))?350:55)) + {scene.Destination=crowded;scene.Approaching=true;return true;} return false; } bool OnChair(Player* player,GameObject* chair,float& error) diff --git a/src/server/game/World/ClientlessActionExecutorMgr.cpp b/src/server/game/World/ClientlessActionExecutorMgr.cpp index 063f9f5d8c..30ac845948 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.cpp +++ b/src/server/game/World/ClientlessActionExecutorMgr.cpp @@ -1881,8 +1881,21 @@ bool ClientlessActionExecutorMgr::TryFallbackFlight(Player* player, uint32 chara if (record.FallbackFlightAttempted || player->IsFlying()) return false; ClientlessTravelNode goal; - if (!GetTravelGoal(record, goal) || goal.MapId != player->GetMapId()) - return false; + if (!GetTravelGoal(record, goal))return false; + bool intermediate=goal.MapId!=player->GetMapId(); + if(intermediate) + { + bool found=false; + for(std::size_t i=record.RouteStep;iFindNode(record.Route[i].NodeKey); + if(!node || node->MapId!=player->GetMapId() || !sAdventureAreaMgr->AllowsNode(player,*node) || player->GetExactDist(node->X,node->Y,node->Z)<5)continue; + Position point;point.Relocate(node->X,node->Y,node->Z); + if(!std::isfinite(sClientlessFlightMgr->Estimate(player,node->MapId,*player,point,true)))continue; + goal=*node;found=true;break; + } + if(!found)return false; + } Position from, target; from.Relocate(player); target.Relocate(goal.X, goal.Y, goal.Z, goal.O); @@ -1897,12 +1910,13 @@ bool ClientlessActionExecutorMgr::TryFallbackFlight(Player* player, uint32 chara return false; } record.FallbackFlightAttempted = true; + record.FallbackIntermediate = intermediate; record.EdgePath.clear(); record.GroundExitActive = false; record.GroundResumeActive = false; record.RecoveryWanderActive = false; record.FinalLocalLegActive = false; - record.FinalLocalLegPending = record.Status.ActionName == "travel_to_location"; + record.FinalLocalLegPending = !intermediate && record.Status.ActionName == "travel_to_location"; record.Route.clear(); ClientlessTravelRouteStep source, destination; source.NodeKey = "flight_fallback_departure"; @@ -2345,6 +2359,11 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( RecoverFlightLeg(player,characterGuid,record,flight?flight->Detail:"free flight state disappeared"); return; } + if(record.FallbackIntermediate) + { + record.FallbackIntermediate=false; + RecoverTravel(player,characterGuid,record,0,"flight reached intermediate departure node");return; + } auto const& step=record.Route[record.RouteStep]; if (player->GetExactDist(step.X,step.Y,step.Z)>1.5f) { diff --git a/src/server/game/World/ClientlessActionExecutorMgr.h b/src/server/game/World/ClientlessActionExecutorMgr.h index bcfbbc6f15..88cbfcf6cf 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.h +++ b/src/server/game/World/ClientlessActionExecutorMgr.h @@ -77,7 +77,7 @@ private: bool GroundExitActive = false; bool GroundResumeActive = false; bool GroundFlightExit = false; - bool FallbackFlightAttempted = false; + bool FallbackFlightAttempted = false, FallbackIntermediate = false; // C8.10b: active server-side taxi edge. bool TransportActive = false; diff --git a/src/server/game/World/ClientlessFlightMgr.cpp b/src/server/game/World/ClientlessFlightMgr.cpp index b452981693..dc23030ac7 100644 --- a/src/server/game/World/ClientlessFlightMgr.cpp +++ b/src/server/game/World/ClientlessFlightMgr.cpp @@ -166,6 +166,10 @@ Surface ClientlessFlightMgr::Sample(Player * player, Record & record, float x, f { Map * map = player->GetMap(); uint32 terrain = PhasingHandler::GetTerrainMapId(player->GetPhaseShift(), map->GetId(), map->GetTerrain(), x, y); + auto grid=Trinity::ComputeGridCoord(x,y); + int32 gx=(MAX_NUMBER_OF_GRIDS-1)-grid.x_coord,gy=(MAX_NUMBER_OF_GRIDS-1)-grid.y_coord; + if(terrain!=map->GetTerrain()->GetId() && !map->GetTerrain()->HasChildTerrainGridFile(terrain,gx,gy)) + terrain=map->GetTerrain()->GetId(); std::string key = std::to_string(terrain) + ":" + std::to_string(int(std::floor(x / 533.33333f))) + ":" + std::to_string(int(std::floor(y / 533.33333f))); auto found = record.Tiles.find(key); if (found == record.Tiles.end()) found = record.Tiles.emplace(key, TerrainMgr::ExistMapAndVMap(terrain, x, y)).first; @@ -648,6 +652,9 @@ bool ClientlessFlightMgr::Command(ChatHandler * handler, char const * args) handler->SendSysMessage("Usage: .clientless flight status OR test_node . test_node isolates flight execution; normal trips use the planner."); return false; } + if(auto session=sWorld->FindClientlessSession(guid))if(auto player=session->GetPlayer()) + handler->PSendSysMessage("Flight capability GUID %u: enabled=%u riding=%u flightLicense=%u coldWeather=%u outdoors=%u",guid,Enabled(player)?1:0, + player->GetSkillValue(SKILL_RIDING),player->HasSpell(90267)?1:0,player->HasSpell(54197)?1:0,(player->IsInWorld() && player->IsOutdoors())?1:0); if (! status) handler->SendSysMessage("No free-flight state for this character."); else handler->PSendSysMessage("Flight GUID %u: state=%s map=%u mountSpell=%u segments=%u avoidances=%u handoffs=%u travelled=%.1f detail='%s'", guid, StateName(status->State), status->MapId, status->MountSpell, status->Segments, status->Avoidances, status->Handoffs, status->Travelled, status->Detail.c_str()); return true; diff --git a/src/server/game/World/ClientlessLuaEngine.cpp b/src/server/game/World/ClientlessLuaEngine.cpp index cfaa91ed4d..b138ea0d56 100644 --- a/src/server/game/World/ClientlessLuaEngine.cpp +++ b/src/server/game/World/ClientlessLuaEngine.cpp @@ -246,6 +246,8 @@ public: {sAdventureGroupMgr->RequestPlayerAdmission(player,requester);} void OnTextEmote(Player* player, Player* source, uint32 emote) override { sAdventureInteractionMgr->Emote(player,source,emote); } + void OnResurrection(Player* player,time_t when) override + {sAdventureCombatMgr->Revived(player,when);} void OnDamageTaken(Player* player, Unit* attacker, uint32 damage) override { sAdventureCombatMgr->DamageTaken(player, attacker, damage); } void OnPlayerLogout(Player* player) override diff --git a/src/server/game/World/ClientlessPlayerMgr.cpp b/src/server/game/World/ClientlessPlayerMgr.cpp index 17e8e238cc..66281aa9ca 100644 --- a/src/server/game/World/ClientlessPlayerMgr.cpp +++ b/src/server/game/World/ClientlessPlayerMgr.cpp @@ -168,10 +168,11 @@ void ClientlessPlayerMgr::Update(uint32 diff) // Damage can arrive from different map workers. Hand GUID-only notices to // the world-thread controller, preserving order within each character. decltype(_damageNotices) damageNotices; + decltype(_resurrectionNotices) resurrectionNotices; decltype(_emoteNotices) emoteNotices; decltype(_helpNotices) helpNotices; decltype(_groupJoinNotices) groupJoinNotices; - { std::lock_guard lock(_damageMutex); damageNotices.swap(_damageNotices); emoteNotices.swap(_emoteNotices); helpNotices.swap(_helpNotices); groupJoinNotices.swap(_groupJoinNotices); } + { std::lock_guard lock(_damageMutex); damageNotices.swap(_damageNotices); resurrectionNotices.swap(_resurrectionNotices); emoteNotices.swap(_emoteNotices); helpNotices.swap(_helpNotices); groupJoinNotices.swap(_groupJoinNotices); } // CLIENTLESS_PLAYER_CORE_C8_2_TRAVEL_ROBUSTNESS measure(0,[&]{sClientlessTravelMgr->Update(diff);}); @@ -201,6 +202,21 @@ void ClientlessPlayerMgr::Update(uint32 diff) if (state == ClientlessPlayerState::Online && player) { + // Clientless sessions must acknowledge native taxi/graveyard map + // transfers too, not only the dedicated portal/transport executor. + if(player->IsBeingTeleported())session->CompleteClientlessTeleport(); + player=session->GetPlayer(); + if(!player || !player->IsInWorld()) + { + auto pending=resurrectionNotices.find(characterGuid); + if(player && pending!=resurrectionNotices.end()) + { + std::lock_guard lock(_damageMutex); + auto& queue=_resurrectionNotices[characterGuid]; + queue.insert(queue.begin(),pending->second.begin(),pending->second.end()); + } + ++itr;continue; + } auto begin=Clock::now(); if (!record.OnlineNotified) { @@ -214,6 +230,8 @@ void ClientlessPlayerMgr::Update(uint32 diff) } if (record.Controller) { + auto revivals=resurrectionNotices.find(characterGuid); + if(revivals!=resurrectionNotices.end())for(auto when:revivals->second)record.Controller->OnResurrection(player,when); auto notices=damageNotices.find(characterGuid); if(notices!=damageNotices.end())for(auto const& notice:notices->second) record.Controller->OnDamageTaken(player,ObjectAccessor::GetUnit(*player,notice.Attacker),notice.Damage); @@ -251,6 +269,12 @@ void ClientlessPlayerMgr::Update(uint32 diff) } } +void ClientlessPlayerMgr::Resurrection(uint32 characterGuid,time_t when) +{ + std::lock_guard lock(_damageMutex); + _resurrectionNotices[characterGuid].push_back(when); +} + void ClientlessPlayerMgr::DamageTaken(Player* player, Unit* attacker, uint32 damage) { if(!damage || !attacker || !player->GetSession()->IsClientless())return; diff --git a/src/server/game/World/ClientlessPlayerMgr.h b/src/server/game/World/ClientlessPlayerMgr.h index af8a055a35..0874d329d2 100644 --- a/src/server/game/World/ClientlessPlayerMgr.h +++ b/src/server/game/World/ClientlessPlayerMgr.h @@ -39,6 +39,7 @@ public: virtual void OnPlayerLogout(Player*) { } // Notification after health was reduced, never the mutable pre-damage event. virtual void OnDamageTaken(Player*, Unit*, uint32) { } + virtual void OnResurrection(Player*,time_t) { } virtual void OnGroupJoinRequest(Player*,Player*) { } virtual void OnTextEmote(Player*, Player*, uint32) { } virtual void OnSharedTargetDamage(Player*, ObjectGuid, ObjectGuid) { } @@ -55,6 +56,7 @@ public: bool IsManaged(uint32 characterGuid) const; void DamageTaken(Player* player, Unit* attacker, uint32 damage); + void Resurrection(uint32 characterGuid,time_t when); void GroupJoinRequest(Player* target,Player* requester); void TextEmote(Player* source, Player* target, uint32 emote); void SharedTargetDamage(Unit* victim,Unit* attacker,uint32 damage); @@ -95,6 +97,7 @@ private: std::unordered_map _activity; std::mutex _damageMutex; std::unordered_map> _damageNotices; + std::unordered_map> _resurrectionNotices; std::unordered_map _records; }; diff --git a/src/server/game/World/ClientlessPositioning.cpp b/src/server/game/World/ClientlessPositioning.cpp new file mode 100644 index 0000000000..e9bfb1a658 --- /dev/null +++ b/src/server/game/World/ClientlessPositioning.cpp @@ -0,0 +1,83 @@ +#include "ClientlessPositioning.h" +#include "AdventureAreaMgr.h" +#include "Creature.h" +#include "GameTime.h" +#include "Map.h" +#include "PathGenerator.h" +#include "Player.h" +#include +#include +#include + +namespace +{ +struct Reservation {uint32 Map;Position Point;time_t Until;float Radius;}; +// Clientless controllers and transport executors run on the world thread. +std::unordered_map reservations; +} +float ClientlessPositioning::Clearance(Player* p,Position const& point) +{ + float clearance=10;auto now=GameTime::GetGameTime(); + float range=std::min(60.0f,p->GetExactDist(point)+12); + std::vector creatures;p->GetCreatureListWithEntryInGrid(creatures,0,range); + std::vector players;p->GetPlayerListInGrid(players,range); + auto measure=[&](Unit* other) + { + if(other==p || !other->IsAlive() || !p->IsInPhase(other) || std::fabs(point.GetPositionZ()-other->GetPositionZ())>2)return; + float radii=std::clamp(p->GetFloatValue(UNIT_FIELD_BOUNDINGRADIUS),0.3f,2.0f)+ + std::clamp(other->GetFloatValue(UNIT_FIELD_BOUNDINGRADIUS),0.3f,3.0f); + clearance=std::min(clearance,point.GetExactDist2d(other)-radii); + }; + for(auto other:creatures)measure(other); + for(auto other:players)measure(other); + for(auto it=reservations.begin();it!=reservations.end();) + { + if(it->second.Until<=now){it=reservations.erase(it);continue;} + if(it->first!=p->GetGUID().GetCounter() && it->second.Map==p->GetMapId() && std::fabs(point.GetPositionZ()-it->second.Point.GetPositionZ())<2) + clearance=std::min(clearance,point.GetExactDist2d(it->second.Point)-it->second.Radius- + std::clamp(p->GetFloatValue(UNIT_FIELD_BOUNDINGRADIUS),0.3f,2.0f)); + ++it; + } + return clearance; +} +void ClientlessPositioning::Reserve(Player* p,Position const& point) +{reservations[p->GetGUID().GetCounter()]={p->GetMapId(),point,GameTime::GetGameTime()+30,std::clamp(p->GetFloatValue(UNIT_FIELD_BOUNDINGRADIUS),0.3f,2.0f)};} +bool ClientlessPositioning::Nearby(Player* p,Position const& center,float radius,Position& result,std::vector& path) +{ + if(!p || p->GetTransport() || !p->IsAlive())return false; + for(unsigned i=0;i<24;++i) + { + float angle=float((p->GetGUID().GetCounter()*7+i*5)%24)*6.2831853f/24; + float reach=std::min(radius,1.8f+float(i/8)*1.5f); + Position point;point.Relocate(center.GetPositionX()+std::cos(angle)*reach,center.GetPositionY()+std::sin(angle)*reach,center.GetPositionZ()); + float floor=p->GetMap()->GetHeight(p->GetPhaseShift(),point.GetPositionX(),point.GetPositionY(),center.GetPositionZ()+2,true,6); + if(floor<=INVALID_HEIGHT+1 || std::fabs(floor-center.GetPositionZ())>1.25f)continue; + point.m_positionZ=floor; + if(Clearance(p,point)<1.3f || !sAdventureAreaMgr->AllowsPosition(p,p->GetMapId(),point))continue; + PathGenerator nav(p);if(!nav.CalculatePath(point.GetPositionX(),point.GetPositionY(),floor) || (nav.GetPathType()&(PATHFIND_NOPATH|PATHFIND_INCOMPLETE)))continue; + auto const& points=nav.GetPath();if(points.size()<2)continue; + std::vector candidate;float length=0;bool safe=true; + for(auto const& v:points) + { + Position q;q.Relocate(v.x,v.y,v.z); + if(!candidate.empty()) + { + auto const& previous=candidate.back();float segment=previous.GetExactDist(&q);length+=segment; + if(segment>8 || std::fabs(previous.GetPositionZ()-q.GetPositionZ())>3 || + !sAdventureAreaMgr->AllowsSegment(p,p->GetMapId(),previous,q)){safe=false;break;} + unsigned steps=std::max(1u,unsigned(std::ceil(segment))); + for(unsigned s=1;s<=steps;++s) + { + float t=float(s)/steps,x=previous.GetPositionX()+(q.GetPositionX()-previous.GetPositionX())*t, + y=previous.GetPositionY()+(q.GetPositionY()-previous.GetPositionY())*t,z=previous.GetPositionZ()+(q.GetPositionZ()-previous.GetPositionZ())*t; + float ground=p->GetMap()->GetHeight(p->GetPhaseShift(),x,y,z+2,true,6); + if(ground<=INVALID_HEIGHT+1 || std::fabs(ground-z)>1){safe=false;break;} + } + } + candidate.push_back(q); + } + if(!safe || length>20 || candidate.back().GetExactDist(&point)>1)continue; + result=point;path=std::move(candidate);Reserve(p,point);return true; + } + return false; // No space is acceptable: retain the original shared spot. +} diff --git a/src/server/game/World/ClientlessPositioning.h b/src/server/game/World/ClientlessPositioning.h new file mode 100644 index 0000000000..ffb2e3eaa2 --- /dev/null +++ b/src/server/game/World/ClientlessPositioning.h @@ -0,0 +1,13 @@ +#ifndef VCORE_CLIENTLESS_POSITIONING_H +#define VCORE_CLIENTLESS_POSITIONING_H +#include "Common.h" +#include "Position.h" +#include +class Player; +namespace ClientlessPositioning +{ +float Clearance(Player* player,Position const& point); +void Reserve(Player* player,Position const& point); +bool Nearby(Player* player,Position const& center,float radius,Position& point,std::vector& path); +} +#endif diff --git a/src/server/game/World/ClientlessTransportMgr.cpp b/src/server/game/World/ClientlessTransportMgr.cpp index 137dad4490..904c7f453c 100644 --- a/src/server/game/World/ClientlessTransportMgr.cpp +++ b/src/server/game/World/ClientlessTransportMgr.cpp @@ -1,4 +1,5 @@ #include "AdventureAreaMgr.h" +#include "ClientlessPositioning.h" #include "ClientlessTravelGraphMgr.h" #include "ClientlessTransportMgr.h" #include "Chat.h" @@ -453,8 +454,48 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff) if (ride.Elapsed > limit) { fail("transport timeout; any passenger attachment retained"); return; } if (ride.Status.Phase == Phase::Waiting) { + if(player->GetMapId()!=ride.From.Map){fail("departure map changed while waiting");return;} + if(ride.WaitStage==1) + { + if(ride.WaitTrace.empty() || player->GetExactDist(ride.WaitTrace.back())>=0.3f)ride.WaitTrace.push_back(*player); + if(player->IsSplineEnabled())return; + if(ride.WaitTrace.back().GetExactDist(player)>0.05f)ride.WaitTrace.push_back(*player); + ride.WaitStage=2; + } + if(ride.WaitStage==3) + { + if(player->IsSplineEnabled())return; + if(ride.WaitTrace.empty() || player->GetExactDist(ride.WaitTrace.front())>1.5f) + {fail("return to shared transport node did not finish");return;} + ride.WaitStage=0;ride.WaitTrace.clear(); + } + if(ride.WaitStage==2) + { + if(!ride.Abort && !AtDock(player,object,ride.From,0))return; + std::vector back;back.push_back(*player); + for(auto it=ride.WaitTrace.rbegin();it!=ride.WaitTrace.rend();++it) + if(back.back().GetExactDist(&*it)>0.11f)back.push_back(*it); + ride.WaitStage=3; + if(back.size()>1)player->GetMotionMaster()->MoveSmoothPath(1,back.data(),back.size(),false); + ride.Status.Detail="returning along actual waiting path to shared transport node"; + return; + } if (ride.Abort) { fail("transport cancelled on land"); return; } - if (!AtDock(player, object, ride.From, ride.RequiredPause)) return; + if (!AtDock(player, object, ride.From, ride.RequiredPause)) + { + if(!ride.WaitTried && !player->GetTransport() && !player->IsInCombat()) + { + ride.WaitTried=true;Position spot;std::vector path; + if(ClientlessPositioning::Nearby(player,ride.From.Points.front().Pos,6,spot,path)) + { + ride.WaitTrace.push_back(*player);ride.WaitStage=1; + player->GetMotionMaster()->MoveSmoothPath(1,path.data(),path.size(),false); + ride.Status.Detail="walking to nearby spaced waiting position"; + TC_LOG_INFO("server.adventurebot.navigation","TRANSPORT_WAIT_SPOT guid=%u node='%s' x=%.2f y=%.2f",player->GetGUID().GetCounter(),ride.From.Node.c_str(),spot.GetPositionX(),spot.GetPositionY()); + } + } + return; + } if (Distance(*player, ride.From.Points.front().Pos) > 3.0f) { fail("player moved away from departure node while waiting"); return; } ride.Point = 0; diff --git a/src/server/game/World/ClientlessTransportMgr.h b/src/server/game/World/ClientlessTransportMgr.h index c370ad6c03..44831dffde 100644 --- a/src/server/game/World/ClientlessTransportMgr.h +++ b/src/server/game/World/ClientlessTransportMgr.h @@ -83,6 +83,9 @@ private: bool Abort = false; uint32 RecoveryMs = 0, Recoveries = 0; bool RecoveryMoving = false; + uint8 WaitStage=0; // 0 node, 1 walking out, 2 waiting, 3 returning + bool WaitTried=false; + std::vector WaitTrace; }; bool EnsureLoaded(); bool Sample(Player* player, Recording& recording, bool force); diff --git a/src/server/game/World/ClientlessTravelGraphMgr.cpp b/src/server/game/World/ClientlessTravelGraphMgr.cpp index 50bca64767..6459bfc117 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.cpp +++ b/src/server/game/World/ClientlessTravelGraphMgr.cpp @@ -578,7 +578,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( return false; } - if (!sAdventureAreaMgr->AllowsNode(player,destinationItr->second)) + if (!locationGoal && !sAdventureAreaMgr->AllowsNode(player,destinationItr->second)) { error="destination node denied by area/node catalog"; return false; } if (locationGoal) { @@ -587,13 +587,20 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( { error="destination location denied by area catalog"; return false; } } bool const flightEnabled = sClientlessFlightMgr->Enabled(player); - auto flightCost = [&](ClientlessTravelNode const& from, uint32 map, float x, float y, float z) + auto flightCost = [&](ClientlessTravelNode const& from, uint32 map, float x, float y, float z, bool access = false) { if (!flightEnabled || from.MapId != map) return std::numeric_limits::infinity(); Position a,b; a.Relocate(from.X,from.Y,from.Z,from.O); b.Relocate(x,y,z); auto begin=std::chrono::steady_clock::now();++trace.FlightCalls; - float result=sClientlessFlightMgr->Estimate(player,map,a,b); + float result=sClientlessFlightMgr->Estimate(player,map,a,b,access); + if(access && std::isfinite(result)) + { + float distance=a.GetExactDist(&b); + if(distance<20)result=std::numeric_limits::infinity(); + else result=(sClientlessFlightMgr->Preferred(player,map,a) || sClientlessFlightMgr->Preferred(player,map,b))? + 40+distance*0.4f:180+distance*1.5f; + } trace.FlightUs+=std::chrono::duration_cast(std::chrono::steady_clock::now()-begin).count(); return result; }; @@ -684,6 +691,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( : std::numeric_limits::infinity(); }; + std::unordered_set flightRoots; uint32 startCount = 0; for (auto const& pair : _nodes) { @@ -712,6 +720,14 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( bool const reachable = entryNav.Calculated && entryNav.Complete && entryNav.FinalDistance <= 1.5f && entryNav.MaxSegmentLength <= 8.0f && entryNav.MaxZDelta <= 5.0f && entryNav.PathLength <= 500.0f; + ClientlessTravelNode departure; + departure.MapId=player->GetMapId();departure.X=player->GetPositionX();departure.Y=player->GetPositionY();departure.Z=player->GetPositionZ(); + float air=blockedBridgeKeys && blockedBridgeKeys->count("flight:flight_departure->"+node.Key)? + std::numeric_limits::infinity():flightCost(departure,node.MapId,node.X,node.Y,node.Z,!reachable); + if(std::isfinite(air) && (!reachable || airGetMapId();departure.X=player->GetPositionX();departure.Y=player->GetPositionY();departure.Z=player->GetPositionZ(); + route.push_back(departure); + } ClientlessTravelNode const& node = _nodes.at(item.Value.Node); ClientlessTravelRouteStep step; step.NodeKey = node.Key; step.NodeType = node.Type; step.WorldPointKey = node.WorldPointKey; - step.EdgeType = item.IncomingEdgeType; + step.EdgeType = item.IncomingEdgeType=="start" && flightRoots.count(item.Value.Node)?"free_flight":item.IncomingEdgeType; step.EdgeDatabaseId = item.EdgeDatabaseId; step.EdgeReverse = item.EdgeReverse; if (item.VirtualGroundEntry) diff --git a/tests/clientless/test_adventure_combat_reactions.py b/tests/clientless/test_adventure_combat_reactions.py index 1dce40e396..f9196f01dc 100644 --- a/tests/clientless/test_adventure_combat_reactions.py +++ b/tests/clientless/test_adventure_combat_reactions.py @@ -11,6 +11,7 @@ header=(root/'src/server/game/World/AdventureCombatMgr.h').read_text() header='\n'.join(x for x in header.splitlines() if not x.startswith('#include')).replace('private:','public:') code=r''' #include "AdventureCombatPolicy.h" +#include "AdventureRecoveryPolicy.h" #include #include #include @@ -120,7 +121,7 @@ Creature* AdventureCombatMgr::Proactive(Player*)const{return nullptr;} bool AdventureCombatMgr::Start(Player*,Record&r,Unit*c,bool train,bool defense,time_t until){r.AI=std::make_shared();r.Target=c->GetGUID();r.Training=train;r.Defending=defense;r.Until=until;return true;} void AdventureCombatMgr::Finish(Player*,Record&r){r.AI.reset();r.Target.Clear();r.Defending=false;r.Training=false;r.Cooldown=10000;} ''' -for name in ('bool AdventureCombatMgr::Valid(', 'void AdventureCombatMgr::DamageTaken(', 'bool AdventureCombatMgr::Flee(', 'bool AdventureCombatMgr::Death(', 'bool AdventureCombatMgr::Update('):code+='\n'+function(name) +for name in ('bool AdventureCombatMgr::Valid(', 'void AdventureCombatMgr::DamageTaken(', 'bool AdventureCombatMgr::Flee(', 'void AdventureCombatMgr::Revived(', 'bool AdventureCombatMgr::Death(', 'bool AdventureCombatMgr::Update('):code+='\n'+function(name) code+=r''' int main(){ using namespace AdventureCombatPolicy; @@ -211,6 +212,14 @@ int main(){ GameTime::now=1006;assert(mgr.Flee(&p,r)&&r.Fleeing); p.Relocate(-60,0,0);GameTime::now=1008;assert(mgr.Flee(&p,r)&&!r.Fleeing&&!r.AI); assert(p.revives==oldRevives&&p.graveyards==oldGraves); + // Observe actual resurrection events, including a redeath BEFORE an alive poll. + r={};p.teleport=false;p.grouped=false;int beforeGraves=p.graveyards; + for(unsigned i=1;i<=5;++i){mgr.Revived(&p,2000+i*100);p.alive=false;p.ghost=false;GameTime::now=2001+i*100;mgr.Death(&p,r);assert(!r.ForceGraveyard&&p.graveyards==beforeGraves);} + assert(r.DeathChain.NextGraveyard); + mgr.Revived(&p,2600);p.alive=false;p.ghost=false;GameTime::now=2601; + mgr.Death(&p,r);assert(r.ForceGraveyard);mgr.Death(&p,r); + assert(p.graveyards==beforeGraves+1&&r.CemeteryPending); + p.teleport=false;mgr.Death(&p,r);assert(p.alive&&!p.sickness); // No aggression means no flight solely from low health. r={};p.health=1;assert(!mgr.Flee(&p,r)); } diff --git a/tests/clientless/test_adventure_day_persistence.py b/tests/clientless/test_adventure_day_persistence.py index b4d81d51ed..96860ee281 100644 --- a/tests/clientless/test_adventure_day_persistence.py +++ b/tests/clientless/test_adventure_day_persistence.py @@ -19,7 +19,7 @@ struct Guid{unsigned GetCounter(){return 99;}}; struct Player{Guid GetGUID(){return {};}}; struct Field{unsigned n=0;std::string s;unsigned GetUInt8(){return n;}unsigned GetUInt32(){return 0;}std::string GetString(){return s;}}; struct Rows{std::vector> data;unsigned index=0;Field*Fetch(){return data[index].data();}bool NextRow(){return ++index rows;std::shared_ptr saved;unsigned oldCleanup=0; auto PQuery(char const*,unsigned,unsigned){if(rows)rows->index=0;return rows;} @@ -56,6 +56,11 @@ int main(){ assert(WorldDatabase.saved && WorldDatabase.saved->repairs==24 && relog.AreaRevision==1); for(unsigned h=0;h<24;++h){assert(relog.Plan[h].Intent==(h%2?"social":"adventure"));assert(relog.Plan[h].Location!="saved_"+std::to_string(h));} locations.valid=true; + // No following intent exists at 23:00: repair preparation to rest and persist it. + rows->data[23][1].s="groupformation";mgr.Generate(&p,relog,20261029); + assert(relog.Plan[23].Intent=="rest"&&relog.Plan[23].Location.find("rest_")==0); + assert(WorldDatabase.saved->repairs==1); + rows->data[23][1].s="social"; // Missing/incomplete current day is completely replaced, deleting older dates. rows->data.resize(4);mgr.Generate(&p,r,20261030); assert(r.Date==20261030 && mgr.picks>0 && WorldDatabase.saved->slots==24 && WorldDatabase.saved->deletes==1); diff --git a/tests/clientless/test_adventure_destination_level.py b/tests/clientless/test_adventure_destination_level.py new file mode 100644 index 0000000000..2566eeb90a --- /dev/null +++ b/tests/clientless/test_adventure_destination_level.py @@ -0,0 +1,38 @@ +"""Compile the real destination eligibility method with native-level fixtures.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureLocationMgr.cpp').read_text() +a=s.index('bool AdventureLocationMgr::LevelEligible(');b=s.index('\nbool AdventureLocationMgr::IsWorkstation',a) +code=r''' +#include +#include +#include +struct Player{unsigned level=60;unsigned getLevel()const{return level;}}; +struct AdventureLocation{unsigned InstanceMap=0,AreaId=0,ZoneId=0;}; +struct Area{bool KnownLevel=true;unsigned MinLevel=1;}; +struct Areas{auto Area(unsigned id)->::Area{return {id!=0,id};}}areas;auto sAdventureAreaMgr=&areas; +struct AdventureLocationMgr{ + std::unordered_map_instanceMinimum{{533,80},{36,15}}; + bool LevelEligible(Player const*,AdventureLocation const&,std::string const&,bool strictArea=false)const; +}; +'''+s[a:b]+r''' +int main(){ + AdventureLocationMgr mgr;Player p;AdventureLocation naxx{533,0,0}; + assert(!mgr.LevelEligible(&p,naxx,"raid"));p.level=79;assert(!mgr.LevelEligible(&p,naxx,"raid")); + p.level=80;assert(mgr.LevelEligible(&p,naxx,"raid")); + AdventureLocation deadmines{36,0,0};p.level=14;assert(!mgr.LevelEligible(&p,deadmines,"instance")); + p.level=15;assert(mgr.LevelEligible(&p,deadmines,"instance")); + AdventureLocation unknown{999,0,0};assert(!mgr.LevelEligible(&p,unknown,"raid")); + AdventureLocation zone{0,60,60};p.level=59; + assert(mgr.LevelEligible(&p,zone,"adventure")); // own area policy retains its separate tolerance + assert(!mgr.LevelEligible(&p,zone,"adventure",true)); + p.level=60;assert(mgr.LevelEligible(&p,zone,"adventure",true)); + assert(!mgr.LevelEligible(nullptr,zone,"adventure",true)); +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Native dungeon/raid minimum levels, unknown destinations and strict adventure invitation levels passed') diff --git a/tests/clientless/test_adventure_groups.py b/tests/clientless/test_adventure_groups.py index 169b19144c..587b5a1a81 100644 --- a/tests/clientless/test_adventure_groups.py +++ b/tests/clientless/test_adventure_groups.py @@ -53,13 +53,16 @@ struct Combat{bool Busy(Player*){return false;}}combat;auto sAdventureCombatMgr= namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}} float frand(float,float){return 0;}unsigned urand(unsigned a,unsigned){return a;} '''+header+r''' -struct Location{std::string Name="Stormwind";}location;struct Locations{Location*Find(std::string const&){return &location;}}locations;auto sAdventureLocationMgr=&locations; +struct Position{void Relocate(float,float,float){}}; +struct Location{std::string Name="Stormwind";struct{unsigned MapId=0;float X=0,Y=0,Z=0;}Point;}location; +struct Locations{bool levelEligible=true;Location*Find(std::string const&){return &location;}bool LevelEligible(Player*,Location const&,std::string const&,bool){return levelEligible;}}locations;auto sAdventureLocationMgr=&locations; +struct Areas{bool allowed=true;bool AllowsPosition(Player*,unsigned,Position const&){return allowed;}}areas;auto sAdventureAreaMgr=&areas; struct Behavior{std::unordered_map plans;AdventureGroupPlan GroupPlan(Player*p){return plans[p->guid.id];}}behavior;auto sAdventureBehaviorMgr=&behavior; int messages=0; void AdventureGroupMgr::Speak(Player*,std::string const&){++messages;} ''' code+=fun('float Sociability(')+'\n'+fun('bool Ready(')+'\n'+fun('std::string Activity(') -for name in ['std::string AdventureGroupMgr::RecruitmentText(', 'bool AdventureGroupMgr::RecruitPlayer(', 'bool AdventureGroupMgr::BotLed(', 'bool AdventureGroupMgr::Forming(', 'bool AdventureGroupMgr::PlannedTransition(', 'bool AdventureGroupMgr::Accept(', '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::Forming(', 'bool AdventureGroupMgr::PlannedTransition(', 'bool AdventureGroupMgr::CanInvite(', 'bool AdventureGroupMgr::Accept(', '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; @@ -113,6 +116,17 @@ int main(){ mgr.RequestPlayerAdmission(&a,&human);assert(invites==5&&human.invite==&party); human.group=&party;Member humanSlot;humanSlot.player=&human;party.members=&humanSlot; mgr.Update(&a);assert(party.leader==human.guid&&!mgr.BotLed(&a)); + // Content checks and level ranges apply before any native invitation. + human.group=nullptr;human.invite=nullptr;a.level=60;human.level=54; + assert(!mgr.CanInvite(&a,&human));human.level=55;locations.levelEligible=false; + assert(!mgr.CanInvite(&a,&human));locations.levelEligible=true;areas.allowed=false; + assert(!mgr.CanInvite(&a,&human));areas.allowed=true;human.level=60; + // A player must not bypass the level limit by targeting another bot in a group. + party.leader=a.guid;a.group=&party;a.level=80;b.level=85;b.group=&party;human.level=75; + party.slots={{{29}},{{31}}}; + assert(!mgr.CanInvite(&a,&human));int beforeAdmission=invites; + mgr.RequestPlayerAdmission(&b,&human);assert(invites==beforeAdmission); + a.level=b.level=human.level=60;party.slots.clear(); // Inform nearby humans of the actual NEXT destination before invitation. {mgr={};Player human;human.guid={999};human.session.clientless=false; ObjectAccessor::players[human.guid]=&human;a.group=nullptr;a.people={&human}; diff --git a/tests/clientless/test_adventure_intent_state.py b/tests/clientless/test_adventure_intent_state.py index 5b18304fdd..5c6644a20c 100644 --- a/tests/clientless/test_adventure_intent_state.py +++ b/tests/clientless/test_adventure_intent_state.py @@ -69,6 +69,7 @@ public: int begun=0,fallbacks=0,micros=0,resets=0,switches=0,queues=0,leisureUpdates=0; bool Allowed(Player*,AdventureLocation const&,std::string const&){return true;} bool AdoptGroup(Player*,Player*){return false;} + bool UpdateNavigationRescue(Player*,Record&){return false;} void Update(Player*,uint32); void EnterPlayerInstance(Player*); void Generate(Player*,Record&,uint32){} diff --git a/tests/clientless/test_adventure_interactions.py b/tests/clientless/test_adventure_interactions.py index 9ddc3dce21..32b295452e 100644 --- a/tests/clientless/test_adventure_interactions.py +++ b/tests/clientless/test_adventure_interactions.py @@ -9,6 +9,7 @@ source=(root/'src/server/game/World/AdventureInteractionMgr.cpp').read_text() source='\n'.join(line for line in source.splitlines() if not line.startswith('#include')) code=r''' #include "AdventureInteractionPolicy.h" +#include "AdventureGroupPolicy.h" #include #include #include @@ -42,7 +43,7 @@ struct Player:Unit { explicit Player(unsigned id=26){guid.id=id;session.owner=this;} ObjectGuid GetGUID(){return guid;}bool IsAlive(){return alive;}bool IsInWorld(){return inWorld;}bool IsInCombat(){return combat;}bool IsBeingTeleported(){return teleport;}bool GetTransport(){return transport;}bool IsFlying(){return false;}bool IsInFlight(){return flight;}bool IsCharmed(){return charmed;}bool HasUnitState(int){return casting;} bool IsInMap(Player*p){return mapid==p->mapid;}bool IsInPhase(Player*p){return phase && p->phase;}bool CanSeeOrDetect(Player*p){return p->visible;}bool IsWithinLOSInMap(Player*p){return los && p->los;}bool IsFriendlyTo(Player*p){return team==p->team;} - Map*GetMap(){return ↦}unsigned GetMapId(){return mapid;}Session*GetSession(){return &session;}Group*GetGroup(){return group;}void UninviteFromGroup(){invite=nullptr;}Group*GetGroupInvite(){return invite;}unsigned GetTeam(){return team;}ObjectGuid GetTarget(){return selection;}bool isAFK(){return afk;} + Map*GetMap(){return ↦}unsigned GetMapId(){return mapid;}unsigned level=60;unsigned getLevel(){return level;}Session*GetSession(){return &session;}Group*GetGroup(){return group;}void UninviteFromGroup(){invite=nullptr;}Group*GetGroupInvite(){return invite;}unsigned GetTeam(){return team;}ObjectGuid GetTarget(){return selection;}bool isAFK(){return afk;} void SetStandState(int s){posture=s;}void SetFacingToObject(Player*){}void HandleEmoteCommand(uint32 e){++gestures;lastEmote=e;}void Say(char const*,int,Player*){++says;} void GetNearPoint(Player*,float&x,float&y,float&z,float,float){x=m_positionX;y=m_positionY;z=m_positionZ;} std::vector people;void GetPlayerListInGrid(std::vector&out,float){out=people;} diff --git a/tests/clientless/test_adventure_navigation_rescue.py b/tests/clientless/test_adventure_navigation_rescue.py new file mode 100644 index 0000000000..8a5968592d --- /dev/null +++ b/tests/clientless/test_adventure_navigation_rescue.py @@ -0,0 +1,78 @@ +"""Run the real hearth recovery state machine with a deterministic clock.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() +a=s.index('bool AdventureBehaviorMgr::UpdateNavigationRescue(');b=s.index('\nvoid AdventureBehaviorMgr::Update(',a) +code=r''' +#include "AdventureRecoveryPolicy.h" +#include +#include +#include +#include +#include +using uint32=unsigned; +#define TC_LOG_INFO(...) ((void)0) +constexpr int UNIT_NPC_EMOTESTATE=0,UNIT_STAND_STATE_STAND=0,UNIT_STATE_CASTING=1,EMOTE_STATE_SPELL_PRECAST=193,TEAM_ALLIANCE=0; +struct Position{float x=0,y=0,z=0,o=0;void Relocate(float a,float b,float c,float d=0){x=a;y=b;z=c;o=d;} + void Relocate(Position const*p){*this=*p;}float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;} + float GetExactDist(Position const&p)const{return std::sqrt((x-p.x)*(x-p.x)+(y-p.y)*(y-p.y)+(z-p.z)*(z-p.z));}}; +struct Map{bool dungeon=false;bool IsDungeon(){return dungeon;}}map; +struct Session{void CompleteClientlessTeleport(){}}session; +struct Motion{int expired=0;void MovementExpired(){++expired;}}motion; +struct Player:Position{bool alive=true,combat=false,taxi=false,flying=false,aboard=false,falling=false,casting=false,alliance=false; + unsigned mapId=1,emote=0,visual=0,teleports=0,saves=0; + struct Guid{unsigned GetCounter(){return 26;}};Guid GetGUID(){return {};} + bool IsAlive(){return alive;}bool IsInCombat(){return combat;}bool IsBeingTeleported(){return false;}Map*GetMap(){return ↦} + unsigned GetMapId(){return mapId;}unsigned GetTeamId(){return alliance?0:1;}bool IsInFlight(){return taxi;}bool IsFlying(){return flying;}bool GetTransport(){return aboard;} + bool IsFalling(){return falling;}bool HasUnitState(int){return casting;}void StopMoving(){}void SetStandState(int){} + void SetUInt32Value(int,unsigned n){emote=n;}void SendPlaySpellVisualKit(unsigned kit,unsigned,unsigned duration){assert(kit==36&&duration==10000);++visual;} + Motion*GetMotionMaster(){return &motion;}void CleanupAfterTaxiFlight(){taxi=false;} + bool TeleportTo(unsigned m,float a,float b,float c,float d){mapId=m;Relocate(a,b,c,d);++teleports;return true;} + Session*GetSession(){return &session;}void SaveToDB(){++saves;} +}; +struct Tele{unsigned mapId;float position_x,position_y,position_z,orientation;std::string name;}; +struct Objects{Tele alliance{0,-8833,622,94,0,"Stormwind"},horde{1,1570,-4397,16,0,"Orgrimmar"}; + Tele*GetGameTeleExactName(std::string const&name){return name=="Stormwind"?&alliance:&horde;}}objects;auto sObjectMgr=&objects; +namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}} +struct Areas{bool AllowsPosition(Player*,unsigned,Position const&){return true;}}areas;auto sAdventureAreaMgr=&areas; +enum class ClientlessActionExecutionState{Idle,Moving,Failed}; +struct Status{ClientlessActionExecutionState State=ClientlessActionExecutionState::Failed;}; +struct Executor{Status status;int clears=0;Status*GetStatus(unsigned){return &status;}void Clear(unsigned){++clears;status.State=ClientlessActionExecutionState::Idle;}}executor;auto sClientlessActionExecutorMgr=&executor; +struct Core{void ClearQueuedActions(unsigned){}}core;auto sClientlessPlayerMgr=&core; +struct Travel{void CancelTravel(unsigned){}}travel;auto sClientlessTravelMgr=&travel; +struct AdventureBehaviorMgr{ + enum class Phase{Idle,Travel,OnSite}; + struct Record{time_t StalledSince=100,LastRescue=0,RescueAt=0,LastNavigationCheck=0;unsigned RescueFailures=5,ProgressMap=1;bool LastTravelFailed=true,Queued=true,Dirty=false;Position ProgressPosition,RescueOrigin;Phase State=Phase::Travel;std::string Detail;struct{bool Active=false;}Instance;}; + void ResetMicro(Player*,Record&){}void FinishInstance(Player*,Record&r){r.Instance.Active=false;} + bool UpdateNavigationRescue(Player*,Record&); +}; +'''+s[a:b]+r''' +int main(){AdventureBehaviorMgr mgr;Player p;AdventureBehaviorMgr::Record r; + GameTime::now=699;assert(!mgr.UpdateNavigationRescue(&p,r)&&p.teleports==0); + GameTime::now=700;assert(mgr.UpdateNavigationRescue(&p,r)&&p.visual==1&&p.emote==193&&p.teleports==0); + GameTime::now=709;assert(mgr.UpdateNavigationRescue(&p,r)&&p.teleports==0); + GameTime::now=710;assert(mgr.UpdateNavigationRescue(&p,r)&&p.teleports==1&&p.mapId==1&&p.saves==1&&p.emote==0&&r.Dirty); + assert(r.LastRescue==710); + // Alliance capital, never the character's personal homebind. + p={};p.alliance=true;r={};executor.status.State=ClientlessActionExecutionState::Failed; + GameTime::now=700;assert(mgr.UpdateNavigationRescue(&p,r));GameTime::now=710;assert(mgr.UpdateNavigationRescue(&p,r)&&p.mapId==0); + // Movement/combat interrupts the animation before it can teleport. + p={};r={};executor.status.State=ClientlessActionExecutionState::Failed;GameTime::now=700; + assert(mgr.UpdateNavigationRescue(&p,r));p.x=2;GameTime::now=701;assert(!mgr.UpdateNavigationRescue(&p,r)&&!r.RescueAt&&p.teleports==0); + p={};r={};GameTime::now=700;assert(mgr.UpdateNavigationRescue(&p,r));p.combat=true;GameTime::now=710; + assert(!mgr.UpdateNavigationRescue(&p,r)&&!r.RescueAt&&p.teleports==0); + // Long gaps due to other activities cannot count as stalled navigation. + p={};r={};r.LastNavigationCheck=100;GameTime::now=700; + assert(!mgr.UpdateNavigationRescue(&p,r)&&p.visual==0&&r.StalledSince==700); + // Native taxi/transport waits receive thirty minutes, rather than ten. + p={};p.taxi=true;r={};GameTime::now=1899;assert(!mgr.UpdateNavigationRescue(&p,r)); + r.LastNavigationCheck=0;GameTime::now=1900;assert(mgr.UpdateNavigationRescue(&p,r)); + GameTime::now=1910;assert(mgr.UpdateNavigationRescue(&p,r)&&p.teleports==1&&!p.taxi); +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Real hearth recovery: grace, animation, faction capitals, cancellation, activity gaps and long taxi grace passed') diff --git a/tests/clientless/test_adventure_recovery_policy.py b/tests/clientless/test_adventure_recovery_policy.py new file mode 100644 index 0000000000..2512a35a1b --- /dev/null +++ b/tests/clientless/test_adventure_recovery_policy.py @@ -0,0 +1,35 @@ +"""Boundary tests for the production death-chain, rescue and invitation policies.""" +from pathlib import Path +import subprocess +import tempfile +root=Path(__file__).resolve().parents[2] +code=r''' +#include "AdventureRecoveryPolicy.h" +#include "AdventureGroupPolicy.h" +#include +#include +int main(){ + using namespace AdventureRecoveryPolicy; + DeathChain c; + assert(!c.Died(1) && c.QuickDeaths==0); + for(int i=1;i<=5;++i){c.Revived=i*100;assert(!c.Died(i*100+60));assert(c.QuickDeaths==unsigned(i));} + assert(c.NextGraveyard);c.Revived=600; + assert(c.Died(610));assert(!c.NextGraveyard && c.QuickDeaths==0); + c.Revived=700;assert(!c.Died(761)&&c.QuickDeaths==0); + c.Revived=800;assert(!c.Died(810)&&c.QuickDeaths==1); + c.Revived=900;assert(!c.Died(961)&&c.QuickDeaths==0); + assert(!Rescue(699,100,0,9));assert(Rescue(700,100,0,5)); + assert(!Rescue(700,100,0,4));assert(!Rescue(10000,0,0,9)); + assert(!Rescue(2000,100,201,5));assert(Rescue(2001,100,201,5)); + using namespace AdventureGroupPolicy; + assert(InviteLevel(60,55)&&InviteLevel(60,65)); + assert(!InviteLevel(60,54)&&!InviteLevel(60,66)); + assert(!AcceptPlayerLevel(85,10)&&!AcceptPlayerLevel(60,54)); + assert(AcceptPlayerLevel(60,55)&&AcceptPlayerLevel(10,85)); + std::cout<<"Five quick redeaths arm the NEXT graveyard fallback; rescue grace/cooldown and asymmetric invitation limits passed.\n"; +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) diff --git a/tests/clientless/test_flight_terrain_fallback.py b/tests/clientless/test_flight_terrain_fallback.py new file mode 100644 index 0000000000..0ee1173583 --- /dev/null +++ b/tests/clientless/test_flight_terrain_fallback.py @@ -0,0 +1,40 @@ +"""Exercise the production terrain-selection and flight-surface guard.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/ClientlessFlightMgr.cpp').read_text() +a=s.index('Surface ClientlessFlightMgr::Sample(');b=s.index('\nbool ClientlessFlightMgr::Start(',a) +code=r''' +#include +#include +#include +#include +#include +using uint32=unsigned;using int32=int; +constexpr int MAX_NUMBER_OF_GRIDS=64;constexpr float MAX_HEIGHT=100000,INVALID_HEIGHT=-100000; +struct Grid{int x_coord=35,y_coord=23;};namespace Trinity{Grid ComputeGridCoord(float,float){return {};}} +struct Terrain{bool child=false;uint32 GetId(){return 1;} + bool HasChildTerrainGridFile(unsigned,int,int){return child;}float GetMaxHeight(int,unsigned,float,float){return 1000;}}terrain; +struct Map{unsigned GetId(){return 1;}Terrain*GetTerrain(){return &terrain;}void LoadGrid(float,float){} + float GetHeight(int,float,float,float,bool,float){return 20;}float GetWaterLevel(int,float,float){return -100000;}}map; +struct Player{Map*GetMap(){return ↦}int GetPhaseShift(){return 0;}}; +namespace PhasingHandler{unsigned swap=719;unsigned GetTerrainMapId(int,unsigned,Terrain*,float,float){return swap;}} +struct TerrainMgr{static inline std::setfiles{1};static inline unsigned checked=0; + static bool ExistMapAndVMap(unsigned id,float,float){checked=id;return files.count(id);}}; +struct Surface{float Ground=0,Ceiling=0;bool Valid=false;}; +struct Record{std::unordered_mapTiles;}; +struct ClientlessFlightMgr{Surface Sample(Player*,Record&,float,float)const;}; +'''+s[a:b]+r''' +int main(){ClientlessFlightMgr mgr;Player p;Record a; + auto surface=mgr.Sample(&p,a,1751,-4389);assert(surface.Valid&&surface.Ground==20&&TerrainMgr::checked==1); + terrain.child=true;Record b;assert(!mgr.Sample(&p,b,1751,-4389).Valid&&TerrainMgr::checked==719); + TerrainMgr::files.insert(719);Record c;assert(mgr.Sample(&p,c,1751,-4389).Valid&&TerrainMgr::checked==719); + TerrainMgr::files.clear();Record d;assert(!mgr.Sample(&p,d,1751,-4389).Valid); + terrain.child=false;TerrainMgr::files.insert(1);PhasingHandler::swap=1; + Record e;assert(mgr.Sample(&p,e,1751,-4389).Valid&&TerrainMgr::checked==1);} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Missing phase tile uses native base terrain; an existing phase tile with missing collision stays forbidden') diff --git a/tests/clientless/test_free_flight_planner.py b/tests/clientless/test_free_flight_planner.py index 2998a4ce6a..441546ce73 100644 --- a/tests/clientless/test_free_flight_planner.py +++ b/tests/clientless/test_free_flight_planner.py @@ -27,18 +27,19 @@ using uint32=uint32_t; using int32=int32_t; #define TC_LOG_DEBUG(...) do {} while(0) #define TC_LOG_WARN(...) do {} while(0) constexpr uint32 NAV_GROUND=1, PATHFIND_INCOMPLETE=2, PATHFIND_NOPATH=4; -struct Position { float x=0,y=0,z=0;float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;} void Relocate(float a,float b,float c,float=0){x=a;y=b;z=c;} }; +struct Position { float x=0,y=0,z=0;float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;} float GetExactDist(Position const* p)const{return std::sqrt((x-p->x)*(x-p->x)+(y-p->y)*(y-p->y)+(z-p->z)*(z-p->z));} void Relocate(float a,float b,float c,float=0){x=a;y=b;z=c;} }; struct Player { float x=0,y=0,z=0; bool IsInWorld() const{return true;} uint32 GetMapId()const{return 0;} uint32 GetInstanceId()const{return 0;} float GetPositionX()const{return x;} float GetPositionY()const{return y;} float GetPositionZ()const{return z;} }; float dist(Position a,Position b){return std::sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y)+(a.z-b.z)*(a.z-b.z));} struct ClientlessPathDiagnostics {bool Calculated=true, Complete=true,Suspicious=false; uint32 PathType=1,PointCount=2; float DirectDistance=0,PathLength=0,MaxSegmentLength=0,MaxZDelta=0,FinalDistance=0; std::string Reason;}; -struct Travel {void AnalyzePath(Player* p,Position b,ClientlessPathDiagnostics& n){Position a;a.Relocate(p->x,p->y,p->z);n.PathLength=n.DirectDistance=dist(a,b);}} travel; +struct Travel {bool complete=true;void AnalyzePath(Player* p,Position b,ClientlessPathDiagnostics& n){Position a;a.Relocate(p->x,p->y,p->z);n.PathLength=n.DirectDistance=dist(a,b);n.Complete=complete;}} travel; auto sClientlessTravelMgr=&travel; struct Flight { bool enabled=false;float minimumX=-10000; void BeginPlanning(Player const* = nullptr){} bool Enabled(Player const*){return enabled;} - float Estimate(Player const*,uint32 map,Position a,Position b){ - float d=dist(a,b);return enabled&&map==0&&a.x>=minimumX&&d>=60?180+d*1.5f:std::numeric_limits::infinity(); + bool Preferred(Player const*,uint32,Position const&){return false;} + float Estimate(Player const*,uint32 map,Position a,Position b,bool recovery=false){ + float d=dist(a,b);return enabled&&map==0&&a.x>=minimumX&&(recovery||d>=60)?(recovery?d:180+d*1.5f):std::numeric_limits::infinity(); } } flight; auto sClientlessFlightMgr=&flight; @@ -142,6 +143,15 @@ assert(cached.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why)&&done); access.blocked.insert("B"); assert(!cached.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why)); access.blocked.clear(); +// A failed local MMAP approach must not exclude a native-capable flight root. +travel.complete=false;flight.enabled=true;flight.minimumX=-10000;PathGenerator::available=false; +ClientlessTravelGraphMgr accessFlight;node(accessFlight,"A",300);node(accessFlight,"B",2000);edge(accessFlight,"A","B",77,1,false,"taxi"); +assert(accessFlight.PlanAdaptiveRoute(&start,"B",400,200,route,done,endKey,why)&&done); +assert(route.size()==3&&route.front().NodeKey=="flight_departure"&&route[1].NodeKey=="A"&&route[1].EdgeType=="free_flight"); +std::unordered_set deniedFlightRoots={"flight:flight_departure->A"}; +route.clear(); +assert(!accessFlight.PlanAdaptiveRoute(&start,"B",400,200,route,done,endKey,why,nullptr,&deniedFlightRoots)); +travel.complete=true; std::cout<<"ground planner regressions and dynamic flight/node/location/virtual-exit integration passed\n"; } ''' diff --git a/tests/clientless/test_group_flight_follow.py b/tests/clientless/test_group_flight_follow.py index 4ed09493b8..c56d790ea4 100644 --- a/tests/clientless/test_group_flight_follow.py +++ b/tests/clientless/test_group_flight_follow.py @@ -20,7 +20,8 @@ Point PointOf(Position const&p){return {p.x,p.y,p.z};} namespace Trinity{bool IsValidMapCoord(float x,float y,float z){return std::isfinite(x)&&std::isfinite(y)&&std::isfinite(z);}} struct Map{float floor=0;float GetHeight(int,float,float,float,bool,float){return floor;}}map; struct Guid{unsigned GetCounter(){return 26;}}; -struct Player:Position{bool spline=false;unsigned launches=0,stops=0;Guid GetGUID(){return {};}bool IsSplineEnabled(){return spline;}void StopMoving(){++stops;spline=false;}void AddUnitMovementFlag(int){}float GetSpeed(int){return 20;}Map*GetMap(){return ↦}int GetPhaseShift(){return 169;}}; +struct Player:Position{bool spline=false;unsigned launches=0,stops=0;unsigned GetMapId(){return 0;}Guid GetGUID(){return {};}bool IsSplineEnabled(){return spline;}void StopMoving(){++stops;spline=false;}void AddUnitMovementFlag(int){}float GetSpeed(int){return 20;}Map*GetMap(){return ↦}int GetPhaseShift(){return 169;}}; +struct AreaAccess{bool allowed=true;bool AllowsPosition(Player*,unsigned,Position const&){return allowed;}bool AllowsSegment(Player*,unsigned,Position const&,Position const&){return allowed;}}access;auto sAdventureAreaMgr=&access; namespace Movement{struct MoveSplineInit{Player*p;Point target;MoveSplineInit(Player*who):p(who){}void MoveTo(float x,float y,float z,bool){target={x,y,z};}void SetFly(){}void SetWalk(bool){}void SetVelocity(float){}int Launch(){++p->launches;p->spline=true;return 1;}};} enum class ClientlessFlightState{Mounting,Flying,Arrived,Failed}; struct Record{ diff --git a/tests/clientless/test_native_skill_slot.py b/tests/clientless/test_native_skill_slot.py new file mode 100644 index 0000000000..cb75217790 --- /dev/null +++ b/tests/clientless/test_native_skill_slot.py @@ -0,0 +1,63 @@ +"""Exercise the actual free-slot branch used by Player::SetSkill.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/Entities/Player/Player.cpp').read_text() +a=s.index('uint16 skillSlot = PLAYER_MAX_SKILLS;',s.index('void Player::SetSkill(')) +b=s.index(' if (skillEntry->CategoryID',a) +code=r''' +#include +#include +using uint16=unsigned short;using uint32=unsigned; +constexpr unsigned PLAYER_MAX_SKILLS=128,PLAYER_SKILL_LINEID_0=0; +#define TC_LOG_ERROR(...) ((void)0) +std::array fields; +unsigned GetUInt16Value(unsigned f,unsigned offset){return fields[2*f+offset];} +int selected=-1; +void add(){ +'''+s[a:b]+r''' +selected=skillSlot; +} +int main(){fields.fill(1);fields[0]=0;add();assert(selected==0); + fields.fill(1);fields[127]=0;add();assert(selected==127); + fields.fill(1);selected=-1;add();assert(selected==-1);} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Native riding/profession allocation accepts slot zero and last slot, rejects a full skill array') + +# Exercise recursive rewarded-spell learning on an initialized zero-rank placeholder. +a=s.index(' // update step',s.index('void Player::SetSkill(')) +b=s.index(' // archaeology handling',a) +activation=r''' +#include +#include +using uint16=unsigned short;using uint32=unsigned; +enum {SKILL_UNCHANGED,SKILL_NEW,SKILL_CHANGED,SKILL_DELETED}; +enum {SKILL_CATEGORY_PROFESSION=11,SKILL_RIDING=762,PLAYER_PROFESSION_SKILL_LINE_1=3, + ACHIEVEMENT_CRITERIA_TYPE_REACH_SKILL_LEVEL=4,ACHIEVEMENT_CRITERIA_TYPE_LEARN_SKILL_LEVEL=5}; +struct State {unsigned pos=0,uState=SKILL_UNCHANGED;}; +struct Entry{unsigned CategoryID=0;} entry; +std::map states{{762,{}}};unsigned value=0;bool recursing=false; +void SetSkillStep(unsigned,unsigned){}void SetSkillRank(unsigned,unsigned v){value=v;} +void SetSkillMaxRank(unsigned,unsigned){}unsigned GetUInt32Value(unsigned){return 0;} +void SetUInt32Value(unsigned,unsigned){}void refreshSkillBonusAuras(){} +void UpdateSkillEnchantments(unsigned,unsigned,unsigned){}void UpdateMountCapability(){} +void UpdateAchievementCriteria(unsigned,unsigned){} +void activate(unsigned newVal); +void LearnSkillRewardedSpells(unsigned,unsigned v){if(!recursing){recursing=true;activate(v);recursing=false;}} +void activate(unsigned newVal){unsigned currVal=value,step=3,maxVal=225,id=762; + auto itr=states.find(id);auto skillEntry=&entry; +'''+s[a:b]+r''' +} +int main(){activate(225);assert(states[762].uState==SKILL_NEW); + states[762].uState=SKILL_UNCHANGED;activate(300);assert(states[762].uState==SKILL_CHANGED); + states[762].uState=SKILL_DELETED;value=0;activate(225);assert(states[762].uState==SKILL_CHANGED);} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'activation.cpp').write_text(activation) + subprocess.run(['c++','-std=c++17',str(p/'activation.cpp'),'-o',str(p/'activation')],check=True) + subprocess.run([str(p/'activation')],check=True) +print('Native recursive skill activation preserves INSERT for new placeholders and UPDATE for saved skills') diff --git a/tests/clientless/test_transport.py b/tests/clientless/test_transport.py index 72f924878a..031a5aff60 100644 --- a/tests/clientless/test_transport.py +++ b/tests/clientless/test_transport.py @@ -53,6 +53,7 @@ struct Position { static float NormalizeOrientation(float a){a=std::fmod(a,float(2*M_PI));return a<0?a+2*M_PI:a;} void Relocate(float a,float b,float c,float d=0){x=a;y=b;z=c;o=d;} void Relocate(Position const& p){*this=p;} + float GetExactDist(Position const&p)const{return GetExactDist(&p);} Position const& GetPosition()const{return *this;} void GetPosition(float&a,float&b,float&c,float&d)const{a=x;b=y;c=z;d=o;} float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;} @@ -89,13 +90,17 @@ public: int teleports=0;bool falling=false; bool IsFalling()const{return falling;}bool IsMounted(){return false;}void Dismount(){}void UpdateSpeed(int){}void RemoveAurasByType(int){} bool TeleportTo(uint32 m,float a,float b,float c,float d){++teleports;map=m;Relocate(a,b,c,d);falling=false;return true;} - bool IsAlive()const{return true;}bool IsFlying()const{return false;}bool IsInFlight()const{return false;} + bool IsInCombat(){return false;}bool IsAlive()const{return true;}bool IsFlying()const{return false;}bool IsInFlight()const{return false;} bool IsInWorld()const{return true;}bool IsInPhase(GameObject*)const{return true;} bool IsBeingTeleported()const{return pending;}TransportBase* GetTransport()const{return transport;} ObjectGuid GetGUID()const{return {26};}uint32 GetMapId()const{return map;} float GetSpeed(int)const{return 7;}void StopMoving(){}bool IsSplineEnabled()const{return false;} Motion* GetMotionMaster(){return &motion;}Session* GetSession(){return &session;} }; +struct Node{} node; +struct Graph{Node* FindNode(std::string const&){return &node;}}graph;auto sClientlessTravelGraphMgr=&graph; +struct AreaAccess{bool allowed=true;bool Applies(Player*){return true;}bool AllowsNode(Player*,Node const&){return allowed;}}areas;auto sAdventureAreaMgr=&areas; +namespace ClientlessPositioning{bool available=false;bool Nearby(Player*p,Position const&,float,Position&spot,std::vector&path){if(!available)return false;spot=*p;spot.x+=2;path={*p,spot};return true;}} void TransportBase::CalculatePassengerPosition(float&x,float&y,float&z,float*o)const{CalculatePassengerPosition(x,y,z,o,go->x,go->y,go->z,go->o);} void TransportBase::CalculatePassengerOffset(float&x,float&y,float&z,float*o)const{CalculatePassengerOffset(x,y,z,o,go->x,go->y,go->z,go->o);} void TransportBase::AddPassenger(Player*p){p->transport=this;p->m_movementInfo.transport.guid=go->GetGUID();} @@ -221,6 +226,20 @@ int main(){ lost.x=50;lost.teleports=0;travel.path=true;recovery.Status.Phase=Phase::Riding; mgr.UpdateRide(&lost,recovery,100);assert(lost.teleports==0 && travel.starts==1); mgr.UpdateRide(&lost,recovery,100);assert(!recovery.Status.Recovering); + // Waiting spots return along the observed path before the existing boarding sequence. + Player waiting;GameObject dock;dock.x=5;dock.state.Stopped=false;waiting.object=&dock; + ClientlessPositioning::available=true; + assert(mgr.Start(&waiting,"A","B",error));auto& spaced=mgr._rides.at(26); + mgr.UpdateRide(&waiting,spaced,100);assert(spaced.WaitStage==1&&waiting.x==2&&!waiting.transport); + mgr.UpdateRide(&waiting,spaced,100);assert(spaced.WaitStage==2&&spaced.WaitTrace.size()==2); + dock.state.Stopped=true;dock.state.RemainingPause=10000; + mgr.UpdateRide(&waiting,spaced,100);assert(spaced.WaitStage==3&&waiting.x==0&&!waiting.transport); + run(mgr,waiting,spaced);assert(spaced.Status.Phase==Phase::Riding&&waiting.transport); + dock.x=100;waiting.x=101;run(mgr,waiting,spaced); + assert(spaced.Status.Phase==Phase::Arrived&&!waiting.transport&&waiting.x==104); + ClientlessPositioning::available=false; + Player denied;denied.object=&dock;areas.allowed=false; + assert(!mgr.Start(&denied,"A","B",error));areas.allowed=true; std::cout<<"Transport dock, frame, recorder, cross-map copy and ride regressions passed\n"; } '''