From 373818628d13c750d34201247898bd522321010e Mon Sep 17 00:00:00 2001 From: nico Date: Fri, 30 Oct 2026 02:15:06 +0100 Subject: [PATCH] Implement instance modes, full-group departures and mandatory flight regions --- doc/clientless/SESSION-RESUME.md | 139 +++++++++ .../oom-incident.log | 2 + .../validation.json | 280 ++++++++++++++++++ .../vcore-flight-regions-build.log | 4 + .../vcore-flight-regions-tests.json | 278 +++++++++++++++++ .../live-validation.json | 39 +++ .../vcore-northrend-flight-final-build.log | 4 + .../vcore-northrend-flight-final-install.log | 16 + .../vcore-northrend-flight-tests.json | 278 +++++++++++++++++ .../group-departure-and-instance-modes.md | 59 ++++ .../northrend-flight-requirement.md | 53 ++++ doc/clientless/zeppelin-orgrimmar-warsong.md | 41 +++ .../2026_10_29_08_adventure_group_handoff.sql | 10 + src/server/game/World/AdventureAreaMgr.cpp | 14 +- .../game/World/AdventureBehaviorMgr.cpp | 175 ++++++++++- src/server/game/World/AdventureBehaviorMgr.h | 6 + .../game/World/AdventureGeneratorMgr.cpp | 23 +- src/server/game/World/AdventureGroupMgr.cpp | 49 ++- src/server/game/World/AdventureGroupMgr.h | 5 +- src/server/game/World/AdventureGroupPolicy.h | 11 + .../game/World/AdventureInstancePolicy.h | 2 +- .../game/World/AdventureLocationMgr.cpp | 83 ++++-- src/server/game/World/AdventureLocationMgr.h | 8 +- .../game/World/AdventurePopulationMgr.cpp | 7 +- src/server/game/World/ClientlessLuaEngine.cpp | 8 + .../test_adventure_destination_level.py | 11 +- .../test_adventure_graph_handover.py | 2 +- .../test_adventure_group_departure.py | 104 +++++++ tests/clientless/test_adventure_group_plan.py | 6 +- tests/clientless/test_adventure_groups.py | 36 ++- tests/clientless/test_adventure_instance.py | 9 +- .../test_adventure_instance_begin.py | 3 +- .../test_adventure_instance_persistence.py | 2 + .../test_adventure_instance_variants.py | 67 +++++ .../clientless/test_adventure_intent_state.py | 4 +- .../test_adventure_location_import.py | 4 +- .../test_adventure_navigation_rescue.py | 2 +- .../test_adventure_northrend_flight.py | 72 +++++ tests/clientless/test_adventure_population.py | 2 +- .../test_adventure_profession_profile.py | 2 +- 40 files changed, 1838 insertions(+), 82 deletions(-) create mode 100644 doc/clientless/checkpoints/2026-10-29-group-departure/oom-incident.log create mode 100644 doc/clientless/checkpoints/2026-10-29-group-departure/validation.json create mode 100644 doc/clientless/checkpoints/2026-10-30-flight-regions/vcore-flight-regions-build.log create mode 100644 doc/clientless/checkpoints/2026-10-30-flight-regions/vcore-flight-regions-tests.json create mode 100644 doc/clientless/checkpoints/2026-10-30-northrend-flight/live-validation.json create mode 100644 doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-final-build.log create mode 100644 doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-final-install.log create mode 100644 doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-tests.json create mode 100644 doc/clientless/group-departure-and-instance-modes.md create mode 100644 doc/clientless/northrend-flight-requirement.md create mode 100644 doc/clientless/zeppelin-orgrimmar-warsong.md create mode 100644 sql/custom/world/2026_10_29_08_adventure_group_handoff.sql create mode 100644 tests/clientless/test_adventure_group_departure.py create mode 100644 tests/clientless/test_adventure_instance_variants.py create mode 100644 tests/clientless/test_adventure_northrend_flight.py diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 8c99eaa94b..51609408f3 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,142 @@ +# ACTIVE USER REQUEST: commit flight-region extension, repeat groups, observe until04:00CET + +Current dateOct30; user requires flight not only Northrend but also Outland and +Cataclysm regions. Implemented AreaMgr guard for catalog prefer_flight regions; +keeps map530 racial low-level zones separate. Cold68+ onlyNorthrend; otherflight +regions require60+, actual native flight,90267 outside530/571. Build-j10 and69 +suite tests passed, logs copied to checkpoints/2026-10-30-flight-regions. Commit +all pending requested work before starting group test, no push. Still need deploy +this final small AreaMgr change (previous broader feature already installed). + +Then repeat group test: current local hour(groupformation) atAllianceStormwind/ +HordeOrgrimmar, next hour mixed valid normal/heroic dungeons/raids. Exclude bots +below15 as user previously requested. IncludeArthoran26 plus110readygenerated. +Preparation script/tmp/vcore-repeat-group-test.py prepare running (log +/tmp/vcore-group-test-prepare.log). It builds native map/mode bands, uses exact +server eligibility, logs in offline candidates as needed, falls back from raids +without valid entrances to suitable dungeons. Output/tmp/vcore-group-test-20261030/ +state.json. NO test DB/config edits yet; prepare onlynativechecks/logins. + +Next steps: ensure prepare completes; run script pause (pauses loader, rotation0, +requests participant logout); shutdown current385679 via dedicated session; wait +full exit (oldknown shutdownSIGSEGVpossible); script apply edits onlytwo current +andnextday slots, teleports offlineparticipants factioncapitals, clearsvisit/ghost/ +oldhandoff state, restores loader enabled1 but rotation remains0 during test. +Install compiled finalregionalbinary, startfreshdedicatedtmux, verifyreadyports. +RotationPercent previous15 saved in /tmp/vcore-group-test-20261030/worldserver.conf.before +(contains secrets! Never print/copy into repo). Restore onlyRotationPercent at04:00. + +Observe bots+logs+server until **2026-10-30 04:00 Europe/Berlin =03:00UTC**, report +then. Clock atinitialrequest01:06UTC(local02:06). Must actually monitor, not final +prematurely. Currentserver385679/sessionvcore-worldserver console +/tmp/vcore-northrend-flight-final-start-20261030.log. Olduser `vcore` session is +shell only; leave it. No secondworldserver. User insists all builds -j10; own build +processes inherit oom_score_adj1000 to prefer build termination over server if +memory exhausted. No running build now. Currentmanifest prepare needsfollow-up. + +# Latest: mandatory flight build INSTALLED, final server READY + +User says no ground-only bots in Northrend. New source+previous pending instance +modes/group-handoff WIP installed; worldSQL08 applied. Final helper uses existing +native FlightMgr::Enabled, not a direct mounted-speed aura assumption (initial +live mistake fixed). All builds -j10,69 tests passed with updated fixtures and +production native-flight harness. Final dedicated tmuxvcore-worldserver PID385679, +ready12s,8085+8087 listening. Console +/tmp/vcore-northrend-flight-final-start-20261030.log. Build/install/tests saved +under checkpoints/2026-10-30-northrend-flight. No commit/push requested. + +Old manually started371982 sessionvcore retained as user's shell after known +shutdownSIGSEGV. Intermediate383849 stopped before final install. Do not start a +second server. Recording hold released by user earlier. Zeppelin1826 and all +recordings unchanged. Final native area probes PASS:26 Arthoran60 ship_valiance_kraken allowed0; +55Zanzul82 zeppelin_warsong allowed1. DB after saveall:26riding225,license1, +cold0,flightmounts1;55riding225,license1,cold1,flightmounts3. No final +mandatory flight/BOT_MANAGER_CHECK errors observed.35/36not used (offline). + +# Active: mandatory Northrend flight; final correction built, deploy/verify next + +User forbids ground-only bots anywhere in Northrend; all60+ autonomous bots must +have basic flight,68+ cold weather. Implemented profile defaults (legacy too), +legacy manager maintenance without inventory cleaning, actual native provisioning, +map571 admission guard requiring68+54197+FlightMgr::Enabled. No DungeonBot changes. +See northrend-flight-requirement.md. Added production harness with native +FlightType/Enabled and69 tests (two fixture fixes passed separately). + +User finished recording. Explicitly announced install/restart and deployed prior +ready instance-mode/handoff WIP together with flight policy; applied worldSQL08. +Old manual sessionvcore371982 stopped, encountered known shutdown SIGSEGV (console +preserved in vcore); installed, started dedicated vcore-worldserver PID383849, +ready12s +8085/8087 verified. Live Arthoran26 revealed helper's direct-speed-aura +mount validation was wrong for native Cataclysm mount data. Fixed ProvisionFlight +to reuse ClientlessFlightMgr::Enabled. Final rebuild -j10 log +/tmp/vcore-northrend-flight-final-build.log currently linking; wait/check before +redeploy. Do NOT claim final correction live until installed. Old373849 remains +running (actual PID383849), installation must wait for its full shutdown. +Earlier first build log /tmp/vcore-northrend-flight-build.log; install log +/tmp/vcore-northrend-flight-install.log; first-start console +/tmp/vcore-northrend-flight-start-20261030.log. SQL08 already applied, do not lose +user's new Zeppelin edge1826/recordings. No push/commit requested. All builds -j10. + +# Latest: recorded Orgrimmar–Warsong Zeppelin activated live, no restart + +User finished recording routes and asked to validate/activate new Horde Northrend +zeppelin. Existing manual server371982 runs in tmux session `vcore` (NOT +`vcore-worldserver`). No restart/install performed. Previous source feature build +is successful but still undeployed; migration08 remains unapplied. + +Validated native service12 entry186238 The Mighty Wind, period298828ms, two docks +and two map legs. Both recorded stops load natively: zeppelin_org_northrend(map1, +5points,9.45yd) and zeppelin_warsong(map571,10points,16.70yd), same stand +mighty_wind_center; deck endpoints within3yd. Core link command successfully +created enabled bidirectional edge1826, zeppelin,cost100. Added explicit Horde-only +allowed_factions2 to both nodes; adventure catalogs+graph reloaded live. +Original recordings unchanged. Source connected via ground edge1819 to +orgrimmar_zeppelintower1. Destination has no recorded onward ground edge yet; +nearest taxi257 is on another storey, so do not invent an untested straight edge. +Native timetable pauses60s at each dock. Native node permission probes with +36Edwin85 allowed both,35Karggan85 denied both (nodeMask2). No forced bot +relocation, no claimed live passenger ride acceptance. + +# Latest user instruction: retain -j10 for builds + +User explicitly requires builds with -j10, including after crashes. Do not replace +this with -j1/-j2 as a standing policy. This supersedes the temporary lower-job +recommendations below. Final implementation build already succeeded Oct30 +01:45:11; latest BehaviorMgr object is newer than source. All67 Adventure tests +passed; latest intent-state, departure and native-DBC variant tests rechecked. +No migration applied, no install/restart, no commit/push. Existing server371982 +remains running with unchanged installed binary. Deployment hold still applies. + +# Latest: instance modes / full-group early departure — deployment HOLD + +User requested implementation of dungeon upper levels (raid minimum-only), native +normal/heroic dungeon+raid planning and early departure of complete groups. +USER RECORDING ROUTES: DO NOT INSTALL, APPLY SQL OR RESTART until hold released. +Current source WIP is intentional, not unexpected changes; preserve it. + +Added logical per-mode destination variants reusing physical BaseKey; native LFG +map/mode bands, max+5 dungeon tolerance, raid min-only, reject zero-capacity native +placeholders, native difficulty session handlers and mode/capacity inheritance. +Added per-bot persistent current-block copy in world migration08, actual-members +full detection (human-led too), min(now+300, original hour end, armed source end), +existing gather micro handling, no kick/vacancy rollback, unchanged next day slot, +restart restore/expiry/tempbot cleanup and explicit admin command cancellation. +See group-departure-and-instance-modes.md and tests/checkpoint. + +IMPORTANT INCIDENT: Initial -j10 compilation alongside installed worldserver used +all VM RAM; kernel OOM-killed worldserver362937 at2026-10-29 23:36:38 CET. No +shutdown/restart/install was issued by agent. User informed with cause/apology. +At interruption worldserver was OFFLINE. On Oct30 resumption, found an externally +started worldserver371982 (startedOct29 23:57:44). Do not touch it. Installed +executable remains21:21:03 Oct29 unchanged. +Do not restart under recording hold, even to restore the old binary. Future builds +must follow the user-mandated -j10. The temporary -j1 resumption build is +historical and must not be treated as a new build policy. No unrelated service touched. + +Final build log /tmp/vcore-group-difficulty-final-build.log; test results +/tmp/vcore-group-difficulty-tests.json. Completed successfully Oct30 01:45:11, final object/source timestamps checked. +No SQL08 applied, no live gameplay verification, no commit/push requested. + # Commit checkpoint: transport resumption, deck return and capital portals User requested a local commit of all completed changes since19075b6c75. Includes diff --git a/doc/clientless/checkpoints/2026-10-29-group-departure/oom-incident.log b/doc/clientless/checkpoints/2026-10-29-group-departure/oom-incident.log new file mode 100644 index 0000000000..a6852c6229 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-group-departure/oom-incident.log @@ -0,0 +1,2 @@ +Oct 29 23:36:38 vcore-test kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/user.slice/user-1000.slice/user@1000.service/tmux-spawn-c0aac031-fb9c-46cd-b1a7-eba85fc42191.scope,task=worldserver,pid=362937,uid=1000 +Oct 29 23:36:38 vcore-test kernel: Out of memory: Killed process 362937 (worldserver) total-vm:11848452kB, anon-rss:5819912kB, file-rss:3156kB, shmem-rss:0kB, UID:1000 pgtables:19928kB oom_score_adj:0 diff --git a/doc/clientless/checkpoints/2026-10-29-group-departure/validation.json b/doc/clientless/checkpoints/2026-10-29-group-departure/validation.json new file mode 100644 index 0000000000..53ee5e2521 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-29-group-departure/validation.json @@ -0,0 +1,280 @@ +{ + "adventure_suite": [ + [ + "test_adventure_supply_catalog.py", + 0 + ], + [ + "test_adventure_instance.py", + 0 + ], + [ + "test_adventure_encounter.py", + 0 + ], + [ + "test_adventure_economy.py", + 0 + ], + [ + "test_adventure_world_presence.py", + 0 + ], + [ + "test_adventure_supplies.py", + 0 + ], + [ + "test_adventure_equipment_maintenance.py", + 0 + ], + [ + "test_adventure_pvp.py", + 0 + ], + [ + "test_adventure_combat_reactions.py", + 0 + ], + [ + "test_adventure_rest_targets.py", + 0 + ], + [ + "test_adventure_recovery_policy.py", + 0 + ], + [ + "test_adventure_graph_handover.py", + 0 + ], + [ + "test_adventure_exploration.py", + 0 + ], + [ + "test_adventure_rotation.py", + 0 + ], + [ + "test_adventure_interactions.py", + 0 + ], + [ + "test_adventure_group_plan.py", + 0 + ], + [ + "test_adventure_micro_reset.py", + 0 + ], + [ + "test_adventure_rest_space.py", + 0 + ], + [ + "test_adventure_destination_level.py", + 0 + ], + [ + "test_adventure_combat_exercise.py", + 0 + ], + [ + "test_adventure_training_catalog.py", + 0 + ], + [ + "test_adventure_native_lfg.py", + 0 + ], + [ + "test_adventure_area_policy.py", + 0 + ], + [ + "test_adventure_instance_variants.py", + 0 + ], + [ + "test_adventure_instance_loader.py", + 0 + ], + [ + "test_adventure_ground_avoidance.py", + 0 + ], + [ + "test_adventure_repair_catalog.py", + 0 + ], + [ + "test_adventure_profession_profile.py", + 0 + ], + [ + "test_adventure_navigation_rescue.py", + 0 + ], + [ + "test_adventure_generator_ownership.py", + 0 + ], + [ + "test_adventure_instance_autoresume.py", + 0 + ], + [ + "test_adventure_economy_catalog.py", + 0 + ], + [ + "test_adventure_async_database.py", + 0 + ], + [ + "test_adventure_profession_targets.py", + 0 + ], + [ + "test_adventure_area_segment.py", + 0 + ], + [ + "test_adventure_intent_state.py", + 0 + ], + [ + "test_adventure_talent_templates.py", + 0 + ], + [ + "test_adventure_location_import.py", + 0 + ], + [ + "test_adventure_profession_catalog.py", + 0 + ], + [ + "test_adventure_walk.py", + 0 + ], + [ + "test_adventure_group_gather.py", + 0 + ], + [ + "test_adventure_profile_selection.py", + 0 + ], + [ + "test_adventure_social.py", + 0 + ], + [ + "test_adventure_rest.py", + 0 + ], + [ + "test_adventure_exploration_catalog.py", + 0 + ], + [ + "test_adventure_supply_items.py", + 0 + ], + [ + "test_adventure_instance_persistence.py", + 0 + ], + [ + "test_adventure_profession_sightline.py", + 0 + ], + [ + "test_adventure_population.py", + 0 + ], + [ + "test_adventure_rest_fire.py", + 0 + ], + [ + "test_adventure_retry_policy.py", + 0 + ], + [ + "test_adventure_manager_inventory.py", + 0 + ], + [ + "test_adventure_instance_catalog.py", + 0 + ], + [ + "test_adventure_combat_preparation.py", + 0 + ], + [ + "test_adventure_social_fire.py", + 0 + ], + [ + "test_adventure_leisure.py", + 0 + ], + [ + "test_adventure_instance_begin.py", + 0 + ], + [ + "test_adventure_day_persistence.py", + 0 + ], + [ + "test_adventure_behavior_policy.py", + 0 + ], + [ + "test_adventure_groups.py", + 0 + ], + [ + "test_adventure_leisure_catalog.py", + 0 + ], + [ + "test_adventure_group_departure.py", + 0 + ], + [ + "test_adventure_generator_policy.py", + 0 + ], + [ + "test_adventure_group_travel.py", + 0 + ], + [ + "test_adventure_area_node.py", + 0 + ], + [ + "test_adventure_class_rotation.py", + 0 + ], + [ + "test_adventure_generator_equipment.py", + 0 + ] + ], + "latest_change_rechecked": [ + "test_adventure_intent_state.py", + "test_adventure_group_departure.py", + "test_adventure_instance_variants.py" + ], + "deployed": false, + "sql_applied": false, + "gameplay_verified": false +} diff --git a/doc/clientless/checkpoints/2026-10-30-flight-regions/vcore-flight-regions-build.log b/doc/clientless/checkpoints/2026-10-30-flight-regions/vcore-flight-regions-build.log new file mode 100644 index 0000000000..c84d0afca3 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-30-flight-regions/vcore-flight-regions-build.log @@ -0,0 +1,4 @@ +[1/4] 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/4] Building CXX object src/server/game/CMakeFiles/game.dir/World/AdventureAreaMgr.cpp.o +[3/4] Linking CXX static library src/server/game/libgame.a +[4/4] Linking CXX executable src/server/worldserver/worldserver diff --git a/doc/clientless/checkpoints/2026-10-30-flight-regions/vcore-flight-regions-tests.json b/doc/clientless/checkpoints/2026-10-30-flight-regions/vcore-flight-regions-tests.json new file mode 100644 index 0000000000..ff29864ccd --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-30-flight-regions/vcore-flight-regions-tests.json @@ -0,0 +1,278 @@ +[ + [ + "test_adventure_supply_catalog.py", + 0 + ], + [ + "test_adventure_instance.py", + 0 + ], + [ + "test_adventure_encounter.py", + 0 + ], + [ + "test_adventure_economy.py", + 0 + ], + [ + "test_adventure_world_presence.py", + 0 + ], + [ + "test_adventure_supplies.py", + 0 + ], + [ + "test_adventure_equipment_maintenance.py", + 0 + ], + [ + "test_adventure_pvp.py", + 0 + ], + [ + "test_adventure_combat_reactions.py", + 0 + ], + [ + "test_adventure_rest_targets.py", + 0 + ], + [ + "test_adventure_recovery_policy.py", + 0 + ], + [ + "test_adventure_graph_handover.py", + 0 + ], + [ + "test_adventure_exploration.py", + 0 + ], + [ + "test_adventure_rotation.py", + 0 + ], + [ + "test_adventure_interactions.py", + 0 + ], + [ + "test_adventure_group_plan.py", + 0 + ], + [ + "test_adventure_micro_reset.py", + 0 + ], + [ + "test_adventure_rest_space.py", + 0 + ], + [ + "test_adventure_destination_level.py", + 0 + ], + [ + "test_adventure_combat_exercise.py", + 0 + ], + [ + "test_adventure_training_catalog.py", + 0 + ], + [ + "test_adventure_native_lfg.py", + 0 + ], + [ + "test_adventure_area_policy.py", + 0 + ], + [ + "test_adventure_instance_variants.py", + 0 + ], + [ + "test_adventure_instance_loader.py", + 0 + ], + [ + "test_adventure_ground_avoidance.py", + 0 + ], + [ + "test_adventure_repair_catalog.py", + 0 + ], + [ + "test_adventure_profession_profile.py", + 0 + ], + [ + "test_adventure_navigation_rescue.py", + 0 + ], + [ + "test_adventure_northrend_flight.py", + 0 + ], + [ + "test_adventure_generator_ownership.py", + 0 + ], + [ + "test_adventure_instance_autoresume.py", + 0 + ], + [ + "test_adventure_economy_catalog.py", + 0 + ], + [ + "test_adventure_async_database.py", + 0 + ], + [ + "test_adventure_profession_targets.py", + 0 + ], + [ + "test_adventure_area_segment.py", + 0 + ], + [ + "test_adventure_intent_state.py", + 0 + ], + [ + "test_adventure_talent_templates.py", + 0 + ], + [ + "test_adventure_location_import.py", + 0 + ], + [ + "test_adventure_profession_catalog.py", + 0 + ], + [ + "test_adventure_walk.py", + 0 + ], + [ + "test_adventure_group_gather.py", + 0 + ], + [ + "test_adventure_profile_selection.py", + 0 + ], + [ + "test_adventure_social.py", + 0 + ], + [ + "test_adventure_rest.py", + 0 + ], + [ + "test_adventure_exploration_catalog.py", + 0 + ], + [ + "test_adventure_supply_items.py", + 0 + ], + [ + "test_adventure_instance_persistence.py", + 0 + ], + [ + "test_adventure_profession_sightline.py", + 0 + ], + [ + "test_adventure_population.py", + 0 + ], + [ + "test_adventure_rest_fire.py", + 0 + ], + [ + "test_adventure_retry_policy.py", + 0 + ], + [ + "test_adventure_manager_inventory.py", + 0 + ], + [ + "test_adventure_instance_catalog.py", + 0 + ], + [ + "test_adventure_combat_preparation.py", + 0 + ], + [ + "test_adventure_social_fire.py", + 0 + ], + [ + "test_adventure_leisure.py", + 0 + ], + [ + "test_adventure_instance_begin.py", + 0 + ], + [ + "test_adventure_day_persistence.py", + 0 + ], + [ + "test_adventure_behavior_policy.py", + 0 + ], + [ + "test_adventure_groups.py", + 0 + ], + [ + "test_adventure_leisure_catalog.py", + 0 + ], + [ + "test_adventure_group_departure.py", + 0 + ], + [ + "test_adventure_generator_policy.py", + 0 + ], + [ + "test_adventure_group_travel.py", + 0 + ], + [ + "test_adventure_area_node.py", + 0 + ], + [ + "test_adventure_class_rotation.py", + 0 + ], + [ + "test_adventure_generator_equipment.py", + 0 + ], + [ + "test_flight_area_query.py", + 0 + ] +] \ No newline at end of file diff --git a/doc/clientless/checkpoints/2026-10-30-northrend-flight/live-validation.json b/doc/clientless/checkpoints/2026-10-30-northrend-flight/live-validation.json new file mode 100644 index 0000000000..2428697684 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-30-northrend-flight/live-validation.json @@ -0,0 +1,39 @@ +{ + "pid": 385679, + "ready_seconds": 12, + "ports": [ + 8085, + 8087 + ], + "area_probes": [ + { + "guid": 26, + "level": 60, + "map": 571, + "allowed": false + }, + { + "guid": 55, + "level": 82, + "map": 571, + "allowed": true + } + ], + "saved_training": [ + { + "guid": 26, + "riding": 225, + "license": true, + "cold_weather": false, + "flight_mounts": 1 + }, + { + "guid": 55, + "riding": 225, + "license": true, + "cold_weather": true, + "flight_mounts": 3 + } + ], + "full_zeppelin_ride_tested": false +} diff --git a/doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-final-build.log b/doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-final-build.log new file mode 100644 index 0000000000..5ae1ba5952 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-final-build.log @@ -0,0 +1,4 @@ +[1/4] 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/4] Building CXX object src/server/game/CMakeFiles/game.dir/World/AdventureGeneratorMgr.cpp.o +[3/4] Linking CXX static library src/server/game/libgame.a +[4/4] Linking CXX executable src/server/worldserver/worldserver diff --git a/doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-final-install.log b/doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-final-install.log new file mode 100644 index 0000000000..5b22a9c17b --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-final-install.log @@ -0,0 +1,16 @@ +-- Install configuration: "Debug" +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/authserver +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/etc/authserver.conf.dist +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless/tests +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless/tests/smoke.lua +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless/adventure +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless/adventure/generic.lua +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless/adventure/overrides +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless/adventure/overrides/arthoran.lua +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless/adventure/profiles +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless/adventure/profiles/generic.lua +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/bin/scripts/clientless/adventure/profiles/arthoran.lua +-- Installing: /home/wow_admin/wow_cata_server/server/debug/bin/worldserver +-- Set non-toolchain portion of runtime path of "/home/wow_admin/wow_cata_server/server/debug/bin/worldserver" to "/home/wow_admin/wow_cata_server/server/debug/lib" +-- Up-to-date: /home/wow_admin/wow_cata_server/server/debug/etc/worldserver.conf.dist diff --git a/doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-tests.json b/doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-tests.json new file mode 100644 index 0000000000..e50b68b8ed --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-30-northrend-flight/vcore-northrend-flight-tests.json @@ -0,0 +1,278 @@ +[ + [ + "test_adventure_supply_catalog.py", + 0 + ], + [ + "test_adventure_instance.py", + 0 + ], + [ + "test_adventure_encounter.py", + 0 + ], + [ + "test_adventure_economy.py", + 0 + ], + [ + "test_adventure_world_presence.py", + 0 + ], + [ + "test_adventure_supplies.py", + 0 + ], + [ + "test_adventure_equipment_maintenance.py", + 0 + ], + [ + "test_adventure_pvp.py", + 0 + ], + [ + "test_adventure_combat_reactions.py", + 0 + ], + [ + "test_adventure_rest_targets.py", + 0 + ], + [ + "test_adventure_recovery_policy.py", + 0 + ], + [ + "test_adventure_graph_handover.py", + 0 + ], + [ + "test_adventure_exploration.py", + 0 + ], + [ + "test_adventure_rotation.py", + 0 + ], + [ + "test_adventure_interactions.py", + 0 + ], + [ + "test_adventure_group_plan.py", + 0 + ], + [ + "test_adventure_micro_reset.py", + 0 + ], + [ + "test_adventure_rest_space.py", + 0 + ], + [ + "test_adventure_destination_level.py", + 0 + ], + [ + "test_adventure_combat_exercise.py", + 0 + ], + [ + "test_adventure_training_catalog.py", + 0 + ], + [ + "test_adventure_native_lfg.py", + 0 + ], + [ + "test_adventure_area_policy.py", + 0 + ], + [ + "test_adventure_instance_variants.py", + 0 + ], + [ + "test_adventure_instance_loader.py", + 0 + ], + [ + "test_adventure_ground_avoidance.py", + 0 + ], + [ + "test_adventure_repair_catalog.py", + 0 + ], + [ + "test_adventure_profession_profile.py", + 0 + ], + [ + "test_adventure_navigation_rescue.py", + 0 + ], + [ + "test_adventure_northrend_flight.py", + 0 + ], + [ + "test_adventure_generator_ownership.py", + 0 + ], + [ + "test_adventure_instance_autoresume.py", + 0 + ], + [ + "test_adventure_economy_catalog.py", + 0 + ], + [ + "test_adventure_async_database.py", + 0 + ], + [ + "test_adventure_profession_targets.py", + 0 + ], + [ + "test_adventure_area_segment.py", + 0 + ], + [ + "test_adventure_intent_state.py", + 0 + ], + [ + "test_adventure_talent_templates.py", + 0 + ], + [ + "test_adventure_location_import.py", + 0 + ], + [ + "test_adventure_profession_catalog.py", + 0 + ], + [ + "test_adventure_walk.py", + 0 + ], + [ + "test_adventure_group_gather.py", + 0 + ], + [ + "test_adventure_profile_selection.py", + 0 + ], + [ + "test_adventure_social.py", + 0 + ], + [ + "test_adventure_rest.py", + 0 + ], + [ + "test_adventure_exploration_catalog.py", + 0 + ], + [ + "test_adventure_supply_items.py", + 0 + ], + [ + "test_adventure_instance_persistence.py", + 0 + ], + [ + "test_adventure_profession_sightline.py", + 0 + ], + [ + "test_adventure_population.py", + 0 + ], + [ + "test_adventure_rest_fire.py", + 0 + ], + [ + "test_adventure_retry_policy.py", + 0 + ], + [ + "test_adventure_manager_inventory.py", + 0 + ], + [ + "test_adventure_instance_catalog.py", + 0 + ], + [ + "test_adventure_combat_preparation.py", + 0 + ], + [ + "test_adventure_social_fire.py", + 0 + ], + [ + "test_adventure_leisure.py", + 0 + ], + [ + "test_adventure_instance_begin.py", + 0 + ], + [ + "test_adventure_day_persistence.py", + 0 + ], + [ + "test_adventure_behavior_policy.py", + 0 + ], + [ + "test_adventure_groups.py", + 0 + ], + [ + "test_adventure_leisure_catalog.py", + 0 + ], + [ + "test_adventure_group_departure.py", + 0 + ], + [ + "test_adventure_generator_policy.py", + 0 + ], + [ + "test_adventure_group_travel.py", + 0 + ], + [ + "test_adventure_area_node.py", + 0 + ], + [ + "test_adventure_class_rotation.py", + 0 + ], + [ + "test_adventure_generator_equipment.py", + 0 + ], + [ + "test_flight_area_query.py", + 0 + ] +] diff --git a/doc/clientless/group-departure-and-instance-modes.md b/doc/clientless/group-departure-and-instance-modes.md new file mode 100644 index 0000000000..e37b0837be --- /dev/null +++ b/doc/clientless/group-departure-and-instance-modes.md @@ -0,0 +1,59 @@ +# Planned instance modes and full-group departure + +Instance destinations now use native Cataclysm LFG level bands per map and +supported difficulty, rather than the area level around the exterior entrance. +Dungeons allow at most five levels above the native recommended maximum; raids +only require the native minimum. Normal Shadowfang's obsolete84–85 beta LFG row +must not widen its normal16–26 band. Native access requirements can tighten bands. +Unknown dungeon bands are excluded from automatic selection. + +The location catalog exposes logical variants of the same physical entrance: +base key for normal dungeon / preferred normal raid mode, `_dN` for other native +modes. `BaseKey` retains the actual recorded worldpoint and instance mapping. +Both persistent day slots and visit records already store these logical keys, +so selected normal/heroic difficulty survives logout without extra day columns. +Only native modes with nonzero capacity are selectable; zero-capacity placeholder +entries do not manufacture heroic versions of classic raids. Dungeon groups have +five slots;10-player raids ten and larger raids at most twenty, as requested. +Recruitment names include normal/heroic and native size. Bot leaders set the mode +through the native session handler; human leaders retain control of difficulty. + +A full group means actual joined members, not pending invitations. Each forming +bot snapshots the recruiting leader's next intent/location. The gather deadline +is the earlier of now+300s, the original formation-hour end and an already armed +leader's deadline. Full14:20 departs14:25; full14:57 still departs15:00. Existing +safe micro completion, return to meeting point and travel/combat safeguards apply. +A vacancy or kick does not revert an already copied current slot. The original +next `vcore_adventure_day` row remains untouched. A detached bot resumes that +original next slot at its boundary; an ongoing group continues following its +leader. The copy retains the next activity's planned end for a continuous visit. + +World migration `2026_10_29_08_adventure_group_handoff.sql` stores the temporary +copy and deadlines. Arming requires a successful transaction; missing storage +fails closed. Login restores the copy; expiry and temporary-character deletion +clean it up. Explicit console intent/schedule/stop commands cancel the copy. +Player-led groups are supported and retain the formation AFK exemption. The early +transition has a one-time intent-change leave exemption, including human leaders. + +## Release state + +Source/build only. User is recording routes: no install, migration application, +or restart is authorized in this step. Apply migration08 before the new binary +and loader run. In-game normal/heroic entry, gathering and departure still require +acceptance testing after deployment. + +During the initial -j10 build, VM memory exhaustion caused Linux to kill the +existing worldserver362937 at2026-10-29 23:36:38 CET. This was not a requested +shutdown or an application crash. Kernel evidence is preserved in this step's +checkpoint. The user was informed; no restart was attempted under the recording +hold. The final resumption build used temporary reduced parallelism and succeeded. +The user explicitly requires future builds with -j10 even after crashes; the +temporary reduction must not be treated as a standing policy. + +On Oct30 resumption an externally started worldserver371982 was found running +(startOct29 23:57:44). Agent left it untouched. The last source correction is +rebuilt with -j1 to limit memory while it runs. Installed binary remains unchanged. + +Final build succeeded Oct30 01:45:11. All67 AdventureBot tests passed; the last +intent-state, departure and native-DBC tests were rechecked after the final change. +No installation, migration application or restart was performed. diff --git a/doc/clientless/northrend-flight-requirement.md b/doc/clientless/northrend-flight-requirement.md new file mode 100644 index 0000000000..2f91af8ec2 --- /dev/null +++ b/doc/clientless/northrend-flight-requirement.md @@ -0,0 +1,53 @@ +# Mandatory flight for Northrend AdventureBots + +User requirement: no ground-only AdventureBots in Northrend. Autonomous bots +receive basic flying at level60, and cold weather flying at level68. This covers +legacy named/manual profiles as well as generated profiles. + +Profile loading enables free flight at target level60+ and supplies a faction +appropriate gryphon32235 / wyvern32243 if the flight-mount list is empty. Existing +mount choices and weights are preserved. The manager checks legacy autonomous +bots too, while preserving their manual inventories and equipment. + +Reconciliation supplies native riding spells33388/33391/34090, skill225 minimum, +Flight Master's License90267 and (level68+) Cold Weather Flying54197, and learns +profile mounts. The capability check reuses ClientlessFlightMgr::Enabled and its +native mount-type data; Cataclysm mounts must not be classified by assuming a +mounted-flight-speed aura resides directly on the mount spell. + +AdventureAreaMgr::AllowsPosition denies all map571 destinations to managed bots +below68, missing54197 or lacking actual learned profile flight capability. This +applies equally to day planning, graph nodes, ships/zeppelins and portals. It +checks capability, not whether the bot currently flies: walking inside buildings, +boarding and safe disembarkation remain possible. Ordinary players and independent +DungeonBots without Adventure profiles remain unaffected. Area/faction bans still +apply in addition. Flight configuration disabled means no AdventureBot admission +to Northrend, rather than a ground-only fallback there. + +Compiled and linked with user-required -j10;69 tests including the production +profile/default, training/repair and map571 guard harness passed. Initial live +legacy provision check exposed the incorrect direct-aura classification; this +was corrected to the existing native flight manager before final deployment. +Full Zeppelin passenger travel and terrain flight still require gameplay testing. + +Final deployment: world migration08 applied (previous pending group feature), +new binary installed, final server385679 ready12s with8085/8087 listeners. +Native checks:26Arthoran60 denied map571;55Zanzul82 allowed Horde Zeppelin target. +After saveall DB confirms26riding225/license1/cold0/flightmounts1 and +55riding225/license1/cold1/flightmounts3. No mandatory-flight manager errors in +final startup. Current population scan found saved Northrend bots112level73 and +135level85, both riding225 and cold weather learned. No forced relocation used. + +## Extension to Outland and Cataclysm flight regions + +The subsequent user request applies the same admission prerequisite to all flight +regions declared by the editable area catalog (`prefer_flight`), covering Outland +and the Cataclysm outdoor regions. Require actual learned profile flight and +level60+. Northrend additionally requires68+ and54197; other flight regions outside +maps530/571 also require90267. Area bans (including Vashjir) remain in force. +The low-level blood-elf/draenei regions share map530 but are not catalog flight +regions and remain usable without flying. The extended production test covers +Outland60 without cold weather, denied ground-only arrivals, Cataclysm license +checks and preserved access to the low-level map530 racial areas. + +Latest flight-region build completed with -j10 and the69-test suite passed. diff --git a/doc/clientless/zeppelin-orgrimmar-warsong.md b/doc/clientless/zeppelin-orgrimmar-warsong.md new file mode 100644 index 0000000000..1f9202e856 --- /dev/null +++ b/doc/clientless/zeppelin-orgrimmar-warsong.md @@ -0,0 +1,41 @@ +# Orgrimmar–Warsong Hold: The Mighty Wind + +Activated2026-10-30 on the testserver without a restart. Native transport +spawn12, entry186238, taxi path712, two-map circuit298828ms. Stops: + +| Node | Map | Recorded points | Transition length | +| --- | --- | --- | --- | +| zeppelin_org_northrend | 1 | 5 | 9.45yd | +| zeppelin_warsong | 571 | 10 | 16.70yd | + +Both recordings use `mighty_wind_center` and their deck endpoints differ by about +1.39yd, within the core3yd limit. Native reload accepted both recordings; the +normal transport-link validator accepted their service, maps and stand match. + +Activated with the existing live command: + +```text +clientless transport link zeppelin_org_northrend zeppelin_warsong zeppelin 100 both +``` + +Result: edge1826, enabled and bidirectional. Both nodes have explicit +`vcore_clientless_node_access.allowed_factions=2` (Horde only), with catalogs and +graph reloaded. Area permissions continue to take precedence over node masks. +Source ground edge1819 joins `orgrimmar_zeppelintower1`. No onward ground edge is +recorded at Warsong yet. Taxi257 is nearby horizontally but another storey, so no +unverified straight ground connection was fabricated. Existing permitted flight +and local navigation remain responsible for onward travel until routes are added. + +Recorded points, dock positions and stand coordinates were preserved. A complete +live bot boarding/ride/disembarkation has not been acceptance-tested in this step. +No C++ change, build, install, restart or player teleport was required. + +Live native permission checks: Horde36Edwin85 allowed at both nodes; Alliance +35Karggan85 denied at both nodes, all four report nodeMask2. Native timetable +confirms60-second pauses at each recorded dock, matching dock coordinates. + +Subsequent user requirement: Northrend has no ground-only AdventureBots. All +autonomous level60+ profiles receive flight training/mount defaults; level68+ +receive cold weather54197. Access to map571 additionally requires actual learned +flight capability and cold weather, including transport and portal destinations. +Lack of a recorded onward ground edge does not justify sending non-flyers here. diff --git a/sql/custom/world/2026_10_29_08_adventure_group_handoff.sql b/sql/custom/world/2026_10_29_08_adventure_group_handoff.sql new file mode 100644 index 0000000000..967926a47a --- /dev/null +++ b/sql/custom/world/2026_10_29_08_adventure_group_handoff.sql @@ -0,0 +1,10 @@ +-- Persistent current-block copy. Original vcore_adventure_day slots stay unchanged. +CREATE TABLE IF NOT EXISTS `vcore_adventure_group_handoff` ( + `character_guid` INT UNSIGNED NOT NULL, + `depart_at` BIGINT UNSIGNED NOT NULL, + `formation_end` BIGINT UNSIGNED NOT NULL, + `activity_end` BIGINT UNSIGNED NOT NULL, + `intent` VARCHAR(32) NOT NULL, + `location_key` VARCHAR(100) NOT NULL, + PRIMARY KEY (`character_guid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/server/game/World/AdventureAreaMgr.cpp b/src/server/game/World/AdventureAreaMgr.cpp index d3ed867ba9..a66f363bef 100644 --- a/src/server/game/World/AdventureAreaMgr.cpp +++ b/src/server/game/World/AdventureAreaMgr.cpp @@ -1,5 +1,6 @@ #include "AdventureAreaMgr.h" #include "ClientlessLuaEngine.h" +#include "ClientlessFlightMgr.h" #include "ClientlessTravelGraphMgr.h" #include "DatabaseEnv.h" #include "DBCStores.h" @@ -144,7 +145,18 @@ bool AdventureAreaMgr::AllowsPosition(Player const* player,uint32 map,Position c { if(!Applies(player))return true; if(!std::isfinite(pos.GetPositionX()) || !std::isfinite(pos.GetPositionY()) || !std::isfinite(pos.GetPositionZ()))return false; - return EnsureLoaded() && AllowsArea(player,AreaAt(player,map,pos)); + if(!EnsureLoaded())return false; + auto area=AreaAt(player,map,pos); + // The editable catalog declares Outland/Cataclysm flight regions. Keep + // low-level blood-elf/draenei areas on map530 separate from Outland. + bool flightRequired=map==571 || Area(area).PreferFlight; + if(flightRequired) + { + if(player->getLevel()<60 || !sClientlessFlightMgr->Enabled(player))return false; + if(map==571 && (player->getLevel()<68 || !player->HasSpell(54197)))return false; + if(map!=530 && map!=571 && !player->HasSpell(90267))return false; + } + return AllowsArea(player,area); } bool AdventureAreaMgr::PreferredFlight(Player const* player,uint32 map,Position const& pos) { diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index 91bcc49cab..717c8e8dfb 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -200,6 +200,7 @@ void AdventureBehaviorMgr::Online(Player * player) r.Random.seed(player->GetGUID().GetCounter() ^ uint32(GameTime::GetGameTime())); auto & record = _records.insert_or_assign(player->GetGUID().GetCounter(), std::move(r)).first->second; if(record.Schedule)Generate(player,record,DateOf(Local(GameTime::GetGameTime()))); + RestoreDeparture(player,record); if (RestoreInstance(player,record)) return; if (! record.Schedule && Known(profile->InitialIntent)) SetWanted(player, record, profile->InitialIntent, profile->InitialLocation, GameTime::GetGameTime() + profile->InitialDurationSeconds); } @@ -212,7 +213,12 @@ void AdventureBehaviorMgr::EnterPlayerInstance(Player* player) void AdventureBehaviorMgr::RefreshDay(Player* player) { - auto it=_records.find(player->GetGUID().GetCounter());if(it==_records.end() || !it->second.Schedule)return; + auto it=_records.find(player->GetGUID().GetCounter());if(it==_records.end())return; + // Maintenance also runs while a human leader's follow controller owns + // movement. Advance the logical copy there without queuing autonomous travel. + if(it->second.Departure.At && !sAdventureEncounterMgr->Active(player)) + UpdateDeparture(player,it->second,GameTime::GetGameTime()); + if(!it->second.Schedule)return; auto date=DateOf(Local(GameTime::GetGameTime())); if(it->second.Date!=date){Generate(player,it->second,date);it->second.Hour=-1;} } @@ -234,6 +240,7 @@ void AdventureBehaviorMgr::IntentChanged(Player * player, ClientlessIntent const if (i == _records.end() || i->second.Publishing) return; if (! intent) { + ClearDeparture(player,i->second); SetWanted(player, i->second, "", "", 0); return; } @@ -242,6 +249,7 @@ void AdventureBehaviorMgr::IntentChanged(Player * player, ClientlessIntent const auto & r = i->second; r.Enabled = true; r.Schedule = false; + ClearDeparture(player,r); SetWanted(player, r, intent->Name, location == intent->Parameters.end() ? PickLocation(player, r, intent->Name) : location->second, GameTime::GetGameTime() + 3600); } @@ -475,7 +483,7 @@ void AdventureBehaviorMgr::Begin(Player * player, Record & r) if ((r.Intent=="instance" || r.Intent=="raid")) { if (!ResolveInstance(player,r)) {Fallback(player,r);return;} - Queue(player,r,"travel_to_location","id",r.Location,true); + Queue(player,r,"travel_to_location","id",l->BaseKey.empty()?r.Location:l->BaseKey,true); } else if (l->IsArea) { @@ -1446,10 +1454,11 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) } r.Timer = 1000; time_t now = GameTime::GetGameTime(); + UpdateDeparture(player,r,now); if (r.Instance.Active && UpdateInstance(player,r,now)) return; auto local = Local(now); uint32 date = DateOf(local); - if (r.Schedule) + if (r.Schedule && !r.Departure.Departed(now)) { if (r.Date != date) { @@ -1459,7 +1468,8 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) if (r.Hour != local.tm_hour) { auto const & slot = r.Plan[local.tm_hour]; - bool changed = r.Hour < 0 || r.Plan[r.Hour].Intent != slot.Intent || r.Plan[r.Hour].Location != slot.Location; + bool changed = (r.Hour < 0 || r.Plan[r.Hour].Intent != slot.Intent || r.Plan[r.Hour].Location != slot.Location) && + (r.WantedIntent != slot.Intent || r.WantedLocation != slot.Location); r.Hour = local.tm_hour; std::tm end = local; end.tm_min = 0; @@ -1472,7 +1482,7 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) else r.Deadline = deadline; } } - else if (r.Deadline && now >= r.Deadline) + else if (!r.Schedule && !r.Departure.Departed(now) && r.Deadline && now >= r.Deadline) { if(r.Intent=="groupformation" && !r.ManualNextIntent.empty()) { @@ -1482,6 +1492,7 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) } else SetWanted(player,r,"","",0); } + UpdateDeparture(player,r,now); if(auto group=player->GetGroup())if(sAdventureGroupMgr->BotLed(player) && group->GetLeaderGUID()!=player->GetGUID()) if(auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID()))AdoptGroup(player,leader); if(UpdateGathering(player,r,now))return; @@ -1708,6 +1719,7 @@ bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args) std::string mode; in >> mode; if (mode != "on" && mode != "off") return false; + ClearDeparture(player,r); r.Schedule = mode == "on"; r.Enabled = true; r.Hour = - 1; @@ -1722,6 +1734,7 @@ bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args) auto location=sAdventureLocationMgr->Find(meeting); if(!AdventureGroupPolicy::Eligible(next) || !location || !Allowed(player,*location,"groupformation"))return false; auto destination=PickLocation(player,r,next);if(destination.empty())return false; + ClearDeparture(player,r); r.Schedule=false;r.Enabled=true;r.ManualNextIntent=next;r.ManualNextLocation=destination; SetWanted(player,r,"groupformation",meeting,GameTime::GetGameTime()+seconds); handler->SendSysMessage("Manual preparation started; next activity begins at the deadline even if the group is incomplete.");return true; @@ -1738,6 +1751,7 @@ bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args) handler->SendSysMessage("Use social/adventure/profession/rest/leisure/economy/instance/raid/groupformation and a matching enabled location."); return false; } + ClearDeparture(player,r); r.Schedule = false; r.Enabled = true;r.ManualNextIntent.clear();r.ManualNextLocation.clear(); SetWanted(player, r, intent, location, GameTime::GetGameTime() + seconds); @@ -1746,6 +1760,7 @@ bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args) } if (op == "stop") { + ClearDeparture(player,r); r.Schedule = false;r.ManualNextIntent.clear();r.ManualNextLocation.clear(); SetWanted(player, r, "", "", 0); handler->SendSysMessage("Behavior stop requested at a safe point."); @@ -1790,7 +1805,9 @@ bool AdventureBehaviorMgr::RestoreInstance(Player* player, Record& r) v.Map=f[3].GetUInt32();v.InstanceId=f[4].GetUInt32();v.BlockEnd=time_t(f[5].GetUInt64()); v.PrepareUntil=time_t(f[6].GetUInt64());v.Stage=AdventureInstancePolicy::Phase(f[7].GetUInt8()); v.LossPercent=f[8].GetUInt8();v.InjuryApplied=f[9].GetUInt8()!=0;r.Schedule=f[10].GetUInt8()!=0; + if(auto selected=sAdventureLocationMgr->Find(v.Location))v.Difficulty=selected->InstanceDifficulty; auto restoredMap=sMapStore.LookupEntry(v.Map); + if(player->GetMapId()==v.Map && restoredMap)v.Difficulty=uint32(player->GetDifficulty(restoredMap->IsRaid())); r.Intent=r.WantedIntent=restoredMap && restoredMap->IsRaid()?"raid":"instance";r.Location=r.WantedLocation=v.Location;r.Deadline=v.BlockEnd; r.State=v.Stage==AdventureInstancePolicy::Phase::Travel?Phase::Idle:Phase::OnSite; r.Dirty=v.Stage==AdventureInstancePolicy::Phase::Travel; @@ -1822,7 +1839,9 @@ void AdventureBehaviorMgr::FinishInstance(Player* player, Record& r) bool AdventureBehaviorMgr::ResolveInstance(Player* player, Record& r) { if (r.Instance.Active) return r.Instance.Location==r.Location; - auto key=r.Location;WorldDatabase.EscapeString(key); + auto selected=sAdventureLocationMgr->Find(r.Location); + if(!selected){r.Detail="instance mode variant missing";return false;} + auto key=selected->BaseKey.empty()?r.Location:selected->BaseKey;WorldDatabase.EscapeString(key); auto row=WorldDatabase.PQuery("SELECT entry_trigger,exit_trigger FROM vcore_adventure_instance WHERE location_key='%s' AND enabled=1",key.c_str()); if (!row) {r.Detail="instance entrance mapping unavailable";return false;} uint32 entry=row->Fetch()[0].GetUInt32(),exit=row->Fetch()[1].GetUInt32(); @@ -1856,12 +1875,12 @@ bool AdventureBehaviorMgr::ResolveInstance(Player* player, Record& r) } while(rows->NextRow()); } r.Instance.Active=true;r.Instance.Location=r.Location;r.Instance.Entry=entry;r.Instance.Exit=exit; - r.Instance.Map=target->target_mapId;r.Instance.BlockEnd=r.Deadline; + r.Instance.Map=target->target_mapId;r.Instance.Difficulty=selected->InstanceDifficulty;r.Instance.BlockEnd=r.Deadline; r.Instance.LossPercent=std::uniform_int_distribution(0,50)(r.Random); if (!SaveInstance(player,r)) {r.Instance={};return false;} - TC_LOG_INFO("server.adventurebot","INSTANCE_PLAN guid=%u entry=%u exit=%u map=%u deadline=%llu lossPercent=%u", + TC_LOG_INFO("server.adventurebot","INSTANCE_PLAN guid=%u entry=%u exit=%u map=%u deadline=%llu lossPercent=%u mode=%u", player->GetGUID().GetCounter(),entry,exit,target->target_mapId, - static_cast(r.Deadline),r.Instance.LossPercent); + static_cast(r.Deadline),r.Instance.LossPercent,r.Instance.Difficulty); return true; } @@ -1914,9 +1933,9 @@ bool AdventureBehaviorMgr::UpdateInstance(Player* player, Record& r, time_t now) v.InstanceId=player->GetInstanceId();v.Stage=Stage::Inside; if (!SaveInstance(player,r)) {v.Stage=Stage::Entering;return true;} r.Detail="waiting just inside private instance entrance"; - TC_LOG_INFO("server.adventurebot","INSTANCE_ENTERED guid=%u map=%u instance=%u returnAt=%llu", + TC_LOG_INFO("server.adventurebot","INSTANCE_ENTERED guid=%u map=%u instance=%u returnAt=%llu mode=%u", player->GetGUID().GetCounter(),v.Map,v.InstanceId, - static_cast(AdventureInstancePolicy::ReturnAt(v.BlockEnd))); + static_cast(AdventureInstancePolicy::ReturnAt(v.BlockEnd)),v.Difficulty); return true; } if (v.Abort || !AdventureInstancePolicy::MayEnter(now,v.BlockEnd)) @@ -1924,6 +1943,20 @@ bool AdventureBehaviorMgr::UpdateInstance(Player* player, Record& r, time_t now) else { auto map=sMapStore.LookupEntry(v.Map); + auto group=player->GetGroup(); + bool humanLeader=false; + if(group)if(auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID()))humanLeader=!leader->GetSession()->IsClientless(); + if(humanLeader)v.Difficulty=uint32(group->GetDifficulty(map->IsRaid())); + else if(player->GetDifficulty(map->IsRaid())!=Difficulty(v.Difficulty)) + { + if(group && group->GetLeaderGUID()!=player->GetGUID()) + {r.Detail="waiting for leader instance difficulty";return true;} + WorldPacket mode(map->IsRaid()?MSG_SET_RAID_DIFFICULTY:MSG_SET_DUNGEON_DIFFICULTY,4);mode<IsRaid())player->GetSession()->HandleSetRaidDifficultyOpcode(mode); + else player->GetSession()->HandleSetDungeonDifficultyOpcode(mode); + if(player->GetDifficulty(map->IsRaid())!=Difficulty(v.Difficulty)) + {r.Detail="native instance difficulty change rejected";return true;} + } auto bind=player->GetBoundInstance(v.Map,player->GetDifficulty(map->IsRaid())); bool shared=false; if (bind && bind->save) @@ -2057,6 +2090,7 @@ std::string AdventureBehaviorMgr::InteractionSchedule(Player* player) auto const& location=r.GroupOverride?r.PersonalLocation:r.WantedLocation; return (deadline && GameTime::GetGameTime()>=deadline?"expired:":"")+intent+":"+location; } + if(r.Departure.Departed(GameTime::GetGameTime()))return r.Departure.Intent+":"+r.Departure.Location; auto const& slot=r.Plan[Local(GameTime::GetGameTime()).tm_hour]; return slot.Intent+":"+slot.Location; } @@ -2068,6 +2102,91 @@ void AdventureBehaviorMgr::InterruptForInteraction(Player* player) if(it!=_records.end())it->second.Detail="social/group interaction; original deadline retained"; } +void AdventureBehaviorMgr::ClearDeparture(Player* player,Record& r) +{ + if(!r.Departure.At)return; + r.Departure={}; + WorldDatabase.PExecute("DELETE FROM vcore_adventure_group_handoff WHERE character_guid=%u",player->GetGUID().GetCounter()); +} + +void AdventureBehaviorMgr::RestoreDeparture(Player* player,Record& r) +{ + if(!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_group_handoff'"))return; + auto row=WorldDatabase.PQuery("SELECT depart_at,formation_end,activity_end,intent,location_key FROM vcore_adventure_group_handoff WHERE character_guid=%u",player->GetGUID().GetCounter()); + if(!row)return; + auto f=row->Fetch(); + r.Departure={time_t(f[0].GetUInt64()),time_t(f[1].GetUInt64()),time_t(f[2].GetUInt64()),f[3].GetString(),f[4].GetString()}; + auto location=sAdventureLocationMgr->Find(r.Departure.Location); + if(!r.Departure.Active(GameTime::GetGameTime()) || !location || !Allowed(player,*location,r.Departure.Intent)) + { + r.Departure={}; + WorldDatabase.PExecute("DELETE FROM vcore_adventure_group_handoff WHERE character_guid=%u",player->GetGUID().GetCounter()); + } +} + +void AdventureBehaviorMgr::ArmGroupDeparture(Player* player,AdventureGroupPlan const& source) +{ + auto it=_records.find(player->GetGUID().GetCounter());if(it==_records.end())return; + auto& r=it->second;auto now=GameTime::GetGameTime();auto local=Local(now); + auto own=GroupPlan(player); + if(!own.Forming || r.Departure.Active(now) || own.NextIntent!=source.NextIntent || source.NextLocation.empty())return; + auto location=sAdventureLocationMgr->Find(source.NextLocation); + if(!location || !Allowed(player,*location,source.NextIntent))return; + time_t end=own.Deadline; + if(r.Schedule){auto tm=local;tm.tm_min=tm.tm_sec=0;++tm.tm_hour;tm.tm_isdst=-1;end=mktime(&tm);} + if(end<=now)return; + // Snapshot once per bot. Later kicks, leadership changes and vacancies do + // not change this copied current slot or the original following day slot. + auto departure=AdventureGroupPolicy::Departure{AdventureGroupPolicy::DepartureAt(now,end),end, + source.NextDeadline?source.NextDeadline:end+3600,source.NextIntent,source.NextLocation}; + if(source.Deadline>now)departure.At=std::min(departure.At,source.Deadline); + if(!WorldDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_group_handoff'")) + {TC_LOG_ERROR("server.adventurebot","GROUP_HANDOFF_UNAVAILABLE apply world migration08 before enabling early departures");return;} + auto key=departure.Location,intent=departure.Intent;WorldDatabase.EscapeString(key);WorldDatabase.EscapeString(intent); + auto tx=WorldDatabase.BeginTransaction(); + tx->PAppend("REPLACE INTO vcore_adventure_group_handoff (character_guid,depart_at,formation_end,activity_end,intent,location_key) VALUES (%u,%llu,%llu,%llu,'%s','%s')", + player->GetGUID().GetCounter(),static_cast(departure.At),static_cast(departure.Until), + static_cast(departure.ActivityEnd),intent.c_str(),key.c_str()); + auto commit=WorldDatabase.AsyncCommitTransaction(tx); + if(!commit.m_future.get()){TC_LOG_ERROR("server.adventurebot","GROUP_HANDOFF_SAVE_FAILED guid=%u",player->GetGUID().GetCounter());return;} + r.Departure=std::move(departure);r.Deadline=r.Departure.At; + TC_LOG_INFO("server.adventurebot","GROUP_FULL_GATHER guid=%u departAt=%llu intent='%s' goal='%s'", + player->GetGUID().GetCounter(),static_cast(r.Departure.At),r.Departure.Intent.c_str(),r.Departure.Location.c_str()); +} + +bool AdventureBehaviorMgr::UpdateDeparture(Player* player,Record& r,time_t now) +{ + if(!r.Departure.Active(now)) + { + if(r.Departure.At) + { + r.Departure={};r.Hour=-1; + if(r.Schedule && !r.GroupOverride) + { + auto local=Local(now); + if(r.Date!=DateOf(local))Generate(player,r,DateOf(local)); + auto const& slot=r.Plan[local.tm_hour]; + if(r.WantedIntent!=slot.Intent || r.WantedLocation!=slot.Location) + { + auto end=local;end.tm_min=end.tm_sec=0;++end.tm_hour;end.tm_isdst=-1; + SetWanted(player,r,slot.Intent,slot.Location,mktime(&end)); + } + } + WorldDatabase.PExecute("DELETE FROM vcore_adventure_group_handoff WHERE character_guid=%u",player->GetGUID().GetCounter()); + } + return false; + } + if(!r.Departure.Departed(now)) + {r.Deadline=r.Departure.At;return false;} + if(r.WantedIntent!=r.Departure.Intent || r.WantedLocation!=r.Departure.Location) + { + SetWanted(player,r,r.Departure.Intent,r.Departure.Location,r.Departure.ActivityEnd); + TC_LOG_INFO("server.adventurebot","GROUP_EARLY_DEPART guid=%u intent='%s' goal='%s'", + player->GetGUID().GetCounter(),r.Departure.Intent.c_str(),r.Departure.Location.c_str()); + } + return true; +} + AdventureGroupPlan AdventureBehaviorMgr::GroupPlan(Player* player) { AdventureGroupPlan plan; @@ -2082,7 +2201,27 @@ AdventureGroupPlan AdventureBehaviorMgr::GroupPlan(Player* player) if(local.tm_hour<23){plan.NextIntent=r.Plan[local.tm_hour+1].Intent;plan.NextLocation=r.Plan[local.tm_hour+1].Location;} } if(!r.Schedule){plan.NextIntent=r.ManualNextIntent;plan.NextLocation=r.ManualNextLocation;} + if(r.Departure.Active(now)) + { + if(r.Departure.Departed(now)) + {plan.Intent=r.Departure.Intent;plan.Location=r.Departure.Location;plan.Deadline=r.Departure.ActivityEnd;} + else {plan.Deadline=r.Departure.At;plan.NextIntent=r.Departure.Intent;plan.NextLocation=r.Departure.Location;} + } + if(r.Schedule && local.tm_hour<23) + { + auto end=local;end.tm_min=end.tm_sec=0;end.tm_hour=local.tm_hour+2; + while(end.tm_hour<24 && r.Plan[end.tm_hour].Intent==plan.NextIntent && r.Plan[end.tm_hour].Location==plan.NextLocation)++end.tm_hour; + end.tm_isdst=-1;plan.NextDeadline=mktime(&end); + } + else plan.NextDeadline=plan.Deadline+3600; + if(r.Departure.Active(now))plan.NextDeadline=r.Departure.ActivityEnd; + plan.EarlyDeparture=r.Departure.Departed(now); plan.Forming=plan.Enabled && plan.Intent=="groupformation"; + auto activity=plan.Forming?plan.NextIntent:plan.Intent; + plan.Capacity=AdventureGroupPolicy::Capacity(activity); + if(auto location=sAdventureLocationMgr->Find(plan.Forming?plan.NextLocation:plan.Location)) + if(activity=="raid" || activity=="instance") + {plan.Capacity=location->InstanceCapacity;plan.Mode=location->InstanceDifficulty;} return plan; } bool AdventureBehaviorMgr::AdoptGroup(Player* player,Player* leader) @@ -2095,9 +2234,13 @@ bool AdventureBehaviorMgr::AdoptGroup(Player* player,Player* leader) r.GroupOverride=true;r.PersonalIntent=r.WantedIntent;r.PersonalLocation=r.WantedLocation;r.PersonalDeadline=r.Deadline; } if(r.Instance.Active && r.Instance.Stage!=AdventureInstancePolicy::Phase::Travel)return true; - if(r.WantedIntent!=source.Intent || r.WantedLocation!=source.Location) - SetWanted(player,r,source.Intent,source.Location,source.Deadline); - else r.Deadline=source.Deadline; + auto now=GameTime::GetGameTime(); + auto const& intent=source.Departure.Departed(now)?source.Departure.Intent:source.Intent; + auto const& location=source.Departure.Departed(now)?source.Departure.Location:source.Location; + auto deadline=source.Departure.Departed(now)?source.Departure.ActivityEnd:source.Deadline; + if(r.WantedIntent!=intent || r.WantedLocation!=location) + SetWanted(player,r,intent,location,deadline); + else r.Deadline=deadline; return true; } @@ -2106,7 +2249,9 @@ void AdventureBehaviorMgr::ResumePersonalPlan(Player* player) auto it=_records.find(player->GetGUID().GetCounter());if(it==_records.end())return; auto& r=it->second;if(!r.GroupOverride)return;r.GroupOverride=false; auto now=GameTime::GetGameTime(); - if(r.Schedule) + if(r.Departure.Departed(now)) + SetWanted(player,r,r.Departure.Intent,r.Departure.Location,r.Departure.ActivityEnd); + else if(r.Schedule) { auto local=Local(now);if(r.Date!=DateOf(local))Generate(player,r,DateOf(local)); auto const& slot=r.Plan[local.tm_hour]; diff --git a/src/server/game/World/AdventureBehaviorMgr.h b/src/server/game/World/AdventureBehaviorMgr.h index 74572cee00..e83fedba7b 100644 --- a/src/server/game/World/AdventureBehaviorMgr.h +++ b/src/server/game/World/AdventureBehaviorMgr.h @@ -2,6 +2,7 @@ #define VCORE_ADVENTURE_BEHAVIOR_MGR_H #include "Common.h" #include "AdventureGroupMgr.h" +#include "AdventureGroupPolicy.h" #include "Position.h" #include "ObjectGuid.h" #include "AdventureBehaviorPolicy.h" @@ -36,6 +37,7 @@ public: void InterruptForInteraction(Player* player); AdventureGroupPlan GroupPlan(Player* player); void ResumePersonalPlan(Player* player); + void ArmGroupDeparture(Player* player,AdventureGroupPlan const& source); bool AdoptGroup(Player* player,Player* leader); std::string CombatIntent(Player* player) const; std::string InteractionIntent(Player* player) const; @@ -59,6 +61,7 @@ private: std::string PersonalIntent,PersonalLocation; time_t PersonalDeadline=0; time_t Deadline=0; + AdventureGroupPolicy::Departure Departure; bool Gathering=false; time_t GatherRetryAt=0; uint32 AreaRevision=0; @@ -92,6 +95,9 @@ private: uint32 WorkEmote=0; bool WorkPending=false; }; + void ClearDeparture(Player* player,Record& record); + void RestoreDeparture(Player* player,Record& record); + bool UpdateDeparture(Player* player,Record& record,time_t now); bool ResolveInstance(Player* player, Record& record); bool UpdateNavigationRescue(Player* player,Record& record); bool UpdateGathering(Player* player,Record& record,time_t now); diff --git a/src/server/game/World/AdventureGeneratorMgr.cpp b/src/server/game/World/AdventureGeneratorMgr.cpp index 7efcf207c3..358eb2de05 100644 --- a/src/server/game/World/AdventureGeneratorMgr.cpp +++ b/src/server/game/World/AdventureGeneratorMgr.cpp @@ -7,6 +7,7 @@ #include "AdventureProfessionPolicy.h" #include "AdventureSupplyMgr.h" #include "ClientlessCompanionMgr.h" +#include "ClientlessFlightMgr.h" #include "Bag.h" #include "Group.h" #include "CharacterCache.h" @@ -59,6 +60,24 @@ namespace return info && info->SpellLevel<=p->getLevel() && info->BaseLevel<=p->getLevel() && p->IsSpellFitByClassAndRace(id) && SpellMgr::IsSpellValid(info,p,false); } + bool ProvisionFlight(Player* player,ClientlessAdventureProfile const& profile,std::string& error) + { + if(!profile.AutonomousBehavior || player->getLevel()<60)return true; + for(auto spell:{33388u,33391u,34090u,90267u,54197u}) + { + if(spell==54197 && player->getLevel()<68)continue; + if(!player->HasSpell(spell) && Learnable(player,spell))player->LearnSpell(spell,false); + if(!player->HasSpell(spell)){error="mandatory flight training unavailable: "+std::to_string(spell);return false;} + } + if(player->GetSkillValue(SKILL_RIDING)<225)player->SetSkill(SKILL_RIDING,3,225,225); + for(auto const& mount:profile.FlightMounts) + { + if(!player->HasSpell(mount.DisplayId) && Learnable(player,mount.DisplayId))player->LearnSpell(mount.DisplayId,false); + } + if(!sClientlessFlightMgr->Enabled(player)) + {error="mandatory profile flying mount unavailable";return false;} + return true; + } std::vector Taught(uint32 id) { std::vector result; @@ -576,7 +595,8 @@ bool AdventureGeneratorMgr::PrepareCombat(Player* player,std::string& error) bool AdventureGeneratorMgr::Reconcile(Player* player,std::string& error) { auto profile=sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter()); - if(!profile || !profile->Level || !profile->Class || profile->ProfileVersion<2)return true; + if(!profile)return true; + if(!profile->Level || !profile->Class || profile->ProfileVersion<2)return ProvisionFlight(player,*profile,error); if(profile->Class!=player->getClass()){error="Profile class mismatch; no class conversion performed.";return false;} auto found=std::find_if(AdventureGeneratorPolicy::Archetypes().begin(),AdventureGeneratorPolicy::Archetypes().end(), [&](Archetype const& a){return a.Class==profile->Class;}); @@ -621,6 +641,7 @@ bool AdventureGeneratorMgr::Reconcile(Player* player,std::string& error) if(player->getLevel()>=60 && Learnable(player,90267) && !player->HasSpell(90267))player->LearnSpell(90267,false); if(player->getLevel()>=68 && Learnable(player,54197) && !player->HasSpell(54197))player->LearnSpell(54197,false); for(auto const& mount:profile->FlightMounts)if(Learnable(player,mount.DisplayId) && !player->HasSpell(mount.DisplayId))player->LearnSpell(mount.DisplayId,false); + if(!ProvisionFlight(player,*profile,error))return false; player->SetUInt32Value(PLAYER_XP,0); return true; } diff --git a/src/server/game/World/AdventureGroupMgr.cpp b/src/server/game/World/AdventureGroupMgr.cpp index 9970fa86f3..fbb22cf170 100644 --- a/src/server/game/World/AdventureGroupMgr.cpp +++ b/src/server/game/World/AdventureGroupMgr.cpp @@ -37,6 +37,8 @@ bool Ready(Player* p) {return p && p->IsInWorld() && p->IsAlive() && !p->IsInCombat() && !sAdventureCombatMgr->Busy(p) && !p->IsBeingTeleported() && !p->GetTransport() && !p->IsInFlight() && !p->IsFlying() && !p->IsCharmed() && !p->GetMap()->IsDungeon();} std::string Activity(AdventureGroupPlan const& plan){return plan.Forming?plan.NextIntent:plan.Intent;} +unsigned Capacity(AdventureGroupPlan const& plan) +{return plan.Capacity?plan.Capacity:AdventureGroupPolicy::Capacity(Activity(plan));} } AdventureGroupMgr* AdventureGroupMgr::instance(){static AdventureGroupMgr mgr;return &mgr;} bool AdventureGroupMgr::BotLed(Player* p) const @@ -48,12 +50,12 @@ bool AdventureGroupMgr::BotLed(Player* p) const bool AdventureGroupMgr::Forming(Player* p) const { if(auto group=p->GetGroup())if(auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID())) - return sAdventureBehaviorMgr->GroupPlan(leader).Forming; + if(leader->GetSession()->IsClientless())return sAdventureBehaviorMgr->GroupPlan(leader).Forming; return sAdventureBehaviorMgr->GroupPlan(p).Forming; } bool AdventureGroupMgr::PlannedTransition(Player* p) { - if(!BotLed(p))return false; + if(!BotLed(p) && !sAdventureBehaviorMgr->GroupPlan(p).EarlyDeparture)return false; auto it=_records.find(p->GetGUID()); if(it==_records.end() || it->second.FormationIntent.empty())return false; auto plan=sAdventureBehaviorMgr->GroupPlan(p); @@ -122,7 +124,7 @@ bool AdventureGroupMgr::Accept(Player* invited,Player* inviter) if(!a.Enabled || !b.Enabled || a.Forming!=b.Forming || !AdventureGroupPolicy::Compatible(intent,Activity(b),inviter->getLevel(),invited->getLevel()))return false; auto group=inviter->GetGroup(); - return (!group || (group->GetLeaderGUID()==inviter->GetGUID() && group->GetMembersCount()GetLeaderGUID()==inviter->GetGUID() && group->GetMembersCount()Point.X,location->Point.Y,location->Point.Z); return sAdventureAreaMgr->AllowsPosition(member,location->Point.MapId,goal); } +void AdventureGroupMgr::CheckFullGroup(Player* p) +{ + auto group=p->GetGroup();if(!group)return; + auto source=ObjectAccessor::FindPlayer(group->GetLeaderGUID()); + if(!source)return; + // Human-led groups retain the preparing bot's advertised activity. A real + // player's schedule and difficulty are never changed by this handoff. + if(!source->GetSession()->IsClientless())source=p; + if(source->GetMap()->IsDungeon())return; + auto plan=sAdventureBehaviorMgr->GroupPlan(source); + if(!plan.Forming || !AdventureGroupPolicy::Eligible(plan.NextIntent) || group->GetMembersCount()GetFirstMember();ref;ref=ref->next()) + if(auto member=ref->GetSource())if(member->GetSession()->IsClientless()) + { + _records[member->GetGUID()].FormationIntent=plan.NextIntent; + sAdventureBehaviorMgr->ArmGroupDeparture(member,plan); + } +} + void AdventureGroupMgr::Update(Player* p) { auto now=GameTime::GetGameTime();auto& r=_records[p->GetGUID()]; + CheckFullGroup(p); if(PlayerLeadership(p))return; if(!Ready(p))return; if(EnforceCapacity(p))return; @@ -151,12 +173,13 @@ void AdventureGroupMgr::Update(Player* p) if(group && BotLed(p))if(auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID())) {auto leaderPlan=sAdventureBehaviorMgr->GroupPlan(leader);if(leaderPlan.Forming)r.FormationIntent=Activity(leaderPlan);} if(group && group->GetLeaderGUID()!=p->GetGUID())return; - if(intent=="raid" && p->GetRaidDifficulty()!=RAID_DIFFICULTY_25MAN_NORMAL) + if((intent=="raid" || intent=="instance") && p->GetDifficulty(intent=="raid")!=Difficulty(plan.Mode)) { - WorldPacket mode(MSG_SET_RAID_DIFFICULTY,4);mode<GetSession()->HandleSetRaidDifficultyOpcode(mode); + WorldPacket mode(intent=="raid"?MSG_SET_RAID_DIFFICULTY:MSG_SET_DUNGEON_DIFFICULTY,4);mode<GetSession()->HandleSetRaidDifficultyOpcode(mode); + else p->GetSession()->HandleSetDungeonDifficultyOpcode(mode); } - auto capacity=AdventureGroupPolicy::Capacity(intent); + auto capacity=Capacity(plan); if(group && capacity>5 && !group->isRaidGroup())group->ConvertToRaid(); if(group && capacity<=5 && group->isRaidGroup() && group->GetMembersCount()<=5)group->ConvertToGroup(); unsigned pending=0; @@ -200,7 +223,7 @@ void AdventureGroupMgr::Update(Player* p) if(auto existing=other->GetGroup()) { if(group || existing->GetLeaderGUID()!=other->GetGUID() || - existing->GetMembersCount()>=AdventureGroupPolicy::Capacity(intent))continue; + existing->GetMembersCount()>=Capacity(peerPlan))continue; if(plan.Forming) {if(!AdventureGroupPolicy::Capital(other->GetZoneId()))continue;} else if(!p->IsInMap(other) || !p->IsInPhase(other) || !p->CanSeeOrDetect(other) || p->GetExactDist(other)>25 || !p->IsWithinLOSInMap(other))continue; @@ -222,7 +245,7 @@ void AdventureGroupMgr::Update(Player* p) void AdventureGroupMgr::Report(ChatHandler* h,Player* p) const { auto plan=sAdventureBehaviorMgr->GroupPlan(p); - 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()); + 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),Capacity(plan),plan.Location.c_str()); } bool AdventureGroupMgr::FollowMember(Player* player) @@ -241,8 +264,8 @@ void AdventureGroupMgr::Invite(Player* p,Player* other,std::string const& intent unsigned pending=0; for(auto const& entry:_records)if(entry.second.Inviter==p->GetGUID() && entry.second.InviteUntil>now) if(auto target=ObjectAccessor::FindPlayer(entry.first))if(target->GetGroupInvite())++pending; - auto group=p->GetGroup(); - if((group?group->GetMembersCount():1)+pending>=AdventureGroupPolicy::Capacity(intent))return; + auto group=p->GetGroup();auto plan=sAdventureBehaviorMgr->GroupPlan(p); + if((group?group->GetMembersCount():1)+pending>=Capacity(plan))return; peer.Inviter=p->GetGUID();peer.InviteUntil=now+30; if(forming){peer.FormationIntent=intent;r.FormationIntent=intent;} peer.Partners[p->GetGUID()]=now+600;r.Partners[other->GetGUID()]=now+600; @@ -265,7 +288,7 @@ void AdventureGroupMgr::RequestPlayerAdmission(Player* member,Player* requester) auto now=GameTime::GetGameTime();auto& r=_records[leader->GetGUID()]; if(r.Partners[requester->GetGUID()]>now)return; auto plan=sAdventureBehaviorMgr->GroupPlan(leader); - if(group->GetMembersCount()>=AdventureGroupPolicy::Capacity(Activity(plan)) || frand(0,1)>Sociability(leader))return; + if(group->GetMembersCount()>=Capacity(plan) || frand(0,1)>Sociability(leader))return; Invite(leader,requester,Activity(plan),false); } bool AdventureGroupMgr::PlayerLeadership(Player* leader) @@ -288,7 +311,7 @@ bool AdventureGroupMgr::EnforceCapacity(Player* p) auto leader=ObjectAccessor::FindPlayer(group->GetLeaderGUID());if(!leader)return false; auto plan=sAdventureBehaviorMgr->GroupPlan(leader);auto intent=Activity(plan); if(!AdventureGroupPolicy::Eligible(intent))return false; - auto capacity=AdventureGroupPolicy::Capacity(intent);if(group->GetMembersCount()<=capacity)return false; + auto capacity=Capacity(plan);if(group->GetMembersCount()<=capacity)return false; unsigned rank=1; for(auto const& member:group->GetMemberSlots()) { diff --git a/src/server/game/World/AdventureGroupMgr.h b/src/server/game/World/AdventureGroupMgr.h index 5ac38e3881..9b70ce16d8 100644 --- a/src/server/game/World/AdventureGroupMgr.h +++ b/src/server/game/World/AdventureGroupMgr.h @@ -11,7 +11,9 @@ struct AdventureGroupPlan { bool Enabled=false,Forming=false; std::string Intent,Location,NextIntent,NextLocation; - time_t Deadline=0; + time_t Deadline=0,NextDeadline=0; + unsigned Capacity=0,Mode=0; + bool EarlyDeparture=false; }; class TC_GAME_API AdventureGroupMgr { @@ -38,6 +40,7 @@ private: time_t InviteUntil=0; std::unordered_map Partners; }; + void CheckFullGroup(Player* player); void Invite(Player* leader,Player* member,std::string const& intent,bool forming); void Speak(Player* player,std::string const& message); std::string RecruitmentText(Player* player); diff --git a/src/server/game/World/AdventureGroupPolicy.h b/src/server/game/World/AdventureGroupPolicy.h index d2e98f77fa..56a94ba167 100644 --- a/src/server/game/World/AdventureGroupPolicy.h +++ b/src/server/game/World/AdventureGroupPolicy.h @@ -2,12 +2,23 @@ #define VCORE_ADVENTURE_GROUP_POLICY_H #include #include +#include +#include namespace AdventureGroupPolicy { inline bool Capital(unsigned zone) {switch(zone){case 1519:case 1537:case 1657:case 1637:case 1638:case 1497:case 3487:case 3557:case 3703:case 4395:return true;default:return false;}} inline unsigned CapitalFaction(unsigned zone) {switch(zone){case 1519:case 1537:case 1657:case 3557:return 1;case 1637:case 1638:case 1497:case 3487:return 2;default:return 0;}} +struct Departure +{ + time_t At=0, Until=0, ActivityEnd=0; + std::string Intent,Location; + bool Active(time_t now) const {return At && now=At;} +}; +inline time_t DepartureAt(time_t now,time_t blockEnd) +{return std::min(now+300,blockEnd);} inline bool Gathering(std::string const& intent,long long deadline,long long now) {return intent=="groupformation" && deadline>now && deadline-now<=300;} inline bool Eligible(std::string const& intent) diff --git a/src/server/game/World/AdventureInstancePolicy.h b/src/server/game/World/AdventureInstancePolicy.h index 1fe0309043..5f2da7dd5f 100644 --- a/src/server/game/World/AdventureInstancePolicy.h +++ b/src/server/game/World/AdventureInstancePolicy.h @@ -13,7 +13,7 @@ namespace AdventureInstancePolicy bool Active=false, InjuryApplied=false, Abort=false; Phase Stage=Phase::Travel; std::string Location; - uint32_t Entry=0, Exit=0, Map=0, InstanceId=0, LossPercent=0; + uint32_t Entry=0, Exit=0, Map=0, InstanceId=0, LossPercent=0, Difficulty=0; time_t BlockEnd=0, PrepareUntil=0; }; inline time_t ReturnAt(time_t end) {return end>300?end-300:0;} diff --git a/src/server/game/World/AdventureLocationMgr.cpp b/src/server/game/World/AdventureLocationMgr.cpp index 440bcadcd0..f69c285f46 100644 --- a/src/server/game/World/AdventureLocationMgr.cpp +++ b/src/server/game/World/AdventureLocationMgr.cpp @@ -11,6 +11,7 @@ #include "PhaseShift.h" #include "TerrainMgr.h" #include +#include #include #include AdventureLocationMgr * AdventureLocationMgr::instance() @@ -33,25 +34,16 @@ bool AdventureLocationMgr::Reload() _actions.clear(); _dialogs.clear(); _loaded = false; - _instanceMinimum.clear(); + _instanceBands.clear(); for(auto entry:sLFGDungeonStore) { auto map=sMapStore.LookupEntry(entry->MapID); if(!map || !map->IsDungeon() || !entry->MinLevel)continue; - Difficulty difficulty=map->IsRaid()?RAID_DIFFICULTY_25MAN_NORMAL:DUNGEON_DIFFICULTY_NORMAL; - sDBCManager.GetDownscaledMapDifficultyData(map->ID,difficulty); - if(entry->DifficultyID!=difficulty)continue; - auto& minimum=_instanceMinimum[uint32(entry->MapID)]; - minimum=minimum?std::min(minimum,entry->MinLevel):entry->MinLevel; - } - // The native access requirements are authoritative even for maps absent - // from the dungeon finder catalog (and may tighten its recommended band). - for(auto map:sMapStore)if(map->IsDungeon()) - { - Difficulty difficulty=map->IsRaid()?RAID_DIFFICULTY_25MAN_NORMAL:DUNGEON_DIFFICULTY_NORMAL; - sDBCManager.GetDownscaledMapDifficultyData(map->ID,difficulty); - if(auto access=sObjectMgr->GetAccessRequirement(map->ID,difficulty)) - if(access->levelMin)_instanceMinimum[map->ID]=std::max(_instanceMinimum[map->ID],uint32(access->levelMin)); + auto& band=_instanceBands[(uint64(map->ID)<<8)|entry->DifficultyID]; + // Prefer the lowest native band when obsolete beta LFG entries share + // map/mode (notably normal Shadowfang16..26 versus a beta84..85 row). + if(!band.Min || entry->MinLevelMinLevel,entry->Maxlevel}; + else if(entry->MinLevel==band.Min)band.Max=std::max(band.Max,entry->Maxlevel); } if (! WorldDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_location'")) return false; if (auto rows = WorldDatabase.Query("SELECT w.id,w.point_key,w.category,w.map,w.position_x,w.position_y,w.position_z,w.orientation," "COALESCE(a.display_name,w.point_key),COALESCE(a.region_key,''),a.area_id," "a.zone_id,a.is_area,a.faction " "FROM vcore_clientless_world_point w LEFT JOIN vcore_adventure_location a ON a.point_key=w.point_key " "WHERE w.enabled=1 AND w.category NOT LIKE 'travel%' ORDER BY w.point_key")) @@ -89,6 +81,7 @@ bool AdventureLocationMgr::Reload() auto f=rows->Fetch(); for(auto& l:_locations)if(l.Point.Id==f[0].GetString())l.InstanceMap=f[1].GetUInt16(); }while(rows->NextRow()); + BuildInstanceVariants(); if (auto rows = WorldDatabase.Query("SELECT category,intent,weight FROM vcore_adventure_location_intent WHERE weight>=0")) do { auto f = rows->Fetch(); @@ -129,7 +122,7 @@ bool AdventureLocationMgr::Reload() auto f=rows->Fetch();_workstations[f[0].GetUInt32()].insert(f[1].GetString()); } while(rows->NextRow()); _loaded = true; - TC_LOG_INFO("server.adventurebot", "ADVENTURE_CATALOG locations=%u actions=%u lines=%u instanceBands=%u", uint32(_locations.size()), uint32(_actions.size()), uint32(_dialogs.size()),uint32(_instanceMinimum.size())); + TC_LOG_INFO("server.adventurebot", "ADVENTURE_CATALOG locations=%u actions=%u lines=%u instanceBands=%u", uint32(_locations.size()), uint32(_actions.size()), uint32(_dialogs.size()),uint32(_instanceBands.size())); return true; } @@ -144,8 +137,11 @@ bool AdventureLocationMgr::LevelEligible(Player const* player,AdventureLocation if(!player)return false; if(intent=="instance" || intent=="raid") { - auto it=_instanceMinimum.find(location.InstanceMap); - return it!=_instanceMinimum.end() && it->second && player->getLevel()>=it->second; + auto it=_instanceBands.find((uint64(location.InstanceMap)<<8)|location.InstanceDifficulty); + if(it==_instanceBands.end() || !it->second.Min || player->getLevel()second.Min)return false; + // Raids intentionally retain minimum-only eligibility. For dungeons, + // five levels over the recommended upper edge is the outer tolerance. + return intent=="raid" || (it->second.Max && player->getLevel()<=it->second.Max+5); } if(!strictArea)return true; auto area=sAdventureAreaMgr->Area(location.AreaId?location.AreaId:location.ZoneId); @@ -163,12 +159,7 @@ float AdventureLocationMgr::Weight(AdventureLocation const & location, std::stri { auto map=sMapStore.LookupEntry(location.InstanceMap); if(!map || !map->IsDungeon() || map->IsRaid()!=(intent=="raid"))return 0; - if(intent=="raid") - { - Difficulty difficulty=RAID_DIFFICULTY_25MAN_NORMAL; - auto data=sDBCManager.GetDownscaledMapDifficultyData(location.InstanceMap,difficulty); - if(!data || data->maxPlayers<20)return 0; - } + if(!location.InstanceCapacity)return 0; } if(intent=="groupformation" && !AdventureGroupPolicy::Capital(location.ZoneId?location.ZoneId:location.AreaId))return 0; auto type = _types.find(location.Point.Category); @@ -399,3 +390,47 @@ bool AdventureLocationMgr::Import(ChatHandler * handler, bool repair) handler->PSendSysMessage("Location import: %u seeds, %u area destinations, %u inferred guard POIs, %u unusable coordinates skipped. Existing records preserved. Active semantic locations: %u.", count, areas, inferred, skipped, uint32(_locations.size())); return true; } + +void AdventureLocationMgr::BuildInstanceVariants() +{ + std::vector variants; + for(auto& location:_locations) + { + location.BaseKey=location.Point.Id; + if(!location.InstanceMap)continue; + auto map=sMapStore.LookupEntry(location.InstanceMap); + if(!map || !map->IsDungeon())continue; + auto large=map->IsRaid()?sDBCManager.GetMapDifficultyData(map->ID,RAID_DIFFICULTY_25MAN_NORMAL):nullptr; + uint8 preferred=large && large->maxPlayers?1:0; + AdventureLocation original=location;bool baseValid=false; + for(uint8 mode=0;mode<(map->IsRaid()?4:2);++mode) + { + auto data=sDBCManager.GetMapDifficultyData(map->ID,Difficulty(mode)); + if(!data || !data->maxPlayers)continue; // never invent a heroic fallback + auto key=(uint64(map->ID)<<8)|mode; + auto band=_instanceBands[key]; + if(map->IsRaid() && !band.Min) + for(uint8 normal=0;normal<2;++normal) + { + auto it=_instanceBands.find((uint64(map->ID)<<8)|normal); + if(it!=_instanceBands.end() && it->second.Min && (!band.Min || it->second.Minsecond; + } + if(auto access=sObjectMgr->GetAccessRequirement(map->ID,Difficulty(mode))) + { + band.Min=std::max(band.Min,uint32(access->levelMin)); + if(access->levelMax)band.Max=band.Max?std::min(band.Max,uint32(access->levelMax)):access->levelMax; + } + if(!band.Min || (!map->IsRaid() && !band.Max))continue; + _instanceBands[key]=band; + auto variant=original;variant.InstanceDifficulty=mode; + variant.InstanceCapacity=map->IsRaid()?std::min(20u,uint32(data->maxPlayers)):std::min(5u,uint32(data->maxPlayers)); + variant.MinLevel=uint8(band.Min);variant.MaxLevel=uint8(band.Max); + variant.Name += " ["+std::string((map->IsRaid()?mode>=2:mode==1)?"heroisch":"normal")+ + ", "+std::to_string(data->maxPlayers)+"]"; + if(mode==preferred){location=std::move(variant);baseValid=true;} + else {variant.Point.Id=original.Point.Id+"_d"+std::to_string(mode);variants.push_back(std::move(variant));} + } + if(!baseValid){location.InstanceCapacity=0;location.MinLevel=location.MaxLevel=0;} + } + _locations.insert(_locations.end(),variants.begin(),variants.end()); +} diff --git a/src/server/game/World/AdventureLocationMgr.h b/src/server/game/World/AdventureLocationMgr.h index cbe07e3cf9..738c06db2a 100644 --- a/src/server/game/World/AdventureLocationMgr.h +++ b/src/server/game/World/AdventureLocationMgr.h @@ -11,7 +11,9 @@ class Player; struct AdventureLocation { ClientlessWorldPoint Point; - std::string Name, Region; + std::string Name, Region, BaseKey; + uint8 InstanceDifficulty=0; + uint32 InstanceCapacity=0; uint32 AreaId = 0, ZoneId = 0, InstanceMap = 0; bool IsArea = false; uint8 Faction = 0; @@ -43,7 +45,9 @@ public: float Weight(AdventureLocation const& location, std::string const& intent) const; bool LevelEligible(Player const* player, AdventureLocation const& location, std::string const& intent, bool strictArea = false) const; private: - std::unordered_map _instanceMinimum; + struct Band {uint32 Min=0,Max=0;}; + std::unordered_map _instanceBands; + void BuildInstanceVariants(); std::unordered_map> _workstations; bool _loaded = false; std::vector _locations; diff --git a/src/server/game/World/AdventurePopulationMgr.cpp b/src/server/game/World/AdventurePopulationMgr.cpp index 0746d3be6a..2cfa7146e4 100644 --- a/src/server/game/World/AdventurePopulationMgr.cpp +++ b/src/server/game/World/AdventurePopulationMgr.cpp @@ -197,6 +197,7 @@ void AdventurePopulationMgr::DeleteRotated() if(rows){Failed("Native deletion not complete; cleanup deferred.");return;} auto world=WorldDatabase.BeginTransaction(); world->PAppend("DELETE FROM vcore_adventure_day WHERE character_guid=%u",guid); + world->PAppend("DELETE FROM vcore_adventure_group_handoff WHERE character_guid=%u",guid); auto worldSaved=WorldDatabase.AsyncCommitTransaction(world); worldSaved.AfterComplete([this,guid](bool ok) { @@ -221,7 +222,7 @@ void AdventurePopulationMgr::DeleteRotated() void AdventurePopulationMgr::Online(Player* p) { auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); - if(profile && profile->ProfileVersion>=2 && profile->Class && profile->Level)_checks[p->GetGUID().GetCounter()]=0; + if(profile && (profile->AutonomousBehavior || (profile->ProfileVersion>=2 && profile->Class && profile->Level)))_checks[p->GetGUID().GetCounter()]=0; } void AdventurePopulationMgr::Offline(Player* p){_checks.erase(p->GetGUID().GetCounter());} void AdventurePopulationMgr::CleanInventory(Player* p) @@ -256,7 +257,9 @@ void AdventurePopulationMgr::Maintain(Player* p,uint32 diff) auto it=_checks.find(p->GetGUID().GetCounter());if(it==_checks.end())return; if(it->second>diff){it->second-=diff;return;} if(!p->IsInWorld() || !p->IsAlive() || p->IsInCombat() || p->GetMap()->IsDungeon() || p->GetTransport() || p->IsInFlight() || p->IsFlying() || p->IsBeingTeleported() || p->HasUnitState(UNIT_STATE_CASTING))return; - it->second=_maintenance+urand(0,30000);CleanInventory(p); + it->second=_maintenance+urand(0,30000); + auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); + if(profile && profile->ProfileVersion>=2 && profile->Class && profile->Level)CleanInventory(p); std::string error; if(!sAdventureGeneratorMgr->Reconcile(p,error))TC_LOG_WARN("server.adventurebot","BOT_MANAGER_CHECK guid=%u detail='%s'",p->GetGUID().GetCounter(),error.c_str()); auto tx=CharacterDatabase.BeginTransaction();p->SaveToDB(tx);CharacterDatabase.CommitTransaction(tx); diff --git a/src/server/game/World/ClientlessLuaEngine.cpp b/src/server/game/World/ClientlessLuaEngine.cpp index 4e06fce66b..6de5ab244e 100644 --- a/src/server/game/World/ClientlessLuaEngine.cpp +++ b/src/server/game/World/ClientlessLuaEngine.cpp @@ -508,6 +508,14 @@ bool ClientlessLuaEngine::LoadAdventureProfile(Player* player) lua_pop(_state,1); } lua_pop(_state,1); + // All autonomous level60+ characters can fly. Legacy/manual profiles get + // the same minimum capability as generated profiles, retaining preferences. + if(profile.AutonomousBehavior && (profile.Level?profile.Level:player->getLevel())>=60) + { + profile.CanFreeFly=true; + if(profile.FlightMounts.empty()) + profile.FlightMounts.push_back({player->GetTeamId()==TEAM_ALLIANCE?32235u:32243u,1}); + } uint32 const guid = player->GetGUID().GetCounter(); _profiles[guid] = std::move(profile); ClientlessAdventureProfile const& stored = _profiles.at(guid); diff --git a/tests/clientless/test_adventure_destination_level.py b/tests/clientless/test_adventure_destination_level.py index 2566eeb90a..72e2c39f61 100644 --- a/tests/clientless/test_adventure_destination_level.py +++ b/tests/clientless/test_adventure_destination_level.py @@ -8,12 +8,13 @@ code=r''' #include #include #include +using uint64=unsigned long long; struct Player{unsigned level=60;unsigned getLevel()const{return level;}}; -struct AdventureLocation{unsigned InstanceMap=0,AreaId=0,ZoneId=0;}; +struct AdventureLocation{unsigned InstanceMap=0,AreaId=0,ZoneId=0,InstanceDifficulty=0;}; struct Area{bool KnownLevel=true;unsigned MinLevel=1;}; struct Areas{auto Area(unsigned id)->::Area{return {id!=0,id};}}areas;auto sAdventureAreaMgr=&areas; struct AdventureLocationMgr{ - std::unordered_map_instanceMinimum{{533,80},{36,15}}; + struct Band{unsigned Min,Max;};std::unordered_map_instanceBands{{533ull<<8,{80,80}},{36ull<<8,{15,21}},{(36ull<<8)|1,{85,85}},{33ull<<8,{16,26}},{(33ull<<8)|1,{85,85}},{34ull<<8,{20,30}}}; bool LevelEligible(Player const*,AdventureLocation const&,std::string const&,bool strictArea=false)const; }; '''+s[a:b]+r''' @@ -23,6 +24,12 @@ int main(){ p.level=80;assert(mgr.LevelEligible(&p,naxx,"raid")); AdventureLocation deadmines{36,0,0};p.level=14;assert(!mgr.LevelEligible(&p,deadmines,"instance")); p.level=15;assert(mgr.LevelEligible(&p,deadmines,"instance")); + p.level=26;assert(mgr.LevelEligible(&p,deadmines,"instance"));p.level=27;assert(!mgr.LevelEligible(&p,deadmines,"instance")); + AdventureLocation stockade{34};for(auto level:{49,57,85}){p.level=level;assert(!mgr.LevelEligible(&p,stockade,"instance"));} + for(unsigned map:{33,36}){AdventureLocation normal{map},heroic{map,0,0,1};p.level=85; + assert(!mgr.LevelEligible(&p,normal,"instance"));assert(mgr.LevelEligible(&p,heroic,"instance")); + p.level=84;assert(!mgr.LevelEligible(&p,heroic,"instance"));} + p.level=85;assert(mgr.LevelEligible(&p,naxx,"raid")); AdventureLocation unknown{999,0,0};assert(!mgr.LevelEligible(&p,unknown,"raid")); AdventureLocation zone{0,60,60};p.level=59; assert(mgr.LevelEligible(&p,zone,"adventure")); // own area policy retains its separate tolerance diff --git a/tests/clientless/test_adventure_graph_handover.py b/tests/clientless/test_adventure_graph_handover.py index 058b0d0f2f..8f38f8a2bb 100644 --- a/tests/clientless/test_adventure_graph_handover.py +++ b/tests/clientless/test_adventure_graph_handover.py @@ -19,7 +19,7 @@ using uint32=uint32_t; enum class ClientlessActionExecutionState{Moving,Completed}; struct Player {bool transport=false,taxi=false,flying=false,teleport=false;bool GetTransport(){return transport;}bool IsInFlight(){return taxi;}bool IsFlying(){return flying;}bool IsBeingTeleported(){return teleport;}}; struct Record { - bool Active=true,StopAtNode=false;std::size_t RouteStep=1; + bool Active=true,StopAtNode=false,RescueActive=false;std::size_t RouteStep=1; struct Step{std::string NodeKey;};std::vector Route{{"goldshire"}}; struct{ClientlessActionExecutionState State=ClientlessActionExecutionState::Moving;std::string Detail;}Status; }; diff --git a/tests/clientless/test_adventure_group_departure.py b/tests/clientless/test_adventure_group_departure.py new file mode 100644 index 0000000000..702c05ff9a --- /dev/null +++ b/tests/clientless/test_adventure_group_departure.py @@ -0,0 +1,104 @@ +"""Execute persisted production handoffs with an actual clock and transactional DB double.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() +def function(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] +header=(root/'src/server/game/World/AdventureGroupMgr.h').read_text() +a=header.index('struct AdventureGroupPlan');b=header.index('class TC_GAME_API',a) +code=r''' +#include "AdventureBehaviorPolicy.h" +#include "AdventureInstancePolicy.h" +#include "AdventureGroupPolicy.h" +#include +#include +#include +#include +#include +using uint32=unsigned;using uint64=unsigned long long; +#define TC_LOG_INFO(...) ((void)0) +#define TC_LOG_ERROR(...) ((void)0) +struct Guid{unsigned id=0;unsigned GetCounter(){return id;}};struct Player{Guid guid;Guid GetGUID(){return guid;}}; +namespace GameTime{time_t now;time_t GetGameTime(){return now;}} +std::tm Local(time_t now){std::tm tm{};localtime_r(&now,&tm);return tm;}unsigned DateOf(std::tm tm){return (1900+tm.tm_year)*10000+(tm.tm_mon+1)*100+tm.tm_mday;} +time_t Clock(int h,int m=0){std::tm tm{};tm.tm_year=126;tm.tm_mon=9;tm.tm_mday=29;tm.tm_hour=h;tm.tm_min=m;return mktime(&tm);} +struct Field{std::string value;uint64 GetUInt64(){return std::stoull(value);}std::string GetString(){return value;}}; +struct Row{Field fields[5];Field*Fetch(){return fields;}}; +using Result=std::shared_ptr; +struct Transaction{std::string sql;templatevoid PAppend(char const*fmt,Args...args){char b[2000];snprintf(b,sizeof(b),fmt,args...);sql=b;}}; +using Tx=std::shared_ptr; +struct Database{ + bool present=true,fail=false;std::unordered_maprows; + Tx BeginTransaction(){return std::make_shared();}void EscapeString(std::string&){} + struct Future{bool ok;bool get(){return ok;}};struct Commit{Future m_future;}; + Commit AsyncCommitTransaction(Tx tx){if(fail)return {{false}};unsigned id;uint64 at,end,activity;char intent[64],location[128]; + int count=sscanf(tx->sql.c_str(),"REPLACE INTO vcore_adventure_group_handoff (character_guid,depart_at,formation_end,activity_end,intent,location_key) VALUES (%u,%llu,%llu,%llu,'%63[^']','%127[^']')",&id,&at,&end,&activity,intent,location); + assert(count==6);rows[id]={{{std::to_string(at)},{std::to_string(end)},{std::to_string(activity)},{intent},{location}}};return {{true}};} + Result Query(char const*){return present?std::make_shared():nullptr;} + Result PQuery(char const*,unsigned id){return rows.count(id)?std::make_shared(rows.at(id)):nullptr;} + void PExecute(char const*,unsigned id){rows.erase(id);} +}WorldDatabase; +struct Location{unsigned InstanceCapacity=5,InstanceDifficulty=1;}location; +struct Locations{Location*Find(std::string const&key){return key.empty()?nullptr:&location;}}locations;auto sAdventureLocationMgr=&locations; +'''+header[a:b]+r''' +struct Encounters{bool Active(Player*){return false;}}encounters;auto sAdventureEncounterMgr=&encounters; +struct AdventureBehaviorMgr{ + struct Record{ + bool Enabled=true,Dirty=false,Schedule=true,GroupOverride=false;int Hour=14;unsigned Date=20261029; + std::string Intent="groupformation",Location="capital",WantedIntent="groupformation",WantedLocation="capital",PersonalIntent,PersonalLocation,ManualNextIntent,ManualNextLocation; + time_t Deadline=Clock(15),PersonalDeadline=0;AdventureBehaviorPolicy::Day Plan; + AdventureGroupPolicy::Departure Departure;AdventureInstancePolicy::Visit Instance; + }; + std::unordered_map_records;unsigned changes=0; + void Generate(Player*,Record&r,unsigned date){r.Date=date;} + bool Allowed(Player*,Location const&,std::string const&){return true;} + void SetWanted(Player*,Record&r,std::string const&i,std::string const&l,time_t end){++changes;r.WantedIntent=i;r.WantedLocation=l;r.Deadline=end;r.Dirty=true;} + void RefreshDay(Player*);void RestoreDeparture(Player*,Record&);bool UpdateDeparture(Player*,Record&,time_t);void ArmGroupDeparture(Player*,AdventureGroupPlan const&); + AdventureGroupPlan GroupPlan(Player*);void ResumePersonalPlan(Player*);bool AdoptGroup(Player*,Player*);std::string InteractionSchedule(Player*); +}; +''' +for sig in ('void AdventureBehaviorMgr::RefreshDay(', 'void AdventureBehaviorMgr::RestoreDeparture(', 'void AdventureBehaviorMgr::ArmGroupDeparture(', 'bool AdventureBehaviorMgr::UpdateDeparture(', 'AdventureGroupPlan AdventureBehaviorMgr::GroupPlan(', 'void AdventureBehaviorMgr::ResumePersonalPlan(', 'bool AdventureBehaviorMgr::AdoptGroup(', 'std::string AdventureBehaviorMgr::InteractionSchedule('):code+=function(sig)+'\n' +code+=r''' +void Prepare(AdventureBehaviorMgr::Record&r,std::string const&goal){for(auto&slot:r.Plan)slot={"rest","inn"};r.Plan[14]={"groupformation","capital"};r.Plan[15]={"instance",goal};} +int main(){setenv("TZ","UTC",1);tzset();GameTime::now=Clock(14,20);AdventureBehaviorMgr mgr;Player leader{{1}},member{{2}}; + auto&a=mgr._records[1];auto&b=mgr._records[2];Prepare(a,"deadmines_d1");Prepare(b,"shadowfang_d1"); + auto source=mgr.GroupPlan(&leader);assert(source.Capacity==5&&source.Mode==1&&source.NextDeadline==Clock(16)); + mgr.ArmGroupDeparture(&leader,source);mgr.ArmGroupDeparture(&member,source); + assert(a.Departure.At==Clock(14,25)&&b.Departure.At==Clock(14,25)); + assert(b.Departure.Location=="deadmines_d1"&&b.Plan[15].Location=="shadowfang_d1"); + assert(WorldDatabase.rows.size()==2);auto saved=WorldDatabase.rows[2]; + GameTime::now=Clock(14,23);mgr.ArmGroupDeparture(&member,source);assert(b.Departure.At==Clock(14,25)); // no extending/rearming + assert(!mgr.UpdateDeparture(&member,b,GameTime::now)&&b.Deadline==Clock(14,25)); + assert(mgr.GroupPlan(&member).Forming); + GameTime::now=Clock(14,25);assert(mgr.UpdateDeparture(&member,b,GameTime::now)); + assert(b.WantedLocation=="deadmines_d1"&&b.WantedIntent=="instance"&&b.Deadline==Clock(16)); + auto plan=mgr.GroupPlan(&member);assert(!plan.Forming&&plan.EarlyDeparture&&plan.Location=="deadmines_d1"); + // Kick/incomplete group cannot roll the copied current slot back. + b.GroupOverride=true;mgr.ResumePersonalPlan(&member);assert(!b.GroupOverride&&b.WantedLocation=="deadmines_d1"); + assert(mgr.InteractionSchedule(&member)=="instance:deadmines_d1"); + // Restore independently of any surviving group and continue the same current slot. + AdventureBehaviorMgr restored;auto&c=restored._records[2];Prepare(c,"shadowfang_d1"); + restored.RestoreDeparture(&member,c);assert(c.Departure.At==Clock(14,25)); + restored.RefreshDay(&member);assert(c.WantedLocation=="deadmines_d1"); // human follow can skip the movement behavior tick + assert(c.Plan[15].Location=="shadowfang_d1"); + // At the original boundary the next, untouched personal slot applies. + GameTime::now=Clock(15);restored.UpdateDeparture(&member,c,GameTime::now); + assert(c.WantedLocation=="shadowfang_d1"&&!c.Departure.At&&!WorldDatabase.rows.count(2)); + // Late completion never extends the fixed last-five-minute window. + GameTime::now=Clock(14,57);auto&late=restored._records[3];Player lateBot{{3}};Prepare(late,"deadmines_d1"); + restored.ArmGroupDeparture(&lateBot,restored.GroupPlan(&lateBot));assert(late.Departure.At==Clock(15)); + assert(AdventureGroupPolicy::Gathering("groupformation",late.Deadline,GameTime::now)); + GameTime::now=Clock(15);assert(!restored.UpdateDeparture(&lateBot,late,GameTime::now));assert(late.WantedIntent=="instance"); + // No armed in-memory transition when persistence fails. + GameTime::now=Clock(14,20);auto&failed=restored._records[4];Player failedBot{{4}};Prepare(failed,"deadmines_d1"); + WorldDatabase.fail=true;restored.ArmGroupDeparture(&failedBot,restored.GroupPlan(&failedBot));assert(!failed.Departure.At&&!WorldDatabase.rows.count(4)); +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Production handoffs: full-group deadline, late clamp, unchanged next slot, kick, restart persistence, expiry and failed save passed.') diff --git a/tests/clientless/test_adventure_group_plan.py b/tests/clientless/test_adventure_group_plan.py index 602dae1b52..caa332ba94 100644 --- a/tests/clientless/test_adventure_group_plan.py +++ b/tests/clientless/test_adventure_group_plan.py @@ -10,6 +10,7 @@ schedule=s[start:end] a=s.index('AdventureGroupPlan AdventureBehaviorMgr::GroupPlan(') code=r''' #include "AdventureBehaviorPolicy.h" +#include "AdventureGroupPolicy.h" #include "AdventureInstancePolicy.h" #include #include @@ -17,11 +18,12 @@ code=r''' using uint32=unsigned; struct Guid{unsigned id;unsigned GetCounter(){return id;}}; struct Player{Guid guid;Guid GetGUID(){return guid;}}; -struct AdventureGroupPlan{bool Enabled=false,Forming=false;std::string Intent,Location,NextIntent,NextLocation;time_t Deadline=0;}; +struct AdventureGroupPlan{bool Enabled=false,Forming=false;std::string Intent,Location,NextIntent,NextLocation;time_t Deadline=0,NextDeadline=0;unsigned Capacity=0,Mode=0;bool EarlyDeparture=false;}; namespace GameTime{time_t GetGameTime(){return 100;}} std::tm Local(time_t){std::tm tm{};tm.tm_hour=5;return tm;}unsigned DateOf(std::tm){return 1;} +struct Location{unsigned InstanceCapacity=5,InstanceDifficulty=1;};struct Locations{Location l;Location*Find(std::string const&){return &l;}}locations;auto sAdventureLocationMgr=&locations; struct AdventureBehaviorMgr{ - struct Record{bool Enabled=true,Schedule=true,GroupOverride=false,Dirty=false;int Hour=-1;unsigned Date=1;std::string Intent,Location,WantedIntent,WantedLocation,ManualNextIntent,ManualNextLocation;std::string PersonalIntent,PersonalLocation;time_t PersonalDeadline=0;time_t Deadline=3600;AdventureBehaviorPolicy::Day Plan;AdventureInstancePolicy::Visit Instance;}; + struct Record{bool Enabled=true,Schedule=true,GroupOverride=false,Dirty=false;int Hour=-1;unsigned Date=1;std::string Intent,Location,WantedIntent,WantedLocation,ManualNextIntent,ManualNextLocation;std::string PersonalIntent,PersonalLocation;time_t PersonalDeadline=0;time_t Deadline=3600;AdventureBehaviorPolicy::Day Plan;AdventureInstancePolicy::Visit Instance;AdventureGroupPolicy::Departure Departure;}; std::unordered_map_records;int changes=0; void Generate(Player*,Record&r,unsigned date){r.Date=date;} void SetWanted(Player*,Record&r,std::string const&i,std::string const&l,time_t end){++changes;r.WantedIntent=i;r.WantedLocation=l;r.Deadline=end;} diff --git a/tests/clientless/test_adventure_groups.py b/tests/clientless/test_adventure_groups.py index 73d11972b4..aca305ce4f 100644 --- a/tests/clientless/test_adventure_groups.py +++ b/tests/clientless/test_adventure_groups.py @@ -29,11 +29,11 @@ struct Map{bool IsDungeon(){return false;}}map; struct Member{Player*player=nullptr;Member*nextMember=nullptr;Player*GetSource(){return player;}Member*next(){return nextMember;}}; constexpr int GROUP_REMOVEMETHOD_LEAVE=1; struct Group{struct Slot{ObjectGuid guid;};std::vectorslots;auto const&GetMemberSlots(){return slots;}void RemoveMember(ObjectGuid,int);Member*members=nullptr;Member*GetFirstMember(){return members;}void ChangeLeader(ObjectGuid g){leader=g;}void SendUpdate(){}ObjectGuid leader;unsigned count=2;bool raid=false;ObjectGuid GetLeaderGUID(){return leader;}unsigned GetMembersCount(){return count;}bool isRaidGroup(){return raid;}void ConvertToRaid(){raid=true;}void ConvertToGroup(){raid=false;}}; -struct WorldPacket{unsigned value=0;WorldPacket(int,int=0){}WorldPacket&operator<<(unsigned n){value=n;return *this;}};constexpr int MSG_SET_RAID_DIFFICULTY=2,RAID_DIFFICULTY_25MAN_NORMAL=1;constexpr int ALLIANCE=0,LANG_COMMON=7,LANG_ORCISH=1;constexpr int CMSG_PARTY_INVITE=1,EMOTE_ONESHOT_WAVE=1; +struct WorldPacket{unsigned value=0;WorldPacket(int,int=0){}WorldPacket&operator<<(unsigned n){value=n;return *this;}};using Difficulty=unsigned;constexpr int MSG_SET_DUNGEON_DIFFICULTY=3;constexpr int MSG_SET_RAID_DIFFICULTY=2,RAID_DIFFICULTY_25MAN_NORMAL=1;constexpr int ALLIANCE=0,LANG_COMMON=7,LANG_ORCISH=1;constexpr int CMSG_PARTY_INVITE=1,EMOTE_ONESHOT_WAVE=1; namespace WorldPackets{namespace Party{struct PartyInviteClient{PartyInviteClient(WorldPacket&&){}std::string TargetName;ObjectGuid TargetGUID;};}} -struct Session{Player*owner;bool clientless=true;bool IsClientless(){return clientless;}void HandlePartyInviteOpcode(WorldPackets::Party::PartyInviteClient&);void HandleSetRaidDifficultyOpcode(WorldPacket&);}; +struct Session{Player*owner;bool clientless=true;bool IsClientless(){return clientless;}void HandlePartyInviteOpcode(WorldPackets::Party::PartyInviteClient&);void HandleSetRaidDifficultyOpcode(WorldPacket&);void HandleSetDungeonDifficultyOpcode(WorldPacket&p){HandleSetRaidDifficultyOpcode(p);}}; struct Player{ObjectGuid guid;Group*group=nullptr,*invite=nullptr;Session session{this};bool combat=false;unsigned zone=1519,level=60,team=0;float distance=10; - unsigned difficulty=0;unsigned GetRaidDifficulty(){return difficulty;}ObjectGuid GetGUID(){return guid;}bool IsInWorld(){return true;}bool IsAlive(){return true;}bool IsInCombat(){return combat;} + unsigned difficulty=0;unsigned GetRaidDifficulty(){return difficulty;}unsigned GetDifficulty(bool){return difficulty;}ObjectGuid GetGUID(){return guid;}bool IsInWorld(){return true;}bool IsAlive(){return true;}bool IsInCombat(){return combat;} bool IsBeingTeleported(){return false;}bool GetTransport(){return false;}bool IsFlying(){return false;}bool IsInFlight(){return false;}bool IsCharmed(){return false;}Map*GetMap(){return ↦} unsigned GetZoneId(){return zone;}unsigned getLevel(){return level;}unsigned GetTeam(){return team;} Group*GetGroup(){return group;}Group*GetGroupInvite(){return invite;}Session*GetSession(){return &session;}std::string GetName(){return std::to_string(guid.id);} @@ -57,16 +57,38 @@ struct Position{void Relocate(float,float,float){}}; struct Location{std::string Name="Stormwind";struct{unsigned MapId=0;float X=0,Y=0,Z=0;}Point;}location; struct Locations{bool levelEligible=true;Location*Find(std::string const&){return &location;}bool LevelEligible(Player*,Location const&,std::string const&,bool){return levelEligible;}}locations;auto sAdventureLocationMgr=&locations; struct Areas{bool allowed=true;bool AllowsPosition(Player*,unsigned,Position const&){return allowed;}}areas;auto sAdventureAreaMgr=&areas; -struct Behavior{std::unordered_map plans;AdventureGroupPlan GroupPlan(Player*p){return plans[p->guid.id];}}behavior;auto sAdventureBehaviorMgr=&behavior; +struct Behavior{std::unordered_map plans;AdventureGroupPlan GroupPlan(Player*p){return plans[p->guid.id];}unsigned arms=0;void ArmGroupDeparture(Player*,AdventureGroupPlan const&){++arms;}}behavior;auto sAdventureBehaviorMgr=&behavior; struct GroupTravel {unsigned follows=0;bool Follow(Player*){++follows;return true;}}groupTravel;auto sAdventureGroupTravelMgr=&groupTravel; int messages=0; void AdventureGroupMgr::Speak(Player*,std::string const&){++messages;} ''' -code+=fun('float Sociability(')+'\n'+fun('bool Ready(')+'\n'+fun('std::string Activity(') -for name in ['std::string AdventureGroupMgr::RecruitmentText(', 'bool AdventureGroupMgr::RecruitPlayer(', 'bool AdventureGroupMgr::BotLed(', 'bool AdventureGroupMgr::FollowMember(', 'bool AdventureGroupMgr::Forming(', 'bool AdventureGroupMgr::PlannedTransition(', 'bool AdventureGroupMgr::CanInvite(', 'bool AdventureGroupMgr::Accept(', 'void AdventureGroupMgr::Update(', 'void AdventureGroupMgr::Invite(', 'void AdventureGroupMgr::RequestPlayerAdmission(', 'bool AdventureGroupMgr::PlayerLeadership(', 'bool AdventureGroupMgr::EnforceCapacity(']:code+='\n'+fun(name) +code+=fun('float Sociability(')+'\n'+fun('bool Ready(')+'\n'+fun('std::string Activity(')+'\n'+fun('unsigned Capacity(') +for name in ['std::string AdventureGroupMgr::RecruitmentText(', 'bool AdventureGroupMgr::RecruitPlayer(', 'bool AdventureGroupMgr::BotLed(', 'bool AdventureGroupMgr::FollowMember(', 'bool AdventureGroupMgr::Forming(', 'bool AdventureGroupMgr::PlannedTransition(', 'bool AdventureGroupMgr::CanInvite(', 'bool AdventureGroupMgr::Accept(', 'void AdventureGroupMgr::CheckFullGroup(', 'void AdventureGroupMgr::Update(', 'void AdventureGroupMgr::Invite(', 'void AdventureGroupMgr::RequestPlayerAdmission(', 'bool AdventureGroupMgr::PlayerLeadership(', 'bool AdventureGroupMgr::EnforceCapacity(']:code+='\n'+fun(name) code+=r''' int main(){ using namespace AdventureGroupPolicy; + // Joined membership triggers gathering; pending invitations alone do not. + {AdventureGroupMgr check;Player one,two,human;one.guid={801};two.guid={802};human.guid={803};human.session.clientless=false; + Group g;g.leader=one.guid;g.count=4;one.group=two.group=&g; + Member second{&two},first{&one,&second};g.members=&first; + ObjectAccessor::players={{one.guid,&one},{two.guid,&two},{human.guid,&human}}; + behavior.plans[801]={true,true,"groupformation","city","instance","destination",3600};behavior.plans[802]=behavior.plans[801]; + unsigned before=behavior.arms;check._records[two.guid].Inviter=one.guid;check._records[two.guid].InviteUntil=200; + check.CheckFullGroup(&one);assert(behavior.arms==before); + g.count=5;check.CheckFullGroup(&one);assert(behavior.arms==before+2); + // A real player may lead the full group; forming and one-time departure exemption still work. + g.leader=human.guid;human.group=&g;before=behavior.arms; + assert(check.Forming(&one));check.CheckFullGroup(&one);assert(behavior.arms==before+2); + behavior.plans[801].Intent="instance";behavior.plans[801].Forming=false;behavior.plans[801].EarlyDeparture=true; + assert(check.PlannedTransition(&one)&&!check.PlannedTransition(&one)); + // Ten-player native raids fill at ten; the twenty-bot cap still applies to larger raids. + behavior.plans[801]={true,true,"groupformation","city","raid","destination",3600};behavior.plans[801].Capacity=10; + g.count=9;before=behavior.arms;check.CheckFullGroup(&one);assert(behavior.arms==before); + g.count=10;check.CheckFullGroup(&one);assert(behavior.arms==before+2); + g.leader=one.guid;behavior.plans[801].Mode=2;check._records[one.guid].SearchAt=10000; + check.Update(&one);assert(one.difficulty==2&&g.raid); // native heroic10, no hardcoded25-normal reset + ObjectAccessor::players.clear();behavior.plans.clear();} + {AdventureGroupMgr check;Player leader,member;leader.guid={901};member.guid={902};Group party;party.leader=leader.guid;leader.group=member.group=&party; ObjectAccessor::players[leader.guid]=&leader;ObjectAccessor::players[member.guid]=&member; behavior.plans[leader.guid.id]={true,true,"groupformation","capital","instance","target"}; @@ -106,7 +128,7 @@ int main(){ b.group=&party;behavior.plans[31].Intent="instance";behavior.plans[31].Forming=false; assert(mgr.PlannedTransition(&b));assert(!mgr.PlannedTransition(&b)); // Raid native conversion and twentieth-member boundary. - b.group=nullptr;b.invite=nullptr;a.group=&party;party.count=19;behavior.plans[29].NextIntent="raid"; + b.group=nullptr;b.invite=nullptr;a.group=&party;party.count=19;behavior.plans[29].NextIntent="raid";behavior.plans[29].Mode=1; behavior.plans[31]={true,true,"groupformation","ironforge","raid","otherraid",3600}; mgr._records[a.guid].SearchAt=0;mgr._records[a.guid].Partners.clear();mgr._records[b.guid].Partners.clear();mgr._records[b.guid].InviteUntil=0; mgr.Update(&a);assert(a.difficulty==RAID_DIFFICULTY_25MAN_NORMAL&&party.raid&&invites==3&&mgr.Accept(&b,&a)); diff --git a/tests/clientless/test_adventure_instance.py b/tests/clientless/test_adventure_instance.py index 77d2ba27fc..3b2d35971c 100644 --- a/tests/clientless/test_adventure_instance.py +++ b/tests/clientless/test_adventure_instance.py @@ -25,13 +25,15 @@ struct Maps{auto LookupEntry(uint32){return &mapEntry;}}sMapStore; struct WorldPacket{uint32 trigger=0;WorldPacket(int,int){}WorldPacket& operator<<(uint32 id){trigger=id;return *this;}}; struct Save{uint32 GetInstanceId(){return 7;}unsigned GetPlayerCount(){return 1;}unsigned GetGroupCount(){return 0;}}save; struct Bind{Save* save=nullptr;}; +using Difficulty=unsigned;constexpr int MSG_SET_RAID_DIFFICULTY=2,MSG_SET_DUNGEON_DIFFICULTY=3; +struct Group{unsigned GetLeaderGUID(){return 0;}Difficulty GetDifficulty(bool){return 0;}}group; struct Session; struct Player:Position{ uint32 map=0,instance=0,health=100,maximum=100;int stand=0,damages=0,entryCalls=0,exitCalls=0,teleports=0; bool safe=true,group=false,deny=false;Bind bind;Session* session=nullptr; - struct Guid{uint32 GetCounter(){return 26;}};Guid GetGUID(){return {};} + struct Guid{uint32 GetCounter(){return 26;}operator unsigned()const{return 26;}};Guid GetGUID(){return {};} uint32 GetMapId(){return map;}uint32 GetInstanceId(){return instance;}bool IsInWorld(){return true;} - bool IsBeingTeleported(){return false;}bool GetGroup(){return group;} + bool IsBeingTeleported(){return false;}Group* GetGroup(){return group?&::group:nullptr;} Bind* GetBoundInstance(uint32,int){return bind.save?&bind:nullptr;}int GetDifficulty(bool){return 0;} bool IsInAreaTriggerRadius(Trigger* t){return map==t->ContinentID&&std::fabs(x-t->Pos.X)<3;} float GetAngle(float,float){return 0;}void SetFacingTo(float){}void HandleEmoteCommand(int){} @@ -41,7 +43,8 @@ struct Player:Position{ float GetExactDist(Position const& p){return GetExactDist(p.x,p.y,p.z);} bool TeleportTo(uint32 m,float a,float b,float c,float o){++teleports;map=m;instance=m==36?7:0;x=a;y=b;z=c;this->o=o;return true;} }; -struct Session{Player* p;void CompleteClientlessTeleport(){}void HandleAreaTriggerOpcode(WorldPacket const& packet){ +namespace ObjectAccessor{Player*FindPlayer(unsigned){return nullptr;}} +struct Session{bool IsClientless(){return true;}void HandleSetRaidDifficultyOpcode(WorldPacket&){}void HandleSetDungeonDifficultyOpcode(WorldPacket&){}Player* p;void CompleteClientlessTeleport(){}void HandleAreaTriggerOpcode(WorldPacket const& packet){ if(packet.trigger==78){++p->entryCalls;if(!p->deny){p->map=36;p->instance=7;p->x=0;}} else{++p->exitCalls;if(!p->deny){p->map=0;p->instance=0;p->x=500;}} }}; diff --git a/tests/clientless/test_adventure_instance_begin.py b/tests/clientless/test_adventure_instance_begin.py index 0d45e4bd3d..5bef65bfdc 100644 --- a/tests/clientless/test_adventure_instance_begin.py +++ b/tests/clientless/test_adventure_instance_begin.py @@ -12,7 +12,7 @@ code=r''' #define TC_LOG_INFO(...) ((void)0) struct Player{struct Guid{int GetCounter(){return 26;}};Guid GetGUID(){return {};}}; struct ClientlessIntent{std::string Name;std::unordered_map Parameters;}; -struct Location{bool IsArea=false;}location; +struct Location{bool IsArea=false;std::string BaseKey;}location; struct Store{bool exists=true;Location* Find(std::string const&){return exists?&location:nullptr;}}store;auto sAdventureLocationMgr=&store; struct Manager{void ClearIntent(int){}void SetIntent(int,ClientlessIntent){}}manager;auto sClientlessPlayerMgr=&manager; struct AdventureBehaviorMgr{ @@ -30,6 +30,7 @@ struct AdventureBehaviorMgr{ int main(){Player p;AdventureBehaviorMgr mgr;AdventureBehaviorMgr::Record r; mgr.Begin(&p,r);assert(mgr.action=="travel_to_location"&&mgr.parameter=="id"&&mgr.target=="instance_visit_at_101"&&mgr.hard); assert(r.State==AdventureBehaviorMgr::Phase::Travel&&!r.Dirty); + location.BaseKey="instance_visit_at_101";r.WantedLocation="instance_visit_at_101_d1";mgr.Begin(&p,r);assert(mgr.target==location.BaseKey&&r.Location==r.WantedLocation); mgr.mapped=false;mgr.Begin(&p,r);assert(mgr.fallback==1); mgr.mapped=true;r.WantedIntent="social";mgr.Begin(&p,r);assert(mgr.action=="travel_to_location"&&!mgr.hard); location.IsArea=true;mgr.Begin(&p,r);assert(mgr.action=="travel_to_node"&&mgr.target=="reachable"&&mgr.hard); diff --git a/tests/clientless/test_adventure_instance_persistence.py b/tests/clientless/test_adventure_instance_persistence.py index 0bc0dcc688..19063a5a87 100644 --- a/tests/clientless/test_adventure_instance_persistence.py +++ b/tests/clientless/test_adventure_instance_persistence.py @@ -45,12 +45,14 @@ struct DB{ } }CharacterDatabase; struct Player{ + unsigned GetDifficulty(bool){return 0;} uint32 health=100,map=36,instance=7;bool teleport=false;int stand=0; struct Guid{uint32 GetCounter(){return 26;}};Guid GetGUID(){return {};} bool IsBeingTeleported(){return teleport;}uint32 GetMapId(){return map;}uint32 GetInstanceId(){return instance;} void SaveToDB(Tx& tx){tx->PAppend("CHARACTER_SAVE",health,map,instance);} void SetStandState(int v){stand=v;} }; +struct Location{unsigned InstanceDifficulty=1;};struct Locations{Location l;Location*Find(std::string const&){return &l;}}locations;auto sAdventureLocationMgr=&locations; struct AdventureBehaviorMgr{ enum class Phase{Idle,OnSite}; struct Record{AdventureInstancePolicy::Visit Instance;bool Schedule=false,Dirty=false;Phase State=Phase::Idle; diff --git a/tests/clientless/test_adventure_instance_variants.py b/tests/clientless/test_adventure_instance_variants.py new file mode 100644 index 0000000000..b23ffa246e --- /dev/null +++ b/tests/clientless/test_adventure_instance_variants.py @@ -0,0 +1,67 @@ +"""Exercise production catalog expansion with the testserver's Cataclysm DBC records.""" +from pathlib import Path +import struct,subprocess,tempfile +root=Path(__file__).resolve().parents[2] +source=(root/'src/server/game/World/AdventureLocationMgr.cpp').read_text() +def function(sig): + a=source.index(sig);b=source.index('{',a);e=b+1;n=1 + while n:n+=(source[e]=='{')-(source[e]=='}');e+=1 + return source[a:e] +def dbc(name): + b=(Path('/home/wow_admin/wow_cata_server/data/dbc/enUS')/name).read_bytes() + magic,count,fields,size,strings=struct.unpack_from('<4s4I',b);assert magic==b'WDBC' + return [struct.unpack_from('<'+'I'*fields,b,20+n*size)for n in range(count)] +maps={33,34,36,469,533,671} +lfg=[r for r in dbc('LFGDungeons.dbc')if r[7]in maps] +modes=[r for r in dbc('MapDifficulty.dbc')if r[1]in maps] +header=(root/'src/server/game/World/AdventureLocationMgr.h').read_text();a=header.index('struct AdventureLocation');b=header.index('struct AdventureMicroAction',a) +code=r''' +#include +#include +#include +#include +#include +using uint8=unsigned char;using uint32=unsigned;using uint64=unsigned long long;using Difficulty=unsigned; +constexpr unsigned RAID_DIFFICULTY_25MAN_NORMAL=1; +struct ClientlessWorldPoint{std::string Id;}; +'''+header[a:b]+r''' +struct MapEntry{unsigned ID;bool raid;bool IsRaid(){return raid;}bool IsDungeon(){return true;}}; +struct Maps{std::unordered_mapentries;MapEntry*LookupEntry(unsigned id){return entries.count(id)?&entries.at(id):nullptr;}}sMapStore; +struct MapDifficulty{unsigned maxPlayers;};struct DBC{std::unordered_mapentries;MapDifficulty*GetMapDifficultyData(unsigned id,Difficulty mode){auto key=(uint64(id)<<8)|mode;return entries.count(key)?&entries.at(key):nullptr;}}sDBCManager; +struct LFGEntry{unsigned MapID,DifficultyID,MinLevel,Maxlevel;};std::vectorsLFGDungeonStore; +struct Access{unsigned levelMin,levelMax;};struct Objects{Access*GetAccessRequirement(unsigned,Difficulty){return nullptr;}}objects;auto sObjectMgr=&objects; +struct AdventureLocationMgr{struct Band{unsigned Min=0,Max=0;};std::unordered_map_instanceBands;std::vector_locations;void BuildInstanceVariants(); + AdventureLocation const*Find(std::string const&key){for(auto const&l:_locations)if(l.Point.Id==key)return &l;return nullptr;} + void LoadBands(){ +''' +a=source.index(' _instanceBands.clear();');b=source.index(' if (! WorldDatabase.Query',a) +code+=source[a:b]+'}\n};\n'+function('void AdventureLocationMgr::BuildInstanceVariants(')+'\nint main(){\n' +for id in maps:code+=f'sMapStore.entries[{id}]={{{id},{str(id in {469,533,671}).lower()}}};\n' +for r in modes:code+=f'sDBCManager.entries[({r[1]}ull<<8)|{r[2]}]={{{r[5]}}};\n' +for r in lfg:code+=f'sLFGDungeonStore.push_back(new LFGEntry{{{r[7]},{r[8]},{r[2]},{r[3]}}});\n' +code+='AdventureLocationMgr mgr;mgr.LoadBands();\n' +for id in maps:code+=f'{{AdventureLocation l;l.Point.Id="map{id}";l.Name="Map{id}";l.InstanceMap={id};mgr._locations.push_back(l);}}\n' +code+=r''' +mgr.BuildInstanceVariants(); +for(auto id:{33,36}){ + auto normal=mgr.Find("map"+std::to_string(id)),heroic=mgr.Find("map"+std::to_string(id)+"_d1"); + assert(normal&&heroic&&normal->InstanceDifficulty==0&&heroic->InstanceDifficulty==1); + assert(normal->MinLevel<30&&normal->MaxLevel<30); // excludes obsolete84..85 normal SFK row + assert(heroic->MinLevel==85&&heroic->MaxLevel==85&&heroic->InstanceCapacity==5); + assert(heroic->BaseKey==normal->Point.Id&&heroic->Name.find("heroisch")!=std::string::npos); +} +assert(mgr.Find("map34")->MaxLevel==30&&!mgr.Find("map34_d1")); +auto classic=mgr.Find("map469");assert(classic->InstanceCapacity==20&&classic->InstanceDifficulty==0&&classic->MinLevel==60); +assert(!mgr.Find("map469_d1")&&!mgr.Find("map469_d2")&&!mgr.Find("map469_d3")); // native zero-capacity placeholders +assert(mgr.Find("map533")->InstanceCapacity==20&&mgr.Find("map533_d0")->InstanceCapacity==10); +for(unsigned mode=0;mode<4;++mode){auto key=mode==1?"map671":"map671_d"+std::to_string(mode);auto l=mgr.Find(key); + assert(l&&l->InstanceDifficulty==mode&&l->MinLevel==85&&l->InstanceCapacity==(mode%2?20:10)); + assert(l->BaseKey=="map671");} +for(auto entry:sLFGDungeonStore)delete entry; +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Real Cataclysm DBC: dungeon normal/heroic, obsolete SFK row, classic raid placeholders, native10/25 normal/heroic and physical entrance reuse passed.') diff --git a/tests/clientless/test_adventure_intent_state.py b/tests/clientless/test_adventure_intent_state.py index aede760cc4..ef2fb63e2a 100644 --- a/tests/clientless/test_adventure_intent_state.py +++ b/tests/clientless/test_adventure_intent_state.py @@ -6,6 +6,7 @@ s=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() a=s.index('void AdventureBehaviorMgr::Update(');b=s.index('bool AdventureBehaviorMgr::Command(',a) code=r''' #include "AdventureBehaviorPolicy.h" +#include "AdventureGroupPolicy.h" #include #include #include @@ -55,7 +56,7 @@ class AdventureBehaviorMgr { public: enum class Phase{Idle,Travel,OnSite,Switching,Handover}; struct Record{ - bool Enabled=true,Schedule=false,Queued=false,Dirty=false; + bool Enabled=true,Schedule=false,Queued=false,Dirty=false;AdventureGroupPolicy::Departure Departure; Phase State=Phase::Travel; std::string Intent="social",Location="tavern",WantedIntent="social",WantedLocation="tavern",Detail,Micro,ManualNextIntent,ManualNextLocation; time_t Deadline=1000;uint32 AreaRevision=0,Date=20261026,Timer=0,MicroTimer=0,GestureTimer=0,RetryTimer=0;int Hour=5; @@ -71,6 +72,7 @@ public: bool AdoptGroup(Player*,Player*){return false;} bool UpdateNavigationRescue(Player*,Record&){return false;} bool UpdateGathering(Player*,Record&,time_t){return false;} + bool UpdateDeparture(Player*,Record&,time_t){return false;} void Update(Player*,uint32); void EnterPlayerInstance(Player*); void Generate(Player*,Record&,uint32){} diff --git a/tests/clientless/test_adventure_location_import.py b/tests/clientless/test_adventure_location_import.py index 8a3342700d..0f2f12ba3b 100644 --- a/tests/clientless/test_adventure_location_import.py +++ b/tests/clientless/test_adventure_location_import.py @@ -27,7 +27,7 @@ struct Field{ ''' code+=function(field,'uint8 Field::GetUInt8() const')+'\n'+function(field,'uint16 Field::GetUInt16() const')+'\n'+function(field,'uint32 Field::GetUInt32() const') code+=r''' -struct AdventureLocation{struct{uint32 MapId=0;std::string Category;}Point;bool IsArea=false;uint32 InstanceMap=0,ZoneId=0,AreaId=0;}; +struct AdventureLocation{struct{uint32 MapId=0;std::string Category;}Point;bool IsArea=false;uint32 InstanceMap=0,ZoneId=0,AreaId=0,InstanceCapacity=5;}; struct MapEntry{bool raid=false;bool IsDungeon(){return true;}bool IsRaid(){return raid;}}dungeon,raid{true};struct Maps{MapEntry*LookupEntry(unsigned id){return id==36?&dungeon:(id==409||id==999)?&raid:nullptr;}}sMapStore; using Difficulty=unsigned;constexpr unsigned RAID_DIFFICULTY_25MAN_NORMAL=1;struct MapDifficulty{unsigned maxPlayers=40;}largeRaid,smallRaid{10};struct DBC{MapDifficulty*GetDownscaledMapDifficultyData(unsigned id,Difficulty&){return id==999?&smallRaid:&largeRaid;}}sDBCManager; struct AdventureLocationMgr{std::unordered_map> _types;float Weight(AdventureLocation const&,std::string const&)const;}; @@ -62,7 +62,7 @@ int main(){ place.Point.Category="instance_entrance";mgr._types[place.Point.Category]["instance"]=5;mgr._types[place.Point.Category]["raid"]=5; place.InstanceMap=36;assert(mgr.Weight(place,"instance")==5&&mgr.Weight(place,"raid")==0); place.InstanceMap=409;assert(mgr.Weight(place,"instance")==0&&mgr.Weight(place,"raid")==5); - place.InstanceMap=999;assert(mgr.Weight(place,"raid")==0); + place.InstanceMap=999;assert(mgr.Weight(place,"raid")==5);place.InstanceCapacity=0;assert(mgr.Weight(place,"raid")==0);place.InstanceCapacity=5; place.InstanceMap=0;assert(mgr.Weight(place,"instance")==0&&mgr.Weight(place,"raid")==0); place.Point.Category="landmark";mgr._types["landmark"]["groupformation"]=1; place.ZoneId=12;assert(mgr.Weight(place,"groupformation")==0); diff --git a/tests/clientless/test_adventure_navigation_rescue.py b/tests/clientless/test_adventure_navigation_rescue.py index 8a5968592d..77ed20e938 100644 --- a/tests/clientless/test_adventure_navigation_rescue.py +++ b/tests/clientless/test_adventure_navigation_rescue.py @@ -3,7 +3,7 @@ from pathlib import Path import subprocess,tempfile root=Path(__file__).resolve().parents[2] s=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() -a=s.index('bool AdventureBehaviorMgr::UpdateNavigationRescue(');b=s.index('\nvoid AdventureBehaviorMgr::Update(',a) +a=s.index('bool AdventureBehaviorMgr::UpdateNavigationRescue(');b=s.index('\nbool AdventureBehaviorMgr::UpdateGathering(',a) code=r''' #include "AdventureRecoveryPolicy.h" #include diff --git a/tests/clientless/test_adventure_northrend_flight.py b/tests/clientless/test_adventure_northrend_flight.py new file mode 100644 index 0000000000..00f24bce17 --- /dev/null +++ b/tests/clientless/test_adventure_northrend_flight.py @@ -0,0 +1,72 @@ +"""Run production provisioning, legacy profile defaults and Northrend entry gate.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +def function(source,sig): + a=source.index(sig);b=source.index('{',a);e=b+1;n=1 + while n:n+=(source[e]=='{')-(source[e]=='}');e+=1 + return source[a:e] +generator=(root/'src/server/game/World/AdventureGeneratorMgr.cpp').read_text() +area=(root/'src/server/game/World/AdventureAreaMgr.cpp').read_text() +flight=(root/'src/server/game/World/ClientlessFlightMgr.cpp').read_text() +lua=(root/'src/server/game/World/ClientlessLuaEngine.cpp').read_text() +a=lua.index(' // All autonomous level60+');b=lua.index(' uint32 const guid =',a) +code=r''' +#include +#include +#include +#include +#include +using uint32=unsigned; +constexpr unsigned TEAM_ALLIANCE=0,SKILL_RIDING=1,SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED=206,SPELL_AURA_MOUNTED=78; +struct Mount{unsigned DisplayId,Weight;};struct ClientlessAdventureProfile{bool AutonomousBehavior=true,CanFreeFly=false;unsigned Level=0;std::vectorFlightMounts;}profile; +struct Guid{unsigned GetCounter()const{return 1;}}; +struct Player{unsigned level=60,team=0,riding=0;bool managed=true;std::unordered_setspells,blocked; + unsigned getLevel()const{return level;}unsigned GetTeamId(){return team;}Guid GetGUID()const{return {};} + bool HasSpell(unsigned spell)const{return spells.count(spell);}void LearnSpell(unsigned spell,bool){spells.insert(spell);} + unsigned GetSkillValue(unsigned)const{return riding;}void SetSkill(unsigned,unsigned,unsigned value,unsigned){riding=value;} +}; +bool Learnable(Player*p,unsigned spell){return !p->blocked.count(spell);} +struct Effect{unsigned ApplyAuraName;int MiscValueB;};struct Spell{std::vectorEffects{{78,247}};}spell; +struct Spells{Spell*GetSpellInfo(unsigned id){return id==32235||id==32243?&spell:nullptr;}}spells;auto sSpellMgr=&spells; +struct Lua{ClientlessAdventureProfile*GetAdventureProfile(unsigned){return &profile;}}engine;auto sClientlessLuaEngine=&engine; +struct Config{bool enabled=true;bool GetBoolDefault(char const*,bool){return enabled;}}config;auto sConfigMgr=&config; +using SpellInfo=Spell; +'''+function(flight,'uint32 FlightType(')+r''' +struct ClientlessFlightMgr{bool Enabled(Player const*)const;}flightMgr;auto sClientlessFlightMgr=&flightMgr; +struct Position{float GetPositionX()const{return 0;}float GetPositionY()const{return 0;}float GetPositionZ()const{return 0;}}; +struct AdventureAreaMgr{bool preferred=false;struct Policy{bool PreferFlight;};Policy Area(unsigned){return {preferred};}bool Applies(Player const*p){return p->managed;}bool EnsureLoaded(){return true;}unsigned AreaAt(Player const*,unsigned,Position const&){return 1;}bool AllowsArea(Player const*,unsigned){return true;}bool AllowsPosition(Player const*,uint32,Position const&);}; +void Normalize(Player*player,ClientlessAdventureProfile&profile){ +'''+lua[a:b]+'}\n'+function(generator,'bool ProvisionFlight(')+'\n'+function(flight,'bool ClientlessFlightMgr::Enabled(')+'\n'+function(area,'bool AdventureAreaMgr::AllowsPosition(')+r''' +int main(){Player p;Position pos;AdventureAreaMgr areas;std::string error; + p.level=59;Normalize(&p,profile);assert(!profile.CanFreeFly);assert(ProvisionFlight(&p,profile,error));assert(!areas.AllowsPosition(&p,571,pos)); + p.level=60;Normalize(&p,profile);assert(profile.CanFreeFly&&profile.FlightMounts.size()==1&&profile.FlightMounts[0].DisplayId==32235); + assert(ProvisionFlight(&p,profile,error)&&p.riding==225&&p.HasSpell(90267)&&p.HasSpell(34090)); + assert(!p.HasSpell(54197)&&flightMgr.Enabled(&p)&&!areas.AllowsPosition(&p,571,pos));assert(areas.AllowsPosition(&p,530,pos)); + areas.preferred=true;assert(areas.AllowsPosition(&p,530,pos)); // level60 Outland, no cold weather needed + p.spells.erase(90267);assert(!areas.AllowsPosition(&p,1,pos)&&!areas.AllowsPosition(&p,646,pos)); + assert(areas.AllowsPosition(&p,530,pos));p.spells.insert(90267); + p.level=59;assert(!areas.AllowsPosition(&p,530,pos));p.level=60; + p.spells.erase(32235);assert(!areas.AllowsPosition(&p,530,pos)&&!areas.AllowsPosition(&p,0,pos));p.spells.insert(32235); + areas.preferred=false;p.level=1;assert(areas.AllowsPosition(&p,530,pos)); // racial starting islands are not flight regions + p.level=60; + p.level=67;assert(ProvisionFlight(&p,profile,error)&&!areas.AllowsPosition(&p,571,pos)); + p.level=68;assert(ProvisionFlight(&p,profile,error)&&p.HasSpell(54197)&&areas.AllowsPosition(&p,571,pos)); + // Missing native permission, skill, learned mount or enabled flight denies ALL arrivals. + p.spells.erase(54197);assert(!areas.AllowsPosition(&p,571,pos));p.spells.insert(54197); + p.riding=150;assert(!areas.AllowsPosition(&p,571,pos));assert(ProvisionFlight(&p,profile,error)); + p.spells.erase(32235);assert(!areas.AllowsPosition(&p,571,pos));assert(ProvisionFlight(&p,profile,error)); + config.enabled=false;assert(!areas.AllowsPosition(&p,571,pos));config.enabled=true; + p.blocked.insert(54197);p.spells.erase(54197);assert(!ProvisionFlight(&p,profile,error)&&!error.empty()&&!areas.AllowsPosition(&p,571,pos)); + // Horde legacy profiles receive a wyvern; existing mount preferences survive normalization. + profile={};p={};p.level=85;p.team=1;p.riding=300;Normalize(&p,profile); + assert(profile.FlightMounts[0].DisplayId==32243&&ProvisionFlight(&p,profile,error)&&p.riding==300&&areas.AllowsPosition(&p,571,pos)); + profile.FlightMounts={{32243,17}};Normalize(&p,profile);assert(profile.FlightMounts.size()==1&&profile.FlightMounts[0].Weight==17); + p.managed=false;p.level=1;p.spells.clear();assert(areas.AllowsPosition(&p,571,pos)); // ordinary players unaffected +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Production flight: legacy defaults, level60 riding, level68 cold weather, all-entry denial, repair, faction mount, preserved weights and ordinary-player isolation passed.') diff --git a/tests/clientless/test_adventure_population.py b/tests/clientless/test_adventure_population.py index 24626fb7a7..9dc3a0c952 100644 --- a/tests/clientless/test_adventure_population.py +++ b/tests/clientless/test_adventure_population.py @@ -26,7 +26,7 @@ struct Core{bool succeed=true;std::vectorstarted;bool StartPlayer(unsi int CreateClientlessAdventureLuaController(){return 0;} float sample=.5;float frand(int,int){return sample;} #define TC_LOG_INFO(...) ((void)0) -struct Profile{unsigned ProfileVersion=0,Class=0,Level=0;std::string SourcePath;}; +struct Profile{bool AutonomousBehavior=false;unsigned ProfileVersion=0,Class=0,Level=0;std::string SourcePath;}; struct Player{ObjectGuid GetGUID(){return ObjectGuid{ObjectGuid::Player,26};}}; struct Lua{Profile* profile=nullptr;Profile* GetAdventureProfile(unsigned){return profile;}}lua;auto sClientlessLuaEngine=&lua; struct AdventurePopulationMgr{ diff --git a/tests/clientless/test_adventure_profession_profile.py b/tests/clientless/test_adventure_profession_profile.py index a07b6afd92..9e47efe3a8 100644 --- a/tests/clientless/test_adventure_profession_profile.py +++ b/tests/clientless/test_adventure_profession_profile.py @@ -5,7 +5,7 @@ import tempfile root = Path(__file__).resolve().parents[2] source = (root/'src/server/game/World/ClientlessLuaEngine.cpp').read_text() a = source.index(' lua_getfield(_state,rootTable,"professions");') -b = source.index(' uint32 const guid', a) +b = source.index(' // All autonomous level60+', a) code = r''' #include #include "AdventureProfessionPolicy.h"