diff --git a/contrib/clientless/export_area_catalog.py b/contrib/clientless/export_area_catalog.py index 1e6c094683..ea4e872754 100644 --- a/contrib/clientless/export_area_catalog.py +++ b/contrib/clientless/export_area_catalog.py @@ -79,8 +79,15 @@ def export(directory): 4707, 4720, 4737} outland = {3483, 3521, 3519, 3518, 3522, 3523, 3520, 3703} cataclysm = {616, 4922, 5034, 5042, 5095, 5389} + # Native city roots still have physical surroundings. These semantic + # catalog parents are editable and never alter the native DBC hierarchy. + city_parents = {1519:12,1537:1,1657:141,1637:14,1638:215,1497:85, + 3487:3430,3557:3524,3703:3519,4395:2817} + area_maps = {r[0]:r[1] for r in areas} for r in sorted(areas): area, map_id, parent = r[:3] + if not parent and area_maps.get(city_parents.get(area)) == map_id: + parent = city_parents[area] faction = '1' if area in alliance else '2' if area in horde else 'NULL' note = 'Racial home region/capital' if faction != 'NULL' else '' if area == 5146: diff --git a/doc/clientless/2026-10-30-city-catalog-build-policy.md b/doc/clientless/2026-10-30-city-catalog-build-policy.md new file mode 100644 index 0000000000..9857cce214 --- /dev/null +++ b/doc/clientless/2026-10-30-city-catalog-build-policy.md @@ -0,0 +1,126 @@ +# Gebietskatalog, POI-Zuordnung und sichere Builds + +## Befund und Korrektur + +Pibwin (GUID88, Stufe18) kündigte ein Abenteuer bei „Dalaran Krasus' Landing“ an. +Der importierte Ort `poi_guard_140` lag tatsächlich auf Karte1 bei5831/508/Z3,55, +Gebiet2078, Zone148 (Dunkelküste). Der Gebietskatalog prüfte deshalb einen +niedrigstufigen Ort, nicht Dalaran. Ursache war die Auswahl des nächsten +`game_tele`-Ankers nach X/Y über verschiedene Kontinente hinweg. + +POIs erhalten ihre Karte jetzt über die nativen Gesprächsmenüs der tatsächlich +platzierten NPCs (`creature_template_gossip`, `gossip_menu_option`, `creature`). +Untermenüs werden verfolgt, Zyklen abgefangen. Mehrdeutige oder fehlende native +Zuordnungen werden nicht durch eine Koordinatenvermutung ersetzt. Erst innerhalb +der eindeutigen Karte werden die Bodenhöhe und die Gebietszuordnung bestimmt. + +`clientless adventure repairimport` erkennt unveränderte generierte Orte anhand +von Quelle, Name, Position und Geografiemetadaten. Es korrigiert ihre Karte/Höhe/ +Gebietsdaten und deaktiviert solche ohne sichere native Zuordnung. Manuell +veränderte Orte sowie Original-POIs und aufgezeichnete Reiserouten bleiben erhalten. + +Die Katalogmigration `2026_10_30_00_adventure_city_parents.sql` ordnet native +Stadtwurzeln ihren geografischen Umgebungen zu, insbesondere Dalaran4395 dem +Kristallsangwald2817 und Shattrath3703 Terokkar3519. Auch die acht Volksstädte +bekommen ihre Heimatregion als semantischen Katalog-Elternbereich. Vorhandene +manuelle Elternzuordnungen bleiben bestehen; Karten müssen übereinstimmen. +Neue DBC-Exporte erzeugen dieselben editierbaren Vorgaben. Die native DBC- und +Spielergebietshierarchie wird nicht geändert. Levelminimum, Fraktionssperren und +fehlende Flugvorgaben werden über die vorhandene Katalogvererbung berücksichtigt. + +Gruppenpläne prüfen auch das tatsächlich angekündigte nächste Ziel gegen die +Berechtigungen des Bots. Ungültige gespeicherte/manuelle Ziele werden nicht mehr +beworben. Bei Einladungen werden sowohl Anführer als auch Teilnehmer geprüft. +Gespeicherte Tagespläne werden durch die vorhandene Reparatur beim Laden bzw. +Katalogwechsel auf zulässige Ziele umgestellt. + +## Verbindlicher Ablauf vor künftigen Builds + +1. Den bisherigen Wert von `Clientless.Adventure.Loader.Enabled` sichern, auf0 + setzen, `reload config` und `clientless population reload` ausführen. Bei einer + noch laufenden Loader-Operation deren Abschluss abwarten und erneut laden. +2. `clientless logout safe all` ausführen. Der Welt-Thread fordert einmalig einen + sicheren Verhaltensstopp an und lässt die bestehenden Controller weiterlaufen. + Er wartet auf lebende, geladene Bots ohne Kampf, Transportbindung, Taxi, Flug, + Fall, Teleport, Zauber oder aktive Bewegung. Es gibt keinen erzwungenen Logout + nach einer Wartefrist. Menschen und normale Netzwerksitzungen sind ausgenommen. +3. Mit `clientless logout status` warten, bis `pending=0 sessions=0` gemeldet wird. + Erst dann `cmake --build /home/wow_admin/wow_cata_server/build/debug -j10` starten. + Bei blockierten Bots zuerst die Ursache untersuchen; nicht nebenher bauen. +4. Den Loader durch Installation und Neustart hindurch pausiert lassen. + Laufende Binärdateien nicht überschreiben; nach sauberem Serverstopp installieren. + Frischen Ready-Eintrag, Prozess und Listener prüfen. +5. `clientless logout resume` beendet die Sammel-Anforderung, falls derselbe + Weltprozess weiterläuft. Nach Neustart ist sie ohnehin zurückgesetzt. + Bisherige Loader-Konfiguration wiederherstellen, `reload config` und + `clientless population reload` ausführen. Der Loader lädt die Bots wieder + schrittweise. Die Regel ist zusätzlich dauerhaft in `AGENTS.md` festgehalten. + +Einzelne Bots können mit `clientless logout safe ` ebenso sicher abgemeldet +werden. Der bisherige unmittelbare Einzel-Logout bleibt als Administrationswerkzeug +bestehen und ist nicht der empfohlene Build-Ablauf. + +Beim ersten Übergang auf diesen Ablauf war der sichere Sammel-Befehl noch nicht +installiert. Die vorhandenen sicheren Intent-Stopps dienten zur Abmeldung. +Für zwei im alten Schiffscode festhängende Bots wurde zunächst der bereits fertig +gebaute vorherige Transportfix installiert. Tholgar92 und Lillina118 erreichten +anschließend regulär den Anleger (`Arrived`, `passenger=0`,11:54:06). +Bei den zuletzt stationären Bots Edwin36 und Drakogzug77 wurden vermutete +verwaiste native Kampfzustände separat mit `combatstop` bereinigt; dies ist kein +Bestandteil des neuen automatischen Logout-Verfahrens. Anschließend wurden ihre +sicheren Stopps und Offline-Zustände bestätigt. Vor dem neuen Build waren sämtliche +110 Bots offline, der Loader aus und nur eine normale Spielersitzung aktiv. + +## Weltkarte + +Der vorhandene GM-Befehl lautet `.cheat explore 1`. Ohne Zielauswahl wird die +eigene Karte aufgedeckt; bei Auswahl eines Spielers dessen Karte. `.cheat explore 0` +löscht die Erkundungsbits wieder. Behoben wurden die bisher falsche Zielperson +und das wirkungslose Löschen per `SetFlag(...,0)`. Der Befehl schreibt jetzt alle +Erkundungsfelder des ausgewählten Spielers korrekt. + +## Prüfstand + +113 isolierte Clientless-Testprogramme erfolgreich. Neue Prüfungen führen die +produktive POI-Zuordnung, den nativen Logout-Sicherheitscheck und den bestehenden +Kartenbefehl aus. Weitere Prüfungen bestätigen die Katalogmigration einschließlich +Idempotenz und Erhalt manueller Eltern sowie neue DBC-Exporte und Gruppenpläne +mit unzulässigem Folgeziel. Die Integrationsergebnisse nach Installation werden +im Session-Checkpoint und im Abschlussbericht festgehalten. + +Build mit bestehender Debug/Ninja-Konfiguration und `-j10` erfolgreich, ohne +parallel eingeloggte Bots. Logs unter `/tmp/vcore-city-safe-logout-*`, Testausgaben +unter `/tmp/vcore-travel-fixes-tests/`. Geografiesnapshots unter +`/tmp/vcore-city-fix-state/` enthalten keine Konfigurationszugangsdaten. + +### Bestätigte Integration + +- Finaler Testserverstart: PID 446178, frische Ready-Meldung nach 10 Sekunden, + Listener 8085/8087. Beide Builds mit `-j10` bei 0 Bots erfolgreich, ohne OOM-Abbruch. +- Reparatur: 437 native Wächter-POIs, 166 nicht nutzbare Koordinaten übersprungen, + 2916 aktive Locations einschließlich Instanzvarianten. Die Höhenprüfung nutzt + zunächst NPC-Höhe +3; bei weit entferntem Bodentreffer folgt eine Probe von +35. + Der nähere Treffer gewinnt. Nahe Innenböden werden beibehalten. +- `poi_guard_140`: Karte 571, X 5831, Y 508,617, Z 657,7, Gebiet 4564, + Zone 4395 Dalaran. Pibwin auf Stufe 18: `own=0 invitation=0`. Sein Tagesblock + 12 behält Adventure, bekommt `poi_tele_651` (Blutmythosinsel, niedriger Levelbereich). +- Katalogeltern 4395→2817 und 3703→3519 bestätigt. Policytests bestätigen + geerbte Levelminima 77 bzw. 62 und Flugvorgaben. +- Nodes, Kanten und Transportstopps vor/nach Reparatur bytegleich. Alle 17 + vorherigen manuellen Ortsdatensätze unverändert. 236 generierte Datensätze + repariert/deaktiviert, 12 semantische Datensätze ergänzt. Snapshots unter + `/tmp/vcore-city-fix-state/`. +- Sammel-Logout mit Arthoran 26 und Pibwin 88 live ausgeführt: erste Rückmeldung + pending=2/sessions=2/flight=1. Arthorans native Taxiverbindung 2221 endete vor + seinem `CLIENTLESS_SAFE_LOGOUT`, danach Pibwins Bewegungsabschluss und Logout. + Abschluss: `pending=0 sessions=0`. Kein zeitgesteuerter Zwangslogout. +- Sammel-Anforderung mit `logout resume` beendet, ursprünglichen Loaderwert 1 + wiederhergestellt, Config und Population neu geladen. Bots kehren schrittweise + zurück; keine neuen Sammel-Logouts nach der Wiederaufnahme. Zielkonfiguration + unverändert: 50 persistente + 5 temporäre Bots pro Fraktion. Abschließend alle + 110 Bots online, keine ausstehenden Logins, Loader ohne Fehler. + +Die Liveprüfung bestätigt Zielberechtigung und Logoutablauf. Sie ersetzt keine +vollständige Abnahme sämtlicher Reisewege oder aller reparierten POIs. Der +Kartenbefehl wurde am produktiven Handler isoliert geprüft; keinem Spieler wurde +ungefragt seine Karte verändert. diff --git a/doc/clientless/2026-10-30-travel-fixes.md b/doc/clientless/2026-10-30-travel-fixes.md new file mode 100644 index 0000000000..ba04018b41 --- /dev/null +++ b/doc/clientless/2026-10-30-travel-fixes.md @@ -0,0 +1,85 @@ +# Reise- und Gruppenfixes vom 30.10.2026 + +## Betriebsgrenze + +Der Nutzer zeichnet am laufenden Testserver Routen auf. Daher ausschließlich +Quellcode, lokaler Build mit `-j10` und isolierte Tests; **keine Installation, +kein Neustart und keine Änderungen an aufgezeichneten Routen oder Live-Daten**. +Die native Instanz-/Raid-Zulassung bleibt erhalten. + +## Änderungen + +- Gruppenwiederherstellung: Ein vorhandener Charakter ohne Gilde ist weiterhin + ein gültiges Gruppenmitglied. Nur nicht vorhandene Charaktere werden entfernt; + Rollen, Untergruppe und Mitgliedsflags werden regulär wiederhergestellt. +- Portalplanung: Ein generischer read-only ConditionMgr-Zugriff bewertet die + bereits geladenen Spielerbedingungen für den jeweiligen nativen Portalzauber. + Level, Quests, Gegenstände und weitere Spieleranforderungen einschließlich + AND/OR-Gruppen, Referenzen und negativer Bedingungen werden berücksichtigt. + Die Spielerrolle im nativen Cast bestimmt den geprüften ConditionTarget. + Orts-/Kampf-/Skriptbedingungen bleiben bis zur tatsächlichen Benutzung offen. + Diese dynamische Prüfung erfolgt vor dem Cache der statischen Gebietserlaubnis + und nochmals vor der Benutzung. Keine festen Ersatz-Level, entfernten + Bedingungen oder künstlichen Portalteleports. +- Deckrückkehr: Aufgezeichnete Ausstiege benötigen kein optionales freies Bordnetz. + Auch Kraken und Zeppeline können eine unterbrochene Rampenposition sicher zum + aufgezeichneten Stand korrigieren. Weiterhin nur bei stehendem Transport, + echter Passagierbindung und genügend verbleibender Haltezeit. +- Transportzustand: Ein Fehler bei noch angebundenem Passagier behält die Fahrt + und deren Ziel bei, statt sie pro Tick als neue Fahrt zu übernehmen. Kurze + Wiederholungsverzögerung; sichere Ausstiegsversuche am nächsten passenden Halt. + Die Ausstiegsreserve berücksichtigt auch die aktuelle Steuerungstaktung. +- Verlust beim Ein-/Aussteigen: Der erwartete Passagierzustand wird aus dem + tatsächlichen Fortschritt der aufgezeichneten Übergangspunkte abgeleitet. + Ein beabsichtigtes Erreichen des Landpunkts löst keine Bergung aus; tatsächlicher + Verlust kann die bestehende Rettung zum aufgezeichneten Abfahrtsanleger nutzen. +- Flugstart: Ein unterstützter Bodenstand in einem Gebäude führt zunächst zur + Suche nach einem erreichbaren offenen Abflugpunkt. Bis 200 Yards Suchradius, + vollständiger unverdächtiger MMAP-Bodenweg bis 600 Yards, native Außenbereichs- + und Kollisionsprüfung sowie Gebietskatalog. Erst dort native Flugmount-Auswahl + und Cast. Reitfertigkeit und regionale Flugberechtigungen bleiben erforderlich. +- Landung: Mehrere Ringe bis 180 Yards, vollständiger MMAP-Anschluss bis 600 Yards + zum Ziel. Ein Höhenunterschied allein schließt einen sicheren Bodenanschluss + nicht aus; Wasser-, Kollisions-, Gebiets- und native Pfadprüfungen bleiben. +- Kartengrenzen bleiben echte Portal-/Schiffs-/Zeppelinabschnitte. Flug ersetzt + weder einen Kartenübergang noch eine native Zugangsvoraussetzung. + +## Temporäre Diagnose + +`ClientlessTravelTelemetry.h` enthält den ausdrücklichen Vermerk +`TEMPORARY_ADVENTURE_TRAVEL_TELEMETRY` / vor Entwicklungsabschluss entfernen. + +`TEMP_TRAVEL` protokolliert ausschließlich aktive Instanz-/Raidreisen: Reise-ID, +Bot, Gruppe, Anführer, Ziel, Route/Abschnitte, Transportphasen, Portalankunft, +Flugzustand und Berechtigungen, Start-/Landekandidaten und Ablehnungszähler, +Fortschritt sowie Eingang und das getrennte Ergebnis der nativen Zulassung. +Fortschritt höchstens alle 20 Sekunden, gleiche Ereignisdetails innerhalb einer +Minute unterdrückt, insgesamt höchstens 40 Zeilen je Bot/Minute. Auch wechselnde +Fallback-Ziele setzen dieses Minutenbudget nicht zurück. Offline oder anderer +Intent entfernt die Erfassung. Das Verhalten hängt nicht von der Diagnose ab. + +Vor Entwicklungsabschluss diese Instrumentierung und ihre Aufrufstellen entfernen. + +## Prüfung + +109 Clientless-Testprogramme erfolgreich (vollständiger Lauf), einschließlich +neuer produktiver Methoden-Harnesses für gildenlose Gruppen, Portalbedingungen, +alternative Landeflächen und die begrenzte Telemetrie. Transport-/Abflugtests +wurden um unterbrochene Fahrten, Schiffe ohne Bordnetz, Verluste beim Ein- und +Aussteigen und die Bodenannäherung aus einem Gebäude erweitert. + +Die Harnesses verwenden deterministische Welt-/Terrain-Doubles. Sie bestätigen +Entscheidungen und Zustandsübergänge, ersetzen aber keine Prüfung mit echten +VMAP/MMAP-Dateien und fahrenden Schiffen im laufenden Spiel. Durchgängige Reisen +zu allen Instanzzielen und das Verhalten der Gruppenmitglieder müssen nach der +später freigegebenen Installation mit der neuen Telemetrie beobachtet werden. + +Build: bestehende Debug/Ninja-Konfiguration und Installationspräfix unverändert. +Erster `-j10`-Lauf durch getöteten Compilerprozess unter Speicherlast abgebrochen; +Wiederholung ebenfalls mit `-j10`. Abschließender `-j10`-Build erfolgreich (exit 0), inklusive der letzten +Bodenstützprüfung; Log `/tmp/vcore-travel-fixes-build-final.log`. +Rohlogs: `/tmp/vcore-travel-fixes-build*.log`, Testlogs und Ergebnisliste unter +`/tmp/vcore-travel-fixes-tests/`. Keine Gameplay-Abnahme behauptet. + +Der vorherige Laufzeitabsturz hat keinen erhaltenen vollständigen Stacktrace; +diese Änderungen sind kein Nachweis, dass dessen Ursache behoben ist. diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 5bd0856e00..ca65bf2ac5 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,241 @@ +# COMPLETED Oct30: safe builds, city inheritance, native POI map/height fix + +Authorized branch feature/clientless-player-core, expected uncommitted work +includes previous travel fixes and this task. No push or commit requested. +User ALLOWS restart/install again. Permanent new rule in AGENTS.md: before EVERY +build pause loader, request safe logout, let native combat/transport/flight finish, +verify zero CPC sessions; builds ALWAYS -j10. New admin command: +`clientless logout safe all` (or individual guid), `clientless logout status`, +`clientless logout resume`. Native world-thread safety predicate, no timeout force. +Pause loader first; resume clears collection, restore loader setting after work. + +Both new -j10 builds PASSED with all bots OFFLINE and no OOM. Final installed +binary includes POI raised-platform height fallback. Full113 tests passed, final +POI height regression also passed. git diff --check clean. Build logs +/tmp/vcore-city-safe-logout-build.log and /tmp/vcore-city-height-build.log. + +Final server446178 in tmuxvcore-worldserver; console /tmp/vcore-city-height-start.log. +Fresh ready10sec, ports8085/8087 verified. Final startup proof saved separately +/tmp/vcore-city-fix-state/fresh-startup-evidence.txt because reloadconfig truncates +Server.log. No pending build/install/restart. Prior prebuilt transport fixes were +installed first to release old stranded ship passengers;92/118 Arrived passenger0 +at11:54:06. Bootstrap orphan combat flags36/77 were separately cleared with native +combatstop after stationary old-state diagnosis; NOT part of future auto logout. + +Pibwin88 lvl18 root cause: guardPOI140 DalaranKrasusLanding was guessed on map1 +Darkshore based on closestXY across continents. New AdventurePoiPolicy.h uses +recursive native gossip menus -> spawned NPC map bindings; cycles/ambiguity +closed. Height from native NPC neighborhood, +35 retry only if first floor hit +far>25yd below/reference (floating Dalaran); retain close normal floor. Scoped +repair protects source/name/position/geography and manual edits. Original POI +source table and recorded routes untouched. Native repairimport final437POIs, +166invalid skipped,2916active locations. poi_guard140 now map571 X5831 Y508.617 +Z657.7 area4564 zone4395 Dalaran. Live eligibility88 adventure140:own0 invitation0. +Pibwin's preserved12:00Adventure block repaired to poi_tele651(Bloodmyst low-level +Alliance starting region, map530 NOT Outland). No manual test schedule override. + +City-parent migration2026_10_30_00_adventure_city_parents.sql APPLIED; exporter +updated likewise. Dalaran4395->CrystalSong2817,Shattrath3703->Terokkar3519 +eight +racial capitals->home regions. Catalog only; operator nonzero parents preserved, +no native DBC/player hierarchy changes. Effective native query at taxi310: +minLevel77/max85/preferFlight1,Arthoran60 denied. GroupPlan validates actualnextgoal +before advertising; CanInvite validates leader +member. Existing .cheat explore1 +fixed selected-player target, explore0 clears fields. Tell user: clear selection +then `.cheat explore 1` to reveal own map. Native command fixture passed; no human +exploration fields were altered by us. + +Live safe-batch test26+88 passed: pending2/sessions2/flight1 first; native taxi2221 +completed for26 BEFORE CLIENTLESS_SAFE_LOGOUT26, then88 motion complete+logout. +Final native pending0 sessions0. Called logoutresume, restored original config +Loader.Enabled1, reloadconfig/populationreload. Targets UNCHANGED50persistent+ +5temporary PERFACTION. Loader schema1,busy0,error empty, gradually loading110; +FINAL native population status: ALL110 ONLINE, pools50/5/50/5, pending0 in +every pool, manager110, schema1,busy0,error empty. No new safe-logouts after +resume. Bot operation fully restored, final process446178 alive,branch correct. + +Snapshots /tmp/vcore-city-fix-state/: nodes/edges/stops before vsafter EXACTMATCH; +all17 prior manual semantic records unchanged.236 imported rows repaired/disabled, +12 semantic rows added. No production/remote auth changes. Detailed German report +in doc/clientless/2026-10-30-city-catalog-build-policy.md. Earlier historical WIP +and checkpoints below are superseded. Need only final current pool/process/port +check and concise German final response now. Do NOT restart or build again. + +# UPDATE current: final POI height correction built, install/restart pending + +Builds /tmp/vcore-city-safe-logout-build.log and /tmp/vcore-city-height-build.log +bothPASSED -j10 withallbotsOFFLINE. Full113/113testsPASS; finalnewPoiHeightfixture +passes. NewcitymigrationAPPLIEDandbackupunder/tmp/vcore-city-fix-state/. +InstallednewbatchSafeLogoutcode(firstcurrenttaskbuild),server445223ready11sec, +ports8085/8087,console/tmp/vcore-city-safe-logout-start.log. Ranrepairimport: +421nativePOIs,182skipped,2913active. poi_guard140map571BUTfirstheight184wasunder +floatingcity. AddedAdventurePoiPolicy::Height referenceNPC+3 thenretry+35ONLYif +firstfloorfar>25ydfromNPC,choosecloserfloor (keepsnormalnearbyfloor). Addedscoped +firstNative height correctioncandidate torepaircurrentintermediaterecord; doNOT +manuallypatchcoordinates. Nativeanchor651,nearestservice639,realplatform658. +FinalheightbuildPASSED;server445223shutdown10requestednow. Waitexit,cmakeinstall, +startnewfreshconsolethenready/process/ports. LoaderSTILL0andallCPC0confirmedvia +native`clientless logout safe all` +`logout status`pending0sessions0. NativeAllflag +setoncurrentprocess,willresetafterrestart. NormalhumanTestonlineearliernotCPC. + +Nextrequired: finalrestart->repairimportagain->reloadcatalogs->verifypoi140actual +platform/cityandlevel88denied;checkmanualroutes/semanticmanualRows snapshots; +loginPibwin88+Arthoran26 withloader0;eligibility88adventurepoi_guard140mustown0, +planrepaired;nativebatchsafelogout2bots->pending0sessions0;logoutresume;restore +loaderconfigpreviousvalue1 from/tmp/vcore-build-drain-loader-setting.json then +reloadconfig/populationreload;verifynormalgradual110population. Preservefreshready +beforeconfigreloadtruncatesServer.log. Updatedocs/checkpointfinalreply.No commitasked. + +# ACTIVE NEW TASK Oct30: city/POI eligibility, safe pre-build logout, map reveal + +User now ALLOWS INSTALL/RESTART. New permanent rule: before EVERY build pause +loader, finish combat/flights/transports safely, log out ALL CPC bots, verify0 +native sessions. Always j10. Rule appended AGENTS.md. Previous fixes' prebuilt +binary installed after clean shutdown of417346; fresh437976 started ready12sec, +ports8085/8087. Console /tmp/vcore-prebuilt-travel-start.log, native ready in +freshServer.log. Loader currentlyDISABLED (config Enabled0), previous value saved +/tmp/vcore-build-drain-loader-setting.json (value1). Restore only after final +install/restart/native verification. Original110botlist /tmp/vcore-build-drain-bots.json. +19remainingresumedmanuallyafterprebuiltinstall so oldships canfinallyexit;92/118 +ARRIVED passenger0 at11:54:06. Most thenstopped/logout; currentremaining36/77 +were ground/healthy/stationary butUnsafe stuck. Nativecombatstop requested to +clear suspectedorphancombatstate, inspectresultsbeforelogout/build. Never +force transportdetach or airborne logout. PlayerTest/Allitesterarehumans, leavealone. + +RootPibwin88level18: poi_guard_140 namedDalaranKrasusLanding wronglyIMPORTEDon +map1Darkshore XY5831,508Z3.55 area2078zone148! ThusactualAreaCatalogchecks saw +lowlevelzone, notDalaran. Realnativegossipbinding140uniquemap571,39guardanchors. +NewAdventurePoiPolicy.h resolvesrecursivegossipMenu->POI via spawnedNPCmaps, +rejectsambiguous/unbound, choosesnativeheight. AdventureLocationMgr.cpp importer +usesit; repairimport matchesoldgeneratedposition+metadata, preservesmanualedits, +updatesmap/Z/geography anddisablesunchangedunboundlegacyPOIs. Mustrunrepairimport +onTEST afternewbuildinstalled. DoNOTchangeoriginalpoints_of_interest oruserRoutes. + +Newworldmigration2026_10_30_00_adventure_city_parents.sql +export_area_catalog.py +curatededitablecatalogcityparents10capitals:4395->2817(DalaranCrystalSong), +3703->3519(ShattrathTerokkar),othershomezones. Parent0only/samemap, preserveoperator +parentassignments. ExistingResolve inheritsmin andflight;testsenhanced. SQLnotyet +applied. GroupPlan disablesrecruitmentforinvalidactualgoal;CanInvitechecksboth +leader/membernativeArea position. Existing.cheat explore1commandbugfix selected +playerfields(andexplore0clearing), no newlyinventedcommandneeded. + +NewadminbatchsafeLogout namespaceinCustom/viano_bot_commands.cpp: +clientless logout safe all|, logout status, logout resume. +WorldScriptUpdate requestsBehavior stoponce, waitsnativealive/inworld/noncombat/ +nontransport/nontaxi/nonflying/nonfalling/nonteleport/noncast/nonspline then +StopPlayer atomicallyonworldthread. No timeoutforce. PauseLoaderbeforecommand. +All modecontinuescatchingnewCPCsessions untilresume/restart;futurebuildworkflow +mustusenativepending0+sessions0. Bootstrapcurrentprebuiltlackscommand,uses +Behaviorstop+statusEnabled0thenlogout;twooldshipsfixedviaprebuiltdeployment. + +Tests113expected(109previous+POI+safeLogout+cheatExplore+cityCatalog), fullrunbefore +lastfixturefixhad2mockerrors(instance_autoresume/instance_loader missingnewSafe +Update double), nowbothfixedandtargetedpass. NewPOI/area/city/explore/safeallpass, +groupPlan/departure/groupspass. Fullrerunandbuild-j10stillREQUIRED. No newbuildyet +sincebotsneedfinishlogout. SourcechangesuncommittedincludeprevioustaskexpectedWIP. + +Next:confirm0CPCbots ->buildj10(noOOMnowbotsout),full113tests;resolvecompilation; +applycitymigration;cleanstop/install/restartloader0;nativeReady/process/ports; +runrepairimport,snapshotmanualroutesnotchanged/Pibwinpoi140now571actualDalaran; +verifyPibwin18deniedandplanrepaired;testnativebatchsafelogoutwith2bots;logoutresume, +restoreloader1reloadconfig/populationreload,verifybotsgraduallynormal. Updatedocs, +reportcommand.cheat explore1plusvalidation. No push/commitasked. + +# CURRENT Oct30: travel fixes implemented, DEPLOYMENT HELD BY USER + +User is recording routes: NO INSTALL and NO RESTART until expressly released. +Source-only task implemented. Worktree includes expected source/test/doc changes; +no commit/push, DB writes or live route edits in this task. Authorized branch +feature/clientless-player-core. Keep all builds -j10. + +Implemented guildless native group restoration; conservative native portal actor +prerequisites BEFORE geographic permission cache and before real portal use; +recorded deck-stand fallback for ships without optional deck meshes; interrupted +passenger transitions retain ride/3-second retry delay; lost-passenger detection +in Boarding/Leaving; exit reserve accounts for update cadence. Flight walks from +indoor/layered starts to verified open MMAP-reachable departure (200yd search, +600yd ground cap), landing alternatives180yd/600yd native MMAP approach, no fixed +5yd height rejection. Native permission/collision/area/raid admission preserved. +Temporary scoped TEMP_TRAVEL instrumentation is header-only, bounded40/bot/min, +20sec progress, deduplicated, includes REMOVE BEFORE DEVELOPMENT COMPLETION. +Details: doc/clientless/2026-10-30-travel-fixes.md. + +109/109 clientless test programs passed. Added production-method harnesses for +guildless restore, portal prerequisites, landing alternatives and telemetry; +expanded deck/transition/indoor-departure tests. Final NaN-foot-support regression +passed separately after full suite. git diff --check clean. + +Final build PASSED (exit0), existing Debug/Ninja configuration with -j10. +Attempts1–3 had compiler OOM kills; attempt4 and final incremental build passed. +Final exact source guard included: /tmp/vcore-travel-fixes-build-final.log, +4/4 linking worldserver. No CMake config changes. Tests109/109 plus final targeted +flight departure/NaN-foot-support regression passed. Raw earlier build logs +/tmp/vcore-travel-fixes-build{,-2,-3,-4}.log; test results +/tmp/vcore-travel-fixes-tests/results.json. DO NOT INSTALL / RESTART. + +Server remains SAME installed binary/process417346 in tmuxvcore-worldserver, +ports8085/8087 checked. No lifecycle action this task. User performs route recording. +Live behavior and end-to-end instance journeys remain unverified until later +authorized deployment/observation. Prior runtime crash root remains unproven. + +The entries below are historical checkpoints, not current task instructions. + +# Latest: testserver recovered after10:36 Oct30 runtime crash + +User asked whether testserver offline. Verified no worldserver/ports; kernel +journal records SIGSEGV of389322 at2026-10-30 10:36:35 inlibstdc++. No kernelOOM +observed for this incident; exact cause not investigated. This happened AFTER +02:20–04:00 observationwindow, not during thattest. Old console tail preserved +/tmp/vcore-flight-regions-group-test-start-20261030.log. No source/build/install. +Restarted SAME installedbinary in freshdedicatedtmuxvcore-worldserver, PID417346. +Freshready11s,processalive,ports8085/8087listening. New console +/tmp/vcore-recovery-20261030-1036.log. Deferredfix/discussioninstructions below +remainunchanged; no nextfix implementation started. Preserveanycoredump forlater +analysis before subsequent crash overwrites it. + +# Latest user request: record next-fix scope; discussion only, no implementation yet + +User explicitly adds ship boarding/disembarking gaps to the NEXT fix. More +importantly, investigate end-to-end destination reachability of flying instance/ +raid bots and groups, not whether they know flight. Existing transitions should +connect Kalimdor/EasternKingdoms/Outland/Northrend for flyers; missing low-level +non-flying Horde Kalimdor↔EK links remain a known exception. Roofs or failure of +one landing candidate must lead to a reachable ground exit/free takeoff area or +alternative safe landing+validated final ground approach, not abandonment or a +replanning loop. Preserve safety/native access checks. + +TEMPORARY TELEMETRY expressly authorized for NEXT fix: track flying bots' whole +instance journeys, leaders/followers, routes/map transitions/modes, exact flight +state rejection, takeoff/landing candidates and ground approaches, progress, +fallbacks and entry-vs-admission. Instrumentation MUST carry a clear code comment +that it is TEMPORARY and must be REMOVED BEFORE DEVELOPMENT COMPLETION. Targeted, +bounded/deduplicated logging; no per-tick spam. Not implemented in this turn. +Detailed scope recorded in doc/clientless/group-observation-followups.md. + +Previously deferred items1(guildless group restore) and4(portal prerequisites) +remain recorded. Item5(native instance admission restriction) is accepted as +correct, not something to bypass. No source/server changes during discussion. +Existing uncommitted resume/followup-doc changes belong to this discussion. + +# Latest user steering: discussion first; defer fixes1+4, accept restriction5 + +User asks to note reportpoints1(guildlessgrouprestore) and4(portalprerequisites) +for laterfix, not implement now. Reportpoint5(raid admissiondenial) is initially +CORRECT intended instance restriction; do not bypass/remove it. Recorded in +`doc/clientless/group-observation-followups.md`. Current turn is investigation/ +explanation of shipstandfallback, fallenpassengerrescue, and flightgroupfailures. +No source/server changes requested atthisstage. + +Codeconfirmed:DeckMgrGet supports onlyBravery176310 andtram176080–86. Kraken190536 +returnsnull andUpdate returns true BEFORE ReturnToRecordedStand. Thus its stopped +standteleportfallback neverruns, recordedexit startsfrommid-ramp andfails>8yd. +Tholgarremainedpassenger1/transguid17,notactuallydetached. RecoverPassenger entry +is inRiding whenGetTransport becomesnull; no universal Boarding/Leaving lossguard. +ResumePassenger re-adoptsFailedattachedride andresetsprogress, causingretryloop. +Flightrequiredskillsarepresent; failurepoints include map transitions, indoor +starts, rejecteddepartureheights, safe landing/shortapproach. TKsuccess concrete +NAVFLIGHT_LANDED guid74/53 27segments736.81yd error0 andnativeinstanceentries. +Do notpresent allfailedflightstarts asprovenindoors; generalguardmessageambiguous. + # COMPLETED Oct30 04:00CET: flightregions committed + group observation Observation endedautomatically04:00:00.126CET; monitorPID387861/execcell67325 exited0. diff --git a/doc/clientless/group-observation-followups.md b/doc/clientless/group-observation-followups.md new file mode 100644 index 0000000000..4cc5127777 --- /dev/null +++ b/doc/clientless/group-observation-followups.md @@ -0,0 +1,123 @@ +# Umsetzung begonnen am 30.10.2026 + +Die unten dokumentierten Besprechungspunkte wurden anschließend zur Umsetzung +freigegeben. Quellcodekorrekturen und Regressionstests sind in Arbeit. +**Installation und Neustart bleiben ausdrücklich gesperrt**, während der Nutzer +Routen aufzeichnet. Details und abschließender Prüfstand folgen in +`2026-10-30-travel-fixes.md`. Native Raid-Zulassung bleibt unverändert. + +# Nachverfolgung nach dem Gruppentest vom 30.10.2026 + +Nutzerentscheidung nach dem Bericht: + +- **Berichtspunkt 1, Gruppenwiederherstellung ohne Gilde:** für einen späteren + Fix vormerken. Nicht während der anschließenden Besprechung implementieren. +- **Berichtspunkt 4, Portalvoraussetzungen in der Routenplanung:** ebenfalls + für einen späteren Fix vormerken. Native Zugangsbeschränkungen beibehalten. +- **Berichtspunkt 5, abgewiesener Raidbeitritt:** zunächst als korrekt greifende + Instanzbeitrittsbeschränkung behandeln, nicht als zu beseitigenden Fehler. + +## Erklärung der Transportlücke + +Der bestehende Rücktransfer zum aufgezeichneten Deckstand liegt hinter der +Deckmodell-Auswahl in `ClientlessTransportDeckMgr::Update`. Modelle werden nur +für Bravery 176310 und Tiefenbahn 176080–176086 geladen. Kraken 190536 fällt vor +Rückwegprüfung und `ReturnToRecordedStand` mit `return true` heraus. Dadurch +startet der Ausstieg direkt, auch wenn der Passagier an einer Zwischenposition +steht. Der generelle Fallback ist somit zu eng an die freie Bordnavigation +gekoppelt und greift auf Kraken nicht. Dasselbe Modell-Gate ist für andere +Transporttypen ohne Decknetz relevant. + +Tholgar blieb laut Log mit `passenger=1` und Transport-GUID 17 angebunden. Er war +nicht nachweislich von Bord gefallen. Der Verlust-Fallback setzt erst beim +fehlenden Transport während der Phase Riding `Recovering=true`. Er ist damit +für diesen Fall nicht zuständig. Ein echter Verlust während Boarding/Leaving +ist durch diesen Einstieg ebenfalls nicht allgemein abgedeckt; erwartetes +Absteigen und versehentliches Fallen müssten dort ausdrücklich unterschieden +werden. Nach Failed gibt es derzeit zusätzlich eine Wiederaufnahme des noch +angebundenen Passagiers ohne Fortschrittsübernahme des begonnenen Ausstiegs. + +## Erklärung der Flugprobleme + +Flugfähigkeit ersetzt keine Kartenübergänge; dafür werden weiterhin Schiffe, +Zeppeline und Portale benötigt. Zusätzlich verlangt der echte Flugstart unter +anderem einen lebenden, kampffreien, nicht fahrenden, nicht fallenden Bot im +Freien. Das Planungsmodell kann einen Flug vorher bereits als günstig einstufen. +Logs zeigen Fehlstarts an Gasthäusern, abgewiesene Abflughöhen und fehlende sichere +Landepunkte. Die allgemeine Fehlermeldung unterscheidet nicht alle Startzustände; +der exakte Zustand ist daher nicht für jede Ablehnung nachgewiesen. + +Für Festung der Stürme sind erfolgreiche letzte Flugabschnitte konkret belegt: +unter anderem Nimbin 74 und Noranda 53, je 27 Segmente über 736,81 Yards, +`FLIGHT_LANDED ... error=0.00`, anschließend bestätigter Instanzeintritt. +Das belegt einen funktionierenden konkreten Weg, nicht die Zuverlässigkeit +sämtlicher Start-, Übergangs- und Landekombinationen anderer Ziele. + +## Für den nächsten Fix ausdrücklich vorgemerkt + +### Ein- und Aussteigen von Schiffen + +- Den sicheren Rücktransfer zum aufgezeichneten Deckstand unabhängig davon + ermöglichen, ob für das Fahrzeug bereits ein freies Decknetz vorhanden ist. + Die bestehenden Bedingungen für einen stehenden Transport und eine sichere + Passagierbindung beibehalten. +- Einen unterbrochenen Ausstieg beim nächsten geeigneten Halt sinnvoll fortsetzen + beziehungsweise zuerst zum normalen Stand zurückkehren. Keine dauernde + Wiederaufnahme vom falschen Startpunkt mit derselben Acht-Yards-Ablehnung. +- Verlorene Passagiere auch während Boarding und Leaving erfassen. Einen echten + Sturz vom vorgesehenen Absteigen unterscheiden und bei tatsächlichem Verlust + die bereits vereinbarte Rettung zum aufgezeichneten Anleger einsetzen. + +### Durchgängige Instanzanreise flugfähiger Bots und Gruppen + +Die Nutzerfrage betrifft die tatsächliche Erreichbarkeit des Ziels, nicht das +Vorhandensein der Flugfähigkeit. Für flugfähige Bots müssen Übergänge zwischen +Kalimdor, den Östlichen Königreichen, Scherbenwelt und Nordend über die vorhandenen +Verbindungen nutzbar sein. Diese Erreichbarkeit ist im nächsten Fix konkret zu +prüfen. Bei nicht flugfähigen Horde-Bots sind fehlende Verbindungen zwischen +Kalimdor und den Östlichen Königreichen weiterhin eine bekannte Einschränkung. + +Überdachungen und ein ungeeigneter erster Landeversuch sind keine ausreichende +Begründung, die Reise aufzugeben oder dauerhaft neu zu planen: + +- Aus einem überdachten Startbereich zunächst auf gültigem Bodenweg eine + erreichbare freie Abflugfläche suchen und ansteuern, anschließend abheben. +- Am Ziel alternative sichere Landeflächen mit validiertem Bodenanschluss zum + Instanzeingang suchen. Ein einzelner abgewiesener Punkt darf nicht den gesamten + Reiseweg unbrauchbar machen. +- Die Sicherheitsprüfungen für Geometrie, Höhen, Kollisionen, Gebiete und native + Voraussetzungen erhalten. Die Suche und Verkettung der Reiseabschnitte + verbessern, statt Prüfungen abzuschalten oder normale Reisen durch Teleports + zu ersetzen. +- Gruppenanführer und Mitglieder getrennt erfassen: gemeinsame Route, + Kartenwechsel, Anschlussverlust, Zielwechsel und Fortschritt müssen erklärbar + sein. Die tatsächliche Ankunft am Eingang getrennt von einer anschließend + korrekt abgewiesenen Instanzzulassung beurteilen; Berichtspunkt 5 bleibt gültig. + +### Temporäre Telemetrie für die Untersuchung + +Der Nutzer erlaubt für diesen nächsten Fix ausdrücklich temporäre Telemetrie +im Code. Sie ist jetzt nur vorgemerkt, noch nicht implementiert. Jede entsprechende +Instrumentierung erhält einen eindeutigen Codevermerk, zum Beispiel: + +```cpp +// TEMPORARY_ADVENTURE_TRAVEL_TELEMETRY: +// Remove before development completion / vor Entwicklungsabschluss entfernen. +``` + +Erfassen sollen wir mindestens: + +- Bot-, Gruppen- und Anführer-ID sowie eine zusammenhängende Reise-/Abschnitt-ID; +- geplantes Instanzziel, tatsächlich verfolgtes Ziel und Gründe für Zielwechsel; +- Graphroute, Kartenübergänge, gewähltes Reisemittel und konkrete Ablehnungsgründe; +- Zustand bei Flugstart: drinnen/draußen, Kampf, Wasser, Fallen, Taxi, Transport, + Teleport, Reitfertigkeit, gelernte Berechtigungen und gewähltes Reittier; +- gesuchte Abflug- und Landekandidaten, ihre Bodenanschlüsse und Ablehnungsgründe; +- Fortschritt, Stillstand, Wiederholungen, Zeit pro Abschnitt und Fallbacks; +- erreichten Eingang und das davon getrennte Ergebnis der nativen Zulassung. + +Die Erfassung gezielt auf die relevanten Instanz-/Raidanreisen begrenzen und +wiederholte identische Meldungen zusammenfassen. Sie soll Ursachen nachvollziehbar +machen, ohne erneut pro Tick tausende gleiche Fehlermeldungen zu erzeugen. +Vor Abschluss der Entwicklung die temporäre Instrumentierung entfernen und dies +als eigenen Abschlussprüfpunkt behandeln. diff --git a/sql/custom/world/2026_10_30_00_adventure_city_parents.sql b/sql/custom/world/2026_10_30_00_adventure_city_parents.sql new file mode 100644 index 0000000000..5998411d0e --- /dev/null +++ b/sql/custom/world/2026_10_30_00_adventure_city_parents.sql @@ -0,0 +1,11 @@ +-- Semantic containment for cities that are independent roots in AreaTable.dbc. +-- Catalog-only: native area hierarchy and player permissions are unchanged. +-- Preserve existing operator parent assignments; inherited minima cannot weaken. +UPDATE vcore_adventure_area_catalog city +JOIN vcore_adventure_area_catalog region ON region.area_id=CASE city.area_id + WHEN 1519 THEN 12 WHEN 1537 THEN 1 WHEN 1657 THEN 141 WHEN 1637 THEN 14 + WHEN 1638 THEN 215 WHEN 1497 THEN 85 WHEN 3487 THEN 3430 WHEN 3557 THEN 3524 + WHEN 3703 THEN 3519 WHEN 4395 THEN 2817 END +SET city.parent_area_id=region.area_id +WHERE city.parent_area_id=0 AND city.map_id=region.map_id +AND city.area_id IN(1519,1537,1657,1637,1638,1497,3487,3557,3703,4395); diff --git a/src/server/game/Conditions/ConditionMgr.cpp b/src/server/game/Conditions/ConditionMgr.cpp index 0d812781d4..54bb463ed7 100644 --- a/src/server/game/Conditions/ConditionMgr.cpp +++ b/src/server/game/Conditions/ConditionMgr.cpp @@ -966,6 +966,54 @@ bool ConditionMgr::CanHaveConditionType(ConditionSourceType sourceType, Conditio return true; } +bool ConditionMgr::IsPlayerMeetingPrerequisiteConditions(ConditionSourceType sourceType, uint32 entry, + Player const* player, uint8 playerTargetMask) const +{ + if(!player || sourceType<=CONDITION_SOURCE_TYPE_NONE || sourceType>=CONDITION_SOURCE_TYPE_MAX)return false; + auto list=ConditionStore[sourceType].find(entry); + if(list==ConditionStore[sourceType].end())return true; + ConditionSourceInfo info(const_cast(player),const_cast(player),const_cast(player)); + // Three states preserve OR branches containing requirements that cannot yet + // be evaluated at a remote waypoint. Only a proven failure excludes a route. + auto evaluate=[&](auto&& self,ConditionContainer const& conditions,unsigned depth)->int + { + if(depth>32)return -1; + std::map groups; + for(auto condition:conditions) + { + if(!condition->isLoaded())continue; + auto inserted=groups.emplace(condition->ElseGroup,1);auto& group=inserted.first->second; + if(group==0)continue; + int result=-1; + if(condition->ReferenceId) + { + auto ref=ConditionReferenceStore.find(condition->ReferenceId); + if(ref!=ConditionReferenceStore.end())result=self(self,ref->second,depth+1); + } + else if(!condition->ScriptId && condition->ConditionTarget<3 && (playerTargetMask & (1u<ConditionTarget))) + { + switch(condition->ConditionType) + { + case CONDITION_ITEM: case CONDITION_ITEM_EQUIPPED: + case CONDITION_REPUTATION_RANK: case CONDITION_TEAM: case CONDITION_SKILL: + case CONDITION_QUESTREWARDED: case CONDITION_QUESTTAKEN: case CONDITION_QUEST_NONE: + case CONDITION_CLASS: case CONDITION_RACE: case CONDITION_ACHIEVEMENT: + case CONDITION_TITLE: case CONDITION_GENDER: case CONDITION_SPELL: + case CONDITION_LEVEL: case CONDITION_QUEST_COMPLETE: + case CONDITION_DAILY_QUEST_DONE: case CONDITION_QUESTSTATE: + result=condition->Meets(info)?1:0;break; + default:break; + } + } + if(result==0)group=0;else if(result<0)group=-1; + } + bool unknown=false; + for(auto const& group:groups){if(group.second==1)return 1;if(group.second<0)unknown=true;} + return groups.empty()?1:unknown?-1:0; + }; + return evaluate(evaluate,list->second,0)!=0; +} + bool ConditionMgr::IsObjectMeetingNotGroupedConditions(ConditionSourceType sourceType, uint32 entry, ConditionSourceInfo& sourceInfo) const { if (sourceType > CONDITION_SOURCE_TYPE_NONE && sourceType < CONDITION_SOURCE_TYPE_MAX) diff --git a/src/server/game/Conditions/ConditionMgr.h b/src/server/game/Conditions/ConditionMgr.h index a42c1749f5..84d2659f85 100644 --- a/src/server/game/Conditions/ConditionMgr.h +++ b/src/server/game/Conditions/ConditionMgr.h @@ -262,6 +262,10 @@ class TC_GAME_API ConditionMgr bool IsObjectMeetingNotGroupedConditions(ConditionSourceType sourceType, uint32 entry, WorldObject* target0, WorldObject* target1 = nullptr, WorldObject* target2 = nullptr) const; bool IsMapMeetingNotGroupedConditions(ConditionSourceType sourceType, uint32 entry, Map const* map) const; bool HasConditionsForNotGroupedEntry(ConditionSourceType sourceType, uint32 entry) const; + // Conservative remote planning check. Evaluate only actor prerequisites; + // spatial/script/other-target requirements remain unknown until actual use. + bool IsPlayerMeetingPrerequisiteConditions(ConditionSourceType sourceType, uint32 entry, + Player const* player, uint8 playerTargetMask) const; bool IsObjectMeetingSpellClickConditions(uint32 creatureId, uint32 spellId, WorldObject* clicker, WorldObject* target) const; ConditionContainer const* GetConditionsForSpellClickEvent(uint32 creatureId, uint32 spellId) const; bool IsObjectMeetingVehicleSpellConditions(uint32 creatureId, uint32 spellId, Player* player, Unit* vehicle) const; diff --git a/src/server/game/Groups/Group.cpp b/src/server/game/Groups/Group.cpp index 2dc9d2ea84..4f5d1a9f88 100644 --- a/src/server/game/Groups/Group.cpp +++ b/src/server/game/Groups/Group.cpp @@ -206,12 +206,11 @@ void Group::LoadGroupFromDB(Field* fields) void Group::LoadMemberFromDB(ObjectGuid::LowType guidLow, uint8 memberFlags, uint8 subgroup, uint8 roles) { - MemberSlot member; + MemberSlot member{}; member.guid = ObjectGuid(HighGuid::Player, guidLow); // skip non-existed member - if (!sCharacterCache->GetCharacterNameByGuid(member.guid, member.name) || - !sCharacterCache->GetPlayerGuildIdByGUID(member.guid, member.guildId)) + if (!sCharacterCache->GetCharacterNameByGuid(member.guid, member.name)) { CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_DEL_GROUP_MEMBER); stmt->setUInt32(0, guidLow); @@ -219,6 +218,10 @@ void Group::LoadMemberFromDB(ObjectGuid::LowType guidLow, uint8 memberFlags, uin return; } + // A cached character without a guild is still a valid group member. + // The cache query deliberately returns false for GuildId 0. + sCharacterCache->GetPlayerGuildIdByGUID(member.guid, member.guildId); + member.group = subgroup; member.flags = memberFlags; member.roles = roles; diff --git a/src/server/game/World/AdventureAreaMgr.cpp b/src/server/game/World/AdventureAreaMgr.cpp index a66f363bef..464747abc5 100644 --- a/src/server/game/World/AdventureAreaMgr.cpp +++ b/src/server/game/World/AdventureAreaMgr.cpp @@ -207,6 +207,9 @@ bool AdventureAreaMgr::AllowsEdge(Player const* player,ClientlessTravelEdge cons if(!Applies(player))return true; auto from=sClientlessTravelGraphMgr->FindNode(edge.FromNode),to=sClientlessTravelGraphMgr->FindNode(edge.ToNode); if(!from || !to || !AllowsNode(player,*from) || !AllowsNode(player,*to))return false; + // Items/quests/spells may change without a catalog reload; never cache + // actor prerequisites as geography or test them only after reaching a portal. + if(edge.Type=="portal_use" && !sClientlessTravelGraphMgr->PortalPrerequisites(player,edge.DatabaseId))return false; std::string key=std::to_string(player->getLevel())+":"+std::to_string(player->GetTeam())+":"+std::to_string(edge.DatabaseId)+":"+edge.FromNode+":"+edge.ToNode; for(auto const& swap:player->GetPhaseShift().GetVisibleMapIds())key+=":"+std::to_string(swap.first); auto cached=_edgeAccess.find(key);if(cached!=_edgeAccess.end())return cached->second; diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index bf38ceec75..6c2fdae137 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -1,6 +1,7 @@ #include "ClientlessPositioning.h" #include "MotionMaster.h" #include "AdventureAreaMgr.h" +#include "ClientlessTravelTelemetry.h" #include "ClientlessTravelGraphMgr.h" #include "AdventureBehaviorMgr.h" #include "AdventureCombatMgr.h" @@ -225,6 +226,7 @@ void AdventureBehaviorMgr::RefreshDay(Player* player) void AdventureBehaviorMgr::Offline(Player * player) { + ClientlessTravelTelemetry::Clear(player); auto i = _records.find(player->GetGUID().GetCounter()); if (i != _records.end()) { @@ -457,6 +459,7 @@ void AdventureBehaviorMgr::Begin(Player * player, Record & r) r.Dirty = false; r.Intent = r.WantedIntent; r.Location = r.WantedLocation; + ClientlessTravelTelemetry::Begin(player,r.Intent,r.Location,r.Deadline); if (r.Intent.empty()) { r.Enabled = false; @@ -1415,6 +1418,7 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) auto entry = _records.find(guid); if (entry == _records.end() || ! entry->second.Enabled || ! player->IsInWorld()) return; auto & r = entry->second; + ClientlessTravelTelemetry::Tick(player); if(UpdateNavigationRescue(player,r))return; if(r.AreaRevision!=sAdventureAreaMgr->Revision()) { @@ -1874,6 +1878,7 @@ bool AdventureBehaviorMgr::ResolveInstance(Player* player, Record& r) if (validExit(id)) {exit=id;break;} } while(rows->NextRow()); } + ClientlessTravelTelemetry::Note(player,"instance-route","entry="+std::to_string(entry)+" map="+std::to_string(target->target_mapId)+" mode="+std::to_string(selected->InstanceDifficulty)); r.Instance.Active=true;r.Instance.Location=r.Location;r.Instance.Entry=entry;r.Instance.Exit=exit; r.Instance.Map=target->target_mapId;r.Instance.Difficulty=selected->InstanceDifficulty;r.Instance.BlockEnd=r.Deadline; r.Instance.LossPercent=std::uniform_int_distribution(0,50)(r.Random); @@ -1932,6 +1937,7 @@ bool AdventureBehaviorMgr::UpdateInstance(Player* player, Record& r, time_t now) { v.InstanceId=player->GetInstanceId();v.Stage=Stage::Inside; if (!SaveInstance(player,r)) {v.Stage=Stage::Entering;return true;} + ClientlessTravelTelemetry::Note(player,"instance-entered","instance="+std::to_string(v.InstanceId)); r.Detail="waiting just inside private instance entrance"; TC_LOG_INFO("server.adventurebot","INSTANCE_ENTERED guid=%u map=%u instance=%u returnAt=%llu mode=%u", player->GetGUID().GetCounter(),v.Map,v.InstanceId, @@ -1979,6 +1985,7 @@ bool AdventureBehaviorMgr::UpdateInstance(Player* player, Record& r, time_t now) if (player->GetMapId()!=v.Map || !player->GetInstanceId() || player->GetExactDist(entryTarget->target_X,entryTarget->target_Y,entryTarget->target_Z)>5) { + ClientlessTravelTelemetry::Note(player,"instance-denied","native admission denied at entry="+std::to_string(v.Entry)); TC_LOG_WARN("server.adventurebot","INSTANCE_ENTRY_DENIED guid=%u entry=%u",player->GetGUID().GetCounter(),v.Entry); v.Stage=Stage::Rest;SaveInstance(player,r);r.Detail="native instance entry denied; no visit started"; } @@ -2227,6 +2234,8 @@ AdventureGroupPlan AdventureBehaviorMgr::GroupPlan(Player* player) plan.Forming=plan.Enabled && plan.Intent=="groupformation"; auto activity=plan.Forming?plan.NextIntent:plan.Intent; plan.Capacity=AdventureGroupPolicy::Capacity(activity); + auto destination=sAdventureLocationMgr->Find(plan.Forming?plan.NextLocation:plan.Location); + if(!destination || !Allowed(player,*destination,activity))plan.Enabled=false; if(auto location=sAdventureLocationMgr->Find(plan.Forming?plan.NextLocation:plan.Location)) if(activity=="raid" || activity=="instance") {plan.Capacity=location->InstanceCapacity;plan.Mode=location->InstanceDifficulty;} diff --git a/src/server/game/World/AdventureGroupMgr.cpp b/src/server/game/World/AdventureGroupMgr.cpp index fbb22cf170..ed890743df 100644 --- a/src/server/game/World/AdventureGroupMgr.cpp +++ b/src/server/game/World/AdventureGroupMgr.cpp @@ -136,10 +136,11 @@ bool AdventureGroupMgr::CanInvite(Player* leader,Player* member) if(bot->GetSession() && bot->GetSession()->IsClientless() && !AdventureGroupPolicy::AcceptPlayerLevel(bot->getLevel(),member->getLevel()))return false; auto plan=sAdventureBehaviorMgr->GroupPlan(leader); auto location=sAdventureLocationMgr->Find(plan.Forming?plan.NextLocation:plan.Location); - if(!location || !sAdventureLocationMgr->LevelEligible(member,*location,Activity(plan),true) || + if(!plan.Enabled || !location || !sAdventureLocationMgr->LevelEligible(member,*location,Activity(plan),true) || !sAdventureLocationMgr->LevelEligible(leader,*location,Activity(plan),true))return false; Position goal;goal.Relocate(location->Point.X,location->Point.Y,location->Point.Z); - return sAdventureAreaMgr->AllowsPosition(member,location->Point.MapId,goal); + return sAdventureAreaMgr->AllowsPosition(leader,location->Point.MapId,goal) && + sAdventureAreaMgr->AllowsPosition(member,location->Point.MapId,goal); } void AdventureGroupMgr::CheckFullGroup(Player* p) { diff --git a/src/server/game/World/AdventureLocationMgr.cpp b/src/server/game/World/AdventureLocationMgr.cpp index f69c285f46..7b25655d0a 100644 --- a/src/server/game/World/AdventureLocationMgr.cpp +++ b/src/server/game/World/AdventureLocationMgr.cpp @@ -1,4 +1,5 @@ #include "AdventureAreaMgr.h" +#include "AdventurePoiPolicy.h" #include "ObjectMgr.h" #include "Player.h" #include "AdventureLocationMgr.h" @@ -183,8 +184,8 @@ bool AdventureLocationMgr::Import(ChatHandler * handler, bool repair) struct Seed { std::string Key, Name, Type, Source; - uint32 Id, Map; - float X, Y, Z, O; + uint32 Id=0, Map=0; + float X=0, Y=0, Z=0, O=0; uint8 Faction = 0; }; std::vector < Seed > seeds, tele; @@ -232,6 +233,21 @@ bool AdventureLocationMgr::Import(ChatHandler * handler, bool repair) if (! t) t = sTerrainMgr.LoadTerrain(map); return t ? t->GetAreaId(phase, map, x, y, z) : 0u; }; + // A POI has no map. Recover it through its real gossip menu graph and + // spawned NPCs, never by comparing XY coordinates across continents. + AdventurePoiPolicy::Menus menus; + if(auto rows=WorldDatabase.Query("SELECT MenuID,ActionMenuID,ActionPoiID FROM gossip_menu_option WHERE ActionMenuID>0 OR ActionPoiID>0"))do + { + auto f=rows->Fetch();menus[f[0].GetUInt32()].push_back({f[1].GetUInt32(),f[2].GetUInt32()}); + }while(rows->NextRow()); + std::vector roots; + if(auto rows=WorldDatabase.Query("SELECT DISTINCT g.MenuID,c.map,c.position_x,c.position_y,c.position_z FROM creature_template_gossip g JOIN creature c ON c.id=g.CreatureID"))do + { + auto f=rows->Fetch();roots.push_back({f[0].GetUInt32(),{f[1].GetUInt16(),f[2].GetFloat(),f[3].GetFloat(),f[4].GetFloat()}}); + }while(rows->NextRow()); + auto bindings=AdventurePoiPolicy::Bind(menus,roots); + struct Correction {Seed Old,New;bool Valid=false;}; + std::vector corrections; uint32 inferred = 0, skipped = 0; if (auto rows = WorldDatabase.Query("SELECT ID,Name,PositionX,PositionY FROM points_of_interest ORDER BY ID")) do { @@ -258,34 +274,97 @@ bool AdventureLocationMgr::Import(ChatHandler * handler, bool repair) anchor = & t; } } - if (! anchor) + Seed previous=s;previous.Z=INVALID_HEIGHT; + if(anchor) { - ++ skipped; - continue; - } - s.Map = anchor->Map; - auto & t = terrains[s.Map]; - if (! t) t = sTerrainMgr.LoadTerrain(s.Map); - Seed const* ground=anchor; - float groundDistance=std::hypot(s.X-anchor->X,s.Y-anchor->Y); - for (auto const& candidate:seeds) - if (candidate.Source=="service_npc" && candidate.Map==s.Map) + previous.Map = anchor->Map; + auto & t = terrains[previous.Map]; + if (! t) t = sTerrainMgr.LoadTerrain(previous.Map); + Seed const* ground=anchor; + float groundDistance=std::hypot(previous.X-anchor->X,previous.Y-anchor->Y); + for (auto const& candidate:seeds) + if (candidate.Source=="service_npc" && candidate.Map==previous.Map) + { + float d=std::hypot(previous.X-candidate.X,previous.Y-candidate.Y); + if (dGetStaticHeight(phase, previous.Map, previous.X, previous.Y, ground->Z+3,true,100) : INVALID_HEIGHT; + if (! std::isfinite(previous.Z) || previous.Z <= INVALID_HEIGHT + 1) { - float d=std::hypot(s.X-candidate.X,s.Y-candidate.Y); - if (dGetStaticHeight(phase, s.Map, s.X, s.Y, ground->Z+3,true,100) : INVALID_HEIGHT; - if (! std::isfinite(s.Z) || s.Z <= INVALID_HEIGHT + 1) - { - ++ skipped; - continue; + } + AdventurePoiPolicy::Anchor native; + if(!AdventurePoiPolicy::Select(bindings,s.Id,s.X,s.Y,native)) + {corrections.push_back({previous,s,false});++skipped;continue;} + s.Map=native.Map; + auto& nativeTerrain=terrains[s.Map];if(!nativeTerrain)nativeTerrain=sTerrainMgr.LoadTerrain(s.Map); + float height=native.Z,nearest=std::hypot(s.X-native.X,s.Y-native.Y); + for(auto const& candidate:seeds)if(candidate.Source=="service_npc" && candidate.Map==s.Map) + { + float distance=std::hypot(s.X-candidate.X,s.Y-candidate.Y); + if(distanceGetStaticHeight(phase,s.Map,s.X,s.Y,height+3,true,100):INVALID_HEIGHT; + s.Z=AdventurePoiPolicy::Height(height,INVALID_HEIGHT,[&](float from) + {return nativeTerrain?nativeTerrain->GetStaticHeight(phase,s.Map,s.X,s.Y,from,true,100):INVALID_HEIGHT;}); + if(!std::isfinite(s.Z) || s.Z<=INVALID_HEIGHT+1) + {corrections.push_back({previous,s,false});++skipped;continue;} + corrections.push_back({previous,s,true}); + if(std::isfinite(firstNative.Z) && firstNative.Z>INVALID_HEIGHT+1 && std::fabs(firstNative.Z-s.Z)>0.01f) + corrections.push_back({firstNative,s,true}); seeds.push_back(s); ++ inferred; } while (rows->NextRow()); auto transaction = WorldDatabase.BeginTransaction(); uint32 count = 0, areas = 0; + if(repair)for(auto const& correction:corrections) + { + auto const& old=correction.Old;auto const& next=correction.New; + if(!std::isfinite(old.Z) || old.Z<=INVALID_HEIGHT+1)continue; + std::string key=old.Key,name=old.Name; + WorldDatabase.EscapeString(key);WorldDatabase.EscapeString(name); + uint32 oldArea=areaAt(old.Map,old.X,old.Y,old.Z),oldZone=oldArea; + if(auto entry=sAreaTableStore.LookupEntry(oldArea))if(entry->ParentAreaID)oldZone=entry->ParentAreaID; + std::string oldRegion;uint32 oldFaction=0; + if(auto entry=sAreaTableStore.LookupEntry(oldZone)) + { + oldRegion=Normalize(entry->AreaName?entry->AreaName:""); + auto mask=entry->FactionGroupMask & (FACTION_GROUP_MASK_ALLIANCE|FACTION_GROUP_MASK_HORDE); + oldFaction=mask==FACTION_GROUP_MASK_ALLIANCE?1:mask==FACTION_GROUP_MASK_HORDE?2:0; + } + if(oldZone==12)oldRegion="elwynn";else if(oldZone==1)oldRegion="dun_morogh";else if(oldZone==1519)oldRegion="stormwind";else if(oldZone==4)oldRegion="blasted_lands"; + bool oldIsArea=false; + if(auto entry=sAreaTableStore.LookupEntry(oldZone))oldIsArea=Normalize(old.Name)==Normalize(entry->AreaName?entry->AreaName:""); + std::string oldType=oldIsArea?"zone":Classify(old.Name); + WorldDatabase.EscapeString(oldRegion);WorldDatabase.EscapeString(oldType); + std::string guard=" AND a.area_id="+std::to_string(oldArea)+" AND a.zone_id="+std::to_string(oldZone)+ + " AND a.region_key='"+oldRegion+"' AND a.faction="+std::to_string(oldFaction)+ + " AND a.is_area="+std::to_string(oldIsArea?1:0)+" AND w.category='"+oldType+"'"; + // Match the old generated position and source exactly. Manually moved + // locations, transport recordings and original POI tables stay untouched. + if(!correction.Valid) + { + transaction->PAppend("UPDATE vcore_clientless_world_point w JOIN vcore_adventure_location a USING(point_key) SET w.enabled=0 WHERE w.point_key='%s' AND a.source_type='guard_poi_inferred' AND a.source_id=%u AND a.display_name='%s' AND w.map=%u AND ABS(w.position_x-%.6f)<0.001 AND ABS(w.position_y-%.6f)<0.001 AND ABS(w.position_z-%.6f)<0.01%s",key.c_str(),old.Id,name.c_str(),old.Map,old.X,old.Y,old.Z,guard.c_str()); + continue; + } + uint32 area=areaAt(next.Map,next.X,next.Y,next.Z),zone=area; + if(auto entry=sAreaTableStore.LookupEntry(area))if(entry->ParentAreaID)zone=entry->ParentAreaID; + std::string region; + uint32 faction=0; + if(auto entry=sAreaTableStore.LookupEntry(zone)) + { + region=Normalize(entry->AreaName?entry->AreaName:""); + auto mask=entry->FactionGroupMask & (FACTION_GROUP_MASK_ALLIANCE|FACTION_GROUP_MASK_HORDE); + faction=mask==FACTION_GROUP_MASK_ALLIANCE?1:mask==FACTION_GROUP_MASK_HORDE?2:0; + } + if(zone==12)region="elwynn";else if(zone==1)region="dun_morogh";else if(zone==1519)region="stormwind";else if(zone==4)region="blasted_lands"; + WorldDatabase.EscapeString(region); + transaction->PAppend("UPDATE vcore_clientless_world_point w JOIN vcore_adventure_location a USING(point_key) SET w.map=%u,w.position_z=%.6f,a.area_id=%u,a.zone_id=%u,a.region_key='%s',a.faction=%u WHERE w.point_key='%s' AND a.source_type='guard_poi_inferred' AND a.source_id=%u AND a.display_name='%s' AND w.map=%u AND ABS(w.position_x-%.6f)<0.001 AND ABS(w.position_y-%.6f)<0.001 AND ABS(w.position_z-%.6f)<0.01%s",next.Map,next.Z,area,zone,region.c_str(),faction,key.c_str(),old.Id,name.c_str(),old.Map,old.X,old.Y,old.Z,guard.c_str()); + } auto metadata =[&](Seed const & s, bool isArea, uint32 area, uint32 zone) { std::string key = s.Key, name = s.Name, source = s.Source, region; diff --git a/src/server/game/World/AdventurePoiPolicy.h b/src/server/game/World/AdventurePoiPolicy.h new file mode 100644 index 0000000000..b00ee6d368 --- /dev/null +++ b/src/server/game/World/AdventurePoiPolicy.h @@ -0,0 +1,64 @@ +#ifndef VCORE_ADVENTURE_POI_POLICY_H +#define VCORE_ADVENTURE_POI_POLICY_H +#include +#include +#include +#include +#include +namespace AdventurePoiPolicy +{ +struct Anchor { unsigned Map=0;float X=0,Y=0,Z=0; }; +struct Root { unsigned Menu=0;Anchor Position; }; +struct Edge { unsigned Menu=0,Poi=0; }; +using Menus=std::unordered_map>; +using Bindings=std::unordered_map>; +inline Bindings Bind(Menus const& menus,std::vector const& roots) +{ + Bindings bindings; + for(auto const& root:roots) + { + std::vector pending{root.Menu};std::unordered_set seen; + while(!pending.empty()) + { + unsigned menu=pending.back();pending.pop_back(); + if(!seen.insert(menu).second)continue; + auto it=menus.find(menu);if(it==menus.end())continue; + for(auto const& edge:it->second) + { + if(edge.Poi)bindings[edge.Poi].push_back(root.Position); + if(edge.Menu)pending.push_back(edge.Menu); + } + } + } + return bindings; +} +template +inline float Height(float reference,float invalid,Sample&& sample) +{ + float floor=sample(reference+3); + auto valid=[&](float z){return std::isfinite(z) && z>invalid+1;}; + // A nearby NPC may be at the foot of a raised platform. A low query can + // miss the floating city's collision and hit terrain hundreds of yards below. + // Keep the close lower-floor hit; otherwise retry above the NPC's level. + if(!valid(floor) || std::fabs(floor-reference)>25) + { + float raised=sample(reference+35); + if(valid(raised) && (!valid(floor) || std::fabs(raised-reference)second.empty())return false; + unsigned map=it->second.front().Map;float nearest=std::numeric_limits::infinity(); + for(auto const& anchor:it->second) + { + // Ambiguous native associations must not silently choose another continent. + if(anchor.Map!=map)return false; + float distance=std::hypot(anchor.X-x,anchor.Y-y); + if(distanceGetMapId() || !sClientlessFlightMgr->Start(player,target,error)) { + ClientlessTravelTelemetry::Note(player,"flight-start-rejected",error+" "+ClientlessTravelTelemetry::State(player)); return RecoverFlightLeg(player,characterGuid,record,error.empty()?"free flight source not physically reached":error); } record.FlightActive=true;record.Active=true; @@ -1565,6 +1568,7 @@ bool ClientlessActionExecutorMgr::StartPortalUse(Player* player, auto const* portal = sClientlessTravelGraphMgr->GetPortalUse(step.EdgeDatabaseId); auto fail = [&](char const* reason) { + ClientlessTravelTelemetry::Note(player,"portal-rejected",reason); TC_LOG_WARN("server.adventurebot.navigation", "PORTAL_USE_FAILED guid=%u edge=%u reason='%s'", characterGuid, step.EdgeDatabaseId, reason); record.PortalUseActive = false; @@ -1573,6 +1577,8 @@ bool ClientlessActionExecutorMgr::StartPortalUse(Player* player, record.Status.Detail = reason; return false; }; + if(portal && !sClientlessTravelGraphMgr->PortalPrerequisites(player,step.EdgeDatabaseId)) + return fail("native portal prerequisites not satisfied"); if (!portal || !record.RouteStep || step.EdgeReverse || player->GetMapId() != portal->Source.MapId || !player->IsAlive() || player->IsBeingTeleported() || player->GetTransport() || @@ -1647,6 +1653,7 @@ void ClientlessActionExecutorMgr::UpdatePortalUse(Player* player, !player->IsBeingTeleported() && player->GetMapId() == step.MapId && arrivalError <= 5.0f; if (arrived) { + ClientlessTravelTelemetry::Note(player,"portal-arrived","node="+step.NodeKey+" "+ClientlessTravelTelemetry::State(player)); TC_LOG_INFO("server.adventurebot.navigation", "EDGE_COMPLETE guid=%u name='%s' edgeType='portal_use' edgeDbId=%u node='%s' spawn=%u map=%u arrivalError=%.2f recovery=%u", characterGuid, player->GetName().c_str(), step.EdgeDatabaseId, step.NodeKey.c_str(), record.PortalUseSpawnId, player->GetMapId(), arrivalError, record.RecoveryCount); @@ -1911,6 +1918,7 @@ bool ClientlessActionExecutorMgr::TryFallbackFlight(Player* player, uint32 chara std::string error; if (!sClientlessFlightMgr->Start(player, target, error)) { + ClientlessTravelTelemetry::Note(player,"flight-fallback-rejected",error+" "+ClientlessTravelTelemetry::State(player)); TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_FALLBACK_UNAVAILABLE guid=%u reason='%s'", characterGuid, error.c_str()); return false; diff --git a/src/server/game/World/ClientlessFlightMgr.cpp b/src/server/game/World/ClientlessFlightMgr.cpp index ebe98659a6..f3b810db3d 100644 --- a/src/server/game/World/ClientlessFlightMgr.cpp +++ b/src/server/game/World/ClientlessFlightMgr.cpp @@ -1,4 +1,5 @@ #include "AdventureAreaMgr.h" +#include "ClientlessTravelTelemetry.h" #include "ClientlessFlightMgr.h" #include #include "ClientlessLuaEngine.h" @@ -319,9 +320,10 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s error = "invalid free-flight destination coordinates"; return false; } - if (! player || ! player->IsInWorld() || ! Enabled(player) || ! player->IsAlive() || player->GetMap()->GetEntry()->MapType != MAP_COMMON || player->GetTransport() || player->IsInFlight() || player->IsBeingTeleported() || player->IsInCombat() || ! player->IsOutdoors() || player->IsInWater() || player->IsFalling()) + if (! player || ! player->IsInWorld() || ! Enabled(player) || ! player->IsAlive() || player->GetMap()->GetEntry()->MapType != MAP_COMMON || player->GetTransport() || player->IsInFlight() || player->IsBeingTeleported() || player->IsInCombat() || player->IsInWater() || player->IsFalling()) { error = "free flight unavailable in current player/location state"; + if(player)ClientlessTravelTelemetry::Note(player,"flight-state-rejected",ClientlessTravelTelemetry::State(player)); return false; } if (!sAdventureAreaMgr->AllowsSegment(player,player->GetMapId(),*player,target)) @@ -337,6 +339,29 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s error = "battlefield currently forbids flying"; return false; } + if(((player->GetMapId()==0 || player->GetMapId()==1) && !player->HasSpell(90267)) || + (player->GetMapId()==571 && !player->HasSpell(54197))) + {error="native regional flying license missing";return false;} + Record record; + auto previous=_records.find(player->GetGUID().GetCounter()); + if(previous!=_records.end() && previous->second.ApproachingDeparture) + record.Published=previous->second.Published; + record.Status.MapId = player->GetMapId(); + record.Status.Target = target; + record.Start.Relocate(player); + record.LastPosition.Relocate(player); + record.Settings.Clearance = std::clamp(sConfigMgr->GetFloatDefault("Clientless.Flight.Clearance", 60), 20.0f, 200.0f); + auto origin = Sample(player, record, player->GetPositionX(), player->GetPositionY()); + float support=player->GetMap()->GetHeight(player->GetPhaseShift(),player->GetPositionX(),player->GetPositionY(),player->GetPositionZ()+2,true,6); + bool supported=std::isfinite(support) && support>INVALID_HEIGHT+1 && std::fabs(support-player->GetPositionZ())<=3; + if(supported && (!player->IsOutdoors() || !origin.Valid || std::fabs(origin.Ground-player->GetPositionZ())>3 || + !OpenColumn(player,player->GetPositionX(),player->GetPositionY(),origin))) + { + if(ApproachDeparture(player,record,following))return true; + error="departure-unavailable: no reachable open takeoff point"; + ClientlessTravelTelemetry::Note(player,"flight-departure-rejected",error+" "+ClientlessTravelTelemetry::State(player)); + return false; + } auto const * profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter()); std::vector < std::pair < uint32, uint32 >> candidates; uint32 total = 0; @@ -362,30 +387,27 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s if (roll <= candidate.second) break; roll -= candidate.second; } - Record record; - auto previous=_records.find(player->GetGUID().GetCounter()); - if(previous!=_records.end() && previous->second.ApproachingDeparture) - record.Published=previous->second.Published; - record.Status.MapId = player->GetMapId(); - record.Status.Target = target; - record.Start.Relocate(player); - record.LastPosition.Relocate(player); - record.Settings.Clearance = std::clamp(sConfigMgr->GetFloatDefault("Clientless.Flight.Clearance", 60), 20.0f, 200.0f); - auto origin = Sample(player, record, player->GetPositionX(), player->GetPositionY()); uint32 existingMount=0; if(player->IsMounted() && player->CanFly()) for(auto const& candidate:candidates)if(player->HasAura(candidate.first)){existingMount=candidate.first;break;} bool const airborne=existingMount && origin.Valid && player->GetPositionZ()>=origin.Ground+0.7f && player->GetPositionZ()+5<=origin.Ceiling && ClearRay(player,PointOf(*player),{player->GetPositionX(),player->GetPositionY(),player->GetPositionZ()+5}); float const feet=player->GetMap()->GetHeight(player->GetPhaseShift(),player->GetPositionX(),player->GetPositionY(),player->GetPositionZ()+2,true,6); - if (!origin.Valid || (!airborne && (std::fabs(origin.Ground-player->GetPositionZ())>3 || std::fabs(feet-player->GetPositionZ())>3))) + if (!origin.Valid || (!airborne && (!std::isfinite(feet) || feet<=INVALID_HEIGHT+1 || std::fabs(origin.Ground-player->GetPositionZ())>3 || std::fabs(feet-player->GetPositionZ())>3))) { - if(origin.Valid && std::isfinite(feet) && feet>INVALID_HEIGHT+1 && std::fabs(feet-player->GetPositionZ())<=3 && ApproachDeparture(player,record,following))return true; + if(std::isfinite(feet) && feet>INVALID_HEIGHT+1 && std::fabs(feet-player->GetPositionZ())<=3 && ApproachDeparture(player,record,following))return true; error = "departure-unavailable: no supported open takeoff point"; TC_LOG_WARN("server.adventurebot.navigation","FLIGHT_DEPARTURE_REJECT guid=%u z=%.3f surface=%.3f feet=%.3f valid=%u mounted=%u canFly=%u",player->GetGUID().GetCounter(),player->GetPositionZ(),origin.Ground,feet,origin.Valid?1:0,player->IsMounted()?1:0,player->CanFly()?1:0); return false; } bool foundLanding = false; + std::array landingRejected{}; // surface, water, column, MMAP, area + auto landingSummary=[&]() + { + std::string detail="rejected surface/water/column/mmap/area="; + for(auto count:landingRejected)detail+=std::to_string(count)+"/"; + ClientlessTravelTelemetry::Note(player,"flight-landing-search",detail); + }; if(following) { auto surface=Sample(player,record,target.GetPositionX(),target.GetPositionY()); @@ -395,7 +417,7 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s } if(!following) for (float radius : { - 0.0f, 6.0f, 12.0f, 20.0f + 0.0f, 6.0f, 12.0f, 20.0f, 40.0f, 80.0f, 120.0f, 180.0f }) { for (unsigned i = 0; i <(radius == 0 ? 1u : 12u); ++ i) @@ -403,27 +425,31 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s float angle = float(i) * 6.283185307f / 12; float x = target.GetPositionX() + radius * std::cos(angle), y = target.GetPositionY() + radius * std::sin(angle); auto landing = Sample(player, record, x, y); - if (! landing.Valid || std::fabs(landing.Ground - target.GetPositionZ()) > 5) continue; + if (! landing.Valid){++landingRejected[0];continue;} float solid = player->GetMap()->GetHeight(player->GetPhaseShift(), x, y, MAX_HEIGHT, true, MAX_HEIGHT); - if (landing.Ground > solid + 0.7f) continue; + if (landing.Ground > solid + 0.7f){++landingRejected[1];continue;} // water is no solid touchdown - if(!OpenColumn(player,x,y,landing,record.Settings.Clearance))continue; + if(!OpenColumn(player,x,y,landing,record.Settings.Clearance)){++landingRejected[2];continue;} Position candidate; candidate.Relocate(x, y, landing.Ground, target.GetOrientation()); if (radius > 0 || std::fabs(landing.Ground - target.GetPositionZ()) > 0.7f) { auto path = PathGenerator::AnalyzePositions(player->GetMapId(), player->GetMapId(), player->GetInstanceId(), candidate, target, NAV_GROUND); - if (! path.IsComplete() || path.FinalDistance > 1.5f || path.PathLength > 120) continue; + if (! path.IsComplete() || path.FinalDistance > 1.5f || path.PathLength > 600){++landingRejected[3];continue;} } + if(!sAdventureAreaMgr->AllowsSegment(player,player->GetMapId(),candidate,target)){++landingRejected[4];continue;} + ClientlessTravelTelemetry::Note(player,"flight-landing","radius="+std::to_string(radius)+" xyz="+std::to_string(x)+","+std::to_string(y)+","+std::to_string(landing.Ground)); record.Status.Landing = candidate; foundLanding = true; break; } if (foundLanding) break; } + landingSummary(); if (! foundLanding) { - error = "no free solid landing with a validated short target approach"; + error = "no free solid landing with a validated target approach"; + ClientlessTravelTelemetry::Note(player,"flight-landing-rejected",error); return false; } sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter()); @@ -467,6 +493,7 @@ bool ClientlessFlightMgr::Begin(Player * player, Position const & target, std::s error = "native mount spell rejected"; return false; } + ClientlessTravelTelemetry::Note(player,"flight-start","spell="+std::to_string(spell)+" target="+std::to_string(target.GetPositionX())+","+std::to_string(target.GetPositionY())+","+std::to_string(target.GetPositionZ())+" "+ClientlessTravelTelemetry::State(player)); TC_LOG_INFO("server.adventurebot.navigation", "FLIGHT_START guid=%u map=%u spell=%u target=(%.2f %.2f %.2f)", player->GetGUID().GetCounter(), player->GetMapId(), spell, target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()); return true; } @@ -496,30 +523,43 @@ bool ClientlessFlightMgr::ApproachDeparture(Player* player, Record& record, bool { // Only called after a real support surface was confirmed at the feet. // Walk out from under the upper floor; never raise the player through it. + std::array rejected{}; // surface, indoor, area, column, MMAP, movement + auto summary=[&]() + { + std::string detail="rejected surface/indoor/area/column/mmap/movement="; + for(auto count:rejected)detail+=std::to_string(count)+"/"; + ClientlessTravelTelemetry::Note(player,"flight-departure-search",detail); + }; float heading=std::atan2(record.Status.Target.GetPositionY()-player->GetPositionY(),record.Status.Target.GetPositionX()-player->GetPositionX()); - for(float radius:{8.0f,16.0f,24.0f,40.0f})for(float turn:{0.0f,0.785398f,-0.785398f,1.570796f,-1.570796f,2.356194f,-2.356194f,3.141593f}) + for(float radius:{8.0f,16.0f,24.0f,40.0f,64.0f,96.0f,144.0f,200.0f})for(float turn:{0.0f,0.785398f,-0.785398f,1.570796f,-1.570796f,2.356194f,-2.356194f,3.141593f}) { float x=player->GetPositionX()+radius*std::cos(heading+turn),y=player->GetPositionY()+radius*std::sin(heading+turn); auto surface=Sample(player,record,x,y); - if(!surface.Valid)continue; + if(!surface.Valid){++rejected[0];continue;} + PositionFullTerrainStatus terrain; + player->GetMap()->GetFullTerrainStatusForPosition(player->GetPhaseShift(),x,y,surface.Ground,terrain); + if(!terrain.outdoors){++rejected[1];continue;} Position point;point.Relocate(x,y,surface.Ground,player->GetOrientation()); - if(!sAdventureAreaMgr->AllowsSegment(player,player->GetMapId(),*player,point))continue; - if(!OpenColumn(player,x,y,surface))continue; + if(!sAdventureAreaMgr->AllowsSegment(player,player->GetMapId(),*player,point)){++rejected[2];continue;} + if(!OpenColumn(player,x,y,surface)){++rejected[3];continue;} ClientlessPathDiagnostics path; if(!sClientlessTravelMgr->AnalyzePath(player,point,path) || !path.Calculated || !path.Complete || path.Suspicious || - path.FinalDistance>1.5f || path.PathLength>80 || path.MaxSegmentLength>8 || path.MaxZDelta>4)continue; - if(!sClientlessTravelMgr->StartTravel(player,point,false))continue; + path.FinalDistance>1.5f || path.PathLength>600 || path.MaxSegmentLength>8 || path.MaxZDelta>4){++rejected[4];continue;} + if(!sClientlessTravelMgr->StartTravel(player,point,false)){++rejected[5];continue;} + summary(); + ClientlessTravelTelemetry::Note(player,"flight-departure-walk","radius="+std::to_string(radius)+" length="+std::to_string(path.PathLength)); record.ApproachingDeparture=true;record.Departure=point; record.Status.State=ClientlessFlightState::Mounting;record.Status.FollowingPlayer=following; record.Status.Detail="walking to an open takeoff point"; TC_LOG_INFO("server.adventurebot.navigation","FLIGHT_DEPARTURE_WALK guid=%u destination=(%.3f %.3f %.3f) path=%.2f",player->GetGUID().GetCounter(),x,y,surface.Ground,path.PathLength); _records[player->GetGUID().GetCounter()]=std::move(record);return true; } - return false; + summary();return false; } void ClientlessFlightMgr::Fail(Player * player, Record & record, std::string const & reason) { + ClientlessTravelTelemetry::Note(player,"flight-failed",reason+" "+ClientlessTravelTelemetry::State(player)); if(record.ApproachingDeparture) { sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter());record.ApproachingDeparture=false; diff --git a/src/server/game/World/ClientlessTransportDeckMgr.cpp b/src/server/game/World/ClientlessTransportDeckMgr.cpp index 549ba0d8fc..228fa430e9 100644 --- a/src/server/game/World/ClientlessTransportDeckMgr.cpp +++ b/src/server/game/World/ClientlessTransportDeckMgr.cpp @@ -111,7 +111,18 @@ void ClientlessTransportDeckMgr::Clear(uint32 guid){Passengers.erase(guid);} bool ClientlessTransportDeckMgr::Update(Player* player,GameObject* object,Position const& exitAnchor,bool arriving,uint32 exitBudget) { auto model=Get(object->GetEntry()); - if(!model || !model->Mesh.Polygons() || std::fabs(object->GetObjectScale()-1.0f)>0.001f)return true; + if(!model || !model->Mesh.Polygons() || std::fabs(object->GetObjectScale()-1.0f)>0.001f) + { + // Recorded transitions do not require an optional free-walking mesh. + // An interrupted exit can leave the passenger halfway down the ramp. + // Restore the recorded stand only at a sufficiently long real stop. + if(!arriving)return false; + Point anchor{exitAnchor.GetPositionX(),exitAnchor.GetPositionY(),exitAnchor.GetPositionZ()}; + TransportDockState dock; + if(!object->GetTransportDockState(dock) || !dock.Stopped)return false; + if(Distance(Local(player),anchor)<=0.65f)return dock.RemainingPause>=exitBudget; + return ReturnToRecordedStand(player,object,exitAnchor,exitBudget); + } uint32 guid=player->GetGUID().GetCounter();auto& state=Passengers[guid]; if(state.Transport!=object->GetGUID()){state=Passenger{};state.Transport=object->GetGUID();} TransportDockState dock;bool stopped=object->GetTransportDockState(dock) && dock.Stopped; diff --git a/src/server/game/World/ClientlessTransportMgr.cpp b/src/server/game/World/ClientlessTransportMgr.cpp index f19841ac9e..efd87e3042 100644 --- a/src/server/game/World/ClientlessTransportMgr.cpp +++ b/src/server/game/World/ClientlessTransportMgr.cpp @@ -2,6 +2,7 @@ #include "ClientlessPositioning.h" #include "ClientlessTravelGraphMgr.h" #include "ClientlessTransportMgr.h" +#include "ClientlessTravelTelemetry.h" #include "ClientlessTransportDeckMgr.h" #include "Chat.h" #include "ClientlessPlayerMgr.h" @@ -475,6 +476,7 @@ void ClientlessTransportMgr::SetPhase(Player* player, Ride& ride, ClientlessTran { if(phase==ClientlessTransportPhase::Arrived || phase==ClientlessTransportPhase::Failed) ClientlessTransportDeckMgr::Clear(player->GetGUID().GetCounter()); + ClientlessTravelTelemetry::Note(player,"transport",std::string(PhaseName(phase))+" from="+ride.From.Node+" to="+ride.To.Node+" "+detail); ride.Status = { phase, detail, ride.Abort }; ride.PhaseElapsed = 0; ride.Moving = false; TC_LOG_INFO("server.adventurebot.navigation", "TRANSPORT_STATE guid=%u phase='%s' from='%s' to='%s' passenger=%u detail='%s'", player->GetGUID().GetCounter(), PhaseName(phase), ride.From.Node.c_str(), ride.To.Node.c_str(), player->GetTransport() ? 1u : 0u, detail); @@ -497,15 +499,24 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff) return; } if(ride.Status.Recovering){RecoverPassenger(player,ride,diff);return;} + if(ride.RetryDelay){ride.RetryDelay=diff>=ride.RetryDelay?0:ride.RetryDelay-diff;return;} GameObject* object = FindObject(player, ride.From); uint32 limit = uint32(std::min(1800000, std::max(180000, uint64(ride.From.Period) * 3 + 30000))); auto fail = [&](char const* detail) { player->StopMoving(); - SetPhase(player, ride, Phase::Failed, detail); + if(player->GetTransport() && object && player->GetTransport()==object->ToTransportBase()) + { + ride.Abort=true;ride.RetryDelay=3000; + SetPhase(player,ride,Phase::Riding,detail); + // Keep the original ride; its recorded stand recovery runs at the + // next stopped dock. Never recreate this ride on every world tick. + } + else SetPhase(player, ride, Phase::Failed, detail); // Never detach an actual passenger in midair or in the sea. }; - if (ride.Elapsed > limit) { fail("transport timeout; any passenger attachment retained"); return; } + if (ride.Elapsed > limit && !ride.Abort) + {fail("transport timeout; any passenger attachment retained");return;} if (ride.Status.Phase == Phase::Waiting) { if(player->GetMapId()!=ride.From.Map){fail("departure map changed while waiting");return;} @@ -555,6 +566,26 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff) ride.Point = 0; SetPhase(player, ride, Phase::Boarding, "boarding recorded land-to-deck transition"); } + if((ride.Status.Phase==Phase::Boarding || ride.Status.Phase==Phase::Leaving) && !player->GetTransport()) + { + auto const& transition=ride.Status.Phase==Phase::Boarding?ride.From:ride.To; + bool boarding=ride.Status.Phase==Phase::Boarding; + bool expectedPassenger=boarding ? (ride.Point>0 && transition.Points[ride.Point-1].OnTransport) : + (ride.PointGetGUID().GetCounter()); + player->StopMoving();ride.Status.Recovering=true; + ride.Status.Detail="lost passenger during recorded transition; returning to departure"; + ride.RecoveryMs=0;ride.RecoveryMoving=false;++ride.Recoveries; + ClientlessTravelTelemetry::Note(player,"transport-loss",ride.Status.Detail); + RecoverPassenger(player,ride,diff);return; + } + } if (ride.Status.Phase == Phase::Boarding && ride.Abort) { player->StopMoving(); @@ -579,7 +610,7 @@ void ClientlessTransportMgr::UpdateRide(Player* player, Ride& ride, uint32 diff) if (permittedArrival && AtDock(player, object, ride.To, 0)) arrival = &ride.To; else if (!ride.Resumed && ride.Abort && AtDock(player, object, ride.From, 0)) arrival = &ride.From; auto const& exit = arrival ? *arrival : ride.From; - uint32 exitBudget=uint32(PathLength(exit)/std::max(1.0f,player->GetSpeed(MOVE_RUN))*1000)+uint32(exit.Points.size())*100+1500; + uint32 exitBudget=uint32(PathLength(exit)/std::max(1.0f,player->GetSpeed(MOVE_RUN))*1000)+uint32(exit.Points.size())*std::max(100u,std::min(diff,2000u)*2)+2500; bool ready=ClientlessTransportDeckMgr::Update(player,object,exit.Points.back().Pos,arrival!=nullptr,exitBudget); if (!arrival || !ready || !AtDock(player,object,*arrival,exitBudget)) return; if (arrival == &ride.From) ride.To = ride.From; diff --git a/src/server/game/World/ClientlessTransportMgr.h b/src/server/game/World/ClientlessTransportMgr.h index e4fa140e1f..c6a4eeb90f 100644 --- a/src/server/game/World/ClientlessTransportMgr.h +++ b/src/server/game/World/ClientlessTransportMgr.h @@ -81,6 +81,7 @@ private: uint32 PhaseElapsed = 0; uint32 RequiredPause = 0; uint32 PointTimeout = 0; + uint32 RetryDelay = 0; bool Moving = false; bool Abort = false; bool Resumed = false; diff --git a/src/server/game/World/ClientlessTravelGraphMgr.cpp b/src/server/game/World/ClientlessTravelGraphMgr.cpp index 849c8860c0..562186bd7b 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.cpp +++ b/src/server/game/World/ClientlessTravelGraphMgr.cpp @@ -1,4 +1,5 @@ #include "AdventureAreaMgr.h" +#include "ConditionMgr.h" #include "ClientlessTravelGraphMgr.h" #include "ClientlessTransportMgr.h" #include "ClientlessFlightMgr.h" @@ -102,6 +103,18 @@ ClientlessPortalUse const* ClientlessTravelGraphMgr::GetPortalUse(uint32 edgeDat return itr != _portalUses.end() ? &itr->second : nullptr; } +bool ClientlessTravelGraphMgr::PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const +{ + auto portal=GetPortalUse(edgeDatabaseId); + auto info=portal?sObjectMgr->GetGameObjectTemplate(portal->Entry):nullptr; + if(!info || info->type!=GAMEOBJECT_TYPE_SPELLCASTER)return false; + // Native Spell::CheckCast sees caster,target. Public portals either let the + // player cast on themself or let the object cast on the visiting player. + uint8 playerTargets=info->spellcaster.playerCast?3:2; + return sConditionMgr->IsPlayerMeetingPrerequisiteConditions(CONDITION_SOURCE_TYPE_SPELL, + portal->SpellId,player,playerTargets); +} + bool ClientlessTravelGraphMgr::Reload() { _positionProbes.Clear(); diff --git a/src/server/game/World/ClientlessTravelGraphMgr.h b/src/server/game/World/ClientlessTravelGraphMgr.h index 71ccf8b00b..3814da6b5a 100644 --- a/src/server/game/World/ClientlessTravelGraphMgr.h +++ b/src/server/game/World/ClientlessTravelGraphMgr.h @@ -176,6 +176,7 @@ public: static bool ResolvePortalUse(uint32 spawnId, ClientlessPortalUse& portal, std::string& error); ClientlessPortalUse const* GetPortalUse(uint32 edgeDatabaseId) const; + bool PortalPrerequisites(Player const* player,uint32 edgeDatabaseId) const; private: std::string FindNearestStartNode(Player const* player, float maxDistance) const; diff --git a/src/server/game/World/ClientlessTravelTelemetry.h b/src/server/game/World/ClientlessTravelTelemetry.h new file mode 100644 index 0000000000..4226395284 --- /dev/null +++ b/src/server/game/World/ClientlessTravelTelemetry.h @@ -0,0 +1,74 @@ +#ifndef VCORE_CLIENTLESS_TRAVEL_TELEMETRY_H +#define VCORE_CLIENTLESS_TRAVEL_TELEMETRY_H + +// TEMPORARY_ADVENTURE_TRAVEL_TELEMETRY: +// Remove before development completion / vor Entwicklungsabschluss entfernen. +// Investigation only: native travel/access decisions must not depend on this. +#include "GameTime.h" +#include "Group.h" +#include "Log.h" +#include "Player.h" +#include +#include +#include + +namespace ClientlessTravelTelemetry +{ +struct Trace +{ + uint64 Id=0; + std::string Intent,Goal; + time_t Deadline=0,Started=0,Window=0,ProgressAt=0; + uint32 Lines=0,Suppressed=0; + std::unordered_map> Last; +}; +inline auto& Traces(){static std::unordered_map traces;return traces;} +inline void Note(Player const* player,char const* event,std::string detail) +{ + if(!player)return; + auto it=Traces().find(player->GetGUID().GetCounter());if(it==Traces().end())return; + auto& trace=it->second;auto now=GameTime::GetGameTime(); + if(now-trace.Window>=60){trace.Window=now;trace.Lines=0;} + for(auto& c:detail)if(c=='\n' || c=='\r' || c=='\'')c=' '; + if(detail.size()>900)detail.resize(900); + auto& last=trace.Last[event]; + if(trace.Lines>=40 || (last.first==detail && now-last.second<60)){++trace.Suppressed;return;} + last={detail,now};++trace.Lines; + auto group=player->GetGroup(); + TC_LOG_INFO("server.adventurebot.navigation", + "TEMP_TRAVEL trace=%llu guid=%u group=%u leader=%u map=%u elapsed=%llu intent='%s' goal='%s' event='%s' suppressed=%u detail='%s'", + static_cast(trace.Id),player->GetGUID().GetCounter(),group?group->GetGUID().GetCounter():0, + group?group->GetLeaderGUID().GetCounter():0,player->GetMapId(),static_cast(now-trace.Started), + trace.Intent.c_str(),trace.Goal.c_str(),event,trace.Suppressed,detail.c_str()); + trace.Suppressed=0; +} +inline void Begin(Player const* player,std::string const& intent,std::string const& goal,time_t deadline) +{ + uint32 guid=player->GetGUID().GetCounter(); + if(intent!="instance" && intent!="raid"){Traces().erase(guid);return;} + auto& trace=Traces()[guid]; + if(trace.Id && trace.Intent==intent && trace.Goal==goal && trace.Deadline==deadline)return; + static uint64 next=0;trace.Id=++next;trace.Intent=intent;trace.Goal=goal;trace.Deadline=deadline; + trace.Started=GameTime::GetGameTime();trace.Last.clear(); + Note(player,"journey","selected target; deadline="+std::to_string(deadline)); +} +inline void Clear(Player const* player){if(player)Traces().erase(player->GetGUID().GetCounter());} +inline std::string State(Player const* p) +{ + std::ostringstream s;s<<"position="<GetPositionX()<<","<GetPositionY()<<","<GetPositionZ() + <<" level="<getLevel()<<" area="<GetAreaId()<<" zone="<GetZoneId() + <<" alive="<IsAlive()<<" outdoor="<IsOutdoors()<<" water="<IsInWater() + <<" falling="<IsFalling()<<" combat="<IsInCombat()<<" taxi="<IsInFlight() + <<" transport="<GetTransport())<<" teleport="<IsBeingTeleported() + <<" mounted="<IsMounted()<<" canFly="<CanFly()<<" riding="<GetSkillValue(SKILL_RIDING) + <<" license="<HasSpell(90267)<<" cold="<HasSpell(54197); + return s.str(); +} +inline void Tick(Player const* player) +{ + auto it=Traces().find(player->GetGUID().GetCounter());if(it==Traces().end())return; + auto now=GameTime::GetGameTime();if(nowsecond.ProgressAt)return;it->second.ProgressAt=now+20; + Note(player,"progress",State(player)); +} +} +#endif diff --git a/src/server/scripts/Commands/cs_cheat.cpp b/src/server/scripts/Commands/cs_cheat.cpp index 1547f3a820..8f60f4264e 100644 --- a/src/server/scripts/Commands/cs_cheat.cpp +++ b/src/server/scripts/Commands/cs_cheat.cpp @@ -260,9 +260,9 @@ public: for (uint8 i = 0; i < PLAYER_EXPLORED_ZONES_SIZE; ++i) { if (flag != 0) - handler->GetSession()->GetPlayer()->SetFlag(PLAYER_EXPLORED_ZONES_1+i, 0xFFFFFFFF); + chr->SetUInt32Value(PLAYER_EXPLORED_ZONES_1+i, 0xFFFFFFFF); else - handler->GetSession()->GetPlayer()->SetFlag(PLAYER_EXPLORED_ZONES_1+i, 0); + chr->SetUInt32Value(PLAYER_EXPLORED_ZONES_1+i, 0); } return true; diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index 8291a06d12..4d9e5df3ae 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -66,6 +66,12 @@ #include #include +namespace ClientlessSafeLogout +{ +bool Command(ChatHandler* handler,char const* args); +void Update(); +} + namespace { bool Population(ChatHandler* handler,char const* args) @@ -2297,6 +2303,9 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg } static bool HandleClientlessLogout(ChatHandler* handler, char const* args) { + std::string command=args?args:""; + if(command=="status" || command=="resume" || command.rfind("safe",0)==0) + return ClientlessSafeLogout::Command(handler,args); uint32 characterGuid = 0; if (!ParseCharacterGuid(handler, args, characterGuid)) return true; @@ -2627,6 +2636,78 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg } // Restart recovery belongs to the optional AdventureBot script layer. +namespace ClientlessSafeLogout +{ +std::unordered_map Pending; +bool All=false; +bool Ready(Player const* player) +{ + // Never force a timeout: native transport/flight/combat controllers must + // finish first. Being on solid ground is sufficient even if a graph + // planner is waiting on an unreachable future waypoint. + return player && player->IsInWorld() && player->IsAlive() && !player->IsInCombat() && + !player->GetTransport() && !player->IsInFlight() && !player->IsFlying() && + !player->IsFalling() && !player->IsBeingTeleported() && + !player->HasUnitState(UNIT_STATE_CASTING) && !player->IsSplineEnabled(); +} +bool Command(ChatHandler* handler,char const* args) +{ + std::istringstream input(args?args:"");std::string op,target;input>>op>>target; + if(op=="resume"){All=false;Pending.clear();} + else if(op=="safe" && target=="all") + { + All=true; + for(auto const& session:sWorld->GetAllClientlessSessions()) + if(sClientlessPlayerMgr->IsManaged(session.first))Pending.emplace(session.first,false); + } + else if(op=="safe") + { + char* end=nullptr;auto guid=std::strtoul(target.c_str(),&end,10); + if(target.empty() || !end || *end || !guid || guid>0xFFFFFFFFul || !sClientlessPlayerMgr->IsManaged(uint32(guid))) + {handler->SendSysMessage("Usage: clientless logout safe OR logout status. Pause the loader first.");return false;} + Pending.emplace(uint32(guid),false); + } + else if(op!="status")return false; + uint32 combat=0,transport=0,flight=0,loading=0; + for(auto const& entry:Pending) + { + auto session=sWorld->FindClientlessSession(entry.first);auto player=session?session->GetPlayer():nullptr; + if(!player || !player->IsInWorld()){++loading;continue;} + combat+=player->IsInCombat()?1:0;transport+=player->GetTransport()?1:0; + flight+=(player->IsInFlight() || player->IsFlying())?1:0; + } + handler->PSendSysMessage("Safe logout pending=%u sessions=%u combat=%u transport=%u flight=%u loading=%u",uint32(Pending.size()),sWorld->GetClientlessSessionCount(),combat,transport,flight,loading); + return true; +} +void Update() +{ + if(All)for(auto const& session:sWorld->GetAllClientlessSessions()) + if(sClientlessPlayerMgr->IsManaged(session.first))Pending.emplace(session.first,false); + for(auto it=Pending.begin();it!=Pending.end();) + { + uint32 guid=it->first; + auto session=sWorld->FindClientlessSession(guid); + if(!session){it=Pending.erase(it);continue;} + auto player=session->GetPlayer(); + if(!player || !player->IsInWorld() || sWorld->GetClientlessPlayerState(guid)!=ClientlessPlayerState::Online){++it;continue;} + if(!it->second) + { + ChatHandler handler(session); + sAdventureBehaviorMgr->Command(&handler,("stop "+std::to_string(guid)).c_str()); + it->second=true; + } + if(Ready(player)) + { + // World-thread eligibility and lifecycle request occur together; + // never infer safety from stale database positions or intent text. + if(sClientlessPlayerMgr->StopPlayer(guid)) + TC_LOG_INFO("server.worldserver","CLIENTLESS_SAFE_LOGOUT guid=%u",guid); + } + ++it; + } +} +} + class ClientlessInstanceVisitResume : public WorldScript { public: @@ -2640,6 +2721,7 @@ public: void OnUpdate(uint32 diff) override { ClientlessCombatPreparation::Update(diff); + ClientlessSafeLogout::Update(); _queries.ProcessReadyCallbacks(); if(_delay>diff){_delay-=diff;return;} _delay=2000; diff --git a/tests/clientless/telemetry_test_stub.h b/tests/clientless/telemetry_test_stub.h new file mode 100644 index 0000000000..38bfa86bc2 --- /dev/null +++ b/tests/clientless/telemetry_test_stub.h @@ -0,0 +1,13 @@ +// Travel algorithms are extracted into small world doubles in these tests. +// Logging is independently exercised by test_travel_telemetry.py. +#ifndef CLIENTLESS_TELEMETRY_TEST_STUB_H +#define CLIENTLESS_TELEMETRY_TEST_STUB_H +#include +namespace ClientlessTravelTelemetry { +template void Note(T const&...) {} +template void Begin(T const&...) {} +template void Tick(T const&...) {} +template void Clear(T const&...) {} +template std::string State(T const&...) { return {}; } +} +#endif diff --git a/tests/clientless/test_adventure_area_policy.py b/tests/clientless/test_adventure_area_policy.py index a7820a2575..0b3bcc170b 100644 --- a/tests/clientless/test_adventure_area_policy.py +++ b/tests/clientless/test_adventure_area_policy.py @@ -37,6 +37,14 @@ int main() assert(!Resolve(areas,999).Valid); areas[8]={0,9};assert(!Resolve(areas,8).Valid); // orphan areas[9]={0,8};assert(!Resolve(areas,8).Valid); // cycle + // Independent native city roots inherit physical surrounding regions. + areas[2817]={571,0,-1,77,80,-1,1}; + areas[4395]={571,2817,-1,68,85,-1,-1}; + auto dalaran=Resolve(areas,4395); + assert(dalaran.PreferFlight&&dalaran.MinLevel==77&&!dalaran.Allows(1,18,true)); + areas[3519]={530,0,-1,62,65,-1,1};areas[3703]={530,3519}; + auto shattrath=Resolve(areas,3703); + assert(shattrath.PreferFlight&&shattrath.MinLevel==62&&!shattrath.Allows(2,18,true)); // A reloaded parent restriction changes the exact same child without edits. areas[1].Factions=0; assert(!Resolve(areas,2).Allows(2,85,true)); diff --git a/tests/clientless/test_adventure_group_plan.py b/tests/clientless/test_adventure_group_plan.py index caa332ba94..3433cdd832 100644 --- a/tests/clientless/test_adventure_group_plan.py +++ b/tests/clientless/test_adventure_group_plan.py @@ -24,6 +24,7 @@ std::tm Local(time_t){std::tm tm{};tm.tm_hour=5;return tm;}unsigned DateOf(std:: struct Location{unsigned InstanceCapacity=5,InstanceDifficulty=1;};struct Locations{Location l;Location*Find(std::string const&){return &l;}}locations;auto sAdventureLocationMgr=&locations; struct AdventureBehaviorMgr{ struct Record{bool Enabled=true,Schedule=true,GroupOverride=false,Dirty=false;int Hour=-1;unsigned Date=1;std::string Intent,Location,WantedIntent,WantedLocation,ManualNextIntent,ManualNextLocation;std::string PersonalIntent,PersonalLocation;time_t PersonalDeadline=0;time_t Deadline=3600;AdventureBehaviorPolicy::Day Plan;AdventureInstancePolicy::Visit Instance;AdventureGroupPolicy::Departure Departure;}; + bool allowed=true;bool Allowed(Player*,::Location const&,std::string const&){return allowed;} std::unordered_map_records;int changes=0; void Generate(Player*,Record&r,unsigned date){r.Date=date;} void SetWanted(Player*,Record&r,std::string const&i,std::string const&l,time_t end){++changes;r.WantedIntent=i;r.WantedLocation=l;r.Deadline=end;} @@ -34,6 +35,7 @@ int main(){ AdventureBehaviorMgr mgr;Player leader{{29}},member{{31}};auto& a=mgr._records[29];auto& b=mgr._records[31]; a.Intent="groupformation";a.Location="leader-capital";a.Plan[5]={"groupformation","leader-capital"};a.Plan[6]={"instance","deadmines"}; b.Intent="groupformation";b.Location="other-capital";b.Plan[5]={"groupformation","other-capital"};b.Plan[6]={"instance","shadowfang"}; + mgr.allowed=false;assert(!mgr.GroupPlan(&member).Enabled);mgr.allowed=true; auto plan=mgr.GroupPlan(&member);assert(plan.Forming&&plan.NextIntent=="instance"&&plan.NextLocation=="shadowfang"); assert(mgr.AdoptGroup(&member,&leader));assert(b.WantedLocation=="leader-capital"&&b.Deadline==a.Deadline&&b.Schedule); assert(b.Plan[6].Location=="shadowfang"); // own future plan remains available after departure diff --git a/tests/clientless/test_adventure_instance.py b/tests/clientless/test_adventure_instance.py index 3b2d35971c..630874607c 100644 --- a/tests/clientless/test_adventure_instance.py +++ b/tests/clientless/test_adventure_instance.py @@ -120,6 +120,6 @@ int main(){ ''' with tempfile.TemporaryDirectory() as tmp: src=Path(tmp)/'instance.cpp';exe=Path(tmp)/'instance';src.write_text(code) - subprocess.run(['g++','-std=c++17','-I'+str(root/'src/server/game/World'),str(src),'-o',str(exe)],check=True) + subprocess.run(['g++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')),'-std=c++17','-I'+str(root/'src/server/game/World'),str(src),'-o',str(exe)],check=True) subprocess.run([str(exe)],check=True) print('Production instance lifecycle: time budget, true entry, denied/shared entry, native rear exit, restart, expired copy, late arrival, HP and return replay passed.') diff --git a/tests/clientless/test_adventure_instance_autoresume.py b/tests/clientless/test_adventure_instance_autoresume.py index caa32bb85b..4a0e672453 100644 --- a/tests/clientless/test_adventure_instance_autoresume.py +++ b/tests/clientless/test_adventure_instance_autoresume.py @@ -14,6 +14,7 @@ code=r''' #include using uint32=uint32_t; struct WorldScript{WorldScript(char const*){}virtual void OnStartup(){}virtual void OnUpdate(uint32){}}; +namespace ClientlessSafeLogout{void Update(){}} namespace ClientlessCombatPreparation{int ticks=0;void Update(uint32){++ticks;}} struct Field{uint32 value;uint32 GetUInt32(){return value;}}; struct Rows{std::vector> data;unsigned index=0;Field* Fetch(){return data[index].data();}bool NextRow(){return ++indexTried;}; + struct Record{long Deadline=0;bool Dirty=true,Enabled=true,Publishing=false,Queued=false;std::string Intent,Location,WantedIntent="instance",WantedLocation="instance_visit_at_101",Detail;Phase State=Phase::Idle;std::unordered_setTried;}; bool mapped=true,allowed=true;int fallback=0;std::string action,parameter,target;bool hard=false; void Begin(Player*,Record&); bool Allowed(Player*,Location const&,std::string const&){return allowed;} @@ -38,6 +38,6 @@ int main(){Player p;AdventureBehaviorMgr mgr;AdventureBehaviorMgr::Record r; ''' with tempfile.TemporaryDirectory() as tmp: src=Path(tmp)/'begin.cpp';exe=Path(tmp)/'begin';src.write_text(code) - subprocess.run(['g++','-std=c++17',str(src),'-o',str(exe)],check=True) + subprocess.run(['g++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')),'-std=c++17',str(src),'-o',str(exe)],check=True) subprocess.run([str(exe)],check=True) print('Production entrance travel uses the existing location planner, requires mapping and retains normal social/area travel.') diff --git a/tests/clientless/test_adventure_instance_loader.py b/tests/clientless/test_adventure_instance_loader.py index d635294519..12e48cfac6 100644 --- a/tests/clientless/test_adventure_instance_loader.py +++ b/tests/clientless/test_adventure_instance_loader.py @@ -30,6 +30,7 @@ enum class ClientlessPlayerState{Offline,Online}; struct World{std::unordered_mapstates;auto GetClientlessPlayerState(unsigned id){return states[id];}}world;auto sWorld=&world; struct Core{std::vectorstarts;void StartPlayer(unsigned,unsigned guid,int){starts.push_back(guid);world.states[guid]=ClientlessPlayerState::Online;}}core;auto sClientlessPlayerMgr=&core; int CreateClientlessAdventureLuaController(){return 0;} +namespace ClientlessSafeLogout{void Update(){}} namespace ClientlessCombatPreparation{unsigned updates=0;void Update(unsigned){++updates;}} '''+s[a:b]+r''' int main(){ diff --git a/tests/clientless/test_adventure_intent_state.py b/tests/clientless/test_adventure_intent_state.py index ef2fb63e2a..5d8224dc25 100644 --- a/tests/clientless/test_adventure_intent_state.py +++ b/tests/clientless/test_adventure_intent_state.py @@ -132,6 +132,6 @@ int main(){ ''' with tempfile.TemporaryDirectory(prefix='vcore-adventure-state-') as folder: cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code) - subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True) + subprocess.run(['c++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')),'-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True) subprocess.run([str(binary)],check=True) print('Production intent arrival, failed/partial travel, deadlines and safe schedule handover passed.') diff --git a/tests/clientless/test_adventure_poi_binding.py b/tests/clientless/test_adventure_poi_binding.py new file mode 100644 index 0000000000..4e55dd86f8 --- /dev/null +++ b/tests/clientless/test_adventure_poi_binding.py @@ -0,0 +1,34 @@ +"""Native gossip binding resolves POI maps independently of overlapping continent XY.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +code=r''' +#include "AdventurePoiPolicy.h" +#include +using namespace AdventurePoiPolicy; +int main(){ + Menus menus{{100,{{101,0}}},{101,{{100,140},{0,141}}}}; + std::vector roots{{100,{571,5800,600,650}}}; + auto bindings=Bind(menus,roots);Anchor chosen; + assert(Select(bindings,140,5831,508,chosen)&&chosen.Map==571&&chosen.Z==650); + assert(!Select(bindings,999,5831,508,chosen)); + // A much closer unrelated teleport anchor on map1 is not part of gossip ownership. + roots.push_back({999,{1,5831,508,3}});bindings=Bind(menus,roots); + assert(Select(bindings,140,5831,508,chosen)&&chosen.Map==571); + // Native association with several maps is ambiguous; do not guess by XY. + roots.push_back({100,{0,5831,508,3}});bindings=Bind(menus,roots); + assert(!Select(bindings,140,5831,508,chosen)); + // A floating platform must not become the terrain hundreds of yards below. + assert(Height(640,-100000,[](float from){return from<658?184.f:658.f;})==658); + assert(Height(650,-100000,[](float from){return from<675?650.f:675.f;})==650); + assert(Height(640,-100000,[](float){return -100000.f;})==-100000); + // Choose a nearby guard's height only after its map has been established. + roots.pop_back();roots.push_back({100,{571,5832,508,658}});bindings=Bind(menus,roots); + assert(Select(bindings,140,5831,508,chosen)&&chosen.Map==571&&chosen.Z==658); +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('POI native gossip graph: recursive menus/cycles, unique map, ambiguity rejection, local height anchor.') diff --git a/tests/clientless/test_cheat_explore.py b/tests/clientless/test_cheat_explore.py new file mode 100644 index 0000000000..f1ed89c5b6 --- /dev/null +++ b/tests/clientless/test_cheat_explore.py @@ -0,0 +1,30 @@ +"""The existing reveal/hide command must change the selected player's full bitfield.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2];s=(root/'src/server/scripts/Commands/cs_cheat.cpp').read_text() +a=s.index(' static bool HandleExploreCheatCommand(');b=s.index('\n};',a) +code=r''' +#include +#include +#include +using uint8=unsigned char; +constexpr int PLAYER_EXPLORED_ZONES_SIZE=156,PLAYER_EXPLORED_ZONES_1=0; +constexpr int LANG_NO_CHAR_SELECTED=0,LANG_YOU_SET_EXPLORE_ALL=1,LANG_YOURS_EXPLORE_SET_ALL=2,LANG_YOU_SET_EXPLORE_NOTHING=3,LANG_YOURS_EXPLORE_SET_NOTHING=4; +struct Session{}; +struct Player{unsigned zones[156]{};Session session;Session*GetSession(){return &session;}void SetUInt32Value(int i,unsigned v){zones[i]=v;}}; +struct ChatHandler{Player*target=nullptr;explicit ChatHandler(Session*){}ChatHandler()=default; + Player*getSelectedPlayer(){return target;}void SendSysMessage(int){}void SetSentErrorMessage(bool){} + bool needReportToTarget(Player*){return false;}std::string GetNameLink(Player* =nullptr){return "Tester";} + templatevoid PSendSysMessage(T const&...){} }; +'''+s[a:b]+r''' +int main(){Player selected,caller;ChatHandler handler;handler.target=&selected; + assert(HandleExploreCheatCommand(&handler,"1"));for(auto bit:selected.zones)assert(bit==0xFFFFFFFFu);for(auto bit:caller.zones)assert(bit==0); + assert(HandleExploreCheatCommand(&handler,"0"));for(auto bit:selected.zones)assert(bit==0); + assert(!HandleExploreCheatCommand(&handler,""));handler.target=nullptr;assert(!HandleExploreCheatCommand(&handler,"1")); +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Native cheat explore reveals/clears every zone field on selected player; caller stays unchanged.') diff --git a/tests/clientless/test_city_catalog_parents.py b/tests/clientless/test_city_catalog_parents.py new file mode 100644 index 0000000000..5f4abccefe --- /dev/null +++ b/tests/clientless/test_city_catalog_parents.py @@ -0,0 +1,34 @@ +"""Execute the city-parent migration on fixtures, preserving operator overrides.""" +from pathlib import Path +import sqlite3,re,importlib.util,struct,tempfile,sys +sys.dont_write_bytecode=True +root=Path(__file__).resolve().parents[2] +s=(root/'sql/custom/world/2026_10_30_00_adventure_city_parents.sql').read_text() +s='\n'.join(l for l in s.splitlines() if not l.startswith('--')) +# Only adapt UPDATE JOIN syntax; execute the production CASE/filters unchanged. +m=re.search(r'UPDATE (\w+) city\s+JOIN \w+ region ON (.*?)\s+SET city.parent_area_id=region.area_id\s+WHERE (.*);',s,re.S) +assert m +sql=f'UPDATE {m[1]} AS city SET parent_area_id=region.area_id FROM {m[1]} AS region WHERE ({m[2]}) AND ({m[3]})' +db=sqlite3.connect(':memory:');db.execute('CREATE TABLE vcore_adventure_area_catalog(area_id INTEGER PRIMARY KEY,map_id INTEGER,parent_area_id INTEGER,min_level INTEGER,prefer_flight INTEGER)') +rows=[(2817,571,0,77,1),(4395,571,0,68,None),(3519,530,0,62,1),(3703,530,0,None,None),(12,0,0,1,0),(1519,0,999,None,None),(1,0,0,1,0),(1537,1,0,None,None),(999,0,0,1,0)] +db.executemany('INSERT INTO vcore_adventure_area_catalog VALUES(?,?,?,?,?)',rows);db.execute(sql) +after=db.execute('SELECT * FROM vcore_adventure_area_catalog ORDER BY area_id').fetchall() +assert db.execute('SELECT parent_area_id FROM vcore_adventure_area_catalog WHERE area_id=4395').fetchone()==(2817,) +assert db.execute('SELECT parent_area_id FROM vcore_adventure_area_catalog WHERE area_id=3703').fetchone()==(3519,) +assert db.execute('SELECT parent_area_id FROM vcore_adventure_area_catalog WHERE area_id=1519').fetchone()==(999,) +assert db.execute('SELECT parent_area_id FROM vcore_adventure_area_catalog WHERE area_id=1537').fetchone()==(0,) +db.execute(sql);assert db.execute('SELECT * FROM vcore_adventure_area_catalog ORDER BY area_id').fetchall()==after +# New DBC exports use the same semantic parents, without needing a C++ change. +spec=importlib.util.spec_from_file_location('catalog',root/'contrib/clientless/export_area_catalog.py');mod=importlib.util.module_from_spec(spec);spec.loader.exec_module(mod) +def dbc(path,fields,rows): + data=b''.join(struct.pack('<'+'I'*fields,*r) for r in rows);strings=b'\0Test\0' + path.write_bytes(struct.pack('<4s4I',b'WDBC',len(rows),fields,fields*4,len(strings))+data+strings) +with tempfile.TemporaryDirectory()as directory: + p=Path(directory);maps=[];areas=[] + for mid in (530,571): + r=[0]*20;r[0]=mid;r[6]=1;maps.append(r) + for aid,mid in [(2817,571),(4395,571),(3519,530),(3703,530)]: + r=[0]*26;r[0]=aid;r[1]=mid;r[11]=1;areas.append(r) + dbc(p/'Map.dbc',20,maps);dbc(p/'AreaTable.dbc',26,areas);exported=mod.export(p) + assert 'SELECT 4395,571,2817,' in exported and 'SELECT 3703,530,3519,' in exported +print('Editable city catalog parents: Dalaran/CrystalSong, Shattrath/Terokkar, operator preservation, map guard, idempotence and new DBC exports.') diff --git a/tests/clientless/test_flight_departure.py b/tests/clientless/test_flight_departure.py index 8255e3fdce..0b17deb050 100644 --- a/tests/clientless/test_flight_departure.py +++ b/tests/clientless/test_flight_departure.py @@ -9,6 +9,8 @@ a=s.index('bool ClientlessFlightMgr::ApproachDeparture(');b=s.index('\nvoid Clie approach=s[a:b] a=s.index(' bool OpenColumn(');b=s.index(' char const * StateName',a) column=s[a:b] +a=s.index(' float support=',s.index('bool ClientlessFlightMgr::Begin('));b=s.index(' auto const * profile =',a) +preparation=s[a:b] a=s.index(' if(!existingMount)\n');b=s.index(' record.Status.State = ClientlessFlightState::Mounting;',a) dismount=s[a:b] a=s.index(' auto floor = sample(current.X, current.Y);');b=s.index(' player->StopMoving();',a) @@ -31,10 +33,11 @@ struct Position{float x=0,y=0,z=0,o=0; float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;} void Relocate(float a,float b,float c,float d){x=a;y=b;z=c;o=d;}}; struct Guid{unsigned GetCounter()const{return 84;}}; -struct Map{float feet=0;float GetHeight(int,float,float,float,bool,float){return feet;}}map; +struct PositionFullTerrainStatus{bool outdoors=true;}; +struct Map{bool outdoors=true;void GetFullTerrainStatusForPosition(int,float,float,float,PositionFullTerrainStatus&t){t.outdoors=outdoors;}float feet=0;float GetHeight(int,float,float,float,bool,float){return feet;}}map; struct Player:Position{bool mounted=false,fly=false,aura=false;int removals=0; Guid GetGUID(){return {};}unsigned GetMapId(){return 530;}int GetPhaseShift(){return 0;}Map* GetMap(){return ↦} - bool IsMounted(){return mounted;}bool CanFly(){return fly;}bool HasAura(unsigned){return aura;} + bool outdoor=true;bool IsOutdoors(){return outdoor;}bool IsMounted(){return mounted;}bool CanFly(){return fly;}bool HasAura(unsigned){return aura;} void RemoveAurasByType(unsigned){++removals;mounted=fly=false;}}; Point PointOf(Position const&p){return {p.x,p.y,p.z};} enum class ClientlessFlightState{Mounting,Flying,Arrived,Failed}; @@ -60,6 +63,9 @@ struct ClientlessFlightMgr{ std::unordered_map_records;Surface surface{0,300,true};int approached=0;bool seek=false; Surface Sample(Player*,Record&,float,float){return surface;} bool ApproachDeparture(Player*,Record&,bool);bool FindReturnLanding(Player*,Record&); + bool Prepare(Player*player,Record&record,std::string&error){auto origin=surface;bool following=false; +'''+preparation+r''' + return false;} bool Admit(Player* player,Record&record,std::string&error,bool following=false){ auto origin=surface;std::vector>candidates{{123,1}}; '''+admission+r''' @@ -80,6 +86,13 @@ struct ClientlessFlightMgr{ '''+find_return+approach+r''' int main(){ ClientlessFlightMgr mgr;Player p;Record r;std::string error;r.Status.Target={100,0,0}; + // An indoor actor may walk to a verified outdoor spot before mounting. + p.outdoor=false;assert(mgr.Prepare(&p,r,error)&&mgr._records.at(84).ApproachingDeparture); + mgr._records.clear();travel.starts=0;map.outdoors=false; + assert(!mgr.Prepare(&p,r,error)&&travel.starts==0);map.outdoors=true;p.outdoor=true; + // Open ground beyond the old 40-yard ring remains usable, with a bounded MMAP leg. + travel.nav.PathLength=200;assert(mgr.ApproachDeparture(&p,r,false)); + travel.nav.PathLength=15;travel.starts=0;mgr._records.clear(); // A normal supported open-air departure remains valid. assert(mgr.Admit(&p,r,error)); // Brynli: a native flying mount already suspended ten yards above terrain. @@ -108,9 +121,10 @@ int main(){ areas.allowed=false;assert(!mgr.Admit(&p,r,error));areas.allowed=true; travel.nav.Complete=false;assert(!mgr.Admit(&p,r,error));travel.nav.Complete=true; travel.nav.MaxZDelta=20;assert(!mgr.Admit(&p,r,error));travel.nav.MaxZDelta=1; - travel.nav.PathLength=200;assert(!mgr.Admit(&p,r,error));travel.nav.PathLength=15; + travel.nav.PathLength=601;assert(!mgr.Admit(&p,r,error));travel.nav.PathLength=15; travel.start=false;assert(!mgr.Admit(&p,r,error));travel.start=true; map.feet=INVALID_HEIGHT;assert(!mgr.Admit(&p,r,error)); + mgr.surface.Ground=0;map.feet=NAN;assert(!mgr.Admit(&p,r,error));map.feet=0;mgr.surface.Ground=12; // A touchdown may use the actual lower floor, never a roof above it. map.feet=0;p.z=0;mgr.surface.Ground=12; assert(OpenColumn(&p,0,0,{0,300,true}));narrow=true;assert(!OpenColumn(&p,0,0,{0,300,true}));narrow=false; @@ -124,6 +138,6 @@ int main(){ ''' with tempfile.TemporaryDirectory(prefix='vcore-flight-departure-') 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(['c++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')),'-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 flight admission: airborne mount retained, lower-floor MMAP departure, blocked/cliff/missing support rejected.') diff --git a/tests/clientless/test_flight_landing_search.py b/tests/clientless/test_flight_landing_search.py new file mode 100644 index 0000000000..b59ff07eea --- /dev/null +++ b/tests/clientless/test_flight_landing_search.py @@ -0,0 +1,51 @@ +"""Exercise the production landing search, including distant and layered alternatives.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2];s=(root/'src/server/game/World/ClientlessFlightMgr.cpp').read_text() +a=s.index(' bool foundLanding = false;');b=s.index(' sClientlessTravelMgr->CancelTravel(',a) +code=r''' +#include +#include +#include +#include +#include +using uint32=unsigned; +constexpr float MAX_HEIGHT=100000;constexpr int NAV_GROUND=1; +struct Position{float x=0,y=0,z=0,o=0;float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;} + void Relocate(float a,float b,float c,float d){x=a;y=b;z=c;o=d;}}; +struct Surface{float Ground=0,Ceiling=500;bool Valid=true;}; +struct Map{float GetHeight(int,float,float,float,bool,float){return 20;}}map; +struct Player{int GetPhaseShift(){return 0;}Map*GetMap(){return ↦}unsigned GetMapId(){return 530;}unsigned GetInstanceId(){return 0;}}; +struct Record{struct{Position Landing;bool FollowingPlayer=false;}Status;struct{float Clearance=60;}Settings;}; +struct Areas{bool allowed=true;bool AllowsSegment(Player*,unsigned,Position const&,Position const&){return allowed;}}areas;auto sAdventureAreaMgr=&areas; +struct PathGenerator{struct Path{bool complete=true;float FinalDistance=0,PathLength=180;bool IsComplete(){return complete;}}; + static inline Path result;static inline bool roofReachable=false;static inline unsigned probes=0; + static Path AnalyzePositions(unsigned,unsigned,unsigned,Position const&a,Position const&,int){++probes;auto p=result;if(a.z==20&&!roofReachable)p.complete=false;return p;}}; +bool column=true;float openAt=50;bool water=false; +bool OpenColumn(Player*,float x,float y,Surface const&,float){return column&&std::hypot(x,y)>=openAt;} +Surface Sample(Player*,Record&,float x,float y){return {water?30.f:std::hypot(x,y)<50?20.f:0.f,500,true};} +bool Find(Player*player,Record&record,Position const&target,std::string&error){bool following=false; +'''+s[a:b]+r''' + return true; +} +int main(){Player p;Record record;Position target;std::string error; + // Nearby roof candidates are unusable. A safe outdoor landing 80yd away works. + assert(Find(&p,record,target,error));assert(std::hypot(record.Status.Landing.x,record.Status.Landing.y)>=79&&record.Status.Landing.z==0); + // >5yd level difference is fine only when the native MMAP approach is complete. + openAt=0;PathGenerator::roofReachable=true;assert(Find(&p,record,target,error)&&record.Status.Landing.z==20); + PathGenerator::roofReachable=false;assert(Find(&p,record,target,error)&&record.Status.Landing.z==0); + PathGenerator::result.complete=false;assert(!Find(&p,record,target,error));PathGenerator::result.complete=true; + PathGenerator::result.FinalDistance=2;assert(!Find(&p,record,target,error));PathGenerator::result.FinalDistance=0; + PathGenerator::result.PathLength=601;assert(!Find(&p,record,target,error));PathGenerator::result.PathLength=180; + column=false;assert(!Find(&p,record,target,error));column=true; + areas.allowed=false;assert(!Find(&p,record,target,error));areas.allowed=true; + water=true;assert(!Find(&p,record,target,error)); +} +''' +# Define the static aggregate out-of-line for older standalone compilers. +code=code.replace('static inline Path result;', 'static Path result;').replace('bool column=true;', 'PathGenerator::Path PathGenerator::result;\nbool column=true;') +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17','-include',str(Path(__file__).with_name('telemetry_test_stub.h')),str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Production landing search: distant/layered alternatives, native final ground approach, water/collision/area rejection.') diff --git a/tests/clientless/test_flight_policy.py b/tests/clientless/test_flight_policy.py index 7ba509d424..9d0ad2bd25 100644 --- a/tests/clientless/test_flight_policy.py +++ b/tests/clientless/test_flight_policy.py @@ -129,6 +129,6 @@ int main(){ ''' with tempfile.TemporaryDirectory(prefix='vcore-flight-policy-') as folder: cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(harness) - subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True) + subprocess.run(['c++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')),'-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True) subprocess.run([str(binary)],check=True) print('Flight native eligibility, license/profile/zone policy, RP costs and fail-safe return decisions passed.') diff --git a/tests/clientless/test_ground_exit_continuation.py b/tests/clientless/test_ground_exit_continuation.py index 2c172f073d..096ba85477 100644 --- a/tests/clientless/test_ground_exit_continuation.py +++ b/tests/clientless/test_ground_exit_continuation.py @@ -49,8 +49,8 @@ struct Motion { int launches=0;std::vector path; void MoveSmoothPath(int,Position const* p,std::size_t count,bool){++launches;path.assign(p,p+count);} }; -struct Player { - float x=0; bool spline=true, flying=false;Motion motion; +struct Player:Position { + bool spline=true, flying=false;Motion motion; void StopMoving(){spline=false;}bool IsFlying()const{return flying;}bool IsSplineEnabled()const{return spline;} bool IsInWorld()const{return true;}uint32 GetMapId()const{return 0;} float GetPositionX()const{return x;}float GetPositionY()const{return 0;}float GetPositionZ()const{return 0;} @@ -94,6 +94,7 @@ struct Graph { ClientlessTravelNode const* FindNode(std::string const& key){return key==node.Key?&node:nullptr;} } 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;} ''' @@ -202,5 +203,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++', '-std=c++17', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(binary)], check=True) + 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([str(binary)], check=True) diff --git a/tests/clientless/test_guildless_group_restore.py b/tests/clientless/test_guildless_group_restore.py new file mode 100644 index 0000000000..f6e0a39b76 --- /dev/null +++ b/tests/clientless/test_guildless_group_restore.py @@ -0,0 +1,30 @@ +"""Run the production member restore for guildless, guilded and deleted characters.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2];s=(root/'src/server/game/Groups/Group.cpp').read_text() +a=s.index('void Group::LoadMemberFromDB(');b=s.index('\nvoid Group::ConvertToLFG',a) +code=r''' +#include +#include +#include +using uint8=unsigned char; +enum class HighGuid{Player};struct ObjectGuid{using LowType=unsigned;unsigned id;ObjectGuid()=default;ObjectGuid(HighGuid,unsigned i):id(i){}}; +struct Cache{bool exists=true;unsigned guild=0;bool GetCharacterNameByGuid(ObjectGuid,std::string&n){n="Bot";return exists;} + bool GetPlayerGuildIdByGUID(ObjectGuid,unsigned&g){if(!guild)return false;g=guild;return true;}}cache;auto sCharacterCache=&cache; +constexpr int CHAR_DEL_GROUP_MEMBER=1;struct CharacterDatabasePreparedStatement{void setUInt32(int,unsigned){}}; +struct DB{int deletes=0;CharacterDatabasePreparedStatement stmt;auto GetPreparedStatement(int){return &stmt;}void Execute(CharacterDatabasePreparedStatement*){++deletes;}}CharacterDatabase; +struct LFG{unsigned loaded=0;void SetupGroupMember(ObjectGuid,unsigned){++loaded;}}lfg;auto sLFGMgr=&lfg; +struct Group{struct MemberSlot{ObjectGuid guid;std::string name;unsigned guildId;uint8 group,flags,roles;};std::vectorm_memberSlots;unsigned subgroups=0; + unsigned GetGUID(){return 10;}void SubGroupCounterIncrease(unsigned){++subgroups;}void LoadMemberFromDB(unsigned,uint8,uint8,uint8);}; +'''+s[a:b]+r''' +int main(){Group group;group.LoadMemberFromDB(26,2,1,4);assert(CharacterDatabase.deletes==0&&group.m_memberSlots.size()==1); + auto member=group.m_memberSlots[0];assert(member.guildId==0&&member.guid.id==26&&member.roles==4&&member.flags==2&&member.group==1); + cache.guild=123;group.LoadMemberFromDB(27,0,0,1);assert(group.m_memberSlots[1].guildId==123&&lfg.loaded==2&&group.subgroups==2); + cache.exists=false;group.LoadMemberFromDB(28,0,0,1);assert(CharacterDatabase.deletes==1&&group.m_memberSlots.size()==2&&lfg.loaded==2); +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Production group restore retains guildless members and their roles; only missing characters are deleted.') diff --git a/tests/clientless/test_portal_use.py b/tests/clientless/test_portal_use.py index 153a7f9f67..19df20e208 100644 --- a/tests/clientless/test_portal_use.py +++ b/tests/clientless/test_portal_use.py @@ -81,7 +81,7 @@ void Session::CompleteClientlessTeleport(){ } struct Node {std::string Key;uint32 MapId=530;}; struct Portal {uint32 SpawnId=83,Entry=183325,SpellId=17334;Node Source;}; -struct Graph {bool available=true;Portal portal;auto GetPortalUse(uint32){return available?&portal:nullptr;}} graph; +struct Graph {bool prerequisites=true;bool PortalPrerequisites(Player*,uint32){return prerequisites;}bool available=true;Portal portal;auto GetPortalUse(uint32){return available?&portal:nullptr;}} graph; struct Travel {void CancelTravel(uint32){}} travel; struct Lua {void DismountGroundMount(Player*,const char*){}} lua; auto sClientlessTravelGraphMgr=&graph;auto sClientlessTravelMgr=&travel;auto sClientlessLuaEngine=&lua; @@ -105,6 +105,7 @@ harness += methods harness += r''' int main(){ ClientlessActionExecutorMgr mgr; + {Player p;Record r;graph.prerequisites=false;assert(!mgr.StartPortalUse(&p,26,r)&&p.session.clicks==0);graph.prerequisites=true;} {Player p;Record r;assert(mgr.StartPortalUse(&p,26,r));assert(p.session.clicks==1&&!p.world); mgr.UpdatePortalUse(&p,26,r);assert(!r.PortalUseActive&&r.RouteStep==2&&p.mapId==0&&p.session.sequence==1);} // The global lifecycle may have ACKed a delayed cast before our next update. @@ -139,7 +140,7 @@ int main(){ with tempfile.TemporaryDirectory(prefix='vcore-portal-use-test-') as folder: cpp, binary = Path(folder)/'test.cpp', Path(folder)/'test' cpp.write_text(harness) - subprocess.run(['c++', '-std=c++17', str(cpp), '-o', str(binary)], check=True) + subprocess.run(['c++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')), '-std=c++17', str(cpp), '-o', str(binary)], check=True) subprocess.run([str(binary)], check=True) print('Interactive portal native click, near/far/global ACK, denial, timeout and target checks passed.') @@ -190,7 +191,7 @@ int main(){ with tempfile.TemporaryDirectory(prefix='vcore-portal-definition-test-') as folder: cpp, binary = Path(folder)/'test.cpp', Path(folder)/'test' cpp.write_text(definition_harness) - subprocess.run(['c++', '-std=c++17', str(cpp), '-o', str(binary)], check=True) + subprocess.run(['c++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')), '-std=c++17', str(cpp), '-o', str(binary)], check=True) subprocess.run([str(binary)], check=True) print('Native portal destination discovery and unsupported/ambiguous spell rejection passed.') @@ -224,6 +225,6 @@ int main(){ with tempfile.TemporaryDirectory(prefix='vcore-portal-ack-test-') as folder: cpp, binary = Path(folder)/'test.cpp', Path(folder)/'test' cpp.write_text(ack_harness) - subprocess.run(['c++', '-std=c++17', str(cpp), '-o', str(binary)], check=True) + subprocess.run(['c++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')), '-std=c++17', str(cpp), '-o', str(binary)], check=True) subprocess.run([str(binary)], check=True) print('Production clientless ACK completion sequence increments only after completed near/far transfers.') diff --git a/tests/clientless/test_route_portal_prerequisites.py b/tests/clientless/test_route_portal_prerequisites.py new file mode 100644 index 0000000000..52771ffb28 --- /dev/null +++ b/tests/clientless/test_route_portal_prerequisites.py @@ -0,0 +1,48 @@ +"""Run production remote prerequisite evaluation with native condition-shaped doubles.""" +from pathlib import Path +import subprocess,tempfile,re +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/Conditions/ConditionMgr.cpp').read_text() +a=s.index('bool ConditionMgr::IsPlayerMeetingPrerequisiteConditions(');b=s.index('\nbool ConditionMgr::IsObjectMeetingNotGroupedConditions(',a) +method=s[a:b] +names=sorted(set(re.findall(r'\bCONDITION_[A-Z_]+',method)) - {'CONDITION_SOURCE_TYPE_NONE','CONDITION_SOURCE_TYPE_MAX'}) +code=r''' +#include +#include +#include +using uint32=unsigned;using uint8=unsigned char; +enum ConditionSourceType{CONDITION_SOURCE_TYPE_NONE, SPELL, CONDITION_SOURCE_TYPE_MAX}; +'''+ 'enum Kind{'+','.join(names)+r''',SPATIAL}; +struct Player{unsigned level=52;bool quest=false;}; +struct ConditionSourceInfo{Player* targets[3];ConditionSourceInfo(Player*a,Player*b,Player*c):targets{a,b,c}{}}; +struct Condition{unsigned ElseGroup=0,ReferenceId=0,ScriptId=0,ConditionTarget=1;Kind ConditionType=CONDITION_LEVEL;bool negative=false; + bool isLoaded()const{return true;} + bool Meets(ConditionSourceInfo&i)const{bool v=ConditionType==CONDITION_LEVEL?i.targets[ConditionTarget]->level>=58:i.targets[ConditionTarget]->quest;return negative?!v:v;}}; +using ConditionContainer=std::vector; +struct ConditionMgr{std::mapConditionStore[CONDITION_SOURCE_TYPE_MAX],ConditionReferenceStore; + bool IsPlayerMeetingPrerequisiteConditions(ConditionSourceType,unsigned,Player const*,uint8)const;}; +'''+method+r''' +int main(){ConditionMgr mgr;Player p;Condition level,quest;quest.ConditionType=CONDITION_QUESTREWARDED; + auto check=[&](unsigned mask=2){return mgr.IsPlayerMeetingPrerequisiteConditions(SPELL,65729,&p,mask);}; + assert(check());mgr.ConditionStore[SPELL][65729]={&level};assert(!check());p.level=58;assert(check());p.level=52; + // Visiting player is target1 for GO casts. Do not apply player tests to a GO caster. + level.ConditionTarget=0;assert(check());assert(!check(3));level.ConditionTarget=1; + mgr.ConditionStore[SPELL][65729]={&level,&quest};assert(!check());p.quest=true;assert(!check()); + quest.ElseGroup=1;assert(check());p.quest=false;assert(!check()); + // Remote spatial/script requirements are unknown, not a reason to reject an OR branch. + quest.ConditionType=SPATIAL;assert(check());quest.ElseGroup=0;assert(!check()); + quest.ElseGroup=1;quest.ConditionType=CONDITION_QUESTREWARDED;quest.ScriptId=12;assert(check());quest.ScriptId=0; + Condition ref;ref.ReferenceId=42;mgr.ConditionReferenceStore[42]={&level};mgr.ConditionStore[SPELL][65729]={&ref};assert(!check()); + p.level=58;assert(check());level.negative=true;assert(!check());p.level=52;assert(check()); + // Recursive/broken reference must defer to native use rather than recurse forever. + mgr.ConditionReferenceStore[42]={&ref};assert(check()); +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +# Prerequisites must run before the cached static edge decision. +a=(root/'src/server/game/World/AdventureAreaMgr.cpp').read_text();a=a[a.index('bool AdventureAreaMgr::AllowsEdge('):] +assert a.index('PortalPrerequisites') Pending;'));b=s.index('\nbool Command(',a) +code=r''' +#include +#include +constexpr int UNIT_STATE_CASTING=1; +struct Player{bool world=true,alive=true,combat=false,transport=false,taxi=false,flying=false,falling=false,teleport=false,casting=false,spline=false; + bool IsInWorld()const{return world;}bool IsAlive()const{return alive;}bool IsInCombat()const{return combat;}bool GetTransport()const{return transport;} + bool IsInFlight()const{return taxi;}bool IsFlying()const{return flying;}bool IsFalling()const{return falling;}bool IsBeingTeleported()const{return teleport;} + bool HasUnitState(int)const{return casting;}bool IsSplineEnabled()const{return spline;}}; +'''+s[a:b]+r''' +int main(){Player p;assert(Ready(&p)&&!Ready(nullptr)); + for(auto field:{&Player::combat,&Player::transport,&Player::taxi,&Player::flying,&Player::falling,&Player::teleport,&Player::casting,&Player::spline}){ + p.*field=true;assert(!Ready(&p));p.*field=false;assert(Ready(&p)); + } + p.world=false;assert(!Ready(&p));p.world=true;p.alive=false;assert(!Ready(&p)); +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Native safe logout gate waits for combat, casting, movement, transport, taxi, flight, falling, teleport and resurrection.') diff --git a/tests/clientless/test_transport.py b/tests/clientless/test_transport.py index 0d85e6c1e0..6d27ec45b8 100644 --- a/tests/clientless/test_transport.py +++ b/tests/clientless/test_transport.py @@ -128,7 +128,7 @@ bool Safe(std::string const& key){return !key.empty();} namespace Trinity {bool IsValidMapCoord(float,float,float,float=0){return true;}} ''' helpers = '\n'.join(function(source, marker) for marker in [ - ' float Distance(', ' bool SameService(', ' bool LoadNode(', ' Position WorldPoint(', ' float PathLength(' + ' char const* PhaseName(', ' float Distance(', ' bool SameService(', ' bool LoadNode(', ' Position WorldPoint(', ' float PathLength(' ]) methods = '\n'.join(function(source, marker) for marker in [ 'bool ClientlessTransportMgr::ResumePassenger(', 'bool ClientlessTransportMgr::SelectResumedDock(', @@ -190,7 +190,7 @@ int main(){ go.x=50;p.x=51;mgr.Cancel(26);run(mgr,p,abort);assert(abort.Status.Phase==Phase::Riding&&p.transport); go.x=5;p.x=6;run(mgr,p,abort);assert(abort.Status.Phase==Phase::Failed&&abort.Status.Cancelled&&!p.transport&&p.x==0); // A timeout aboard never removes the passenger over sea/in midair. - assert(mgr.Start(&p,"A","B",error));auto&timeout=mgr._rides.at(26);run(mgr,p,timeout);assert(p.transport);timeout.Elapsed=1800001;run(mgr,p,timeout,1);assert(timeout.Status.Phase==Phase::Failed&&p.transport);go.base.RemovePassenger(&p); + assert(mgr.Start(&p,"A","B",error));auto&timeout=mgr._rides.at(26);run(mgr,p,timeout);assert(p.transport);timeout.Elapsed=1800001;run(mgr,p,timeout,1);assert(timeout.Status.Phase==Phase::Riding&&timeout.Abort&&timeout.RetryDelay==3000&&p.transport);go.base.RemovePassenger(&p); // Departure during a boarding transition keeps the Passenger until safe recovery. p.x=0;go.x=5;go.state.Stopped=true;assert(mgr.Start(&p,"A","B",error));auto&late=mgr._rides.at(26); for(int i=0;i<10&&!p.transport;i++)mgr.UpdateRide(&p,late,100); @@ -280,6 +280,16 @@ int main(){ copy1.base.RemovePassenger(&orphan);orphan.map=0;orphan.object=&vessel;vessel.x=100; vessel.base.AddPassenger(&orphan);orphan.x=101;orphan.m_movementInfo.transport.pos.x=1; run(resumed,orphan,resumed._rides.at(26));assert(!orphan.transport&&orphan.map==0&&orphan.x==104); + // Lost attachment during both boarding and disembarkation uses recorded departure rescue. + travel.path=false;travel.present=false; + for(auto phase:{Phase::Boarding,Phase::Leaving}){ + Player fallen;fallen.object=&vessel;fallen.x=50;fallen.falling=true; + auto ride=adopted;ride.From=stop("A",5,0);ride.To=stop("B",100,104); + ride.Status.Phase=phase;ride.Status.Recovering=false;ride.Abort=false;ride.Elapsed=0;ride.RetryDelay=0; + ride.Point=phase==Phase::Boarding?3:2;ride.Moving=false; + resumed.UpdateRide(&fallen,ride,100); + assert(ride.Status.Recovering&&fallen.teleports==1&&fallen.x==0); + } std::cout<<"Transport dock, frame, recorder, cross-map copy and ride regressions passed\n"; } ''' @@ -287,5 +297,5 @@ with tempfile.TemporaryDirectory(prefix='vcore-transport-test-') as directory: path = Path(directory) cpp = path / 'transport.cpp' cpp.write_text(preamble + position + '\n' + offset + world + header + helpers + stubs + methods + animated + tests) - subprocess.run(['g++', '-std=c++17', '-O0', '-g', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(path / 'test')], check=True) + subprocess.run(['g++','-include',str(Path(__file__).with_name('telemetry_test_stub.h')), '-std=c++17', '-O0', '-g', '-Wall', '-Wextra', '-Wno-unused-parameter', str(cpp), '-o', str(path / 'test')], check=True) subprocess.run([str(path / 'test')], check=True) diff --git a/tests/clientless/test_transport_deck_control.py b/tests/clientless/test_transport_deck_control.py index f6e17a3636..52a6bdca04 100644 --- a/tests/clientless/test_transport_deck_control.py +++ b/tests/clientless/test_transport_deck_control.py @@ -74,7 +74,16 @@ int main(){Player p;Position anchor; assert(launches==2&&Distance(first,Passengers[2].Target)>=1.5f); // Near departure, a running walk is stopped on the deck. object.dock.RemainingPause=1000;assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,false,5000));assert(!p.spline); - object.entry=999;assert(ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000)); + // Ships without a mesh still recover interrupted exits at a real stop. + object.entry=190536;p.current={9,0,0};auto unsupportedBefore=fallback; + assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000));assert(fallback==unsupportedBefore); + object.dock.RemainingPause=60000;object.dock.Stopped=false; + assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000));assert(fallback==unsupportedBefore); + object.dock.Stopped=true;assert(ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000)); + assert(fallback==unsupportedBefore+1&&p.current[0]==0&&p.transport==&object); + assert(ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000)&&fallback==unsupportedBefore+1); + object.entry=186238;p.current={9,0,0};assert(ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000)); + assert(fallback==unsupportedBefore+2); Passengers.clear();object.entry=176310;object.dock.RemainingPause=60000; p.current={4,0,0};p.spline=false;launchOK=false;auto before=fallback; assert(!ClientlessTransportDeckMgr::Update(&p,&object,anchor,true,5000)&&Passengers[1].ReturnFailed); diff --git a/tests/clientless/test_travel_telemetry.py b/tests/clientless/test_travel_telemetry.py new file mode 100644 index 0000000000..8aace5df26 --- /dev/null +++ b/tests/clientless/test_travel_telemetry.py @@ -0,0 +1,48 @@ +"""Run the real temporary trace lifecycle and its per-bot log budget.""" +from pathlib import Path +import subprocess,tempfile,re +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/ClientlessTravelTelemetry.h').read_text() +assert 'Remove before development completion' in s +s=re.sub(r'#include "[^\n]+"\n','',s) +code=r''' +#include +#include +#include +using uint64=uint64_t;using uint32=unsigned; +namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}} +unsigned logs=0; +#define TC_LOG_INFO(...) (++logs) +constexpr int SKILL_RIDING=1; +struct Guid{unsigned id;unsigned GetCounter()const{return id;}}; +struct Group{Guid GetGUID()const{return {1};}Guid GetLeaderGUID()const{return {26};}}; +struct Player{unsigned guid=26;Guid GetGUID()const{return {guid};}Group*GetGroup()const{return nullptr;} + unsigned GetMapId()const{return 0;}float GetPositionX()const{return 1;}float GetPositionY()const{return 2;}float GetPositionZ()const{return 3;} + unsigned getLevel()const{return 60;}unsigned GetAreaId()const{return 12;}unsigned GetZoneId()const{return 12;} + bool IsAlive()const{return true;}bool IsOutdoors()const{return true;}bool IsInWater()const{return false;}bool IsFalling()const{return false;} + bool IsInCombat()const{return false;}bool IsInFlight()const{return false;}void*GetTransport()const{return nullptr;} + bool IsBeingTeleported()const{return false;}bool IsMounted()const{return false;}bool CanFly()const{return false;} + unsigned GetSkillValue(int)const{return 225;}bool HasSpell(int)const{return true;}}; +'''+s+r''' +#include +int main(){using namespace ClientlessTravelTelemetry;Player p; + Note(&p,"route","no journey");assert(logs==0&&Traces().empty()); + Begin(&p,"adventure","Elwynn",200);assert(logs==0&&Traces().empty()); + Begin(&p,"instance","Deadmines",200);assert(logs==1); + auto id=Traces().at(26).Id;Begin(&p,"instance","Deadmines",200);assert(logs==1&&Traces().at(26).Id==id); + Note(&p,"route","same");Note(&p,"route","same");assert(logs==2); + for(unsigned i=0;i<100;++i)Note(&p,"route",std::to_string(i));assert(logs==40); + for(unsigned i=0;i<100;++i)Begin(&p,"raid",std::to_string(i),200);assert(logs==40); + GameTime::now+=60;Note(&p,"route","new minute");assert(logs==41); + Tick(&p);assert(logs==42);Tick(&p);assert(logs==42); + GameTime::now+=20;Tick(&p); // unchanged full state is deduplicated, too + assert(logs==42); + Begin(&p,"rest","inn",300);assert(Traces().empty()); + Begin(&p,"raid","Karazhan",400);assert(!Traces().empty());Clear(&p);assert(Traces().empty()); +} +''' +with tempfile.TemporaryDirectory()as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['c++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Temporary travel traces: instance/raid scope, stable journey ID, deduplication, 40/min budget across goal changes and logout cleanup.')