From 2778cf9bdde18cc06082fe01e95a8d61b1ea7929 Mon Sep 17 00:00:00 2001 From: nico Date: Mon, 2 Nov 2026 06:26:58 +0100 Subject: [PATCH] Use journey-stable distance costs for clientless travel routes --- .../2026-11-01-raid-test-investigation.md | 132 ++++++++ doc/clientless/SESSION-RESUME.md | 262 +++++++++++++++ .../2026-11-02-0600-group-test/observe.py | 203 ++++++++++++ .../2026-11-02-0600-group-test/test_driver.py | 72 ++++ doc/clientless/travel-costs.md | 36 ++ .../World/ClientlessActionExecutorMgr.cpp | 53 ++- .../game/World/ClientlessActionExecutorMgr.h | 3 + .../game/World/ClientlessTransportMgr.cpp | 59 ++++ .../game/World/ClientlessTransportMgr.h | 1 + src/server/game/World/ClientlessTravelCost.h | 57 ++++ .../game/World/ClientlessTravelGraphMgr.cpp | 310 +++++++++++++----- .../game/World/ClientlessTravelGraphMgr.h | 13 +- src/server/worldserver/worldserver.conf.dist | 15 + tests/clientless/test_free_flight_planner.py | 68 +++- .../test_ground_exit_continuation.py | 11 +- tests/clientless/test_shared_group_route.py | 11 +- .../test_transport_cost_distance.py | 50 +++ tests/clientless/test_travel_cost_journey.py | 50 +++ tests/clientless/test_travel_costs.py | 64 ++++ 19 files changed, 1375 insertions(+), 95 deletions(-) create mode 100644 doc/clientless/2026-11-01-raid-test-investigation.md create mode 100644 doc/clientless/checkpoints/2026-11-02-0600-group-test/observe.py create mode 100644 doc/clientless/checkpoints/2026-11-02-0600-group-test/test_driver.py create mode 100644 doc/clientless/travel-costs.md create mode 100644 src/server/game/World/ClientlessTravelCost.h create mode 100644 tests/clientless/test_transport_cost_distance.py create mode 100644 tests/clientless/test_travel_cost_journey.py create mode 100644 tests/clientless/test_travel_costs.py diff --git a/doc/clientless/2026-11-01-raid-test-investigation.md b/doc/clientless/2026-11-01-raid-test-investigation.md new file mode 100644 index 0000000000..46950541cc --- /dev/null +++ b/doc/clientless/2026-11-01-raid-test-investigation.md @@ -0,0 +1,132 @@ +# Ursachenanalyse: Raidtest 01.11.2026, 22/23 Uhr + +Nur Diagnose; kein Produktionscode, Botplan, Loader oder Serverzustand geändert. +Branch feature/clientless-player-core; vorhandene Checkpoints/WIP erhalten. + +## 1. Bestätigter Aufbruchblocker: falsche Zielvariante + +Vorbereitung verwendete instance_visit_at_6354. BuildInstanceVariants in +AdventureLocationMgr.cpp bevorzugt bei Raids mit vorhandener 25er-Normalvariante +Difficulty1 für den Basisschlüssel. Difficulty0 wird als _d0 erzeugt. +Native MapDifficulty.dbc für Karte671: modes0/1/2/3 haben10/25/10/25 Plätze. +Dies wurde direkt aus der installierten DBC gelesen, nicht aus Namen geraten. +Die passende 10er-Normalvariante ist instance_visit_at_6354_d0; native Eligibility +für Renren bestätigt map671/content1/own1/invitation1 für beide Schlüssel. + +Zusätzliche Inkonsistenz: InstanceCapacity=min(20,maxPlayers) begrenzt die 25er- +Variante auf20, während Name und Difficulty bei25 bleiben. GroupPlan übernimmt +InstanceCapacity. Complete verlangt exakt Capacity Mitglieder plus Rollen: +capacity20 benötigt2T/4H/14D; tatsächlich sind10Bots mit2T/2H/6D in Gruppe2. +Complete ist somit false, CheckFullGroup aktiviert keinen frühen Abreisehandoff. +Um23:00 blockiert Begin vor ResolveInstance/Queue(travel_to_node) in WaitingGroup. +Der korrekte Eingangspfad wird folglich gar nicht angefordert. Aktuell alle10 +im selben raid-Intent/WaitingGroup. Kein Beleg für ein Problem der Bastionroute. + +Der falsche Basisschlüssel war ein Fehler der Testvorbereitung. Die20/25- +Inkonsistenz ist ein separater Implementierungsfehler. Ein bloßer Teleport oder +Reisereset kann beides nicht beheben. Der vorhandene Variantentest erwartet +explizit20 für25er-Modi; Tests decken diese fehlerhafte Semantik daher nicht auf. +Keine Tests/Builds in dieser reinen Diagnose gestartet. + +## 2. Bestätigte Zerstreuungskette: Reiseablehnung und ungebundener Fallback + +Historisches rotiertes Navigationlog:22:00:02 guid159 bei +(-8864.300,682.378,97.943), ZielAllison(-8867.790,673.673,97.986). +NAV_PLAN calculated1/complete1/type0x1, Weg9.38yd,4Punkte,maxZDelta0.03, +finalDistance0.08 und reason ok. NAV_START trotzdem PathRejected. +Fünf Recoveryversuche zu anderen Graphankern schlagen ebenfalls fehl; +Flugfallback kann den Startplatz nicht erreichen.22:00:04 RECOVERY_FAILED und +INTENT_ARRIVAL_REJECT beim vereinbarten Treffpunkt. + +Fallback schützt einen aktiven Instanzbesuch sowie Botgruppenmitglieder mit +GroupOverride, aber nicht den Treffpunkt einer Gruppenbildung beim Leader. +Er durchsucht ALLE Allowed-Locations und priorisiert andere Maps lediglich mit +Distanzaufschlag100000. Es gibt keine Bindung an vereinbarten Treffpunkt, +Sturmwind oder aktuelle Hauptstadt. Logs zeigen Allison->GildedRose->weitere +SW-Orte->Eisenschmiede->andere Hauptstädte, insgesamt37 Ersatzwahlen beim Leader. +AdoptGroup übernimmt jeweils den Laufzeitzustand des Leaders. Mitglieder +beenden laufende Taxi/Flug/Transport-Schritte vor dem Wechsel; deshalb verfolgen +sie zeitversetzt verschiedene alte Ziele und verteilen sich über Kontinente. + +Die genaue geometrische Ursache der ERSTEN PathRejected-Ablehnung ist offen. +MMAP hat einen vollständigen Weg geliefert. Nachfolgende Ausführungsprüfungen +umfassen AllowsSegment und den für Gruppen installierten GroundPathValidator +(Höhenprobe/Steigung/LOS). Beide können ohne präzises NAV_REJECT-Log abbrechen. +Die vorhandenen Logs rechtfertigen keine eindeutige Schuldzuweisung an eine +bestimmte Höhen-/LOS-Probe. Zur Behebung diesen Ort gezielt reproduzieren und +Ablehnungszweig/Koordinaten/erwartete und gemessene Höhe diagnostizieren; keine +pauschale Abschaltung von Sicherheitsprüfungen. + +## 3. Zwei beim Stundenwechsel verzögerte Mitglieder + +Um23:00 warteten164/165 noch auf eine Leaderroute für die alte Gruppenbildung. +Queue hängt Botmitgliedern route_source an; der Executor wartet bis15Sekunden +auf eine geteilte Route, danach erfolgt eigenständige Anschlussplanung. +SetWanted fordert während aktiver Reise StopAtNode an; Switching wartet auf +Busy=false und führt gegebenenfalls einen Graphknoten-Handover aus. +Nachfolgende native Diagnosen: beide travel_to_node stormwind_tradecenter +Completed/Arrived und raid/WaitingGroup. Die anfängliche Aussage eines dauerhaften +Intent-Hängers ist nicht belegt; das beobachtete Warten war vorübergehend. + +## Gezielte Korrekturen für einen Folgetest + +- Testziel explizit instance_visit_at_6354_d0; Difficulty0/capacity10/2T2H6D + zusammen mit nativer Gruppen-Difficulty und Complete überprüfen. +- 25er-Modi nicht als20er-Gruppe für einen25er-Raid vollständig melden; + konsistente tatsächliche Kapazität oder ausdrückliche Nichtunterstützung. +- Formationstreffpunkt beim Leader stabil halten; Fehlschlag führt zu lokaler + Wiederholung/Diagnose, nicht automatischem Hauptstadtwechsel. +- Anfangsablehnung in GoldenerRose mit genauer Ausführungsdiagnostik reproduzieren. +- Regressionen für vollständige10er-Gruppe/früheAbreise, Treffpunktbindung bei + Reisefehler, Stundenwechsel mit ausstehender geteilter Route und20/25-Konsistenz. + +Belege: checkpoints/2026-11-01-raid-2200/initial-navigation-failure.log, +investigation-console.log, reset-console.log und verified-db.tsv. Server.log +enthält die Formation-Fallbacks und Mergefolge. Gameplay/Instanzanreise unbestätigt. + +## Zusatz: drei Nutzerfragen zu Portalen und Kolonne, ca.23:17–23:21 + +1. Eisenschmiede: Portalspawn217855/GO195141/Spell65728 ist gebunden an +edge1827 portal_use, enabled1/cost1, vonportal_go_217855_source nach +portal_go_217855_target (VerwüsteteLande). Renren erreichte Quelle tatsächlich; +23:09:48 folgte laut Log free_flight direkt zurBastion statt portal_use. +Start wurde verworfen: no reachable open takeoff point, outdoor0, +Diagnosecounter surface/indoor/area/column/mmap/movement=0/0/0/1/63/0. +Danach Recovery über virtuelleStraße wird vom safetyAdapter abgelehnt. +Also Portalbenutzung in dieser Folge NICHT ausgeführt, keine nachgewiesene +native Portalzugangsverweigerung. Replan anQuelle: start->free_flight wiederholt. +Plannerportalguard gilt nur isPortalSource && previous enthältCurrentState; +Quellknoten alsStartwurzel fällt dadurch aus demGuard. Ein interaktivesPortal +teleportiert anders als einAreaTrigger nicht beim bloßenAnlaufen. + +2. Sturmwind-Schattenhochland: spawn200627/GO207694/Spell85545, edge1801, +Quelle(-8186.15,413.729,116.75), Ziel(-4890.07,-6626.74,10.2846), map0. +Enabled1/cost1. DB zeigt KEINE eingehende festeKante zurQuelle. Zurückliegender +Livebeleg Elanora16:57:17 benutztPortal nativ mit arrivalError0; Voraussetzungen +nicht pauschal alsGrund behaupten. Level80+-AdventureUnlock existiert. +Aktuelle Route zumBastioneingang: SWtradecenter->taxi2->taxi6->IFportalQuelle, +danach freieFlugplanung/Recovery. Nur dynamischerAnschluss kannSWPortal +verbinden (allgemeineBrücken max200yd, Flugkandidaten16/Plan und6/Ursprung). +Offline geometrische Kandidatenrangberechnung OHNEArea/Prereq/FlightFilter: +SWportalQuelle steht vonSWtradecenter sowietaxi2 sogar aufRang1 der +Makrokandidaten. Damit ist reineDistanzsortierung NICHT alsAblehnungsgrund +belegt. Es fehlen personenbezogene Logs zu Kandidatenbudget/Estimate-Ablehnung +und Folgeverbindung abPortalziel. Exakten verlorenenRoutenvergleich erst mit +gezieltemPlantrace behaupten. Keine Runtimeplanung/Teleport zuTest erzwungen. + +3. Kolonne: AdventureInteractionMgr kehrt beiBotLed vorGroupTravelMgr::Follow +zurück. Botgruppenmitglieder adoptierenExecutorroute (AdoptRoute), teilen also +Makroziel/Route, nicht automatisch individuellePositionen. Die allgemeine +Formation()/Follow-Steuerung wird dabei nicht verwendet. AdaptRoad läuft +nur imStartRecordedEdgePath, nicht für lokaleMMAP-Zuläufe/local_bridge/start. +AdaptRoad erhält den gemeinsamenEndpunkt; wennSeitversatz unsicher ist, zweiter +Versuch mitside0. Damit kann selbstStraßenadapter aufgleicheLinie zurückfallen. +NativeTaxiflüge haben identischen nativenPfad ohne separateKolonnenversätze. +EigeneFreeFlight hat sharedTrail mit3.5s+slot*0.6s Startverzögerung nur wenn +passenderLeaderflightRecord mit gleichemZiel vorhanden. Kein allgemeiner +Abstandsregler über alleReisemodi; keinePortalWarteaufstellung. +Nutzerbeobachtung desÜbereinanderlaufens passt zu diesen Integrationslücken; +keinBeleg, dass jedeFormationkomponente deaktiviert wäre. + +Belege Zusatz: ironforge-portal-route-evidence.log; Sourceprüfung der erwähnten +Methoden und nur lesendeWorldDB-Abfragen. KeineBot-/Codeänderung/Build/Restart. diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 14359a5474..0ad0a45cc3 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,265 @@ +# Aktueller Stand 02.11.2026, 06:21 MEZ + +Das Entfernungskostenmodell ist gebaut und installiert. Acht gezielte Tests sind +bestanden; Build und Installation exit 0. Details und Logs: +`checkpoints/2026-11-02-travel-costs/`. Kein Commit oder Push. + +Testserver nach Wiederaufnahme frisch gestartet: PID 219333, ready nachgewiesen, +Ports 8085/8087 offen. Loader wieder aktiv; 110 Bots online. Die vorherigen 15 +Recovery-Bots wurden offline nativ in ihre Fraktionshauptstadt versetzt und wieder +geladen. Die Unterbrechung und der ausgefallene ursprüngliche Test bleiben unten +dokumentiert; keine Auswertung um 03:00 behaupten. + +Vom Nutzer neu bestätigt: 06:00 Gruppenbildung, 07:00 Raid/Dungeon. 92 Bots ab +Level 15 haben beide Slots verifiziert; missing=0. Allianz Goldene Rose, +Horde Broken Tusk (poi_npc_287214). 18 aktuell online unter Level 15 wurden von +Teständerungen ausgenommen. Alle zehn bekannten 85er haben 07:00 raid mit +instance_visit_at_6354_d0 (Bastion normal10). Aktuelle Gruppe 5, Leader Renren159: +159,160,161,162,163,164,165,167,176. Corric166 ist noch solo, schedule=1, +Gruppenbildungs-Fallback poi_npc_313808 statt eigentlichem Treffpunkt beobachtet. +Elanora168 wurde aus der Testgruppe entfernt; externe Allianz85 35/168 bekommen +07:00 Dungeon statt Raid, damit sie nicht die zehnte Position belegen. Einmaliger +nativer groupmanager run führte Corric bisher nicht zu. Nicht behaupten, alle +zehn seien bereits gemeinsam. Im Managerlauf außerdem wiederholte Transfers +Arthoran26 zwischen zwei Dungeon-Gruppen beobachtet; Log erhalten, kein Fix. + +Read-only Beobachter und Berichtstimer sind implementiert und aktiv: +`~/.config/systemd/user/vcore-adventure-observer-20261102.service` +`~/.config/systemd/user/vcore-adventure-report-20261102.{service,timer}`. +Timer nachgewiesen für 02.11.2026 08:00 Europe/Berlin, Persistent=true. +Skripte, Manifest, Rohdaten: `checkpoints/2026-11-02-0600-group-test/`. +Erste vollständige Beobachtung ca. 06:13; frühere Lücke im Bericht ausweisen. +Bericht wird lokal nach `doc/clientless/2026-11-02-0800-test-report.md` geschrieben. +Bis dahin ist die Datei ausdrücklich Platzhalter. Keine automatische Chatnachricht +möglich/zugesagt. Bericht nach 08:00 auf erfolgreiche Erstellung prüfen und Nutzer +über Ergebnisse informieren. Beobachter repariert keine Bots während des Tests. + +Noch offen: tatsächliche Ankunft/Instanzeintritt und vollständige Zehnergruppe; +ursprüngliche MMAP-Höhenvalidator-Ablehnung, Treffpunkt-Fallback und Kolonnenbildung +sind nicht durch das Kostenmodell behoben. Gameplay-Abnahme steht aus. + +--- + +# Aktuell: Fortsetzung06:04MEZ, Ersatztest06/07/08 vomNutzerfreigegeben + +Reisekostenmodell kompiliert/installiert (build-installed.log exit0, install.log +exit0), ZweiterStart00:30 readyPID214613 Ports8085/8087. NativeTaxi15=6457.25yd, +Transportgeometrien positiv. SiebengezielteTestsgrün: Kosten/NativeTransport/ +JourneyLifecycle/SharedRoute/Planner/GroundExit/AreaNode, RescueGraphzusätzlich. +NeueQuellen ClientlessTravelCost.h undProfile-/Geometrieintegration inGraph/ +Executor/Transport; ConfigCostMultiplier alle7Modi1, Variation0.20. NativeRNG +proReise, RecoverybehältProfil, FollowerkopierenLeaderRoute+Faktoren. +NochkeinevollständigeLive-Abnahme/keinCommit/Push. + +SitzungvorAbschlussunterbrochen: Loaderblieb0, alte03Uhr-TesteinrichtungNICHT +abgeschlossen, KEINE03Uhr-Auswertung/keinTimer eingerichtet. NICHTanderesbehaupten. +BeiFortsetzung06:02 Serveroffline, keinvcore-worldserver-tmux/Prozess. Logsenden +04:03:45ohnesauberesShutdown; keinaktuellerCore (nurhistorischer01.11.Core). +Ursacheungeklärt. VorigesServerloggesichertimKostencheckpoint. + +NutzerneuerTest: JETZT06:00Gruppenbildung,07:00Dungeon/Raid; Auswertungentsprechend +08MEZ (bisheralsrelativeFortsetzungkommuniziert). AlleAllianz:GoldeneRoseSW, +Horde:BrokenTuskORG. Unter15ausnehmen undnormalePläneunverändert (explizitbeantwortet). +Bekannte85er159..167/176 auf10normalBastion6354_d0für07. Alle10wiederladen. +Testcheckpoint checkpoints/2026-11-02-0600-group-test/. +Serverjetztrecovern, alte15Restbotsnochprüfen/bergensoweitnötig, Loader1restaurieren. +NativeeditplanallnurOnline: erst07instance-or-raidauto,06formationmitFraktions- +location. FürCohort07raid6354_d0 explizit. Unter15unverändert. + +Plan fürlokaleZeitsteuerung: UserSystemd funktioniert, keineChat-Automationstools +verfügbar. NochNICHTimplementiert: read-onlyObserver mitNativeStatus/DBGroup- +Snapshots+Logdelta, einmaligerTimer08MEZ schreibtBericht. Keineautomatische +Chatnachrichtzusagen. KeineCLI-Subagentenstarten (Delegationnichtbeauftragt). + +--- + +# Aktuell: Routenalternativenfix final installiert, Restbetrieb restauriert + +Details: checkpoints/2026-11-01-route-alternatives/README.md. +FinalerBuild-j10/InstallPASS, freshreadyPID200529/Ports8085+8087. +ViergezielteRegressionenPASS inklPortalroot, PortalblockEscape, verworfeneRoad- +Kante1885 gehtanRecovery, konkurrierendeBudgetverbraucher+isolierterPortalflug. +NegativeKontrolle ohnePortalverbindungs-Pass fällt erwartbarfehl. +LiveIFPortalnutzungRenren23:32:04->06/arrivalError0.00 bestätigt. +LiveSW23:42:21: vollständigeFlug->Portal200627->Flug-Verbindungwirdjetzt +berücksichtigt, gewinntabernicht gegenTaxiroute. NeuerbelegterKostenbefund: +Taxikante15(SW->IF) cost1, aufgezeichneteStraße478.308, FlügeDistanzkosten. +Nichtbehaupten, SWPortal werdejetztgewählt; konsistenteMakroreisekosten offen. +SourceänderungenPlanner/Executor, zweiTests; keinKolonnen- oderHöhenvalidator- +Bypass. ZweiBuilds/Restarts mitLoader0/SafeLogout undje5Minuten-Regel; Recovery- +Listen22/3 undBergungeninHauptstädten dokumentiert. RenrenScheduleonrestauriert, +Loaderconfig1/nativeReloadrestauriert, normalerBetrieb läuftwiederan. +UrsprünglicheZehnergruppewarbeimSafeLogout imaltenRuntimebereitsinTeilgruppen +zerfallen;kein vollständigerRaid/Ankunftbestätigt. KeinCommit/Push. +NächsteArbeit: echteTransportzeitkosten stattTaxipauschale1, genaueGrundweg- +Ablehnungsdiagnostik, Kolonnenintegrationwievorheruntersucht. + +--- + +# Aktuell: finaler Portal-Flugverbindungsfix gebaut, zweiterRestart ausstehend + +ErsterFix installiert/frischready PID195658 Ports8085/8087. LiveRenren +benutzteIFPortal217855 nativ23:32:04->23:32:06, arrivalError0. SW->Bastion +wählteaberweiterIF. Deshalb zweiterPlannerfix: vorRoadExitBudget begrenzt +vollständige nahePortal-Flugverbindungen root->source(freeflight)->portal-> +target->destination(freeflight) aufGesamtkosten prüfen, nativeRechte/Sperren +behalten, maximal8Kandidaten/6neueEstimates imbestehenden16erBudget. +KeineBodenkanten zuSWPortalen hinzugefügt. NeueRegression konkurrierendeExits+ +unbrauchbarerStart schlägt OHNE dieseErgänzung erwartbarfehl, MIT ihrPASS. +FinalerCMakeBuild-j10 EXIT0 (build-final.log), nochNICHTinstalliert. +ZweiterSafeLogout23:35:20MEZ, Loader weiterhin0; FünfMinutengrenze23:40:20. +Zuletzt4Restbots73/92/96/132,96bereitsdanachsicherausgeloggt. NativeDiagnose, +Recoveryliste recovery-before-final-install.tsv unter route-alternatives. +VorShutdown aktuelleRestliste/Flags/Saveallaktualisieren, shutdown10, Prozessende +prüfen, finalinstall,startready/ports, Restbotsprüfen/ggfHauptstädte. +Loader1 zumAbschlussrestaurieren. Renren159 wurdefürPortaltests manuellgestoppt, +abschließend schedule159on wiederherstellen. AndereBotpläneunverändert. +KeinCommit/Push. ÄnderungenPlanner+Executor+zweiRegressionen. KeineKolonnen- +oderGrundvalidatorreparatur. DerenBefunde bleibenoffen. + +--- + +# Aktuell: Routenalternativen-Fix gebaut, Installation steht unmittelbar aus + +Autorisiert durchNutzerkontext: trotz gescheitertemFlugstart Graphalternativen +nutzen; SWCataclysmPortale nurdurchFlug erreichbar lassen. ZweiSourcefixes: +PortalGuard auchStartwurzel, bei gesperrtemPortal weiterhinStraßen zulassen; +GruppenRoadAdapter-Reject führt überRecoverTravel mitgesperrterKante weiter. +StartFlightProbes max4 reserviertPlanbudget für isolierteFluganschlüsse. +Plannerregression erweitert: Portalroot/PortalblockEscape/40unerreichbareRoots+ +isolierterPortalflug; PASS. GroundExit-Test verifiziertRecovery1885; PASS. +PortalPrerequisites/PortalUse PASS. CMakeDebugBuild-j10 EXIT0, nochNICHTinstalliert. +Loaderconfig1->0, tatsächlichenabled0/busy0. SafeLogout23:24:15MEZ angefordert; +22Restbots gegen23:28 inFlug/Kampf. FünfMinuten-Grenze23:29:15. +Belege/Recoveryliste/Flags/NativeStatus unter +checkpoints/2026-11-01-route-alternatives/. VorShutdown nochmalsStatus sichern; +sauberShutdown10, Prozessende VOR cmake--install. Dannfreshready+Ports prüfen, +22Recoverybots laden/Transitstatesprüfen und nötigenfallsAllianceSW/HordeORG. +Loader1 undnormalenBetrieb danachwiederherstellen. Gruppe warbereits imalten +Runtime beimSafeLogout inmehrereTeilgruppen zerfallen; keinevollständigeGruppe +behaupten. KeinCommit/Push. KeinKolonnenumbau/keinGrundvalidatorBypass. + +--- + +# Aktuell: Nutzerfragen Portale/Kolonne untersucht, nurDiagnose + +Zusatz imBericht doc/clientless/2026-11-01-raid-test-investigation.md. +IFPortal wird NICHT benutzt: LeaderRoute enthält an217855Quelle free_flight +zurBastion; Start indoor/no reachable open takeoff point; Recovery safetyAdapter +reject. Quellknoten alsStartwurzel umgehtPortalGuard(previous fehlt). +SW200627Portal vorhanden/benutzbarhistorisch, aber ohneeingehendefesteKante. +Exakter Grund der Nichtauswahl UNGEKLÄRT: dynamischeFlugverbindung/budget/Estimate +nicht kandidatenbezogen geloggt. Ungefiltert geometrisch sogarRang1 vonSWtrade/ +taxi2; nicht behaupten, es wäre nur zuweit/Questgesperrt. +Kolonne: BotLed umgehtallgemeinesFollow; gemeinsameExecutorRouten statt dauernder +Positionsformation. Adapter nuraufgezeichneteStraßen, keineZuläufe/nativeTaxi- +Versätze; gemeinsameEndpunkte undside0Fallback ermöglichenÜberlagerung. +Winienwi nachNutzerTeleport bisIF gereist, dort23:13:45 und23:14:25 gleiche +safetyAdapterAblehnung bei(-4606.44,-928.997,501.07). Einzelbeobachtung auf +Nutzerhinweis beendet: gesamteGruppebetroffen. KeinEingriff/Build/Restart. + +--- + +# Aktuell: Winienwis Blockade live genauer eingegrenzt,23:07 + +Nutzer bat besonders Winienwi163 zu beobachten, virtueller Einstieg vorGasthaus. +Nur Diagnose, keine Code-/Botänderung. Wiederholte Versuche23:06:45 und23:07:17: +virtueller Einstieg WIRD gewählt: (-8843.320,667.142,97.829), Start +(-8863.633,676.073,97.903), vollständiger MMAP10Punkte/35.30yd/22.19Luftlinie, +maxZDelta0.60, finalDistance0.00. Native travelinfo belegt eindeutig +Path.Reason='owner ground-safety validator rejected spline'. Gruppenvalidator +ValidateGroundPath (AdventureGroupTravelMgr) wird bei Observe auch fürLeader +installiert; prüft alle2yd Höhe/Steigung sowieLOS proSegment. Welcher innere +Zweig/Probe ablehnt, wird noch nicht geloggt. Nicht mehr bloß Vermutung über +Gebietspflicht: bei Winienwi ist OWNER VALIDATOR nachgewiesen. +Alternative echteKnoten und5Recoveries scheitern ebenso; Flugfallback scheitert +am Bodenweg zum Freiluft-Startplatz. Statische Flight-probe liest am aktuellen +XY roof115.617, nearFeet97.903 (korrekter Innenboden); beim Ziel97.830. +Dies beweist mehrere Höhenlagen, aber NICHT, dass der Gruppenvalidator die +Dachhöhe ausgewählt hat. Keine pauschale Ground-/LOS-Abschaltung vornehmen. +Belege winienwi-live-diagnostics.log/winienwi-navigation.log unter +checkpoints/2026-11-01-raid-2200/. Nächster sinnvoller Schritt: gezielte genaue +Ablehnungsdiagnostik in ValidateGroundPath, an derselbenPosition reproduzieren. +Kein Build/Install/Restart, kein Teleport.10erRaidintents bleiben gesetzt. + +--- + +# Aktuell: Alle10 Raidintents auf10normal korrigiert, ca.23:05 + +Nutzer autorisierte Anpassung aller zehn aktuellen Raidintents. Native editplan +preview/apply für159..167/176: aktueller Slot23 raid instance_visit_at_6354_d0. +DB bestätigt alle10 persistent. GroupPlan Leader159 jetzt capacity10; native +INSTANCE_PLAN mode0/map671/entry6354. NeunBots in erster Nachkontrolle Moving/ +Travel zurBastion, Winienwi163 lokaler Anreiseversuch Failed und30sRetry zum +selben Ziel (ground approach interrupted). Keine Ankunft/Gameplay bestätigt. +Keine Sourceänderung/Build/Restart. Gruppe bleibt zehnköpfig. Nachweise: +checkpoints/2026-11-01-raid-2200/10man-applied.tsv und10man-console.log. + +--- + +# Aktuell: Fehlgeschlagenen Raidtest untersucht (nur Diagnose) + +Bericht: doc/clientless/2026-11-01-raid-test-investigation.md. +Bestätigt: Basisschlüssel6354 wählt25normal/Difficulty1, intern fälschlich20; +10er-Normal heißt6354_d0 (nativeEligibility erlaubt). Completefalse verhindert +frühe und23Uhr-Abreise. Vorbereitung hätte explizite10er-Variante wählen müssen. +Zerstreuung:22:00:02 vollständiger9.38yd-MMAP-Weg zuAllison bei Ausführung +PathRejected, Recoveries erschöpft; Leader-Fallback wechselt37x Treffpunkt bis +andere Kontinente. Genauer erster Höhen/LOS/Gebiet-Ablehnungszweig noch offen. +164/165 nur vorübergehend im Reisehandover; inzwischen alle10 raid/WaitingGroup. +Kein Codefix/Build/Restart/Botplanänderung bei dieser Diagnose. +Belege unter checkpoints/2026-11-01-raid-2200/initial-navigation-failure.log und +investigation-console.log. Vorschläge/Regressionsumfang im Bericht. + +--- + +# Aktuell: Raidtest-Reisereset am 01.11.2026 ca.22:53 MEZ + +Auf ausdrücklichen Nutzerauftrag alle10 Raidbots159..167/176 zurück zur +Goldenen Rose gesetzt und alte Reisepläne entfernt. Vorherige Diagnose22:47: +Gruppe2/Leader159 hat exakt10 online, aber Plan meldet capacity20/Anzeige25; +Formation-Fallback wechselte wiederholt Hauptstädte/Kontinente. Kein Codefix. +Für vollständigen Reset gezielter Logout der10 (ohne Serverrestart), Offline- +Status ALLER10 vor Positionsänderung geprüft. Offlineposition genau auf Allison +poi_npc_314039: map0 -8867.79/673.673/97.9864/o5.20108, zone1519, +instance0, taxi_path leer. Wieder eingeloggt: alte Executor-/Travel-/Flight- +und Behavior-RAM-Records durch Logout verworfen. Aktuellen Formationplan aller10 +nativ erneut auf poi_npc_314039 gesetzt, Schedule1. 23-Raidplan unverändert. +Alle10 native area5148/map0 bestätigt, jeweils kein freier Flugzustand. +Loader weiter normal1; kein Build/Install/Serverrestart. Gruppe bleibt bestehen. +Nachweise checkpoints/2026-11-01-raid-2200/{before-reset-positions.tsv,reset-console.log}. +Reset behebt nicht den zugrunde liegenden Treffpunkt-/Raidkapazitätsfehler; +weiteren Verlauf nicht als erfolgreich abgenommen darstellen. + +--- + +# Aktuell: Raidtest am 01.11.2026 um 22/23 Uhr vorbereitet + +Nutzerauftrag: zehn Allianz-AdventureBots Stufe85 in Sturmwind, 22:00 +Gruppenbildung, 23:00 Raid Bastion des Zwielichts. Roster: Tanks Ragnorli167 +und Ashaelri176; Heiler Marnda161 (Schamane), Aruendas162 (Priester); +Damage Renren159, Marndra160, Winienwi163, Taliaarnora164, Brynda165, Corric166. +Rollen aus generierten Profilen geprüft: 2T/2H/6D. Alle zehn eingeloggt. +Tagesplan für 20261101 persistent über native editplan-Befehle gesetzt: +21 rest, 22 groupformation, beide poi_npc_314039 (Allison, Gilded Rose, SW); +23 raid instance_visit_at_6354 (Twilight Bastion Entrance, NICHT3529/MoltenCore). +Tagesplansteuerung an. Bestehende Teilgruppen bleiben dem normalen Gruppenmanager +überlassen; noch keine vollständige Zehnergruppe oder Raid-Ankunft behauptet. +Zunächst poi_guard_22 vom Editor abgelehnt, deshalb auf gültigen gemeinsamen +NPC-Treffpunkt korrigiert. Aktive Altanreise einiger Bots brauchte sicheren +Handover; 163/164/166 BehaviorStop, erneuter nativer SW-Teleport und ScheduleOn. + +Testserver war zu Beginn offline (kein Prozess/tmux). Ohne Build gestartet, +frische ready-Meldung/PID180309 und Ports8085/8087 geprüft. Normalen Loaderbetrieb +beibehalten. Kein Build, keine Installation, kein Push/Commit. +Nachweise: checkpoints/2026-11-01-raid-2200/console.log und verified-db.tsv. +Final: alle10 nach Saveall online1/Level85/map0/zone1519 und taxi_path leer; +Corric nach abgeschlossenem Routen-Handover erneut SW gesetzt, native area5148 +und schedule1/rest bestätigt. Alle10 DB-Slots22/23 geprüft. +Die tatsächliche Gruppenbildung/Abreise/Gameplay ab22/23 bleibt live zu prüfen. + +--- + # Aktuell: Quest-unabhängige Portalnutzung für AdventureBots ab Stufe 80 AdventureBots können keinen Questfortschritt erwerben. Graphgebundene öffentliche diff --git a/doc/clientless/checkpoints/2026-11-02-0600-group-test/observe.py b/doc/clientless/checkpoints/2026-11-02-0600-group-test/observe.py new file mode 100644 index 0000000000..b9ec046d7e --- /dev/null +++ b/doc/clientless/checkpoints/2026-11-02-0600-group-test/observe.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Read-only, bounded observation and deterministic test report. No bot repairs.""" +from pathlib import Path +import collections,datetime,json,os,re,subprocess,sys,time +import test_driver as d +BASE=d.BASE +REPORT=d.ROOT/'doc/clientless/2026-11-02-0800-test-report.md' +def append(path,value): + with path.open('a') as f:f.write(json.dumps(value,ensure_ascii=False)+'\n') +def load(path): + result=[] + if path.exists(): + for line in path.read_text().splitlines(): + try:result.append(json.loads(line)) + except json.JSONDecodeError:pass + return result +def manifest():return json.loads((BASE/'manifest.json').read_text()) +def alive(): + r=subprocess.run(['pgrep','-x','worldserver'],text=True,capture_output=True) + result=[] + for value in r.stdout.split(): + try: + if os.readlink('/proc/'+value+'/exe')=='/home/wow_admin/wow_cata_server/server/debug/bin/worldserver':result.append(int(value)) + except OSError:pass + return result +EVENT=re.compile(r'\b(?:GROUP_[A-Z_]+|INSTANCE_[A-Z_]+|INTENT_[A-Z_]+|EDGE_[A-Z_]+|RECOVERY_[A-Z_]+|NAV_REJECT|TEMP_TRAVEL|PORTAL_[A-Z_]+|TRAVEL_(?:PLAN[A-Z_]*|JOURNEY_COSTS|SHARED_ROUTE)|CPC_PERF)\b') +class Tail: + def __init__(self,path):self.path=path;self.file=None;self.initial=True + def poll(self): + try: + st=self.path.stat() + if self.file is None: + self.file=self.path.open();self.inode=st.st_ino + if self.initial:self.file.seek(0,2);self.initial=False + lines=self.file.readlines() + if self.inode!=st.st_ino: + self.file.close();self.file=self.path.open();self.inode=st.st_ino;lines+=self.file.readlines() + elif self.file.tell()>st.st_size:self.file.seek(0) + stamp=d.now().isoformat() + for line in lines: + match=EVENT.search(line) + if match:append(BASE/'events.jsonl',{'observed_at':stamp,'source':self.path.name,'event':match[0],'line':line.rstrip()}) + except OSError as error:append(BASE/'observer-errors.jsonl',{'time':d.now().isoformat(),'error':str(error)}) +def snapshot(native=True): + record={'time':d.now().isoformat(),'server_pids':alive()} + record['roster']=d.roster() + record['groups']=d.query('Character','SELECT g.guid,g.leaderGuid,g.groupType,m.memberGuid,c.name,c.level,c.online FROM groups g JOIN group_member m ON m.guid=g.guid JOIN characters c ON c.guid=m.memberGuid ORDER BY g.guid,m.memberGuid;') + record['plans']=d.plans();record['native']={} + if native and record['server_pids']: + raw=[];ids=[r['guid'] for r in record['roster'] if r['level']>=15] + for pos in range(0,len(ids),20): + chunk=ids[pos:pos+20] + for g in chunk: + for command in [f'clientless adventure status {g}',f'clientless actioninfo {g}',f'clientless interaction {g}',f'clientless adventure area {g}']:d.command(command) + time.sleep(1.5);text=d.console(1400);raw.append(text) + for g in chunk: + bot={} + for key,pattern in { + 'behavior':rf'Adventure GUID {g}: enabled=(\d+) schedule=(\d+) phase=(\d+) intent=\x27([^\x27]*)\x27 location=\x27([^\x27]*)\x27.*?detail=\x27([^\x27]*)\x27', + 'action':rf'Action GUID {g}: state=(\w+) action=\x27([^\x27]*)\x27 target=\x27([^\x27]*)\x27.*?detail=\x27([^\x27]*)\x27', + 'area':rf'Area access GUID {g}: .*?map=(\d+) area=(\d+).*?allowed=(\d+)', + }.items(): + matches=list(re.finditer(pattern,text)) + if matches:bot[key]=list(matches[-1].groups()) + record['native'][str(g)]=bot + (BASE/('snapshot-'+d.now().strftime('%Y%m%d-%H%M%S')+'.log')).write_text('\n'.join(raw)) + append(BASE/'snapshots.jsonl',record) + return record +def observe(): + end=datetime.datetime.fromisoformat(manifest()['evaluate_at']) + logs=Path('/home/wow_admin/wow_cata_server/server/debug/bin') + tails=[Tail(logs/'Server.log'),Tail(logs/'adventurebot-navigation.log')] + for tail in tails:tail.poll() + next_snapshot=0 + append(BASE/'lifecycle.jsonl',{'event':'observer_started','time':d.now().isoformat(),'end':end.isoformat()}) + while d.now()=next_snapshot: + try:snapshot() + except Exception as error:append(BASE/'observer-errors.jsonl',{'time':d.now().isoformat(),'error':str(error)}) + next_snapshot=time.monotonic()+300 + # Preserve a final snapshot before the activity slot ends. + if (end-d.now()).total_seconds()<35: + try:snapshot() + except Exception as error:append(BASE/'observer-errors.jsonl',{'time':d.now().isoformat(),'error':str(error)}) + while d.now()max_group:max_group=len(overlap) + if len(overlap)==10:together=True + latest_group=grouped + for snap in snapshots: + if not snap['server_pids']:continue + for guid,bot in snap.get('native',{}).items(): + status=bot.get('behavior') + if status and status[2]=='2' and status[3]=='groupformation' and status[4] in m['meeting_locations'].values():arrivals.add(int(guid)) + latest=snapshots[-1] if snapshots else None + leader_seeds={};copies=0;matched_seeds=0;unknown_seeds=0;mismatched_seeds=0 + for e in events: + line=e['line'] + if e['event']=='TRAVEL_JOURNEY_COSTS': + match=re.search(r'guid=(\d+) seed=(\d+)',line) + if match:leader_seeds[int(match[1])]=int(match[2]) + elif e['event']=='TRAVEL_SHARED_ROUTE': + match=re.search(r'leader=(\d+).*?journeySeed=(\d+)',line) + if match: + copies+=1;leader=int(match[1]);seed=int(match[2]) + if leader not in leader_seeds:unknown_seeds+=1 + elif leader_seeds[leader]==seed:matched_seeds+=1 + else:mismatched_seeds+=1 + stamp=d.now();future=preview or stampsecond); + // Inherit preferences even while the leader's route is still being + // published. The shared-route timeout must not roll a follower's own + // modal preference before a local catch-up plan. + ClientlessTravelCost::Profile const* inherited=nullptr; + auto requested=action.Parameters.find("route_source"); + auto group=player->GetGroup(); + if(requested!=action.Parameters.end() && group) + { + uint32 sourceGuid=uint32(std::strtoul(requested->second.c_str(),nullptr,10)); + auto source=_records.find(sourceGuid); + if(group->GetLeaderGUID().GetCounter()==sourceGuid && sourceGuid!=player->GetGUID().GetCounter() && + source!=_records.end() && source->second.HasJourneyCosts && source->second.JourneyCostKey==key) + inherited=&source->second.JourneyCosts; + } + if(inherited || !record.HasJourneyCosts || record.JourneyCostKey!=key || record.Status.State==ClientlessActionExecutionState::Completed) + { + record.JourneyCosts=inherited?*inherited:sClientlessTravelGraphMgr->NewJourneyCosts(); + record.JourneyCostKey=key;record.HasJourneyCosts=true; + TC_LOG_INFO("server.adventurebot.navigation", + "TRAVEL_JOURNEY_COSTS guid=%u seed=%u goal='%s' ground=%.3f taxi=%.3f flight=%.3f ship=%.3f zeppelin=%.3f tram=%.3f portal=%.3f", + player->GetGUID().GetCounter(),record.JourneyCosts.Seed,key.c_str(), + record.JourneyCosts.Factors[0],record.JourneyCosts.Factors[1],record.JourneyCosts.Factors[2], + record.JourneyCosts.Factors[3],record.JourneyCosts.Factors[4],record.JourneyCosts.Factors[5],record.JourneyCosts.Factors[6]); + } + } record.StopAtNode = false;record.FormationWaiting=false;record.GroundChunk=false; record.TransportActive = false; record.FlightActive = false; @@ -635,7 +664,7 @@ bool ClientlessActionExecutorMgr::StartTravelToNode( if (!sClientlessTravelGraphMgr->PlanAdaptiveRoute( player, destinationNode, startRadius, record.LocalBridgeRadius, record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, - &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys)) + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, nullptr, &record.JourneyCosts)) { TC_LOG_WARN("server.adventurebot.navigation", "TRAVEL_PLAN_FAILED guid=%u name='%s' action='travel_to_node' destination='%s' reason='%s'", @@ -827,7 +856,7 @@ bool ClientlessActionExecutorMgr::StartTravelToLocation( if (!sClientlessTravelGraphMgr->PlanAdaptiveRoute( player, anchorNode, startRadius, record.LocalBridgeRadius, record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, - &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal)) + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal, &record.JourneyCosts)) { TC_LOG_WARN("server.adventurebot.navigation", "C820_LOCATION_PLAN_FAILED location='%s' reason='%s'", @@ -1854,8 +1883,11 @@ bool ClientlessActionExecutorMgr::StartRecordedEdgePath( size_t original=path.size(); if(!adapter->second(player,path)) { - player->StopMoving();record.Active=false;record.Status.State=ClientlessActionExecutionState::Failed; - record.Status.Detail="recorded group road rejected by safety adapter";return false; + player->StopMoving(); + uint32 const failedEdge = record.RouteStep < record.Route.size() + ? record.Route[record.RouteStep].EdgeDatabaseId : 0; + return RecoverTravel(player,characterGuid,record,failedEdge, + "recorded group road rejected by safety adapter"); } if(path.size()<2) {if(player->IsSplineEnabled())player->StopMoving();record.FormationWaiting=true;record.Status.State=ClientlessActionExecutionState::Moving;record.Status.Detail="waiting for leading formation row";return true;} @@ -2335,7 +2367,7 @@ bool ClientlessActionExecutorMgr::RecoverTravel( planned = sClientlessTravelGraphMgr->PlanAdaptiveRoute( player, anchorNode, radius, record.LocalBridgeRadius, record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, - &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal); + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, &goal, &record.JourneyCosts); } else error = "no graph node exists on destination map"; @@ -2351,7 +2383,7 @@ bool ClientlessActionExecutorMgr::RecoverTravel( planned = sClientlessTravelGraphMgr->PlanAdaptiveRoute( player, record.PlannedDestinationNode, radius, record.LocalBridgeRadius, record.Route, record.RouteComplete, record.AdaptiveReachedNode, error, - &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys); + &record.FailedEdgeDatabaseIds, &record.FailedBridgeKeys, nullptr, &record.JourneyCosts); if (planned) TC_LOG_WARN("server.adventurebot.navigation", @@ -3034,6 +3066,11 @@ bool ClientlessActionExecutorMgr::AdoptRoute(Player* player,ClientlessAction con auto const snapshot=source->second; auto goal=action.Parameters.find(action.Name=="travel_to_location"?"id":"node"); if(goal==action.Parameters.end() || goal->second!=(action.Name=="travel_to_location"?snapshot.FinalLocationId:snapshot.PlannedDestinationNode))return false; + // Catch-up planning and later recoveries share the leader's sampled modal + // costs. A follower must never choose a new transport preference here. + record.JourneyCosts=snapshot.JourneyCosts; + record.JourneyCostKey=snapshot.JourneyCostKey; + record.HasJourneyCosts=snapshot.HasJourneyCosts; auto start=std::min(snapshot.RouteStep?snapshot.RouteStep-1:0,route.size()-1); auto anchor=route[start]; std::vector joined; @@ -3043,7 +3080,7 @@ bool ClientlessActionExecutorMgr::AdoptRoute(Player* player,ClientlessAction con else { bool complete=false;std::string reached,error; - if(!sClientlessTravelGraphMgr->PlanAdaptiveRoute(player,anchor.NodeKey,500,300,joined,complete,reached,error) || !complete)return false; + if(!sClientlessTravelGraphMgr->PlanAdaptiveRoute(player,anchor.NodeKey,500,300,joined,complete,reached,error,nullptr,nullptr,nullptr,&record.JourneyCosts) || !complete)return false; } joined.insert(joined.end(),route.begin()+start+1,route.end()); record.Route=std::move(joined);record.RouteStep=0; @@ -3053,6 +3090,6 @@ bool ClientlessActionExecutorMgr::AdoptRoute(Player* player,ClientlessAction con record.FinalDatabaseId=snapshot.FinalDatabaseId;record.PlannedDestinationNode=snapshot.PlannedDestinationNode; record.RouteComplete=snapshot.RouteComplete;record.AdaptiveReachedNode=snapshot.AdaptiveReachedNode; record.HardDestination=snapshot.HardDestination;record.Active=true; - TC_LOG_INFO("server.adventurebot.navigation","TRAVEL_SHARED_ROUTE guid=%u leader=%u steps=%u",player->GetGUID().GetCounter(),sourceGuid,uint32(record.Route.size())); + TC_LOG_INFO("server.adventurebot.navigation","TRAVEL_SHARED_ROUTE guid=%u leader=%u steps=%u journeySeed=%u",player->GetGUID().GetCounter(),sourceGuid,uint32(record.Route.size()),record.JourneyCosts.Seed); return StartRouteStep(player,player->GetGUID().GetCounter(),record); } diff --git a/src/server/game/World/ClientlessActionExecutorMgr.h b/src/server/game/World/ClientlessActionExecutorMgr.h index 20a493302b..0215c6b425 100644 --- a/src/server/game/World/ClientlessActionExecutorMgr.h +++ b/src/server/game/World/ClientlessActionExecutorMgr.h @@ -57,6 +57,9 @@ public: private: struct Record { + ClientlessTravelCost::Profile JourneyCosts; + std::string JourneyCostKey; + bool HasJourneyCosts = false; ClientlessActionExecutionStatus Status; bool Active = false; std::shared_ptr PendingShared; diff --git a/src/server/game/World/ClientlessTransportMgr.cpp b/src/server/game/World/ClientlessTransportMgr.cpp index a069d04db3..37dedaa3ac 100644 --- a/src/server/game/World/ClientlessTransportMgr.cpp +++ b/src/server/game/World/ClientlessTransportMgr.cpp @@ -22,8 +22,10 @@ #include "Player.h" #include "Transport.h" #include "TransportMgr.h" +#include "Spline.h" #include "World.h" #include "WorldSession.h" +#include #include #include #include @@ -326,6 +328,63 @@ ClientlessTransportStop const* ClientlessTransportMgr::GetStop(std::string const if(!EnsureLoaded())return nullptr; auto found=_stops.find(node);return found==_stops.end()?nullptr:&found->second; } +float ClientlessTransportMgr::ConnectionDistance(std::string const& from,std::string const& to) +{ + auto source=GetStop(from),target=GetStop(to); + if(!source || !target || source->Entry!=target->Entry)return 0; + struct Sample { uint32 Map;Position Pos;double Arc; }; + std::vector samples; + double totalLength=0; + if(auto native=sTransportMgr->GetTransportTemplate(source->Entry)) + { + // Native splines already expose cumulative arclength at each stop. + // Sum their legs, never geographic jumps between maps/teleports. + for(auto const& leg:native->PathLegs) + { + if(!leg.Spline || leg.Spline->length()<=0)continue; + G3D::Vector3 point; + leg.Spline->evaluate_percent(0,point); + samples.push_back({leg.MapId,Position(point.x,point.y,point.z),totalLength}); + for(auto const& segment:leg.Segments) + { + leg.Spline->evaluate_percent(float(segment.DistanceFromLegStartAtEnd/leg.Spline->length()),point); + samples.push_back({leg.MapId,Position(point.x,point.y,point.z),totalLength+segment.DistanceFromLegStartAtEnd}); + } + totalLength+=leg.Spline->length(); + } + } + else if(auto animation=sTransportMgr->GetTransportAnimInfo(source->Entry)) + { + auto spawn=sObjectMgr->GetGameObjectData(source->Spawn); + if(!spawn)return 0; + QuaternionData parent; + if(auto addon=sObjectMgr->GetGameObjectAddon(source->Spawn))parent=addon->ParentRotation; + auto rotation=G3D::Quat(parent.x,parent.y,parent.z,parent.w).toRotationMatrix(); + for(auto const& node:animation->Path) + { + auto const& p=node.second->Pos; + G3D::Vector3 world=G3D::Vector3(p.X,p.Y,p.Z)*rotation; + Position position(spawn->spawnPoint.GetPositionX()+world.x,spawn->spawnPoint.GetPositionY()+world.y,spawn->spawnPoint.GetPositionZ()+world.z); + if(!samples.empty())totalLength+=samples.back().Pos.GetExactDist(position); + samples.push_back({source->Map,position,totalLength}); + } + if(samples.size()>1)totalLength+=samples.back().Pos.GetExactDist(samples.front().Pos); + } + if(samples.size()<2 || totalLength<=0)return 0; + auto nearest=[&](ClientlessTransportStop const& stop) + { + size_t result=samples.size();float best=std::numeric_limits::infinity(); + for(size_t i=0;i=samples.size() || end>=samples.size() || begin==end)return 0; + double length=samples[end].Arc-samples[begin].Arc; + if(length<0)length+=totalLength; + return float(length); +} + bool ClientlessTransportMgr::ObserveJourney(Player* player,uint32 lastMap,Position const& ground,std::string& from,std::string& to) { from.clear();to.clear();if(!player->GetTransport() || !EnsureLoaded())return false; diff --git a/src/server/game/World/ClientlessTransportMgr.h b/src/server/game/World/ClientlessTransportMgr.h index c6a4eeb90f..281ab028d3 100644 --- a/src/server/game/World/ClientlessTransportMgr.h +++ b/src/server/game/World/ClientlessTransportMgr.h @@ -50,6 +50,7 @@ public: static ClientlessTransportMgr* instance(); bool Reload(); bool HasConnection(std::string const& from, std::string const& to); + float ConnectionDistance(std::string const& from, std::string const& to); bool ObserveJourney(Player* passenger, uint32 lastMap, Position const& lastGround, std::string& from, std::string& to); bool ArrivalNode(Player* player, std::string const& from, std::string& to); ClientlessTransportStop const* GetStop(std::string const& node); diff --git a/src/server/game/World/ClientlessTravelCost.h b/src/server/game/World/ClientlessTravelCost.h new file mode 100644 index 0000000000..62336463c4 --- /dev/null +++ b/src/server/game/World/ClientlessTravelCost.h @@ -0,0 +1,57 @@ +#ifndef VCORE_CLIENTLESS_TRAVEL_COST_H +#define VCORE_CLIENTLESS_TRAVEL_COST_H + +#include +#include +#include +#include +#include + +namespace ClientlessTravelCost +{ +enum Mode : unsigned { Ground, Taxi, Flight, Ship, Zeppelin, Tram, Portal, Count }; +inline Mode Type(std::string const& type) +{ + if(type=="taxi")return Taxi; + if(type=="free_flight")return Flight; + if(type=="ship")return Ship; + if(type=="zeppelin")return Zeppelin; + if(type=="tram" || type=="elevator")return Tram; + if(type=="portal" || type=="portal_use")return Portal; + return Ground; +} +inline char const* Name(Mode mode) +{ + static char const* const names[]={"Ground","Taxi","Flight","Ship","Zeppelin","Tram","Portal"}; + return names[mode]; +} +struct Profile +{ + std::array Factors{{1,1,1,1,1,1,1}}; + std::uint32_t Seed=0; + float Cost(Mode mode,float distance) const + { + // A teleport traverses no world-space road. Its transition remains + // positive; coordinates on different maps are never subtracted. + float length=mode==Portal?1.0f:std::max(0.0f,distance); + return length*Factors[mode]; + } + float Cost(std::string const& type,float distance)const{return Cost(Type(type),distance);} +}; +inline Profile Sample(Profile base,std::uint32_t seed,float variation) +{ + variation=std::isfinite(variation)?std::clamp(variation,0.0f,0.9f):0.0f; + base.Seed=seed; + // Separate deterministic draws per mode, never per edge or search probe. + for(unsigned mode=0;mode>16;x*=0x7feb352du;x^=x>>15;x*=0x846ca68bu;x^=x>>16; + float u=float(x>>8)/16777215.0f; + float factor=std::isfinite(base.Factors[mode])?std::clamp(base.Factors[mode],0.01f,100.0f):1.0f; + base.Factors[mode]=factor*(1.0f+variation*(2.0f*u-1.0f)); + } + return base; +} +} +#endif diff --git a/src/server/game/World/ClientlessTravelGraphMgr.cpp b/src/server/game/World/ClientlessTravelGraphMgr.cpp index d084604a98..e622f98c56 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.cpp +++ b/src/server/game/World/ClientlessTravelGraphMgr.cpp @@ -6,6 +6,9 @@ #include "ClientlessLuaEngine.h" #include "DatabaseEnv.h" +#include "DBCStores.h" +#include "Config.h" +#include "Random.h" #include "Log.h" #include "Player.h" #include "ClientlessTravelMgr.h" @@ -317,6 +320,7 @@ bool ClientlessTravelGraphMgr::Reload() while (pointResult->NextRow()); } + RebuildEdgeDistances(); _loaded = true; TC_LOG_INFO("server.adventurebot", @@ -326,6 +330,61 @@ bool ClientlessTravelGraphMgr::Reload() return !_nodes.empty(); } +ClientlessTravelCost::Profile ClientlessTravelGraphMgr::NewJourneyCosts() const +{ + ClientlessTravelCost::Profile base; + for(unsigned mode=0;modeGetFloatDefault( + std::string("Clientless.Travel.CostMultiplier.")+ClientlessTravelCost::Name(ClientlessTravelCost::Mode(mode)),1.0f); + return ClientlessTravelCost::Sample(base,urand(1,0xffffffffu), + sConfigMgr->GetFloatDefault("Clientless.Travel.CostVariation",0.20f)); +} + +void ClientlessTravelGraphMgr::RebuildEdgeDistances() +{ + for(auto& outgoing:_edges)for(auto& edge:outgoing.second) + { + auto const& from=_nodes.at(edge.FromNode);auto const& to=_nodes.at(edge.ToNode); + float length=0; + auto segment=[&](uint32 map,float x,float y,float z,uint32 otherMap,float a,float b,float c) + {if(map==otherMap)length+=std::hypot(std::hypot(x-a,y-b),z-c);}; + if(edge.Type=="portal" || edge.Type=="portal_use")length=1; + else if(edge.Type=="taxi") + { + if(edge.FromNode.rfind("taxi_",0)==0 && edge.ToNode.rfind("taxi_",0)==0) + { + uint32 path=0,fare=0; + sObjectMgr->GetTaxiPath(uint32(std::strtoul(edge.FromNode.c_str()+5,nullptr,10)), + uint32(std::strtoul(edge.ToNode.c_str()+5,nullptr,10)),path,fare); + if(pathContinentID,nodes[i-1]->Loc.X,nodes[i-1]->Loc.Y,nodes[i-1]->Loc.Z, + nodes[i]->ContinentID,nodes[i]->Loc.X,nodes[i]->Loc.Y,nodes[i]->Loc.Z); + } + } + } + else if(ClientlessTransportMgr::IsTransportEdge(edge.Type)) + length=sClientlessTransportMgr->ConnectionDistance(edge.FromNode,edge.ToNode); + else if(auto points=GetEdgePoints(edge.DatabaseId);points && !points->empty()) + { + auto const& first=edge.Reverse?points->back():points->front(); + auto const& last=edge.Reverse?points->front():points->back(); + segment(from.MapId,from.X,from.Y,from.Z,first.MapId,first.X,first.Y,first.Z); + for(size_t i=1;isize();++i) + segment((*points)[i-1].MapId,(*points)[i-1].X,(*points)[i-1].Y,(*points)[i-1].Z, + (*points)[i].MapId,(*points)[i].X,(*points)[i].Y,(*points)[i].Z); + segment(last.MapId,last.X,last.Y,last.Z,to.MapId,to.X,to.Y,to.Z); + } + if(length<=0 && from.MapId==to.MapId) + segment(from.MapId,from.X,from.Y,from.Z,to.MapId,to.X,to.Y,to.Z); + edge.Cost=std::isfinite(length)&&length>0?length:std::numeric_limits::infinity(); + TC_LOG_DEBUG("server.adventurebot.navigation","TRAVEL_EDGE_DISTANCE edge=%u type='%s' reverse=%u distance=%.2f", + edge.DatabaseId,edge.Type.c_str(),edge.Reverse?1u:0u,edge.Cost); + } +} + std::string ClientlessTravelGraphMgr::FindNearestStartNode( Player const* player, float maxDistance) const @@ -364,8 +423,11 @@ bool ClientlessTravelGraphMgr::PlanRoute( float startRadius, std::vector& route, std::string& error, - std::unordered_set const* blockedEdgeIds) + std::unordered_set const* blockedEdgeIds, + ClientlessTravelCost::Profile const* journeyCosts) { + ClientlessTravelCost::Profile const defaults; + auto const& costs=journeyCosts?*journeyCosts:defaults; route.clear(); error.clear(); @@ -448,7 +510,7 @@ bool ClientlessTravelGraphMgr::PlanRoute( blockedEdgeIds->find(edge.DatabaseId) != blockedEdgeIds->end()) continue; - float const candidate = current.Cost + edge.Cost; + float const candidate = current.Cost + costs.Cost(edge.Type,edge.Cost); if (candidate >= distance[edge.ToNode]) continue; @@ -575,7 +637,8 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( std::string& error, std::unordered_set const* blockedEdgeIds, std::unordered_set const* blockedBridgeKeys, - ClientlessTravelLocationGoal const* locationGoal) + ClientlessTravelLocationGoal const* locationGoal, + ClientlessTravelCost::Profile const* journeyCosts) { struct Trace { @@ -639,6 +702,11 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( if (!sAdventureAreaMgr->AllowsPosition(player,locationGoal->MapId,goal)) { error="destination location denied by area catalog"; return false; } } + ClientlessTravelCost::Profile const defaults; + auto const& costs=journeyCosts?*journeyCosts:defaults; + auto groundCost=[&](float length){return costs.Cost(ClientlessTravelCost::Ground,length);}; + auto flightDistanceCost=[&](float length){return costs.Cost(ClientlessTravelCost::Flight,length);}; + auto edgeCost=[&](ClientlessTravelEdge const& edge){return costs.Cost(edge.Type,edge.Cost);}; bool const flightEnabled = sClientlessFlightMgr->Enabled(player); constexpr std::uint64_t MaxFlightEstimatesPerPlan = 16; // Preserve part of the global budget for nodes reached later in the @@ -666,12 +734,10 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( 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)) + if(std::isfinite(result)) { - float distance=a.GetExactDist(&b); - if(distance<20)result=std::numeric_limits::infinity(); - else result=(sClientlessFlightMgr->Preferred(player,map,a) || sClientlessFlightMgr->Preferred(player,map,b))? - 40+distance*0.4f:180+distance*1.5f; + float length=a.GetExactDist(&b); + result=access && length<20?std::numeric_limits::infinity():flightDistanceCost(length); } trace.FlightUs+=std::chrono::duration_cast(std::chrono::steady_clock::now()-begin).count(); flightEstimates.emplace(estimateKey, result); @@ -687,45 +753,38 @@ 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()) + float directFlightCost=std::numeric_limits::infinity(); + auto directFlightRoute=[&]() { - 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 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; - } + 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; + }; + if(locationGoal && locationGoal->MapId==player->GetMapId()) + { + ClientlessTravelNode departure; + departure.Key="flight_departure";departure.MapId=player->GetMapId(); + departure.X=player->GetPositionX();departure.Y=player->GetPositionY();departure.Z=player->GetPositionZ(); + directFlightCost=flightCost(departure,locationGoal->MapId,locationGoal->X,locationGoal->Y,locationGoal->Z); } struct State @@ -807,6 +866,10 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( std::unordered_set flightRoots; uint32 startCount = 0; + // Unreachable nearby pavement/indoor anchors must not consume the entire + // flight budget before graph search can consider flight-only connections. + std::uint64_t startFlightProbes = 0; + constexpr std::uint64_t MaxStartFlightProbes = 4; for (auto const& pair : _nodes) { ClientlessTravelNode const& node = pair.second; @@ -820,7 +883,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( float const localDistance = std::sqrt(dx * dx + dy * dy + dz * dz); if (localDistance > startRadius) continue; - float startCost = localDistance; + float startCost = groundCost(localDistance); // VCORE_VIRTUAL_EDGE_V11_PORTAL_APPROACH: never seed an unreachable node. if (blockedBridgeKeys && blockedBridgeKeys->count("start:" + node.Key)) @@ -842,9 +905,12 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( // the recovery/re-entry mode for nodes that local navigation cannot // reach, not a shortcut racing every nearby pavement node. float air = std::numeric_limits::infinity(); - if (!reachable && !(blockedBridgeKeys && + if (!reachable && startFlightProbes < MaxStartFlightProbes && !(blockedBridgeKeys && blockedBridgeKeys->count("flight:flight_departure->"+node.Key))) + { + ++startFlightProbes; air=flightCost(departure,node.MapId,node.X,node.Y,node.Z,true); + } if(std::isfinite(air) && !reachable) {flightRoots.insert(node.Key);startCost=air;} else @@ -870,7 +936,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( node.Key.c_str(), localDistance, entryNav.Reason.c_str()); continue; } - startCost = entryNav.PathLength; + startCost = groundCost(entryNav.PathLength); if (!reachable) startCost += 100000.0f; // Tier 2 only: deferred roots rank after reachable roots } @@ -988,9 +1054,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( continue; uint32 const startIndex = candidate.Edge.Reverse ? candidate.Count - 1 - candidate.Index : candidate.Index; - float newCost = (alreadyThere ? 0.0f : nav.PathLength) + + float newCost = (alreadyThere ? 0.0f : groundCost(nav.PathLength)) + roadSectionCost(candidate.Edge.DatabaseId, candidate.Edge.Reverse, - startIndex, candidate.Count - 1, candidate.Edge.Cost); + startIndex, candidate.Count - 1, edgeCost(candidate.Edge)); auto old = distance.find(key); if (old != distance.end() && old->second <= newCost) continue; @@ -1000,7 +1066,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( previous.erase(key); virtualRoots[key] = { candidate.Edge.DatabaseId, candidate.Index, candidate.Edge.Reverse, candidate.Point, - alreadyThere ? 0.0f : nav.PathLength }; + alreadyThere ? 0.0f : groundCost(nav.PathLength) }; open.push({ newCost, state }); ++accepted; } @@ -1011,10 +1077,92 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( if (!startCount && !accepted) { + if(std::isfinite(directFlightCost))return directFlightRoute(); error = "no reachable travel node or recorded ground edge near current position"; return false; } + // Reserve a bounded part of the flight budget for complete local portal + // connections before speculative road exits spend it. Nearby ground roots + // are entry options, not a reason to overlook flight-only portal islands. + if (flightEnabled && !locationGoal) + { + struct PortalConnection + { + float Rank; + std::string Root; + ClientlessTravelEdge Edge; + }; + std::vector connections; + auto const& goal = destinationItr->second; + for (auto const& outgoing : _edges) + for (auto const& edge : outgoing.second) + { + if (edge.Type != "portal" && edge.Type != "portal_use") continue; + if (!RescueEdgeAllowed(edge) || !sAdventureAreaMgr->AllowsEdge(player,edge) || + (blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId))) continue; + auto const& source = _nodes.at(edge.FromNode); + auto const& target = _nodes.at(edge.ToNode); + if (target.MapId != goal.MapId) continue; + float dx=goal.X-target.X,dy=goal.Y-target.Y,dz=goal.Z-target.Z; + float finish = flightDistanceCost(std::sqrt(dx*dx+dy*dy+dz*dz)); + for (auto const& root : states) + { + auto const& origin = _nodes.at(root.second.Node); + if (origin.MapId != source.MapId || origin.Key == source.Key) continue; + auto originEdges = _edges.find(origin.Key); + if (originEdges != _edges.end() && std::any_of(originEdges->second.begin(),originEdges->second.end(), + [](ClientlessTravelEdge const& e){return e.Type=="portal" || e.Type=="portal_use";})) continue; + dx=source.X-origin.X;dy=source.Y-origin.Y;dz=source.Z-origin.Z; + float direct=std::sqrt(dx*dx+dy*dy+dz*dz); + if (direct > 1500.0f) continue; + connections.push_back({distance.at(root.first)+flightDistanceCost(direct)+edgeCost(edge)+finish,root.first,edge}); + } + } + std::sort(connections.begin(),connections.end(),[](PortalConnection const& a,PortalConnection const& b) + { + if(a.Rank!=b.Rank)return a.Rank8 || trace.FlightCalls-callsBefore>=6) break; + auto const edge=candidate.Edge; + auto const& origin=_nodes.at(states.at(candidate.Root).Node); + auto const& source=_nodes.at(edge.FromNode); + auto const& target=_nodes.at(edge.ToNode); + if (blockedBridgeKeys && (blockedBridgeKeys->count("flight:"+origin.Key+"->"+source.Key) || + blockedBridgeKeys->count("flight:"+target.Key+"->"+goal.Key))) continue; + float entry=flightCost(origin,source.MapId,source.X,source.Y,source.Z,true); + if (!std::isfinite(entry)) continue; + float finish=target.Key==goal.Key?0.0f:flightCost(target,goal.MapId,goal.X,goal.Y,goal.Z,true); + if (!std::isfinite(finish)) continue; + auto relax=[&](std::string const& from,ClientlessTravelNode const& to, + std::string const& type,uint32 id,bool reverse,float cost) + { + State next{to.Key,false};auto key=stateKey(next); + float score=distance.at(from)+cost; + auto old=distance.find(key); + if(old==distance.end() || scoresecond) + { + distance[key]=score;states[key]=next;persistentHops[key]=persistentHops[from]+1; + previous[key]={from,states.at(from).Node,type,id,reverse}; + open.push({score,next}); + } + return key; + }; + auto sourceKey=relax(candidate.Root,source,"free_flight",0,false,entry); + auto targetKey=relax(sourceKey,target,edge.Type,edge.DatabaseId,edge.Reverse,edgeCost(edge)); + if(target.Key!=goal.Key)relax(targetKey,goal,"free_flight",0,false,finish); + TC_LOG_INFO("server.adventurebot.navigation", + "PORTAL_FLIGHT_CONNECTION from='%s' source='%s' target='%s' goal='%s' entry=%.1f finish=%.1f", + origin.Key.c_str(),source.Key.c_str(),target.Key.c_str(),goal.Key.c_str(),entry,finish); + } + } + // C8.20: virtual node bridges are equal-cost ground alternatives and // participate even when stored graph routes also exist. bool const repairRequired = true; @@ -1163,13 +1311,13 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( candidate.EdgeId = edge.DatabaseId; candidate.Reverse = edge.Reverse; candidate.EndIndex = i; - candidate.RoadCost = edge.Cost * (travelled / totalLength); + candidate.RoadCost = edgeCost(edge) * (travelled / totalLength); candidate.DistanceSq = dsq; candidate.Point = point; bool const groundExitExhausted = blockedBridgeKeys && blockedBridgeKeys->count("ground_exit_edge:" + identity); candidate.LocalCost = locationGoal && !groundExitExhausted && dsq <= locationAccessRadius*locationAccessRadius - ? std::sqrt(dsq) : std::numeric_limits::infinity(); + ? groundCost(std::sqrt(dsq)) : std::numeric_limits::infinity(); // A recorded edge can contain hundreds of samples. Calling // FlightMgr::Estimate for every sample made the synchronous @@ -1186,10 +1334,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( 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, + // Use this journey's flight factor for both candidate + // ordering and execution cost, including expensive modes. + rankedFlightExits.push_back({ candidate.RoadCost + flightDistanceCost(direct), std::move(candidate) }); } } @@ -1245,7 +1392,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( uint32 StartIndex = 0; ClientlessTravelEdgePoint EntryPoint; } bestExit; - float bestGoalCost = std::numeric_limits::infinity(); + float bestGoalCost = directFlightCost; std::string bestGoalState; bool bestFinalFlight = false; // Every permitted node is a potential way back into the persistent graph. @@ -1261,7 +1408,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( if (node.MapId != destination.MapId) return std::numeric_limits::infinity(); float dx = destination.X-node.X, dy = destination.Y-node.Y, dz = destination.Z-node.Z; - return 40.0f + std::sqrt(dx*dx+dy*dy+dz*dz)*0.4f; + return flightDistanceCost(std::sqrt(dx*dx+dy*dy+dz*dz)); }; float best = lowerBoundToGoal(candidate); auto edges = _edges.find(candidate.Key); @@ -1275,7 +1422,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( auto next = _nodes.find(edge.ToNode); if (next == _nodes.end()) continue; - best = std::min(best, edge.Cost + lowerBoundToGoal(next->second)); + best = std::min(best, edgeCost(edge) + lowerBoundToGoal(next->second)); } return best; }; @@ -1308,7 +1455,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( if (fromNodeItr != _edges.end()) for (ClientlessTravelEdge const& edge : fromNodeItr->second) if (edge.DatabaseId == exit.EdgeId && edge.Reverse == exit.Reverse) - fullEdgeCost = edge.Cost; + fullEdgeCost = edgeCost(edge); if (fullEdgeCost <= 0.0f) continue; float const partialCost = roadSectionCost(exit.EdgeId, @@ -1371,7 +1518,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( std::string const finishKey = "finish:" + candidateNode.Key; float const finalCost = blockedBridgeKeys && blockedBridgeKeys->count(finishKey) ? std::numeric_limits::infinity() : - std::min(direct<=locationAccessRadius ? direct : std::numeric_limits::infinity(),air); + std::min(direct<=locationAccessRadius ? groundCost(direct) : std::numeric_limits::infinity(),air); if (current.Cost + finalCost < bestGoalCost) { bestGoalCost = current.Cost + finalCost; @@ -1420,7 +1567,17 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( std::any_of(sourceEdges->second.begin(), sourceEdges->second.end(), [](ClientlessTravelEdge const& edge) { return edge.Type == "portal" || edge.Type == "portal_use"; }); - bool const portalArrival = isPortalSource && previous.find(currentKey) != previous.end(); + // Root states are physical arrivals too: replanning while standing at + // a portal must not turn its source into an indoor flight departure. + // If the portal failed or is forbidden, retain its recorded escape roads. + bool const portalArrival = isPortalSource && + std::any_of(sourceEdges->second.begin(), sourceEdges->second.end(), + [&](ClientlessTravelEdge const& edge) + { + return (edge.Type == "portal" || edge.Type == "portal_use") && + RescueEdgeAllowed(edge) && sAdventureAreaMgr->AllowsEdge(player,edge) && + !(blockedEdgeIds && edge.DatabaseId && blockedEdgeIds->count(edge.DatabaseId)); + }); // Persistent TravelGraph edges always reset the bridge guard. auto edgeItr = _edges.find(current.Value.Node); @@ -1438,7 +1595,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( State next{ edge.ToNode, false }; std::string const nextKey = stateKey(next); - float const candidate = current.Cost + edge.Cost; + float const candidate = current.Cost + edgeCost(edge); auto old = distance.find(nextKey); if (old != distance.end() && candidate >= old->second) @@ -1474,7 +1631,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( // Even a preferred legal flight costs at least this much. // Skip expensive terrain sampling when it cannot improve the // route already found; this preserves the full graph search. - float lowerBound=40.0f+std::sqrt(dx*dx+dy*dy+dz*dz)*0.4f; + float lowerBound=flightDistanceCost(std::sqrt(dx*dx+dy*dy+dz*dz)); if(old!=distance.end() && old->second<=current.Cost+lowerBound)continue; if ((!RescueNodeAllowed(target) || !sAdventureAreaMgr->AllowsNode(player,target))) continue; if (blockedBridgeKeys && blockedBridgeKeys->count("flight:"+origin.Key+"->"+target.Key)) continue; @@ -1637,9 +1794,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( if (exit.EdgeId != entry.Edge->DatabaseId || exit.Reverse != entry.Edge->Reverse || exit.EndIndex <= entryIndex) continue; - float const score = current.Cost + probe.PathLength + exit.LocalCost + + float const score = current.Cost + groundCost(probe.PathLength) + exit.LocalCost + roadSectionCost(exit.EdgeId, exit.Reverse, entryIndex, - exit.EndIndex, entry.Edge->Cost); + exit.EndIndex, edgeCost(*entry.Edge)); if (score >= bestGoalCost) continue; bestGoalCost = score; @@ -1651,9 +1808,9 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( std::string const nextKey = stateKey(next); uint32 const begin = entry.Edge->Reverse ? entry.PointCount - 1 - entry.Index : entry.Index; - float const cost = current.Cost + probe.PathLength + + float const cost = current.Cost + groundCost(probe.PathLength) + roadSectionCost(entry.Edge->DatabaseId, entry.Edge->Reverse, - begin, entry.PointCount - 1, entry.Edge->Cost); + begin, entry.PointCount - 1, edgeCost(*entry.Edge)); auto old = distance.find(nextKey); if (old != distance.end() && cost >= old->second) continue; @@ -1767,7 +1924,7 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( // C8.20: dynamic node-to-node MMAP bridges are ordinary local // ground connections, not penalized fallback routes. - float const candidate = current.Cost + measuredLength; + float const candidate = current.Cost + groundCost(measuredLength); auto old = distance.find(nextKey); if (old != distance.end() && candidate >= old->second) continue; @@ -1782,6 +1939,8 @@ bool ClientlessTravelGraphMgr::PlanAdaptiveRoute( } } + if(bestGoalState.empty() && std::isfinite(directFlightCost))return directFlightRoute(); + if ((locationGoal || flightEnabled) && bestExit.Active && bestExit.RootPartial && !bestGoalState.empty()) previous.erase(bestGoalState); @@ -2132,8 +2291,11 @@ bool ClientlessTravelGraphMgr::PlanRouteToPosition( std::vector& route, std::string& anchorNode, std::string& error, - std::unordered_set const* blockedEdgeIds) + std::unordered_set const* blockedEdgeIds, + ClientlessTravelCost::Profile const* journeyCosts) { + ClientlessTravelCost::Profile const defaults; + auto const& costs=journeyCosts?*journeyCosts:defaults; route.clear(); anchorNode.clear(); error.clear(); @@ -2202,8 +2364,8 @@ bool ClientlessTravelGraphMgr::PlanRouteToPosition( if (localStartDistance > startRadius) continue; - distance[node.Key] = localStartDistance; - open.push({ localStartDistance, node.Key }); + distance[node.Key] = costs.Cost(ClientlessTravelCost::Ground,localStartDistance); + open.push({ distance[node.Key], node.Key }); ++startCandidateCount; } @@ -2233,7 +2395,7 @@ bool ClientlessTravelGraphMgr::PlanRouteToPosition( blockedEdgeIds->find(edge.DatabaseId) != blockedEdgeIds->end()) continue; - float const candidate = current.Cost + edge.Cost; + float const candidate = current.Cost + costs.Cost(edge.Type,edge.Cost); if (candidate >= distance[edge.ToNode]) continue; diff --git a/src/server/game/World/ClientlessTravelGraphMgr.h b/src/server/game/World/ClientlessTravelGraphMgr.h index aa68af6836..72fe0edfae 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.h +++ b/src/server/game/World/ClientlessTravelGraphMgr.h @@ -12,6 +12,7 @@ #include "Common.h" #include "ClientlessGraphProbeCache.h" +#include "ClientlessTravelCost.h" #include #include @@ -114,13 +115,16 @@ public: bool Reload(); bool EnsureLoaded(); + ClientlessTravelCost::Profile NewJourneyCosts() const; + bool PlanRoute( Player const* player, std::string const& destinationNode, float startRadius, std::vector& route, std::string& error, - std::unordered_set const* blockedEdgeIds = nullptr); + std::unordered_set const* blockedEdgeIds = nullptr, + ClientlessTravelCost::Profile const* journeyCosts = nullptr); bool PlanRouteToPosition( Player const* player, @@ -133,7 +137,8 @@ public: std::vector& route, std::string& anchorNode, std::string& error, - std::unordered_set const* blockedEdgeIds = nullptr); + std::unordered_set const* blockedEdgeIds = nullptr, + ClientlessTravelCost::Profile const* journeyCosts = nullptr); // C8.13b: transient local bridges connect nearby graph islands for one // route plan. Bridges are never persisted and may never follow another @@ -151,7 +156,8 @@ public: std::string& error, std::unordered_set const* blockedEdgeIds = nullptr, std::unordered_set const* blockedBridgeKeys = nullptr, - ClientlessTravelLocationGoal const* locationGoal = nullptr); + ClientlessTravelLocationGoal const* locationGoal = nullptr, + ClientlessTravelCost::Profile const* journeyCosts = nullptr); bool FindNearestNodeToPosition( uint32 mapId, @@ -182,6 +188,7 @@ public: bool PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const; private: + void RebuildEdgeDistances(); std::string FindNearestStartNode(Player const* player, float maxDistance) const; ClientlessGraphProbeCache _positionProbes; diff --git a/src/server/worldserver/worldserver.conf.dist b/src/server/worldserver/worldserver.conf.dist index d2bdd612f6..90b062d90e 100644 --- a/src/server/worldserver/worldserver.conf.dist +++ b/src/server/worldserver/worldserver.conf.dist @@ -4396,3 +4396,18 @@ Clientless.Browser.Battleground.QueueEnabled = 1 Clientless.Browser.Battleground.JoinEnabled = 0 # Explicit isolated test override. Does not override disabled Queue/Join switches. Clientless.Browser.AllowBotOnlyTests = 0 + +# Clientless travel route scoring: measured path length (yards), not ticket +# price or the legacy travel_edge.cost column. Higher factors discourage a +# mode. Portal transitions have a unit base cost (no fictitious map distance). +# Variation draws independent positive mode factors once per journey; recovery +# retains the draw and group members inherit their leader's profile and route. +Clientless.Travel.CostMultiplier.Ground = 1.0 +Clientless.Travel.CostMultiplier.Taxi = 1.0 +Clientless.Travel.CostMultiplier.Flight = 1.0 +Clientless.Travel.CostMultiplier.Ship = 1.0 +Clientless.Travel.CostMultiplier.Zeppelin = 1.0 +Clientless.Travel.CostMultiplier.Tram = 1.0 +Clientless.Travel.CostMultiplier.Portal = 1.0 +# 0 disables variation; 0.20 means a per-mode range of 80%..120% per journey. +Clientless.Travel.CostVariation = 0.20 diff --git a/tests/clientless/test_free_flight_planner.py b/tests/clientless/test_free_flight_planner.py index 166e7ba91d..d16c68d888 100644 --- a/tests/clientless/test_free_flight_planner.py +++ b/tests/clientless/test_free_flight_planner.py @@ -11,6 +11,7 @@ preamble=r''' #include "ClientlessGraphProbeCache.h" #include #include +#include "ClientlessTravelCost.h" #include #include #include @@ -34,14 +35,14 @@ struct ClientlessPathDiagnostics {bool Calculated=true, Complete=true,Suspicious struct Travel {bool complete=true;void AnalyzePath(Player* p,Position b,ClientlessPathDiagnostics& n){Position a;a.Relocate(p->x,p->y,p->z);n.PathLength=n.DirectDistance=dist(a,b);n.Complete=complete;}} travel; auto sClientlessTravelMgr=&travel; struct Flight { - bool enabled=false;float minimumX=-10000;float forbiddenTargetX=1e9; + float closedFromX=1e9;bool rejectNearbyTargets=false;bool enabled=false;float minimumX=-10000;float forbiddenTargetX=1e9; float forbiddenPairFromX=1e9,forbiddenPairTargetX=1e9; void BeginPlanning(Player const* = nullptr){} bool Enabled(Player const*){return enabled;} bool Preferred(Player const*,uint32,Position const&){return false;} float Estimate(Player const*,uint32 map,Position a,Position b,bool recovery=false){ bool forbiddenPair=std::fabs(a.x-forbiddenPairFromX)<0.1f&&std::fabs(b.x-forbiddenPairTargetX)<0.1f; - float d=dist(a,b);return enabled&&map==0&&a.x>=minimumX&&std::fabs(b.x-forbiddenTargetX)>0.1f&&!forbiddenPair&&(recovery||d>=60)?(recovery?d:180+d*1.5f):std::numeric_limits::infinity(); + float d=dist(a,b);return enabled&&std::fabs(a.x-closedFromX)>0.1f&&!(rejectNearbyTargets&&b.x>1&&b.x<450)&&map==0&&a.x>=minimumX&&std::fabs(b.x-forbiddenTargetX)>0.1f&&!forbiddenPair&&(recovery||d>=60)?(recovery?d:180+d*1.5f):std::numeric_limits::infinity(); } } flight; auto sClientlessFlightMgr=&flight; @@ -187,6 +188,69 @@ assert(route.size()==4&&route[1].NodeKey=="P"&&route[1].EdgeType=="free_flight") assert(route[2].NodeKey=="Q"&&route[2].EdgeType=="portal_use"); assert(route[3].NodeKey=="B"&&route[3].EdgeType=="free_flight"); flight.forbiddenPairFromX=flight.forbiddenPairTargetX=1e9;travel.complete=true; + +// Replanning AT an interactive portal must traverse it, even when flying +// directly from the indoor source looks cheaper to the estimate. +ClientlessTravelGraphMgr rootPortal;node(rootPortal,"P",500);node(rootPortal,"Q",-1000);node(rootPortal,"B",2000); +edge(rootPortal,"P","Q",91,1,false,"portal_use"); +Player atPortal;atPortal.x=500;travel.complete=false;route.clear(); +assert(rootPortal.PlanAdaptiveRoute(&atPortal,"B",1,200,route,done,endKey,why)&&done); +assert(route.size()>=3&&route[1].EdgeType=="portal_use"&&route[1].NodeKey=="Q"); +// A blocked portal does not trap the actor: its recorded escape road remains. +edge(rootPortal,"P","B",92,400,false,"ground"); +std::unordered_set failedPortal{91};flight.enabled=false;route.clear(); +assert(rootPortal.PlanAdaptiveRoute(&atPortal,"B",1,200,route,done,endKey,why,&failedPortal)&&done); +assert(route.back().EdgeDatabaseId==92); +// Many nearby inaccessible anchors must leave budget for an isolated portal +// outside the local-entry radius and the flight from its destination. +flight.enabled=true;flight.rejectNearbyTargets=true; +flight.forbiddenPairFromX=0;flight.forbiddenPairTargetX=2000; +ClientlessTravelGraphMgr crowded;node(crowded,"A",0);node(crowded,"P",500);node(crowded,"Q",1500);node(crowded,"B",2000); +edge(crowded,"P","Q",93,1,false,"portal_use"); +for(unsigned i=1;i<=40;++i)node(crowded,"indoor"+std::to_string(i),float(i*10)); +route.clear();assert(crowded.PlanAdaptiveRoute(&start,"B",400,200,route,done,endKey,why)&&done); +bool usedIsolatedPortal=false;for(auto const& step:route)if(step.EdgeDatabaseId==93)usedIsolatedPortal=true; +assert(usedIsolatedPortal); +flight.rejectNearbyTargets=false;flight.forbiddenPairFromX=flight.forbiddenPairTargetX=1e9; + +// Speculative road exits and an unusable initial departure must not starve a +// nearby flight-only portal. The cheap complete macro route must beat the +// long native fallback, not merely appear as a considered candidate. +flight.enabled=true;flight.closedFromX=0;travel.complete=true; +ClientlessTravelGraphMgr portalBudget;node(portalBudget,"A",0);node(portalBudget,"C",100); +node(portalBudget,"P",500);node(portalBudget,"Q",1500);node(portalBudget,"B",2000); +edge(portalBudget,"P","Q",94,1,false,"portal_use"); +edge(portalBudget,"A","B",95,10000,false,"taxi"); +for(unsigned i=0;i<4;++i){ + auto key="remote"+std::to_string(i);node(portalBudget,key,3000+float(i*100)); + edge(portalBudget,"A",key,100+i,10000); + for(float x:{0.f,1000.f,2000.f,3000.f}){ClientlessTravelEdgePoint pt;pt.X=x;pt.Y=float(i*20);portalBudget._edgePoints[100+i].push_back(pt);} +} +route.clear();assert(portalBudget.PlanAdaptiveRoute(&start,"B",150,200,route,done,endKey,why)&&done); +bool budgetPortal=false;for(auto const& step:route)if(step.EdgeDatabaseId==94)budgetPortal=true; +assert(budgetPortal);flight.closedFromX=1e9; + +// Apply modal factors to ALL persisted edges and keep a profile stable over +// replanning. Independent journeys may choose different near-equal modes. +flight.enabled=false;travel.complete=true;PathGenerator::available=false; +ClientlessTravelGraphMgr modal;node(modal,"A",0);node(modal,"B",1000); +edge(modal,"A","B",200,1000,false,"ground");edge(modal,"A","B",201,1050,false,"taxi"); +bool sawGround=false,sawTaxi=false; +for(unsigned seed=1;seed<=100;++seed){ + auto profile=ClientlessTravelCost::Sample({},seed,.2f); + route.clear();assert(modal.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,nullptr,&profile)&&done); + auto picked=route.back().EdgeDatabaseId;sawGround|=picked==200;sawTaxi|=picked==201; + route.clear();assert(modal.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,nullptr,&profile)&&done); + assert(route.back().EdgeDatabaseId==picked); +}assert(sawGround&&sawTaxi); +// A same-map flight is a competing route, never an unconditional override. +flight.enabled=true;ClientlessTravelCost::Profile expensiveFlight;expensiveFlight.Factors[ClientlessTravelCost::Flight]=100; +ClientlessTravelLocationGoal sameMap;sameMap.X=1000;sameMap.AnchorRadius=5; +route.clear();assert(modal.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,&sameMap,&expensiveFlight)&&done); +assert(route.back().NodeKey!="flight_location"); +expensiveFlight.Factors[ClientlessTravelCost::Flight]=.25f; +route.clear();assert(modal.PlanAdaptiveRoute(&start,"B",1,200,route,done,endKey,why,nullptr,nullptr,&sameMap,&expensiveFlight)&&done); +assert(route.back().NodeKey=="flight_location"&&route.back().EdgeType=="free_flight"); std::cout<<"ground planner regressions and dynamic flight/node/location/virtual-exit integration passed\n"; } ''' diff --git a/tests/clientless/test_ground_exit_continuation.py b/tests/clientless/test_ground_exit_continuation.py index bed958865b..22a75327b1 100644 --- a/tests/clientless/test_ground_exit_continuation.py +++ b/tests/clientless/test_ground_exit_continuation.py @@ -18,6 +18,7 @@ def method(signature): return source[start:end] preamble = r''' +#include "ClientlessTravelCost.h" #include #include #include @@ -95,8 +96,8 @@ struct Graph { } graph; auto sClientlessTravelGraphMgr=&graph; struct Areas{bool AllowsNode(Player*,ClientlessTravelNode const&){return true;}bool AllowsSegment(Player*,uint32,Position const&,Position const&){return true;}}areas;auto sAdventureAreaMgr=&areas; -static int recoveries=0; -bool ClientlessActionExecutorMgr::RecoverTravel(Player*,uint32,Record&,uint32,std::string const&){++recoveries;return false;} +static int recoveries=0;static uint32 recoveryEdge=0; +bool ClientlessActionExecutorMgr::RecoverTravel(Player*,uint32,Record&,uint32 edge,std::string const&){++recoveries;recoveryEdge=edge;return false;} ''' methods = '\n'.join(method(sig) for sig in ( 'bool StartGroundConnection(', @@ -190,8 +191,10 @@ int main(){ // Owner adapters can reject, wait, or shorten a recorded road without // losing the original index mapping or launching an unsafe spline. Record adapted;adapted.Active=true;adapted.EdgePath.resize(5);for(unsigned i=0;i<5;++i)adapted.EdgePath[i].X=150+i*10;p.x=140; - mgr._groundAdapters[26]=[](Player*,std::vector&){return false;};before=p.motion.launches; + adapted.Route.resize(1);adapted.Route[0].EdgeDatabaseId=1885; + mgr._groundAdapters[26]=[](Player*,std::vector&){return false;};before=p.motion.launches;auto previousRecoveries=recoveries; assert(!mgr.StartRecordedEdgePath(&p,26,adapted) && p.motion.launches==before); + assert(recoveries==previousRecoveries+1 && recoveryEdge==1885); mgr._groundAdapters[26]=[](Player*,std::vector&path){path.resize(1);return true;}; assert(mgr.StartRecordedEdgePath(&p,26,adapted) && adapted.FormationWaiting && p.motion.launches==before); mgr._groundAdapters[26]=[](Player*,std::vector&path){path.resize(3);return true;}; @@ -203,5 +206,5 @@ with tempfile.TemporaryDirectory(prefix='vcore-ground-exit-') as folder: cpp = Path(folder) / 'test.cpp' binary = Path(folder) / 'test' cpp.write_text(preamble + structs + cls + doubles + methods + tests) - subprocess.run(['c++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')), '-std=c++17', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(binary)], check=True) + subprocess.run(['c++','-I'+str(root/'src/server/game/World'),'-include',str(Path(__file__).with_name('telemetry_test_stub.h')), '-std=c++17', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(binary)], check=True) subprocess.run([str(binary)], check=True) diff --git a/tests/clientless/test_shared_group_route.py b/tests/clientless/test_shared_group_route.py index 7e36dab70c..4663096940 100644 --- a/tests/clientless/test_shared_group_route.py +++ b/tests/clientless/test_shared_group_route.py @@ -5,6 +5,7 @@ root=Path(__file__).resolve().parents[2] s=(root/'src/server/game/World/ClientlessActionExecutorMgr.cpp').read_text() a=s.index('bool ClientlessActionExecutorMgr::AdoptRoute(') code=r''' +#include "ClientlessTravelCost.h" #include #include #include @@ -20,9 +21,9 @@ struct Group{Guid leader{29};Guid GetLeaderGUID(){return leader;}}group; struct Player{bool grouped=true;unsigned map=0;Guid GetGUID(){return {31};}Group*GetGroup(){return grouped?&group:nullptr;}unsigned GetMapId(){return map;}float GetExactDist(Position const&p){return std::hypot(p.x,p.y);}}; struct ClientlessAction{std::string Name;std::unordered_mapParameters;}; struct ClientlessTravelRouteStep{std::string NodeKey,EdgeType;unsigned EdgeDatabaseId=0,MapId=0;float X=0,Y=0,Z=0;}; -struct Graph{bool allowed=true;bool PlanAdaptiveRoute(Player*,std::string const&node,int,int,std::vector&r,bool&complete,std::string&,std::string&){complete=true;r={{node,"start"}};return allowed;}}graph;auto sClientlessTravelGraphMgr=&graph; +struct Graph{ClientlessTravelCost::Profile observed;bool allowed=true;bool PlanAdaptiveRoute(Player*,std::string const&node,int,int,std::vector&r,bool&complete,std::string&,std::string&,void const* =nullptr,void const* =nullptr,void const* =nullptr,ClientlessTravelCost::Profile const*costs=nullptr){if(costs)observed=*costs;complete=true;r={{node,"start"}};return allowed;}}graph;auto sClientlessTravelGraphMgr=&graph; struct ClientlessActionExecutorMgr{ - struct Record{std::vectorRoute;size_t RouteStep=0;bool FinalLocalLegPending=false,RouteComplete=true,HardDestination=false,Active=false; + struct Record{ClientlessTravelCost::Profile JourneyCosts;std::string JourneyCostKey;bool HasJourneyCosts=false;std::vectorRoute;size_t RouteStep=0;bool FinalLocalLegPending=false,RouteComplete=true,HardDestination=false,Active=false; unsigned FinalMapId=0,FinalDatabaseId=0;float FinalX=0,FinalY=0,FinalZ=0,FinalO=0;std::string FinalLocationId,PlannedDestinationNode,AdaptiveReachedNode;}; std::unordered_map_records;int starts=0;bool StartRouteStep(Player*,unsigned,Record&){++starts;return true;} bool AdoptRoute(Player*,ClientlessAction const&,Record&); @@ -31,14 +32,16 @@ struct ClientlessActionExecutorMgr{ int main(){ ClientlessActionExecutorMgr mgr;Player p;ClientlessAction a;a.Name="travel_to_location";a.Parameters={{"id","destination"},{"route_source","29"}}; auto& lead=mgr._records[29];lead.Route={{"start","start",0,0,0,0,0},{"taxi_end","taxi",18,0,30,0,0},{"flight_end","free_flight",0,0,80,0,0}}; + lead.JourneyCosts=ClientlessTravelCost::Sample({},12345,0.2f);lead.JourneyCostKey="leader-trip";lead.HasJourneyCosts=true; lead.RouteStep=1;lead.FinalLocationId="destination";lead.FinalDatabaseId=77;lead.FinalX=90;lead.FinalLocalLegPending=true; ClientlessActionExecutorMgr::Record peer; assert(mgr.AdoptRoute(&p,a,peer));assert(peer.Route.size()==3&&peer.Route[1].EdgeType=="taxi"&&peer.Route[1].EdgeDatabaseId==18&&peer.Route[2].EdgeType=="free_flight"); assert(peer.FinalLocationId=="destination"&&peer.FinalLocalLegPending&&peer.FinalX==90&&peer.FinalDatabaseId==77); + assert(peer.HasJourneyCosts&&peer.JourneyCosts.Seed==12345&&peer.JourneyCostKey=="leader-trip"&&peer.JourneyCosts.Factors==lead.JourneyCosts.Factors); assert(peer.RouteStep==0&&peer.Active&&lead.RouteStep==1);peer.Route[1].NodeKey="changed";assert(lead.Route[1].NodeKey=="taxi_end"); // Late arrival joins the active route at its previous reached node. lead.RouteStep=2;assert(mgr.AdoptRoute(&p,a,peer));assert(peer.Route.size()==2&&peer.Route[0].NodeKey=="taxi_end"&&peer.Route[0].EdgeType=="start"&&peer.Route[1].EdgeType=="free_flight"); - p.map=1;assert(mgr.AdoptRoute(&p,a,peer));graph.allowed=false;assert(!mgr.AdoptRoute(&p,a,peer));graph.allowed=true;p.map=0; + p.map=1;assert(mgr.AdoptRoute(&p,a,peer));assert(graph.observed.Factors==lead.JourneyCosts.Factors&&graph.observed.Seed==12345);graph.allowed=false;assert(!mgr.AdoptRoute(&p,a,peer));graph.allowed=true;p.map=0; // No cross-group route copying and no stale destination reuse. group.leader={32};assert(!mgr.AdoptRoute(&p,a,peer));group.leader={29};p.grouped=false;assert(!mgr.AdoptRoute(&p,a,peer));p.grouped=true; a.Parameters["id"]="different";assert(!mgr.AdoptRoute(&p,a,peer));a.Parameters["id"]="destination"; @@ -47,6 +50,6 @@ int main(){ ''' with tempfile.TemporaryDirectory() as d: p=Path(d);(p/'route.cpp').write_text(code) - subprocess.run(['g++','-std=c++17',str(p/'route.cpp'),'-o',str(p/'route')],check=True) + subprocess.run(['g++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'route.cpp'),'-o',str(p/'route')],check=True) subprocess.run([str(p/'route')],check=True) print('Production generic shared routes: same edges/taxi/free flight, independent execution state, final local goal, late catch-up, permissions and stale-goal rejection passed.') diff --git a/tests/clientless/test_transport_cost_distance.py b/tests/clientless/test_transport_cost_distance.py new file mode 100644 index 0000000000..352b298d9b --- /dev/null +++ b/tests/clientless/test_transport_cost_distance.py @@ -0,0 +1,50 @@ +"""Production native transport arclength, directed wraparound and static animation distance.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/ClientlessTransportMgr.cpp').read_text() +a=s.index('float ClientlessTransportMgr::ConnectionDistance(');b=s.index('bool ClientlessTransportMgr::ObserveJourney(',a) +code=r''' +#include +#include +#include +#include +#include +#include +#include +#include +using uint32=unsigned; +struct Position{float x=0,y=0,z=0;Position(){}Position(float a,float b,float c):x(a),y(b),z(c){}float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetExactDist(Position const&p)const{return std::sqrt((x-p.x)*(x-p.x)+(y-p.y)*(y-p.y)+(z-p.z)*(z-p.z));}}; +namespace G3D{struct Matrix{};struct Vector3{float x=0,y=0,z=0;Vector3(){}Vector3(float a,float b,float c):x(a),y(b),z(c){}Vector3 operator*(Matrix)const{return *this;}};struct Quat{Quat(float,float,float,float){}Matrix toRotationMatrix(){return {};}};} +struct QuaternionData{float x=0,y=0,z=0,w=1;}; +struct Spline{double Length;float X,Span;double length()const{return Length;}void evaluate_percent(float t,G3D::Vector3&p)const{p={X+t*Span,0,0};}}; +struct Segment{double DistanceFromLegStartAtEnd;}; +struct Leg{unsigned MapId;std::shared_ptrSpline;std::vectorSegments;}; +struct Native{std::vectorPathLegs;}; +struct AnimPoint{struct{float X,Y,Z;}Pos;};struct Animation{std::mapPath;}; +struct TransportManager{Native native;Animation animation;bool useNative=true;Native*GetTransportTemplate(unsigned){return useNative?&native:nullptr;}Animation*GetTransportAnimInfo(unsigned){return &animation;}}tm;auto sTransportMgr=&tm; +struct Spawn{Position spawnPoint{100,0,0};}spawn; +struct Addon{QuaternionData ParentRotation;}addon; +struct Objects{Spawn*GetGameObjectData(unsigned){return &spawn;}Addon*GetGameObjectAddon(unsigned){return &addon;}}objects;auto sObjectMgr=&objects; +struct ClientlessTransportStop{unsigned Entry=1,Spawn=5,Map=0;Position Dock;}; +struct ClientlessTransportMgr{std::unordered_mapstops;auto GetStop(std::string const&n){return stops.count(n)?&stops[n]:nullptr;}float ConnectionDistance(std::string const&,std::string const&);}; +'''+s[a:b]+r''' +int main(){ + ClientlessTransportMgr m;m.stops["from"]={1,5,0,{10,0,0}};m.stops["to"]={1,5,1,{1000005,0,0}}; + tm.native.PathLegs={{0,std::make_shared(Spline{100,0,10}),{{100}}},{1,std::make_shared(Spline{300,1000000,20}),{{75},{300}}}}; + assert(m.ConnectionDistance("from","to")==75);assert(m.ConnectionDistance("to","from")==325); + // Geometric jumps between maps contribute zero, native curved-leg length is retained. + assert(m.ConnectionDistance("missing","to")==0); + tm.useNative=false;AnimPoint p0{{0,0,0}},p1{{3,0,0}},p2{{10,0,0}}; + tm.animation.Path={{0,&p0},{1,&p1},{2,&p2},{3,&p0}}; + m.stops["from"].Map=m.stops["to"].Map=0;m.stops["from"].Dock={103,0,0};m.stops["to"].Dock={110,0,0}; + assert(m.ConnectionDistance("from","to")==7);assert(m.ConnectionDistance("to","from")==13); +} +''' +# Avoid a type/member identifier collision in this small native-shaped fixture. +code=code.replace('struct Spline{','struct NativeSpline{').replace('std::shared_ptrSpline','std::shared_ptrSpline').replace('std::make_shared(Spline{','std::make_shared(NativeSpline{') +with tempfile.TemporaryDirectory(prefix='vcore-transport-costs-') 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 native transport distances: spline arclength, cross-map discontinuities, directed cycle and static animation stops passed.') diff --git a/tests/clientless/test_travel_cost_journey.py b/tests/clientless/test_travel_cost_journey.py new file mode 100644 index 0000000000..83ee0678e4 --- /dev/null +++ b/tests/clientless/test_travel_cost_journey.py @@ -0,0 +1,50 @@ +"""Exercise production executor journey initialization, retry stability and leader inheritance.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/ClientlessActionExecutorMgr.cpp').read_text() +a=s.index('bool ClientlessActionExecutorMgr::StartAction(') +b=s.index(' record.StopAtNode = false;',a) +method=s[a:b]+' return true;\n}\n' +code=r''' +#include "ClientlessTravelCost.h" +#include +#include +#include +using uint32=unsigned; +#define TC_LOG_INFO(...) ((void)0) +struct Guid{unsigned id;unsigned GetCounter()const{return id;}}; +struct Group{unsigned leader=29;Guid GetLeaderGUID(){return {leader};}}group; +struct Player{unsigned id=29;Guid GetGUID(){return {id};}Group*GetGroup(){return &group;}}; +struct ClientlessAction{std::string Name="travel_to_node";std::unordered_mapParameters{{"node","destination"}};}; +enum class ClientlessActionExecutionState{Idle,Completed,Failed,SelectingTarget}; +struct Graph{unsigned draws=0;auto NewJourneyCosts(){return ClientlessTravelCost::Sample({},++draws,.2f);}}graph;auto sClientlessTravelGraphMgr=&graph; +struct ClientlessActionExecutorMgr{ + struct Record{ClientlessTravelCost::Profile JourneyCosts;std::string JourneyCostKey;bool HasJourneyCosts=false;struct{ClientlessActionExecutionState State=ClientlessActionExecutionState::Idle;}Status;}; + std::unordered_map_records; + bool StartAction(Player*,ClientlessAction const&,Record&); +}; +'''+method+r''' +int main(){ + ClientlessActionExecutorMgr m;Player leader;ClientlessAction action;auto& r=m._records[29]; + m.StartAction(&leader,action,r);auto original=r.JourneyCosts;assert(graph.draws==1); + r.Status.State=ClientlessActionExecutionState::Failed;m.StartAction(&leader,action,r); + assert(graph.draws==1&&r.JourneyCosts.Factors==original.Factors); + r.Status.State=ClientlessActionExecutionState::SelectingTarget;m.StartAction(&leader,action,r);assert(graph.draws==1); + action.Parameters["node"]="another-destination";m.StartAction(&leader,action,r);assert(graph.draws==2); + r.Status.State=ClientlessActionExecutionState::Completed;m.StartAction(&leader,action,r);assert(graph.draws==3); + Player follower;follower.id=31;action.Parameters["route_source"]="29";auto& peer=m._records[31]; + // Preferences are already inherited before a shared route is available. + m.StartAction(&follower,action,peer);assert(graph.draws==3&&peer.JourneyCosts.Factors==r.JourneyCosts.Factors); + action.Parameters.erase("route_source");peer.Status.State=ClientlessActionExecutionState::SelectingTarget; + m.StartAction(&follower,action,peer);assert(graph.draws==3&&peer.JourneyCosts.Seed==r.JourneyCosts.Seed); + // A forged/stale source from another group is not inherited. + group.leader=99;action.Parameters["route_source"]="29";peer.Status.State=ClientlessActionExecutionState::Completed; + m.StartAction(&follower,action,peer);assert(graph.draws==4&&peer.JourneyCosts.Seed!=r.JourneyCosts.Seed); +} +''' +with tempfile.TemporaryDirectory(prefix='vcore-journey-costs-') 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 journey initialization: new trip/new draw, stable retries, delayed leader route and group-safe modal inheritance passed.') diff --git a/tests/clientless/test_travel_costs.py b/tests/clientless/test_travel_costs.py new file mode 100644 index 0000000000..813c8ac4c3 --- /dev/null +++ b/tests/clientless/test_travel_costs.py @@ -0,0 +1,64 @@ +"""Measured production edge lengths and stable per-journey modal preferences.""" +from pathlib import Path +import subprocess, tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/ClientlessTravelGraphMgr.cpp').read_text() +a=s.index('void ClientlessTravelGraphMgr::RebuildEdgeDistances(') +b=s.index('\nstd::string ClientlessTravelGraphMgr::FindNearestStartNode(',a) +code=r''' +#include "ClientlessTravelCost.h" +#include +#include +#include +#include +#include +using uint32=unsigned; +#define TC_LOG_DEBUG(...) ((void)0) +struct Node{unsigned MapId=0;float X=0,Y=0,Z=0;}; +struct Point:Node{float O=0;}; +struct Edge{unsigned DatabaseId=0;std::string FromNode,ToNode,Type;float Cost=99999;bool Reverse=false;}; +struct NativePoint{unsigned ContinentID;struct{float X,Y,Z;}Loc;}; +using TaxiPathNodeList=std::vector; +std::vectorsTaxiPathNodesByPath(2); +struct Objects{void GetTaxiPath(unsigned,unsigned,unsigned&p,unsigned&fare){p=1;fare=98765;}}objects;auto sObjectMgr=&objects; +struct Transports{static bool IsTransportEdge(std::string const&t){return t=="ship"||t=="tram";}float ConnectionDistance(std::string const&,std::string const&){return 777;}}transports; +using ClientlessTransportMgr=Transports;auto sClientlessTransportMgr=&transports; +struct ClientlessTravelGraphMgr{ + std::unordered_map_nodes;std::unordered_map>_edges; + std::unordered_map>points; + auto GetEdgePoints(unsigned id){return points.count(id)?&points[id]:nullptr;} + void RebuildEdgeDistances(); +}; +'''+s[a:b]+r''' +int main(){ + using namespace ClientlessTravelCost; + Profile neutral; + auto first=Sample(neutral,1234,.2f),same=Sample(neutral,1234,.2f),other=Sample(neutral,9876,.2f); + assert(first.Factors==same.Factors&&first.Factors!=other.Factors); + for(float factor:first.Factors)assert(factor>=.8f&&factor<=1.2f); + assert(Sample(neutral,1234,0).Factors==neutral.Factors); + bool ground=false,taxi=false; + for(unsigned seed=1;seed<=200;++seed){auto p=Sample(neutral,seed,.2f);if(p.Cost(Ground,1000)