diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index e4e72332ff..5c3d0080cf 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,36 @@ +# Aktuell: Gemeinsame Gruppenreisen implementiert + +Auftrag: Spieler- und Botgruppen mit sicheren Formationen, Reittieren, freiem +Flug, nativen Taxis/Portalen und aufgezeichneten Verkehrsmitteln reisen lassen. +Branch feature/clientless-player-core; Ausgangscommit a758239da9. +Dieser Stand wird auf ausdrücklichen Nutzerauftrag lokal committet; kein Push. +Doku: doc/clientless/group-travel.md. + +AdventureGroupTravelMgr/Policy übernimmt lokale Gruppenverfolgung, sichere +Bodenpfade, stabile Formationsplätze, begrenzte Straßenabschnitte, Flugverfolgung +und Übergangsqueues. >500Yards startet getrennte 10-Minuten-Uhr; bekannte Reisen +und fortschreitendes Aufholen sind begrenzt geschützt. Reine Botgruppen behalten +ihre gemeinsamen Graph-/Flugrouten. Native Transportverluste führen zum erfassten +Einstieg zurück, zuerst per Pfad, bei Scheitern gezielt per Teleport. Keine +allgemeinen Teleports zum Anführer. Generic CPC verwendet optionale owner-Callbacks, +bleibt unabhängig von AdventureBots. DungeonBots unverändert. + +Build6 erfolgreich: /tmp/vcore-group-travel-build6.log. Alle82 Tests erfolgreich: +/tmp/vcore-group-travel-tests2.log. Nach letzten Änderungen zusätzlich Gruppenreise, +Straßenfortsetzung und bewegter Flug erneut erfolgreich getestet. Installation +mit bestehender CMake-Konfiguration erfolgreich; vorheriger Server vollständig +beendet. Neuer Start läuft: /tmp/vcore-group-travel-start1.log, PID183811, +tmux vcore-worldserver. Startbereitschaft bestätigt: frische ready-Meldung, Prozess stabil, Port8085 +LISTEN. Loader24 online,10/2/10/2, pending0, error leer. +Loader muss weiterhin aktiv mit10 persistent+2 temporär je Fraktion bleiben. + +Native Spielerreisen und visuelle Formation sind noch nicht im Spiel abgenommen. +Unbekannte/scriptgesteuerte Portale oder fehlende Serviceaufzeichnungen werden +nicht erraten; Bots warten bzw. suchen vorhandene Graphwege. Keine Behauptung, +dass Adaptertests Gameplay-Abnahme ersetzen. Kein Push. + +--- + # Aktuell: Klassen-/Rollen-Kampf und Spieler-Instanzmodus implementiert Nutzer autorisierte die gemeinsam besprochene Umsetzung. Ausgangsstand sauber diff --git a/doc/clientless/checkpoints/2026-10-28-group-travel/build.log b/doc/clientless/checkpoints/2026-10-28-group-travel/build.log new file mode 100644 index 0000000000..e2bbd54e64 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-group-travel/build.log @@ -0,0 +1,6 @@ +[1/6] cd /home/wow_admin/wow_cata_server/source/TrinityCore && /usr/bin/cmake -DBUILDDIR="/home/wow_admin/wow_cata_server/build/debug" -P /home/wow_admin/wow_cata_server/source/TrinityCore/cmake/genrev.cmake /home/wow_admin/wow_cata_server/build/debug +[2/6] Building CXX object src/server/game/CMakeFiles/game.dir/World/ClientlessLuaEngine.cpp.o +[3/6] Building CXX object src/server/game/CMakeFiles/game.dir/World/AdventureGroupTravelMgr.cpp.o +[4/6] Building CXX object src/server/game/CMakeFiles/game.dir/World/ClientlessActionExecutorMgr.cpp.o +[5/6] Linking CXX static library src/server/game/libgame.a +[6/6] Linking CXX executable src/server/worldserver/worldserver diff --git a/doc/clientless/checkpoints/2026-10-28-group-travel/native-status.log b/doc/clientless/checkpoints/2026-10-28-group-travel/native-status.log new file mode 100644 index 0000000000..630c56d327 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-group-travel/native-status.log @@ -0,0 +1,26 @@ +TrinityCore rev. a758239da956+ 2026-10-28 22:37:18 +0100 (feature/clientless-player-core branch) (Unix, Debug, Static) (worldserver-daemon) +World initialized in 0 minutes 15 seconds +TrinityCore rev. a758239da956+ 2026-10-28 22:37:18 +0100 (feature/clientless-player-core branch) (Unix, Debug, Static) (worldserver-daemon) ready... +TC>Adventure loader enabled=1 schema=1 busy=0 pools=10/2/10/2 known=25 rotations=0 manager=5 error='' +pool=0 faction=alliance persistent=1 target=10 online=2 pending=0 saved=10 +pool=1 faction=alliance persistent=0 target=2 online=1 pending=0 saved=3 +pool=2 faction=horde persistent=1 target=10 online=1 pending=0 saved=10 +pool=3 faction=horde persistent=0 target=2 online=1 pending=0 saved=2 +No group travel state. +Interactions guid=29 grouped=0 leader=0 partner=0 intent='rest' schedule='' +TC>Adventure loader enabled=1 schema=1 busy=0 pools=10/2/10/2 known=25 rotations=0 manager=17 error='' +pool=0 faction=alliance persistent=1 target=10 online=7 pending=0 saved=10 +pool=1 faction=alliance persistent=0 target=2 online=2 pending=0 saved=3 +pool=2 faction=horde persistent=1 target=10 online=6 pending=0 saved=10 +pool=3 faction=horde persistent=0 target=2 online=2 pending=0 saved=2 +No group travel state. +Interactions guid=31 grouped=0 leader=0 partner=0 intent='economy' schedule='' +TC>Adventure loader enabled=1 schema=1 busy=0 pools=10/2/10/2 known=25 rotations=0 manager=24 error='' +pool=0 faction=alliance persistent=1 target=10 online=10 pending=0 saved=10 +pool=1 faction=alliance persistent=0 target=2 online=2 pending=0 saved=3 +pool=2 faction=horde persistent=1 target=10 online=10 pending=0 saved=10 +pool=3 faction=horde persistent=0 target=2 online=2 pending=0 saved=2 + +Final: PID183811 remains running; TCP8085 LISTEN. +Native ungrouped interaction diagnostics work. No human-led travel acceptance performed. +Existing terrain719/config/script warnings remain; no fatal/assertion observed in this startup. diff --git a/doc/clientless/checkpoints/2026-10-28-group-travel/tests.log b/doc/clientless/checkpoints/2026-10-28-group-travel/tests.log new file mode 100644 index 0000000000..40da56fade --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-group-travel/tests.log @@ -0,0 +1,263 @@ +TEST tests/clientless/test_adventure_area_node.py +Production area destination excludes outside and unreachable graph nodes. + +TEST tests/clientless/test_adventure_async_database.py +Async creation DB contracts: prepared connection masks, native security column names, no future waits, successful-save cache publication and world-update-only loader hook passed + +TEST tests/clientless/test_adventure_behavior_policy.py +24-hour generation, shared intents with changing destinations, broad location classification and repetition penalties passed. + +TEST tests/clientless/test_adventure_class_rotation.py +Production class rotations: persistent PvE solo pools, native rank aura IDs, combo/holy power/proc/execute/resource gates, Eclipse and healer coverage passed + +TEST tests/clientless/test_adventure_combat_exercise.py +Production exercise: stance/charge/melee/casts, native validation delegation, bounded lifetime, invalid targets and unchanged native phases passed. + +TEST tests/clientless/test_adventure_combat_preparation.py +Production talent learner: native tiers/dependencies/class gates, all 26 legal level60 points, no capstone bypass and repeat safety passed. + +TEST tests/clientless/test_adventure_combat_reactions.py +Production combat focus, damage ordering, risk filters, corpse radius/delay, flight/group override, blocked escape, danger avoidance and malus-free persisted fallback passed. + +TEST tests/clientless/test_adventure_day_persistence.py +Production day planner: all 24 TINYINT slots restored across logins, incomplete/old-day replacement, full transaction and curiosity/level weighting passed + +TEST tests/clientless/test_adventure_economy.py +Economy: native service roles, friendly/phase/LOS/bounds gates, no bot service targets and coherent performative cues passed. + +TEST tests/clientless/test_adventure_economy_catalog.py +Economy catalog: eleven actions, three faction-bound service anchors, native flag gates and idempotent preservation passed. + +TEST tests/clientless/test_adventure_encounter.py +Production encounters: bot-only exclusion, mixed mode, extension hook, wait/revive, wipe fallback/reentry/gather/return, living-player priority, kick exit and offline cleanup passed + +TEST tests/clientless/test_adventure_equipment_maintenance.py +Production gear maintenance: same birth/retention gate rejects wrong armor/stats, malformed effects, level, temporary, reputation, map, holiday, native-use and slot mismatches passed + +TEST tests/clientless/test_adventure_exploration.py +Exploration: visible native props only, no chests/traps/furniture, phase/map/LOS/range gates and contextual preparation passed. + +TEST tests/clientless/test_adventure_exploration_catalog.py +Exploration catalog: ten actions, shared scenes, default wander replacement, explicit category eligibility and preserved edits passed. + +TEST tests/clientless/test_adventure_generator_equipment.py +Production equipment affinity: mana-only/direct/triggered procs, caster/physical stat effects and bounded cycles passed + +TEST tests/clientless/test_adventure_generator_ownership.py +Production generator commands: batch bounds, dedicated ownership, persistent/online/removing retirement protection and retired login refusal passed + +TEST tests/clientless/test_adventure_generator_policy.py +Production generator: 6144 cultural names and 61440 real Lua profiles, profession coherence, personality ranges, escaping and equipment/armor gates passed + +TEST tests/clientless/test_adventure_graph_handover.py +Production graph handover waits for ship/taxi/flight/teleport and stops at safe road/node boundaries. + +TEST tests/clientless/test_adventure_ground_avoidance.py +Production ground avoidance: harmful/friendly, phase169, height, reachable safe endpoint, rejected paths and reuse without cast/movement thrashing passed + +TEST tests/clientless/test_adventure_group_plan.py +Production group plans: leader meeting/goal/deadline inheritance, untouched personal day, manual preparation and instance-state guards passed. + +TEST tests/clientless/test_adventure_group_travel.py +Production group travel: distinct slots, phase169 cliff safety, width, mounts, flight/kick landing, taxi grace, portal queue, bounded separation and recorded-road validation passed + +TEST tests/clientless/test_adventure_groups.py +Production recruitment: capitals, different goals, native inviter leadership, joining existing groups, capacity/reservations, raid conversion, planned transition and local contact passed. + +TEST tests/clientless/test_adventure_instance.py +Production instance lifecycle: time budget, true entry, denied/shared entry, native rear exit, restart, expired copy, late arrival, HP and return replay passed. + +TEST tests/clientless/test_adventure_instance_autoresume.py +Deferred instance script: missing schema/visits stays idle; no login work on startup; saved visitors resume with Adventure controllers and native accounts. + +TEST tests/clientless/test_adventure_instance_begin.py +Production entrance travel uses the existing location planner, requires mapping and retains normal social/area travel. + +TEST tests/clientless/test_adventure_instance_catalog.py +Instance migrations: outside entrances only, native graph preserved, disabled/missing transitions excluded, edits and saved visits preserved. + +TEST tests/clientless/test_adventure_instance_loader.py +Production instance recovery: no startup DB/login work, asynchronous delayed/paced manual recovery, generated-bot quota ownership, absent-schema compatibility and existing preparation ticks passed + +TEST tests/clientless/test_adventure_instance_persistence.py +Production instance persistence: phases, original deadline, instance ID, transactional HP marker, failed commit and intent release passed. + +TEST tests/clientless/test_adventure_intent_state.py +Production intent arrival, failed/partial travel, deadlines and safe schedule handover passed. + +TEST tests/clientless/test_adventure_interactions.py +production interaction manager: invitations, factions, reservations, follow, AFK and deferred departure passed +/tmp/tmp4c3prj7a/test.cpp: In member function ‘void AdventureInteractionMgr::Leave(Player*, Record&, const char*)’: +/tmp/tmp4c3prj7a/test.cpp:143:69: warning: unused parameter ‘reason’ [-Wunused-parameter] + 143 | void AdventureInteractionMgr::Leave(Player* p,Record& r,char const* reason) + | ~~~~~~~~~~~~^~~~~~ + +TEST tests/clientless/test_adventure_leisure.py +Leisure: substantial curved MMAP routes, mirrored return, interrupted return, rejected corridors, actual arrival, companion follow, riding, flight and fishing passed. + +TEST tests/clientless/test_adventure_leisure_catalog.py +Leisure migration: native city anchor, shared scene references, pet-walk mapping and preservation of edited weights passed. + +TEST tests/clientless/test_adventure_location_import.py +Locations: actual Debug SMALLINT reads retain continent IDs; explicit location eligibility and edited weight overrides passed. + +TEST tests/clientless/test_adventure_manager_inventory.py +Production inventory maintenance: only full backpacks, native stack trimming, level-appropriate supplies, tools, leash, fishing pole, containers and untouched equipped slots passed + +TEST tests/clientless/test_adventure_micro_reset.py +Micro cleanup ends physical local movement, preserves transit and does not stand up dead players. + +TEST tests/clientless/test_adventure_population.py +Production population: four independent faction/persistence targets, fair selection, pending reservations, retries, existing inventory, once-per-boot rotation and connected/persistent protection passed + +TEST tests/clientless/test_adventure_profession_catalog.py +Profession catalog: native workstation roles, no workshop/resource inference, idempotence and preserved edits passed. + +TEST tests/clientless/test_adventure_profession_profile.py +Production profession parser, real Lua profiles, primary restrictions and secondary permissions passed. + +TEST tests/clientless/test_adventure_profession_sightline.py +Native workstation working-surface sightline and blocked geometry rejection passed. + +TEST tests/clientless/test_adventure_profession_targets.py +Production profession targets: native locks, phase/spawn/loot, stations, corpses, history and trainers passed. + +TEST tests/clientless/test_adventure_profile_selection.py +Production profile selection: real Lua ID dictionaries, exact 9:1 companion weighting, zero/negative/infinite/NaN/oversized weights rejected and legacy companion fallback passed + +TEST tests/clientless/test_adventure_pvp.py +Production PvP selector: level boundaries, radius, factions, native legality, navigation, intent, personality and resources passed. + +TEST tests/clientless/test_adventure_repair_catalog.py +Repair action catalog: proper native service requirement, economy-only intent and preserved administrator weights passed. + +TEST tests/clientless/test_adventure_rest.py +Recovery: authored places, bounds, arrival, occupancy, full rest duration, failure, reload and interruption passed. + +TEST tests/clientless/test_adventure_rest_fire.py +Rest fire: shared scene mapping, additive migration, preserved weights and authored creation-place policy passed. + +TEST tests/clientless/test_adventure_rest_space.py +Sleep footprint: furniture, body extent, overhead collision, ledges, stairs, missing heights, rotation and scale passed. + +TEST tests/clientless/test_adventure_rest_targets.py +Native sleep targets: chair exclusion, phase, despawn, floor search, indoors, water, storey, occupancy, bed/surface preference passed. + +TEST tests/clientless/test_adventure_rotation.py +Production rotation: connected/native-online protection, ownership refusal, native delete failure, world cleanup failure and journal-retained retry before successful profile/plan/state removal passed +/tmp/tmpthw2w5iq/test.cpp: In instantiation of ‘std::string Trinity::StringFormat(const char*, A ...) [with A = {}; std::string = std::__cxx11::basic_string]’: +/tmp/tmpthw2w5iq/test.cpp:22:126: required from ‘void Tx::PAppend(const char*, A ...) [with A = {}]’ + 22 | struct Tx{std::vectorsql;templatevoid PAppend(char const*f,A...a){sql.push_back(Trinity::StringFormat(f,a...));}}; + | ~~~~~~~~~~~~~~~~~~~~~^~~~~~~~ +/tmp/tmpthw2w5iq/test.cpp:51:75: required from here + 51 | auto barrier=CharacterDatabase.BeginTransaction();barrier->PAppend("DO 0"); + | ~~~~~~~~~~~~~~~~^~~~~~~~ +/tmp/tmpthw2w5iq/test.cpp:11:113: warning: format not a string literal and no format arguments [-Wformat-security] + 11 | namespace Trinity{templatestd::string StringFormat(char const* f,A...args){char b[4096];std::snprintf(b,sizeof(b),f,args...);return b;}} + | ~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~ + +TEST tests/clientless/test_adventure_social.py +Production social flow: approach before talk, coherent greeting/talk/listen/farewell, lost targets, native chair confirmation and real fire confirmation passed. + +TEST tests/clientless/test_adventure_social_fire.py +Shared fire: native detection/provisioning, buildings/city/parent/zone restrictions, existing fires and outdoor/water checks passed. + +TEST tests/clientless/test_adventure_supplies.py +Production supplies: exact10% boundary, bandage-before-food, native lockout fallback, recovery completion, combat potions, mana-only drinking and daily/post-combat top-ups passed. + +TEST tests/clientless/test_adventure_supply_catalog.py +Supply migrations: all five roles, level60 netherweave tier, editable/disabled catalog preservation and persistent daily stamp passed. + +TEST tests/clientless/test_adventure_supply_items.py +Production native-item delegation: usable bandage tiers, native cooldown/lockout gates, real inventory consumption, self targeting and level60 secondary caps passed. + +TEST tests/clientless/test_adventure_talent_templates.py +Canonical Cata PvE builds: 4.3.4 class/tree/rank/prerequisite gates, all 1..41 point budgets, solo selection coverage passed + +TEST tests/clientless/test_adventure_training_catalog.py +Training catalog: real dummy requirement, leisure-only intent, bounded duration and preserved administrator weights passed. + +TEST tests/clientless/test_adventure_walk.py +Production travel gait reaches native spline, survives repaths, prevents mounting and preserves legacy running defaults. + +TEST tests/clientless/test_clientless_character_factory.py +Production native factory: character/profile atomicity, failed preparation/commit, cache publication order, account/security/name/cap gates passed + +TEST tests/clientless/test_clientless_companion.py +Native companion: provisioning, delayed summon, ownership, real item target, leash confirmation/timeout, reuse and cleanup passed. + +TEST tests/clientless/test_clientless_damage_notifications.py +Production damage handoff: 16 concurrent map-worker writers, ordered GUID-only events, clientless-only/positive damage gates and post-health notification passed. + +TEST tests/clientless/test_clientless_near_teleport_observer.py +Production near-teleport observer packet includes actual mover GUID and destination; clientless timestamp refreshed, regular client timestamp preserved. + +TEST tests/clientless/test_clientless_skill_preparation.py +Production offline skill preparation: native tier caps, no downgrading, rank/level/race/tier gates and no modification of online players passed + +TEST tests/clientless/test_clientless_social_notifications.py +Production social/helper queues: concurrent GUID-only delivery, bounded queues, human/self/zero-damage gates, pet attribution and lethal-cleanup ordering passed + +TEST tests/clientless/test_default_phase.py +Native PhaseShift: implicit/default 169 visibility, genuine custom-phase separation and reference updates passed. + +TEST tests/clientless/test_empty_spell_effect.py +Native empty spell effects: poisoned-memory scaling initialization, Lock and Load cleared slot, active effect values and all-empty spell values passed + +TEST tests/clientless/test_flight_area_query.py +Native terrain query map/phase, area/zone ordering and cache passed. + +TEST tests/clientless/test_flight_handoff.py +Flight handoff before spline end, terminal landing, return corners, collision stop and actual distance passed. + +TEST tests/clientless/test_flight_policy.py +Flight native eligibility, license/profile/zone policy, RP costs and fail-safe return decisions passed. + +TEST tests/clientless/test_follow_spline_velocity.py +Native follow: actual player/creature spline velocity, client gait and independent follower speeds passed. + +TEST tests/clientless/test_free_flight.py +Flight geometry passed: takeoff/landing, smoothed peak, ceiling, lateral/climbing avoidance, missing terrain and blocked corridors. + +TEST tests/clientless/test_free_flight_planner.py +ground planner regressions and dynamic flight/node/location/virtual-exit integration passed + +TEST tests/clientless/test_ground_exit_continuation.py +optional exits: repeated probes, retained route, delayed success, forward resume, flight fallback passed + +TEST tests/clientless/test_group_flight_follow.py +Moving-target flight: bounded phased corridors, obstacle hold, ceiling rejection, aerial formation hold, safe landing and return fallback passed + +TEST tests/clientless/test_item_equip_cooldown.py +Native item equip cooldown: zero/sentinel/missing spells safely skipped, valid use cooldown preserved and longer/disabled cooldowns untouched passed + +TEST tests/clientless/test_local_spline_segments.py +Production local spline clipping passes native path-length validation, tiny remainders and 1000 sampled paths. + +TEST tests/clientless/test_lua_reload.py +Lua reload regression tests passed (production reload/bindings, real Lua 5.4). + +TEST tests/clientless/test_player_pure_max_skill.py +Native pure skill cap: learned skills return stored cap; missing/deleted/zero-rank skills return0. + +TEST tests/clientless/test_portal_transfer.py +Portal near/far ACK and denied same-map transfer regressions passed. + +TEST tests/clientless/test_portal_use.py +Interactive portal native click, near/far/global ACK, denial, timeout and target checks passed. +Native portal destination discovery and unsupported/ambiguous spell rejection passed. +Production clientless ACK completion sequence increments only after completed near/far transfers. + +TEST tests/clientless/test_shared_group_route.py +Production generic shared routes: same edges/taxi/free flight, independent execution state, final local goal, late catch-up, permissions and stale-goal rejection passed. + +TEST tests/clientless/test_taxi_import_disabled.py +Taxi importer preserves disabled rows and their reasons. + +TEST tests/clientless/test_transport.py +Transport dock, frame, recorder, cross-map copy and ride regressions passed + +TEST tests/clientless/test_transport_grid_loading.py +Core transport grid initialization regressions passed (no CPC dependency). + +TOTAL 82 FAILED [] diff --git a/doc/clientless/group-travel.md b/doc/clientless/group-travel.md new file mode 100644 index 0000000000..7e444985c2 --- /dev/null +++ b/doc/clientless/group-travel.md @@ -0,0 +1,106 @@ +# AdventureBot group travel + +AdventureGroupTravelMgr is the common local-follow owner for player-led and +bot-led groups. Bot-led macro travel retains the existing shared TravelGraph +route and flight trail; DungeonBot CreatureAI remains independent. + +## Formation and ground safety + +Group members have stable GUID-ordered formation slots. Standing companions +form a small staggered group. Moving companions form one, two or three columns +according to checked walkable space; mounts increase spacing. Narrow/uncertain +passages use one column. This estimates usable ground, not a semantic road width. +Combat positioning and encounter avoidance retain priority. + +Local following uses complete, unsuspicious MMAP paths. Phased floor samples +along paths reject holes, large height discontinuities and unsupported ground. +The same owner-supplied validator runs before every clientless local spline, +including graph approaches/repaths, while the bot is grouped. Dynamic LOS is +checked for the actual segments. A rejected path stops following and is retried; +it does not itself trigger a departure or a teleport. + +Recorded graph roads have an optional CPC GroundPathAdapter. The Adventure +adapter checks bounded chunks, offsets safe lanes, falls back to single-file, +and holds a row behind the moving leader. Edge point indices remain aligned; +only an actually reached chunk endpoint advances the next road index. Native +portal/boarding endpoints remain unshifted. Optional speculative road exits are +disabled for adapted group roads so they cannot bypass the safety adapter. +Ungrouped CPC navigation and unrelated scripts have no adapter by default. + +Mounted leaders cause capable profile-defined followers to use their own ground +mounts even on short follow legs. Nearby followers dismount when the leader is +on foot. Ordinary long ground catch-up can also use profile mounts. + +## Flight + +ClientlessFlightMgr::Follow adds a moving-target mode beside existing point +flights. Native learned mounts/capabilities, area restrictions, casting and +VMAP height/volume collision checks still decide whether flight is possible. +Followers update their own formation target and fly bounded segments. Unknown +terrain, blocked corridors or insufficient headroom cause a mounted hold. +A moving aerial target is never treated as a ground touchdown. + +On landing, kick, leader change/loss or excessive separation, a tracking flight +lands on verified local solid ground or uses its checked reverse trail. It never +removes flight at an unverified height. Own group flights are serviced before +self-care/combat actions can replace their movement; death handling still runs +for dead characters. Daily-plan date refresh continues while airborne. + +Bot-led shared point flights preserve their native shared corridor and use +staggered departures to form a single-file column. They finish a safe native +flight leg rather than being cancelled in the air. Ground-only followers may +follow safe projected ground points while the leader is no more than12 yards +above them; greater height or missing ground paths makes them wait. + +## Known transitions and separation + +The leader is observed independently of a follower's self-care or current taxi/ +transport state. Taxi destinations use the final node of the native taxi queue. +Ships, zeppelins and trams use recorded, matching native services. Fixed-destination +SPELLCASTER GameObjects (including temporary portals with native DB destinations) +and native AreaTrigger portals are recognized from the actual source and arrival. +Bots approach and use the native handler/object; destination teleports are not +fabricated. Per-follower queues retain consecutive transitions for late arrivals. +Unknown/script-selected portals are not guessed; existing graph routes may still +provide a valid way to the known destination. + +Player-led groups accumulate a separate10-minute timer beyond500 yards (3D, +or separated maps/instances). Coming within500 yards resets it. A missing path +alone never advances a special leave reason. Existing combat/death/instance/ +transport departure guards apply, and an airborne bot lands before leaving. +Pure bot groups retain their previous late-rendezvous rules. + +Known actual journeys, own native taxi/transport travel and recorded transport +recovery pause separation. Arrival has60 seconds of grace. Further catch-up stays +protected only with route execution and recent physical progress; this does not +extend unrelated later ground separation. Journey protection is bounded to30 +minutes. The ordinary AFK/hour-change departures are deferred during protected +travel. No travel marker grants unlimited protection after a failed route. + +## Lost transport passengers + +ClientlessTransportMgr detects loss of an actually boarded recorded service. +It retains the ride and attempts a complete, unsuspicious return path to that +ride's recorded departure node. A missing/rejected/stuck path, cross-map loss, +or a return that has not completed within60 seconds allows a targeted teleport +to that same known safe entry point. It never teleports toward the leader or +forward to the destination. The next service is boarded normally. Repeated losses +are bounded; after three recoveries the ride terminates safely at departure. +Ordinary death/resurrection remains owned by combat handling. + +Services require valid recorded boarding/alighting transitions and native graph +connections. Missing service/portal data causes waiting or route failure rather +than invented passenger attachment or a general catch-up teleport. + +## Diagnostics and validation + +`clientless interaction ` reports formation travel ownership, separation +milliseconds, protection, flight, queued transitions and route detail, alongside +the existing group/interaction state. + +Automated tests exercise the production formation/ground validation, widths, +mount choice, flight and kick landing, taxi grace, sequential portals, bounded +separation, recorded-road adapters and native transport-loss return paths. Flight +adapter tests exercise real corridor geometry and moving-target/landing decisions. +Compilation and these adapters do not constitute visual in-game acceptance of +player taxi, portal, flight or deck movement. diff --git a/src/server/game/World/AdventureGroupMgr.cpp b/src/server/game/World/AdventureGroupMgr.cpp index 1efe72a3ae..b0d5ea640a 100644 --- a/src/server/game/World/AdventureGroupMgr.cpp +++ b/src/server/game/World/AdventureGroupMgr.cpp @@ -1,4 +1,5 @@ #include "AdventureGroupMgr.h" +#include "AdventureGroupTravelMgr.h" #include "AdventureGroupPolicy.h" #include "AdventureBehaviorMgr.h" #include "AdventureCombatMgr.h" @@ -175,35 +176,11 @@ void AdventureGroupMgr::Report(ChatHandler* h,Player* p) const h->PSendSysMessage("Bot group intent='%s' next='%s' forming=%u botLed=%u capacity=%u meeting='%s'",plan.Intent.c_str(),plan.NextIntent.c_str(),plan.Forming,BotLed(p),AdventureGroupPolicy::Capacity(Activity(plan)),plan.Location.c_str()); } -bool AdventureGroupMgr::FollowMember(Player* p) +bool AdventureGroupMgr::FollowMember(Player* player) { - auto group=p->GetGroup(); - if(!group || !BotLed(p) || group->GetLeaderGUID()==p->GetGUID())return false; - auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID());if(!leader)return true; - auto& r=_records[p->GetGUID()];auto now=GameTime::GetGameTime(); - if(!p->IsInMap(leader) || p->GetExactDist(leader)>=100) - { - if(now>=r.SearchAt) - { - r.SearchAt=now+15;std::string node;float distance=0; - if(sClientlessTravelGraphMgr->FindNearestNodeToPosition(leader->GetMapId(),leader->GetPositionX(),leader->GetPositionY(),leader->GetPositionZ(),node,distance) && distance<1000) - {ClientlessAction action;action.Name="travel_to_node";action.Parameters["node"]=node;sClientlessPlayerMgr->QueueAction(p->GetGUID().GetCounter(),std::move(action));} - } - return true; - } - if(!p->IsInPhase(leader) || !p->CanSeeOrDetect(leader))return true; - if(p->GetExactDist(leader)>5) - { - auto nav=sClientlessTravelMgr->GetStatus(p->GetGUID().GetCounter()); - if(!nav || nav->State!=ClientlessTravelState::Moving || nav->Target.GetExactDist(leader)>5) - { - Position end;leader->GetNearPoint(p,end.m_positionX,end.m_positionY,end.m_positionZ,3,leader->GetOrientation()+3.14159265f); - ClientlessPathDiagnostics path; - if(sClientlessTravelMgr->AnalyzePath(p,end,path) && path.Complete && !path.Suspicious && path.PathLength<150)sClientlessTravelMgr->StartTravel(p,end); - } - } - else sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); - return true; + auto group=player->GetGroup(); + if(!group || !BotLed(player) || group->GetLeaderGUID()==player->GetGUID())return false; + return sAdventureGroupTravelMgr->Follow(player); } void AdventureGroupMgr::Invite(Player* p,Player* other,std::string const& intent,bool forming) diff --git a/src/server/game/World/AdventureGroupTravelMgr.cpp b/src/server/game/World/AdventureGroupTravelMgr.cpp new file mode 100644 index 0000000000..253c6a095a --- /dev/null +++ b/src/server/game/World/AdventureGroupTravelMgr.cpp @@ -0,0 +1,465 @@ +#include "AdventureGroupTravelMgr.h" +#include "AdventureGroupTravelPolicy.h" +#include "ClientlessTravelMgr.h" +#include "ClientlessTravelGraphMgr.h" +#include "ClientlessActionExecutorMgr.h" +#include "ClientlessTransportMgr.h" +#include "ClientlessFlightMgr.h" +#include "ClientlessPlayerMgr.h" +#include "ClientlessLuaEngine.h" +#include "Chat.h" +#include "Player.h" +#include "Group.h" +#include "GameObject.h" +#include "GameTime.h" +#include "DBCStores.h" +#include "Map.h" +#include "ObjectAccessor.h" +#include "ObjectMgr.h" +#include "PathGenerator.h" +#include "SpellMgr.h" +#include "SpellInfo.h" +#include "WorldSession.h" +#include "WorldPacket.h" +#include "Opcodes.h" +#include "Log.h" +#include "VMapFactory.h" +#include +#include +#include + +namespace +{ + bool Executing(uint32 guid) + { + auto action=sClientlessActionExecutorMgr->GetStatus(guid); + return action && (action->State==ClientlessActionExecutionState::Moving || action->State==ClientlessActionExecutionState::SelectingTarget); + } + Position OffsetPoint(Position const& origin,AdventureGroupTravelPolicy::Offset offset) + { + float angle=origin.GetOrientation(); + return Position(origin.GetPositionX()-std::cos(angle)*offset.Behind-std::sin(angle)*offset.Side, + origin.GetPositionY()-std::sin(angle)*offset.Behind+std::cos(angle)*offset.Side, + origin.GetPositionZ()+offset.Height,angle); + } + bool FixedPortal(GameObject* object,uint32& map,Position& destination) + { + auto info=object->GetGOInfo();if(info->type!=GAMEOBJECT_TYPE_SPELLCASTER)return false; + auto spell=sSpellMgr->GetSpellInfo(info->spellcaster.spell);if(!spell)return false; + SpellTargetPosition const* target=nullptr; + for(uint8 i=0;iEffects[i];if(effect.Effect!=SPELL_EFFECT_TELEPORT_UNITS)continue; + if(target || (effect.TargetA.GetTarget()!=TARGET_DEST_DB && effect.TargetB.GetTarget()!=TARGET_DEST_DB))return false; + target=sSpellMgr->GetSpellTargetPosition(spell->Id,SpellEffIndex(i));if(!target)return false; + } + if(!target)return false; + map=target->target_mapId;destination.Relocate(target->target_X,target->target_Y,target->target_Z,target->target_Orientation);return true; + } +} +AdventureGroupTravelMgr* AdventureGroupTravelMgr::instance(){static AdventureGroupTravelMgr manager;return &manager;} + +void AdventureGroupTravelMgr::SampleLeader(Player* player,Leader& state,time_t now) +{ + if(state.Seen && state.SampleAt==now)return; + bool taxi=player->IsInFlight(),transport=player->GetTransport()!=nullptr; + if(taxi && !player->m_taxi.GetPath().empty()) + { + std::string goal="taxi_"+std::to_string(player->m_taxi.GetPath().back()); + if(state.Kind!=Journey::Taxi || state.To!=goal || !state.WasTaxi) + { + state.Kind=Journey::Taxi;state.From="taxi_"+std::to_string(player->m_taxi.GetTaxiSource());state.To=goal; + state.Began=now;state.Arrived=0;++state.Generation; + if(auto node=sTaxiNodesStore.LookupEntry(player->m_taxi.GetPath().back())) + {state.GoalMap=node->ContinentID;state.Goal.Relocate(node->Pos.X,node->Pos.Y,node->Pos.Z);} + } + } + else if(transport && !state.WasTransport) + { + state.Kind=Journey::Transport;state.Began=now;state.Arrived=0;++state.Generation; + state.From.clear();state.To.clear(); + sClientlessTransportMgr->ObserveJourney(player,state.Seen?state.GroundMap:player->GetMapId(),state.Seen?state.Ground:*player,state.From,state.To); + if(auto stop=sClientlessTransportMgr->GetStop(state.To)){state.GoalMap=stop->Map;state.Goal=stop->Points.front().Pos;} + } + if(!taxi && state.WasTaxi && state.Kind==Journey::Taxi)state.Arrived=now; + if(!transport && state.WasTransport && state.Kind==Journey::Transport) + { + sClientlessTransportMgr->ArrivalNode(player,state.From,state.To); + state.Arrived=now;state.GoalMap=player->GetMapId();state.Goal.Relocate(player); + } + bool jumped=state.Seen && !taxi && !transport && !state.WasTaxi && !state.WasTransport && + (state.Map!=player->GetMapId() || state.Previous.GetExactDist(player)>80); + if(jumped) + { + bool known=false; + if(state.CandidatePortal && state.CandidateSource.GetExactDist(state.Previous)<30 && state.CandidateSourceMap==state.Map && state.CandidateGoalMap==player->GetMapId() && state.CandidateGoal.GetExactDist(player)<100) + {state.Kind=Journey::Portal;state.PortalObject=state.CandidatePortal;state.Source=state.CandidateSource;state.SourceMap=state.CandidateSourceMap;state.Goal=state.CandidateGoal;state.GoalMap=state.CandidateGoalMap;state.PortalTrigger=0;known=true;} + if(!known)for(auto area:sAreaTriggerStore) + { + if(area->ContinentID!=state.Map || state.Previous.GetExactDist(area->Pos.X,area->Pos.Y,area->Pos.Z)>35)continue; + auto target=sObjectMgr->GetAreaTrigger(area->ID); + if(!target || target->target_mapId!=player->GetMapId() || player->GetExactDist(target->target_X,target->target_Y,target->target_Z)>100)continue; + state.Kind=Journey::Portal;state.PortalTrigger=area->ID;state.PortalObject.Clear();state.SourceMap=area->ContinentID; + state.Source.Relocate(area->Pos.X,area->Pos.Y,area->Pos.Z);state.GoalMap=target->target_mapId; + state.Goal.Relocate(target->target_X,target->target_Y,target->target_Z,target->target_Orientation);known=true;break; + } + if(known){state.Began=now;state.Arrived=now;++state.Generation;state.From.clear();state.To.clear();} + else {state.Kind=Journey::None;state.PortalObject.Clear();} + } + if(!taxi && !transport && !player->IsFlying() && !player->IsFalling()) + { + state.Ground.Relocate(player);state.GroundMap=player->GetMapId(); + // Remember a nearby native portal before the player disappears through it. + { + state.CandidatePortal.Clear(); + std::vector portals;player->GetGameObjectListWithEntryInGrid(portals,0,20); + for(auto object:portals) + { + uint32 map=0;Position target; + if(!object->IsInPhase(player) || !FixedPortal(object,map,target))continue; + state.CandidatePortal=object->GetGUID();state.CandidateSourceMap=player->GetMapId();state.CandidateSource.Relocate(object); + state.CandidateGoalMap=map;state.CandidateGoal=target;break; + } + } + } + state.Seen=true;state.SampleAt=now;state.Map=player->GetMapId();state.Previous.Relocate(player); + state.WasTaxi=taxi;state.WasTransport=transport; +} + +void AdventureGroupTravelMgr::Observe(Player* player,uint32 diff) +{ + auto group=player->GetGroup(); + if(!group)return; + auto inserted=_records.try_emplace(player->GetGUID().GetCounter()); + auto& record=inserted.first->second; + if(inserted.second) + { + sClientlessActionExecutorMgr->SetGroundPathAdapter(player->GetGUID().GetCounter(),[](Player* member,std::vector& path){return sAdventureGroupTravelMgr->AdaptRoad(member,path);}); + sClientlessTravelMgr->SetPathValidator(player->GetGUID().GetCounter(),[](Player* member,std::vector const& path){return sAdventureGroupTravelMgr->ValidateGroundPath(member,path);}); + } + if(auto flight=sClientlessFlightMgr->GetStatus(player->GetGUID().GetCounter())) + if(flight->State==ClientlessFlightState::Mounting || flight->State==ClientlessFlightState::Flying)record.FlightOwned=true; + if(group->GetLeaderGUID()==player->GetGUID())return; + record.Timer+=std::min(diff,30000u);if(record.TimerGetLeaderGUID());if(!leader)return; + auto now=GameTime::GetGameTime(); + if(record.LeaderGuid!=leader->GetGUID()) + { + if(record.RouteOwned) + { + sClientlessPlayerMgr->ClearQueuedActions(player->GetGUID().GetCounter()); + if(player->GetTransport() || player->IsInFlight() || player->IsFlying())sClientlessActionExecutorMgr->RequestStopAtNode(player->GetGUID().GetCounter()); + else {sClientlessActionExecutorMgr->Clear(player->GetGUID().GetCounter());record.RouteOwned=false;} + } + record.Pending.clear();record.LeaderGuid=leader->GetGUID();record.SeparationMs=0;record.Generation=0;record.ProgressAt=now;record.ProgressPosition.Relocate(player);record.BotMap=player->GetMapId();} + auto& state=_leaders[leader->GetGUID()];SampleLeader(leader,state,now); + if(record.Generation!=state.Generation) + { + record.Generation=state.Generation;record.ProgressAt=now;record.ProgressPosition.Relocate(player);record.BotMap=player->GetMapId(); + if(state.Kind!=Journey::None && record.Pending.size()<16)record.Pending.push_back(state); + } + if(!record.Pending.empty() && record.Pending.back().Generation==state.Generation)record.Pending.back()=state; + auto ownRide=sClientlessTransportMgr->GetStatus(player->GetGUID().GetCounter()); + bool riding=ownRide && ownRide->Phase!=ClientlessTransportPhase::Failed && ownRide->Phase!=ClientlessTransportPhase::Arrived; + while(!record.Pending.empty() && !riding && !player->IsInFlight() && !player->IsFlying() && !player->GetTransport() && + player->GetMapId()==record.Pending.front().GoalMap && player->GetExactDist(record.Pending.front().Goal)<35) + { + record.Pending.pop_front(); + if(record.RouteOwned){sClientlessPlayerMgr->ClearQueuedActions(player->GetGUID().GetCounter());sClientlessActionExecutorMgr->Clear(player->GetGUID().GetCounter());record.RouteOwned=false;} + } + if(record.BotMap!=player->GetMapId() || record.ProgressPosition.GetExactDist(player)>1) + {record.ProgressAt=now;record.ProgressPosition.Relocate(player);record.BotMap=player->GetMapId();} + bool near=player->IsInMap(leader) && player->GetExactDist(leader)<=AdventureGroupTravelPolicy::SeparationDistance; + bool progressing=now-record.ProgressAt<90; + bool ownTransit=player->GetTransport() || player->IsInFlight(); + auto ride=sClientlessTransportMgr->GetStatus(player->GetGUID().GetCounter()); + bool recordedRide=ride && ride->Phase!=ClientlessTransportPhase::Failed && ride->Phase!=ClientlessTransportPhase::Arrived; + auto const& journey=record.Pending.empty()?state:record.Pending.front(); + bool bounded=journey.Kind!=Journey::None && journey.Began && now>=journey.Began && now-journey.Began<1800; + bool grace=journey.Arrived && now-journey.Arrived<60; + if(ownTransit || recordedRide){if(!record.NativeTransitAt)record.NativeTransitAt=now;}else record.NativeTransitAt=0; + bool nativeProtected=record.NativeTransitAt && now-record.NativeTransitAt<1800; + record.ProtectedTravel=nativeProtected || (bounded && (leader->IsInFlight() || leader->GetTransport() || (!record.Pending.empty() && (grace || (record.RouteOwned && Executing(player->GetGUID().GetCounter()) && progressing))))); + record.SeparationMs=AdventureGroupTravelPolicy::Separation(record.SeparationMs,elapsed,!near,record.ProtectedTravel || !player->IsAlive()); + if(leader->GetSession()->IsClientless())record.SeparationMs=0; // late bot recruits retain their agreed rendezvous behavior +} +bool AdventureGroupTravelMgr::Airborne(Player* player) const +{ + if(!player->IsAlive())return false; + auto found=_records.find(player->GetGUID().GetCounter());if(found==_records.end() || !found->second.FlightOwned)return false; + auto flight=sClientlessFlightMgr->GetStatus(player->GetGUID().GetCounter()); + return player->IsFlying() || (flight && (flight->State==ClientlessFlightState::Mounting || flight->State==ClientlessFlightState::Flying)); +} +bool AdventureGroupTravelMgr::Protected(Player* player) const +{auto found=_records.find(player->GetGUID().GetCounter());return found!=_records.end() && found->second.ProtectedTravel;} +bool AdventureGroupTravelMgr::Separated(Player* player) const +{auto found=_records.find(player->GetGUID().GetCounter());return found!=_records.end() && found->second.SeparationMs>=AdventureGroupTravelPolicy::SeparationTimeoutMs;} + +bool AdventureGroupTravelMgr::GroundPoint(Player* player,Position& point) const +{ + float ground=player->GetMap()->GetHeight(player->GetPhaseShift(),point.GetPositionX(),point.GetPositionY(),point.GetPositionZ()+3,true,18); + if(!std::isfinite(ground) || ground<=INVALID_HEIGHT || std::fabs(ground-point.GetPositionZ())>18)return false; + point.m_positionZ=ground;return true; +} +bool AdventureGroupTravelMgr::SafePath(Player* player,Position const& target) const +{ + ClientlessPathDiagnostics diagnostic; + if(!sClientlessTravelMgr->AnalyzePath(player,target,diagnostic) || !diagnostic.Complete || diagnostic.Suspicious || diagnostic.PathLength>250)return false; + PathGenerator path(player);if(!path.CalculatePath(target.GetPositionX(),target.GetPositionY(),target.GetPositionZ()))return false; + auto const& points=path.GetPath();if(points.empty())return false; + float previous=player->GetPositionZ(); + for(size_t i=1;iGetMap()->GetHeight(player->GetPhaseShift(),x,y,z+2,true,6); + if(ground<=INVALID_HEIGHT || !AdventureGroupTravelPolicy::GroundSample(z,ground,previous,length/count))return false; + previous=ground; + } + } + return true; +} +unsigned AdventureGroupTravelMgr::Columns(Player* player,Player* leader) +{ + auto& width=_widths[leader->GetGUID()];auto now=GameTime::GetGameTime(); + if(width.At && now-width.At<2 && width.Center.GetExactDist(leader)<5)return width.Columns; + width.At=now;width.Center.Relocate(leader);width.Columns=1; + if(leader->GetTransport() || leader->IsInFlight() || player->GetMap()->IsDungeon())return 1; + for(unsigned columns:{3u,2u}) + { + bool valid=true; + for(float behind:{4.0f,10.0f})for(float side:{-1.0f,1.0f}) + { + auto point=OffsetPoint(*leader,{behind,side*(columns==3?5.0f:3.0f),0}); + if(!GroundPoint(player,point) || !SafePath(player,point)){valid=false;break;} + } + if(valid){width.Columns=columns;break;} + } + return width.Columns; +} +Position AdventureGroupTravelMgr::Formation(Player* player,Player* leader,bool flight) +{ + std::vector members; + for(auto const& member:player->GetGroup()->GetMemberSlots())if(member.guid!=leader->GetGUID())members.push_back(member.guid); + std::sort(members.begin(),members.end());auto found=std::find(members.begin(),members.end(),player->GetGUID()); + unsigned slot=unsigned(found-members.begin()); + auto offset=AdventureGroupTravelPolicy::Formation(slot,flight?2:Columns(player,leader),leader->isMoving(),leader->IsMounted(),flight); + return OffsetPoint(*leader,offset); +} +bool AdventureGroupTravelMgr::Route(Player* player,Record& record,std::string const& node) +{ + if(node.empty())return false; + uint32 guid=player->GetGUID().GetCounter();auto now=GameTime::GetGameTime(); + if(Executing(guid))return true; + if(sClientlessPlayerMgr->GetQueuedActionCount(guid))return record.RouteOwned; + if(nowQueueAction(guid,std::move(action));return record.RouteOwned; +} +bool AdventureGroupTravelMgr::JourneyFollow(Player* player,Record& record,Leader const& leader) +{ + auto now=GameTime::GetGameTime();if(leader.Kind==Journey::None || now-leader.Began>=1800)return false; + if(leader.Kind==Journey::Taxi)return Route(player,record,leader.To); + if(leader.Kind==Journey::Transport) + { + if(player->GetTransport() || player->IsInFlight())return true; + auto ride=sClientlessTransportMgr->GetStatus(player->GetGUID().GetCounter()); + if(ride && ride->Phase!=ClientlessTransportPhase::Arrived && ride->Phase!=ClientlessTransportPhase::Failed)return true; + auto source=sClientlessTransportMgr->GetStop(leader.From); + if(source && !leader.To.empty()) + { + if(player->GetMapId()==source->Map && player->GetExactDist(source->Points.front().Pos)<3) + {std::string error;return sClientlessTransportMgr->Start(player,leader.From,leader.To,error);} + return Route(player,record,leader.From); + } + if(leader.Arrived) + { + std::string node;float distance=0; + if(sClientlessTravelGraphMgr->FindNearestNodeToPosition(leader.GoalMap,leader.Goal.GetPositionX(),leader.Goal.GetPositionY(),leader.Goal.GetPositionZ(),node,distance) && distance<1000)return Route(player,record,node); + } + return true; + } + if(player->GetMapId()!=leader.SourceMap) + { + if(player->GetMapId()==leader.GoalMap)return false; + std::string node;float distance=0; + if(sClientlessTravelGraphMgr->FindNearestNodeToPosition(leader.SourceMap,leader.Source.GetPositionX(),leader.Source.GetPositionY(),leader.Source.GetPositionZ(),node,distance) && distance<1000)return Route(player,record,node); + return false; + } + if(Executing(player->GetGUID().GetCounter()))return true; + if(player->GetExactDist(leader.Source)>4) + { + if(player->GetExactDist(leader.Source)<100 && SafePath(player,leader.Source))return sClientlessTravelMgr->StartTravel(player,leader.Source,true,true); + std::string node;float distance=0; + if(sClientlessTravelGraphMgr->FindNearestNodeToPosition(leader.SourceMap,leader.Source.GetPositionX(),leader.Source.GetPositionY(),leader.Source.GetPositionZ(),node,distance) && distance<1000)return Route(player,record,node); + return false; + } + if(now-record.LastPortalUse<3)return true; + record.LastPortalUse=now; + if(leader.PortalTrigger) + { + auto area=sAreaTriggerStore.LookupEntry(leader.PortalTrigger); + if(area && player->IsInAreaTriggerRadius(area)) + {WorldPacket packet(CMSG_AREATRIGGER,4);packet<GetSession()->HandleAreaTriggerOpcode(packet);return true;} + } + else if(auto portal=ObjectAccessor::GetGameObject(*player,leader.PortalObject)) + {if(portal->IsInPhase(player)){portal->Use(player);return true;}} + return false; +} +bool AdventureGroupTravelMgr::Follow(Player* player) +{ + uint32 guid=player->GetGUID().GetCounter();auto group=player->GetGroup();auto found=_records.find(guid); + if(!group || group->GetLeaderGUID()==player->GetGUID()) + { + if(found!=_records.end() && found->second.FlightOwned) + { + sClientlessFlightMgr->LandFollowing(player); + auto flight=sClientlessFlightMgr->GetStatus(guid); + if(player->IsFlying() || (flight && (flight->State==ClientlessFlightState::Mounting || flight->State==ClientlessFlightState::Flying)))return true; + } + if(found!=_records.end())Clear(player); + return false; + } + auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID());if(!leader || !player->IsAlive() || player->IsInCombat() || player->IsBeingTeleported() || player->IsCharmed())return true; + auto& record=_records[guid]; + if(record.FlightOwned) + { + auto flight=sClientlessFlightMgr->GetStatus(guid); + if(!flight || flight->State==ClientlessFlightState::Arrived || (!player->IsFlying() && flight->State==ClientlessFlightState::Failed))record.FlightOwned=false; + else if(!player->IsInMap(leader) || !leader->IsFlying() || leader->IsInFlight() || leader->GetTransport() || Separated(player)) + {sClientlessFlightMgr->LandFollowing(player);record.Detail="safe landing before ground regroup/leave";return true;} + else if(!flight->FollowingPlayer)return true; + } + if(player->GetTransport() || player->IsInFlight())return true; + if(!record.Pending.empty()) + { + JourneyFollow(player,record,record.Pending.front()); + record.Detail="following/waiting for known travel transition";return true; + } + if(Executing(guid))return true; + if(!player->IsInMap(leader))return true; + if(!player->IsInPhase(leader) || !player->CanSeeOrDetect(leader))return true; + if(leader->IsInFlight() || leader->GetTransport())return true; + if(leader->IsFlying() && sClientlessFlightMgr->Enabled(player)) + { + auto target=Formation(player,leader,true);std::string error; + if(sClientlessFlightMgr->Follow(player,target,error)) + {record.FlightOwned=true;record.Detail="flying formation";return true;} + record.Detail=error;if(player->IsFlying())return true; + } + if(player->IsFlying() || player->IsFalling())return true; + auto target=Formation(player,leader,false); + if(!GroundPoint(player,target) || (leader->IsFlying() && std::fabs(leader->GetPositionZ()-target.GetPositionZ())>AdventureGroupTravelPolicy::GroundLeaderHeight)) + {sClientlessTravelMgr->CancelTravel(guid);record.Detail="leader above reachable ground; waiting";return true;} + if(player->GetExactDist(leader)>100) + { + std::string node;float distance=0; + if(sClientlessTravelGraphMgr->FindNearestNodeToPosition(leader->GetMapId(),target.GetPositionX(),target.GetPositionY(),target.GetPositionZ(),node,distance) && distance<1000)Route(player,record,node); + return true; + } + if(!leader->IsMounted() && player->IsMounted() && player->GetExactDist(leader)<12)sClientlessLuaEngine->DismountGroundMount(player,"group leader on foot"); + if(player->GetExactDist(target)<1.2f) + {sClientlessTravelMgr->CancelTravel(guid);record.Detail="holding assigned formation position";return true;} + auto nav=sClientlessTravelMgr->GetStatus(guid); + if(nav && nav->State==ClientlessTravelState::Moving && nav->Target.GetExactDist(target)<3) + { + if(auto active=sClientlessTravelMgr->GetActivePath(guid))if(!ValidateGroundPath(player,*active)) + {sClientlessTravelMgr->CancelTravel(guid);record.Detail="active follow corridor became unsafe";} + return true; + } + if(!SafePath(player,target)) + {sClientlessTravelMgr->CancelTravel(guid);record.Detail="no safe complete follow path; waiting";return true;} + if(leader->IsMounted()) + { + auto profile=sClientlessLuaEngine->GetAdventureProfile(guid); + if(profile)sClientlessLuaEngine->PrepareGroundMount(player,std::max(profile->GroundMountMinDistance,player->GetExactDist(target)),"group-follow"); + } + else if(player->IsMounted() && player->GetExactDist(leader)<12)sClientlessLuaEngine->DismountGroundMount(player,"group leader on foot"); + bool started=sClientlessTravelMgr->StartTravel(player,target,true); + record.Detail=started?"safe ground formation":"follow spline rejected; waiting";return true; +} +bool AdventureGroupTravelMgr::AdaptRoad(Player* player,std::vector& path) +{ + auto group=player->GetGroup();if(!group || path.size()<2)return true; + auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID());if(!leader || !player->IsInMap(leader))return true; + Position endpoint=path.back(); + float total=0;size_t end=1; + for(;end60 && end>1)break;} + path.resize(std::min(end,path.size()));if(path.size()<2)return false; + std::vector members;for(auto const& member:group->GetMemberSlots())if(member.guid!=leader->GetGUID())members.push_back(member.guid); + std::sort(members.begin(),members.end());auto member=std::find(members.begin(),members.end(),player->GetGUID()); + bool isLeader=player==leader;unsigned slot=unsigned(member-members.begin()); + unsigned columns=isLeader?1:Columns(player,leader); + auto offset=AdventureGroupTravelPolicy::Formation(slot,columns,true,leader->IsMounted(),false); + if(!isLeader && leader->isMoving() && player->GetExactDist(leader)<65) + { + float walked=0,nearest=1e30f,progress=0; + for(size_t i=1;iprogress-offset.Behind)break;} + path.resize(allowed);if(path.size()<2)return true; // wait for the leading row + } + } + auto original=path; + for(unsigned attempt=0;attempt<2;++attempt) + { + path=original;float side=attempt?0:isLeader?0:offset.Side; + bool valid=true; + for(size_t i=1;i0.1f){path[i].m_positionX-=std::sin(heading)*side;path[i].m_positionY+=std::cos(heading)*side;} + if(!GroundPoint(player,path[i])){valid=false;break;} + } + if(valid)valid=ValidateGroundPath(player,path); + if(valid)return true; + } + return false; +} +bool AdventureGroupTravelMgr::ValidateGroundPath(Player* player,std::vector const& path) const +{ + for(size_t i=1;iGetMap()->GetHeight(player->GetPhaseShift(),x,y,z+2,true,6); + if(floor<=INVALID_HEIGHT || !AdventureGroupTravelPolicy::GroundSample(z,floor,last,length/steps))return false; + last=floor; + } + if(!player->GetMap()->isInLineOfSight(player->GetPhaseShift(),a.GetPositionX(),a.GetPositionY(),a.GetPositionZ()+1,b.GetPositionX(),b.GetPositionY(),b.GetPositionZ()+1,LINEOFSIGHT_ALL_CHECKS,VMAP::ModelIgnoreFlags(0)))return false; + } + return true; +} +void AdventureGroupTravelMgr::Clear(Player* player) +{ + auto found=_records.find(player->GetGUID().GetCounter());if(found==_records.end())return; + if(found->second.RouteOwned){sClientlessPlayerMgr->ClearQueuedActions(player->GetGUID().GetCounter());sClientlessActionExecutorMgr->Clear(player->GetGUID().GetCounter());} + sClientlessActionExecutorMgr->SetGroundPathAdapter(player->GetGUID().GetCounter(),{}); + sClientlessTravelMgr->SetPathValidator(player->GetGUID().GetCounter(),{}); + auto leader=found->second.LeaderGuid; + sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter());_records.erase(found); + if(std::none_of(_records.begin(),_records.end(),[&](auto const& entry){return entry.second.LeaderGuid==leader;})) + {_leaders.erase(leader);_widths.erase(leader);} +} +void AdventureGroupTravelMgr::Offline(Player* player){Clear(player);} +void AdventureGroupTravelMgr::Report(ChatHandler* handler,Player* player) const +{ + auto found=_records.find(player->GetGUID().GetCounter());if(found==_records.end()){handler->SendSysMessage("No group travel state.");return;} + auto const& record=found->second; + handler->PSendSysMessage("Group travel leader=%u separationMs=%u protected=%u flight=%u pending=%u route='%s' detail='%s'",record.LeaderGuid.GetCounter(),record.SeparationMs,record.ProtectedTravel,record.FlightOwned,uint32(record.Pending.size()),record.RouteGoal.c_str(),record.Detail.c_str()); +} diff --git a/src/server/game/World/AdventureGroupTravelMgr.h b/src/server/game/World/AdventureGroupTravelMgr.h new file mode 100644 index 0000000000..fdbbef3e63 --- /dev/null +++ b/src/server/game/World/AdventureGroupTravelMgr.h @@ -0,0 +1,66 @@ +#ifndef VCORE_ADVENTURE_GROUP_TRAVEL_MGR_H +#define VCORE_ADVENTURE_GROUP_TRAVEL_MGR_H +#include "Common.h" +#include "ObjectGuid.h" +#include "Position.h" +#include +#include +#include +#include +#include +class Player; +class ChatHandler; +class TC_GAME_API AdventureGroupTravelMgr +{ +public: + static AdventureGroupTravelMgr* instance(); + void Observe(Player* player, uint32 diff); + bool Follow(Player* player); + bool AdaptRoad(Player* player, std::vector& path); + bool ValidateGroundPath(Player* player, std::vector const& path) const; + bool Protected(Player* player) const; + bool Airborne(Player* player) const; + bool Separated(Player* player) const; + void Clear(Player* player); + void Offline(Player* player); + void Report(ChatHandler* handler, Player* player) const; +private: + enum class Journey { None, Taxi, Transport, Portal }; + struct Leader + { + bool Seen = false, WasTaxi = false, WasTransport = false; + uint32 Map = 0, GroundMap = 0, GoalMap = 0, PortalTrigger = 0; + Position Previous, Ground, Goal, Source; + ObjectGuid PortalObject, CandidatePortal; + uint32 CandidateSourceMap=0,CandidateGoalMap=0; + Position CandidateSource,CandidateGoal; + uint32 SourceMap = 0; + std::string From, To; + Journey Kind = Journey::None; + time_t SampleAt = 0, Began = 0, Arrived = 0; + uint32 Generation = 0; + }; + struct Record + { + ObjectGuid LeaderGuid; + uint32 Timer = 0, SeparationMs = 0, Generation = 0, BotMap = 0; + Position ProgressPosition; + time_t ProgressAt = 0, NextRoute = 0, LastPortalUse = 0, NativeTransitAt = 0; + bool RouteOwned = false, FlightOwned = false, ProtectedTravel = false; + std::string RouteGoal, Detail; + std::deque Pending; + }; + struct Width { time_t At = 0; Position Center; unsigned Columns = 1; }; + void SampleLeader(Player* player, Leader& state, time_t now); + bool GroundPoint(Player* player, Position& point) const; + bool SafePath(Player* player, Position const& point) const; + unsigned Columns(Player* player, Player* leader); + Position Formation(Player* player, Player* leader, bool flight); + bool Route(Player* player, Record& record, std::string const& node); + bool JourneyFollow(Player* player, Record& record, Leader const& leader); + std::unordered_map _records; + std::unordered_map _leaders; + std::unordered_map _widths; +}; +#define sAdventureGroupTravelMgr AdventureGroupTravelMgr::instance() +#endif diff --git a/src/server/game/World/AdventureGroupTravelPolicy.h b/src/server/game/World/AdventureGroupTravelPolicy.h new file mode 100644 index 0000000000..7f00cffb34 --- /dev/null +++ b/src/server/game/World/AdventureGroupTravelPolicy.h @@ -0,0 +1,36 @@ +#ifndef VCORE_ADVENTURE_GROUP_TRAVEL_POLICY_H +#define VCORE_ADVENTURE_GROUP_TRAVEL_POLICY_H +#include +#include +#include +namespace AdventureGroupTravelPolicy +{ + constexpr unsigned SampleMs = 500; + constexpr float SeparationDistance = 500; + constexpr unsigned SeparationTimeoutMs = 600000; + constexpr float GroundLeaderHeight = 12; + constexpr float MaxGroundStep = 1.75f; + constexpr float MaxGroundSlope = 0.8f; + struct Offset { float Behind, Side, Height; }; + inline Offset Formation(unsigned slot, unsigned columns, bool moving, bool mounted, bool flying) + { + columns = std::clamp(columns, 1u, 3u); + float spacing = flying ? 7.0f : mounted ? 4.5f : 3.0f; + if (!moving && !flying) columns = std::min(columns, 3u); + unsigned row = slot / columns; + float side = (float(slot % columns) - float(columns - 1) / 2) * spacing; + return {spacing + float(row) * spacing, side, flying ? float(slot % 2) * 2 : 0}; + } + inline unsigned Separation(unsigned elapsed, unsigned diff, bool far, bool protectedTravel) + { + if (!far) return 0; + if (protectedTravel) return elapsed; + return std::min(SeparationTimeoutMs, elapsed + std::min(diff, 30000u)); + } + inline bool GroundSample(float expected, float actual, float previous, float distance) + { + return std::isfinite(actual) && std::fabs(actual - expected) <= MaxGroundStep && + std::fabs(actual - previous) <= MaxGroundStep + MaxGroundSlope * distance; + } +} +#endif diff --git a/src/server/game/World/AdventureInteractionMgr.cpp b/src/server/game/World/AdventureInteractionMgr.cpp index 557a214084..8fb1a60785 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 "AdventureGroupTravelMgr.h" #include "AdventureBehaviorMgr.h" #include "AdventureCombatMgr.h" #include "ClientlessLuaEngine.h" @@ -75,6 +76,7 @@ void AdventureInteractionMgr::Leave(Player* p,Record& r,char const* reason) p->GetGroup()->RemoveMember(p->GetGUID(),GROUP_REMOVEMETHOD_LEAVE); sAdventureBehaviorMgr->InterruptForInteraction(p); sAdventureBehaviorMgr->ResumePersonalPlan(p); + sAdventureGroupTravelMgr->Clear(p); r.Grouped=false;r.Leader.Clear();r.Invite.Clear();r.Partner.Clear();r.SceneUntil=0; } void AdventureInteractionMgr::Emote(Player* p,Player* source,uint32 emote) @@ -100,12 +102,13 @@ void AdventureInteractionMgr::Reply(Player* p,Player* other,uint32 emote) } void AdventureInteractionMgr::CombatHelp(Player* p,ObjectGuid helper) { _records[p->GetGUID().GetCounter()].Helper=helper; } -void AdventureInteractionMgr::Offline(Player* p){sAdventureGroupMgr->Offline(p);_records.erase(p->GetGUID().GetCounter());} +void AdventureInteractionMgr::Offline(Player* p){sAdventureGroupTravelMgr->Offline(p);sAdventureGroupMgr->Offline(p);_records.erase(p->GetGUID().GetCounter());} bool AdventureInteractionMgr::Update(Player* p,uint32 diff) { auto& r=_records[p->GetGUID().GetCounter()];auto now=GameTime::GetGameTime(); if(r.Timer>diff){r.Timer-=diff;return (r.Grouped && !sAdventureGroupMgr->BotLed(p)) || r.SceneUntil>now;} r.Timer=1000; + if(!p->GetGroup() && sAdventureGroupTravelMgr->Follow(p))return true; bool unsafe=!p->IsAlive() || p->IsInCombat() || sAdventureCombatMgr->Busy(p) || p->IsBeingTeleported() || p->GetTransport() || p->IsInFlight() || p->IsCharmed(); if(unsafe){r.Partner.Clear();r.SceneUntil=0;return p->GetGroup()!=nullptr;} @@ -171,13 +174,16 @@ bool AdventureInteractionMgr::Update(Player* p,uint32 diff) } bool forming=sAdventureGroupMgr->Forming(p); if(forming){r.LastActivity=now;r.AfkSince=0;r.Schedule=sAdventureBehaviorMgr->InteractionSchedule(p);} - if(!forming && AdventureInteractionPolicy::CanLeave(p->IsAlive(),p->IsInCombat(),p->GetMap()->IsDungeon(),p->GetTransport() || p->IsInFlight() || p->IsFlying())) + bool protectedTravel=sAdventureGroupTravelMgr->Protected(p); + if(protectedTravel){r.LastActivity=now;r.AfkSince=0;} + if(!forming && !protectedTravel && AdventureInteractionPolicy::CanLeave(p->IsAlive(),p->IsInCombat(),p->GetMap()->IsDungeon(),p->GetTransport() || p->IsInFlight() || p->IsFlying())) { if(r.Schedule!=sAdventureBehaviorMgr->InteractionSchedule(p)) { r.Schedule=sAdventureBehaviorMgr->InteractionSchedule(p); if(!sAdventureGroupMgr->PlannedTransition(p) && frand(0,1)>=Loyalty(p)){Leave(p,r,"intent-change");return false;} } + if(sAdventureGroupTravelMgr->Separated(p)){Leave(p,r,"leader-distance");return false;} if(AdventureInteractionPolicy::LeaderExpired(now,r.AfkSince?r.AfkSince:r.LastActivity)){Leave(p,r,"leader-afk");return false;} } // Existing warrior executor supplies damage assistance. Roles/healing @@ -191,39 +197,9 @@ bool AdventureInteractionMgr::Update(Player* p,uint32 diff) } if(sAdventureGroupMgr->BotLed(p))return false; if(!leader || leader==p || !leader->IsAlive())return true; - auto execution=sClientlessActionExecutorMgr->GetStatus(p->GetGUID().GetCounter()); - if(execution && (execution->State==ClientlessActionExecutionState::Moving || execution->State==ClientlessActionExecutionState::SelectingTarget))return true; - if(!p->IsInMap(leader) || p->GetExactDist(leader)>=100) - { - if(now>=r.NextRoute) - { - r.NextRoute=now+15; - std::string node;float distance=0; - sClientlessTravelGraphMgr->EnsureLoaded(); - if(sClientlessTravelGraphMgr->FindNearestNodeToPosition(leader->GetMapId(),leader->GetPositionX(),leader->GetPositionY(),leader->GetPositionZ(),node,distance) && distance<1000) - { - ClientlessAction action;action.Name="travel_to_node";action.Parameters["node"]=node; - sClientlessPlayerMgr->QueueAction(p->GetGUID().GetCounter(),std::move(action)); - } - } - return true; - } - if(!p->IsInPhase(leader) || !p->CanSeeOrDetect(leader))return true; - if(p->GetExactDist(leader)>5 && p->GetExactDist(leader)<100) - { - auto nav=sClientlessTravelMgr->GetStatus(p->GetGUID().GetCounter()); - if(!nav || nav->State!=ClientlessTravelState::Moving || nav->Target.GetExactDist(leader)>5) - { - Position end;leader->GetNearPoint(p,end.m_positionX,end.m_positionY,end.m_positionZ,3,leader->GetOrientation()+3.14159265f); - ClientlessPathDiagnostics path; - if(sClientlessTravelMgr->AnalyzePath(p,end,path) && path.Complete && !path.Suspicious && path.PathLength<150) - sClientlessTravelMgr->StartTravel(p,end); - } - } - else if(p->GetExactDist(leader)<=5)sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); - return true; + return sAdventureGroupTravelMgr->Follow(p); } - if(r.Grouped){r.Grouped=false;r.Leader.Clear();sAdventureBehaviorMgr->InterruptForInteraction(p);sAdventureBehaviorMgr->ResumePersonalPlan(p);} + if(r.Grouped){sAdventureGroupTravelMgr->Clear(p);r.Grouped=false;r.Leader.Clear();sAdventureBehaviorMgr->InterruptForInteraction(p);sAdventureBehaviorMgr->ResumePersonalPlan(p);} auto intent=sAdventureBehaviorMgr->InteractionIntent(p); if(now>=r.NextEncounter && now>=r.NextSocial && !p->GetMap()->IsDungeon() && (intent=="social" || intent=="leisure" || intent=="rest")) { @@ -252,6 +228,7 @@ bool AdventureInteractionMgr::Update(Player* p,uint32 diff) void AdventureInteractionMgr::Report(ChatHandler* handler,Player* p) const { sAdventureGroupMgr->Report(handler,p); + sAdventureGroupTravelMgr->Report(handler,p); auto it=_records.find(p->GetGUID().GetCounter()); if(it==_records.end()){handler->SendSysMessage("No interaction state yet.");return;} auto const& r=it->second;auto now=GameTime::GetGameTime(); diff --git a/src/server/game/World/ClientlessActionExecutorMgr.cpp b/src/server/game/World/ClientlessActionExecutorMgr.cpp index 5c4e892449..f34a5c9553 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.cpp +++ b/src/server/game/World/ClientlessActionExecutorMgr.cpp @@ -167,6 +167,9 @@ ClientlessActionExecutorMgr* ClientlessActionExecutorMgr::instance() return &instance; } +void ClientlessActionExecutorMgr::SetGroundPathAdapter(uint32 guid,GroundPathAdapter adapter) +{if(adapter)_groundAdapters[guid]=std::move(adapter);else _groundAdapters.erase(guid);} + void ClientlessActionExecutorMgr::Clear(uint32 characterGuid) { _records.erase(characterGuid); @@ -203,7 +206,7 @@ ClientlessActionExecutionStatus const* ClientlessActionExecutorMgr::GetStatus(ui bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction const& action, Record& record) { - record.StopAtNode = false; + record.StopAtNode = false;record.FormationWaiting=false;record.GroundChunk=false; record.TransportActive = false; record.FlightActive = false; record.FlightApproachActive = false; @@ -1770,6 +1773,22 @@ bool ClientlessActionExecutorMgr::StartRecordedEdgePath( return false; } + float anticipatedDistance=0; + for(size_t i=1;isecond(player,path)) + { + player->StopMoving();record.Active=false;record.Status.State=ClientlessActionExecutionState::Failed; + record.Status.Detail="recorded group road rejected by safety adapter";return false; + } + if(path.size()<2) + {if(player->IsSplineEnabled())player->StopMoving();record.FormationWaiting=true;record.Status.State=ClientlessActionExecutionState::Moving;record.Status.Detail="waiting for leading formation row";return true;} + record.GroundChunk=path.size()PrepareGroundMount(player, recordedDistance, "recorded-ground-edge"); + sClientlessLuaEngine->PrepareGroundMount(player, std::max(recordedDistance,anticipatedDistance), "recorded-ground-edge"); player->GetMotionMaster()->MoveSmoothPath( 1, @@ -2447,6 +2466,7 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( if (!record.EdgePath.empty()) { + if(record.FormationWaiting){StartRecordedEdgePath(player,characterGuid,record);return;} ClientlessTravelEdgePoint const& destination = record.EdgePath.back(); // VCORE_C816_PORTAL_MOUNT_GUARD: when a long recorded-road spline starts @@ -2507,7 +2527,7 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( if (index >= 0 && std::size_t(index) < record.EdgeSplinePoints.size()) record.EdgeNextPoint = std::max(record.EdgeNextPoint, record.EdgeSplinePoints[std::size_t(index)]); - CheckGroundExit(player, characterGuid, record); + if(!_groundAdapters.count(characterGuid))CheckGroundExit(player, characterGuid, record); if (record.GroundExitActive) return; } @@ -2518,6 +2538,14 @@ void ClientlessActionExecutorMgr::UpdateTravelToNode( // it until the spline is finalized. Do not evaluate arrival before then. if (player->IsSplineEnabled()) return; + if(record.GroundChunk && !record.EdgeSplinePoints.empty()) + { + if(player->GetExactDist(record.GroundChunkEnd)>2) + {record.Active=false;record.Status.State=ClientlessActionExecutionState::Failed;record.Status.Detail="group road stopped before checked chunk endpoint";return;} + record.EdgeNextPoint=std::max(record.EdgeNextPoint,record.EdgeSplinePoints.back()); + StartRecordedEdgePath(player,characterGuid,record);return; + } + float dx = player->GetPositionX() - destination.X; float dy = player->GetPositionY() - destination.Y; diff --git a/src/server/game/World/ClientlessActionExecutorMgr.h b/src/server/game/World/ClientlessActionExecutorMgr.h index 5a9d6cbb39..bcfbbc6f15 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.h +++ b/src/server/game/World/ClientlessActionExecutorMgr.h @@ -8,6 +8,8 @@ #include "Common.h" #include "ClientlessTravelGraphMgr.h" #include +#include +#include "Position.h" #include #include #include @@ -42,6 +44,10 @@ public: void Update(uint32 diff); void Clear(uint32 characterGuid); + // Optional owner-supplied recorded-ground safety/formation adapter. It may + // offset points or truncate the tail; fewer than two points means wait. + using GroundPathAdapter=std::function&)>; + void SetGroundPathAdapter(uint32 guid,GroundPathAdapter adapter); bool RequestStopAtNode(uint32 characterGuid); ClientlessActionExecutionStatus const* GetStatus(uint32 characterGuid) const; @@ -55,6 +61,8 @@ private: std::shared_ptr PendingShared; uint32 SharedWaitMs=0; bool StopAtNode = false; + bool FormationWaiting = false, GroundChunk = false; + Position GroundChunkEnd; std::vector Route; std::size_t RouteStep = 0; @@ -138,6 +146,7 @@ private: void UpdateTravelToLocation(Player* player, uint32 characterGuid, Record& record); void UpdateActive(Player* player, uint32 characterGuid, Record& record); + std::unordered_map _groundAdapters; std::unordered_map _records; }; diff --git a/src/server/game/World/ClientlessFlightMgr.cpp b/src/server/game/World/ClientlessFlightMgr.cpp index b66b0fde76..ba6170fe0c 100644 --- a/src/server/game/World/ClientlessFlightMgr.cpp +++ b/src/server/game/World/ClientlessFlightMgr.cpp @@ -167,7 +167,69 @@ Surface ClientlessFlightMgr::Sample(Player * player, Record & record, float x, f }; } -bool ClientlessFlightMgr::Start(Player * player, Position const & target, std::string & error) +bool ClientlessFlightMgr::Start(Player* player,Position const& target,std::string& error) +{return Begin(player,target,error,false);} +bool ClientlessFlightMgr::Follow(Player* player,Position const& target,std::string& error) +{ + auto found=_records.find(player->GetGUID().GetCounter()); + if(found!=_records.end() && (found->second.Status.State==ClientlessFlightState::Mounting || found->second.Status.State==ClientlessFlightState::Flying)) + { + auto& record=found->second; + if(!record.Status.FollowingPlayer || record.Returning){error="existing flight must finish safely";return false;} + if(!Trinity::IsValidMapCoord(target.GetPositionX(),target.GetPositionY(),target.GetPositionZ()))return false; + auto surface=Sample(player,record,target.GetPositionX(),target.GetPositionY()); + if(!surface.Valid || target.GetPositionZ()surface.Ceiling)return false; + record.Status.Target=target;record.Status.Landing=target;record.TargetUpdated=record.Elapsed;return true; + } + return Begin(player,target,error,true); +} +bool ClientlessFlightMgr::LandFollowing(Player* player) +{ + auto found=_records.find(player->GetGUID().GetCounter());if(found==_records.end())return false; + auto& record=found->second;if(!record.Status.FollowingPlayer)return false; + record.Status.FollowingPlayer=false; + if(record.Status.State==ClientlessFlightState::Mounting) + {Fail(player,record,"group flight cancelled before takeoff");return true;} + auto surface=Sample(player,record,player->GetPositionX(),player->GetPositionY()); + float solid=player->GetMap()->GetHeight(player->GetPhaseShift(),player->GetPositionX(),player->GetPositionY(),MAX_HEIGHT,true,MAX_HEIGHT); + if(surface.Valid && std::fabs(surface.Ground-solid)<0.7f) + { + record.Status.Landing.Relocate(player->GetPositionX(),player->GetPositionY(),solid,player->GetOrientation()); + record.Status.Target=record.Status.Landing;record.Memory={};record.Following.reset();record.Status.Travelled=0; + record.Status.Detail="landing after group flight";return true; + } + Fail(player,record,"no safe local landing; returning along validated flight trail");return true; +} +bool ClientlessFlightMgr::AdvanceFollowing(Player* player,Record& record) +{ + Point current=PointOf(*player),goal=PointOf(record.Status.Target); + if(Distance(current,goal)<2){if(record.Moving)player->StopMoving();record.Moving=false;record.Status.Detail="holding flight formation";return true;} + auto sample=[&](float x,float y){return Sample(player,record,x,y);}; + auto ray=[&](Point const& a,Point const& b){return ClearRay(player,a,b);}; + auto settings=record.Settings;settings.Safety=1; + Point best;float score=std::numeric_limits::infinity(); + float heading=std::atan2(goal.Y-current.Y,goal.X-current.X); + float length=std::min(25.0f,Distance(current,goal)); + for(float turn:{0.0f,0.4f,-0.4f,0.8f,-0.8f})for(float climb:{0.0f,4.0f}) + { + auto point=Mix(current,goal,std::min(1.0f,length/std::max(0.1f,Distance(current,goal)))); + if(turn!=0){float horizontal=Horizontal(current,point);point.X=current.X+std::cos(heading+turn)*horizontal;point.Y=current.Y+std::sin(heading+turn)*horizontal;} + point.Z+=climb; + if(!Corridor(current,point,goal,settings,sample,ray,true))continue; + float candidate=Distance(point,goal)+std::fabs(turn)*4+climb; + if(candidateStopMoving();record.Moving=false;record.Status.Detail="group flight corridor blocked; holding mounted";return false;} + if(record.Moving && player->IsSplineEnabled() && Distance(best,PointOf(record.Segment))<3)return true; + if(record.Breadcrumbs.size()<4096)record.Breadcrumbs.push_back(*player); + record.Segment.Relocate(best.X,best.Y,best.Z,player->GetOrientation()); + Movement::MoveSplineInit move(player);move.MoveTo(best.X,best.Y,best.Z,false);move.SetFly();move.SetWalk(false);move.SetVelocity(player->GetSpeed(MOVE_FLIGHT)); + player->AddUnitMovementFlag(MOVEMENTFLAG_FLYING); + if(move.Launch()<=0){record.Moving=false;return false;} + record.Moving=true;record.SegmentElapsed=0;++record.Status.Segments;record.Status.Detail="following moving group flight target";return true; +} + +bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::string & error, bool following) { if (! Trinity::IsValidMapCoord(target.GetPositionX(), target.GetPositionY(), target.GetPositionZ())) { @@ -228,7 +290,14 @@ bool ClientlessFlightMgr::Start(Player * player, Position const & target, std::s return false; } bool foundLanding = false; - for (float radius : + if(following) + { + auto surface=Sample(player,record,target.GetPositionX(),target.GetPositionY()); + if(!surface.Valid || target.GetPositionZ()surface.Ceiling) + {error="group flight target outside verified flight bounds";return false;} + record.Status.Landing=target;record.Status.FollowingPlayer=true;foundLanding=true; + } + if(!following) for (float radius : { 0.0f, 6.0f, 12.0f, 20.0f }) @@ -273,13 +342,16 @@ bool ClientlessFlightMgr::Start(Player * player, Position const & target, std::s record.Status.State = ClientlessFlightState::Mounting; record.Status.MountSpell = spell; record.Status.Detail = "waiting for native flying mount"; - if(auto group=player->GetGroup())if(group->GetLeaderGUID()!=player->GetGUID()) + if(!following)if(auto group=player->GetGroup())if(group->GetLeaderGUID()!=player->GetGUID()) { auto source=_records.find(group->GetLeaderGUID().GetCounter()); if(source!=_records.end() && source->second.Status.MapId==player->GetMapId() && source->second.Status.Target.GetExactDist(target)<1 && !source->second.Published->Aborted) { record.Following=source->second.Published;record.Status.Landing=source->second.Status.Landing; + std::vector members;for(auto const& member:group->GetMemberSlots())if(member.guid!=group->GetLeaderGUID())members.push_back(member.guid); + std::sort(members.begin(),members.end());auto slot=std::find(members.begin(),members.end(),player->GetGUID())-members.begin(); + record.FormationDelay=3500+uint32(slot)*600; // native shared corridor, staggered single-file departure TC_LOG_INFO("server.adventurebot.navigation","FLIGHT_SHARED_TRAIL guid=%u leader=%u",player->GetGUID().GetCounter(),group->GetLeaderGUID().GetCounter()); } } @@ -297,6 +369,7 @@ bool ClientlessFlightMgr::Start(Player * player, Position const & target, std::s void ClientlessFlightMgr::Fail(Player * player, Record & record, std::string const & reason) { record.Published->Aborted=true; + record.Status.FollowingPlayer=false; player->StopMoving(); record.Moving = false; if (record.Status.State == ClientlessFlightState::Mounting) player->InterruptNonMeleeSpells(false); @@ -427,6 +500,7 @@ void ClientlessFlightMgr::Update(uint32 diff) continue; } } + if(record.Following && record.ElapsedCanFly() || record.Elapsed >(record.Returning ? 2100000u : 1800000u)) { Fail(player, record, "flight permission lost or total timeout"); @@ -438,6 +512,11 @@ void ClientlessFlightMgr::Update(uint32 diff) continue; } record.CheckTimer = 200; + if(record.Status.FollowingPlayer) + { + if(record.Elapsed-record.TargetUpdated>5000 || player->IsInCombat())LandFollowing(player); + else {AdvanceFollowing(player,record);continue;} + } if (record.Moving && player->IsSplineEnabled()) { if (record.SegmentElapsed > 10000) diff --git a/src/server/game/World/ClientlessFlightMgr.h b/src/server/game/World/ClientlessFlightMgr.h index 59868fc42b..d72eef79b1 100644 --- a/src/server/game/World/ClientlessFlightMgr.h +++ b/src/server/game/World/ClientlessFlightMgr.h @@ -20,6 +20,7 @@ struct ClientlessFlightStatus uint32 MapId = 0, MountSpell = 0, Segments = 0, Avoidances = 0, Handoffs = 0; Position Target, Landing; float Travelled = 0; + bool FollowingPlayer = false; }; class TC_GAME_API ClientlessFlightMgr { @@ -30,6 +31,8 @@ class TC_GAME_API ClientlessFlightMgr bool Enabled(Player const * player) const; void BeginPlanning(); bool Start(Player * player, Position const & target, std::string & error); + bool Follow(Player* player, Position const& target, std::string& error); + bool LandFollowing(Player* player); void Update(uint32 diff); void Clear(uint32 guid); ClientlessFlightStatus const * GetStatus(uint32 guid) const; @@ -45,12 +48,15 @@ class TC_GAME_API ClientlessFlightMgr Position Start, Segment, LastPosition; uint32 Elapsed = 0, SegmentElapsed = 0, CheckTimer = 0; bool Moving = false, Returning = false; + uint32 TargetUpdated = 0, FormationDelay = 0; std::string AbortReason; std::vector < Position > Breadcrumbs; std::unordered_map < std::string, bool > Tiles; }; ClientlessFlightPath::Surface Sample(Player * player, Record & record, float x, float y) const; bool Advance(Player * player, Record & record, bool handoff = false); + bool Begin(Player* player, Position const& target, std::string& error, bool following); + bool AdvanceFollowing(Player* player, Record& record); void Fail(Player * player, Record & record, std::string const & reason); std::pair < uint32, uint32 > AreaZone(Player const * player, uint32 map, Position const & point) const; mutable std::unordered_map < std::string, std::pair < uint32, uint32 >> _planningAreas; diff --git a/src/server/game/World/ClientlessLuaEngine.cpp b/src/server/game/World/ClientlessLuaEngine.cpp index 2ebc61a9d6..0f36a4eb80 100644 --- a/src/server/game/World/ClientlessLuaEngine.cpp +++ b/src/server/game/World/ClientlessLuaEngine.cpp @@ -8,6 +8,7 @@ #include "AdventureCombatMgr.h" #include "AdventureInteractionMgr.h" #include "AdventureGroupMgr.h" +#include "AdventureGroupTravelMgr.h" #include "AdventureGeneratorMgr.h" #include "AdventurePopulationMgr.h" #include "AdventureSupplyMgr.h" @@ -229,7 +230,9 @@ public: { sAdventureBehaviorMgr->IntentChanged(player, intent); } void Update(Player* player, uint32 diff) override { + sAdventureGroupTravelMgr->Observe(player,diff); sAdventurePopulationMgr->Maintain(player,diff); + if(sAdventureGroupTravelMgr->Airborne(player)){sAdventureGroupTravelMgr->Follow(player);return;} if(sAdventureSupplyMgr->Update(player,diff,sAdventureCombatMgr->Busy(player)))return; if(!sAdventureCombatMgr->Update(player,diff) && !sAdventureInteractionMgr->Update(player,diff)) sAdventureBehaviorMgr->Update(player,diff); diff --git a/src/server/game/World/ClientlessTransportMgr.cpp b/src/server/game/World/ClientlessTransportMgr.cpp index 49805399cc..d2bad5a118 100644 --- a/src/server/game/World/ClientlessTransportMgr.cpp +++ b/src/server/game/World/ClientlessTransportMgr.cpp @@ -314,6 +314,76 @@ bool ClientlessTransportMgr::Save(Player* player, std::string const& stand, std: return true; } +ClientlessTransportStop const* ClientlessTransportMgr::GetStop(std::string const& node) +{ + if(!EnsureLoaded())return nullptr; + auto found=_stops.find(node);return found==_stops.end()?nullptr:&found->second; +} +bool ClientlessTransportMgr::ObserveJourney(Player* player,uint32 lastMap,Position const& ground,std::string& from,std::string& to) +{ + from.clear();to.clear();if(!player->GetTransport() || !EnsureLoaded())return false; + ClientlessTransportStop const* source=nullptr;float best=100; + for(auto const& pair:_stops) + { + auto const& stop=pair.second;if(stop.Map!=lastMap)continue; + auto object=Resolve(player,stop.Spawn); + if(!object || object->ToTransportBase()!=player->GetTransport())continue; + float distance=Distance(ground,stop.Points.front().Pos); + if(distanceNode; + unsigned count=0; + for(auto const& pair:_stops)if(pair.first!=from && SameService(*source,pair.second)) + {to=pair.first;++count;} + if(count!=1)to.clear(); // multi-stop services resolve the actual arrival instead of guessing + return true; +} +bool ClientlessTransportMgr::ArrivalNode(Player* player,std::string const& from,std::string& to) +{ + auto source=GetStop(from);if(!source)return false; + float best=100;to.clear(); + for(auto const& pair:_stops) + { + auto const& stop=pair.second; + if(stop.Map!=player->GetMapId() || pair.first==from || !SameService(*source,stop))continue; + float distance=Distance(*player,stop.Points.front().Pos); + if(distanceIsBeingTeleported()){player->GetSession()->CompleteClientlessTeleport();return;} + if(!player->IsAlive())return; // the ordinary death controller retains resurrection ownership + if(player->GetTransport())return; + if(player->GetMapId()==ride.From.Map && Distance(*player,destination)<2.5f && !player->IsFalling()) + { + sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter()); + ride.Elapsed=0;ride.RecoveryMs=0;ride.RecoveryMoving=false; + if(ride.Abort || ride.Recoveries>=3) + {SetPhase(player,ride,ClientlessTransportPhase::Failed,"safely returned to departure; ride cancelled/recovery limit");return;} + ride.Point=0;SetPhase(player,ride,ClientlessTransportPhase::Waiting,"recovered at safe departure; waiting for next service");return; + } + auto nav=sClientlessTravelMgr->GetStatus(player->GetGUID().GetCounter()); + bool failed=nav && (nav->State==ClientlessTravelState::Stuck || nav->State==ClientlessTravelState::PathRejected); + if(ride.RecoveryMoving && nav && nav->State==ClientlessTravelState::Moving && ride.RecoveryMs<60000)return; + if(!ride.RecoveryMoving && !failed && !player->IsFalling() && player->GetMapId()==ride.From.Map) + { + ClientlessPathDiagnostics path; + if(sClientlessTravelMgr->AnalyzePath(player,destination,path) && path.Complete && !path.Suspicious && path.PathLength<2000 && + sClientlessTravelMgr->StartTravel(player,destination,true)) + {ride.RecoveryMoving=true;return;} + } + sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter()); + // Only a recorded, actually boarded journey can reach this fallback. + // Never teleport toward the leader or to an inferred unrecorded dock. + player->TeleportTo(ride.From.Map,destination.GetPositionX(),destination.GetPositionY(),destination.GetPositionZ(),destination.GetOrientation()); + TC_LOG_WARN("server.adventurebot.navigation","TRANSPORT_RECOVERY_TELEPORT guid=%u node='%s'",player->GetGUID().GetCounter(),ride.From.Node.c_str()); +} + bool ClientlessTransportMgr::Start(Player* player, std::string const& from, std::string const& to, std::string& error) { if (!player || !player->IsInWorld() || player->GetTransport() || !HasConnection(from, to)) @@ -326,6 +396,9 @@ bool ClientlessTransportMgr::Start(Player* player, std::string const& from, std: ride.RequiredPause = uint32(PathLength(source) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000.0f) + uint32(source.Points.size()) * 100 + 1500; ride.Status.Phase = ClientlessTransportPhase::Waiting; ride.Status.Detail = "waiting for recorded transport at departure dock"; + player->RemoveAurasByType(SPELL_AURA_MOUNTED); + if(player->IsMounted())player->Dismount(); + player->UpdateSpeed(MOVE_RUN); sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter()); player->StopMoving(); _rides[player->GetGUID().GetCounter()] = std::move(ride); @@ -363,6 +436,7 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff) player->GetSession()->CompleteClientlessTeleport(); return; } + if(ride.Status.Recovering){RecoverPassenger(player,ride,diff);return;} GameObject* object = FindObject(player, ride.From); uint32 limit = uint32(std::min(1800000, std::max(180000, uint64(ride.From.Period) * 3 + 30000))); auto fail = [&](char const* detail) @@ -390,7 +464,15 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff) if (ride.Status.Phase == Phase::Riding) { if (!object || player->GetTransport() != object->ToTransportBase()) - { fail("transport passenger identity lost"); return; } + { + if(!player->GetTransport()) + { + player->StopMoving();ride.Status.Recovering=true;ride.Status.Detail="lost passenger; returning to recorded safe departure"; + ride.RecoveryMs=0;ride.RecoveryMoving=false;++ride.Recoveries; + RecoverPassenger(player,ride,diff);return; + } + fail("transport passenger identity changed; attachment retained");return; + } ClientlessTransportStop const* arrival = nullptr; if (AtDock(player, object, ride.To, uint32(PathLength(ride.To) / std::max(1.0f, player->GetSpeed(MOVE_RUN)) * 1000) + uint32(ride.To.Points.size()) * 100 + 1500)) arrival = &ride.To; else if (ride.Abort && AtDock(player, object, ride.From, ride.RequiredPause)) arrival = &ride.From; diff --git a/src/server/game/World/ClientlessTransportMgr.h b/src/server/game/World/ClientlessTransportMgr.h index a5f83c2ab0..c370ad6c03 100644 --- a/src/server/game/World/ClientlessTransportMgr.h +++ b/src/server/game/World/ClientlessTransportMgr.h @@ -41,6 +41,7 @@ struct ClientlessTransportStatus ClientlessTransportPhase Phase = ClientlessTransportPhase::Failed; std::string Detail; bool Cancelled = false; + bool Recovering = false; }; class TC_GAME_API ClientlessTransportMgr @@ -49,6 +50,9 @@ public: static ClientlessTransportMgr* instance(); bool Reload(); bool HasConnection(std::string const& from, std::string const& to); + bool ObserveJourney(Player* passenger, uint32 lastMap, Position const& lastGround, std::string& from, std::string& to); + bool ArrivalNode(Player* player, std::string const& from, std::string& to); + ClientlessTransportStop const* GetStop(std::string const& node); bool Start(Player* player, std::string const& from, std::string const& to, std::string& error); ClientlessTransportStatus const* GetStatus(uint32 guid) const; void Cancel(uint32 guid); @@ -77,11 +81,14 @@ private: uint32 PointTimeout = 0; bool Moving = false; bool Abort = false; + uint32 RecoveryMs = 0, Recoveries = 0; + bool RecoveryMoving = false; }; bool EnsureLoaded(); bool Sample(Player* player, Recording& recording, bool force); bool Save(Player* player, std::string const& stand, std::string& error); void UpdateRide(Player* player, Ride& ride, uint32 diff); + void RecoverPassenger(Player* player, Ride& ride, uint32 diff); void SetPhase(Player* player, Ride& ride, ClientlessTransportPhase phase, char const* detail); GameObject* FindObject(Player* player, ClientlessTransportStop const& stop) const; bool AtDock(Player* player, GameObject* object, ClientlessTransportStop const& stop, uint32 requiredPause) const; diff --git a/src/server/game/World/ClientlessTravelMgr.cpp b/src/server/game/World/ClientlessTravelMgr.cpp index a3bc6b80ab..9a41995fae 100644 --- a/src/server/game/World/ClientlessTravelMgr.cpp +++ b/src/server/game/World/ClientlessTravelMgr.cpp @@ -77,6 +77,9 @@ char const* ClientlessTravelMgr::StateName(ClientlessTravelState state) return "Unknown"; } +void ClientlessTravelMgr::SetPathValidator(uint32 guid,PathValidator validator) +{if(validator)_validators[guid]=std::move(validator);else _validators.erase(guid);} + bool ClientlessTravelMgr::AnalyzePath( Player* player, Position const& target, @@ -410,6 +413,12 @@ bool ClientlessTravelMgr::LaunchPath(Player* player, TravelRecord& record) return false; } + auto validator=_validators.find(player->GetGUID().GetCounter()); + if(validator!=_validators.end() && !validator->second(player,spline)) + { + player->StopMoving();record.Status.State=ClientlessTravelState::PathRejected; + record.Status.Path.Reason="owner ground-safety validator rejected spline";return false; + } record.ActiveSpline = spline; ++record.Status.SegmentIndex; diff --git a/src/server/game/World/ClientlessTravelMgr.h b/src/server/game/World/ClientlessTravelMgr.h index b3ffb7dcf7..9a303cd28e 100644 --- a/src/server/game/World/ClientlessTravelMgr.h +++ b/src/server/game/World/ClientlessTravelMgr.h @@ -8,6 +8,7 @@ #include "Common.h" #include "Position.h" #include +#include #include #include @@ -57,6 +58,8 @@ class TC_GAME_API ClientlessTravelMgr public: static ClientlessTravelMgr* instance(); + using PathValidator=std::function const&)>; + void SetPathValidator(uint32 guid,PathValidator validator); bool StartTravel(Player* player, Position const& target, bool allowGroundMount = false, bool portalApproach = false, bool walk = false); bool AnalyzePath(Player* player, Position const& target, ClientlessPathDiagnostics& diagnostics) const; void CancelTravel(uint32 characterGuid); @@ -85,6 +88,7 @@ private: bool LaunchPath(Player* player, TravelRecord& record); float DistanceFromActiveSpline(Player const* player, TravelRecord const& record) const; + std::unordered_map _validators; std::unordered_map _records; uint64 _nextNavSequence = 1; }; diff --git a/tests/clientless/test_adventure_group_travel.py b/tests/clientless/test_adventure_group_travel.py new file mode 100644 index 0000000000..fa5da12175 --- /dev/null +++ b/tests/clientless/test_adventure_group_travel.py @@ -0,0 +1,125 @@ +"""Exercise production group formation, safety, transition queue and separation.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureGroupTravelMgr.cpp').read_text() +def method(sig): + a=s.index(sig);b=s.index('{',a);e=b+1;n=1 + while n:n+=(s[e]=='{')-(s[e]=='}');e+=1 + return s[a:e] +h=(root/'src/server/game/World/AdventureGroupTravelMgr.h').read_text() +h='\n'.join(v for v in h.splitlines() if not v.startswith('#include')).replace('private:','public:') +code=r''' +#include "AdventureGroupTravelPolicy.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using uint32=unsigned; +#define TC_GAME_API +constexpr float INVALID_HEIGHT=-100000; +constexpr int LINEOFSIGHT_ALL_CHECKS=0,CMSG_AREATRIGGER=0; +namespace VMAP{enum class ModelIgnoreFlags{None};} +struct ObjectGuid{unsigned id=0;unsigned GetCounter()const{return id;}void Clear(){id=0;}explicit operator bool()const{return id;}bool operator==(ObjectGuid b)const{return id==b.id;}bool operator!=(ObjectGuid b)const{return id!=b.id;}bool operator<(ObjectGuid b)const{return idstruct hash{size_t operator()(ObjectGuid g)const{return g.id;}};} +struct Position{ + float m_positionX=0,m_positionY=0,m_positionZ=0,o=0;Position()=default;Position(float x,float y,float z=0,float a=0):m_positionX(x),m_positionY(y),m_positionZ(z),o(a){} + void Relocate(Position const*p){*this=*p;}void Relocate(float x,float y,float z,float a=0){*this={x,y,z,a};} + float GetPositionX()const{return m_positionX;}float GetPositionY()const{return m_positionY;}float GetPositionZ()const{return m_positionZ;}float GetOrientation()const{return o;} + float GetExactDist(Position const&p)const{return std::hypot(std::hypot(m_positionX-p.m_positionX,m_positionY-p.m_positionY),m_positionZ-p.m_positionZ);} + float GetExactDist(Position const*p)const{return GetExactDist(*p);}float GetExactDist(float x,float y,float z)const{return GetExactDist(Position{x,y,z});} + float GetExactDist2d(Position const&p)const{return std::hypot(m_positionX-p.m_positionX,m_positionY-p.m_positionY);} +}; +struct Map{bool cliff=false,los=true,narrow=false;bool IsDungeon(){return false;} + float GetHeight(unsigned phase,float x,float y,float,bool,float){assert(phase==169);if(cliff && x>4 && x<15)return -1000;if(narrow && std::fabs(y)>2)return -1000;return 0;} + bool isInLineOfSight(unsigned,float,float,float,float,float,float,int,VMAP::ModelIgnoreFlags){return los;} +}map; +struct Session{bool clientless=false;bool IsClientless(){return clientless;}void HandleAreaTriggerOpcode(struct WorldPacket&){}}; +struct Group{ObjectGuid leader{21};struct Slot{ObjectGuid guid;};std::vectorslots{{{21}},{{26}},{{27}}};ObjectGuid GetLeaderGUID(){return leader;}auto const&GetMemberSlots(){return slots;}}group; +struct Player:Position{ + ObjectGuid guid;bool alive=true,combat=false,teleport=false,charmed=false,flying=false,falling=false,mounted=false,moving=false,taxi=false,transport=false,phase=true,visible=true;unsigned mapid=0;Group*g=&group;Session session; + Player(unsigned id):guid{id}{}ObjectGuid GetGUID(){return guid;}Group*GetGroup(){return g;}Map*GetMap(){return ↦}unsigned GetMapId(){return mapid;}unsigned GetPhaseShift(){return 169;} + Session*GetSession(){return &session;}bool IsAlive(){return alive;}bool IsInCombat(){return combat;}bool IsBeingTeleported(){return teleport;}bool IsCharmed(){return charmed;} + bool IsFlying(){return flying;}bool IsFalling(){return falling;}bool IsMounted(){return mounted;}bool isMoving(){return moving;}bool IsInFlight(){return taxi;}bool GetTransport(){return transport;} + bool IsInMap(Player*p){return mapid==p->mapid;}bool IsInPhase(Player*p){return phase && p->phase;}bool CanSeeOrDetect(Player*p){return visible && p->visible;}bool IsInAreaTriggerRadius(void*){return false;} +}; +struct WorldPacket{WorldPacket(int,int){}templateWorldPacket&operator<<(T){return *this;}}; +struct GameObject{bool IsInPhase(Player*){return true;}void Use(Player*){}}; +namespace ObjectAccessor{std::unordered_mapplayers;Player*FindPlayer(ObjectGuid g){auto it=players.find(g);return it==players.end()?nullptr:it->second;}GameObject*GetGameObject(Player&,ObjectGuid){return nullptr;}} +namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}} +struct Areas{void*LookupEntry(unsigned){return nullptr;}}sAreaTriggerStore; +enum class ClientlessActionExecutionState{Moving,SelectingTarget,Completed,Failed}; +struct Executor{struct Status{ClientlessActionExecutionState State=ClientlessActionExecutionState::Completed;}status;unsigned clears=0;auto GetStatus(unsigned){return &status;}bool RequestStopAtNode(unsigned){return true;}void Clear(unsigned){++clears;status.State=ClientlessActionExecutionState::Completed;}void SetGroundPathAdapter(unsigned,std::function&)>){}}executor;auto sClientlessActionExecutorMgr=&executor; +struct ClientlessAction{std::string Name;std::unordered_mapParameters;}; +struct Core{unsigned queued=0;std::vectornodes;unsigned GetQueuedActionCount(unsigned){return queued;}bool QueueAction(unsigned,ClientlessAction action){++queued;nodes.push_back(action.Parameters["node"]);return true;}void ClearQueuedActions(unsigned){queued=0;}}core;auto sClientlessPlayerMgr=&core; +enum class ClientlessTravelState{Moving,Arrived,Stuck,PathRejected}; +struct ClientlessPathDiagnostics{bool Complete=true,Suspicious=false;float PathLength=20;}; +struct Travel{struct Status{ClientlessTravelState State=ClientlessTravelState::Arrived;Position Target;}status;bool valid=true;unsigned starts=0,cancels=0;std::vectortargets; + auto GetStatus(unsigned){return &status;}bool AnalyzePath(Player*,Position const&,ClientlessPathDiagnostics&d){d.Complete=valid;return valid;} + bool StartTravel(Player*,Position const&target,bool=true,bool=false){++starts;targets.push_back(target);status.Target=target;status.State=ClientlessTravelState::Moving;return true;} + std::vectorconst*GetActivePath(unsigned){return nullptr;}void CancelTravel(unsigned){++cancels;status.State=ClientlessTravelState::Arrived;}void SetPathValidator(unsigned,std::functionconst&)>){} +}travel;auto sClientlessTravelMgr=&travel; +struct PathGenerator{struct Point{float x,y,z;};Player*p;std::vectorpoints;PathGenerator(Player*who):p(who){}bool CalculatePath(float x,float y,float z){points={{p->GetPositionX(),p->GetPositionY(),p->GetPositionZ()},{x,y,z}};return travel.valid;}auto const&GetPath(){return points;}}; +struct Graph{bool FindNearestNodeToPosition(unsigned,float,float,float,std::string&n,float&distance){n="nearest";distance=5;return true;}}graph;auto sClientlessTravelGraphMgr=&graph; +enum class ClientlessTransportPhase{Waiting,Riding,Arrived,Failed}; +struct TransportStatus{ClientlessTransportPhase Phase=ClientlessTransportPhase::Failed;};struct Stop{unsigned Map=0;struct Point{Position Pos;};std::vectorPoints{{{0,0,0}}};}; +struct Transport{TransportStatus status;auto GetStatus(unsigned){return &status;}Stop*GetStop(std::string const&){return nullptr;}bool Start(Player*,std::string const&,std::string const&,std::string&){return false;}}transport;auto sClientlessTransportMgr=&transport; +enum class ClientlessFlightState{Mounting,Flying,Arrived,Failed}; +struct Flight{struct Status{ClientlessFlightState State=ClientlessFlightState::Failed;bool FollowingPlayer=false;}status;bool enabled=false;unsigned follows=0,lands=0;auto GetStatus(unsigned){return &status;}bool Enabled(Player*){return enabled;} + bool Follow(Player*,Position const&,std::string&){++follows;status.State=ClientlessFlightState::Flying;status.FollowingPlayer=true;return true;}bool LandFollowing(Player*){++lands;status.FollowingPlayer=false;return true;}}flight;auto sClientlessFlightMgr=&flight; +struct Profile{float GroundMountMinDistance=60;};struct Lua{Profile profile;auto GetAdventureProfile(unsigned){return &profile;}void PrepareGroundMount(Player*p,float,char const*){p->mounted=true;}void DismountGroundMount(Player*p,char const*){p->mounted=false;}}lua;auto sClientlessLuaEngine=&lua; +struct ChatHandler{void SendSysMessage(char const*){}templatevoid PSendSysMessage(char const*,T...){}}; +'''+h+r''' +AdventureGroupTravelMgr* AdventureGroupTravelMgr::instance(){static AdventureGroupTravelMgr m;return &m;} +AdventureGroupTravelMgr::Leader observed; +void AdventureGroupTravelMgr::SampleLeader(Player*,Leader&state,time_t){state=observed;} +'''+method(' bool Executing(')+method(' Position OffsetPoint(') +for sig in ['void AdventureGroupTravelMgr::Observe(', 'bool AdventureGroupTravelMgr::Airborne(', 'bool AdventureGroupTravelMgr::Protected(', 'bool AdventureGroupTravelMgr::Separated(', 'bool AdventureGroupTravelMgr::GroundPoint(', 'bool AdventureGroupTravelMgr::SafePath(', 'unsigned AdventureGroupTravelMgr::Columns(', 'Position AdventureGroupTravelMgr::Formation(', 'bool AdventureGroupTravelMgr::Route(', 'bool AdventureGroupTravelMgr::JourneyFollow(', 'bool AdventureGroupTravelMgr::Follow(', 'bool AdventureGroupTravelMgr::AdaptRoad(', 'bool AdventureGroupTravelMgr::ValidateGroundPath(', 'void AdventureGroupTravelMgr::Clear(']:code+=method(sig)+'\n' +code+=r''' +int main(){ + using namespace AdventureGroupTravelPolicy; + for(unsigned columns:{1,2,3})for(bool moving:{false,true})for(bool mounted:{false,true})for(bool flying:{false,true}){ + std::set>points;for(unsigned slot=0;slot<20;++slot){auto p=Formation(slot,columns,moving,mounted,flying);assert(points.emplace(p.Behind,p.Side,p.Height).second);} + } + unsigned elapsed=0;for(unsigned i=0;i<1199;++i)elapsed=Separation(elapsed,500,true,false);assert(elapsed==599500); + assert(Separation(elapsed,500,true,true)==elapsed);assert(Separation(elapsed,500,true,false)==600000);assert(Separation(600000,500,false,false)==0); + Player leader(21),a(26),b(27);a.session.clientless=b.session.clientless=true;leader.m_positionX=20; + ObjectAccessor::players={{{21},&leader},{{26},&a},{{27},&b}}; + AdventureGroupTravelMgr mgr;mgr.Observe(&a,500);mgr.Observe(&b,500);assert(mgr.Follow(&a));auto first=travel.targets.back();travel.status.State=ClientlessTravelState::Arrived;assert(mgr.Follow(&b));auto second=travel.targets.back();assert(first.GetExactDist(second)>2); + // A valid-looking MMAP endpoint beyond a hole must never authorize a shortcut. + travel.status.State=ClientlessTravelState::Arrived;map.cliff=true;unsigned starts=travel.starts;assert(mgr.Follow(&a));assert(travel.starts==starts);map.cliff=false; + // Formation compresses on narrow ground; this is checked against phased terrain. + mgr._widths.clear();map.narrow=true;assert(mgr.Columns(&a,&leader)==1);map.narrow=false;mgr._widths.clear();assert(mgr.Columns(&a,&leader)==3); + travel.status.State=ClientlessTravelState::Arrived;leader.mounted=true;mgr.Follow(&a);assert(a.mounted);leader.mounted=false;a=Player(26); // retain a clean origin + // A ground-only bot waits safely when the leader gains excessive height. + leader.flying=true;leader.m_positionZ=80;starts=travel.starts;mgr.Follow(&a);assert(travel.starts==starts);leader.flying=false;leader.m_positionZ=0; + // A native-capable follower uses flight, and safely lands after a kick. + flight.enabled=true;leader.flying=true;mgr.Follow(&a);assert(flight.follows==1 && mgr.Airborne(&a));a.g=nullptr;mgr.Follow(&a);assert(flight.lands==1);a.g=&group;flight.status.State=ClientlessFlightState::Arrived;leader.flying=false;flight.enabled=false;mgr.Follow(&a); + // Taxi journey and its arrival grace pause distance counting. Completion must + // not protect unrelated later ground separation. + auto& state=observed;state.Kind=AdventureGroupTravelMgr::Journey::Taxi;state.Generation=1;state.Began=GameTime::now;state.To="taxi_42";state.GoalMap=1;state.Goal={1000,0,0};leader.taxi=true;leader.m_positionX=900; + mgr.Observe(&a,500);assert(mgr.Protected(&a));mgr.Follow(&a);assert(core.nodes.back()=="taxi_42");unsigned queued=core.queued;mgr.Follow(&a);assert(core.queued==queued); + leader.taxi=false;leader.mapid=1;leader.m_positionX=1000;state.Arrived=++GameTime::now;mgr.Observe(&a,500);assert(mgr.Protected(&a)); + a.mapid=1;a.m_positionX=1000;mgr.Observe(&a,500);assert(mgr._records.at(26).Pending.empty() && !mgr.Protected(&a)); + // Sequential portals retain the first transition until the follower arrives. + state.Kind=AdventureGroupTravelMgr::Journey::Portal;state.Generation=2;state.SourceMap=1;state.GoalMap=2;state.Goal={100,0,0};state.Began=++GameTime::now;mgr.Observe(&a,500); + state.Generation=3;state.SourceMap=2;state.GoalMap=3;state.Goal={200,0,0};mgr.Observe(&a,500);assert(mgr._records.at(26).Pending.size()==2 && mgr._records.at(26).Pending.front().GoalMap==2); + a.mapid=2;a.m_positionX=100;mgr.Observe(&a,500);assert(mgr._records.at(26).Pending.size()==1 && mgr._records.at(26).Pending.front().GoalMap==3); + // A blocked/unrecognized trip cannot grant unlimited separation protection. + mgr._records.at(26).Pending.clear();state.Kind=AdventureGroupTravelMgr::Journey::None;state.Generation=4;leader.mapid=0;leader.m_positionX=1000;a.mapid=0;a.m_positionX=0;executor.status.State=ClientlessActionExecutionState::Completed; + for(unsigned i=0;i<1200;++i){if(i%2==0)++GameTime::now;mgr.Observe(&a,500);}assert(mgr.Separated(&a));leader.m_positionX=10;mgr.Observe(&a,500);assert(!mgr.Separated(&a)); + // Recorded roads are bounded and validated, never a spline through a cliff. + std::vectorroad;for(unsigned x=0;x<=100;x+=10)road.emplace_back(x,0,0);leader.moving=false;map.cliff=true;assert(!mgr.AdaptRoad(&a,road));map.cliff=false;road.clear();for(unsigned x=0;x<=100;x+=10)road.emplace_back(x,0,0);assert(mgr.AdaptRoad(&a,road) && road.back().GetPositionX()<=60); +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['g++','-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('Production group travel: distinct slots, phase169 cliff safety, width, mounts, flight/kick landing, taxi grace, portal queue, bounded separation and recorded-road validation passed') diff --git a/tests/clientless/test_adventure_interactions.py b/tests/clientless/test_adventure_interactions.py index 068ede218e..9ddc3dce21 100644 --- a/tests/clientless/test_adventure_interactions.py +++ b/tests/clientless/test_adventure_interactions.py @@ -67,6 +67,7 @@ enum class ClientlessActionExecutionState{Moving,SelectingTarget,Completed};stru struct Executor {Execution status;Execution*GetStatus(unsigned){return &status;}}executor;auto sClientlessActionExecutorMgr=&executor; struct Graph {void EnsureLoaded(){}bool FindNearestNodeToPosition(unsigned,float,float,float,std::string&node,float&distance){node="entrance";distance=10;return true;}}graph;auto sClientlessTravelGraphMgr=&graph; struct BotGroups{void Offline(Player*){}void Update(Player*){}bool Accept(Player*,Player*){return false;}bool botLed=false;bool BotLed(Player*){return botLed;}bool Forming(Player*){return false;}bool PlannedTransition(Player*){return false;}void Report(ChatHandler*,Player*){}}botGroups;auto sAdventureGroupMgr=&botGroups; +struct GroupTravel{bool protectedTravel=false,separated=false;int follows=0;bool Follow(Player*p){++follows;return p->GetGroup()!=nullptr;}bool Protected(Player*){return protectedTravel;}bool Separated(Player*){return separated;}void Clear(Player*){}void Offline(Player*){}void Report(ChatHandler*,Player*){}}groupTravel;auto sAdventureGroupTravelMgr=&groupTravel; '''+header+'\n'+source+r''' int main(){ using namespace AdventureInteractionPolicy; @@ -82,7 +83,7 @@ int main(){ bot.invite=&group;tick(100);assert(!bot.group);tick(102);assert(bot.group && accepts==1); assert(mgr._records[26].Grouped && behavior.interrupts==1); // Follow uses verified local MMAP paths; invalid paths never move the player. - leader.m_positionX=20;tick(103);assert(travel.starts==1);travel.active=false;travel.valid=false;leader.m_positionX=30;tick(104);assert(travel.starts==1);travel.valid=true; + leader.m_positionX=20;auto follows=groupTravel.follows;tick(103);assert(groupTravel.follows==follows+1);leader.m_positionX=30;tick(104);assert(groupTravel.follows==follows+2); // Changing leader resets both AFK and inactivity timers. mgr._records[26].AfkSince=1;group.leader=other.guid;tick(105);assert(mgr._records[26].AfkSince==0 && mgr._records[26].LastActivity==105); group.leader=leader.guid;tick(106); @@ -109,7 +110,7 @@ int main(){ // Unreserved bot-origin invitations are declined. mgr._records.clear();group.leader=other.guid;bot.invite=&group;tick(2300);tick(2302);assert(!bot.group && declines==1); // Missing leader map uses the existing graph executor, without teleporting. - group.leader=leader.guid;bot.group=&group;leader.mapid=1;tick(2400);assert(core.queued==1);executor.status.State=ClientlessActionExecutionState::Moving;tick(2416);assert(core.queued==1); + group.leader=leader.guid;bot.group=&group;leader.mapid=1;follows=groupTravel.follows;tick(2400);assert(groupTravel.follows>follows);executor.status.State=ClientlessActionExecutionState::Moving;tick(2416);assert(bot.group); // Pending help is acknowledged only outside combat, after caller's self-care. mgr.CombatHelp(&bot,leader.guid);leader.mapid=0;executor.status.State=ClientlessActionExecutionState::Completed;bot.combat=true;auto says=bot.says;tick(2417);assert(bot.says==says);bot.combat=false;tick(2418);assert(bot.says==says+1); // A newly joined bot follower cancels its previous scene without stopping the leader. diff --git a/tests/clientless/test_flight_handoff.py b/tests/clientless/test_flight_handoff.py index 6b8ebe4899..494fa1d52e 100644 --- a/tests/clientless/test_flight_handoff.py +++ b/tests/clientless/test_flight_handoff.py @@ -11,6 +11,7 @@ update = source[a:b] harness = r''' #include "ClientlessFlightPath.h" #include +#include #include #include #include @@ -26,7 +27,7 @@ struct Spline {int remaining=500; int timeRemaining()const{return remaining;}}; struct Player:Position { Spline spline; Spline* movespline=&spline; bool alive=true,fly=true,running=true;int stops=0; - bool IsInWorld(){return true;} uint32 GetMapId(){return 0;} + bool IsInCombat(){return false;}bool IsInWorld(){return true;} uint32 GetMapId(){return 0;} bool IsAlive(){return alive;} bool IsBeingTeleported(){return false;} bool IsMounted(){return true;} bool CanFly(){return fly;} bool IsSplineEnabled(){return running;} @@ -41,9 +42,9 @@ struct World { auto sWorld=&world; enum class ClientlessFlightState {Mounting,Flying,Arrived,Failed}; struct Record { - struct {ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;Position Landing{800,0,0};float Travelled=0;} Status; + struct {bool FollowingPlayer=false;ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;Position Landing{800,0,0};float Travelled=0;} Status; Position LastPosition,Start,Segment{25,0,60};Settings Settings; - uint32 Elapsed=0,SegmentElapsed=0,CheckTimer=0; + std::shared_ptr Following;uint32 FormationDelay=0,TargetUpdated=0,Elapsed=0,SegmentElapsed=0,CheckTimer=0; bool Moving=true,Returning=false; }; bool clear=true; @@ -55,6 +56,7 @@ public: Surface Sample(Player*,Record&,float,float){return {0,300,true};} void Fail(Player*,Record& r,std::string const&){++failures;r.Status.State=ClientlessFlightState::Failed;} bool Advance(Player*,Record&,bool handoff=false){++advances;handoffs+=handoff;return true;} + bool LandFollowing(Player*){return true;}bool AdvanceFollowing(Player*,Record&){return true;} void Update(uint32 diff); }; '''.replace('Settings Settings;', 'ClientlessFlightPath::Settings Settings;') diff --git a/tests/clientless/test_flight_policy.py b/tests/clientless/test_flight_policy.py index 36f47f9273..0a79ef990c 100644 --- a/tests/clientless/test_flight_policy.py +++ b/tests/clientless/test_flight_policy.py @@ -50,7 +50,7 @@ struct Area{bool fly=true;uint32 World_pvp_ID=0;Flags GetMountFlags()const{retur struct Areas{bool known=true;auto LookupEntry(uint32){return known?&area:nullptr;}} areas;auto& sAreaTableStore=areas; enum class ClientlessFlightState{Mounting,Flying,Arrived,Failed}; struct Record{std::shared_ptrPublished=std::make_shared(); - struct{ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;std::string Detail;}Status; + struct{bool FollowingPlayer=false;ClientlessFlightState State=ClientlessFlightState::Flying;uint32 MapId=0;std::string Detail;}Status; bool Moving=true,Returning=false;std::vector Breadcrumbs{{}};std::string AbortReason; }; class ClientlessFlightMgr{public: diff --git a/tests/clientless/test_ground_exit_continuation.py b/tests/clientless/test_ground_exit_continuation.py index cd0587b5ad..2c172f073d 100644 --- a/tests/clientless/test_ground_exit_continuation.py +++ b/tests/clientless/test_ground_exit_continuation.py @@ -29,6 +29,7 @@ preamble = r''' #include #include #include +#include using uint8=uint8_t; using uint32=uint32_t; using uint64=uint64_t; using int32=int32_t; #define TC_GAME_API #define TC_LOG_INFO(...) ((void)0) @@ -40,6 +41,7 @@ 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(Player const*); + float GetExactDist(Position const&p)const{return std::hypot(x-p.x,y-p.y);} float GetPositionX()const{return x;} float GetPositionY()const{return y;} float GetPositionZ()const{return z;} float GetOrientation()const{return o;} }; @@ -49,7 +51,7 @@ struct Motion { }; struct Player { float x=0; bool spline=true, flying=false;Motion motion; - bool IsFlying()const{return flying;}bool IsSplineEnabled()const{return spline;} + void StopMoving(){spline=false;}bool IsFlying()const{return flying;}bool IsSplineEnabled()const{return spline;} bool IsInWorld()const{return true;}uint32 GetMapId()const{return 0;} float GetPositionX()const{return x;}float GetPositionY()const{return 0;}float GetPositionZ()const{return 0;} float GetOrientation()const{return 0;} @@ -184,6 +186,15 @@ int main(){ air.GroundResumeActive=true;air.GroundExitActive=false;p.x=140;travel.status.State=ClientlessTravelState::Arrived; before=p.motion.launches;mgr.UpdateGroundExit(&p,26,air); assert(!air.GroundResumeActive && p.motion.launches==before && air.Active); + // Owner adapters can reject, wait, or shorten a recorded road without + // losing the original index mapping or launching an unsafe spline. + Record adapted;adapted.Active=true;adapted.EdgePath.resize(5);for(unsigned i=0;i<5;++i)adapted.EdgePath[i].X=150+i*10;p.x=140; + mgr._groundAdapters[26]=[](Player*,std::vector&){return false;};before=p.motion.launches; + assert(!mgr.StartRecordedEdgePath(&p,26,adapted) && p.motion.launches==before); + mgr._groundAdapters[26]=[](Player*,std::vector&path){path.resize(1);return true;}; + assert(mgr.StartRecordedEdgePath(&p,26,adapted) && adapted.FormationWaiting && p.motion.launches==before); + mgr._groundAdapters[26]=[](Player*,std::vector&path){path.resize(3);return true;}; + assert(mgr.StartRecordedEdgePath(&p,26,adapted) && adapted.GroundChunk && !adapted.FormationWaiting && adapted.EdgeSplinePoints.size()==3); std::cout<<"optional exits: repeated probes, retained route, delayed success, forward resume, flight fallback passed\n"; } ''' diff --git a/tests/clientless/test_group_flight_follow.py b/tests/clientless/test_group_flight_follow.py new file mode 100644 index 0000000000..4ed09493b8 --- /dev/null +++ b/tests/clientless/test_group_flight_follow.py @@ -0,0 +1,64 @@ +"""Execute moving-target flight and safe landing adapters over real geometry.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/ClientlessFlightMgr.cpp').read_text() +def method(sig): + a=s.index(sig);b=s.index('{',a);e=b+1;n=1 + while n:n+=(s[e]=='{')-(s[e]=='}');e+=1 + return s[a:e] +code=r''' +#include "ClientlessFlightPath.h" +#include +#include +#include +#include +using uint32=unsigned;using namespace ClientlessFlightPath; +constexpr int MAX_HEIGHT=100000,MOVEMENTFLAG_FLYING=1,MOVE_FLIGHT=2; +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;}float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;}}; +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;}}; +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{ + struct{ClientlessFlightState State=ClientlessFlightState::Flying;bool FollowingPlayer=true;Position Target,Landing;unsigned Segments=0;float Travelled=5000;std::string Detail;}Status; + bool Returning=false,Moving=false;unsigned Elapsed=1000,TargetUpdated=0,SegmentElapsed=0;Position Segment; + ClientlessFlightPath::Settings Settings;ClientlessFlightPath::Memory Memory; + std::shared_ptrFollowing;std::vectorBreadcrumbs; +}; +bool clear=true,valid=true;float ceiling=300; +bool ClearRay(Player*,Point const&,Point const&){return clear;} +struct ClientlessFlightMgr{ + std::unordered_map_records;unsigned beginnings=0,failures=0; + Surface Sample(Player*,Record&,float,float){return {map.floor,ceiling,valid};} + bool Begin(Player*,Position const&,std::string&,bool following){assert(following);++beginnings;return true;} + void Fail(Player*,Record&r,std::string const&){++failures;r.Status.State=ClientlessFlightState::Failed;} + bool Follow(Player*,Position const&,std::string&);bool LandFollowing(Player*);bool AdvanceFollowing(Player*,Record&); +}; +''' +for sig in ['bool ClientlessFlightMgr::Follow(', 'bool ClientlessFlightMgr::LandFollowing(', 'bool ClientlessFlightMgr::AdvanceFollowing(']:code+=method(sig)+'\n' +code+=r''' +int main(){ + ClientlessFlightMgr mgr;Player p;p.z=20;Position target;target.Relocate(100,0,20);std::string error; + assert(mgr.Follow(&p,target,error) && mgr.beginnings==1);auto&r=mgr._records[26]; + assert(mgr.Follow(&p,target,error));assert(r.TargetUpdated==r.Elapsed && r.Status.Target.x==100); + assert(mgr.AdvanceFollowing(&p,r));assert(p.launches==1 && r.Segment.x>0 && r.Segment.x<=25 && r.Segment.z==20); + // Moving the leader updates the destination without inventing a ground landing. + target.Relocate(110,20,30);r.Elapsed=2000;assert(mgr.Follow(&p,target,error));assert(r.Status.Target.z==30 && r.TargetUpdated==2000); + clear=false;assert(!mgr.AdvanceFollowing(&p,r));assert(p.stops==1 && r.Status.State==ClientlessFlightState::Flying); + clear=true;ceiling=25;assert(!mgr.Follow(&p,target,error));ceiling=300; + p.Relocate(110,20,30);r.Moving=true;assert(mgr.AdvanceFollowing(&p,r));assert(!r.Moving && p.stops==2); // holds in air, never dismounts at moving goal + assert(mgr.LandFollowing(&p));assert(!r.Status.FollowingPlayer && r.Status.Landing.z==0 && r.Status.Travelled==0); + assert(!mgr.Follow(&p,target,error)); // landing cannot be replaced by a new pursuit + r.Status.FollowingPlayer=true;r.Status.State=ClientlessFlightState::Mounting;assert(mgr.LandFollowing(&p) && mgr.failures==1); + r.Status.State=ClientlessFlightState::Flying;r.Status.FollowingPlayer=true;valid=false;assert(mgr.LandFollowing(&p) && mgr.failures==2); +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['g++','-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('Moving-target flight: bounded phased corridors, obstacle hold, ceiling rejection, aerial formation hold, safe landing and return fallback passed') diff --git a/tests/clientless/test_transport.py b/tests/clientless/test_transport.py index f8f1572fb7..72f924878a 100644 --- a/tests/clientless/test_transport.py +++ b/tests/clientless/test_transport.py @@ -58,7 +58,7 @@ struct Position { 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));} }; -constexpr int MOVE_RUN=0; +constexpr int MOVE_RUN=0,SPELL_AURA_MOUNTED=1; struct TransportDockState {uint32 Progress=0,Period=0,RemainingPause=0;bool Stopped=false;}; class Player; class GameObject; class TransportBase { @@ -86,6 +86,9 @@ class Player:public Position { public: TransportBase* transport=nullptr;uint32 map=0;GameObject* object=nullptr;Motion motion{this};bool pending=false; struct {struct {Position pos;ObjectGuid guid;uint32 time=0;void Reset(){pos={};guid={};time=0;}}transport;}m_movementInfo; + 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 IsInWorld()const{return true;}bool IsInPhase(GameObject*)const{return true;} bool IsBeingTeleported()const{return pending;}TransportBase* GetTransport()const{return transport;} @@ -98,7 +101,11 @@ void TransportBase::CalculatePassengerOffset(float&x,float&y,float&z,float*o)con void TransportBase::AddPassenger(Player*p){p->transport=this;p->m_movementInfo.transport.guid=go->GetGUID();} void TransportBase::RemovePassenger(Player*p){p->transport=nullptr;p->m_movementInfo.transport.Reset();} void Motion::MoveSmoothPath(int,Position* path,int,bool){p->Relocate(path[1]);if(p->transport){Position pos=*p;p->transport->CalculatePassengerOffset(pos.x,pos.y,pos.z,&pos.o);p->m_movementInfo.transport.pos=pos;}} -struct Travel {void CancelTravel(uint32){}}travel; +enum class ClientlessTravelState{Moving,Arrived,Stuck,PathRejected}; +struct ClientlessPathDiagnostics{bool Complete=true,Suspicious=false;float PathLength=50;}; +struct Travel{bool path=false,present=false;int starts=0;struct Status{ClientlessTravelState State=ClientlessTravelState::Moving;}status; + auto GetStatus(uint32)->Status*{return present?&status:nullptr;}bool AnalyzePath(Player*,Position const&,ClientlessPathDiagnostics&){return path;} + bool StartTravel(Player*p,Position const&point,bool){++starts;present=true;p->Relocate(point);return true;}void CancelTravel(uint32){present=false;}}travel; auto sClientlessTravelMgr=&travel; // Model the real Field API's strict debug type checks for SMALLINT versus INT. struct Field { @@ -120,7 +127,7 @@ methods = '\n'.join(function(source, marker) for marker in [ 'bool ClientlessTransportMgr::HasConnection(', 'bool ClientlessTransportMgr::AtDock(', 'bool ClientlessTransportMgr::Sample(', 'bool ClientlessTransportMgr::Start(', 'void ClientlessTransportMgr::SetPhase(', 'void ClientlessTransportMgr::Cancel(', - 'void ClientlessTransportMgr::UpdateRide(' + 'void ClientlessTransportMgr::RecoverPassenger(', 'void ClientlessTransportMgr::UpdateRide(' ]) stubs = r''' void PublishPassengerState(Player*){} @@ -203,6 +210,17 @@ int main(){ run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Riding&&sailor.transport==&destination.base); destination.x=100;sailor.x=101; run(ships,sailor,crossing);assert(crossing.Status.Phase==Phase::Arrived&&!sailor.transport&&sailor.x==104&&sailor.map==0); + // A lost, previously boarded passenger returns to the recorded departure, + // never to the moving leader or the destination harbor. + Player lost;lost.object=&go;lost.x=50;auto recovery=mgr._rides.at(26); + recovery.Status.Phase=Phase::Riding;recovery.Status.Recovering=false;recovery.Abort=false; + travel.path=false;mgr.UpdateRide(&lost,recovery,100); + assert(recovery.Status.Recovering && lost.teleports==1 && lost.x==0); + mgr.UpdateRide(&lost,recovery,100);assert(!recovery.Status.Recovering && recovery.Status.Phase==Phase::Waiting); + // A valid walking return uses native point travel, without teleporting. + 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); std::cout<<"Transport dock, frame, recorder, cross-map copy and ride regressions passed\n"; } '''