From d1ef007ceff20abb73ebf881ca9de2286ba2d571 Mon Sep 17 00:00:00 2001 From: nico Date: Sat, 31 Oct 2026 15:35:46 +0100 Subject: [PATCH] Improve AdventureBot planning and travel performance --- doc/clientless/SESSION-RESUME.md | 112 +++++++++++++ .../validation.json | 13 ++ .../2026-10-31-plan-command/lifecycle.json | 6 + .../live-plan-before.json | 44 +++++ .../live-plan-restored.json | 44 +++++ .../live-temporary-plan.json | 6 + .../2026-10-31-plan-command/recovery.json | 132 +++++++++++++++ .../2026-10-31-plan-command/validation.json | 20 +++ .../recovery-before-build.md | 41 +++++ doc/clientless/plan-commands.md | 66 ++++++++ src/server/game/World/AdventureAreaMgr.cpp | 19 +-- .../game/World/AdventureBehaviorMgr.cpp | 103 ++++++++++++ src/server/game/World/AdventureBehaviorMgr.h | 1 + .../game/World/AdventureGroupTravelMgr.cpp | 9 +- src/server/game/World/AdventurePlanCommand.h | 67 ++++++++ .../game/World/ClientlessTravelGraphMgr.cpp | 155 ++++++++++++++++-- .../clientless/test_adventure_group_travel.py | 12 +- .../test_adventure_plan_commands.py | 83 ++++++++++ 18 files changed, 906 insertions(+), 27 deletions(-) create mode 100644 doc/clientless/checkpoints/2026-10-31-brindra-group-validator/validation.json create mode 100644 doc/clientless/checkpoints/2026-10-31-plan-command/lifecycle.json create mode 100644 doc/clientless/checkpoints/2026-10-31-plan-command/live-plan-before.json create mode 100644 doc/clientless/checkpoints/2026-10-31-plan-command/live-plan-restored.json create mode 100644 doc/clientless/checkpoints/2026-10-31-plan-command/live-temporary-plan.json create mode 100644 doc/clientless/checkpoints/2026-10-31-plan-command/recovery.json create mode 100644 doc/clientless/checkpoints/2026-10-31-plan-command/validation.json create mode 100644 doc/clientless/checkpoints/2026-10-31-route-performance/recovery-before-build.md create mode 100644 doc/clientless/plan-commands.md create mode 100644 src/server/game/World/AdventurePlanCommand.h create mode 100644 tests/clientless/test_adventure_plan_commands.py diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 8cd70f7d30..38b99bc676 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,115 @@ +# Aktuell: Routenplaner-Last behoben und volle Population live geprüft + +Nutzer meldete Serverlags und bat nach Prüfung bestehender Navigationsprobleme +um den Fix. Vorher mit110Bots: ActionExecutor regelmäßig20–29s je30s, einzelne +PlanAdaptiveRoute-Aufrufe bis10.9s/13693FlightEstimates. Ursache war eine implizit +nahezu vollständige Flugverknüpfung, Flugausstiegprüfung jedes Straßenpunktes, +virtuelle Straßeneinstiegssuche auch an normalen Graphknoten und insbesondere +40-Yard-Gebietssampling ganzer nativer Taxipfade für neue Level/Fraktionskeys. + +Fix in ClientlessTravelGraphMgr: maximal2 vorsortierte Flugausstiege pro Kante/ +8proPlan,16 echte FlightEstimates proPlan, Schätzcache über beide Suchtier, +Flugconnectoren nur Ziel/mapwechselnde oder physische Transportquellen statt +aller Taxis, direkter same-map Location-Flug vor Makrosuche, virtuelle Forward- +Entry-Reparatur nur an echten Graph-Sackgassen. AdventureAreaMgr prüft native +Taxipfadexistenz plus bereits fraktions-/gebietskataloggeprüfte Endknoten; das +Überfliegen gesperrter Gebiete ist erlaubt und wird nicht mehr in40yd-Schritten +abgetastet. Native Flug-/Gebiets-/Start-/Landesicherheit bleibt bestehen. + +Mehrere inkrementelle Builds regelkonform: Loader/Testaufnahme0, safe logout, +nullSitzungen oder dokumentierte5min-Ausnahme, saubererShutdown, ausschließlich +-j10. Gezielter test_free_flight_planner bei jedem Build PASS, diff-check PASS. +Final installiert; frischer ready-Prozess47340, Ports8085/8087. Vier Recoverybots +54/64/89/109 in Fraktionshauptstädten geprüft; Details unter +doc/clientless/checkpoints/2026-10-31-route-performance/. Loader/Testaufnahme +wieder1, volle Pools50/5/50/5=110 online. + +Live nach finalem Fix: Kaltplanung statt10–20s noch1.93s einmalig; folgende +vergleichbare Routen51–153ms. Bei110Bots drei volle30s-Fenster Actions4.11/4.38/ +4.14s statt20–29s; Controllers6.63/7.06/6.79s, keine RECOVERY_FAILED oder +TRAVEL_PLAN_FAILED. CPU-Prozess etwa130% über mehrere Threads; Server bleibt +ready. Noch auffällig: Fizzira43 wiederholt alle2–3s denselben Intent-Fallback +instance_visit_at_2217->446 (Intent none, keine Action), kein eigentlicher +Travelplan-Fehler. Andere neue Fallbacks:154(13),95(12),50/114(8),146(7),105(6). +Fizzira ist ein separater Tagesplan/Intent-Fallback-Bug und sollte als nächstes +behoben werden. Kein Commit/Push für diese Arbeit angefordert. + +--- + +# Aktuell: Brindras verwaister Gruppen-Pfadvalidator behoben und live geprüft + +Brindra86 hing nach einer früheren Gruppe hinter der Tiefenbahn auf der +Eisenschmiede-Seite fest. MMAP berechnete vollständige Wege, doch TravelMgr +meldete `owner ground-safety validator rejected spline`; die Route startete +wiederholt über `virtual_ground_entry`. Ursache: `AdventureGroupTravelMgr::Observe` +kehrte bei `GetGroup()==nullptr` zurück, ohne den zuvor installierten +GroundPathAdapter/PathValidator und Gruppenreise-Record zu entfernen. + +Fix in AdventureGroupTravelMgr.cpp: Wenn keine Gruppe mehr besteht und ein +Gruppenreise-Record vorhanden ist, wird `Clear(player)` aufgerufen. Dadurch werden +Adapter, Validator, eigener Reisezustand und Record entfernt; Bots ohne früheren +Record bleiben unberührt. Regression in test_adventure_group_travel.py verfolgt +beide Registrierungen und beweist deren Entfernung samt Record/Reiseabbruch. + +Buildregel eingehalten: Loader/Testaufnahme0, safe logout angefordert. Nach fünf +Minuten blieben13 Bots auf regulären Flug/Taxistrecken; Saveall, vollständige TSV- +Liste mit GUID/Name/Fraktion/Position/Taxi und native Info/Flight/Transport/Action- +Ausgaben unter `/home/wow_admin/wow_cata_server/checkpoints/brindra-group-validator/`. +Server sauber beendet. Gruppentravel-TestPASS. Build-j10 am Ende vollständig +gelinkt (ELF geprüft), installiert, frischer ready-Start Prozess32369, Ports8085/ +8087. Wegen einer fehlerhaften Tool-Sitzungsrückmeldung liefen kurz mehrere eigene +Linkprozesse auf dasselbe Artefakt; ausschließlich diese Prozesse beendet, danach +ein einzelner sauberer Neu-Link. Installierte alte Binary war dabei nie überschrieben. + +Alle13 Restbots eingeloggt, alte Flug-/Transportzustände waren weg, in +Sturmwind/Orgrimmar geborgen und per Area-Ausgabe bestätigt. Loader/Testaufnahme +wieder1; tatsächlich enabled1/schema1/busy0/known110. Brindra war erneut in einer +Gruppe; `interaction 86 leave` führte live zu `No group travel state`, grouped0. +Der zuvor abgelehnte Weg läuft nun mit Travel=Moving und Nav reason=`ok`, ohne +PathRejected. Brindras Schedule ist wieder an. Kein Commit/Push angefordert. + +--- + +# Aktuell: GM-Tagesplaneditor implementiert, gebaut, installiert und live geprüft + +Nutzerauftrag: aktuelle/künftige Intents samtLocation perBefehl, GUID/Population, +Fraktions- undLevelgruppenfilter. NeuerBefehl `clientless adventure editplan` +unterbestehendemGM-Kommando. Syntax/Beispiele: doc/clientless/plan-commands.md. +Scope ausdrücklich EINGELOGGTE AdventureBots, heutige aktuelle/künftigeStunden. +GUID|all, current|next|next+N|Stundenrange, Intent|keep|instance-or-raid, +Location|auto|capital|keep, alliance|horde|both, Min/Max1..85, preview|apply. +Vorschau kopiertRecord und verändert wederDB nochRNG. Änderungen validiert mit +normalemAllowed/Level/Area/Flight; FormationbenötigtzulässigenFolgeblock, nieStunde23. +GültigeBots persistieren sofort und aktualisierenRAMPlan; ungültigeBots werden +mitGrund proBot komplett übersprungen. Aktuell benutzt sicherenSetWantedHandover, +zukünftig lässtaktuellenIntentlaufen. KeineTeleports/Gruppenauflösung durchEditor. +KeineSchemaänderung/keineLuaänderung nötig. Current reaktiviertSchedule, Future +behältmanuellenSchedulemode; beiBedarf schedule GUID on. + +Betrieb: User warerneutbeimAufzeichnen, asynchrongefragt; Antwort "bin fertig gut +aufgepasst" erlaubteInstallation/Restart. Loader/TestAdmissionpausiert, safeall um +13:41:55CET; nachmehrals5min9Restbots53/54/67/74/75/86/89/125/145 mitSnapshot, +saveallDBPositionen und verfügbarenFlags dokumentiert; sauberShutdown10, Prozessende +verifiziertVORBuild. BestehenderNinjaDebugBuild-j10 EXIT0, viergezielteTestsPASS. +InstallEXIT0, frischerreadyStartProzess25737, Ports8085/8087geprüft. NeunBots +geprüft/indenHauptstädtenzurückgesetzt. Brindra86brauchtezweitenTeleport nachsicherem +BehaviorStop; area5148bestätigt undSchedulewiederon. Loader/TestEnabled1undAdmission +restauriert; tatsächlichenLoader enabled1/schema1/busy0/pools50/5/50/5bestätigt. +Restbotpositionsquelle ausdrücklich letztesSaveall, keineunbelegtenLivekoordinaten. + +NativeLiveprüfung: Preview26next undallianceLevel60Filter, dann26Stunde20 +social->rest(auto), DBpersistenzgeprüft; originalsocial/goldshire_tavern wiedermit +Befehlhergestellt und alleSlots18..23gegenBackupidentischbestätigt. Dergeplante +Gruppentest14–15/15–16bleibtbestehen. RaidJoin0/BGJoin0unverändert. +KeinCommitfürdiesenAuftragangefordert/erstellt, vorherigerCommit69b5a1c4a1. +Code: AdventurePlanCommand.h, AdventureBehaviorMgr.cpp/.h. Test: +tests/clientless/test_adventure_plan_commands.py extrahiertproduktiveEditPlanMethode. +Belege: doc/clientless/checkpoints/2026-10-31-plan-command/. VierTestsstattkompletter +Suiteerneut, frühere120/120historisch. git diff --check erfolgreich. +Vorheriges32MBRohlogundPythonCacheweiterlokalunversioniert, nichtlöschen. + +--- + # Aktueller Gruppentest geplant: 31.10., 14–15 Uhr Vorbereitung, 15–16 Uhr Ziele Nutzer verlangt NÄCHSTEN Intent Gruppenbildung, danach gemischt Dungeon/Raid. diff --git a/doc/clientless/checkpoints/2026-10-31-brindra-group-validator/validation.json b/doc/clientless/checkpoints/2026-10-31-brindra-group-validator/validation.json new file mode 100644 index 0000000000..9681041e25 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-31-brindra-group-validator/validation.json @@ -0,0 +1,13 @@ +{ + "cause": "group travel path validator remained registered after leaving group", + "fix": "AdventureGroupTravelMgr::Observe clears an existing group-travel record when GetGroup returns null", + "regression_test": "PASS: test_adventure_group_travel.py", + "build": "PASS: CMake/Ninja Debug -j10, final artifact verified as ELF", + "installed": true, + "startup": "PASS: fresh ready, process 32369, ports 8085 and 8087", + "live_test": "PASS: Brindra left group, report showed no group travel state/grouped=0; same route changed from PathRejected to Moving with nav reason ok", + "loader_restored": "enabled=1 schema=1 busy=0 known=110", + "recovery_count": 13, + "raw_evidence": "/home/wow_admin/wow_cata_server/checkpoints/brindra-group-validator/", + "commit": null +} diff --git a/doc/clientless/checkpoints/2026-10-31-plan-command/lifecycle.json b/doc/clientless/checkpoints/2026-10-31-plan-command/lifecycle.json new file mode 100644 index 0000000000..0e864d2614 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-31-plan-command/lifecycle.json @@ -0,0 +1,6 @@ +{ + "Clientless.Adventure.Loader.Enabled": 1, + "Clientless.TestBot.Enabled": 1, + "requested_at": "2026-10-31T13:41:55.253785+01:00", + "monotonic": 24044.355791678 +} \ No newline at end of file diff --git a/doc/clientless/checkpoints/2026-10-31-plan-command/live-plan-before.json b/doc/clientless/checkpoints/2026-10-31-plan-command/live-plan-before.json new file mode 100644 index 0000000000..fef1ebcea4 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-31-plan-command/live-plan-before.json @@ -0,0 +1,44 @@ +[ + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "18", + "intent": "leisure", + "location_key": "poi_guard_542" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "19", + "intent": "groupformation", + "location_key": "poi_npc_314039" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "20", + "intent": "social", + "location_key": "goldshire_tavern" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "21", + "intent": "social", + "location_key": "goldshire_tavern" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "22", + "intent": "groupformation", + "location_key": "poi_npc_314313" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "23", + "intent": "raid", + "location_key": "instance_visit_at_3528" + } +] \ No newline at end of file diff --git a/doc/clientless/checkpoints/2026-10-31-plan-command/live-plan-restored.json b/doc/clientless/checkpoints/2026-10-31-plan-command/live-plan-restored.json new file mode 100644 index 0000000000..fef1ebcea4 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-31-plan-command/live-plan-restored.json @@ -0,0 +1,44 @@ +[ + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "18", + "intent": "leisure", + "location_key": "poi_guard_542" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "19", + "intent": "groupformation", + "location_key": "poi_npc_314039" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "20", + "intent": "social", + "location_key": "goldshire_tavern" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "21", + "intent": "social", + "location_key": "goldshire_tavern" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "22", + "intent": "groupformation", + "location_key": "poi_npc_314313" + }, + { + "character_guid": "26", + "local_date": "20261031", + "hour_slot": "23", + "intent": "raid", + "location_key": "instance_visit_at_3528" + } +] \ No newline at end of file diff --git a/doc/clientless/checkpoints/2026-10-31-plan-command/live-temporary-plan.json b/doc/clientless/checkpoints/2026-10-31-plan-command/live-temporary-plan.json new file mode 100644 index 0000000000..464f50045f --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-31-plan-command/live-temporary-plan.json @@ -0,0 +1,6 @@ +[ + { + "intent": "rest", + "location_key": "goldshire_tavern" + } +] \ No newline at end of file diff --git a/doc/clientless/checkpoints/2026-10-31-plan-command/recovery.json b/doc/clientless/checkpoints/2026-10-31-plan-command/recovery.json new file mode 100644 index 0000000000..de2c56aa73 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-31-plan-command/recovery.json @@ -0,0 +1,132 @@ +{ + "snapshot_at": "2026-10-31T13:47:02.811753+01:00", + "bots": [ + { + "guid": "53", + "name": "Noranda", + "race": "1", + "map": "571", + "position_x": "5728.95", + "position_y": "695.284", + "position_z": "688.058", + "taxi_path": "", + "faction": "alliance", + "position_source": "saveall immediately before shutdown; last database-saved position", + "individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately", + "recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde." + }, + { + "guid": "54", + "name": "Falaeriel", + "race": "10", + "map": "1", + "position_x": "1614", + "position_y": "-4323.1", + "position_z": "64.094", + "taxi_path": "", + "faction": "horde", + "position_source": "saveall immediately before shutdown; last database-saved position", + "individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately", + "recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde." + }, + { + "guid": "67", + "name": "Volasha", + "race": "8", + "map": "1", + "position_x": "-4483.48", + "position_y": "-1854", + "position_z": "132.231", + "taxi_path": "25 42 ", + "faction": "horde", + "position_source": "saveall immediately before shutdown; last database-saved position", + "individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately", + "recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde." + }, + { + "guid": "74", + "name": "Nimbin", + "race": "7", + "map": "1", + "position_x": "-3962.06", + "position_y": "-2889.03", + "position_z": "111.189", + "taxi_path": "179 389 ", + "faction": "alliance", + "position_source": "saveall immediately before shutdown; last database-saved position", + "individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately", + "recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde." + }, + { + "guid": "75", + "name": "Faeleran", + "race": "10", + "map": "1", + "position_x": "21.0141", + "position_y": "-3100.41", + "position_z": "119.486", + "taxi_path": "25 23 ", + "faction": "horde", + "position_source": "saveall immediately before shutdown; last database-saved position", + "individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately", + "recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde." + }, + { + "guid": "86", + "name": "Brindra", + "race": "3", + "map": "0", + "position_x": "-5298.76", + "position_y": "-598.388", + "position_z": "388.853", + "taxi_path": "", + "faction": "alliance", + "position_source": "saveall immediately before shutdown; last database-saved position", + "individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately", + "recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde. First teleport drifted back to old movement; stopped behavior safely, repeated teleport verified area5148; schedule on restored." + }, + { + "guid": "89", + "name": "Tahuima", + "race": "6", + "map": "571", + "position_x": "2718.31", + "position_y": "6232.1", + "position_z": "83.5786", + "taxi_path": "", + "faction": "horde", + "position_source": "saveall immediately before shutdown; last database-saved position", + "individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately", + "recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde." + }, + { + "guid": "125", + "name": "Koruhon", + "race": "6", + "map": "1", + "position_x": "2822", + "position_y": "-481.923", + "position_z": "112.377", + "taxi_path": "58 25 ", + "faction": "horde", + "position_source": "saveall immediately before shutdown; last database-saved position", + "individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately", + "recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde." + }, + { + "guid": "145", + "name": "Gorgar", + "race": "2", + "map": "530", + "position_x": "-1757.67", + "position_y": "5231.64", + "position_z": "-47.6846", + "taxi_path": "", + "faction": "horde", + "position_source": "saveall immediately before shutdown; last database-saved position", + "individual_combat_teleport": "not exposed by current native status; aggregate safe logout and native flight/transport diagnostics captured separately", + "recovery_result": "Online login confirmed; admin teleport to faction capital; native adventure area confirmed map0/area5148 Alliance or map1/area5170 Horde." + } + ], + "completed": true +} diff --git a/doc/clientless/checkpoints/2026-10-31-plan-command/validation.json b/doc/clientless/checkpoints/2026-10-31-plan-command/validation.json new file mode 100644 index 0000000000..0377728ad1 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-31-plan-command/validation.json @@ -0,0 +1,20 @@ +{ + "build": "PASS -j10", + "installed": true, + "startup": "fresh ready and process25737 with ports8085/8087", + "tests": [ + "plan-command parser and production EditPlan", + "intent state", + "day persistence", + "group departure" + ], + "tests_passed": 4, + "live_validation": "GUID26 future hour20 persisted; restored original18-23 and compared all six rows; filtered read-only previews accepted", + "restored_loader": 1, + "restored_test_admission": true, + "raid_join_enabled": false, + "bg_join_enabled": false, + "recovery_count": 9, + "scope": "online AdventureBots; edits persisted for restart; no offline selection", + "git_commit": "not requested for this task" +} diff --git a/doc/clientless/checkpoints/2026-10-31-route-performance/recovery-before-build.md b/doc/clientless/checkpoints/2026-10-31-route-performance/recovery-before-build.md new file mode 100644 index 0000000000..6d539d3878 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-31-route-performance/recovery-before-build.md @@ -0,0 +1,41 @@ +# Safe-logout recovery list before route-planner performance build + +- Safe logout requested: 2026-10-31 14:28 CET (approximately; console command) +- Snapshot/saveall: 2026-10-31 14:34 CET +- Loader and TestBot admission: disabled before the request +- Native aggregate at the five-minute limit: sessions 4, combat 0, transport 0, + flight/taxi 4, loading 0 +- Teleport-in-progress is not exposed by the current native status command. + +| GUID | Name | Faction | Saved position | Combat | Flight/taxi | Transport | Action/status before shutdown | +|---:|---|---|---|---:|---:|---:|---| +| 54 | Falaeriel | Horde | map 1, 5309.99 -3820.91 1723.56 | no | failed free flight, still flying/mounted | no | `travel_to_location`, no safe bounded corridor | +| 64 | Hildda | Alliance | map 0, -8802.94 642.241 106.207 | no | failed free flight, still flying/mounted | no | `travel_to_location`, no safe bounded corridor | +| 89 | Tahuima | Horde | map 1, 1754.87 -4343.36 115.299 | no | failed free flight, still flying/mounted | no | `travel_to_location`, no safe bounded corridor | +| 109 | Rixbin | Horde | map 530, 157 6047.06 57.2654; taxi 118 102 | no | native taxi active; prior free flight arrived | no | `travel_to_location`, taxi path 537 (118 to 102) | + +The first three records demonstrate an existing safe-logout gap: failed free +flight deliberately holds the bot mounted in the air, so the native safety gate +can never become ready. Per the five-minute build rule they were not detached or +force-logged-out individually; the server is shut down cleanly before compiling. +After restart all four records must be checked and, if necessary, reset to +Stormwind or Orgrimmar before normal loader operation resumes. + +## Incremental performance build + +The first live measurement showed that limiting road exits alone was +insufficient. A second safe logout was requested at approximately 14:36 CET +with the loader still disabled. GUIDs 64, 89 and 109 logged out safely. GUID 54 +remained in `FlightState::Failed` with movement flags still reporting flight +(`flight segment made no confirmed progress`) beyond the five-minute limit. +It is the sole recovery record for the incremental build; no individual forced +logout or flight interruption was used before the clean server shutdown. + +## Recovery result + +After each installed build the recorded characters were logged in with the +loader paused. All four had lost stale taxi/free-flight controller state. +Falaeriel, Tahuima and Rixbin were verified in Orgrimmar (allowed area 5170 or +1637); Hildda was verified in Stormwind (allowed area 5148). Their saved DB +positions are now in the correct faction capitals and have empty taxi paths. +Normal loader and TestBot admission were restored after the final build. diff --git a/doc/clientless/plan-commands.md b/doc/clientless/plan-commands.md new file mode 100644 index 0000000000..9ec2a06d47 --- /dev/null +++ b/doc/clientless/plan-commands.md @@ -0,0 +1,66 @@ +# Tagespläne per GM-Befehl bearbeiten + +In der Konsole ohne Punkt, im Spiel mit führendem Punkt. Benötigt GM-Berechtigung. + +``` +clientless adventure editplan +``` + +- Ziel: einzelne Charakter-GUID oder `all` für alle eingeloggten AdventureBots. + Offlinebots, Dungeonbots und echte Spieler werden nicht verändert. +- Zeitraum: `current`, `next`, `next+1` (übernächste Stunde), eine Stunde `15` + oder inklusive Spanne `15-17`. Nur heutige aktuelle/künftige Stunden; + kein stiller Überlauf zum nächsten Tag. `current-next` ist ebenfalls möglich. +- Intent: vorhandener Intentname, `keep` zum Beibehalten oder `instance-or-raid` + für einen gemischten Test. Letzteres versucht bei geraden GUIDs Raid, sonst Dungeon; + bei `auto` fällt ein nicht zulässiger Raid auf Dungeon zurück. +- Location: konkreter Location-Key, `keep`, `auto` für die normale profil- und + zugangsabhängige Auswahl oder `capital` für einen gültigen Ort in Sturmwind bzw. + Orgrimmar. TravelGraph-Node-Keys sind nicht automatisch Location-Keys. +- Fraktion: `alliance`, `horde`, `both`; Levelgrenzen inklusive, 1–85. +- `preview` zeigt die geplanten Änderungen ohne Datenbank-, Bewegungs- oder + Zufallszustandsänderung. `apply` speichert die Änderungen im persistenten Tagesplan + und aktualisiert die laufenden Pläne ohne Neustart oder Lua-Änderung. + +Ungültige Ziele werden gemeldet und für den betreffenden Bot wird die gesamte +angeforderte Spanne übersprungen. Andere gültige Bots werden bearbeitet. +Alle normalen Level-, Fraktions-, Gebiets-, Flug- und Instanzregeln bleiben aktiv. +Gruppenbildung benötigt eine zulässige Folgeaktivität; diese deshalb zuerst setzen. +Gruppenbildung in Stunde23 wird abgewiesen, weil kein Folgeblock existiert. + +Aktuelle Änderungen aktivieren die Tagesplansteuerung und gehen durch den normalen +sicheren Intentwechsel. Gruppenführung und laufende Spielerinstanzen behalten ihre +normalen Regeln; dieser Befehl teleportiert nicht und löst keine Gruppen auf. +Künftige Änderungen lassen den aktuellen Intent laufen. Bei manuell deaktiviertem +Tagesplan muss für dessen spätere Ausführung `schedule on` verwendet werden. +Bereits begonnene vorgezogene Gruppenreisen werden durch künftige Änderungen nicht +zurückgesetzt. Die betroffene Anzahl und jeder übersprungene Bot werden ausgegeben. + +## Klassischer Gruppentest + +Zuerst die Ziele der Folgestunde setzen, dann die Gruppenbildungsphase. Beispiel +bei aktueller Stunde14: Gruppenbildung14–15, Aktivität15–16. + +``` +clientless adventure editplan all next instance-or-raid auto both 15 85 preview +clientless adventure editplan all next instance-or-raid auto both 15 85 apply +clientless adventure editplan all current groupformation capital both 15 85 preview +clientless adventure editplan all current groupformation capital both 15 85 apply +``` + +Für einen Test erst ab der nächsten Stunde die ersten beiden Befehle mit `next+1` +und die letzten beiden mit `next` verwenden. Bots ohne zulässiges Dungeon-/Raidziel +werden bei der Zielvergabe übersprungen; die Ausgabe vorher prüfen. + +Weitere Beispiele: + +``` +clientless adventure editplan 26 current economy auto alliance 60 60 apply +clientless adventure editplan all 18-20 rest auto horde 1 40 preview +clientless adventure editplan all next keep poi_guard_22 alliance 15 85 preview +clientless adventure plan 26 +clientless adventure schedule 26 on +``` + +Browserfreigaben werden von diesen Befehlen nicht verändert. Raid- und +Schlachtfeldbeitritt bleiben bei entsprechender Konfiguration deaktiviert. diff --git a/src/server/game/World/AdventureAreaMgr.cpp b/src/server/game/World/AdventureAreaMgr.cpp index 464747abc5..b33ed09be0 100644 --- a/src/server/game/World/AdventureAreaMgr.cpp +++ b/src/server/game/World/AdventureAreaMgr.cpp @@ -224,18 +224,13 @@ bool AdventureAreaMgr::AllowsEdge(Player const* player,ClientlessTravelEdge cons if(!source || !target || *sourceEnd || *targetEnd || source>UINT32_MAX || target>UINT32_MAX)return false; sObjectMgr->GetTaxiPath(uint32(source),uint32(target),path,cost); if(!path || path>=sTaxiPathNodesByPath.size())return false; - auto const& points=sTaxiPathNodesByPath[path]; - for(std::size_t i=0;iLoc.X,p->Loc.Y,p->Loc.Z); - allowed=AllowsPosition(player,p->ContinentID,next); - if(i && points[i-1] && points[i-1]->ContinentID==p->ContinentID) - { - auto previous=points[i-1];Position start;start.Relocate(previous->Loc.X,previous->Loc.Y,previous->Loc.Z); - allowed=allowed && AllowsSegment(player,p->ContinentID,start,next); - } - } + // Catalog hierarchy governs boarding and arrival, both checked above + // through the faction-marked taxi nodes. A native taxi may legitimately + // overfly an enemy or otherwise denied zone; sampling its entire spline + // every 40 yards both rejected valid travel and blocked the world thread + // for seconds for each new level/faction cache key. The native path + // lookup remains mandatory, but intermediate airspace is not admission. + allowed = true; } // Native transports have no land path across the sea. Their two dock nodes // and explicit node restrictions decide access; portal destinations likewise. diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index fb14729c61..bae1bcf5aa 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -4,6 +4,7 @@ #include "ClientlessTravelTelemetry.h" #include "ClientlessTravelGraphMgr.h" #include "AdventureBehaviorMgr.h" +#include "AdventurePlanCommand.h" #include "AdventureCombatMgr.h" #include "AdventureEncounterMgr.h" #include "AdventureGroupPolicy.h" @@ -1659,6 +1660,10 @@ bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args) std::string op; uint32 guid = 0; in >> op; + if (op == "editplan") + { + std::string args;std::getline(in,args);return EditPlan(handler,args); + } if (op == "import") return sAdventureLocationMgr->Import(handler); if (op == "repairimport") return sAdventureLocationMgr->Import(handler,true); if (op == "reload") @@ -2323,3 +2328,101 @@ void AdventureBehaviorMgr::ResumePersonalPlan(Player* player) SetWanted(player,r,r.PersonalIntent,r.PersonalLocation,r.PersonalDeadline); else SetWanted(player,r,"","",0); } + +bool AdventureBehaviorMgr::EditPlan(ChatHandler* handler,std::string const& args) +{ + auto now=GameTime::GetGameTime();auto local=Local(now);auto date=DateOf(local); + AdventurePlanCommand::Request request; + if(!AdventurePlanCommand::Parse(args,local.tm_hour,request) || + (request.Intent!="keep" && request.Intent!="instance-or-raid" && !Known(request.Intent))) + { + handler->SendSysMessage("Usage: clientless adventure editplan . Online AdventureBots only; today, no past hours or midnight wrap."); + return false; + } + if(!sAdventureLocationMgr->EnsureLoaded())return false; + struct Change {uint32 Guid;AdventureBehaviorPolicy::Day Plan;}; + std::vector changes; + std::vector guids;for(auto const& entry:_records)guids.push_back(entry.first); + std::sort(guids.begin(),guids.end()); + unsigned matched=0,skipped=0; + for(auto guid:guids) + { + auto session=sWorld->FindClientlessSession(guid);auto player=session?session->GetPlayer():nullptr; + if(!player || !player->IsInWorld() || !sClientlessPlayerMgr->IsManaged(guid))continue; + if(!AdventurePlanCommand::Matches(request,guid,player->getLevel(),player->GetTeamId()==TEAM_ALLIANCE?1:2))continue; + ++matched; + auto const& original=_records.at(guid); + // Preview must not consume random state or modify histories, records or the DB. + if(original.Date!=date){++skipped;handler->PSendSysMessage("Skipped GUID %u: today's plan is not initialized.",guid);continue;} + auto copy=original;bool valid=true; + for(unsigned h=request.First;h<=request.Last;++h) + { + auto intent=request.Intent=="keep"?copy.Plan[h].Intent:request.Intent; + auto location=request.Location=="keep"?copy.Plan[h].Location:request.Location; + if(intent=="instance-or-raid") + { + intent=guid%2?"instance":"raid"; + if(location=="auto") + { + location=PickLocation(player,copy,intent); + if(location.empty()){intent="instance";location=PickLocation(player,copy,intent);} + } + } + if(location=="auto")location=PickLocation(player,copy,intent); + if(location=="capital") + { + location.clear(); + for(auto const& candidate:sAdventureLocationMgr->Locations()) + if((candidate.ZoneId==(player->GetTeamId()==TEAM_ALLIANCE?1519u:1637u)) && Allowed(player,candidate,intent) && + (location.empty() || candidate.Point.IdFind(location); + if((h==23 && intent=="groupformation") || !destination || !Allowed(player,*destination,intent)) + {valid=false;handler->PSendSysMessage("Skipped GUID %u: hour %u has no eligible destination for '%s'.",guid,h,intent.c_str());break;} + copy.Plan[h]={intent,location}; + } + for(unsigned h=request.First;valid && h<=request.Last;++h) + if(copy.Plan[h].Intent=="groupformation") + { + auto const& next=copy.Plan[h+1];auto destination=sAdventureLocationMgr->Find(next.Location); + if(!AdventureGroupPolicy::Eligible(next.Intent) || !destination || !Allowed(player,*destination,next.Intent)) + {valid=false;handler->PSendSysMessage("Skipped GUID %u: formation hour %u needs an eligible following activity; edit that hour first.",guid,h);} + } + // Each bot's requested range is atomic: no partial edit on an invalid slot. + if(!valid){++skipped;continue;} + changes.push_back({guid,copy.Plan}); + } + if(!request.Preview && !changes.empty()) + { + auto tx=WorldDatabase.BeginTransaction(); + for(auto const& change:changes) + for(unsigned h=request.First;h<=request.Last;++h) + { + auto const& slot=change.Plan[h];auto key=slot.Location;WorldDatabase.EscapeString(key); + tx->PAppend("REPLACE INTO vcore_adventure_day (character_guid,local_date,hour_slot,intent,location_key) VALUES (%u,%u,%u,'%s','%s')",change.Guid,date,h,slot.Intent.c_str(),key.c_str()); + } + WorldDatabase.DirectCommitTransaction(tx); + for(auto const& change:changes) + { + auto session=sWorld->FindClientlessSession(change.Guid);auto player=session?session->GetPlayer():nullptr; + if(!player)continue; + auto& r=_records.at(change.Guid);r.Plan=change.Plan; + if(request.First==unsigned(local.tm_hour)) + { + ClearDeparture(player,r);r.Schedule=true;r.Enabled=true;r.Hour=-1; + r.ManualNextIntent.clear();r.ManualNextLocation.clear(); + auto const& slot=r.Plan[local.tm_hour];auto end=local;end.tm_min=0;end.tm_sec=0; + end.tm_hour=local.tm_hour+1; + while(end.tm_hour<24 && r.Plan[end.tm_hour].Intent==slot.Intent && r.Plan[end.tm_hour].Location==slot.Location)++end.tm_hour; + end.tm_isdst=-1;SetWanted(player,r,slot.Intent,slot.Location,mktime(&end)); + } + else if(r.Departure.At && !r.Departure.Departed(now) && request.First<=unsigned(local.tm_hour+1)) + ClearDeparture(player,r); + } + } + for(auto const& change:changes) + for(unsigned h=request.First;h<=request.Last;++h) + handler->PSendSysMessage("%s GUID %u %02u:00-%02u:00 %s %s",request.Preview?"Preview":"Updated",change.Guid,h,h+1,change.Plan[h].Intent.c_str(),change.Plan[h].Location.c_str()); + handler->PSendSysMessage("Plan edit %s: date=%u onlineMatched=%u eligible=%u skipped=%u hours=%u-%u. Offline bots unchanged; movement uses normal safe handover and group/instance rules.",request.Preview?"preview":"applied",date,matched,uint32(changes.size()),skipped,request.First,request.Last); + return true; +} diff --git a/src/server/game/World/AdventureBehaviorMgr.h b/src/server/game/World/AdventureBehaviorMgr.h index e5eda27a6b..a0e4210e97 100644 --- a/src/server/game/World/AdventureBehaviorMgr.h +++ b/src/server/game/World/AdventureBehaviorMgr.h @@ -43,6 +43,7 @@ public: std::string InteractionIntent(Player* player) const; std::string InteractionSchedule(Player* player); private: + bool EditPlan(ChatHandler* handler, std::string const& args); enum class Phase {Idle, Travel, OnSite, Switching, Handover, WaitingGroup}; struct RecoveryScene { diff --git a/src/server/game/World/AdventureGroupTravelMgr.cpp b/src/server/game/World/AdventureGroupTravelMgr.cpp index 0c96ce1d92..403f9c1580 100644 --- a/src/server/game/World/AdventureGroupTravelMgr.cpp +++ b/src/server/game/World/AdventureGroupTravelMgr.cpp @@ -130,7 +130,14 @@ void AdventureGroupTravelMgr::SampleLeader(Player* player,Leader& state,time_t n void AdventureGroupTravelMgr::Observe(Player* player,uint32 diff) { auto group=player->GetGroup(); - if(!group)return; + // Group travel installs an owner-specific road adapter and path validator. + // Remove both as soon as the player leaves the group; otherwise unrelated + // autonomous travel can keep using formation safety rules indefinitely. + if(!group) + { + if(_records.count(player->GetGUID().GetCounter()))Clear(player); + return; + } auto inserted=_records.try_emplace(player->GetGUID().GetCounter()); auto& record=inserted.first->second; if(inserted.second) diff --git a/src/server/game/World/AdventurePlanCommand.h b/src/server/game/World/AdventurePlanCommand.h new file mode 100644 index 0000000000..8fa4369ad3 --- /dev/null +++ b/src/server/game/World/AdventurePlanCommand.h @@ -0,0 +1,67 @@ +#ifndef VCORE_ADVENTURE_PLAN_COMMAND_H +#define VCORE_ADVENTURE_PLAN_COMMAND_H + +#include +#include +#include + +namespace AdventurePlanCommand +{ +struct Request +{ + unsigned Guid=0, First=0, Last=0, MinLevel=1, MaxLevel=85, Faction=0; + bool Preview=true; + std::string Intent, Location; +}; +inline bool Number(std::string const& text, unsigned& value) +{ + if(text.empty())return false; + unsigned result=0; + for(char c:text) + { + if(c<'0' || c>'9' || result>(std::numeric_limits::max()-unsigned(c-'0'))/10)return false; + result=result*10+unsigned(c-'0'); + } + value=result;return true; +} +inline bool Hour(std::string const& text,unsigned current,unsigned& value) +{ + if(text=="current")value=current; + else if(text=="next")value=current+1; + else if(text.compare(0,5,"next+")==0) + { + unsigned offset=0; + if(!Number(text.substr(5),offset) || offset>23)return false; + value=current+1+offset; + } + else if(!Number(text,value))return false; + return value>=current && value<24; +} +inline bool Parse(std::string const& args,unsigned current,Request& out) +{ + std::istringstream input(args); + std::string target,hours,faction,min,max,mode,extra; + Request request; + if(!(input>>target>>hours>>request.Intent>>request.Location>>faction>>min>>max>>mode) || input>>extra)return false; + if(target!="all" && (!Number(target,request.Guid) || !request.Guid))return false; + auto dash=hours.find('-'); + if(dash==std::string::npos) + { + if(!Hour(hours,current,request.First))return false; + request.Last=request.First; + } + else if(!Hour(hours.substr(0,dash),current,request.First) || !Hour(hours.substr(dash+1),current,request.Last) || request.Last85 || request.MinLevel>request.MaxLevel)return false; + if(mode=="apply")request.Preview=false; + else if(mode!="preview")return false; + out=request;return true; +} +inline bool Matches(Request const& request,unsigned guid,unsigned level,unsigned faction) +{ + return (!request.Guid || request.Guid==guid) && level>=request.MinLevel && level<=request.MaxLevel && (!request.Faction || request.Faction==faction); +} +} +#endif diff --git a/src/server/game/World/ClientlessTravelGraphMgr.cpp b/src/server/game/World/ClientlessTravelGraphMgr.cpp index 124d85ff6b..a84a7ea20b 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.cpp +++ b/src/server/game/World/ClientlessTravelGraphMgr.cpp @@ -574,6 +574,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( // Reuse all start-point observations only WITHIN this immutable planning // call, including its two search tiers. Never cache live Player paths globally. std::map startProbes; + std::map, float> flightEstimates; auto analyzeStart=[&](Position const& target,ClientlessPathDiagnostics& result) { auto key=ClientlessGraphProbeCache::MakeKey(player->GetMapId(),player->GetInstanceId(),player->GetPositionX(),player->GetPositionY(),player->GetPositionZ(),target.GetPositionX(),target.GetPositionY(),target.GetPositionZ()); @@ -621,12 +622,26 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( { error="destination location denied by area catalog"; return false; } } bool const flightEnabled = sClientlessFlightMgr->Enabled(player); + constexpr std::uint64_t MaxFlightEstimatesPerPlan = 16; auto flightCost = [&](ClientlessTravelNode const& from, uint32 map, float x, float y, float z, bool access = false) { if (!flightEnabled || from.MapId != map || (blockedBridgeKeys && blockedBridgeKeys->count("flight_origin:"+from.Key))) return std::numeric_limits::infinity(); Position a,b; a.Relocate(from.X,from.Y,from.Z,from.O); b.Relocate(x,y,z); + auto const estimateKey = std::make_pair( + ClientlessGraphProbeCache::MakeKey(map, player->GetInstanceId(), + from.X, from.Y, from.Z, x, y, z), access); + auto cachedEstimate = flightEstimates.find(estimateKey); + if (cachedEstimate != flightEstimates.end()) + return cachedEstimate->second; + // Flight links are optional accelerators around the persistent graph. + // A pathological graph must not monopolize the world thread while + // constructing an implicit complete air graph. Cached estimates remain + // usable after the cap; unexamined links simply stay unavailable in + // this plan and ordinary graph travel can still win. + if (trace.FlightCalls >= MaxFlightEstimatesPerPlan) + return std::numeric_limits::infinity(); auto begin=std::chrono::steady_clock::now();++trace.FlightCalls; float result=sClientlessFlightMgr->Estimate(player,map,a,b,access); if(access && std::isfinite(result)) @@ -637,6 +652,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( 40+distance*0.4f:180+distance*1.5f; } trace.FlightUs+=std::chrono::duration_cast(std::chrono::steady_clock::now()-begin).count(); + flightEstimates.emplace(estimateKey, result); return result; }; if (startRadius <= 0.0f) @@ -649,6 +665,47 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( bridgeRadius = MaxVirtualNodeBridge; bridgeRadius = std::min(bridgeRadius, MaxVirtualNodeBridge); + // The overwhelmingly common free-flight case is a same-map Location. Do + // not construct a macro graph first: Estimate already enforces licenses, + // mount flags, area policy, distance and the complete catalog segment. + // Native departure/landing validation still happens in FlightMgr::Start. + if (locationGoal && locationGoal->MapId == player->GetMapId()) + { + ClientlessTravelNode departureNode; + departureNode.Key = "flight_departure"; + departureNode.MapId = player->GetMapId(); + departureNode.X = player->GetPositionX(); + departureNode.Y = player->GetPositionY(); + departureNode.Z = player->GetPositionZ(); + float const directFlight = flightCost(departureNode, locationGoal->MapId, + locationGoal->X, locationGoal->Y, locationGoal->Z); + if (std::isfinite(directFlight)) + { + ClientlessTravelRouteStep departure; + departure.NodeKey = "flight_departure"; + departure.NodeType = "ground"; + departure.EdgeType = "start"; + departure.MapId = player->GetMapId(); + departure.X = player->GetPositionX(); + departure.Y = player->GetPositionY(); + departure.Z = player->GetPositionZ(); + route.push_back(std::move(departure)); + + ClientlessTravelRouteStep flight; + flight.NodeKey = "flight_location"; + flight.NodeType = "location"; + flight.EdgeType = "free_flight"; + flight.MapId = locationGoal->MapId; + flight.X = locationGoal->X; + flight.Y = locationGoal->Y; + flight.Z = locationGoal->Z; + route.push_back(std::move(flight)); + complete = true; + reachedNode = "flight_location"; + return true; + } + } + struct State { std::string Node; @@ -1005,6 +1062,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( ? std::min(300.0f, locationGoal->AnchorRadius) : 0.0f; std::vector rawExitCandidates; std::unordered_map> exitsByFrom; + std::size_t flightExitProbes = 0; if (locationGoal || flightEnabled) { auto const& targetNode = destinationItr->second; @@ -1046,6 +1104,12 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( float travelled = 0.0f; ExitCandidate nearest; nearest.DistanceSq = std::numeric_limits::infinity(); + struct RankedFlightExit + { + float LowerBound = 0.0f; + ExitCandidate Candidate; + }; + std::vector rankedFlightExits; for (uint32 i = 1; i + 1 < ordered.size(); ++i) { float dx = ordered[i].X - ordered[i - 1].X; @@ -1078,18 +1142,59 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( blockedBridgeKeys->count("ground_exit_edge:" + identity); candidate.LocalCost = locationGoal && !groundExitExhausted && dsq <= locationAccessRadius*locationAccessRadius ? std::sqrt(dsq) : std::numeric_limits::infinity(); + + // A recorded edge can contain hundreds of samples. Calling + // FlightMgr::Estimate for every sample made the synchronous + // world-thread planner quadratic in road geometry (13k+ + // area checks for one route in production). Keep an exact + // ground candidate, but validate only the geometrically best + // flight exits below. Estimate still performs all catalog and + // native flight-permission checks for those candidates. + if (std::isfinite(candidate.LocalCost) && + (!std::isfinite(nearest.DistanceSq) || + candidate.RoadCost + candidate.LocalCost < nearest.RoadCost + nearest.LocalCost)) + nearest = candidate; + + if (flightEnabled && !(blockedBridgeKeys && blockedBridgeKeys->count("flight_exit:" + exitKey))) + { + float const direct = std::sqrt(dsq); + // Forty plus 0.4 * distance is the cheapest possible + // free-flight cost. It is an admissible ordering key; + // rejected area segments simply advance to the next item. + rankedFlightExits.push_back({ candidate.RoadCost + 40.0f + direct * 0.4f, + std::move(candidate) }); + } + } + std::sort(rankedFlightExits.begin(), rankedFlightExits.end(), + [](RankedFlightExit const& left, RankedFlightExit const& right) + { return left.LowerBound < right.LowerBound; }); + constexpr std::size_t MaxFlightExitProbesPerEdge = 2; + for (std::size_t i = 0; i < std::min(MaxFlightExitProbesPerEdge, rankedFlightExits.size()); ++i) + { + constexpr std::size_t MaxFlightExitProbesPerPlan = 8; + if (flightExitProbes >= MaxFlightExitProbesPerPlan) + break; + ++flightExitProbes; + RankedFlightExit const& ranked = rankedFlightExits[i]; + ExitCandidate candidate = ranked.Candidate; ClientlessTravelNode exitNode; - exitNode.MapId=point.MapId;exitNode.X=point.X;exitNode.Y=point.Y;exitNode.Z=point.Z; - float const air = blockedBridgeKeys && blockedBridgeKeys->count("flight_exit:"+exitKey) - ? std::numeric_limits::infinity() : flightCost(exitNode,goalMap,goalX,goalY,goalZ); - if (air < candidate.LocalCost) { candidate.LocalCost=air;candidate.Flight=true; } - if (!std::isfinite(candidate.LocalCost)) continue; - // Never replace a node goal with an unvalidated ground exit. - if (!locationGoal && !candidate.Flight) continue; + exitNode.MapId = candidate.Point.MapId; + exitNode.X = candidate.Point.X; + exitNode.Y = candidate.Point.Y; + exitNode.Z = candidate.Point.Z; + float const air = flightCost(exitNode, goalMap, goalX, goalY, goalZ); + if (!std::isfinite(air)) + continue; + candidate.LocalCost = air; + candidate.Flight = true; if (std::isfinite(nearest.DistanceSq) && - candidate.RoadCost+candidate.LocalCost >= nearest.RoadCost+nearest.LocalCost) continue; + candidate.RoadCost + candidate.LocalCost >= nearest.RoadCost + nearest.LocalCost) + continue; nearest = std::move(candidate); } + // Never replace a node goal with an unvalidated ground exit. + if (!locationGoal && std::isfinite(nearest.DistanceSq) && !nearest.Flight) + nearest.DistanceSq = std::numeric_limits::infinity(); if (std::isfinite(nearest.DistanceSq)) rawExitCandidates.push_back(std::move(nearest)); } @@ -1115,6 +1220,29 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( float bestGoalCost = std::numeric_limits::infinity(); std::string bestGoalState; bool bestFinalFlight = false; + // Free flight is a macro connector, not a reason to turn every graph map + // into a complete directed graph. A flyer only needs direct links to the + // requested destination and to nodes that enter a map-changing or physical + // transport edge (portal, ship, zeppelin or tram). Taxi nodes are excluded: + // a free flyer can already reach their same-map destination directly, and + // adding every taxi source recreates an almost-complete graph. Ordinary + // same-map ground + // nodes remain reachable through their recorded graph, while a location + // goal is evaluated directly from every expanded node below. + std::unordered_set flightConnectorNodes; + flightConnectorNodes.insert(destinationNode); + for (auto const& edgeGroup : _edges) + for (ClientlessTravelEdge const& edge : edgeGroup.second) + { + auto fromItr = _nodes.find(edge.FromNode); + auto toItr = _nodes.find(edge.ToNode); + bool const changesMap = fromItr != _nodes.end() && toItr != _nodes.end() && + fromItr->second.MapId != toItr->second.MapId; + bool const physicalTransport = edge.Type == "portal" || edge.Type == "ship" || + edge.Type == "zeppelin" || edge.Type == "tram" || edge.Type == "transport"; + if (changesMap || physicalTransport) + flightConnectorNodes.insert(edge.FromNode); + } // C8.20: a virtual entry is represented in the queue by its FAR // endpoint. Check exits BEFORE that endpoint independently: otherwise // an exit halfway along the road would pay for the entire remaining edge. @@ -1259,6 +1387,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( // Persistent TravelGraph edges always reset the bridge guard. auto edgeItr = _edges.find(current.Value.Node); + bool hasPersistentContinuation = false; if (edgeItr != _edges.end()) { for (ClientlessTravelEdge const& edge : edgeItr->second) @@ -1268,6 +1397,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( continue; if (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId)) continue; + hasPersistentContinuation = true; State next{ edge.ToNode, false }; std::string const nextKey = stateKey(next); @@ -1294,9 +1424,12 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( if (flightEnabled && !portalArrival) { auto const& origin=_nodes.at(current.Value.Node); - for (auto const& pair:_nodes) + for (std::string const& targetKey : flightConnectorNodes) { - auto const& target=pair.second; + auto targetItr = _nodes.find(targetKey); + if (targetItr == _nodes.end()) + continue; + auto const& target=targetItr->second; if (target.Key==origin.Key || target.MapId!=origin.MapId) continue; State next{target.Key,false};std::string key=stateKey(next); auto old=distance.find(key); @@ -1335,7 +1468,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( from.Key.rfind("portal_at_", 0) == 0 && from.Key.size() >= 7 && from.Key.compare(from.Key.size() - 7, 7, "_source") == 0; - if (!futurePortalSource && persistentHops[currentKey] > 0 && + if (!futurePortalSource && !hasPersistentContinuation && persistentHops[currentKey] > 0 && !(blockedBridgeKeys && blockedBridgeKeys->count(MakeLocalBridgeKey(from.Key, "virtual_ground_entry")))) { diff --git a/tests/clientless/test_adventure_group_travel.py b/tests/clientless/test_adventure_group_travel.py index 29675778c7..83977f10eb 100644 --- a/tests/clientless/test_adventure_group_travel.py +++ b/tests/clientless/test_adventure_group_travel.py @@ -55,15 +55,15 @@ namespace ObjectAccessor{std::unordered_mapplayers;Player*Fi namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}} struct Areas{void*LookupEntry(unsigned){return nullptr;}}sAreaTriggerStore; enum class ClientlessActionExecutionState{Moving,SelectingTarget,Completed,Failed}; -struct Executor{struct Status{ClientlessActionExecutionState State=ClientlessActionExecutionState::Completed;}status;unsigned clears=0;auto GetStatus(unsigned){return &status;}bool RequestStopAtNode(unsigned){return true;}void Clear(unsigned){++clears;status.State=ClientlessActionExecutionState::Completed;}void SetGroundPathAdapter(unsigned,std::function&)>){}}executor;auto sClientlessActionExecutorMgr=&executor; +struct Executor{struct Status{ClientlessActionExecutionState State=ClientlessActionExecutionState::Completed;}status;unsigned clears=0,adapters=0;auto GetStatus(unsigned){return &status;}bool RequestStopAtNode(unsigned){return true;}void Clear(unsigned){++clears;status.State=ClientlessActionExecutionState::Completed;}void SetGroundPathAdapter(unsigned,std::function&)> adapter){adapters+=adapter?1:-1;}}executor;auto sClientlessActionExecutorMgr=&executor; struct ClientlessAction{std::string Name;std::unordered_mapParameters;}; struct Core{unsigned queued=0;std::vectornodes;unsigned GetQueuedActionCount(unsigned){return queued;}bool QueueAction(unsigned,ClientlessAction action){++queued;nodes.push_back(action.Parameters["node"]);return true;}void ClearQueuedActions(unsigned){queued=0;}}core;auto sClientlessPlayerMgr=&core; enum class ClientlessTravelState{Moving,Arrived,Stuck,PathRejected}; struct ClientlessPathDiagnostics{bool Complete=true,Suspicious=false;float PathLength=20;}; -struct Travel{struct Status{ClientlessTravelState State=ClientlessTravelState::Arrived;Position Target;}status;bool valid=true;unsigned starts=0,cancels=0;std::vectortargets; +struct Travel{struct Status{ClientlessTravelState State=ClientlessTravelState::Arrived;Position Target;}status;bool valid=true;unsigned starts=0,cancels=0,validators=0;std::vectortargets; auto GetStatus(unsigned){return &status;}bool AnalyzePath(Player*,Position const&,ClientlessPathDiagnostics&d){d.Complete=valid;return valid;} bool StartTravel(Player*,Position const&target,bool=true,bool=false){++starts;targets.push_back(target);status.Target=target;status.State=ClientlessTravelState::Moving;return true;} - std::vectorconst*GetActivePath(unsigned){return nullptr;}void CancelTravel(unsigned){++cancels;status.State=ClientlessTravelState::Arrived;}void SetPathValidator(unsigned,std::functionconst&)>){} + std::vectorconst*GetActivePath(unsigned){return nullptr;}void CancelTravel(unsigned){++cancels;status.State=ClientlessTravelState::Arrived;}void SetPathValidator(unsigned,std::functionconst&)> validator){validators+=validator?1:-1;} }travel;auto sClientlessTravelMgr=&travel; struct PathGenerator{struct Point{float x,y,z;};Player*p;std::vectorpoints;PathGenerator(Player*who):p(who){}bool CalculatePath(float x,float y,float z){points={{p->GetPositionX(),p->GetPositionY(),p->GetPositionZ()},{x,y,z}};return travel.valid;}auto const&GetPath(){return points;}}; struct Graph{bool FindNearestNodeToPosition(unsigned,float,float,float,std::string&n,float&distance){n="nearest";distance=5;return true;}}graph;auto sClientlessTravelGraphMgr=&graph; @@ -150,6 +150,12 @@ int main(){ leader.taxi=false;executor.status.State=ClientlessActionExecutionState::Completed; // Recorded roads are bounded and validated, never a spline through a cliff. std::vectorroad;for(unsigned x=0;x<=100;x+=10)road.emplace_back(x,0,0);leader.moving=false;map.cliff=true;assert(!mgr.AdaptRoad(&a,road));map.cliff=false;road.clear();for(unsigned x=0;x<=100;x+=10)road.emplace_back(x,0,0);assert(mgr.AdaptRoad(&a,road) && road.back().GetPositionX()<=60); + // Leaving a group clears formation-owned adapters and validators before the + // next autonomous route. This prevents valid MMAP paths being rejected by a + // stale group formation safety check. + assert(mgr._records.count(26)&&executor.adapters==2&&travel.validators==2); + auto cancelsBefore=travel.cancels;a.g=nullptr;mgr.Observe(&a,500); + assert(!mgr._records.count(26)&&executor.adapters==1&&travel.validators==1&&travel.cancels==cancelsBefore+1); } ''' with tempfile.TemporaryDirectory() as d: diff --git a/tests/clientless/test_adventure_plan_commands.py b/tests/clientless/test_adventure_plan_commands.py new file mode 100644 index 0000000000..31f541ba3b --- /dev/null +++ b/tests/clientless/test_adventure_plan_commands.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Exercise the production parser and plan editor, including persistence and preview.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +source=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() +start=source.index('bool AdventureBehaviorMgr::EditPlan(') +end=len(source) +code=r''' +#include "AdventurePlanCommand.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +using uint32=unsigned;constexpr unsigned TEAM_ALLIANCE=0; +namespace AdventureGroupPolicy{bool Eligible(std::string const&s){return s=="instance"||s=="raid"||s=="adventure";}} +namespace AdventureBehaviorPolicy{struct Slot{std::string Intent,Location;};using Day=std::array;} +struct Player{unsigned guid,level=60,team=0;bool world=true;bool IsInWorld(){return world;}unsigned getLevel(){return level;}unsigned GetTeamId(){return team;}}; +struct Session{Player* player;Player*GetPlayer(){return player;}}; +struct World{std::map sessions;Session*FindClientlessSession(unsigned g){auto i=sessions.find(g);return i==sessions.end()?nullptr:&i->second;}} world;auto sWorld=&world; +struct Managed{bool IsManaged(unsigned){return true;}} managed;auto sClientlessPlayerMgr=&managed; +struct Location{unsigned ZoneId;struct{std::string Id;}Point;}; +struct Locations{std::vector locations{{1519,{"sw"}},{1637,{"org"}},{1,{"dungeon"}},{1,{"raid"}},{1,{"forbidden"}}}; + bool EnsureLoaded(){return true;}auto const& Locations(){return locations;}Location const*Find(std::string const& key){for(auto&l:locations)if(l.Point.Id==key)return &l;return nullptr;}} locations;auto sAdventureLocationMgr=&locations; +struct Tx{std::vector sql;void PAppend(char const*fmt,...){char buf[1024];va_list ap;va_start(ap,fmt);vsnprintf(buf,sizeof(buf),fmt,ap);va_end(ap);sql.push_back(buf);}}; +struct Database{Tx tx;unsigned commits=0;Tx*BeginTransaction(){tx.sql.clear();return &tx;}void EscapeString(std::string&){}void DirectCommitTransaction(Tx*&){++commits;}}WorldDatabase; +struct ChatHandler{std::vector messages;void SendSysMessage(char const*s){messages.push_back(s);}void PSendSysMessage(char const*fmt,...){char buf[1024];va_list ap;va_start(ap,fmt);vsnprintf(buf,sizeof(buf),fmt,ap);va_end(ap);messages.push_back(buf);}}; +namespace GameTime{long GetGameTime(){return 100000;}} +std::tm Local(long){std::tm tm{};tm.tm_year=126;tm.tm_mon=9;tm.tm_mday=31;tm.tm_hour=10;return tm;} +unsigned DateOf(std::tm const&){return 20261031;} +bool Known(std::string const&s){return s=="social"||s=="adventure"||s=="instance"||s=="raid"||s=="groupformation";} +struct AdventureBehaviorMgr{ + struct DepartureState{long At=0;bool departed=false;bool Departed(long)const{return departed;}}; + struct Record{unsigned Date=20261031;bool Schedule=true,Enabled=true;int Hour=10;std::string ManualNextIntent,ManualNextLocation;AdventureBehaviorPolicy::Day Plan;DepartureState Departure;unsigned picks=0;}; + std::map_records;unsigned switches=0,clears=0; + bool EditPlan(ChatHandler*,std::string const&); + bool Allowed(Player*p,Location const&l,std::string const&i){return l.Point.Id!="forbidden"&&(i!="raid"||p->level>=60)&&(l.Point.Id!="sw"||p->team==0)&&(l.Point.Id!="org"||p->team==1);} + std::string PickLocation(Player*p,Record&r,std::string const&i){++r.picks;if(i=="raid"&&p->level<60)return "";return i=="instance"?"dungeon":i=="raid"?"raid":p->team?"org":"sw";} + void ClearDeparture(Player*,Record&r){++clears;r.Departure={};}void SetWanted(Player*,Record&,std::string const&,std::string const&,long){++switches;} +}; +'''.replace('struct Locations{','struct LocationCatalog{').replace('}} locations;', '}} locations;') +# Class and member names must differ in C++. +code+=source[start:end] +code+=r''' +int main(){ + using namespace AdventurePlanCommand;Request q; + assert(Parse("all current social capital both 1 85 preview",10,q));assert(q.First==10&&q.Last==10&&q.Preview); + assert(Parse("43 next+2 raid auto alliance 60 85 apply",10,q));assert(q.First==13&&q.Guid==43&&q.Faction==1); + assert(Parse("all 12-15 keep auto horde 1 85 apply",10,q)&&q.Last==15); + for(auto s:{"all next social auto both 0 85 apply","all next social auto both 85 1 apply","all next social auto both 1 86 apply","all 9 social auto both 1 85 apply","all next social auto both 1 85 typo","0 next social auto both 1 85 apply","all 14-12 social auto both 1 85 apply","all next social auto both 1 85 apply extra","4294967296 next social auto both 1 85 apply"})assert(!Parse(s,10,q)); + assert(!Parse("all next social auto both 1 85 apply",23,q));assert(!Parse("all next+23 social auto both 1 85 apply",10,q)); + assert(Parse("43 next raid auto alliance 60 85 apply",10,q));assert(Matches(q,43,60,1));assert(!Matches(q,44,60,1));assert(!Matches(q,43,59,1));assert(!Matches(q,43,60,2)); + Player a{43,60,0},b{44,30,1},c{45,60,0};world.sessions={{43,{&a}},{44,{&b}},{45,{&c}}}; + AdventureBehaviorMgr mgr;for(auto g:{43,44,45}){auto&r=mgr._records[g];for(auto&s:r.Plan)s={"social","sw"};}mgr._records[45].Date=20261030; + ChatHandler h; + assert(mgr.EditPlan(&h,"all next raid auto both 1 85 preview"));assert(WorldDatabase.commits==0&&mgr.switches==0);assert(mgr._records[43].picks==0&&mgr._records[43].Plan[11].Intent=="social"); + assert(mgr.EditPlan(&h,"all next raid auto both 1 85 apply"));assert(WorldDatabase.commits==1&&WorldDatabase.tx.sql.size()==1);assert(mgr._records[43].Plan[11].Intent=="raid");assert(mgr._records[44].Plan[11].Intent=="social");assert(mgr.switches==0); + assert(mgr.EditPlan(&h,"44 next instance-or-raid auto horde 15 59 apply"));assert(mgr._records[44].Plan[11].Intent=="instance"); + assert(mgr.EditPlan(&h,"43 12-14 adventure capital alliance 60 60 apply"));assert(WorldDatabase.tx.sql.size()==3);assert(mgr._records[43].Plan[14].Location=="sw");assert(mgr._records[43].Plan[15].Intent=="social"); + auto commits=WorldDatabase.commits;assert(mgr.EditPlan(&h,"43 next raid forbidden both 1 85 apply"));assert(WorldDatabase.commits==commits); + assert(mgr.EditPlan(&h,"43 22-23 groupformation capital both 1 85 apply"));assert(WorldDatabase.commits==commits&&mgr._records[43].Plan[22].Intent=="social"); + assert(mgr.EditPlan(&h,"43 current adventure capital both 1 85 apply"));assert(mgr.switches==1&&mgr.clears==1&&mgr._records[43].Hour==-1); + assert(WorldDatabase.tx.sql[0].find("20261031,10,'adventure','sw'")!=std::string::npos); + // Formation validates its next block, not just the meeting location. + mgr._records[43].Plan[11]={"social","sw"};auto rejected=WorldDatabase.commits; + assert(mgr.EditPlan(&h,"43 current groupformation capital both 1 85 apply"));assert(WorldDatabase.commits==rejected); + mgr._records[43].Plan[11]={"raid","raid"};assert(mgr.EditPlan(&h,"43 current groupformation capital both 1 85 apply"));assert(mgr._records[43].Plan[10].Intent=="groupformation"); + mgr._records[43].Departure.At=1;mgr._records[43].Departure.departed=true;auto cleared=mgr.clears; + assert(mgr.EditPlan(&h,"43 next adventure auto both 1 85 apply"));assert(mgr.clears==cleared); + mgr._records[43].Departure.departed=false;assert(mgr.EditPlan(&h,"43 next adventure auto both 1 85 apply"));assert(mgr.clears==cleared+1); + assert(!mgr.EditPlan(&h,"all current nonsense capital both 1 85 apply")); +} +''' +with tempfile.TemporaryDirectory() as td: + p=Path(td);(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('Adventure plan commands: PASS')