From c0c799c4b686062b14b8344876e7c514334d3e73 Mon Sep 17 00:00:00 2001 From: nico Date: Tue, 27 Oct 2026 23:17:05 +0100 Subject: [PATCH] Add AdventureBot combat reactions, training and corpse recovery --- doc/clientless/SESSION-RESUME.md | 117 +++++++ .../2026-10-27-combat/live-events.txt | 60 ++++ .../2026-10-27-combat/live-navigation.txt | 16 + .../2026-10-27-combat/validation.txt | 8 + doc/clientless/combat-reactions.md | 189 ++++++++++++ doc/clientless/economy-intent.md | 5 + doc/clientless/leisure-intent.md | 6 + .../world/2026_10_27_08_adventure_repair.sql | 7 + .../2026_10_27_09_adventure_training.sql | 6 + src/server/game/Entities/Unit/Unit.cpp | 3 + .../game/World/AdventureBehaviorMgr.cpp | 24 ++ src/server/game/World/AdventureBehaviorMgr.h | 2 + src/server/game/World/AdventureCombatMgr.cpp | 287 ++++++++++++++++++ src/server/game/World/AdventureCombatMgr.h | 48 +++ src/server/game/World/AdventureCombatPolicy.h | 27 ++ .../World/AdventureCombatPreparationPolicy.h | 31 ++ .../game/World/AdventureEconomyPolicy.h | 8 +- .../game/World/AdventureSocialActions.cpp | 12 +- .../game/World/AdventureSocialActions.h | 1 + src/server/game/World/ClientlessLuaEngine.cpp | 7 +- src/server/game/World/ClientlessPlayerMgr.cpp | 18 ++ src/server/game/World/ClientlessPlayerMgr.h | 9 + src/server/game/World/ClientlessTravelMgr.cpp | 6 + src/server/game/World/ClientlessTravelMgr.h | 1 + .../scripts/Custom/viano_bot_commands.cpp | 260 ++++++++++++++++ .../test_adventure_combat_exercise.py | 62 ++++ .../test_adventure_combat_preparation.py | 56 ++++ .../test_adventure_combat_reactions.py | 143 +++++++++ tests/clientless/test_adventure_economy.py | 19 +- tests/clientless/test_adventure_instance.py | 2 +- .../test_adventure_instance_autoresume.py | 3 +- tests/clientless/test_adventure_leisure.py | 6 + .../test_adventure_repair_catalog.py | 16 + tests/clientless/test_adventure_social.py | 16 + .../test_adventure_training_catalog.py | 16 + .../test_clientless_damage_notifications.py | 44 +++ tests/clientless/test_default_phase.py | 45 +++ 37 files changed, 1568 insertions(+), 18 deletions(-) create mode 100644 doc/clientless/checkpoints/2026-10-27-combat/live-events.txt create mode 100644 doc/clientless/checkpoints/2026-10-27-combat/live-navigation.txt create mode 100644 doc/clientless/checkpoints/2026-10-27-combat/validation.txt create mode 100644 doc/clientless/combat-reactions.md create mode 100644 sql/custom/world/2026_10_27_08_adventure_repair.sql create mode 100644 sql/custom/world/2026_10_27_09_adventure_training.sql create mode 100644 src/server/game/World/AdventureCombatMgr.cpp create mode 100644 src/server/game/World/AdventureCombatMgr.h create mode 100644 src/server/game/World/AdventureCombatPolicy.h create mode 100644 src/server/game/World/AdventureCombatPreparationPolicy.h create mode 100644 tests/clientless/test_adventure_combat_exercise.py create mode 100644 tests/clientless/test_adventure_combat_preparation.py create mode 100644 tests/clientless/test_adventure_combat_reactions.py create mode 100644 tests/clientless/test_adventure_repair_catalog.py create mode 100644 tests/clientless/test_adventure_training_catalog.py create mode 100644 tests/clientless/test_clientless_damage_notifications.py create mode 100644 tests/clientless/test_default_phase.py diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 3af2b76619..0f53487f55 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,120 @@ +# Aktuell: Training, echte Gegner und Todesverhalten abgeschlossen + +Stand 27.10.2026. Auf feature/clientless-player-core bleiben alle bisherigen +Änderungen erhalten. Debug-Build mit -j10 erfolgreich installiert; alle 51 +Testskripte bestanden. Testserver PID 45545 läuft mit frisch geprüftem Ready +und Port 8085. Nutzer hat den lokalen Commit aller Änderungen beauftragt; kein Push. + +Neue AdventureCombatMgr und AdventureCombatPolicy überlagern den Adventure- +Controller während Kampf und Tod. Tatsächlich angewandter Kreaturenschaden +wird über eine threadsichere GUID-Queue geordnet an den Controller übergeben. +Der erste Angreifer bleibt Fokus; weitere folgen nach dessen Tod. Abenteuer +berücksichtigt Gegner am aktiven MMAP-Pfad, Kürschnerei nur Tiere bei bestehendem +Profilrecht. Arthoran bleibt Bergbau/Schmied. Konservative Erstangriffsfilter: +höchstens +2 Level, mindestens 60% HP und gegebenenfalls 30% Mana, keine Elites, +Weltbosse oder Gegnergruppen. Andere Intents jagen nicht. + +Migration 09 ergänzt leisure_training für 60–180 Sekunden an echten erreichbaren +Puppen im lokalen 60-Yards-Umkreis. Bereits in Test-DB angewendet und trotz +SQL-Ignore gezielt vorgemerkt. Ursprüngliche Intentfristen bleiben erhalten. +Todesablauf: native Freigabe, gespeicherter Leichenzeitpunkt, MMAP-Punkte innerhalb +39 Yards und native Aufnahmefrist. Ohne passenden Pfad nach 300 Sekunden native +Friedhofsrückkehr und Wiederbelebung ohne Geistheiler-Mali. + +Live erfolgreich geprüft: +- Trainingsszene: MMAP-Anreise, native Angriffe und reguläres Ende nach 60 Sekunden. +- Echter Bär, Entry 42336 / Spawn 300425, Stufe 58: besiegt, native Respawnzeile + bestätigt. Tatsächlicher Gegentreffer löste den Selbstverteidigungsfokus aus. +- Expliziter Testtod 1: Freigabe um 1793138872, kein akzeptierter Rückweg; + volle 300 Sekunden tatsächlich abgewartet. Friedhofswiederbelebung, anschließend + lebend gespeichert, keine Leiche oder Wiederbelebungskrankheit. +- Expliziter Testtod 2 nahe Friedhof: tatsächlicher MMAP-Geisterlauf von 37,40 + Yards und native Leichenaufnahme. Danach HP 4340 gespeichert, keine Leiche + oder Aura 15007. Kein natürlicher Kampftod behauptet. +- Administrative Offline-Positionierung diente nur isolierten Tests; kein Beleg + für Fernanreise. Mehrfachangreifer und Neustart während Geisterlauf bislang + durch Produktionsadapter geprüft, nicht durch entsprechende Live-Abnahme. + +Arthoran ist wieder angemeldet, ohne Fernauftrag am letzten Rücklaufpunkt: +Karte 0, (-12187.9, -2603.74, 1.5777), Verwüstete Lande. Er reagiert auf Angreifer. +Normales Lua-Profil bytegleich restauriert; kein temporärer Override vorhanden. +Befehle: clientless combat train [seconds], attack , +testdeath , stop . Testdeath ist ein markierter nativer Diagnosetod +für einen verwalteten Bot ohne zusätzlichen Haltbarkeitsverlust. + +Belege: doc/clientless/checkpoints/2026-10-27-combat/. +Logs: /tmp/vcore-reactive-combat-build-final.log und +/tmp/vcore-reactive-tests-final.log. DB-Helfer: /tmp/vcore-instance-db.py, +Modus combat_live_state. Vorherige Ausrüstung/Reparatur-WIP bleibt erhalten. +Pfadfindungsüberarbeitung bleibt vertagt. Weitere Klassenrotationen und konkrete +Spieler-/Bot-Interaktionsregeln sind noch auszuarbeiten. + + +--- + +# Wiederaufnahme: Kampfausstattung und Reparaturtest + +Stand 27.10.2026: Branch feature/clientless-player-core, bestehende Änderungen +bewahren. Debug-Build erfolgreich, installiert; Testserver PID41714 frisch ready +und Port8085 bestätigt. 48 Testskripte bestanden. Kein Push oder neuer Commit. + +Arthoran26 trägt neun native Kopien von Allitester21 (Zorn und Ashkandi), +26/26 native Waffen-Talentpunkte Baum746, stufengerechte Klassenfähigkeiten. +Originalquelle unverändert, alte Ausrüstung in Taschen. Begrenzte Trainingsübung +an echter Puppe314147 erfolgreich: native angenommene Fähigkeiten protokolliert. +Phase169 ist Standard; keine Phasenänderungen. Ursprüngliches Problem war LOS. +Volle autonome Kampf-/Todes-/Interaktionssteuerung bleibt zukünftige Umsetzung. +Malusfreie Friedhofswiederbelebung nach5min ohne Pfad ist verbindliche Vorgabe. + +NEU: economy_repair, Migration08 bereits Test-DB angewendet. Liveprüfung läuft: +Arthoran ausgeloggt, nur seine neun Kopien auf halbe Haltbarkeit gesetzt, +Position administrativ an poi_tele_954 (Handelsdistrikt), wieder angemeldet. +Intent economy am Anker angekommen; Mikroaktion economy_repair gestartet, +echter Händler1287/GUID1879, native MMAP-Anreise. Abschluss bestätigt: ECONOMY_REPAIR_COMPLETE; nach saveall alle neun Kopien +wieder auf voller Haltbarkeit, Geld weiterhin0, Allitester unverändert. +Ausgangswerte /tmp/vcore-repair-before.tsv. +Normales Lua-Profil bytegleich restauriert, kein temporärer Override. + +Werkzeuge /tmp/vcore-instance-db.py gear_verify (prüft auch Allitester-Hash), +repair_test_prepare NICHT WIEDERHOLEN. Konsole saveall nach Reparaturabschluss, +ECONOMY_REPAIR_COMPLETE in Server.log prüfen. Gegebenenfalls Szene beobachten. +Build-/Testlogs /tmp/vcore-combat-preparation-{build,tests-final}.log. +Migration08 wird von *.sql ignoriert, gezielt force-add für Erhalt erforderlich. +Reparaturprüfung abgeschlossen; Arthoran darf den Wirtschaftsintent weiter ausführen. + +--- + +# Neuer Arbeitsbereich: Kampf, Reaktionen und Interaktionen + +Alle sieben Grundintents sind abgeschlossen und die bisherigen Änderungen +lokal committet (e18a416bf4). Der Nutzer hat die visuelle Instanzabnahme erlassen. +Jetzt Kampf, reaktives Verhalten, Bot-Spieler und Bot-Bot gemeinsam ausarbeiten. +Neue Regeln/Vorprüfung in `combat-reactions.md` dauerhaft festgehalten. + +Nutzer: Selbstverteidigung fokussiert erste tatsächlich schädigende Kreatur bis +zu deren Tod; weitere Treffer wechseln den Fokus nicht. Erstangriffe wählen +nächstgelegenes gültiges intentspezifisches Ziel: Abenteuer nahe Feindkreatur auf +aktuellem Weg; Beruf/Lederjagd nahes geeignetes Tier. Stärkecheck verhindert +leichtsinnige Angriffe auf deutlich stärkere Gegner. Exakte Grenzwerte noch offen. +Tod: Leichenrücklauf als Geist, native Wiederbelebungsreichweite; ohne Pfad nach +fünf Minuten Friedhofswiederbelebung. Native Handler/39-Yards-Radius vorhanden. +Timerherkunft und weitere Randfälle als Auslegung/Vorschlag markieren. +Arthoran bleibt mining/blacksmithing; keine stillschweigende skinning-Freigabe. +Sammeln weiterhin performativ, neu gewünschter Kampf real. + +PlayerAI.cpp gründlich geprüft. Viel vorhandene Klassen-/Zauberlogik, aber +Charm-Steuerung muss entkoppelt werden. Technisch nicht ungenutzt: Unit::UpdateCharmAI +und mehrere Bossskripte verwenden sie. Unit::DealDamage liefert UnitAI-Callbacks +und UnitScript-OnDamage VOR endgültigem HP-Abzug: tatsächlichen Erstschaden +sauber bestätigen, keine Nullschaden-/Godmode-Fokussierung. + +Noch keine Kampfimplementierung oder Livekampftests in dieser Vorprüfung. +Nur Dokumentation neu/uncommittet; keine weiteren Commits ohne konkreten Bedarf. +Details zu Flucht, Zielverlust, verbleibenden Angreifern, Erstangriffsschwellen +und konkreten Spieler-/Bot-Reaktionen noch nicht als Nutzerentscheidungen ausgeben. + +--- + # Aktuell: Instanzintent implementiert und technisch live geprüft Stand 27.10.2026, letzter Testserverprozess PID **35865**, frisches Ready beim diff --git a/doc/clientless/checkpoints/2026-10-27-combat/live-events.txt b/doc/clientless/checkpoints/2026-10-27-combat/live-events.txt new file mode 100644 index 0000000000..512f461841 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-27-combat/live-events.txt @@ -0,0 +1,60 @@ +TrinityCore rev. e18a416bf445+ 2026-10-27 20:10:13 +0100 (feature/clientless-player-core branch) (Unix, Debug, Static) (worldserver-daemon) ready... +COMBAT_START guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' training=1 defending=0 until=1793138792 +COMBAT_CAST guid=26 target='GUID Full: 0x000000000000001a Type: Player Low: 26' spell=6673 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=78 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=772 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=12294 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=1464 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=1464 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=1464 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=12294 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=78 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=772 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=7384 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=1464 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=7384 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=12294 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=1464 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=772 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=78 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=7384 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=12294 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=78 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=7384 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=772 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=78 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=7384 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=12294 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=7384 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=1464 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=772 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=78 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=12294 +COMBAT_CAST guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' spell=78 +COMBAT_END guid=26 target='GUID Full: 0xf1307f9a0000050b Type: Creature Entry: 32666 Low: 1291' training=1 +COMBAT_START guid=26 target='GUID Full: 0xf130a56000000b17 Type: Creature Entry: 42336 Low: 2839' training=0 defending=0 until=0 +COMBAT_END guid=26 target='GUID Full: 0xf130a56000000b17 Type: Creature Entry: 42336 Low: 2839' training=0 +COMBAT_START guid=26 target='GUID Full: 0xf130a56000000b17 Type: Creature Entry: 42336 Low: 2839' training=0 defending=1 until=0 +COMBAT_CAST guid=26 target='GUID Full: 0xf130a56000000b17 Type: Creature Entry: 42336 Low: 2839' spell=12294 +COMBAT_CAST guid=26 target='GUID Full: 0xf130a56000000b17 Type: Creature Entry: 42336 Low: 2839' spell=78 +COMBAT_CAST guid=26 target='GUID Full: 0xf130a56000000b17 Type: Creature Entry: 42336 Low: 2839' spell=772 +COMBAT_END guid=26 target='GUID Full: 0xf130a56000000b17 Type: Creature Entry: 42336 Low: 2839' training=0 +DEATH_TEST_REQUEST guid=26 +DEATH_RELEASE guid=26 +DEATH_CEMETERY_REVIVE guid=26 +COMBAT_START guid=26 target='GUID Full: 0xf130b03f00000ae8 Type: Creature Entry: 45119 Low: 2792' training=0 defending=1 until=0 +COMBAT_CAST guid=26 target='GUID Full: 0x000000000000001a Type: Player Low: 26' spell=6673 +COMBAT_CAST guid=26 target='GUID Full: 0xf130b03f00000ae8 Type: Creature Entry: 45119 Low: 2792' spell=78 +COMBAT_CAST guid=26 target='GUID Full: 0xf130b03f00000ae8 Type: Creature Entry: 45119 Low: 2792' spell=772 +COMBAT_CAST guid=26 target='GUID Full: 0xf130b03f00000ae8 Type: Creature Entry: 45119 Low: 2792' spell=7384 +COMBAT_CAST guid=26 target='GUID Full: 0xf130b03f00000ae8 Type: Creature Entry: 45119 Low: 2792' spell=12294 +COMBAT_END guid=26 target='GUID Full: 0xf130b03f00000ae8 Type: Creature Entry: 45119 Low: 2792' training=0 +COMBAT_START guid=26 target='GUID Full: 0xf130b03f00000ae7 Type: Creature Entry: 45119 Low: 2791' training=0 defending=1 until=0 +COMBAT_CAST guid=26 target='GUID Full: 0xf130b03f00000ae7 Type: Creature Entry: 45119 Low: 2791' spell=12294 +COMBAT_CAST guid=26 target='GUID Full: 0xf130b03f00000ae7 Type: Creature Entry: 45119 Low: 2791' spell=772 +COMBAT_CAST guid=26 target='GUID Full: 0xf130b03f00000ae7 Type: Creature Entry: 45119 Low: 2791' spell=7384 +COMBAT_END guid=26 target='GUID Full: 0xf130b03f00000ae7 Type: Creature Entry: 45119 Low: 2791' training=0 +DEATH_TEST_REQUEST guid=26 +DEATH_RELEASE guid=26 +DEATH_CORPSE_RUN guid=26 path=37.40 +DEATH_RECLAIM guid=26 diff --git a/doc/clientless/checkpoints/2026-10-27-combat/live-navigation.txt b/doc/clientless/checkpoints/2026-10-27-combat/live-navigation.txt new file mode 100644 index 0000000000..3d5f60c08e --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-27-combat/live-navigation.txt @@ -0,0 +1,16 @@ +2026-10-27_23:05:32 INFO [server.adventurebot.navigation] NAV_PLAN seq=1 guid=26 name='Arthoran' map=0 segment=0 start=(-8744.788 319.742 100.549) target=(-8745.762 312.810 100.206) type=0x1 calculated=1 complete=1 suspicious=0 points=3 direct=7.01 path=7.02 maxSegment=4.00 maxZDelta=0.31 finalDistance=0.00 repaths=0 reason='ok' +2026-10-27_23:05:32 INFO [server.adventurebot.navigation] NAV_EXEC seq=1 guid=26 name='Arthoran' segment=1 splinePoints=3 splineLength=7.07 segmentEnd=(-8745.762 312.810 100.206) target=(-8745.762 312.810 100.206) walk=0 +2026-10-27_23:05:32 INFO [server.adventurebot.navigation] NAV_START guid=26 name='Arthoran' map=0 target=(-8745.762 312.810 100.206) remaining=5.51 state=Moving +2026-10-27_23:05:33 DEBUG [server.adventurebot.navigation] NAV_SAMPLE seq=1 guid=26 segment=1 pos=(-8745.745 312.927 100.193) remaining=0.00 deviation=0.00 splineActive=1 repaths=0 +2026-10-27_23:06:54 INFO [server.adventurebot.navigation] NAV_PLAN seq=2 guid=26 name='Arthoran' map=0 segment=0 start=(-11831.100 -2510.480 -20.070) target=(-11841.850 -2510.480 -20.025) type=0x1 calculated=1 complete=1 suspicious=0 points=4 direct=10.75 path=10.79 maxSegment=4.03 maxZDelta=0.46 finalDistance=0.00 repaths=0 reason='ok' +2026-10-27_23:06:54 INFO [server.adventurebot.navigation] NAV_EXEC seq=2 guid=26 name='Arthoran' segment=1 splinePoints=4 splineLength=10.85 segmentEnd=(-11841.850 -2510.480 -20.025) target=(-11841.850 -2510.480 -20.025) walk=0 +2026-10-27_23:06:54 INFO [server.adventurebot.navigation] NAV_START guid=26 name='Arthoran' map=0 target=(-11841.850 -2510.480 -20.025) remaining=9.25 state=Moving +2026-10-27_23:06:55 DEBUG [server.adventurebot.navigation] NAV_SAMPLE seq=2 guid=26 segment=1 pos=(-11838.028 -2510.480 -19.686) remaining=2.34 deviation=0.00 splineActive=1 repaths=0 +2026-10-27_23:13:16 INFO [server.adventurebot.navigation] NAV_PLAN seq=3 guid=26 name='Arthoran' map=0 segment=0 start=(-12186.000 -2566.580 3.966) target=(-12187.900 -2603.740 1.578) type=0x1 calculated=1 complete=1 suspicious=0 points=11 direct=37.29 path=37.40 maxSegment=4.06 maxZDelta=0.68 finalDistance=0.00 repaths=0 reason='ok' +2026-10-27_23:13:16 INFO [server.adventurebot.navigation] NAV_EXEC seq=3 guid=26 name='Arthoran' segment=1 splinePoints=11 splineLength=37.40 segmentEnd=(-12187.900 -2603.740 1.578) target=(-12187.900 -2603.740 1.578) walk=0 +2026-10-27_23:13:16 INFO [server.adventurebot.navigation] NAV_START guid=26 name='Arthoran' map=0 target=(-12187.900 -2603.740 1.578) remaining=35.79 state=Moving +2026-10-27_23:13:17 DEBUG [server.adventurebot.navigation] NAV_SAMPLE seq=3 guid=26 segment=1 pos=(-12186.536 -2577.073 3.698) remaining=25.29 deviation=0.00 splineActive=1 repaths=0 +2026-10-27_23:13:18 DEBUG [server.adventurebot.navigation] NAV_SAMPLE seq=3 guid=26 segment=1 pos=(-12187.064 -2587.424 2.146) remaining=14.85 deviation=0.00 splineActive=1 repaths=0 +2026-10-27_23:13:19 DEBUG [server.adventurebot.navigation] NAV_SAMPLE seq=3 guid=26 segment=1 pos=(-12187.603 -2597.920 1.355) remaining=4.33 deviation=0.00 splineActive=1 repaths=0 +2026-10-27_23:13:20 DEBUG [server.adventurebot.navigation] NAV_SAMPLE seq=3 guid=26 segment=1 pos=(-12187.900 -2603.740 1.578) remaining=0.00 deviation=0.00 splineActive=0 repaths=0 +2026-10-27_23:13:20 INFO [server.adventurebot.navigation] NAV_COMPLETE seq=3 guid=26 name='Arthoran' remaining=0.00 repaths=0 segments=1 maxDeviation=0.00 diff --git a/doc/clientless/checkpoints/2026-10-27-combat/validation.txt b/doc/clientless/checkpoints/2026-10-27-combat/validation.txt new file mode 100644 index 0000000000..6f0da06b2a --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-27-combat/validation.txt @@ -0,0 +1,8 @@ +Final Debug build -j10 and installation succeeded. +51 tests/clientless/test_*.py scripts passed. +Live native training: 60 seconds, valid real dummy, normal MMAP approach and regular end. +Real combat: spawn300425 entry42336 level58; native hostile/LOS/phase/strength/path checks; creature killed with native respawn record. +Death fixture1: explicit native testdeath, normal release at1793138872; no acceptable MMAP route; no accelerated clock; cemetery resurrection after1793139172 without sickness. +Death fixture2: explicit native testdeath at nearby native ground point; ghost path37.40 yards; native reclaim; saved aliveHP4340, no corpse or sickness. +Administrative offline positioning used for isolated tests, not evidence of long-distance travel. +Native restarts during a ghost run and multi-attacker focus were exercised by production adapter tests, not live multi-actor gameplay. diff --git a/doc/clientless/combat-reactions.md b/doc/clientless/combat-reactions.md new file mode 100644 index 0000000000..d9dd96b0a9 --- /dev/null +++ b/doc/clientless/combat-reactions.md @@ -0,0 +1,189 @@ +# Kampf, Reaktionen und Interaktionen – vereinbarte Grundlage + +Stand 27.10.2026. Neue Nutzervorgaben nach Abschluss der sieben Grundintents. +Historische Vereinbarungen und aktueller Umsetzungsstand; siehe Nachtrag unten. +Kampf, reaktives Verhalten, Bot-Spieler- und Bot-Bot-Interaktion gehören für den +Nutzer zusammen. Bestehende Intent-, Reise- und Szeneninfrastruktur erhalten; +DungeonBots bleiben unabhängig. Allgemeine Hinderniserkennung bleibt vertagt. + +## Verbindliche Zielregeln + +- Selbstverteidigung: die Kreatur, die zuerst tatsächlich Schaden am Bot + verursacht, wird Fokusziel. Weitere Angreifer verdrängen sie nicht. Fokus bis + zum Tod dieser Kreatur beibehalten. Ein zufälliger Zielwechsel ist unerwünscht. +- Proaktiver Angriff: nächstgelegenes gültiges und zum aktuellen Intent passendes + Ziel wählen. Die Intentregeln bestimmen zuerst die zulässige Zielmenge. +- Abenteuer: nahe feindliche Kreaturen auf dem aktuellen Weg berücksichtigen; + keine wahllose Jagd auf alles im allgemeinen Umkreis. +- Beruf/Lederjagd: ein nahes geeignetes Tier wählen. Die bestehende Freigabe des + betreffenden Hauptberufs im Lua-Profil gilt weiter. Arthoran hat derzeit nur + mining und blacksmithing; diese Absprache erteilt ihm nicht automatisch + skinning. Das tatsächliche Sammeln/Kürschnern bleibt gemäß bisheriger Regel + performativ; der neu beauftragte Kampf selbst ist real. +- Vor einem eigenständigen Angriff Eignung/Stärke prüfen, insbesondere deutlich + überlegene Gegner ausschließen. Exakte Level-, Distanz-, Gesundheits- und + Ressourcen-Grenzen sind noch nicht vom Nutzer festgelegt. + +Noch auszugestalten: Verhalten, wenn ein Fokusziel despawnt, phasen-/kartenfremd +oder dauerhaft unerreichbar wird; Selbstverteidigung gegen überlegene Gegner; +weitere verbleibende Angreifer nach Tod des Erstangreifers. Nicht als bereits +vereinbarte Zielwechsel-/Fluchtregeln ausgeben. + +## Vom Nutzer bestätigte Ergänzungen + +- Bei Berufsarbeit einen nahen feindlichen Gegner berücksichtigen, der den + unmittelbaren Zugang zur konkret gewählten Ressource/Arbeitsstelle blockiert. +- Konservativer Erstangriffsfilter: native Angriffserlaubnis, lebendig, richtige + Phase, lokaler erreichbarer Kampfplatz, kein Weltboss; Elites zunächst meiden. + Zusätzlich Leveldifferenz, aktuelle HP/Ressourcen und nahe weitere Gegner + berücksichtigen. Grenzwerte als anpassbare Regeln führen. +- Geselligkeit, Ruhe, Freizeit, Wirtschaften und der abstrahierte Instanzaufenthalt + begründen von sich aus keine Jagd. Selbstverteidigung ist eine überlagernde + Reaktion und benötigt keinen Wechsel zum Abenteuerintent. + +## Verbindlicher Todesablauf + +Seele freigeben, als Geist vom Friedhof zurück zur eigenen Leiche laufen und +sich wiederbeleben, sobald native Reichweite und übrige native Bedingungen +vorliegen. Ist kein Pfad verfügbar, ist nach fünf Minuten die Wiederbelebung +am Friedhof erlaubt. Ein langsamer, aber funktionierender Leichenrücklauf ist +nicht allein wegen Überschreitens von fünf Minuten ein fehlender Pfad. + +Technische Ableitung: nach erreichbaren Punkten innerhalb des erlaubten +Leichenradius suchen, nicht nur nach einem Pfad zur exakten Leichenposition. +Die fünf Minuten als absolute Frist ab Freigabe/Ankunft am Friedhof behandeln, +nicht bei Pfadversuchen oder Neustart neu beginnen. Die genaue Timerauslegung +ist eine Umsetzungsauslegung des Friedhof-Fallbacks, keine zusätzliche explizite +Antwort des Nutzers. Nach Wiederbelebung ausstehendes Verhalten sicher fortsetzen; +keinen Endzeitblock durch die Todesphase künstlich verlängern. + +## Bestätigte Quellgrundlagen + +- `AI/PlayerAI/PlayerAI.cpp` und `.h`: gemeinsame PlayerAI-Helfer für Rollen, + bekannte Zauberränge, native Wirkprüfung, Autoangriffe und Formen. Die große + Klassen-/Spezialisierungsauswahl liegt derzeit privat in SimpleCharmedPlayerAI. + Sie enthält situative Unterbrechungen, DoTs, Kombopunkte und Schutzfähigkeiten. +- SimpleCharmedPlayerAI ist technisch eine Charm-KI mit Charmer-Zielwahl und + Unterstützung dieses Charmers. Der Core installiert sie in Unit::UpdateCharmAI; + mehrere Bossskripte leiten davon ab. Also nicht als völlig ungenutzt behandeln. + Wiederverwendung erfordert Entkopplung dieser konkreten Charm-Steuerung; + keine Unterstellung, der Nutzer wolle sie unverändert übernehmen. +- Unit::DealDamage ruft DamageTaken/DamageDealt bei vorhandener UnitAI sowie + den UnitScript-OnDamage-Hook auf. Diese Aufrufe liegen VOR dem abschließenden + HP-Abzug, und nachfolgende Bedingungen können Schaden verhindern. Für die + Regel 'zuerst tatsächlich Schaden verursacht' genügt ein ungeprüftes + Vorab-Ereignis daher nicht. Nullschaden/Godmode/abgebrochene Treffer dürfen + keinen falschen Erstangreifer festschreiben. +- WorldSession::HandleRepopRequestOpcode führt BuildPlayerRepop und + RepopAtGraveyard aus. HandleReclaimCorpseOpcode prüft Geiststatus, + GetCorpseReclaimDelay, gleiche Karte und CORPSE_RECLAIM_RADIUS (aktuell 39 Yards), + dann native Wiederbelebung und Corpse-Bones. Keine eigene großzügigere Distanz. +- Der Nutzer lehnt den normalen Geistheiler ausdrücklich ab: beim Fallback + nach fünf Minuten keine Wiederbelebungskrankheit und kein zusätzlicher + Haltbarkeitsverlust. Nicht HandleSpiritHealerActivateOpcode/SendSpiritResurrect + verwenden. Die native ResurrectPlayer-Funktion ohne Sickness und die saubere + Corpse-Bones-/Geistzustandsbereinigung können dafür wiederverwendet werden. + Der normale Leichenrücklauf bleibt zuerst vorgesehen. +- PlayerScript bietet Chat- und gerichtete Textemote-Ereignisse. Vorhandene + AdventureSocialActions stellen bereits Begrüßung, Gespräch und Reaktionen dar. + Konkrete Regeln für Spieler-/Bot-Interaktionen sind noch weiter auszugestalten. + +In dieser Vorprüfung wurden keine Kampf-/Todesabläufe implementiert oder live +getestet und keine Server-/DB-Zustände verändert. + + +## Nachtrag: Reparaturbesuch und Kampfausstattung + +Nutzerauftrag: zusätzliche Wirtschafts-Mikroaktion bei einem echten Händler mit +Reparaturfähigkeit. Sichtbare Szene rein performativ, keine Händler-/Kauf-/ +Reparaturpakete, kein Geldabzug. Nach erfolgreich abgeschlossenem Besuch repariert +Hintergrundlogik alle Ausrüstungsgegenstände auf volle Haltbarkeit. Dies ist die +explizit gewünschte Ausnahme von der bisherigen reinen Darstellung wirtschaftlicher +Besuche; keine Reparaturkosten-/Zustandsplanung nötig. + +Arthoran soll Kopien von Allitesters angelegter Rüstung und Waffen erhalten, +alle stufengerechten Klassenfähigkeiten lernen, Talentpunkte sinnvoll innerhalb +der Stufe-60-Grenze verteilen und passende Angriffe vorführen. Allitesters +Originale bleiben unverändert. Beide sind Krieger; Vorprüfung ergab Zorn-Set und +Ashkandi, passend zu einem Waffen-Build. Quelle ist Stufe70, Arthoran bleibt60. +Die native Level-60-Talenttabelle erlaubt 26 Punkte, keine überhöhte Freischaltung +aller Talentbäume. Kampfübung an einer tatsächlich vorhandenen Stufe-60-Puppe; +damit wird noch keine vollständige autonome Kampf-/Reaktions-KI behauptet. + +## Technischer Umsetzungsstand nach Kampfausstattung + +Reparaturaktion implementiert; native IsArmorer-Prüfung, erreichbare freundliche +NPCs und bestehende Ankunfts-/Szenenprüfung. Einmal nach erfolgreichem Besuch +DurabilityRepairAll(false, 1.0f, false), ohne Kosten oder Interaktionspakete. + +Arthoran erhielt neun neue Iteminstanzen aus Allitesters angelegtem Zorn-Set +und Ashkandi. Frühere Gegenstände bleiben im Inventar; Allitesters vollständiger +Inventar-/Item-/Zauber-/Talentsnapshot blieb unverändert. Native Klassen- und +Stufenprüfung, Plattenkompetenz und Haltungen sowie 26 Waffen-Talentpunkte +über LearnTalent, Baum 746. Keine fremden Spezialisierungsfähigkeiten freischalten. + +Begrenzte Trainingsübung über PlayerAI-Helfer; nur tatsächliche passende +Trainingspuppen, native Sicht-/Phasen-/Angriffserlaubnis. Keine Phasenänderung. +Phase169 ist DEFAULT_PHASE; die erste Fehlposition hatte fehlende Sichtlinie. +Nach administrativer Offline-Positionierung vor der echten Puppe314147 waren +Phase, Sichtbarkeit und Sichtlinie gültig. Live wurden unter anderem Heroischer +Stoß78, Verwunden772, Tödlicher Stoß12294 und Überwältigen7384 von Spell::prepare +mit SPELL_CAST_OK(255) angenommen. Das belegt die begrenzte Übung, keine +fertige autonome Selbstverteidigungs-, Todes- oder Interaktionssteuerung. + +Debug-Build erfolgreich installiert; 48 Testskripte erfolgreich. + +Live-Reparatur erfolgreich: reguläre lokale MMAP-Anreise zu Marda Weller1287, +vollständige Szene und ECONOMY_REPAIR_COMPLETE. Nach saveall alle neun zuvor +halbierten Kopien wieder voll, Geld0 und Allitesters Snapshot unverändert. + +## Ausbau: Training, Erstangriffe und Todesreaktion + +AdventureCombatMgr überlagert den Lua-Adventure-Controller während Kampf/Tod. +Generischer ClientlessPlayerMgr übergibt ausschließlich tatsächlich nach HP-Abzug +entstandene Schäden als GUID-Ereignisse. Mutex-Queue trennt Map-Worker und +World-Thread; andere Controller bleiben bei ihrer bisherigen Standardfunktion. +Der erste tatsächliche Kreaturenangreifer wird festgehalten. Weitere Angreifer +warten in Schadensreihenfolge, bis der Fokus tot ist. Phasenverlust/Despawn bzw. +20 Sekunden unerreichbarer Fokus beenden eine nicht mehr ausführbare Verfolgung. +Spieler und von Spielern kontrollierte Kreaturen sind nicht Gegenstand dieses Ausbaus. + +Automatischer Erstangriff im Abenteuer nur auf nächstgelegenen passenden Gegner +im lokalen20Yards-Umkreis und8Yards-Korridor des tatsächlichen aktiven MMAP-Wegs. +Für Kürschnerei nur bei Profilrecht und lebendem BEAST mit nativer SkinLootId; +Arthoran erhält dieses Recht weiterhin nicht. Andere Intents lösen keine Jagd aus. +Vorläufiger konservativer Filter: höchstens+2Level, mindestens60%HP und bei +Manaklassen30%Mana, keine Elites/Weltbosse, höchstens ein Gegner im12Yards-Umfeld, +kein bereits fremd bekämpfter Gegner. Native Phase/Sicht/Angriffserlaubnis und +vollständiger unverdächtiger MMAP-Anschluss bleiben Voraussetzung. + +Kampf nutzt native Autoangriffe und für Krieger die stufengelernten Fähigkeiten, +Ressourcen und Abklingzeiten. Keine künstlichen Ressourcen-/Schadenswerte. +Andere Klassen erhalten hier zunächst Autoangriffe; eigene Rotationen folgen. +Training ist Freizeit-Aktion leisure_training60–180Sekunden an echter erreichbarer +Puppe im60Yards-Umkreis, begrenzt zusätzlich durch aktuelle Intentfrist. +Echte Selbstverteidigung kann Training unterbrechen. Keine Dummy-Spawns/Phasenänderungen. + +Tod über native Freigabe- und Leichenaufnahmehandler. Persistierter GhostTime +bestimmt sowohl native Aufnahmefrist als auch Fünf-Minuten-Fallback; Charakter +und Leichenzeitpunkt werden nach Freigabe gespeichert. Mehrere mögliche Ziele +innerhalb39Yards werden geprüft. Gescheiterte Bewegungspunkte werden für diesen +Leichenlauf gemieden. Bei funktionierendem Pfad keine pauschale Zeitbegrenzung. +Fallback: erst nach300Sekunden ohne passenden Pfad native Friedhofsrückkehr, +danach ResurrectPlayer(.5,false) und SpawnCorpseBones, keine Geistheiler-Interaktion, +keine Wiederbelebungskrankheit oder zusätzliche Haltbarkeitsänderung. Normale +Kampfschäden bleiben erhalten. Intent-Endzeiten werden durch Reaktionen nicht verlängert. + +Diagnose: clientless combat train [seconds], attack , +testdeath , stop . Testdeath nutzt ausdrücklich einen nativen Testtod +für einen verwalteten Bot, ohne zusätzlichen Haltbarkeitsverlust. +51 Testskripte bestanden; Live-Ergebnisse separat ergänzen. + +Live-Abschluss: Trainingsszene regulär nach60Sekunden beendet; Stufe58-Bär42336 +(spawn300425) nativ besiegt und Respawn registriert. Erster Testtod: mangels +akzeptiertem MMAP-Rückweg volle300Sekunden gewartet, malusfreie Friedhofs- +wiederbelebung bestätigt. Zweiter Testtod naheFriedhof: tatsächlicher37.40Yards- +Geisterlauf und nativeLeichenaufnahme. NachLogout lebendHP4340 ohneLeiche oder +Aura15007 gespeichert. KeineUhrabkürzung, keinNormalreisebeleg durchTestpositionierung. +Belege: checkpoints/2026-10-27-combat/. Mehrfachangreifer undNeustart während +Leichenlauf produktiv mitAdaptern geprüft, nochkeine entsprechendeLiveabnahme. diff --git a/doc/clientless/economy-intent.md b/doc/clientless/economy-intent.md index a43ce17d3f..3a57c5d96e 100644 --- a/doc/clientless/economy-intent.md +++ b/doc/clientless/economy-intent.md @@ -11,6 +11,7 @@ Transaktionshandler aufgerufen und keine wirtschaftlichen Fenster simuliert. | Aktion | Ablauf | |---|---| +| `economy_repair` | Einen echten reparaturfähigen Händler aufsuchen; nach abgeschlossener Szene sämtliche Ausrüstung kostenlos nativ reparieren. Keine Händlerinteraktion. | | `economy_vendor_visit` | Einen freundlichen erreichbaren Händler aufsuchen, kurz umsehen, verabschieden und zurücktreten. | | `economy_goods` | Beim Händler länger Waren betrachten. Die Darstellung richtet sich zum tatsächlichen Händler; eine passende Auslage ist keine Voraussetzung. | | `economy_trade` | Begrüßen, Handelsgespräch darstellen, zuhören, nicken und verabschieden. | @@ -160,3 +161,7 @@ Die anschließende Abenteuer-Migration überführt die allgemeinen Wirtschafts- gewichte in ausdrückliche Location-/Intent-Katalogeinträge. Ein im Code versteckter Kategorie-Fallback entfällt damit; vorhandene angepasste Gewichte einschließlich Null behalten Vorrang. + +Ergänzung: `2026_10_27_08_adventure_repair.sql` ergänzt den Reparaturbesuch. +Die vollständige Haltbarkeitswiederherstellung ist die ausdrücklich gewünschte +Ausnahme von der rein performativen Wirtschaftsdarstellung. diff --git a/doc/clientless/leisure-intent.md b/doc/clientless/leisure-intent.md index bc5465eb88..6824d8a916 100644 --- a/doc/clientless/leisure-intent.md +++ b/doc/clientless/leisure-intent.md @@ -11,6 +11,7 @@ auswählen (Standard 0,5); vorhandene gespeicherte Tagespläne bleiben bestehen. | Schlüssel | Ablauf | |---|---| +| `leisure_training` | Eine echte sichtbare und erreichbare Trainingspuppe im lokalen Umkreis aufsuchen und 60–180 Sekunden mit nativen Angriffen trainieren. | | `leisure_walk` | Zusammenhängender MMAP-Hinweg zu einem entfernten Außenpunkt, danach dieselben Wegpunkte zurück; Gehmodus und kurze Pause am Wendepunkt. | | `leisure_roam` | Eine lokale Spazierstrecke auswählen, vollständig verfolgen und auf demselben Weg zurückkehren. | | `leisure_pet_walk` | Eigenen Begleiter beschwören, gegebenenfalls anleinen, eine zusammenhängende Strecke entspannt hin und zurück gehen, zurückkehren und eigene Begleiterressourcen aufräumen. | @@ -281,3 +282,8 @@ Die abschließende visuelle Beurteilung erfolgt im Spiel. Der Nutzer hat anschließend sowohl die zusammenhängenden Fußspaziergänge als auch das flüssige Haustierausführen nach der Tempokorrektur visuell bestätigt. Schlafdarstellung, Ausritte, Freizeitflüge und Angeln sind dadurch nicht abgenommen. + +Training ergänzt Migration `2026_10_27_09_adventure_training.sql`. Es nutzt +die Kampfreaktion, sucht höchstens 60 Yards lokal und respektiert das Ende des +aktuellen Zeitblocks. Echte Angreifer unterbrechen das Training zugunsten der +Selbstverteidigung. Kein Dummy-Spawn, Phasenwechsel oder künstlicher Schaden. diff --git a/sql/custom/world/2026_10_27_08_adventure_repair.sql b/sql/custom/world/2026_10_27_08_adventure_repair.sql new file mode 100644 index 0000000000..5559809e98 --- /dev/null +++ b/sql/custom/world/2026_10_27_08_adventure_repair.sql @@ -0,0 +1,7 @@ +-- A real native repairer is required. Only the scene is acted; completion +-- restores durability for free without invoking trade/vendor handlers. +INSERT IGNORE INTO vcore_adventure_action + (action_key,kind,min_seconds,max_seconds,requirement,emote_id) + VALUES ('economy_repair','social',25,45,'repair',0); +INSERT IGNORE INTO vcore_adventure_action_intent(action_key,intent,weight) + VALUES ('economy_repair','economy',2); diff --git a/sql/custom/world/2026_10_27_09_adventure_training.sql b/sql/custom/world/2026_10_27_09_adventure_training.sql new file mode 100644 index 0000000000..8367461dac --- /dev/null +++ b/sql/custom/world/2026_10_27_09_adventure_training.sql @@ -0,0 +1,6 @@ +-- Training is a leisure scene, only at a real, visible, reachable training dummy. +INSERT IGNORE INTO vcore_adventure_action + (action_key,kind,min_seconds,max_seconds,requirement,emote_id) + VALUES ('leisure_training','leisure',60,180,'training',0); +INSERT IGNORE INTO vcore_adventure_action_intent(action_key,intent,weight) + VALUES ('leisure_training','leisure',2); diff --git a/src/server/game/Entities/Unit/Unit.cpp b/src/server/game/Entities/Unit/Unit.cpp index 33d7df4fad..9b3eeb0e25 100644 --- a/src/server/game/Entities/Unit/Unit.cpp +++ b/src/server/game/Entities/Unit/Unit.cpp @@ -67,6 +67,7 @@ #include "PetAI.h" #include "PhasingHandler.h" #include "Player.h" +#include "ClientlessPlayerMgr.h" #include "PlayerAI.h" #include "QuestDef.h" #include "ReputationMgr.h" @@ -902,6 +903,8 @@ bool Unit::HasBreakableByDamageCrowdControlAura(Unit* excludeCasterChannel) cons victim->ToPlayer()->UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_TOTAL_DAMAGE_RECEIVED, damage); victim->ModifyHealth(-(int32)damage); + if (damage && victim->IsPlayer()) + sClientlessPlayerMgr->DamageTaken(victim->ToPlayer(), attacker, damage); if (damagetype == DIRECT_DAMAGE || damagetype == SPELL_DIRECT_DAMAGE) victim->RemoveAurasWithInterruptFlags(SpellAuraInterruptFlags::NonPeriodicDamage, spellProto); diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index 0480874fb3..5de1908138 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -1,4 +1,5 @@ #include "AdventureBehaviorMgr.h" +#include "AdventureCombatMgr.h" #include "AdventureLocationMgr.h" #include "AdventureEconomyPolicy.h" #include "AdventureRestSpace.h" @@ -556,6 +557,7 @@ bool AdventureBehaviorMgr::LeisureAvailable(Player* p,Record const& r,AdventureM auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); auto location=sAdventureLocationMgr->Find(r.Location); if (!profile || !location) return false; + if (a.Requirement=="training") return sAdventureCombatMgr->TrainingAvailable(p); if (a.Requirement=="fish") { auto rod=p->GetWeaponForAttack(BASE_ATTACK,true); @@ -640,6 +642,8 @@ bool AdventureBehaviorMgr::PlanLeisureWalk(Player* p, Record& r) bool AdventureBehaviorMgr::StartLeisure(Player* p,Record& r,AdventureMicroAction const& a) { if (!LeisureAvailable(p,r,a)) return false; + if (a.Requirement=="training") + return sAdventureCombatMgr->Train(p,r.MicroTimer,r.Deadline); sClientlessLuaEngine->DismountGroundMount(p,"leisure-scene-start"); auto& s=r.Leisure;s={};s.Kind=a.Requirement;s.Origin.Relocate(p); if (!p->IsOutdoors() && (s.Kind=="walk" || s.Kind=="roam" || s.Kind=="pet_walk")) @@ -1739,3 +1743,23 @@ bool AdventureBehaviorMgr::UpdateInstance(Player* player, Record& r, time_t now) } return true; } + +void AdventureBehaviorMgr::InterruptForCombat(Player* player) +{ + auto it=_records.find(player->GetGUID().GetCounter()); + if(it==_records.end()) return; + auto& r=it->second; + ResetMicro(player,r); + sClientlessPlayerMgr->ClearQueuedActions(player->GetGUID().GetCounter()); + sClientlessActionExecutorMgr->Clear(player->GetGUID().GetCounter()); + sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter()); + if(!r.Instance.Active) {r.State=Phase::Idle;r.Dirty=true;r.Queued=false;} + r.Detail="combat/death reaction; original deadline retained"; +} +std::string AdventureBehaviorMgr::CombatIntent(Player* player) const +{ + auto it=_records.find(player->GetGUID().GetCounter()); + if(it==_records.end() || !it->second.Enabled || it->second.Instance.Active || + (it->second.State!=Phase::Travel && it->second.State!=Phase::OnSite)) return {}; + return it->second.Intent; +} diff --git a/src/server/game/World/AdventureBehaviorMgr.h b/src/server/game/World/AdventureBehaviorMgr.h index 2aab7494a5..45ed8001e6 100644 --- a/src/server/game/World/AdventureBehaviorMgr.h +++ b/src/server/game/World/AdventureBehaviorMgr.h @@ -29,6 +29,8 @@ public: void IntentChanged(Player* player, ClientlessIntent const* intent); void Update(Player* player, uint32 diff); bool Command(ChatHandler* handler, char const* args); + void InterruptForCombat(Player* player); + std::string CombatIntent(Player* player) const; private: enum class Phase {Idle, Travel, OnSite, Switching, Handover}; struct RecoveryScene diff --git a/src/server/game/World/AdventureCombatMgr.cpp b/src/server/game/World/AdventureCombatMgr.cpp new file mode 100644 index 0000000000..8f51eb3916 --- /dev/null +++ b/src/server/game/World/AdventureCombatMgr.cpp @@ -0,0 +1,287 @@ +#include "AdventureCombatMgr.h" +#include "AdventureCombatPolicy.h" +#include "AdventureBehaviorMgr.h" +#include "ClientlessLuaEngine.h" +#include "ClientlessPlayerMgr.h" +#include "ClientlessActionExecutorMgr.h" +#include "ClientlessTravelMgr.h" +#include "Creature.h" +#include "Corpse.h" +#include "Player.h" +#include "PlayerAI.h" +#include "ObjectAccessor.h" +#include "MotionMaster.h" +#include "MoveSpline.h" +#include "Map.h" +#include "Spell.h" +#include "SpellAuras.h" +#include "WorldSession.h" +#include "WorldPacket.h" +#include "Opcodes.h" +#include "GameTime.h" +#include "Log.h" +#include +#include + +// Native player spell checks, cooldowns, rage and autoattacks. No charmer/fake AI +// installation and no generated resources or synthetic damage. +class AdventureCombatMgr::Fighter : public PlayerAI +{ +public: + explicit Fighter(Player* player):PlayerAI(player) { } + void UpdateAI(uint32) override { } + void Tick(Creature* target,uint32 diff) + { + _rend=_rend>diff?_rend-diff:0;_filler=_filler>diff?_filler-diff:0; + me->SetFacingToObject(target); + if(me->HasUnitState(UNIT_STATE_CASTING))return; + if(me->getClass()==CLASS_WARRIOR && !me->HasAura(2457) && Cast(2457,me))return; + if(!me->IsWithinMeleeRange(target))return; // MMAP approach is owned by manager. + if(me->GetVictim()!=target)me->Attack(target,true); + DoAutoAttackIfReady(); + if(me->getClass()!=CLASS_WARRIOR)return; + if(!me->HasAura(6673) && Cast(6673,me))return; + if(Cast(12294,target))return; + if(!_rend && !target->GetAuraApplicationOfRankedSpell(772,me->GetGUID()) && Cast(772,target)) + {_rend=12000;return;} + if(!_filler)for(uint32 id:{7384u,1464u,78u})if(Cast(id,target)){_filler=3000;break;} + } +private: + bool Cast(uint32 id,Unit* target) + { + auto cast=VerifySpellCast(id,target);if(!cast)return false; + uint32 actual=cast.first->GetSpellInfo()->Id; + SpellCastTargets targets;targets.SetUnitTarget(cast.second); + auto result=cast.first->prepare(targets); + if(result==SPELL_CAST_OK)TC_LOG_INFO("server.adventurebot","COMBAT_CAST guid=%u target='%s' spell=%u",me->GetGUID().GetCounter(),target->GetGUID().ToString().c_str(),actual); + return result==SPELL_CAST_OK; + } + uint32 _rend=0,_filler=0; +}; +AdventureCombatMgr* AdventureCombatMgr::instance(){static AdventureCombatMgr mgr;return &mgr;} +bool AdventureCombatMgr::Valid(Player* p,Creature* c,bool training) const +{ + return c && c->IsAlive() && c->IsInWorld() && p->IsInPhase(c) && p->CanSeeOrDetect(c) && + p->IsValidAttackTarget(c) && !c->GetControllingPlayer() && !c->IsEvadingAttacks() && + (training?(c->GetScriptName()=="npc_training_dummy" && c->getLevel()<=p->getLevel()+2 && !c->isWorldBoss()):c->GetScriptName()!="npc_training_dummy"); +} +bool AdventureCombatMgr::Reachable(Player* p,Creature* c) const +{ + if(!p->IsWithinLOSInMap(c))return false; + if(p->IsWithinMeleeRange(c))return true; + Position end;c->GetNearPoint(p,end.m_positionX,end.m_positionY,end.m_positionZ,0.5f,c->GetAngle(p)); + ClientlessPathDiagnostics path; + return sClientlessTravelMgr->AnalyzePath(p,end,path) && path.Complete && !path.Suspicious && path.PathLength<=100; +} +bool AdventureCombatMgr::Safe(Player* p,Creature* c) const +{ + if(!Valid(p,c,false) || p->GetExactDist(c)>40 || (c->GetVictim() && c->GetVictim()!=p))return false; + std::vector others;p->GetCreatureListWithEntryInGrid(others,0,35); + unsigned hostiles=0; + for(auto other:others)if(other->IsAlive() && p->IsInPhase(other) && p->IsHostileTo(other) && + other->GetExactDist(c)<12 && !other->GetControllingPlayer())++hostiles; + // Include neutral quarry itself in the risk count. + if(!p->IsHostileTo(c))++hostiles; + auto maxMana=p->GetMaxPower(POWER_MANA); + return AdventureCombatPolicy::SafeFirstAttack(p->getLevel(),c->getLevel(),p->GetHealthPct(), + c->isElite(),c->isWorldBoss(),hostiles,p->GetPowerType()==POWER_MANA, + maxMana?100.0f*p->GetPower(POWER_MANA)/maxMana:0) && Reachable(p,c); +} +Creature* AdventureCombatMgr::Dummy(Player* p) const +{ + if(!p->IsAlive() || p->IsInCombat() || p->GetTransport() || p->IsInFlight() || p->IsBeingTeleported())return nullptr; + Creature* best=nullptr;std::vector all;p->GetCreatureListWithEntryInGrid(all,0,60); + for(auto c:all)if(Valid(p,c,true) && (!best || p->GetExactDist(c)GetExactDist(best)) && Reachable(p,c))best=c; + return best; +} +bool AdventureCombatMgr::TrainingAvailable(Player* p) const{return Dummy(p)!=nullptr;} +bool AdventureCombatMgr::Start(Player* p,Record& r,Creature* c,bool training,bool defending,time_t until) +{ + if(!Valid(p,c,training) || !p->IsAlive() || p->IsBeingTeleported() || p->GetTransport() || p->IsInFlight() || p->IsCharmed())return false; + sAdventureBehaviorMgr->InterruptForCombat(p); + sClientlessLuaEngine->DismountGroundMount(p,"combat-start"); + p->AttackStop();p->CastStop();p->StopMoving();p->GetMotionMaster()->Clear();p->SetStandState(UNIT_STAND_STATE_STAND); + r.AI=std::make_shared(p);r.Target=c->GetGUID();r.Training=training;r.Defending=defending; + r.Suspended=true;r.Until=until;r.Timer=0;r.UnreachableMs=0; + p->SetSelection(c->GetGUID()); + TC_LOG_INFO("server.adventurebot","COMBAT_START guid=%u target='%s' training=%u defending=%u until=%llu",p->GetGUID().GetCounter(),c->GetGUID().ToString().c_str(),training,defending,static_cast(until)); + return true; +} +bool AdventureCombatMgr::Train(Player* p,uint32 durationMs,time_t deadline) +{ + auto c=Dummy(p);if(!c)return false; + auto now=GameTime::GetGameTime();auto until=now+std::max(10u,std::min(durationMs/1000,180u)); + if(deadline && deadlineGetGUID().GetCounter()],c,true,false,until); +} +bool AdventureCombatMgr::Attack(Player* p,uint32 spawn) +{ + auto c=p->GetMap()->GetCreatureBySpawnId(spawn); + return !p->IsInCombat() && Safe(p,c) && Start(p,_records[p->GetGUID().GetCounter()],c,false,false,0); +} +void AdventureCombatMgr::DamageTaken(Player* p,Unit* attacker,uint32 damage) +{ + auto c=attacker?attacker->ToCreature():nullptr; + if(!damage || !p->IsAlive() || !c || c->GetControllingPlayer())return; + auto& r=_records[p->GetGUID().GetCounter()]; + if(std::find(r.Damagers.begin(),r.Damagers.end(),c->GetGUID())==r.Damagers.end())r.Damagers.push_back(c->GetGUID()); +} +void AdventureCombatMgr::Finish(Player* p,Record& r) +{ + TC_LOG_INFO("server.adventurebot","COMBAT_END guid=%u target='%s' training=%u",p->GetGUID().GetCounter(),r.Target.ToString().c_str(),r.Training); + bool training=r.Training; + p->AttackStop();p->CastStop();p->StopMoving();p->GetMotionMaster()->Clear();p->SetSelection(ObjectGuid::Empty); + sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); + if(training && std::all_of(p->getAttackers().begin(),p->getAttackers().end(),[](Unit* u) + {auto c=u->ToCreature();return c && c->GetScriptName()=="npc_training_dummy";}))p->CombatStop(true); // Only the bounded dummy scene may end its own combat. + r.AI.reset();r.Target.Clear();r.Training=false;r.Defending=false;r.UnreachableMs=0;r.Cooldown=10000; +} +void AdventureCombatMgr::Stop(Player* p) +{ + auto it=_records.find(p->GetGUID().GetCounter());if(it!=_records.end())Finish(p,it->second); +} +void AdventureCombatMgr::Offline(Player* p){_records.erase(p->GetGUID().GetCounter());} +Creature* AdventureCombatMgr::Proactive(Player* p) const +{ + auto intent=sAdventureBehaviorMgr->CombatIntent(p); + if(intent!="adventure" && intent!="profession")return nullptr; + auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); + if(intent=="profession" && (!profile || !profile->PrimaryProfessions.count("skinning")))return nullptr; + if(intent=="adventure" && p->movespline->Finalized())return nullptr; + Creature* best=nullptr;std::vector all;p->GetCreatureListWithEntryInGrid(all,0,AdventureCombatPolicy::SearchRadius); + for(auto c:all) + { + if(best && p->GetExactDist(c)>=p->GetExactDist(best))continue; + if(intent=="adventure") + { + if(!p->IsHostileTo(c))continue; + bool nearPath=false;auto path=sClientlessTravelMgr->GetActivePath(p->GetGUID().GetCounter()); + if(!path)continue; + auto const& points=*path; + for(std::size_t i=1;iGetPositionX(),c->GetPositionY(),c->GetPositionZ(),points[i-1].GetPositionX(),points[i-1].GetPositionY(),points[i-1].GetPositionZ(),points[i].GetPositionX(),points[i].GetPositionY(),points[i].GetPositionZ())<=AdventureCombatPolicy::PathCorridor){nearPath=true;break;} + if(!nearPath)continue; + } + else if(c->GetCreatureTemplate()->type!=CREATURE_TYPE_BEAST || !c->GetCreatureTemplate()->SkinLootId)continue; + if(Safe(p,c))best=c; + } + return best; +} +bool AdventureCombatMgr::Death(Player* p,Record& r) +{ + if(!r.Dead) + { + if(r.AI)Finish(p,r); + sAdventureBehaviorMgr->InterruptForCombat(p);r.Damagers.clear();r.Dead=true;r.Suspended=true; + TC_LOG_INFO("server.adventurebot","DEATH_RELEASE guid=%u",p->GetGUID().GetCounter()); + } + if(p->InBattleground() || p->InArena() || p->IsBeingTeleported())return true; + if(!p->HasFlag(PLAYER_FLAGS,PLAYER_FLAGS_GHOST)) + { + WorldPacket release(CMSG_REPOP_REQUEST,1);release<GetSession()->HandleRepopRequestOpcode(release); + if(auto corpse=p->GetCorpse())corpse->SaveToDB(); + p->SaveToDB(); + return true; + } + if(r.CemeteryPending) + { + r.CemeteryPending=false; + p->ResurrectPlayer(0.5f,false);p->SpawnCorpseBones(); + TC_LOG_INFO("server.adventurebot","DEATH_CEMETERY_REVIVE guid=%u",p->GetGUID().GetCounter()); + return true; + } + auto corpse=p->GetCorpse();if(!corpse)return true; + auto now=GameTime::GetGameTime(); + if(corpse->IsWithinDistInMap(p,CORPSE_RECLAIM_RADIUS,true)) + { + if(nowGetGhostTime()+p->GetCorpseReclaimDelay(corpse->GetType()==CORPSE_RESURRECTABLE_PVP))return true; + WorldPacket reclaim(CMSG_RECLAIM_CORPSE,8);reclaim<GetGUID();p->GetSession()->HandleReclaimCorpseOpcode(reclaim); + if(p->IsAlive())TC_LOG_INFO("server.adventurebot","DEATH_RECLAIM guid=%u",p->GetGUID().GetCounter()); + return true; + } + auto travel=sClientlessTravelMgr->GetStatus(p->GetGUID().GetCounter()); + if(travel && travel->State==ClientlessTravelState::Moving)return true; + if(travel && (travel->State==ClientlessTravelState::Stuck || travel->State==ClientlessTravelState::PathRejected)) + { + r.FailedCorpsePoints.push_back(travel->Target); + sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); + } + if(nowGetMapId()==corpse->GetMapId() && p->GetInstanceId()==corpse->GetInstanceId())for(unsigned ring=0;ring<3;++ring)for(unsigned i=0;i<(ring?12u:1u);++i) + { + Position end;float radius=ring==1?20.0f:ring==2?32.0f:0.0f;float angle=i*6.2831853f/12; + float x=corpse->GetPositionX()+radius*std::cos(angle),y=corpse->GetPositionY()+radius*std::sin(angle); + float z=p->GetMap()->GetHeight(p->GetPhaseShift(),x,y,corpse->GetPositionZ()+3,true,8); + if(z<=INVALID_HEIGHT)continue; + end.Relocate(x,y,z); + if(end.GetExactDist(corpse)>CORPSE_RECLAIM_RADIUS-2)continue; + if(std::any_of(r.FailedCorpsePoints.begin(),r.FailedCorpsePoints.end(),[&](Position const& failed){return failed.GetExactDist(end)<3;}))continue; + ClientlessPathDiagnostics path; + if(sClientlessTravelMgr->AnalyzePath(p,end,path) && path.Complete && !path.Suspicious && path.PathLengthStartTravel(p,best); + TC_LOG_INFO("server.adventurebot","DEATH_CORPSE_RUN guid=%u path=%.2f",p->GetGUID().GetCounter(),shortest); + } + else if(AdventureCombatPolicy::CemeteryFallback(now,corpse->GetGhostTime(),true)) + { + // Native nearest graveyard relocation. No healer interaction, sickness, + // durability repair/loss or resource cheat; native resurrection at 50%. + r.CemeteryPending=true; + p->RepopAtGraveyard(); + } + return true; +} +bool AdventureCombatMgr::Update(Player* p,uint32 diff) +{ + if(!p->IsInWorld())return false; + auto& r=_records[p->GetGUID().GetCounter()]; + r.Cooldown=r.Cooldown>diff?r.Cooldown-diff:0; + if(p->IsBeingTeleported() || p->GetTransport() || p->IsInFlight() || p->IsCharmed())return r.AI || !p->IsAlive(); + if(r.Timer>diff){r.Timer-=diff;return r.AI || !p->IsAlive() || r.Dead;} + r.Timer=500; + if(!p->IsAlive())return Death(p,r); + if(r.Dead){r.Dead=false;r.Suspended=false;r.NextCorpseSearch=0;r.FailedCorpsePoints.clear();r.Cooldown=30000;r.Timer=0;return false;} + // First actual damager wins, including interrupting a proactive/training + // target. Keep all subsequent damagers ordered for after that focus dies. + if(!r.Defending)while(!r.Damagers.empty()) + { + auto id=r.Damagers.front();r.Damagers.pop_front();auto c=ObjectAccessor::GetCreature(*p,id); + if(!Valid(p,c,false))continue; + if(r.AI)Finish(p,r); + Start(p,r,c,false,true,0);break; + } + if(!r.AI && !p->IsInCombat() && !r.Cooldown) + if(auto c=Proactive(p))Start(p,r,c,false,false,0); + if(!r.AI)return false; + auto c=ObjectAccessor::GetCreature(*p,r.Target); + if(!Valid(p,c,r.Training) || (r.Until && GameTime::GetGameTime()>=r.Until) || p->GetExactDist(c)>80) + {Finish(p,r);return true;} + if(!Reachable(p,c)) + { + r.UnreachableMs+=500; + if(r.UnreachableMs>=20000){TC_LOG_WARN("server.adventurebot","COMBAT_TARGET_UNREACHABLE guid=%u",p->GetGUID().GetCounter());Finish(p,r);} + return true; + } + r.UnreachableMs=0; + if(!p->IsWithinMeleeRange(c)) + { + auto nav=sClientlessTravelMgr->GetStatus(p->GetGUID().GetCounter()); + if(!nav || nav->State!=ClientlessTravelState::Moving || nav->Target.GetExactDist(c)>5) + { + Position end;c->GetNearPoint(p,end.m_positionX,end.m_positionY,end.m_positionZ,0.5f,c->GetAngle(p)); + sClientlessTravelMgr->StartTravel(p,end); + } + return true; + } + sClientlessTravelMgr->CancelTravel(p->GetGUID().GetCounter()); + p->StopMoving();r.AI->Tick(c,500); + return true; +} diff --git a/src/server/game/World/AdventureCombatMgr.h b/src/server/game/World/AdventureCombatMgr.h new file mode 100644 index 0000000000..ca3fce31a3 --- /dev/null +++ b/src/server/game/World/AdventureCombatMgr.h @@ -0,0 +1,48 @@ +#ifndef VCORE_ADVENTURE_COMBAT_MGR_H +#define VCORE_ADVENTURE_COMBAT_MGR_H +#include "Common.h" +#include "ObjectGuid.h" +#include +#include +#include +#include +#include "Position.h" +#include +class Player; +class Unit; +class Creature; +class TC_GAME_API AdventureCombatMgr +{ +public: + static AdventureCombatMgr* instance(); + bool Update(Player* player,uint32 diff); + void DamageTaken(Player* player,Unit* attacker,uint32 damage); + void Offline(Player* player); + bool TrainingAvailable(Player* player) const; + bool Train(Player* player,uint32 durationMs,time_t deadline=0); + bool Attack(Player* player,uint32 creatureSpawnId); + void Stop(Player* player); +private: + class Fighter; + struct Record + { + std::shared_ptr AI; + ObjectGuid Target; + std::deque Damagers; + bool Training=false,Defending=false,Suspended=false,Dead=false,CemeteryPending=false; + time_t Until=0,NextCorpseSearch=0; + std::vector FailedCorpsePoints; + uint32 Timer=0,UnreachableMs=0,Cooldown=0; + }; + bool Start(Player* player,Record& record,Creature* target,bool training,bool defending,time_t until); + bool Valid(Player* player,Creature* target,bool training) const; + bool Safe(Player* player,Creature* target) const; + bool Reachable(Player* player,Creature* target) const; + Creature* Dummy(Player* player) const; + Creature* Proactive(Player* player) const; + bool Death(Player* player,Record& record); + void Finish(Player* player,Record& record); + std::unordered_map _records; +}; +#define sAdventureCombatMgr AdventureCombatMgr::instance() +#endif diff --git a/src/server/game/World/AdventureCombatPolicy.h b/src/server/game/World/AdventureCombatPolicy.h new file mode 100644 index 0000000000..b9dbef73df --- /dev/null +++ b/src/server/game/World/AdventureCombatPolicy.h @@ -0,0 +1,27 @@ +#ifndef VCORE_ADVENTURE_COMBAT_POLICY_H +#define VCORE_ADVENTURE_COMBAT_POLICY_H +#include +#include +namespace AdventureCombatPolicy +{ + // Conservative initial tuning. Self-defence deliberately bypasses this filter. + constexpr unsigned MaxLevelAdvantage=2; + constexpr float MinimumHealth=60, MinimumMana=30, SearchRadius=20, PathCorridor=8; + inline bool SafeFirstAttack(unsigned level,unsigned enemy,float health,bool elite, + bool boss,unsigned nearbyHostiles,bool manaUser,float mana) + { + return enemy<=level+MaxLevelAdvantage && health>=MinimumHealth && !elite && !boss && + nearbyHostiles<=1 && (!manaUser || mana>=MinimumMana); + } + inline bool CemeteryFallback(std::int64_t now,std::int64_t released,bool noPath) + {return noPath && released>0 && now>=released && now-released>=300;} + inline float SegmentDistance(float x,float y,float z,float ax,float ay,float az,float bx,float by,float bz) + { + float dx=bx-ax,dy=by-ay,dz=bz-az,length=dx*dx+dy*dy+dz*dz; + float t=length>0?((x-ax)*dx+(y-ay)*dy+(z-az)*dz)/length:0; + t=t<0?0:t>1?1:t; + dx=x-ax-t*dx;dy=y-ay-t*dy;dz=z-az-t*dz; + return std::sqrt(dx*dx+dy*dy+dz*dz); + } +} +#endif diff --git a/src/server/game/World/AdventureCombatPreparationPolicy.h b/src/server/game/World/AdventureCombatPreparationPolicy.h new file mode 100644 index 0000000000..0864ee5e57 --- /dev/null +++ b/src/server/game/World/AdventureCombatPreparationPolicy.h @@ -0,0 +1,31 @@ +#ifndef VCORE_ADVENTURE_COMBAT_PREPARATION_POLICY_H +#define VCORE_ADVENTURE_COMBAT_PREPARATION_POLICY_H +#include +#include +namespace AdventureCombatPreparationPolicy +{ + // Level-60 solo Arms: damage, bleed/Overpower synergy, self-recovery and + // sensible rage/cooldown support. All ranks still pass Player::LearnTalent. + struct Talent {uint32_t Id, Ranks;}; + inline std::array const& Arms60() + { + static std::array const talents={{{10134,3},{11163,2},{8176,3},{8190,2}, + {10138,3},{10741,2},{11418,2},{9662,2},{11223,1},{10520,3},{8208,1},{8214,1},{8194,1}}}; + return talents; + } + template + unsigned SpendArms60(Learn learn,Remaining remaining) + { + unsigned spent=0; + for (unsigned pass=0;pass<40 && remaining();++pass) + { + bool progress=false; + for (auto const& talent:Arms60()) + for (unsigned rank=0;rankIsVendor(),unit->IsBanker(), - unit->HasFlag(UNIT_NPC_FLAGS,UNIT_NPC_FLAG_AUCTIONEER)); + unit->HasFlag(UNIT_NPC_FLAGS,UNIT_NPC_FLAG_AUCTIONEER),unit->IsArmorer()); } bool TargetVisible(Player* player, WorldObject* target, std::string const& kind) { @@ -66,7 +66,7 @@ namespace if (snapshot) for (auto const& entity:snapshot->Entities) { if (entity.Type!=ClientlessEntityType::Creature || !entity.Alive || - !(entity.Capabilities & (CLIENTLESS_CAP_VENDOR|CLIENTLESS_CAP_BANKER|CLIENTLESS_CAP_AUCTIONEER))) continue; + !(entity.Capabilities & (CLIENTLESS_CAP_VENDOR|CLIENTLESS_CAP_BANKER|CLIENTLESS_CAP_AUCTIONEER|CLIENTLESS_CAP_REPAIR))) continue; auto unit=ObjectAccessor::GetCreature(*player,ObjectGuid(HighGuid::Unit,entity.Entry,entity.Guid)); if (Friendly(player,unit,c) && ServiceMatches(unit,kind) && (c.ServiceKind.empty() || ServiceMatches(unit,c.ServiceKind))) people.push_back(unit); @@ -772,6 +772,14 @@ bool AdventureSocialActions::Update(Player * p, AdventureSocialScene & s, uint32 } if (AdventureEconomyPolicy::RequiredService(s.Kind)!=AdventureEconomyPolicy::Service::None) { + if (s.Kind=="repair" && !s.RepairDone) + { + // The visit is performative. No vendor packet, transaction or money + // operation; the completed, validated scene restores durability. + p->DurabilityRepairAll(false,1.0f,false); + s.RepairDone=true; + TC_LOG_INFO("server.adventurebot","ECONOMY_REPAIR_COMPLETE guid=%u target='%s'",p->GetGUID().GetCounter(),s.TargetKey.c_str()); + } s.Destination=c.Anchor; if (!c.Move(s.Destination,350)) return false; s.Departing=true; diff --git a/src/server/game/World/AdventureSocialActions.h b/src/server/game/World/AdventureSocialActions.h index 6d8a9e2f32..76e84cedcd 100644 --- a/src/server/game/World/AdventureSocialActions.h +++ b/src/server/game/World/AdventureSocialActions.h @@ -14,6 +14,7 @@ struct AdventureMicroAction; struct AdventureLocation; struct AdventureSocialScene { + bool RepairDone=false; bool Active=false, Approaching=false, Casting=false, WaitingChair=false, Seated=false, Departing=false; std::string Action, Kind, TargetKey; ObjectGuid Target, PreviousSelection; diff --git a/src/server/game/World/ClientlessLuaEngine.cpp b/src/server/game/World/ClientlessLuaEngine.cpp index 47ead95e23..dce326c7a2 100644 --- a/src/server/game/World/ClientlessLuaEngine.cpp +++ b/src/server/game/World/ClientlessLuaEngine.cpp @@ -4,6 +4,7 @@ #include "ClientlessActionExecutorMgr.h" #include "ClientlessTravelMgr.h" #include "AdventureBehaviorMgr.h" +#include "AdventureCombatMgr.h" #include "AdventureSocialActions.h" #include "Config.h" #include "Log.h" @@ -218,9 +219,11 @@ public: void OnIntentChanged(Player* player, ClientlessIntent const* intent) override { sAdventureBehaviorMgr->IntentChanged(player, intent); } void Update(Player* player, uint32 diff) override - { sAdventureBehaviorMgr->Update(player, diff); } + { if (!sAdventureCombatMgr->Update(player, diff)) sAdventureBehaviorMgr->Update(player, diff); } + void OnDamageTaken(Player* player, Unit* attacker, uint32 damage) override + { sAdventureCombatMgr->DamageTaken(player, attacker, damage); } void OnPlayerLogout(Player* player) override - { sAdventureBehaviorMgr->Offline(player); } + { sAdventureCombatMgr->Offline(player); sAdventureBehaviorMgr->Offline(player); } }; } diff --git a/src/server/game/World/ClientlessPlayerMgr.cpp b/src/server/game/World/ClientlessPlayerMgr.cpp index e2ca2aa815..aa728e65e3 100644 --- a/src/server/game/World/ClientlessPlayerMgr.cpp +++ b/src/server/game/World/ClientlessPlayerMgr.cpp @@ -6,6 +6,7 @@ #include "ClientlessFlightMgr.h" #include "Log.h" #include "Player.h" +#include "ObjectAccessor.h" #include "World.h" #include "WorldSession.h" @@ -150,6 +151,11 @@ std::deque const* ClientlessPlayerMgr::GetQueuedActions(uint32 void ClientlessPlayerMgr::Update(uint32 diff) { + // Damage can arrive from different map workers. Hand GUID-only notices to + // the world-thread controller, preserving order within each character. + decltype(_damageNotices) damageNotices; + { std::lock_guard lock(_damageMutex); damageNotices.swap(_damageNotices); } + // CLIENTLESS_PLAYER_CORE_C8_2_TRAVEL_ROBUSTNESS sClientlessTravelMgr->Update(diff); // CLIENTLESS_PLAYER_CORE_C8_3_LOCAL_PERCEPTION @@ -189,7 +195,12 @@ void ClientlessPlayerMgr::Update(uint32 diff) } } if (record.Controller) + { + auto notices=damageNotices.find(characterGuid); + if(notices!=damageNotices.end())for(auto const& notice:notices->second) + record.Controller->OnDamageTaken(player,ObjectAccessor::GetUnit(*player,notice.Attacker),notice.Damage); record.Controller->Update(player, diff); + } } else if (state == ClientlessPlayerState::Removing && !record.LogoutNotified) { @@ -201,3 +212,10 @@ void ClientlessPlayerMgr::Update(uint32 diff) ++itr; } } + +void ClientlessPlayerMgr::DamageTaken(Player* player, Unit* attacker, uint32 damage) +{ + if(!damage || !attacker || !player->GetSession()->IsClientless())return; + std::lock_guard lock(_damageMutex); + _damageNotices[player->GetGUID().GetCounter()].push_back({attacker->GetGUID(),damage}); +} diff --git a/src/server/game/World/ClientlessPlayerMgr.h b/src/server/game/World/ClientlessPlayerMgr.h index 713cc6d9a3..04dd722c3a 100644 --- a/src/server/game/World/ClientlessPlayerMgr.h +++ b/src/server/game/World/ClientlessPlayerMgr.h @@ -10,8 +10,11 @@ #include #include #include +#include +#include "ObjectGuid.h" class Player; +class Unit; struct TC_GAME_API ClientlessIntent { @@ -34,6 +37,8 @@ public: virtual void OnIntentChanged(Player*, ClientlessIntent const*) { } virtual void Update(Player*, uint32) { } virtual void OnPlayerLogout(Player*) { } + // Notification after health was reduced, never the mutable pre-damage event. + virtual void OnDamageTaken(Player*, Unit*, uint32) { } }; class TC_GAME_API ClientlessPlayerMgr @@ -46,6 +51,7 @@ public: bool StopPlayer(uint32 characterGuid); bool IsManaged(uint32 characterGuid) const; + void DamageTaken(Player* player, Unit* attacker, uint32 damage); uint32 GetAccountId(uint32 characterGuid) const; uint32 GetManagedCount() const { return uint32(_records.size()); } @@ -72,6 +78,9 @@ private: bool LogoutNotified = false; }; + struct DamageNotice { ObjectGuid Attacker; uint32 Damage; }; + std::mutex _damageMutex; + std::unordered_map> _damageNotices; std::unordered_map _records; }; diff --git a/src/server/game/World/ClientlessTravelMgr.cpp b/src/server/game/World/ClientlessTravelMgr.cpp index 78f24e9640..a3bc6b80ab 100644 --- a/src/server/game/World/ClientlessTravelMgr.cpp +++ b/src/server/game/World/ClientlessTravelMgr.cpp @@ -656,3 +656,9 @@ void ClientlessTravelMgr::Update(uint32 diff) LaunchPath(player, record); } } + +std::vector const* ClientlessTravelMgr::GetActivePath(uint32 guid) const +{ + auto it=_records.find(guid); + return it!=_records.end() && it->second.Status.State==ClientlessTravelState::Moving ? &it->second.ActiveSpline : nullptr; +} diff --git a/src/server/game/World/ClientlessTravelMgr.h b/src/server/game/World/ClientlessTravelMgr.h index 59fafece3d..b3ffb7dcf7 100644 --- a/src/server/game/World/ClientlessTravelMgr.h +++ b/src/server/game/World/ClientlessTravelMgr.h @@ -62,6 +62,7 @@ public: void CancelTravel(uint32 characterGuid); ClientlessTravelStatus const* GetStatus(uint32 characterGuid) const; void Update(uint32 diff); + std::vector const* GetActivePath(uint32 characterGuid) const; static char const* StateName(ClientlessTravelState state); diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index 602809d9b7..51d732f5a0 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -19,6 +19,7 @@ #include "ClientlessActionExecutorMgr.h" #include "ClientlessWorldPointMgr.h" #include "AdventureBehaviorMgr.h" +#include "AdventureCombatMgr.h" #include "ClientlessTravelGraphMgr.h" #include "ClientlessRouteRecorderMgr.h" #include "ClientlessLuaEngine.h" @@ -36,6 +37,16 @@ #include "TemporarySummon.h" #include "Unit.h" #include "World.h" +#include "Item.h" +#include "ItemTemplate.h" +#include "PlayerAI.h" +#include "PhasingHandler.h" +#include "PhaseShift.h" +#include "Map.h" +#include "Spell.h" +#include "SpellMgr.h" +#include "Log.h" +#include "AdventureCombatPreparationPolicy.h" #include #include @@ -44,6 +55,253 @@ #include #include #include +#include +#include +#include + +namespace ClientlessCombatPreparation +{ + struct Gear {uint8 Slot;uint32 Entry;std::string Enchantments;uint8 RandomType;uint32 RandomId;}; + + bool Prepare(ChatHandler* handler,Player* bot,uint32 sourceGuid) + { + if (bot->getClass()!=CLASS_WARRIOR || bot->getLevel()!=60 || bot->IsInCombat() || + bot->IsBeingTeleported() || bot->GetTransport() || bot->GetGUID().GetCounter()==sourceGuid) + {handler->SendSysMessage("Preparation requires an idle level-60 warrior and a different source character.");return false;} + auto source=CharacterDatabase.PQuery("SELECT class,online FROM characters WHERE guid=%u",sourceGuid); + if (!source || source->Fetch()[0].GetUInt8()!=bot->getClass()) + {handler->SendSysMessage("Source character class differs or source is missing.");return false;} + auto sourcePlayer=ObjectAccessor::FindConnectedPlayer(ObjectGuid(HighGuid::Player,sourceGuid)); + if (source->Fetch()[1].GetUInt8() && !sourcePlayer) + {handler->SendSysMessage("Online source is not locally accessible.");return false;} + // Snapshot first. Never load source Item objects from DB: their load + // correction path can write back to the original character's items. + std::vector gear; + if (sourcePlayer) + { + for(uint8 slot=EQUIPMENT_SLOT_START;slotGetItemByPos(INVENTORY_SLOT_BAG_0,slot)) + { + std::ostringstream enchantments; + for(unsigned i=0;iGetEnchantmentId(EnchantmentSlot(i))<<' '<GetEnchantmentDuration(EnchantmentSlot(i))<<' '<GetEnchantmentCharges(EnchantmentSlot(i))<<' '; + auto random=item->GetItemRandomEnchantmentId(); + gear.push_back({slot,item->GetEntry(),enchantments.str(),uint8(random.Type),random.Id}); + } + } + else if(auto rows=CharacterDatabase.PQuery("SELECT ci.slot,i.itemEntry,i.enchantments,i.randomPropertyType,i.randomPropertyId " + "FROM character_inventory ci JOIN item_instance i ON i.guid=ci.item WHERE ci.guid=%u AND ci.bag=0 AND ci.slot<19 ORDER BY ci.slot",sourceGuid)) do + { + auto f=rows->Fetch();gear.push_back({f[0].GetUInt8(),f[1].GetUInt32(),f[2].GetString(),f[3].GetUInt8(),f[4].GetUInt32()}); + }while(rows->NextRow()); + if (gear.empty()) {handler->SendSysMessage("No source equipment found.");return false;} + for(auto const& g:gear) + { + auto proto=sObjectMgr->GetItemTemplate(g.Entry); + if(!proto || proto->GetRequiredLevel()>bot->getLevel()) + {handler->PSendSysMessage("Item %u is missing or exceeds level 60; nothing copied.",g.Entry);return false;} + } + bot->LearnDefaultSkills();bot->UpdateSkillsForLevel(); + if (bot->GetPrimaryTalentTree(bot->GetActiveSpec())) bot->ResetTalents(true); + bot->InitTalentForLevel(); + if(!bot->LearnPrimaryTalentSpecialization(0)) + {handler->SendSysMessage("Could not choose Arms specialization.");return false;} + auto tabs=sDBCManager.GetTalentTabPages(bot->getClass()); + std::unordered_set otherSpecSpells; + if(tabs)for(unsigned i=1;ibegin(),spells->end()); + uint32 learned=0; + // Armor/weapon proficiencies are generic-family, often level-zero + // spells. Their native race/class/min-level skill rule gates learning. + for(auto ability:sSkillLineAbilityStore) + { + auto skill=sSkillLineStore.LookupEntry(ability->SkillLine); + auto rc=sDBCManager.GetSkillRaceClassInfo(ability->SkillLine,bot->getRace(),bot->getClass()); + auto spell=sSpellMgr->GetSpellInfo(ability->Spell); + if(!skill || (skill->CategoryID!=SKILL_CATEGORY_ARMOR && skill->CategoryID!=SKILL_CATEGORY_WEAPON) || + !rc || rc->MinLevel>bot->getLevel() || !spell || spell->SpellLevel>bot->getLevel() || + !bot->IsSpellFitByClassAndRace(spell->Id) || !SpellMgr::IsSpellValid(spell,bot,false))continue; + if(!bot->HasSpell(spell->Id)){bot->LearnSpell(spell->Id,false);++learned;} + } + for(auto stance:std::array,3>{{{2457,1},{71,10},{2458,30}}}) + if(bot->getLevel()>=stance.second && !bot->HasSpell(stance.first)) + if(auto spell=sSpellMgr->GetSpellInfo(stance.first)) + if(bot->IsSpellFitByClassAndRace(spell->Id) && SpellMgr::IsSpellValid(spell,bot,false)) + {bot->LearnSpell(spell->Id,false);++learned;} + auto cls=sChrClassesStore.LookupEntry(bot->getClass()); + for(auto ability:sSkillLineAbilityStore) + { + auto spell=sSpellMgr->GetSpellInfo(ability->Spell); + if(!spell || !spell->SpellLevel || spell->SpellLevel>bot->getLevel() || + spell->SpellFamilyName!=cls->SpellClassSet || otherSpecSpells.count(spell->Id) || + !bot->IsSpellFitByClassAndRace(spell->Id) || + sDBCManager.GetTalentSpellCost(spell->GetFirstRankSpell()->Id)>0 || + !SpellMgr::IsSpellValid(spell,bot,false))continue; + if(!bot->HasSpell(spell->Id)){bot->LearnSpell(spell->Id,false);++learned;} + } + auto spent=AdventureCombatPreparationPolicy::SpendArms60( + [&](uint32 id,unsigned rank){return bot->LearnTalent(id,rank);},[&](){return bot->GetFreeTalentPoints();}); + if(bot->GetFreeTalentPoints()) + {handler->PSendSysMessage("Talent plan could not spend %u points; gear not copied.",bot->GetFreeTalentPoints());return false;} + // Create new GUIDs through the native item allocator. Displaced gear is + // stored in free bag slots; it is never destroyed or overwritten in DB. + uint32 copied=0; + for(auto const& g:gear) + { + if(auto equipped=bot->GetItemByPos(INVENTORY_SLOT_BAG_0,g.Slot)) + if(equipped->GetEntry()==g.Entry)continue; // repeatable setup + auto discard=[&](Item* p){if(p){RemoveItemFromUpdateQueueOf(p,bot);delete p;}}; + std::unique_ptr item(Item::CreateItem(g.Entry,1,bot),discard); + if(!item)return false; + item->SetItemRandomProperties({ItemRandomEnchantmentType(g.RandomType),g.RandomId}); + std::istringstream enchantments(g.Enchantments); + for(unsigned i=0;i>id>>duration>>charges))return false; + if(id)item->SetEnchantment(EnchantmentSlot(i),id,duration,charges); + } + uint16 destination=0; + auto equipResult=bot->CanEquipItem(g.Slot,destination,item.get(),true); + if(equipResult!=EQUIP_ERR_OK) + {handler->PSendSysMessage("Native equip check rejected item %u at slot %u: error=%u armorMask=%u weaponMask=%u.",g.Entry,g.Slot,uint32(equipResult),bot->GetArmorProficiency(),bot->GetWeaponProficiency());return false;} + if(auto previous=bot->GetItemByPos(INVENTORY_SLOT_BAG_0,g.Slot)) + { + ItemPosCountVec storage; + if(bot->CanStoreItem(NULL_BAG,NULL_SLOT,storage,previous,false)!=EQUIP_ERR_OK) + {handler->SendSysMessage("No bag space for displaced equipment; stopped without discarding it.");return false;} + bot->RemoveItem(INVENTORY_SLOT_BAG_0,g.Slot,true); + bot->StoreItem(storage,previous,true); + } + if(!bot->EquipItem(destination,item.get(),true))return false; + item.release();++copied; + } + bot->DurabilityRepairAll(false,1.0f,false); + bot->SetHealth(bot->GetMaxHealth()); + bot->SendTalentsInfoData(false); + auto tx=CharacterDatabase.BeginTransaction();bot->SaveToDB(tx); + auto commit=CharacterDatabase.AsyncCommitTransaction(tx); + if(!commit.m_future.get()){handler->SendSysMessage("Character save failed.");return false;} + handler->PSendSysMessage("Prepared level-60 Arms warrior: %u copied items, %u newly learned spells, %u talent points spent, %u remaining. Original source unchanged.",copied,learned,spent,bot->GetFreeTalentPoints()); + TC_LOG_INFO("server.adventurebot","COMBAT_PREPARED guid=%u source=%u copied=%u learned=%u talents=%u primaryTree=%u health=%u", + bot->GetGUID().GetCounter(),sourceGuid,copied,learned,spent,bot->GetPrimaryTalentTree(bot->GetActiveSpec()),bot->GetMaxHealth()); + return true; + } + + // A bounded training exercise, not a replacement for the future autonomous + // combat/interaction policy. It reuses actual PlayerAI spell validation. + class Exercise : public PlayerAI + { + public: + Exercise(Player* p,Creature* target,uint32 seconds):PlayerAI(p),_target(target->GetGUID()),_remaining(seconds*1000){} + void UpdateAI(uint32 diff) override {Tick(diff);} + Player* Owner()const{return me;} + bool Tick(uint32 diff) + { + auto target=ObjectAccessor::GetCreature(*me,_target); + if(!me->IsAlive() || me->IsCharmed() || !target || !target->IsAlive() || + target->GetScriptName()!="npc_training_dummy" || !me->IsInPhase(target) || + me->GetExactDist(target)>60 || _remaining<=diff)return false; + _remaining-=diff; + _rendDelay=_rendDelay>diff?_rendDelay-diff:0; + _fillerDelay=_fillerDelay>diff?_fillerDelay-diff:0; + if(_timer>diff){_timer-=diff;return true;}_timer=500; + if(me->HasUnitState(UNIT_STATE_CASTING))return true; + me->SetFacingToObject(target); + if(!me->HasAura(2457)) + if(auto cast=VerifySpellCast(2457,me)){Cast(cast,2457);return true;} + if(!me->IsWithinMeleeRange(target)) + { + if(auto cast=VerifySpellCast(100,target)) + {Cast(cast,100);return true;} + if(!me->GetVictim()) AttackStart(target); + return true; + } + if(me->GetVictim()!=target)AttackStart(target); + DoAutoAttackIfReady(); + if(!me->HasAura(6673))if(auto cast=VerifySpellCast(6673,me)){Cast(cast,6673);return true;} + if(auto cast=VerifySpellCast(12294,target)){Cast(cast,12294);return true;} + if(!_rendDelay && !target->GetAuraApplicationOfRankedSpell(772,me->GetGUID())) + if(auto cast=VerifySpellCast(772,target)){_rendDelay=12000;Cast(cast,772);return true;} + if(!_fillerDelay)for(uint32 id:{7384u,1464u,78u}) + if(auto cast=VerifySpellCast(id,target)){_fillerDelay=3000;Cast(cast,id);break;} + return true; + } + void Stop(){me->AttackStop();me->CastStop();me->GetMotionMaster()->Clear();me->StopMoving();me->SetSelection(ObjectGuid::Empty);} + private: + void Cast(TargetedSpell cast,uint32 spell) + { + SpellCastTargets targets;targets.SetUnitTarget(cast.second); + uint32 actual=cast.first->GetSpellInfo()->Id; + auto result=cast.first->prepare(targets); + TC_LOG_INFO("server.adventurebot",result==SPELL_CAST_OK?"COMBAT_EXERCISE_CAST guid=%u spell=%u actual=%u result=%u":"COMBAT_EXERCISE_REJECT guid=%u spell=%u actual=%u result=%u", + me->GetGUID().GetCounter(),spell,actual,uint32(result)); + } + ObjectGuid _target;uint32 _remaining=0,_timer=0,_rendDelay=0,_fillerDelay=0; + }; + std::unordered_map> Exercises; + bool Command(ChatHandler* handler,char const* args) + { + std::istringstream in(args?args:"");std::string op;uint32 guid=0,source=0;in>>op>>guid>>source; + auto session=sWorld->FindClientlessSession(guid);auto player=session?session->GetPlayer():nullptr; + if(!player || !player->IsInWorld() || !sClientlessPlayerMgr->IsManaged(guid)) + {handler->SendSysMessage("Use an online managed clientless player.");return false;} + if(op=="inspect") + { + auto c=player->GetMap()->GetCreatureBySpawnId(source); + handler->PSendSysMessage("Bot position=(%.3f %.3f %.3f) phases=%s",player->GetPositionX(),player->GetPositionY(),player->GetPositionZ(),PhasingHandler::FormatPhases(player->GetPhaseShift()).c_str()); + if(!c){handler->SendSysMessage("Requested creature spawn is not loaded.");return false;} + handler->PSendSysMessage("Target entry=%u level=%u script='%s' distance=%.2f phase=%u visible=%u attackable=%u los=%u boss=%u flags=%u phases=%s", + c->GetEntry(),c->getLevel(),c->GetScriptName().c_str(),player->GetExactDist(c), + player->IsInPhase(c)?1:0,player->CanSeeOrDetect(c)?1:0,player->IsValidAttackTarget(c)?1:0, + player->IsWithinLOSInMap(c)?1:0,c->isWorldBoss()?1:0,c->GetUInt32Value(UNIT_FIELD_FLAGS),PhasingHandler::FormatPhases(c->GetPhaseShift()).c_str()); + for(auto const& aura:c->GetAppliedAuras())handler->PSendSysMessage("Target aura=%u",aura.first); + return true; + } + if(op=="testdeath" && player->IsAlive() && !player->IsBeingTeleported() && !player->GetTransport() && !player->InBattleground() && !player->InArena()) + { + TC_LOG_INFO("server.adventurebot","DEATH_TEST_REQUEST guid=%u",guid); + Unit::Kill(nullptr,player,false); + handler->SendSysMessage("Native test death triggered without extra durability loss."); + return true; + } + if(op=="attack") + { + bool ok=sAdventureCombatMgr->Attack(player,source); + handler->SendSysMessage(ok?"Native opponent combat started.":"Target rejected: requires a nearby reachable valid opponent within strength limits."); + return ok; + } + if(op=="train")return sAdventureCombatMgr->Train(player,std::max(10u,std::min(source?source:60u,180u))*1000); + if(op=="prepare")return Prepare(handler,player,source); + if(op=="stop") + {sAdventureCombatMgr->Stop(player);auto it=Exercises.find(guid);if(it!=Exercises.end()){it->second->Stop();Exercises.erase(it);}return true;} + if(op=="exercise" && player->getClass()==CLASS_WARRIOR && !player->IsInCombat() && !player->IsBeingTeleported() && !player->GetTransport()) + { + std::vector targets;player->GetCreatureListWithEntryInGrid(targets,0,50); + Creature* best=nullptr; + for(auto c:targets)if(c->GetScriptName()=="npc_training_dummy" && c->getLevel()<=player->getLevel()+2 && !c->isWorldBoss() && c->IsAlive() && player->IsInPhase(c) && player->IsValidAttackTarget(c) && player->IsWithinLOSInMap(c)) + if(!best || player->GetExactDist(c)GetExactDist(best))best=c; + if(!best){handler->SendSysMessage("No native training dummy nearby.");return false;} + // Explicit exercise owns movement only while the usual intent is stopped. + sAdventureBehaviorMgr->Command(handler,("stop "+std::to_string(guid)).c_str()); + sClientlessPlayerMgr->ClearQueuedActions(guid);sClientlessActionExecutorMgr->Clear(guid);sClientlessTravelMgr->CancelTravel(guid); + Exercises[guid]=std::make_unique(player,best,std::max(10u,std::min(source?source:60u,180u))); + handler->PSendSysMessage("Training exercise started at native dummy %u.",best->GetEntry());return true; + } + handler->SendSysMessage("Usage: clientless combat prepare | exercise [seconds] | train [seconds] | attack | inspect | stop ");return false; + } + void Update(uint32 diff) + { + for(auto it=Exercises.begin();it!=Exercises.end();) + { + auto session=sWorld->FindClientlessSession(it->first);auto p=session?session->GetPlayer():nullptr; + if(!p || p!=it->second->Owner()){it=Exercises.erase(it);continue;} + if(!it->second->Tick(diff)){it->second->Stop();TC_LOG_INFO("server.adventurebot","COMBAT_EXERCISE_END guid=%u",it->first);it=Exercises.erase(it);} + else ++it; + } + } +} namespace VianoBotCommands { @@ -2237,6 +2495,7 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg static std::vector clientlessCommands = { + { "combat", rbac::RBAC_PERM_COMMAND_GM, true, &ClientlessCombatPreparation::Command, "" }, { "luareload", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLuaReload, "" }, { "transport", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessTransport, "" }, { "login", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLogin, "" }, @@ -2307,6 +2566,7 @@ class ClientlessInstanceVisitResume : public WorldScript { public: ClientlessInstanceVisitResume() : WorldScript("ClientlessInstanceVisitResume") { } + void OnUpdate(uint32 diff) override {ClientlessCombatPreparation::Update(diff);} void OnStartup() override { if (!CharacterDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_instance_visit'")) return; diff --git a/tests/clientless/test_adventure_combat_exercise.py b/tests/clientless/test_adventure_combat_exercise.py new file mode 100644 index 0000000000..d068042bf4 --- /dev/null +++ b/tests/clientless/test_adventure_combat_exercise.py @@ -0,0 +1,62 @@ +"""Execute the production bounded PlayerAI exercise and native selection and cleanup.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/scripts/Custom/viano_bot_commands.cpp').read_text() +a=s.index(' class Exercise : public PlayerAI');b=s.index(' std::unordered_map>',a) +code=r''' +#include +#include +#include +#include +#include +using uint32=uint32_t; +#define TC_LOG_INFO(...) ((void)0) +constexpr int UNIT_STATE_CASTING=1; +struct ObjectGuid{int value=1;bool operator==(ObjectGuid const&o)const{return value==o.value;}static const ObjectGuid Empty;};const ObjectGuid ObjectGuid::Empty{0}; +struct Creature{ObjectGuid guid;bool alive=true,phase=true,rend=false;std::string script="npc_training_dummy";ObjectGuid GetGUID(){return guid;}bool IsAlive(){return alive;}std::string GetScriptName(){return script;}bool GetAuraApplicationOfRankedSpell(uint32,ObjectGuid){return rend;}}target; +struct Motion{int clears=0;void Clear(){++clears;}}; +struct Player{ + bool alive=true,charmed=false,casting=false;float distance=12;std::set auras,phases{1,169};std::vector casts;Creature* victim=nullptr;int swings=0,stops=0;Motion motion; + bool IsAlive(){return alive;}bool IsCharmed(){return charmed;}bool IsInPhase(Creature*c){return c->phase;} + float GetExactDist(Creature*){return distance;}bool HasUnitState(int){return casting;}void SetFacingToObject(Creature*){} + bool HasAura(uint32 id){return auras.count(id);}bool IsWithinMeleeRange(Creature*){return distance<4;} + Creature* GetVictim(){return victim;}ObjectGuid GetGUID(){return {26};} + void AttackStop(){++stops;victim=nullptr;}void CastStop(){casting=false;}void StopMoving(){}Motion* GetMotionMaster(){return &motion;} + void SetSelection(ObjectGuid){} +}; +namespace ObjectAccessor{Creature* GetCreature(Player&,ObjectGuid){return ⌖}} +struct PhasingHandler{static void RemovePhase(Player* p,uint32 id,bool){p->phases.erase(id);}}; +constexpr int SPELL_CAST_OK=0; +struct SpellCastTargets{void SetUnitTarget(void*){}}; +struct Spell{ + Player* player;std::set* available;struct Info{uint32 Id;}info; + auto GetSpellInfo(){return &info;} + int prepare(SpellCastTargets const&){auto id=info.Id;player->casts.push_back(id);available->erase(id);if(id==2457||id==6673)player->auras.insert(id);if(id==100)player->distance=2;if(id==772)target.rend=true;delete this;return SPELL_CAST_OK;} +}; +struct PlayerAI{ + Player* me;explicit PlayerAI(Player*p):me(p){}virtual ~PlayerAI()=default;virtual void UpdateAI(uint32)=0; + struct TargetedSpell{Spell* first=nullptr;void* second=nullptr;explicit operator bool()const{return first!=nullptr;}}; + std::set available{2457,100,6673,12294,772,7384,1464,78}; + templateTargetedSpell VerifySpellCast(uint32 id,T* t){return available.count(id)?TargetedSpell{new Spell{me,&available,{id}},t}:TargetedSpell{};} + void AttackStart(Creature*c){me->victim=c;}void DoAutoAttackIfReady(){++me->swings;} +}; +'''+s[a:b]+r''' +int main(){ + Player p;Exercise exercise(&p,&target,20); + for(unsigned i=0;i<24;++i)assert(exercise.Tick(500)); + assert((p.casts==std::vector{2457,100,6673,12294,772,7384,1464,78}));assert(p.swings>0); + p.casting=true;auto count=p.casts.size();assert(exercise.Tick(500)&&p.casts.size()==count); + p.distance=61;assert(!exercise.Tick(500));exercise.Stop(); + assert(p.phases.count(169)&&p.phases.count(1)&&p.stops==1&&p.motion.clears==1); + Player q;q.phases={1,169};Exercise withoutAddedPhase(&q,&target,10);withoutAddedPhase.Stop();assert(q.phases.count(169)); + Player hostile;target.script="not_a_training_dummy";Exercise rejected(&hostile,&target,10);assert(!rejected.Tick(500)&&hostile.casts.empty()); + target.script="npc_training_dummy";hostile.charmed=true;assert(!rejected.Tick(500)); + hostile.charmed=false;hostile.alive=false;assert(!rejected.Tick(500)); +} +''' +with tempfile.TemporaryDirectory() as tmp: + src=Path(tmp)/'exercise.cpp';exe=Path(tmp)/'exercise';src.write_text(code) + subprocess.run(['g++','-std=c++17',str(src),'-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) +print('Production exercise: stance/charge/melee/casts, native validation delegation, bounded lifetime, invalid targets and unchanged native phases passed.') diff --git a/tests/clientless/test_adventure_combat_preparation.py b/tests/clientless/test_adventure_combat_preparation.py new file mode 100644 index 0000000000..6ef25bd5af --- /dev/null +++ b/tests/clientless/test_adventure_combat_preparation.py @@ -0,0 +1,56 @@ +"""Run the level-60 Arms plan through production Player::LearnTalent.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/Entities/Player/Player.cpp').read_text() +a=s.index('bool Player::LearnTalent(');b=s.index('\nvoid Player::LearnPetTalent',a) +# Native 4.3.4 Talent.dbc rows used by this plan; Impale depends on Deep Wounds 3/3. +rows=[(10134,0,[84570,84571,84572]),(11163,0,[84579,84580]),(8176,1,[12834,12849,12867]),(8190,1,[29834,29838]), + (10138,2,[56636,56637,56638]),(10741,2,[16493,16494]),(11418,3,[86655,12330]),(9662,3,[29836,29859]), + (11223,3,[85730]),(10520,4,[84583,84587,84588]),(8208,4,[64976]),(8214,4,[29723,29725]),(8194,5,[46867,56611])] +code=r''' +#include "AdventureCombatPreparationPolicy.h" +#include +#include +#include +#include +using uint32=uint32_t;using uint8=uint8_t;using int8=int8_t; +#define TC_LOG_ERROR(...) ((void)0) +#define TC_LOG_DEBUG(...) ((void)0) +constexpr int MAX_TALENT_RANK=5,REQ_PRIMARY_TREE_TALENTS=31; +struct TalentEntry{uint32 ID=0,TabID=746,TierID=0,ColumnIndex=0,SpellRank[5]={},PrereqTalent[3]={},PrereqRank[3]={};}; +struct TalentTabEntry{uint32 ClassMask=1;}; +struct Store{std::unordered_map rows;TalentEntry const* LookupEntry(uint32 id){auto i=rows.find(id);return i==rows.end()?nullptr:&i->second;}uint32 GetNumRows(){return 12000;}}sTalentStore; +struct Tabs{TalentTabEntry tab;auto LookupEntry(uint32){return &tab;}}sTalentTabStore; +struct Player{ + uint32 points=26,mask=1;std::set spells; + uint32 GetFreeTalentPoints(){return points;}uint32 getClassMask(){return mask;}uint32 GetPrimaryTalentTree(int){return 746;}int GetActiveSpec(){return 0;} + bool HasSpell(uint32 spell){return spells.count(spell);} + void LearnSpell(uint32 spell,bool){for(auto const& [id,t]:sTalentStore.rows)for(auto rank:t.SpellRank)if(rank==spell){for(auto old:t.SpellRank)spells.erase(old);}spells.insert(spell);} + void AddTalent(uint32,int,bool){}void SetFreeTalentPoints(uint32 p){points=p;} + bool LearnTalent(uint32,uint32); +}; +'''+s[a:b]+'\nint main(){\n' +for id,tier,spells in rows: + ranks=spells+[0]*(5-len(spells)) + code+=f'sTalentStore.rows[{id}]={{{id},746,{tier},0,{{'+','.join(map(str,ranks))+'},{0,0,0},{0,0,0}};\n' +code+=r''' + sTalentStore.rows[10741].PrereqTalent[0]=8176;sTalentStore.rows[10741].PrereqRank[0]=2; + Player p; + assert(!p.LearnTalent(10741,0)); // cannot skip native dependency/tier gates + unsigned spent=AdventureCombatPreparationPolicy::SpendArms60([&](uint32 id,uint32 rank){return p.LearnTalent(id,rank);},[&]{return p.points;}); + assert(spent==26&&p.points==0); + assert(p.HasSpell(84572)&&p.HasSpell(84580)&&p.HasSpell(12867)&&p.HasSpell(29838)); + assert(p.HasSpell(56638)&&p.HasSpell(16494)&&p.HasSpell(46867)); + assert(!p.HasSpell(46924)); // Bladestorm requires 31 points; level60 does not get it + assert(AdventureCombatPreparationPolicy::SpendArms60([&](uint32 id,uint32 rank){return p.LearnTalent(id,rank);},[&]{return p.points;})==0); + Player wrongClass;wrongClass.mask=128; + assert(AdventureCombatPreparationPolicy::SpendArms60([&](uint32 id,uint32 rank){return wrongClass.LearnTalent(id,rank);},[&]{return wrongClass.points;})==0); + assert(wrongClass.points==26&&wrongClass.spells.empty()); +} +''' +with tempfile.TemporaryDirectory() as tmp: + src=Path(tmp)/'talents.cpp';exe=Path(tmp)/'talents';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([str(exe)],check=True) +print('Production talent learner: native tiers/dependencies/class gates, all 26 legal level60 points, no capstone bypass and repeat safety passed.') diff --git a/tests/clientless/test_adventure_combat_reactions.py b/tests/clientless/test_adventure_combat_reactions.py new file mode 100644 index 0000000000..5eb0527b70 --- /dev/null +++ b/tests/clientless/test_adventure_combat_reactions.py @@ -0,0 +1,143 @@ +"""Execute production reaction/death state machine with native-world adapters.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +source=(root/'src/server/game/World/AdventureCombatMgr.cpp').read_text() +def function(signature): + a=source.index(signature);b=source.index('{',a);e=b+1;depth=1 + while depth:depth+=(source[e]=='{')-(source[e]=='}');e+=1 + return source[a:e] +header=(root/'src/server/game/World/AdventureCombatMgr.h').read_text() +header='\n'.join(x for x in header.splitlines() if not x.startswith('#include')).replace('private:','public:') +code=r''' +#include "AdventureCombatPolicy.h" +#include +#include +#include +#include +#include +#include +#include +#include +using uint32=unsigned;using uint8=unsigned char; +#define TC_GAME_API +#define TC_LOG_INFO(...) ((void)0) +#define TC_LOG_WARN(...) ((void)0) +constexpr int PLAYER_FLAGS=0,PLAYER_FLAGS_GHOST=1,CMSG_REPOP_REQUEST=2,CMSG_RECLAIM_CORPSE=3,CORPSE_RESURRECTABLE_PVP=1; +constexpr float CORPSE_RECLAIM_RADIUS=39,INVALID_HEIGHT=-100000; +struct ObjectGuid{unsigned id=0;unsigned GetCounter()const{return id;}void Clear(){id=0;}std::string ToString()const{return std::to_string(id);}bool operator==(ObjectGuid b)const{return id==b.id;}bool operator!=(ObjectGuid b)const{return id!=b.id;}}; +struct Position{ + float x=0,y=0,z=0;void Relocate(float a,float b,float c){x=a;y=b;z=c;} + float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;} + float GetExactDist(Position const& b)const{return std::sqrt((x-b.x)*(x-b.x)+(y-b.y)*(y-b.y)+(z-b.z)*(z-b.z));} + float GetExactDist(Position const* b)const{return GetExactDist(*b);} +}; +struct Map{float GetHeight(int,float,float,float,bool,float){return 0;}}map; +struct Creature; +struct Unit:Position{virtual Creature* ToCreature(){return nullptr;}}; +struct Creature:Unit{ObjectGuid guid;bool valid=true,alive=true,controlled=false,evading=false,boss=false,phase=true,visible=true,inWorld=true;unsigned level=60;std::string script="mob";Creature(unsigned n):guid{n}{}ObjectGuid GetGUID(){return guid;}Creature* ToCreature()override{return this;}bool GetControllingPlayer(){return controlled;}bool IsAlive(){return alive;}bool IsInWorld(){return inWorld;}bool IsEvadingAttacks(){return evading;}bool isWorldBoss(){return boss;}unsigned getLevel(){return level;}std::string GetScriptName(){return script;}}; +struct Player; +struct Corpse:Position{ + time_t released=100;int saves=0;ObjectGuid GetGUID(){return {700};}time_t GetGhostTime(){return released;}int GetType(){return 0;} + unsigned GetMapId(){return 0;}unsigned GetInstanceId(){return 0;}Map* GetMap(){return ↦}bool IsWithinDistInMap(Player*,float,bool);void SaveToDB(){++saves;} +}corpse; +struct WorldPacket{int opcode;WorldPacket(int o,int):opcode(o){}templateWorldPacket&operator<<(T){return *this;}}; +struct Session{Player* owner=nullptr;int releases=0,reclaims=0;void HandleRepopRequestOpcode(WorldPacket&);void HandleReclaimCorpseOpcode(WorldPacket&);}; +struct Player:Unit{ + bool alive=true,ghost=false,teleport=false,combat=false,melee=true,los=true;unsigned delay=30;int saves=0,revives=0,bones=0,graveyards=0;bool sickness=false;int durability=75; + Session session;Player(){session.owner=this;}ObjectGuid GetGUID(){return {26};}bool IsAlive(){return alive;}bool IsInWorld(){return true;}bool IsInCombat(){return combat;}bool IsBeingTeleported(){return teleport;}bool GetTransport(){return false;}bool IsInFlight(){return false;}bool IsCharmed(){return false;} + unsigned GetMapId(){return 0;}unsigned GetInstanceId(){return 0;}bool InBattleground(){return false;}bool InArena(){return false;}bool HasFlag(int,int){return ghost;}Session*GetSession(){return &session;}Corpse*GetCorpse(){return &corpse;} + uint32 GetCorpseReclaimDelay(bool){return delay;}Map*GetMap(){return ↦}int GetPhaseShift(){return 0;} + bool IsInPhase(Creature*c){return c->phase;}bool CanSeeOrDetect(Creature*c){return c->visible;}bool IsValidAttackTarget(Creature*c){return c->valid;}unsigned getLevel(){return 60;}bool IsWithinMeleeRange(Creature*){return melee;}float GetCombatReach(){return 1;}float GetAngle(Player*){return 0;} + void SaveToDB(){++saves;}void RepopAtGraveyard(){++graveyards;teleport=true;Relocate(-100,0,0);}void ResurrectPlayer(float hp,bool sick){assert(hp==0.5f);sickness=sick;alive=true;ghost=false;++revives;}void SpawnCorpseBones(){++bones;} + void StopMoving(){} +}; +bool Corpse::IsWithinDistInMap(Player*p,float r,bool){return GetExactDist(p)<=r;} +void Session::HandleRepopRequestOpcode(WorldPacket&){++releases;owner->ghost=true;owner->Relocate(-100,0,0);} +void Session::HandleReclaimCorpseOpcode(WorldPacket&){++reclaims;assert(corpse.IsWithinDistInMap(owner,39,true));owner->alive=true;owner->ghost=false;} +// Update's local combat approach adapter. +void nearPoint(Creature*,Player*,float&,float&,float&,float,float){} +struct FakeCreature:Creature{using Creature::Creature;void GetNearPoint(Player*,float&x,float&y,float&z,float,float){x=this->x;y=this->y;z=this->z;}float GetAngle(Player*){return 0;}}; +namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}} +namespace ObjectAccessor{std::unordered_map objects;Creature* GetCreature(Player&,ObjectGuid g){auto i=objects.find(g.id);return i==objects.end()?nullptr:i->second;}} +struct ClientlessPathDiagnostics{bool Complete=true,Suspicious=false;float PathLength=100;}; +enum class ClientlessTravelState{Moving,Arrived,Stuck,PathRejected}; +struct TravelStatus{ClientlessTravelState State=ClientlessTravelState::Arrived;Position Target;}; +struct Travel{ + TravelStatus status;bool present=false,path=true,nearOnly=false;int starts=0,analyses=0; + auto GetStatus(uint32)->TravelStatus*{return present?&status:nullptr;} + void CancelTravel(uint32){present=false;} + bool AnalyzePath(Player*,Position const&end,ClientlessPathDiagnostics& d){++analyses;d.PathLength=std::fabs(end.x+100);return path && (!nearOnly || end.x<-15);} + bool StartTravel(Player*,Position const&end){++starts;present=true;status.State=ClientlessTravelState::Moving;status.Target=end;return true;} +}travel;auto sClientlessTravelMgr=&travel; +struct Behavior{int interrupts=0;void InterruptForCombat(Player*){++interrupts;}}behavior;auto sAdventureBehaviorMgr=&behavior; +''' +# production Update uses direct Position members and creature near point. +code=code.replace('float x=0,y=0,z=0;','union{float x=0;float m_positionX;};union{float y=0;float m_positionY;};union{float z=0;float m_positionZ;};',1) +code=code.replace('bool GetControllingPlayer(){return controlled;}bool IsAlive(){return alive;}bool IsInWorld(){return inWorld;}bool IsEvadingAttacks(){return evading;}bool isWorldBoss(){return boss;}unsigned getLevel(){return level;}std::string GetScriptName(){return script;}};', 'bool GetControllingPlayer(){return controlled;}bool IsAlive(){return alive;}bool IsInWorld(){return inWorld;}bool IsEvadingAttacks(){return evading;}bool isWorldBoss(){return boss;}unsigned getLevel(){return level;}std::string GetScriptName(){return script;}float GetAngle(Player*){return 0;}void GetNearPoint(Player*,float&a,float&b,float&c,float,float){a=x;b=y;c=z;}};') +# Forward declaration needed in Creature's adapter. +code=code.replace('struct Creature;','struct Creature;struct Player;',1) +code+=header+r''' +class AdventureCombatMgr::Fighter{public:int ticks=0;void Tick(Creature*,uint32){++ticks;}}; + +bool AdventureCombatMgr::Reachable(Player*p,Creature*)const{return p->los;} +Creature* AdventureCombatMgr::Proactive(Player*)const{return nullptr;} +bool AdventureCombatMgr::Start(Player*,Record&r,Creature*c,bool train,bool defense,time_t until){r.AI=std::make_shared();r.Target=c->guid;r.Training=train;r.Defending=defense;r.Until=until;return true;} +void AdventureCombatMgr::Finish(Player*,Record&r){r.AI.reset();r.Target.Clear();r.Defending=false;r.Training=false;r.Cooldown=10000;} +''' +for name in ('bool AdventureCombatMgr::Valid(', 'void AdventureCombatMgr::DamageTaken(', 'bool AdventureCombatMgr::Death(', 'bool AdventureCombatMgr::Update('):code+='\n'+function(name) +code+=r''' +int main(){ + using namespace AdventureCombatPolicy; + assert(SafeFirstAttack(60,62,60,false,false,1,false,0)); + assert(!SafeFirstAttack(60,63,100,false,false,1,false,0)); + assert(!SafeFirstAttack(60,60,59,false,false,1,false,0)); + assert(!SafeFirstAttack(60,60,100,true,false,1,false,0)); + assert(!SafeFirstAttack(60,60,100,false,true,1,false,0)); + assert(!SafeFirstAttack(60,60,100,false,false,2,false,0)); + assert(!SafeFirstAttack(60,60,100,false,false,1,true,29)); + assert(!CemeteryFallback(399,100,true));assert(CemeteryFallback(400,100,true)); + assert(!CemeteryFallback(5000,100,false));assert(!CemeteryFallback(99,100,true)); + assert(SegmentDistance(5,3,0,0,0,0,10,0,0)==3); + AdventureCombatMgr mgr;Player p;Creature a(1),b(2),c(3);ObjectAccessor::objects={{1,&a},{2,&b},{3,&c}}; + + assert(mgr.Valid(&p,&a,false));assert(!mgr.Valid(&p,&a,true)); + Creature dummy(4);dummy.script="npc_training_dummy";assert(mgr.Valid(&p,&dummy,true));assert(!mgr.Valid(&p,&dummy,false)); + dummy.level=63;assert(!mgr.Valid(&p,&dummy,true));dummy.level=60; + dummy.boss=true;assert(!mgr.Valid(&p,&dummy,true));dummy.boss=false; + dummy.phase=false;assert(!mgr.Valid(&p,&dummy,true));dummy.phase=true; + dummy.visible=false;assert(!mgr.Valid(&p,&dummy,true));dummy.visible=true; + dummy.controlled=true;assert(!mgr.Valid(&p,&dummy,true));dummy.controlled=false; + dummy.valid=false;assert(!mgr.Valid(&p,&dummy,true)); + auto&r=mgr._records[26]; + mgr.DamageTaken(&p,&a,0);assert(r.Damagers.empty()); + mgr.DamageTaken(&p,&a,5);mgr.DamageTaken(&p,&b,5);mgr.Update(&p,500); + assert(r.Target.id==1 && r.Defending); // first actual damager + mgr.DamageTaken(&p,&c,5);mgr.Update(&p,500);assert(r.Target.id==1); + a.alive=false;mgr.Update(&p,500);assert(!r.AI);mgr.Update(&p,500);assert(r.Target.id==2); + mgr.Finish(&p,r);r.Damagers.clear();mgr.Start(&p,r,&c,true,false,500); + mgr.DamageTaken(&p,&b,1);mgr.Update(&p,500);assert(r.Target.id==2 && r.Defending && !r.Training); + // Release through native handler and save ghost plus corpse timestamp. + mgr.Finish(&p,r);p.alive=false;p.ghost=false;r.Dead=false;mgr.Death(&p,r); + assert(p.ghost && p.session.releases==1 && p.saves==1 && corpse.saves==1); + // Native reclaim radius and native delay both apply. + p.Relocate(38,0,0);GameTime::now=129;mgr.Death(&p,r);assert(p.session.reclaims==0); + GameTime::now=130;mgr.Death(&p,r);assert(p.alive && p.session.reclaims==1); + // Exact corpse point inaccessible, near-side point in legal radius reachable. + p.alive=false;p.ghost=true;p.Relocate(-100,0,0);travel={};travel.nearOnly=true;r.NextCorpseSearch=0; + GameTime::now=500;mgr.Death(&p,r);assert(travel.starts==1 && travel.status.Target.x<-15); + assert(p.graveyards==0 && !p.alive); // time alone never revives reachable ghost + GameTime::now=900;mgr.Death(&p,r);assert(p.graveyards==0); + // No path: wait all five minutes from original native persisted ghost time. + travel={};travel.path=false;r.NextCorpseSearch=0;GameTime::now=399;mgr.Death(&p,r);assert(!p.alive && p.graveyards==0); + // Simulate process restart: volatile record gone; corpse timestamp survives. + AdventureCombatMgr::Record resumed;resumed.Dead=true;GameTime::now=400;mgr.Death(&p,resumed); + assert(p.graveyards==1 && p.teleport && !p.alive && resumed.CemeteryPending); + p.teleport=false;mgr.Death(&p,resumed);assert(p.alive && !p.sickness && p.revives==1 && p.bones==1 && p.durability==75); +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['g++','-std=c++17','-I'+str(root/'src/server/game/World'),str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Production combat focus, damage ordering, risk filters, corpse radius/delay, alternate paths and malus-free persisted fallback passed.') diff --git a/tests/clientless/test_adventure_economy.py b/tests/clientless/test_adventure_economy.py index fdef66001e..438e0ba17f 100644 --- a/tests/clientless/test_adventure_economy.py +++ b/tests/clientless/test_adventure_economy.py @@ -25,8 +25,8 @@ struct WorldObject{ bool IsInWorld()const{return inWorld;}unsigned GetMapId()const{return map;}float GetPositionX()const{return x;}float GetPositionY()const{return 0;}float GetPositionZ()const{return z;}float GetCollisionHeight()const{return 2;} }; struct Unit:WorldObject{ - bool vendor=false,banker=false,auction=false,inn=false,alive=true,combat=false,flying=false,transit=false,critter=false,totem=false,friendly=true,spline=false;Guid owner{0},guid; - bool IsVendor(){return vendor;}bool IsBanker(){return banker;}bool HasFlag(int,int){return auction;}bool IsInnkeeper(){return inn;} + bool repair=false,vendor=false,banker=false,auction=false,inn=false,alive=true,combat=false,flying=false,transit=false,critter=false,totem=false,friendly=true,spline=false;Guid owner{0},guid; + bool IsArmorer(){return repair;}bool IsVendor(){return vendor;}bool IsBanker(){return banker;}bool HasFlag(int,int){return auction;}bool IsInnkeeper(){return inn;} bool IsAlive(){return alive;}bool IsInCombat(){return combat;}bool IsInFlight(){return transit;}bool IsFlying(){return flying;}bool GetTransport(){return transit;} bool IsCritter(){return critter;}bool IsTotem(){return totem;}Guid GetCharmerOrOwnerGUID(){return owner;}Guid GetGUID(){return guid;}bool IsSplineEnabled(){return spline;} }; @@ -44,13 +44,13 @@ struct Player:Unit{ struct Anchor{float GetExactDist(WorldObject const*u)const{return std::hypot(u->x,u->z);}float GetPositionZ()const{return 0;}}; struct AdventureSocialContext{Anchor Anchor;unsigned MapId=0;std::string ServiceKind,Intent="economy";}; enum class ClientlessEntityType{Creature,Player}; -constexpr unsigned CLIENTLESS_CAP_VENDOR=1,CLIENTLESS_CAP_BANKER=8,CLIENTLESS_CAP_AUCTIONEER=32; +constexpr unsigned CLIENTLESS_CAP_REPAIR=256,CLIENTLESS_CAP_VENDOR=1,CLIENTLESS_CAP_BANKER=8,CLIENTLESS_CAP_AUCTIONEER=32; struct Entity{ClientlessEntityType Type=ClientlessEntityType::Creature;bool Alive=true;unsigned Capabilities=0,Entry=0,Guid=0;}; struct Snapshot{float Radius=0;unsigned ScanAgeMs=1000;std::vector Entities;}; Player* observer=nullptr; struct Perception{Snapshot snapshot;Snapshot* GetSnapshot(unsigned){return &snapshot;} void ScanNow(Player*p,float radius){observer=p;snapshot={};snapshot.Radius=radius; - for(auto u:p->creatures)if(std::hypot(u->x,u->z)<=radius) snapshot.Entities.push_back({ClientlessEntityType::Creature,u->alive,unsigned(u->vendor?1:0)|unsigned(u->banker?8:0)|unsigned(u->auction?32:0),0,unsigned(u->guid.value)}); + for(auto u:p->creatures)if(std::hypot(u->x,u->z)<=radius) snapshot.Entities.push_back({ClientlessEntityType::Creature,u->alive,unsigned(u->vendor?1:0)|unsigned(u->banker?8:0)|unsigned(u->auction?32:0)|unsigned(u->repair?256:0),0,unsigned(u->guid.value)}); }}perception;auto sClientlessPerceptionMgr=&perception; namespace ObjectAccessor{Creature* GetCreature(Player&p,ObjectGuid guid){for(auto u:p.creatures)if(u->guid.value==guid.value)return u;return nullptr;}} struct Managed{bool IsManaged(unsigned){return true;}}managed;auto sClientlessPlayerMgr=&managed; @@ -59,15 +59,15 @@ for sig in ('bool InBounds(', 'bool Friendly(', 'bool ServiceMatches(', 'bool Ta code+=r''' int main(){ using namespace AdventureEconomyPolicy;using namespace AdventureSocialFlow; - Player p,bot;Creature vendor,banker,auction,inn,guard; - vendor.vendor=true;banker.banker=true;auction.auction=true;inn.inn=true; - p.creatures={&vendor,&banker,&auction,&inn,&guard};int nextGuid=10;for(auto u:p.creatures)u->guid.value=++nextGuid;p.players={&bot};AdventureSocialContext c; + Player p,bot;Creature vendor,banker,auction,inn,guard,repair; + repair.repair=true;vendor.vendor=true;banker.banker=true;auction.auction=true;inn.inn=true; + p.creatures={&vendor,&banker,&auction,&inn,&guard,&repair};int nextGuid=10;for(auto u:p.creatures)u->guid.value=++nextGuid;p.players={&bot};AdventureSocialContext c; for(auto kind:{"vendor_visit","goods","trade","vendor_compare"}){ auto people=People(&p,kind,c);assert(people.size()==1&&people[0]==&vendor); } for(auto kind:{"bank_visit","banking"})assert(People(&p,kind,c)==std::vector{&banker}); for(auto kind:{"auction_visit","auctions"})assert(People(&p,kind,c)==std::vector{&auction}); - for(auto kind:{"service_wait","service_finish"})assert(People(&p,kind,c).size()==3); + for(auto kind:{"service_wait","service_finish"})assert(People(&p,kind,c).size()==4); c.ServiceKind=LocationRequirement("economy_bank"); assert(People(&p,"goods",c).empty());assert(People(&p,"service_wait",c)==std::vector{&banker}); c.ServiceKind=LocationRequirement("economy_auction"); @@ -89,7 +89,8 @@ int main(){ nativeMap.clear=false;assert(!TargetVisible(&p,&banker,"banking")); // A real wall still blocks the scene. nativeMap.clear=true;p.los=true; assert(People(&p,"order",c)==std::vector{&inn}); // Preserve ordinary innkeeper interactions. - for(auto kind:{"vendor_visit","goods","trade","vendor_compare","bank_visit","banking","auction_visit","auctions"}){ + assert(People(&p,"repair",c)==std::vector{&repair}); + for(auto kind:{"repair","vendor_visit","goods","trade","vendor_compare","bank_visit","banking","auction_visit","auctions"}){ assert(Economic(kind));auto steps=Steps(kind,20000); assert(steps.front().Action==Cue::Greet&&steps.back().Action==Cue::Farewell); for(auto step:steps)assert(step.Action!=Cue::Fire&&step.Action!=Cue::Dance&&step.Action!=Cue::Seat); diff --git a/tests/clientless/test_adventure_instance.py b/tests/clientless/test_adventure_instance.py index d3349fa1c0..95c7dd9290 100644 --- a/tests/clientless/test_adventure_instance.py +++ b/tests/clientless/test_adventure_instance.py @@ -59,7 +59,7 @@ struct AdventureBehaviorMgr{ void FinishInstance(Player* p,Record&r){++finishes;r.Instance={};p->SetStandState(0);} bool UpdateInstance(Player*,Record&,time_t); }; -'''+s[start:]+r''' +'''+s[start:s.index("void AdventureBehaviorMgr::InterruptForCombat(")]+r''' using Stage=AdventureInstancePolicy::Phase; AdventureBehaviorMgr::Record fresh(){AdventureBehaviorMgr::Record r;r.Instance.Active=true;r.Instance.Entry=78;r.Instance.Exit=121;r.Instance.Map=36;r.Instance.BlockEnd=3600;r.Instance.LossPercent=50;return r;} int main(){ diff --git a/tests/clientless/test_adventure_instance_autoresume.py b/tests/clientless/test_adventure_instance_autoresume.py index f9736fc859..c58c631f85 100644 --- a/tests/clientless/test_adventure_instance_autoresume.py +++ b/tests/clientless/test_adventure_instance_autoresume.py @@ -10,7 +10,8 @@ code=r''' #include #include using uint32=uint32_t; -struct WorldScript{WorldScript(char const*){}virtual void OnStartup(){}}; +struct WorldScript{WorldScript(char const*){}virtual void OnStartup(){}virtual void OnUpdate(uint32){}}; +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 ++index rows=std::make_shared(); diff --git a/tests/clientless/test_adventure_leisure.py b/tests/clientless/test_adventure_leisure.py index 4fcdf64e64..8e2dd9321a 100644 --- a/tests/clientless/test_adventure_leisure.py +++ b/tests/clientless/test_adventure_leisure.py @@ -74,6 +74,7 @@ struct ClientlessCompanionMgr{ }; struct Profile{bool CanGroundMount=true;float GroundMountMinDistance=45;std::vector GroundMounts{1};uint32 CompanionSpell=10673,CompanionLeashItem=37460;}profile; struct Lua{int mounts=0,dismounts=0;Profile* GetAdventureProfile(uint32){return &profile;}bool PrepareGroundMount(Player*,float,char const*){++mounts;return true;}void DismountGroundMount(Player*,char const*){++dismounts;}}lua;auto sClientlessLuaEngine=&lua; +struct CombatMgr{bool available=true;int starts=0;bool TrainingAvailable(Player*){return available;}bool Train(Player*,uint32 duration,time_t){assert(duration>0);++starts;return available;}}combatMgr;auto sAdventureCombatMgr=&combatMgr; struct AdventureMicroAction{std::string Requirement;}; struct Location{struct{std::string Category="leisure";float O=0;}Point;bool IsArea=false;uint32 AreaId=0,ZoneId=1519;}location; struct Locations{Location* Find(std::string const&){return &location;}}locations;auto sAdventureLocationMgr=&locations; @@ -214,6 +215,11 @@ int main(){ assert(!mgr.LeisureAvailable(&p,r,{"fish"}));p.hasRod=true;p.water=true; assert(!mgr.LeisureAvailable(&p,r,{"fish"}));p.water=false; + r.Intent="leisure";combatMgr.available=false;assert(!mgr.LeisureAvailable(&p,r,{"training"})); + combatMgr.available=true;assert(mgr.LeisureAvailable(&p,r,{"training"})); + assert(mgr.StartLeisure(&p,r,{"training"}) && combatMgr.starts==1); + r.Intent="adventure";assert(!mgr.LeisureAvailable(&p,r,{"training"})); + } ''' with tempfile.TemporaryDirectory(prefix='vcore-leisure-') as folder: diff --git a/tests/clientless/test_adventure_repair_catalog.py b/tests/clientless/test_adventure_repair_catalog.py new file mode 100644 index 0000000000..cf67d43946 --- /dev/null +++ b/tests/clientless/test_adventure_repair_catalog.py @@ -0,0 +1,16 @@ +"""Apply repair additions and verify editable weights survive repeated migration.""" +from pathlib import Path +import re,sqlite3 +root=Path(__file__).resolve().parents[2] +db=sqlite3.connect(':memory:') +base=(root/'sql/custom/world/2026_10_26_03_adventure_behavior.sql').read_text() +for table in ('vcore_adventure_action','vcore_adventure_action_intent'): + body=re.search(r'CREATE TABLE IF NOT EXISTS '+table+r' \((.*?)\) ENGINE=',base,re.S)[1] + db.execute('CREATE TABLE '+table+' ('+body+')') +sql=(root/'sql/custom/world/2026_10_27_08_adventure_repair.sql').read_text().replace('INSERT IGNORE','INSERT OR IGNORE') +db.executescript(sql) +assert db.execute("SELECT kind,min_seconds,max_seconds,requirement FROM vcore_adventure_action WHERE action_key='economy_repair'").fetchone()==('social',25,45,'repair') +assert db.execute("SELECT intent,weight FROM vcore_adventure_action_intent WHERE action_key='economy_repair'").fetchone()==('economy',2) +db.execute("UPDATE vcore_adventure_action_intent SET weight=0 WHERE action_key='economy_repair'") +before=list(db.iterdump());db.executescript(sql);assert list(db.iterdump())==before +print('Repair action catalog: proper native service requirement, economy-only intent and preserved administrator weights passed.') diff --git a/tests/clientless/test_adventure_social.py b/tests/clientless/test_adventure_social.py index ac02288b27..4f9f07b8d4 100644 --- a/tests/clientless/test_adventure_social.py +++ b/tests/clientless/test_adventure_social.py @@ -52,6 +52,7 @@ struct Position{float m_positionX=0,m_positionY=0,m_positionZ=0;void Relocate(Pl struct NativeMap{float GetHeight(int,float,float,float,bool,float){return 0;}}nativeMap; struct Player:Unit{ Player(){x=2;} + int repairs=0;void DurabilityRepairAll(bool cost,float,bool guild){assert(!cost&&!guild);++repairs;} std::vector emotes;int faces=0,casts=0;bool casting=false,sit=false,los=true;float distance=2;Session session; ObjectGuid selection; bool visible=true;bool CanSeeOrDetect(GameObject*){return visible;} @@ -67,6 +68,7 @@ struct Player:Unit{ void Position::Relocate(Player*p){m_positionX=p->x;m_positionY=p->y;m_positionZ=p->z;} struct AdventureSocialContext{bool AllowCreateFire=true;std::string Intent="social";AdventureProfessionPolicy::Professions PrimaryProfessions;Position Anchor;std::deque TargetHistory;int departures=0;bool reachable=true;bool Move(Position const&,float){++departures;return reachable;}}; struct AdventureSocialScene{ + bool RepairDone=false; bool Active=true,Approaching=false,Casting=false,WaitingChair=false,Seated=false,Departing=false; std::string Action="social_npc",Kind="npc",TargetKey;ObjectGuid Target{1},PreviousSelection;Position Destination,Origin;float Facing=0; uint32 Elapsed=0,CastElapsed=0,CheckTimer=0,Repaths=0,ServiceVisits=0,ServiceDuration=30000;Sequence Sequence; @@ -211,6 +213,20 @@ int main(){ // Displacement while working must reapproach without advancing the cue. p.distance=8;s.Approaching=false;travel.status.State=ClientlessTravelState::Moving; assert(AdventureSocialActions::Update(&p,s,1000,c)&&s.Approaching&&p.states.empty()); + // Repair requires arrival and a completed valid service scene. No packets/casts. + Unit repairer;repairer.service=true;target=&repairer;c.Intent="economy"; + p.distance=2;p.los=true;friendly=true;approach=true; + s={};s.Kind="repair";s.Action="economy_repair";s.Target.id=1;s.Approaching=true; + s.Sequence.Start(Steps(s.Kind,15000));travel.status.State=ClientlessTravelState::Moving; + int beforeRepairs=p.repairs;oldCasts=p.casts;oldUses=p.session.uses; + assert(AdventureSocialActions::Update(&p,s,1000,c)&&p.repairs==beforeRepairs); + travel.status.State=ClientlessTravelState::Arrived; + active=true;for(int i=0;i<80&&active;++i)active=AdventureSocialActions::Update(&p,s,1000,c); + assert(p.repairs==beforeRepairs+1&&s.RepairDone&&p.casts==oldCasts&&p.session.uses==oldUses); + assert(!AdventureSocialActions::Update(&p,s,1000,c)&&p.repairs==beforeRepairs+1); + // Lost repair permission/target must never repair in the background. + s={};s.Kind="repair";s.Target.id=1;s.Sequence.Start(Steps(s.Kind,15000));repairer.service=false; + assert(!AdventureSocialActions::Update(&p,s,1000,c)&&p.repairs==beforeRepairs+1); } ''' diff --git a/tests/clientless/test_adventure_training_catalog.py b/tests/clientless/test_adventure_training_catalog.py new file mode 100644 index 0000000000..0eded7bd56 --- /dev/null +++ b/tests/clientless/test_adventure_training_catalog.py @@ -0,0 +1,16 @@ +"""Apply training additions and verify editable weights survive repeated migration.""" +from pathlib import Path +import re,sqlite3 +root=Path(__file__).resolve().parents[2] +db=sqlite3.connect(':memory:') +base=(root/'sql/custom/world/2026_10_26_03_adventure_behavior.sql').read_text() +for table in ('vcore_adventure_action','vcore_adventure_action_intent'): + body=re.search(r'CREATE TABLE IF NOT EXISTS '+table+r' \((.*?)\) ENGINE=',base,re.S)[1] + db.execute('CREATE TABLE '+table+' ('+body+')') +sql=(root/'sql/custom/world/2026_10_27_09_adventure_training.sql').read_text().replace('INSERT IGNORE','INSERT OR IGNORE') +db.executescript(sql) +assert db.execute("SELECT kind,min_seconds,max_seconds,requirement FROM vcore_adventure_action WHERE action_key='leisure_training'").fetchone()==('leisure',60,180,'training') +assert db.execute("SELECT intent,weight FROM vcore_adventure_action_intent WHERE action_key='leisure_training'").fetchone()==('leisure',2) +db.execute("UPDATE vcore_adventure_action_intent SET weight=0 WHERE action_key='leisure_training'") +before=list(db.iterdump());db.executescript(sql);assert list(db.iterdump())==before +print('Training catalog: real dummy requirement, leisure-only intent, bounded duration and preserved administrator weights passed.') diff --git a/tests/clientless/test_clientless_damage_notifications.py b/tests/clientless/test_clientless_damage_notifications.py new file mode 100644 index 0000000000..2778ed8f5a --- /dev/null +++ b/tests/clientless/test_clientless_damage_notifications.py @@ -0,0 +1,44 @@ +"""Execute the production map-worker damage enqueue concurrently, without pointers.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/ClientlessPlayerMgr.cpp').read_text() +a=s.index('void ClientlessPlayerMgr::DamageTaken(');b=s.index('{',a);e=b+1;depth=1 +while depth:depth+=(s[e]=='{')-(s[e]=='}');e+=1 +code=r''' +#include +#include +#include +#include +#include +#include +using uint32=unsigned; +struct ObjectGuid{unsigned id;unsigned GetCounter(){return id;}}; +struct Unit{ObjectGuid guid;ObjectGuid GetGUID(){return guid;}}; +struct Session{bool clientless=true;bool IsClientless(){return clientless;}}; +struct Player:Unit{Session session;Session* GetSession(){return &session;}}; +struct ClientlessPlayerMgr{ + struct DamageNotice{ObjectGuid Attacker;uint32 Damage;}; + std::mutex _damageMutex;std::unordered_map> _damageNotices; + void DamageTaken(Player*,Unit*,uint32); +}; +'''+s[a:e]+r''' +int main(){ + ClientlessPlayerMgr mgr;std::vector players(16);std::vector threads; + for(unsigned i=0;iDamageTaken(',start) +assert unit.index('GetCommandStatus(CHEAT_GOD)',start)ModifyHealth(-(int32)damage);',start) +#include +#include +#include +using uint32=uint32_t;using int32=int32_t;using ObjectGuid=int; +constexpr uint32 DEFAULT_PHASE=169; +enum class PhaseShiftFlags:uint32{None=0,AlwaysVisible=1,Inverse=2,InverseUnphased=4,Unphased=8,NoCosmetic=16}; +enum class PhaseFlags:uint32{None=0,Cosmetic=1,Personal=2}; +templatestruct EnumFlag{uint32 bits;EnumFlag(T v):bits(uint32(v)){}EnumFlag(uint32 v):bits(v){}bool HasFlag(T f)const{return (bits&uint32(f))!=0;}EnumFlag operator~()const{return EnumFlag(~bits);}EnumFlag& operator|=(T f){bits|=uint32(f);return *this;}EnumFlag& operator|=(EnumFlag f){bits|=f.bits;return *this;}EnumFlag& operator&=(EnumFlag f){bits&=f.bits;return *this;}}; +struct PhaseShift{ + struct PhaseRef{uint32 Id;EnumFlag Flags{PhaseFlags::None};int References=0;bool operator==(PhaseRef const& p)const{return Id==p.Id;}}; + using PhaseContainer=std::vector;PhaseContainer Phases; + EnumFlag Flags{PhaseShiftFlags::Unphased};int PersonalGuid=0,NonCosmeticReferences=0,CosmeticReferences=0,DefaultReferences=0;bool IsDbPhaseShift=false; + bool CanSee(PhaseShift const&)const;void ModifyPhasesReferences(PhaseContainer::iterator,int32);void UpdateUnphasedFlag(); + void Add(uint32 id){Phases.push_back({id});ModifyPhasesReferences(Phases.end()-1,1);} +}; +namespace Trinity{namespace Containers{ + templatebool Intersects(I a,I b,J c,J d,P p){for(;a!=b;++a)for(auto x=c;x!=d;++x)if(*a==*x&&p(*a,*x))return true;return false;} +}} +''' +for sig in ('bool PhaseShift::CanSee(', 'void PhaseShift::ModifyPhasesReferences(', 'void PhaseShift::UpdateUnphasedFlag('):code+=function(sig)+'\n' +code+=r''' +int main(){PhaseShift player,standard;standard.Add(169); + assert(player.CanSee(standard)&&standard.CanSee(player)); // no explicit membership required + PhaseShift custom;custom.Add(170);assert(!player.CanSee(custom)); + custom.Add(169);assert(player.CanSee(custom)); + custom.ModifyPhasesReferences(custom.Phases.begin()+1,-1);custom.Phases.pop_back();assert(!player.CanSee(custom)); + PhaseShift anotherDefault;assert(player.CanSee(anotherDefault)); +} +''' +with tempfile.TemporaryDirectory() as tmp: + src=Path(tmp)/'phase.cpp';exe=Path(tmp)/'phase';src.write_text(code) + subprocess.run(['g++','-std=c++17',str(src),'-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) +print('Native PhaseShift: implicit/default 169 visibility, genuine custom-phase separation and reference updates passed.')