diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 888db3aa0c..a04d81dd75 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,123 @@ +# Aktuell: Loader auf Nutzerauftrag für24 Bots aktiviert + +Nutzer hat ausdrücklich aktive Testpopulation festgelegt: Allianz10 persistent ++2 temporär, Horde10 persistent+2 temporär. In aktiver Testserverkonfiguration +server/debug/etc/worldserver.conf gesetzt, Loader.Enabled1. Native reload config +und clientless population reload ausgeführt, kein Neustart/Build nötig. +Abschließend nativ bestätigt: alle vier Pools online10/2/10/2, pending0, +manager24, busy0, error leer. PID139425 weiterhin aktiv, Port8085 lauscht. +16 fehlende persistente Bots wurden schrittweise erzeugt/angemeldet; +bestehende temporäre Bots zählen mit.25 fertige Registry-Bots, davon24 online, +ein weiterer Allianz-Temp offline. Levelbereich1..85, Rotation15%/Mindestalter24h +und restliche Einstellungen unverändert. Loader AKTIV LASSEN, NICHT auf die +in den historischen Checkpoints beschriebenen deaktivierten Defaults setzen. +Keine weiteren Codeänderungen/Commits/Pushes für diese Konfigurationsaufgabe. + +--- + +# Aktuell: Profile, Tagesplaner, Botmanager und Loader abgeschlossen + +Nutzer beauftragte Umsetzung nach bestätigter Planung. Branch +feature/clientless-player-core war sauber. Änderungen noch NICHT committet; +kein Push. Vollständige Doku: doc/clientless/population.md. + +Neue AdventurePopulationMgr/Policy mit vier unabhängigen Sollzahlen: +Allianz/Horde jeweils persistent/temporär. Normale World-Updates statt +Startup-Warten; verzögerte einzelne Anmeldungen, asynchrone Snapshots, +SRP6-Accountanlage, generischer Factory-Preflight und atomischer Save ohne +Future-Warten im Loader. Native Vorbereitung bleibt World-Thread; vorhandene +native Login-/Profil-/Katalogpfade enthalten weiterhin synchrone Reads. +Zufällige temporäre Rotation vor Login, Mindestalter und Chance konfigurierbar; +pro Kandidat einmal pro Prozess geprüft, keine erzwungene Online-Abmeldung. +Erst Ersatz speichern, dann Journal, nativ alten Bot löschen, World-Plan +bereinigen, zuletzt Profil/Supply-/Instanzstate/Journal. Persistente und +verbundene Charaktere geschützt. Accounts nur explizit eigene normale Accounts. + +Profile v2 in Character-DB, weiterhin Lua-Sandbox/generated:GUID, keine Dateien +im Sourceprofilbaum. Level1..85/DK>=55, Klasse/Spec/Rolle/Seed, Heimat+Hauptstadt, +levelgerechtes Lieblingsgebiet, Aggression/Heimatbindung plus bisherige +Persönlichkeit. Gewichtete native Bodenmount-Displays/Flugmount- und Petspells, +Anzahlen und Inventar-Whitelist. Erweiterte kulturelle Namensgrammatiken. +Rollen beeinflussen native Spezialisierung/Ausrüstung; Tank-/Heiler-Kampf-AI +und volle Klassenrotationen weiterhin spätere Arbeit. Alte Generatorprofile +wurden beim Laden erweitert, Identität/alte Persönlichkeit/Berufe erhalten. +Keine handgeschriebenen Profile geändert, keine temporären Overrides. + +Planer speichert24 vollständige Stunden-Slots vor Ausführung in bestehender +World-Tabelle. Gleiche Folge-Intents/-Locations teilen komplette Blockfrist. +Veraltete/unvollständige Tage werden atomisch ersetzt, gleiche Tage geladen. +Bestehender TINYINT-hour-Lesefehler GetUInt32->GetUInt8 behoben. +Heimatbindung, Neugier und Ziel-/Gebietswiederholungen beeinflussen Gewichte, +Gebietsbänder aus neuem editierbarem Katalog; Profillevel maßgeblich. +Auch neutral markierte Orte in Gegnerhauptstädten sind nun ausgeschlossen. +Manager nur für generierte versionierte Profile: sichere Zustände, Level/XP, +Training/Talente/Riding/fehlende oder unpassende Ausrüstung korrigieren. +Geburt und Erhaltung nutzen dieselbe Armor-/Stat-/Spell-/Level-/Use-Prüfung. +Volle Rucksäcke nativ leeren, angelegte Ausrüstung unberührt, aktuelle +Vorratsstacks/Whitelist/Werkzeuge/Leine/Angel/Taschen geschützt. + +WICHTIGER Integrationsfix: alter ClientlessInstanceVisitResume startete alle +Instanzbesucher synchron in OnStartup und umging die neuen Quoten. Jetzt nur +asynchrones verzögertes/gepacetes Wiederanmelden NICHT-generierter Besucher +in OnUpdate. Generator-eigene Instanzbesucher laufen ausschließlich über die +Quoten. Fehlende Generator-Tabelle lässt alte manuelle Wiederaufnahme bestehen. +Nativ bewiesen: bei finalem Start keine Clientless-Anmeldung trotz gespeicherter +Instanzvisite36, während Loader deaktiviert/alle Sollzahlen0 waren. + +Migrationen world/characters2026_10_29_00 auf TEST-DB zweimal angewendet +(78 Gebiete). Beide SQL-Dateien wegen Ignore explizit vorgemerkt. +75/75 Tests im abschließenden Lauf erfolgreich; Debug-j10 gebaut/installiert. +Letzter Testserver PID139425: frische Ready nach11s, Prozess und Port8085 +bestätigt. Native Tests: beide Fraktionen Level1 und85, vier getrennte Pools, +Stufe60-Schutzkrieger37 mit Tankprofil/Schutzbaum/Waffe+Schild; Level1->5 +administrativ verändert und auf1 korrigiert, Planhash unverändert; alter +Testtag ersetzt; gleiche Pläne nach echtem Neustart bytegleich. +Rotation37->38: alter Nativechar/Profil/Plan/Supply-/Instanzstate weg, +Journal leer. Bestehende29..32 und retired27/28 erhalten. Native Inventarhashes +für menschlichen21 und Arthoran26 vor/nach identisch. + +Eigene neue normale Accounts7 (Allianz) /8 (Horde). Neue persistente Testbots: +33 Tinkbin Gnom-Krieger1,34 Sylaria Blutelf-Schurkin1, +35 Karggan Zwergen-Priester85,36 Edwin Untoten-Krieger85; +38 Renaarna Worgen-Priesterin85 temporär. Diese fünf abschließend OFFLINE. +Testbot37 Elaisia wurde ausdrücklich für Rotation erzeugt und nativ gelöscht. +36 behält gespeicherte Instanz-Anreise; beim nächsten freigegebenen Login +normaler vorhandener Instanz-/Zeitbudget-Abgleich. Kein automatischer Start +unter deaktiviertem Loader. Arthoran26 bleibt OFFLINE/unverändert. +29 Aelion und31 Lenara wieder ONLINE in Goldhain, Intents gestoppt, +schedule0 und grouped0. Vor Login nativ offline GameTele435 positioniert, +nach Profilinitialisierung per Watcher gestoppt, keine Profiländerung dafür. +30 Zanzan nativ zu GameTele823 SenjinVillage/Durotar zurückgesetzt,32 Aldren +zu GameTele1040 TheDeadField/Silberwald nahe ursprünglichem NPC-Ziel; +beide online mit ursprünglichen Social-Zielen823/poi_npc_321922 angefordert. +Das waren administrative Test-Rückstellungen, kein Reisebeleg. + +Aktive Testkonfiguration dauerhaft auf Defaults zurückgesetzt: +Loader.Enabled0, alle vier Sollzahlen0, Min1/Max85, Rotation15%, Mindestalter24h, +Intervall2000ms, Manager300s. Bereits manuell angemeldete vier Bots bleiben +online. Konfigurationsänderung: reload config, dann clientless population reload. +Diagnose population status/reload, generate profile GUID, adventure plan GUID. +Kein Commit/Push dieses neuen Arbeitsstands beauftragt. + +Zwei während Tests gefundene native Abstürze behoben: LOGIN_INS_ACCOUNT braucht +CONNECTION_BOTH für asynchrones SRP6-Insert; Security-Abfrage nutzt tatsächliche +AccountID/SecurityLevel-Spalten. Item-Spell-Sentinel4294967295 erreichte +ApplyEquipCooldown-Assert; Generator schließt fehlende Spellmetadaten aus, +nativer Cooldown-Code prüft GetSpellInfo statt Assert. Factory-Shutdown gibt +wartende Offline-Players vor Map-Unload frei. Ein bekannter temporärer Auth-DB- +Ausfall10.0.0.1 wurde durch SIGTERM des Fehlstarts und zweiten Start überstanden; +keine Remote-Infrastrukturänderung. Aktuell Testserver bereit. + +Restliche Abnahme: große1000er-Last, visuelle Reisen/Interaktionen und volle +Klassen-/Rollen-Kampflogik. Es wurden bestehende TravelGraph/MMAP-Fallbacks und +fehlende Map719-Terraindaten beobachtet; Navigationsüberarbeitung bleibt auf +Nutzerwunsch vertagt. Nicht als erfolgreich geprüfte Fernreise ausgeben. +Belege: checkpoints/2026-10-28-population/; Logs /tmp/vcore-population-*.log. +DB-Helfer /tmp/vcore-population-db.py (Credentials nur aus lokaler Konfiguration, +keine Secrets ausgeben). Originals- und Dayhash-Vergleiche in Checkpoint. + +--- + # Aktuell: Automatische Botgruppen, Raid und Gruppenbildung Nutzer beauftragte Umsetzung der gemeinsam bestätigten Planung. Vollständige diff --git a/doc/clientless/checkpoints/2026-10-28-population/config-defaults.txt b/doc/clientless/checkpoints/2026-10-28-population/config-defaults.txt new file mode 100644 index 0000000000..f92cdd2552 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/config-defaults.txt @@ -0,0 +1,11 @@ +Clientless.Adventure.Loader.Enabled = 0 +Clientless.Adventure.Loader.Alliance.Persistent = 0 +Clientless.Adventure.Loader.Alliance.Temporary = 0 +Clientless.Adventure.Loader.Horde.Persistent = 0 +Clientless.Adventure.Loader.Horde.Temporary = 0 +Clientless.Adventure.Loader.IntervalMs = 2000 +Clientless.Adventure.Loader.MinLevel = 1 +Clientless.Adventure.Loader.MaxLevel = 85 +Clientless.Adventure.Loader.RotationPercent = 15 +Clientless.Adventure.Loader.RotationMinAgeHours = 24 +Clientless.Adventure.Manager.IntervalSeconds = 300 diff --git a/doc/clientless/checkpoints/2026-10-28-population/database-after.txt b/doc/clientless/checkpoints/2026-10-28-population/database-after.txt new file mode 100644 index 0000000000..a3f08b9e98 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/database-after.txt @@ -0,0 +1,20 @@ +zones +78 +Table Create Table +vcore_adventure_day CREATE TABLE `vcore_adventure_day` (\n `character_guid` int(10) unsigned NOT NULL,\n `local_date` int(10) unsigned NOT NULL,\n `hour_slot` tinyint(3) unsigned NOT NULL,\n `intent` varchar(32) NOT NULL,\n `location_key` varchar(100) NOT NULL,\n PRIMARY KEY (`character_guid`,`local_date`,`hour_slot`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + +character_guid name level race class online persistent state profile_bytes +27 Dagri 60 3 1 0 0 retired 871 +28 Jarana 60 8 1 0 0 retired 870 +29 Aelion 60 4 1 1 0 ready 1761 +30 Zanzan 60 8 1 1 0 ready 1749 +31 Lenara 60 22 8 1 0 ready 1740 +32 Aldren 60 5 6 1 0 ready 1765 +33 Tinkbin 1 7 1 0 1 ready 1456 +34 Sylaria 1 10 4 0 1 ready 1478 +35 Karggan 85 3 5 0 1 ready 1530 +36 Edwin 85 5 1 0 1 ready 1543 +38 Renaarna 85 22 5 0 0 ready 1497 +account_id faction +7 1 +8 2 diff --git a/doc/clientless/checkpoints/2026-10-28-population/disabled-startup.txt b/doc/clientless/checkpoints/2026-10-28-population/disabled-startup.txt new file mode 100644 index 0000000000..585ed099af --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/disabled-startup.txt @@ -0,0 +1,4 @@ +TrinityCore rev. d6fea6ee6831+ 2026-10-28 15:00:41 +0100 (feature/clientless-player-core branch) (Unix, Debug, Static) (worldserver-daemon) +World initialized in 0 minutes 11 seconds +TrinityCore rev. d6fea6ee6831+ 2026-10-28 15:00:41 +0100 (feature/clientless-player-core branch) (Unix, Debug, Static) (worldserver-daemon) ready... +No clientless sessions auto-started with all four targets zero, despite saved generated instance visit36. diff --git a/doc/clientless/checkpoints/2026-10-28-population/final-console.txt b/doc/clientless/checkpoints/2026-10-28-population/final-console.txt new file mode 100644 index 0000000000..916148aa96 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/final-console.txt @@ -0,0 +1,25 @@ +World initialized in 0 minutes 11 seconds +Adventure GUID 29: enabled=0 schedule=0 phase=0 intent='' location='' micro='' deadline=0 detail='stopped' +TC> Adventure GUID 31: enabled=0 schedule=0 phase=0 intent='' location='' micro='' deadline=0 detail='stopped' +Interactions guid=29 grouped=0 leader=0 partner=0 intent='' schedule='' +Interactions guid=31 grouped=0 leader=0 partner=0 intent='' schedule='' +TC> Adventure GUID 30: enabled=1 schedule=0 phase=1 intent='social' location='poi_npc_300050' micro='' deadline=1793208319 detail='travelling to poi_npc_300050' +TC> Adventure GUID 32: enabled=1 schedule=0 phase=1 intent='social' location='poi_npc_321922' micro='' deadline=1793208319 detail='waiting for safe player/transport state' +TC> Adventure loader enabled=0 schema=0 busy=0 pools=0/0/0/0 known=0 rotations=0 manager=4 error='' +pool=0 faction=alliance persistent=1 target=0 online=0 pending=0 saved=0 +pool=1 faction=alliance persistent=0 target=0 online=0 pending=0 saved=0 +pool=2 faction=horde persistent=1 target=0 online=0 pending=0 saved=0 +pool=3 faction=horde persistent=0 target=0 online=0 pending=0 saved=0 +TC> All players saved. +TC>Adventure GUID 29: enabled=0 schedule=0 phase=0 intent='' location='' micro='' deadline=0 detail='stopped' +TC> Adventure GUID 31: enabled=0 schedule=0 phase=0 intent='' location='' micro='' deadline=0 detail='stopped' +Interactions guid=29 grouped=0 leader=0 partner=0 intent='' schedule='' +Interactions guid=31 grouped=0 leader=0 partner=0 intent='' schedule='' +TC> Adventure GUID 30: enabled=1 schedule=0 phase=2 intent='social' location='poi_tele_823' micro='social_comment' deadline=1793208765 detail='social scene: comment' +TC>Adventure GUID 32: enabled=1 schedule=0 phase=2 intent='social' location='poi_npc_321922' micro='social_listen' deadline=1793208766 detail='social scene: listen' +TC> Adventure loader enabled=0 schema=0 busy=0 pools=0/0/0/0 known=0 rotations=0 manager=4 error='' +pool=0 faction=alliance persistent=1 target=0 online=0 pending=0 saved=0 +pool=1 faction=alliance persistent=0 target=0 online=0 pending=0 saved=0 +pool=2 faction=horde persistent=1 target=0 online=0 pending=0 saved=0 +pool=3 faction=horde persistent=0 target=0 online=0 pending=0 saved=0 +TC> All players saved. diff --git a/doc/clientless/checkpoints/2026-10-28-population/loader-enabled-24.txt b/doc/clientless/checkpoints/2026-10-28-population/loader-enabled-24.txt new file mode 100644 index 0000000000..d21dd934a2 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/loader-enabled-24.txt @@ -0,0 +1,5 @@ +TC>Adventure loader enabled=1 schema=1 busy=0 pools=10/2/10/2 known=25 rotations=0 manager=24 error='' +pool=0 faction=alliance persistent=1 target=10 online=10 pending=0 saved=10 +pool=1 faction=alliance persistent=0 target=2 online=2 pending=0 saved=3 +pool=2 faction=horde persistent=1 target=10 online=10 pending=0 saved=10 +pool=3 faction=horde persistent=0 target=2 online=2 pending=0 saved=2 diff --git a/doc/clientless/checkpoints/2026-10-28-population/native-equipment.txt b/doc/clientless/checkpoints/2026-10-28-population/native-equipment.txt new file mode 100644 index 0000000000..d7614720d7 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/native-equipment.txt @@ -0,0 +1,117 @@ +guid name level talentTree persistent state +33 Tinkbin 1 0 0 1 ready +34 Sylaria 1 0 0 1 ready +35 Karggan 85 795 0 1 ready +36 Edwin 85 746 0 1 ready +37 Elaisia 60 845 0 0 ready +guid slot itemEntry +33 0 63278 +33 1 27945 +33 2 59113 +33 4 3733 +33 5 65705 +33 6 6087 +33 7 57417 +33 8 60907 +33 9 15463 +33 10 56714 +33 11 59116 +33 12 12846 +33 13 40483 +33 14 60927 +33 15 65687 +33 16 56691 +33 19 54443 +33 20 54443 +33 21 54443 +33 22 54443 +34 0 9978 +34 1 27945 +34 2 59112 +34 4 61050 +34 5 59091 +34 6 56699 +34 7 2033 +34 8 60929 +34 9 60939 +34 10 57717 +34 11 59114 +34 12 12846 +34 13 40483 +34 14 65721 +34 15 56679 +34 16 6504 +34 17 28979 +34 19 54443 +34 20 54443 +34 21 54443 +34 22 54443 +35 0 75073 +35 1 58699 +35 2 75091 +35 4 75088 +35 5 75086 +35 6 75072 +35 7 75095 +35 8 75098 +35 9 75063 +35 10 75068 +35 11 56432 +35 12 58614 +35 13 56351 +35 14 75065 +35 15 63461 +35 17 65172 +35 19 54443 +35 20 54443 +35 21 54443 +35 22 54443 +36 0 75126 +36 1 75078 +36 2 75119 +36 4 75135 +36 5 75123 +36 6 75136 +36 7 75120 +36 8 75124 +36 9 75122 +36 10 58648 +36 11 75071 +36 12 56132 +36 13 73164 +36 14 75077 +36 15 56342 +36 19 54443 +36 20 54443 +36 21 54443 +36 22 54443 +37 0 77867 +37 1 21809 +37 2 21805 +37 4 23300 +37 5 22197 +37 6 13498 +37 7 31125 +37 8 22205 +37 9 77734 +37 10 12058 +37 11 19873 +37 12 19947 +37 13 21488 +37 14 22337 +37 15 19104 +37 16 18756 +37 19 54443 +37 20 54443 +37 21 54443 +37 22 54443 +guid spells +33 24 +34 15 +35 109 +36 110 +37 85 +guid talent_spells +35 22 +36 21 +37 13 diff --git a/doc/clientless/checkpoints/2026-10-28-population/original-inventory-after.txt b/doc/clientless/checkpoints/2026-10-28-population/original-inventory-after.txt new file mode 100644 index 0000000000..dc29878843 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/original-inventory-after.txt @@ -0,0 +1,3 @@ +guid items inventory_hash +21 26 c684ea37abcbdce68e151d144c823fb43abd94f0ec860e03e35738845e37172a +26 24 e1094ed84b20084243dac5338d88814eb54c3bc4ddd3ed64a1a4bca455013a93 diff --git a/doc/clientless/checkpoints/2026-10-28-population/original-inventory-before.txt b/doc/clientless/checkpoints/2026-10-28-population/original-inventory-before.txt new file mode 100644 index 0000000000..dc29878843 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/original-inventory-before.txt @@ -0,0 +1,3 @@ +guid items inventory_hash +21 26 c684ea37abcbdce68e151d144c823fb43abd94f0ec860e03e35738845e37172a +26 24 e1094ed84b20084243dac5338d88814eb54c3bc4ddd3ed64a1a4bca455013a93 diff --git a/doc/clientless/checkpoints/2026-10-28-population/positions-after.txt b/doc/clientless/checkpoints/2026-10-28-population/positions-after.txt new file mode 100644 index 0000000000..31a67bff89 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/positions-after.txt @@ -0,0 +1,11 @@ +guid name online map position_x position_y position_z +26 Arthoran 0 0 -11683.5 -2317.07 25.1635 +29 Aelion 1 0 -9448.55 68.236 56.3225 +30 Zanzan 1 1 -826.364 -4879.64 19.5037 +31 Lenara 1 0 -9448.55 68.236 56.3225 +32 Aldren 1 0 1082.5 1578.14 27.8342 +33 Tinkbin 0 0 -5144.9 453.528 393.232 +34 Sylaria 0 530 8713 -6649.58 72.7513 +35 Karggan 0 0 -8622.47 407.783 102.924 +36 Edwin 0 0 -7065.2 -2602.84 256.074 +38 Renaarna 0 0 -8733.04 543.374 101.123 diff --git a/doc/clientless/checkpoints/2026-10-28-population/restart-plan-after.txt b/doc/clientless/checkpoints/2026-10-28-population/restart-plan-after.txt new file mode 100644 index 0000000000..3648310a7b --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/restart-plan-after.txt @@ -0,0 +1,6 @@ +character_guid local_date slots day_hash +33 20261028 24 ae72eb55c08e2d19c7de777e50778181a1ec37e7a7ca26aeb576b1255bcd0571 +34 20261028 24 ba3246dc3dbffed593e26672ec4d15dace181d2bd8b121fa9b34e2a96b60a964 +35 20261028 24 5fe768de3439ffc9adf00b9694f9bcdd5e91f1a2d1aa46724d04173300203536 +36 20261028 24 81c6f2da21e74edda3ff32e25cb3af0764f8ec13461e8a40d6efcf816a749cd0 +37 20261028 24 5d0052af0910553f70e9ccf411da0386b58e8911a63d2ff540358cb1fe8d1f3f diff --git a/doc/clientless/checkpoints/2026-10-28-population/restart-plan-before.txt b/doc/clientless/checkpoints/2026-10-28-population/restart-plan-before.txt new file mode 100644 index 0000000000..3648310a7b --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/restart-plan-before.txt @@ -0,0 +1,6 @@ +character_guid local_date slots day_hash +33 20261028 24 ae72eb55c08e2d19c7de777e50778181a1ec37e7a7ca26aeb576b1255bcd0571 +34 20261028 24 ba3246dc3dbffed593e26672ec4d15dace181d2bd8b121fa9b34e2a96b60a964 +35 20261028 24 5fe768de3439ffc9adf00b9694f9bcdd5e91f1a2d1aa46724d04173300203536 +36 20261028 24 81c6f2da21e74edda3ff32e25cb3af0764f8ec13461e8a40d6efcf816a749cd0 +37 20261028 24 5d0052af0910553f70e9ccf411da0386b58e8911a63d2ff540358cb1fe8d1f3f diff --git a/doc/clientless/checkpoints/2026-10-28-population/summary.txt b/doc/clientless/checkpoints/2026-10-28-population/summary.txt new file mode 100644 index 0000000000..ff164657b0 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/summary.txt @@ -0,0 +1,13 @@ +Profile/planner/population implementation complete on feature/clientless-player-core. +75/75 production-code regression scripts passed; final Debug j10 built and installed. +Worldserver139425 ready after11s, port8085 listening. No push/commit of this WIP. +Loader disabled; four targets0, level1..85, rotation15%, minimum age24h. +Aelion29 and Lenara31 manually online/stopped/ungrouped in Goldshire. +Zanzan30 and Aldren32 restored to native Durotar/Silverpine home anchors/social goals. +New33..36 persistent and38 temporary offline. Dedicated37 rotated/deleted. +Native level1/85 both factions, protection60, profile-level repair, day relog/restart, +expired-day replacement and native rotation/profile+day cleanup verified. +Generated saved instance visitor36 does not auto-start under disabled/zero quotas; +legacy unowned recovery remains asynchronous and paced. Existing21/26 inventory hashes match. +Large populations, visual gameplay, complete role AI and navigation improvements await acceptance. +See population.md and SESSION-RESUME.md for contracts, caveats and exact state. diff --git a/doc/clientless/checkpoints/2026-10-28-population/tests.log b/doc/clientless/checkpoints/2026-10-28-population/tests.log new file mode 100644 index 0000000000..ab5a139157 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/tests.log @@ -0,0 +1,167 @@ +TEST tests/clientless/test_adventure_area_node.py +Production area destination excludes outside and unreachable graph nodes. +TEST tests/clientless/test_adventure_async_database.py +Async creation DB contracts: prepared connection masks, native security column names, no future waits, successful-save cache publication and world-update-only loader hook passed +TEST tests/clientless/test_adventure_behavior_policy.py +24-hour generation, shared intents with changing destinations, broad location classification and repetition penalties passed. +TEST tests/clientless/test_adventure_combat_exercise.py +Production exercise: stance/charge/melee/casts, native validation delegation, bounded lifetime, invalid targets and unchanged native phases passed. +TEST tests/clientless/test_adventure_combat_preparation.py +Production talent learner: native tiers/dependencies/class gates, all 26 legal level60 points, no capstone bypass and repeat safety passed. +TEST tests/clientless/test_adventure_combat_reactions.py +Production combat focus, damage ordering, risk filters, corpse radius/delay, flight/group override, blocked escape, danger avoidance and malus-free persisted fallback passed. +TEST tests/clientless/test_adventure_day_persistence.py +Production day planner: all 24 TINYINT slots restored across logins, incomplete/old-day replacement, full transaction and curiosity/level weighting passed +TEST tests/clientless/test_adventure_economy.py +Economy: native service roles, friendly/phase/LOS/bounds gates, no bot service targets and coherent performative cues passed. +TEST tests/clientless/test_adventure_economy_catalog.py +Economy catalog: eleven actions, three faction-bound service anchors, native flag gates and idempotent preservation passed. +TEST tests/clientless/test_adventure_equipment_maintenance.py +Production gear maintenance: same birth/retention gate rejects wrong armor/stats, malformed effects, level, temporary, reputation, map, holiday, native-use and slot mismatches passed +TEST tests/clientless/test_adventure_exploration.py +Exploration: visible native props only, no chests/traps/furniture, phase/map/LOS/range gates and contextual preparation passed. +TEST tests/clientless/test_adventure_exploration_catalog.py +Exploration catalog: ten actions, shared scenes, default wander replacement, explicit category eligibility and preserved edits passed. +TEST tests/clientless/test_adventure_generator_equipment.py +Production equipment affinity: mana-only/direct/triggered procs, caster/physical stat effects and bounded cycles passed +TEST tests/clientless/test_adventure_generator_ownership.py +Production generator commands: batch bounds, dedicated ownership, persistent/online/removing retirement protection and retired login refusal passed +TEST tests/clientless/test_adventure_generator_policy.py +Production generator: 6144 cultural names and 61440 real Lua profiles, profession coherence, personality ranges, escaping and equipment/armor gates passed +TEST tests/clientless/test_adventure_graph_handover.py +Production graph handover waits for ship/taxi/flight/teleport and stops at safe road/node boundaries. +TEST tests/clientless/test_adventure_group_plan.py +Production group plans: leader meeting/goal/deadline inheritance, untouched personal day, manual preparation and instance-state guards passed. +TEST tests/clientless/test_adventure_groups.py +Production recruitment: capitals, different goals, native inviter leadership, joining existing groups, capacity/reservations, raid conversion, planned transition and local contact passed. +TEST tests/clientless/test_adventure_instance.py +Production instance lifecycle: time budget, true entry, denied/shared entry, native rear exit, restart, expired copy, late arrival, HP and return replay passed. +TEST tests/clientless/test_adventure_instance_autoresume.py +Deferred instance script: missing schema/visits stays idle; no login work on startup; saved visitors resume with Adventure controllers and native accounts. +TEST tests/clientless/test_adventure_instance_begin.py +Production entrance travel uses the existing location planner, requires mapping and retains normal social/area travel. +TEST tests/clientless/test_adventure_instance_catalog.py +Instance migrations: outside entrances only, native graph preserved, disabled/missing transitions excluded, edits and saved visits preserved. +TEST tests/clientless/test_adventure_instance_loader.py +Production instance recovery: no startup DB/login work, asynchronous delayed/paced manual recovery, generated-bot quota ownership, absent-schema compatibility and existing preparation ticks passed +TEST tests/clientless/test_adventure_instance_persistence.py +Production instance persistence: phases, original deadline, instance ID, transactional HP marker, failed commit and intent release passed. +TEST tests/clientless/test_adventure_intent_state.py +Production intent arrival, failed/partial travel, deadlines and safe schedule handover passed. +TEST tests/clientless/test_adventure_interactions.py +/tmp/tmpwuo364tp/test.cpp: In member function ‘void AdventureInteractionMgr::Leave(Player*, Record&, const char*)’: +/tmp/tmpwuo364tp/test.cpp:142:69: warning: unused parameter ‘reason’ [-Wunused-parameter] + 142 | void AdventureInteractionMgr::Leave(Player* p,Record& r,char const* reason) + | ~~~~~~~~~~~~^~~~~~ +production interaction manager: invitations, factions, reservations, follow, AFK and deferred departure passed +TEST tests/clientless/test_adventure_leisure.py +Leisure: substantial curved MMAP routes, mirrored return, interrupted return, rejected corridors, actual arrival, companion follow, riding, flight and fishing passed. +TEST tests/clientless/test_adventure_leisure_catalog.py +Leisure migration: native city anchor, shared scene references, pet-walk mapping and preservation of edited weights passed. +TEST tests/clientless/test_adventure_location_import.py +Locations: actual Debug SMALLINT reads retain continent IDs; explicit location eligibility and edited weight overrides passed. +TEST tests/clientless/test_adventure_manager_inventory.py +Production inventory maintenance: only full backpacks, native stack trimming, level-appropriate supplies, tools, leash, fishing pole, containers and untouched equipped slots passed +TEST tests/clientless/test_adventure_micro_reset.py +Micro cleanup ends physical local movement, preserves transit and does not stand up dead players. +TEST tests/clientless/test_adventure_population.py +Production population: four independent faction/persistence targets, fair selection, pending reservations, retries, existing inventory, once-per-boot rotation and connected/persistent protection passed +TEST tests/clientless/test_adventure_profession_catalog.py +Profession catalog: native workstation roles, no workshop/resource inference, idempotence and preserved edits passed. +TEST tests/clientless/test_adventure_profession_profile.py +Production profession parser, real Lua profiles, primary restrictions and secondary permissions passed. +TEST tests/clientless/test_adventure_profession_sightline.py +Native workstation working-surface sightline and blocked geometry rejection passed. +TEST tests/clientless/test_adventure_profession_targets.py +Production profession targets: native locks, phase/spawn/loot, stations, corpses, history and trainers passed. +TEST tests/clientless/test_adventure_profile_selection.py +Production profile selection: real Lua ID dictionaries, exact 9:1 companion weighting, zero/negative/infinite/NaN/oversized weights rejected and legacy companion fallback passed +TEST tests/clientless/test_adventure_pvp.py +Production PvP selector: level boundaries, radius, factions, native legality, navigation, intent, personality and resources passed. +TEST tests/clientless/test_adventure_repair_catalog.py +Repair action catalog: proper native service requirement, economy-only intent and preserved administrator weights passed. +TEST tests/clientless/test_adventure_rest.py +Recovery: authored places, bounds, arrival, occupancy, full rest duration, failure, reload and interruption passed. +TEST tests/clientless/test_adventure_rest_fire.py +Rest fire: shared scene mapping, additive migration, preserved weights and authored creation-place policy passed. +TEST tests/clientless/test_adventure_rest_space.py +Sleep footprint: furniture, body extent, overhead collision, ledges, stairs, missing heights, rotation and scale passed. +TEST tests/clientless/test_adventure_rest_targets.py +Native sleep targets: chair exclusion, phase, despawn, floor search, indoors, water, storey, occupancy, bed/surface preference passed. +TEST tests/clientless/test_adventure_rotation.py +/tmp/tmpn8583ajg/test.cpp: In instantiation of ‘std::string Trinity::StringFormat(const char*, A ...) [with A = {}; std::string = std::__cxx11::basic_string]’: +/tmp/tmpn8583ajg/test.cpp:22:126: required from ‘void Tx::PAppend(const char*, A ...) [with A = {}]’ + 22 | struct Tx{std::vectorsql;templatevoid PAppend(char const*f,A...a){sql.push_back(Trinity::StringFormat(f,a...));}}; + | ~~~~~~~~~~~~~~~~~~~~~^~~~~~~~ +/tmp/tmpn8583ajg/test.cpp:51:75: required from here + 51 | auto barrier=CharacterDatabase.BeginTransaction();barrier->PAppend("DO 0"); + | ~~~~~~~~~~~~~~~~^~~~~~~~ +/tmp/tmpn8583ajg/test.cpp:11:113: warning: format not a string literal and no format arguments [-Wformat-security] + 11 | namespace Trinity{templatestd::string StringFormat(char const* f,A...args){char b[4096];std::snprintf(b,sizeof(b),f,args...);return b;}} + | ~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~ +Production rotation: connected/native-online protection, ownership refusal, native delete failure, world cleanup failure and journal-retained retry before successful profile/plan/state removal passed +TEST tests/clientless/test_adventure_social.py +Production social flow: approach before talk, coherent greeting/talk/listen/farewell, lost targets, native chair confirmation and real fire confirmation passed. +TEST tests/clientless/test_adventure_social_fire.py +Shared fire: native detection/provisioning, buildings/city/parent/zone restrictions, existing fires and outdoor/water checks passed. +TEST tests/clientless/test_adventure_supplies.py +Production supplies: exact10% boundary, bandage-before-food, native lockout fallback, recovery completion, combat potions, mana-only drinking and daily/post-combat top-ups passed. +TEST tests/clientless/test_adventure_supply_catalog.py +Supply migrations: all five roles, level60 netherweave tier, editable/disabled catalog preservation and persistent daily stamp passed. +TEST tests/clientless/test_adventure_supply_items.py +Production native-item delegation: usable bandage tiers, native cooldown/lockout gates, real inventory consumption, self targeting and level60 secondary caps passed. +TEST tests/clientless/test_adventure_training_catalog.py +Training catalog: real dummy requirement, leisure-only intent, bounded duration and preserved administrator weights passed. +TEST tests/clientless/test_adventure_walk.py +Production travel gait reaches native spline, survives repaths, prevents mounting and preserves legacy running defaults. +TEST tests/clientless/test_clientless_character_factory.py +Production native factory: character/profile atomicity, failed preparation/commit, cache publication order, account/security/name/cap gates passed +TEST tests/clientless/test_clientless_companion.py +Native companion: provisioning, delayed summon, ownership, real item target, leash confirmation/timeout, reuse and cleanup passed. +TEST tests/clientless/test_clientless_damage_notifications.py +Production damage handoff: 16 concurrent map-worker writers, ordered GUID-only events, clientless-only/positive damage gates and post-health notification passed. +TEST tests/clientless/test_clientless_near_teleport_observer.py +Production near-teleport observer packet includes actual mover GUID and destination; clientless timestamp refreshed, regular client timestamp preserved. +TEST tests/clientless/test_clientless_skill_preparation.py +Production offline skill preparation: native tier caps, no downgrading, rank/level/race/tier gates and no modification of online players passed +TEST tests/clientless/test_clientless_social_notifications.py +Production social/helper queues: concurrent GUID-only delivery, bounded queues, human/self/zero-damage gates, pet attribution and lethal-cleanup ordering passed +TEST tests/clientless/test_default_phase.py +Native PhaseShift: implicit/default 169 visibility, genuine custom-phase separation and reference updates passed. +TEST tests/clientless/test_flight_area_query.py +Native terrain query map/phase, area/zone ordering and cache passed. +TEST tests/clientless/test_flight_handoff.py +Flight handoff before spline end, terminal landing, return corners, collision stop and actual distance passed. +TEST tests/clientless/test_flight_policy.py +Flight native eligibility, license/profile/zone policy, RP costs and fail-safe return decisions passed. +TEST tests/clientless/test_follow_spline_velocity.py +Native follow: actual player/creature spline velocity, client gait and independent follower speeds passed. +TEST tests/clientless/test_free_flight.py +Flight geometry passed: takeoff/landing, smoothed peak, ceiling, lateral/climbing avoidance, missing terrain and blocked corridors. +TEST tests/clientless/test_free_flight_planner.py +ground planner regressions and dynamic flight/node/location/virtual-exit integration passed +TEST tests/clientless/test_ground_exit_continuation.py +optional exits: repeated probes, retained route, delayed success, forward resume, flight fallback passed +TEST tests/clientless/test_item_equip_cooldown.py +Native item equip cooldown: zero/sentinel/missing spells safely skipped, valid use cooldown preserved and longer/disabled cooldowns untouched passed +TEST tests/clientless/test_local_spline_segments.py +Production local spline clipping passes native path-length validation, tiny remainders and 1000 sampled paths. +TEST tests/clientless/test_lua_reload.py +Lua reload regression tests passed (production reload/bindings, real Lua 5.4). +TEST tests/clientless/test_player_pure_max_skill.py +Native pure skill cap: learned skills return stored cap; missing/deleted/zero-rank skills return0. +TEST tests/clientless/test_portal_transfer.py +Portal near/far ACK and denied same-map transfer regressions passed. +TEST tests/clientless/test_portal_use.py +Interactive portal native click, near/far/global ACK, denial, timeout and target checks passed. +Native portal destination discovery and unsupported/ambiguous spell rejection passed. +Production clientless ACK completion sequence increments only after completed near/far transfers. +TEST tests/clientless/test_shared_group_route.py +Production generic shared routes: same edges/taxi/free flight, independent execution state, final local goal, late catch-up, permissions and stale-goal rejection passed. +TEST tests/clientless/test_taxi_import_disabled.py +Taxi importer preserves disabled rows and their reasons. +TEST tests/clientless/test_transport.py +Transport dock, frame, recorder, cross-map copy and ride regressions passed +TEST tests/clientless/test_transport_grid_loading.py +Core transport grid initialization regressions passed (no CPC dependency). +TOTAL 75 FAILED [] diff --git a/doc/clientless/checkpoints/2026-10-28-population/vcore-population-db.py b/doc/clientless/checkpoints/2026-10-28-population/vcore-population-db.py new file mode 100644 index 0000000000..3735e73cd0 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/vcore-population-db.py @@ -0,0 +1,41 @@ +from pathlib import Path +import os,re,subprocess,sys +root=Path('/home/wow_admin/wow_cata_server/source/TrinityCore') +conf=Path('/home/wow_admin/wow_cata_server/server/debug/etc/worldserver.conf').read_text() +def query(kind,sql): + h,port,u,p,db=re.search(r'^'+kind+r'DatabaseInfo\s*=\s*"([^"]+)"',conf,re.M).group(1).split(';') + env=os.environ.copy();env['MYSQL_PWD']=p + r=subprocess.run(['mysql','--protocol=TCP','--host='+h,'--port='+port,'--user='+u,'--database='+db,'--batch','--connect-timeout=10'],input=sql,text=True,capture_output=True,env=env) + if r.returncode:raise SystemExit(r.stderr.replace(p,'[redacted]')) + return r.stdout + +mode=sys.argv[1] +if mode=='apply': + for kind,name in [('World','sql/custom/world/2026_10_29_00_adventure_population.sql'),('Character','sql/custom/characters/2026_10_29_00_adventure_population.sql')]: + print(query(kind,(root/name).read_text())) +elif mode=='audit': + print(query('World',"SELECT COUNT(*) AS zones FROM vcore_adventure_zone; SHOW CREATE TABLE vcore_adventure_day;")) + print(query('Character',"SELECT g.character_guid,c.name,c.level,c.race,c.class,c.online,g.persistent,g.state,LENGTH(g.profile_lua) profile_bytes FROM vcore_adventure_generated_bot g JOIN characters c ON c.guid=g.character_guid ORDER BY c.guid; SELECT * FROM vcore_adventure_rotation; SELECT * FROM vcore_adventure_bot_account;")) +elif mode=='originals': + print(query('Character',"SELECT ci.guid,COUNT(*) items,SHA2(GROUP_CONCAT(CONCAT(ci.item,':',ii.itemEntry) ORDER BY ci.item SEPARATOR ','),256) inventory_hash FROM character_inventory ci JOIN item_instance ii ON ii.guid=ci.item WHERE ci.guid IN(21,26) GROUP BY ci.guid;")) +elif mode=='dayhash': + print(query('World',"SELECT character_guid,local_date,COUNT(*) slots,SHA2(GROUP_CONCAT(CONCAT(hour_slot,':',intent,':',location_key) ORDER BY hour_slot SEPARATOR '|'),256) day_hash FROM vcore_adventure_day WHERE character_guid BETWEEN 33 AND 50 GROUP BY character_guid,local_date;")) +elif mode=='details': + print(query('Character',"SELECT c.guid,c.name,c.level,c.talentTree,g.persistent,g.state FROM characters c JOIN vcore_adventure_generated_bot g ON g.character_guid=c.guid WHERE c.guid>=33; SELECT ci.guid,ci.slot,i.itemEntry FROM character_inventory ci JOIN item_instance i ON i.guid=ci.item WHERE ci.guid>=33 AND ci.bag=0 AND ci.slot<23 ORDER BY ci.guid,ci.slot; SELECT s.guid,COUNT(*) spells FROM character_spell s WHERE s.guid>=33 GROUP BY s.guid; SELECT t.guid,COUNT(*) talent_spells FROM character_talent t WHERE t.guid>=33 GROUP BY t.guid;")) +elif mode=='expire37': + row=query('Character',"SELECT c.online FROM characters c JOIN vcore_adventure_generated_bot g ON c.guid=g.character_guid WHERE c.guid=37 AND g.persistent=0 AND g.state='ready';") + if row.strip().splitlines()!=['online','0']:raise SystemExit('Test bot37 must be offline and temporary') + print(query('World',"UPDATE vcore_adventure_day SET local_date=20261027 WHERE character_guid=37; SELECT character_guid,local_date,COUNT(*) slots FROM vcore_adventure_day WHERE character_guid=37 GROUP BY character_guid,local_date;")) +elif mode=='positions': + print(query('Character',"SELECT guid,name,online,map,position_x,position_y,position_z FROM characters WHERE guid IN(26,29,30,31,32,33,34,35,36,38);")) +elif mode=='visits': + print(query('Character',"SELECT character_guid,phase,block_end,location_key FROM vcore_adventure_instance_visit ORDER BY character_guid;")) +elif mode=='homes': + print(query('World',"SELECT id,name,map,position_x,position_y,position_z FROM game_tele WHERE id=823 OR name LIKE '%sepul%' OR name='Brill'; SELECT point_key,map,position_x,position_y,position_z FROM vcore_clientless_world_point WHERE point_key='poi_npc_321922';")) +elif mode=='near_home': + print(query('World',"SELECT id,name,map,position_x,position_y,position_z FROM game_tele WHERE map=0 ORDER BY POW(position_x-1097.57,2)+POW(position_y-1587.29,2) LIMIT 3;")) +elif mode=='zone_names': + print(query('World',"SELECT DISTINCT zone_id,region_key FROM vcore_adventure_location WHERE zone_id IN(210,33,5287,5288,616,4709,493,2817,4755,5144,5145,4815,11,51,3) ORDER BY zone_id;")) +elif mode=='plans': + print(query('World',"SELECT character_guid,local_date,COUNT(*) slots,MIN(hour_slot),MAX(hour_slot),COUNT(DISTINCT location_key) locations FROM vcore_adventure_day GROUP BY character_guid,local_date;")) +else:raise SystemExit('unknown mode') diff --git a/doc/clientless/checkpoints/2026-10-28-population/vcore-population-native-retry.txt b/doc/clientless/checkpoints/2026-10-28-population/vcore-population-native-retry.txt new file mode 100644 index 0000000000..a66de79708 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/vcore-population-native-retry.txt @@ -0,0 +1,58 @@ +BOT_LOADER_LOGIN guid=33 pool=0 +Clientless player character GUID 33 (account 7) reached state=Online. +DAY_PLAN guid=33 date=20261028 slots=24 +BOT_LOADER_LOGIN guid=34 pool=2 +Clientless player character GUID 34 (account 8) reached state=Online. +DAY_PLAN guid=34 date=20261028 slots=24 +TC>Adventure loader enabled=1 schema=1 busy=0 pools=1/0/1/0 known=6 rotations=0 manager=2 error='' +pool=0 faction=alliance persistent=1 target=1 online=1 pending=0 saved=1 +pool=1 faction=alliance persistent=0 target=0 online=0 pending=0 saved=2 +pool=2 faction=horde persistent=1 target=1 online=1 pending=0 saved=1 +pool=3 faction=horde persistent=0 target=0 online=0 pending=0 saved=2 +TC> Adventure loader enabled=1 schema=1 busy=1 pools=2/0/2/0 known=6 rotations=0 manager=2 error='' +pool=0 faction=alliance persistent=1 target=2 online=1 pending=0 saved=1 +pool=1 faction=alliance persistent=0 target=0 online=0 pending=0 saved=2 +pool=2 faction=horde persistent=1 target=2 online=1 pending=0 saved=1 +pool=3 faction=horde persistent=0 target=0 online=0 pending=0 saved=2 +BOT_LOADER_LOGIN guid=35 pool=0 +Clientless player character GUID 35 (account 7) reached state=Online. +DAY_PLAN guid=35 date=20261028 slots=24 +BOT_LOADER_LOGIN guid=36 pool=2 +Clientless player character GUID 36 (account 8) reached state=Online. +DAY_PLAN guid=36 date=20261028 slots=24 +TC>Adventure loader enabled=1 schema=1 busy=0 pools=2/0/2/0 known=8 rotations=0 manager=4 error='' +pool=0 faction=alliance persistent=1 target=2 online=2 pending=0 saved=2 +pool=1 faction=alliance persistent=0 target=0 online=0 pending=0 saved=2 +pool=2 faction=horde persistent=1 target=2 online=2 pending=0 saved=2 +pool=3 faction=horde persistent=0 target=0 online=0 pending=0 saved=2 +TC>You changed level of Tinkbin to 5. +BOT_LOADER_LOGIN guid=33 pool=0 +Clientless player character GUID 33 (account 7) reached state=Online. +TC> Adventure loader enabled=1 schema=1 busy=1 pools=2/2/2/2 known=8 rotations=0 manager=4 error='' +pool=0 faction=alliance persistent=1 target=2 online=2 pending=0 saved=2 +pool=1 faction=alliance persistent=0 target=2 online=0 pending=0 saved=2 +pool=2 faction=horde persistent=1 target=2 online=2 pending=0 saved=2 +pool=3 faction=horde persistent=0 target=2 online=0 pending=0 saved=2 +BOT_LOADER_LOGIN guid=29 pool=1 +Clientless player character GUID 29 (account 6) reached state=Online. +DAY_PLAN guid=29 date=20261028 slots=24 +BOT_LOADER_LOGIN guid=30 pool=3 +Clientless player character GUID 30 (account 6) reached state=Online. +DAY_PLAN guid=30 date=20261028 slots=24 +BOT_LOADER_LOGIN guid=31 pool=1 +Clientless player character GUID 31 (account 6) reached state=Online. +DAY_PLAN guid=31 date=20261028 slots=24 +BOT_LOADER_LOGIN guid=32 pool=3 +Clientless player character GUID 32 (account 6) reached state=Online. +DAY_PLAN guid=32 date=20261028 slots=24 +Generated Elaisia guid=37 race=1 class=1 level=60 archetype=warrior persistent=0 seed=2 home=poi_npc_280710 (offline). +TC>return { identity = { name = "Elaisia", home_region = "elwynn", profile_version = 2, level = 60, class = 1, specialization = 2, role = "tank", seed = 2, home_zone = 12, home_capital = 1519, favorite_zone = 1377, favorite_region = "silithus", vocation = "Wandernder Waffenschmied", background = "Herkunft: Elwynn. Sucht auf Reisen Arbeit, neue Wege und Begegnungen." }, behavior = { enabled = true, daily_schedule = true, initial_intent = 'social', initial_location = "poi_npc_280710", initial_duration_seconds = 3600, locale = 'deDE' }, professions = { primary = { "mining", "blacksmithing" } }, personality = { sociability = 0.786, adventurousness = 0.328, aggression = 0.575, home_attachment = 0.418, wanderlust = 0.713, group_loyalty = 0.603, tavern_affinity = 0.311, wilderness_affinity = 0.457, city_affinity = 0.593, danger_tolerance = 0.496 }, region_affinity = { ["elwynn"] = 2.5 }, zone_affinity = { [12] = 4.0, [1377] = 5.0, [1519] = 4.0, }, activity_affinity = { social = 0.589, adventure = 0.761, profession = 0.483, leisure = 0.267, economy = 0.351, rest = 0.513, instance = 0.643, raid = 0.180 }, inventory = { keep_items = {37460,6256,5956,2901,7005,6217} }, movement = { can_ground_mount = true, ground_mount_speed_rate = 2, ground_mounts = {[2404]=1,[2402]=2,[2409]=1,}, ground_mount_count = 3, can_free_fly = true, flight_mounts = {[32235]=2,[32239]=10,[32240]=6,}, flight_mount_count = 3, companions = {[10673]=1,}, companion_count = 1, companion_leash_item = 37460 } } +Clientless player character GUID 37 (account 7) reached state=Online. +DAY_PLAN guid=37 date=20261028 slots=24 +Clientless player character GUID 37 (account 7) reached state=Online. +DAY_PLAN guid=37 date=20261028 slots=24 +TC> Adventure loader enabled=0 schema=1 busy=0 pools=2/2/2/2 known=9 rotations=0 manager=8 error='' +pool=0 faction=alliance persistent=1 target=2 online=2 pending=0 saved=2 +pool=1 faction=alliance persistent=0 target=2 online=2 pending=0 saved=3 +pool=2 faction=horde persistent=1 target=2 online=2 pending=0 saved=2 +pool=3 faction=horde persistent=0 target=2 online=2 pending=0 saved=2 diff --git a/doc/clientless/checkpoints/2026-10-28-population/vcore-population-restart.txt b/doc/clientless/checkpoints/2026-10-28-population/vcore-population-restart.txt new file mode 100644 index 0000000000..e477b6b5e0 --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-28-population/vcore-population-restart.txt @@ -0,0 +1,41 @@ +Clientless player character GUID 36 (account 8) reached state=Online. +BOT_LOADER_LOGIN guid=33 pool=0 +Clientless player character GUID 33 (account 7) reached state=Online. +BOT_LOADER_LOGIN guid=29 pool=1 +Clientless player character GUID 29 (account 6) reached state=Online. +BOT_LOADER_LOGIN guid=34 pool=2 +Clientless player character GUID 34 (account 8) reached state=Online. +BOT_LOADER_LOGIN guid=30 pool=3 +Clientless player character GUID 30 (account 6) reached state=Online. +BOT_LOADER_LOGIN guid=35 pool=0 +Clientless player character GUID 35 (account 7) reached state=Online. +BOT_LOADER_LOGIN guid=31 pool=1 +Clientless player character GUID 31 (account 6) reached state=Online. +BOT_LOADER_LOGIN guid=32 pool=3 +Clientless player character GUID 32 (account 6) reached state=Online. +TC>Adventure loader enabled=1 schema=1 busy=0 pools=2/2/2/2 known=9 rotations=0 manager=8 error='' +pool=0 faction=alliance persistent=1 target=2 online=2 pending=0 saved=2 +pool=1 faction=alliance persistent=0 target=2 online=2 pending=0 saved=3 +pool=2 faction=horde persistent=1 target=2 online=2 pending=0 saved=2 +pool=3 faction=horde persistent=0 target=2 online=2 pending=0 saved=2 +TC> Adventure loader enabled=1 schema=0 busy=1 pools=2/3/2/2 known=9 rotations=0 manager=8 error='' +pool=0 faction=alliance persistent=1 target=2 online=2 pending=0 saved=2 +pool=1 faction=alliance persistent=0 target=3 online=2 pending=0 saved=3 +pool=2 faction=horde persistent=1 target=2 online=2 pending=0 saved=2 +pool=3 faction=horde persistent=0 target=2 online=2 pending=0 saved=2 +BOT_ROTATED_REMOVED guid=37 +BOT_LOADER_LOGIN guid=38 pool=1 +Clientless player character GUID 38 (account 7) reached state=Online. +DAY_PLAN guid=38 date=20261028 slots=24 +TC> Adventure loader enabled=0 schema=1 busy=0 pools=0/0/0/0 known=9 rotations=0 manager=9 error='' +pool=0 faction=alliance persistent=1 target=0 online=2 pending=0 saved=2 +pool=1 faction=alliance persistent=0 target=0 online=3 pending=0 saved=3 +pool=2 faction=horde persistent=1 target=0 online=2 pending=0 saved=2 +pool=3 faction=horde persistent=0 target=0 online=2 pending=0 saved=2 +TC> Clientless player character GUID 29 (account 6) reached state=Online. +TC>Clientless player character GUID 31 (account 6) reached state=Online. +TC>Adventure loader enabled=0 schema=1 busy=0 pools=0/0/0/0 known=9 rotations=0 manager=4 error='' +pool=0 faction=alliance persistent=1 target=0 online=0 pending=0 saved=2 +pool=1 faction=alliance persistent=0 target=0 online=2 pending=0 saved=3 +pool=2 faction=horde persistent=1 target=0 online=0 pending=0 saved=2 +pool=3 faction=horde persistent=0 target=0 online=2 pending=0 saved=2 diff --git a/doc/clientless/generator.md b/doc/clientless/generator.md index 41ba744953..5cba55680f 100644 --- a/doc/clientless/generator.md +++ b/doc/clientless/generator.md @@ -20,7 +20,7 @@ clientless generate retire The short create form defaults to level60, any faction/archetype, persistent, and a time-based seed. When specifying options, supply all five optional values. -Count is1–10 per request, level10–85 (also limited by server maximum), faction is +Count is1–10 per request, level1–85 (also limited by server maximum), faction is `any`, `alliance` or `horde`, and persistence is `persistent` or `temporary`. Each character is committed separately. A failed batch reports its actual completed count and retains already completed characters. @@ -38,10 +38,10 @@ an error can stop an online refresh partway through. The initial offline create pipeline is the atomic operation. Creation leaves bots offline. Explicit login uses the existing AdventureBot Lua -controller. Initially they perform a local social intent for one hour at their -home. Enable the existing daily planner with `clientless adventure schedule - on` when desired. Rotation and automatic population targeting are not -activated by this change. +controller. New profiles enable the persistent daily planner automatically. +The optional four-pool loader, temporary replacement and profile maintenance are +documented in [population.md](population.md); automatic loading remains disabled +by default. ## Identity and profile @@ -88,7 +88,7 @@ The AdventureBot layer supplies: - Native class-trainer spells, using the same read-only `CanTeachSpell` predicate as ordinary native teaching; weapon/armor proficiencies are race/class/level checked. Owned weapon skills are raised to their native level cap. -- A solo damage specialization and valid talent allocation through native talent +- A profile specialization/role and valid talent allocation through native talent prerequisites, tiers, ranks and primary-tree gates. Arms uses the existing explicit build, other archetypes use a legal tier-ordered baseline. These generic builds are not claimed to be optimized raid/PvP builds. @@ -110,8 +110,9 @@ The AdventureBot layer supplies: - Existing daily/post-combat supply restocking upon login. Consumables are native items and continue to use the existing actual item/cooldown logic. -No mounts, pets or optimized class rotations are introduced here. Existing -non-warrior combat limitations remain. Being fully trained is distinct from +Weighted native mount/companion catalogs and riding are now generated; see +[population.md](population.md). Optimized class rotations remain outside this +feature. Existing non-warrior combat limitations remain. Being fully trained is distinct from having class-specific automated combat/support AI. ## Persistence and future rotation diff --git a/doc/clientless/population.md b/doc/clientless/population.md new file mode 100644 index 0000000000..8defe976dd --- /dev/null +++ b/doc/clientless/population.md @@ -0,0 +1,195 @@ +# AdventureBot profiles, daily plans and population + +The generator owns its characters explicitly in `vcore_adventure_generated_bot`. +Lua stays in the character database and is loaded through the existing sandbox +as `generated:`. Hand-written named profiles retain precedence. Neither +names nor account prefixes establish ownership. + +## Profile version 2 + +`identity` records name, level, numeric native class and specialization index, +`role` (`tank`, `healer`, `damage`), seed, racial home zone/capital, favorite +adventure zone/region and RP origin/vocation/background. Race/class combinations +remain native and names use cultural grammars with native validation. + +Generation supports levels 1–85, limited by the configured native maximum. +Death knights require at least 55. Primary profession rights are already in the +profile at low levels, but training waits for native level eligibility. Talents +begin at 10. Warriors/paladins may use protection, death knights blood; priests, +shamans and druids can receive healing specifications. Role metadata and native +specialization/equipment are provided, **not complete tank/healer combat AI**. +Existing class rotation limitations still apply. + +Personality includes aggression, adventurousness, wanderlust, home attachment, +sociability, group loyalty, tavern/wilderness/city affinities and danger tolerance. +Activities and regional preferences are weighted. Low aggression reduces only +proactive attacks, never existing self-defence. Group support and the absolute +no-flee rule for grouped bots remain intact. + +`movement.ground_mounts` contains weighted native creature display IDs for the +existing ground adapter; `flight_mounts` and `companions` contain weighted spell +IDs. Corresponding counts are emitted for inspection; parsed dictionary sizes +are authoritative. Ground mounts begin at 20, flight at 60; native geography, +spell/collision restrictions and flight licenses still apply. Companion choice +is rerolled at the beginning of a walk, not during an active scene. Generation +checks available native spell/model data. `inventory.keep_items` protects tools +and other explicitly permitted backpack items. + +Older generated profiles are upgraded on their next load, preserving their +name/background, personality and professions. The baseline native level becomes +the fixed profile level during this one-time upgrade. Hand-written profiles are +not rewritten. Existing stored day plans are retained. + +## Geography and daily plans + +Apply `sql/custom/world/2026_10_29_00_adventure_population.sql`. Its editable +`vcore_adventure_zone` catalog describes outdoor leveling bands and capital +accessibility for Cataclysm. Reapplying preserves operator edits. Unknown outdoor +bands receive a low planning weight. Regions more than five levels above the bot +are excluded; lower-level home visits remain possible, while adventure favors +level-appropriate regions. Favorite adventure regions exclude capitals. + +Racial home/capital preferences are strong during town activities. Curiosity +flattens preference weights and increases location/region variety. These remain +weights among legal targets, not exclusive destinations. Existing native travel +and MMAP validation still determines whether a planned destination is reachable; +existing failure recovery can select an alternative if a route fails. + +The existing `vcore_adventure_day` world table persists exactly 24 hourly slots +per character/date. Every slot contains both intent and destination **before** +execution. An intent spans one or more complete civil hours; destination changes +inside it are also preplanned. Consecutive equal intent/destination slots share +the end of their complete block, including instance timing. Travel consumes this +budget. Group preparation still uses its leader's meeting location and subsequent +goal. Runtime group adoption does not overwrite members' personal plans. + +Dates and hour boundaries use the server process's local timezone, including +native `mktime` DST handling. Same-day relog/restart restores all 24 slots. An +incomplete or outdated day is atomically replaced and old dates removed. The +controller refreshes the day even while combat/recovery temporarily owns visible +behavior. On the DST fall-back day a repeated civil hour uses the same slot; a +skipped spring hour has no extra synthetic activity. + +## Loader configuration + +Apply `sql/custom/characters/2026_10_29_00_adventure_population.sql` as well. +Defaults are deliberately disabled and have four zero online targets: + +```ini +Clientless.Adventure.Loader.Enabled = 0 +Clientless.Adventure.Loader.Alliance.Persistent = 0 +Clientless.Adventure.Loader.Alliance.Temporary = 0 +Clientless.Adventure.Loader.Horde.Persistent = 0 +Clientless.Adventure.Loader.Horde.Temporary = 0 +Clientless.Adventure.Loader.IntervalMs = 2000 +Clientless.Adventure.Loader.MinLevel = 1 +Clientless.Adventure.Loader.MaxLevel = 85 +Clientless.Adventure.Loader.RotationPercent = 15 +Clientless.Adventure.Loader.RotationMinAgeHours = 24 +Clientless.Adventure.Manager.IntervalSeconds = 300 +``` + +Each target is clamped to 0–5000. Zero suppresses new automated logins for that +pool; changing a target does not forcibly evict already connected bots. Persistent +and temporary Alliance/Horde inventories never substitute for one another. +Loading/Removing sessions reserve capacity. A failed saved bot is retried with +backoff instead of generating unbounded replacements. + +Loading starts during normal world updates, with at least a ten-second initial +delay. It is not part of mandatory server initialization. One login/generation +operation is initiated per interval with round-robin pool selection. Snapshot, +account registration, creation preflight and atomic save use polled asynchronous +database callbacks; the loader never waits on a future. Native character setup, +training/equipping and native deletion still execute on the world thread, one +operation at a time. Existing native login/profile/catalog routines contain +synchronous reads; this is not a claim of a completely asynchronous game core. +Saved generated instance visitors also use these quotas. The former synchronous +WorldScript startup loop no longer starts them independently. Legacy manually +created visitors retain asynchronous, delayed and paced recovery; no generator +schema is required for that legacy path. This prevents saved visits from bypassing +a zero/disabled generated population. + +Failures suspend/retry the loader and never request server shutdown. Large +populations need separate latency/memory/CPU load acceptance. + +When saved inventory is insufficient, new normal accounts with random unknown +credentials are registered using native SRP6 and prepared account inserts. +`vcore_adventure_bot_account` records ownership and faction. Native account +expansion/security/session checks and per-realm character limits are enforced. +Accounts are retained when bots rotate. External/player accounts are not +converted or deleted. + +## Temporary rotation and maintenance + +Rotation is considered once per offline temporary candidate per process, before +login, with configurable chance/minimum age. It remains in the same faction and +temporary pool. First create and atomically save a replacement on an available +owned account (a new account can provide spare capacity). Then journal the pair +in `vcore_adventure_rotation` and mark the old bot `replacing`. Only a ready saved +replacement permits native permanent deletion. Persistent, connected and +non-owned characters cannot pass the deletion checks. Failed creation retains +the original. The journal resumes cleanup after a restart. + +Cleanup verifies native character removal, then commits deletion of the old +world day plan before deleting profile, supply/instance state and the journal. +A failed world cleanup retains the journal for retry. Retired characters from +older manual `retire` operations are retained; retirement is not implicitly +converted to deletion. + +The manager acts on versioned generated profiles only. At login and on staggered +periodic checks it restores fixed level/XP, native class abilities, profession +ranks, available talents, riding and profile equipment. Wrong class is reported, +not converted. Dead/combat/dungeon/transit/casting states defer mutation. A full +backpack is cleaned through native item destruction; equipped slots are untouched. +One stack of each currently appropriate supply, bags, fishing poles and explicit +profile tools/leash survive. Full inventories containing only permitted items +are retained. Missing/invalid gear is repaired through the native equip checks, +not rerolled unconditionally every interval. Ordinary player characters and +hand-written Arthoran profiles are outside this maintenance ownership boundary. + +Console diagnostics: + +```text +clientless population status +clientless population reload +clientless generate profile +clientless adventure plan +``` + +After editing the configuration, use the native `reload config` before +`clientless population reload` (or restart). Reload is refused while a population +operation is pending. Disabling the loader does not disable profile maintenance +for already online generated bots. No forced logout occurs on disable. + +## Technical verification and remaining acceptance + +Production-code tests cover daily persistence with the real TINYINT hour shape, +four pool quotas/retries, replacement ownership and cleanup failures, weighted +Lua dictionaries/companions, full-inventory protection, shared birth/maintenance +gear eligibility, native malformed item-spell cooldown safety and asynchronous +prepared-statement contracts. Existing clientless regressions are retained. + +On the test server, both factions received persistent bots at levels 1 and 85. +All four pool targets were independently reached. A level-60 protection warrior +was generated with its tank profile, native protection tree and weapon/shield. +An administrative level1 -> level5 change was corrected to profile level1 on +relogin; the saved day hash did not change. An expired test day was replaced by +24 current-date slots, and same-day plans survived a complete server restart. +A dedicated temporary test character37 was replaced by38; native character, +profile, day and supply/instance state were removed, with no remaining journal. +Existing characters29–32 were retained. Inventory ownership hashes for human21 +and Arthoran26 remained unchanged. + +Two native defects exposed during these tests were fixed: the account insertion +statement needed asynchronous registration, and an item sentinel spell ID could +reach an asserting equip-cooldown lookup. The generator now rejects missing item +spell data and native equip cooldowns skip missing spells. Unowned characters +retain their prior native behavior for valid item data. + +The test also encountered existing route failures involving unavailable map719 +terrain and NPC/graph destinations. Those use existing fallback behavior; the +MMAP/obstacle/TravelGraph overhaul remains deferred. Neutral-tagged markers in +opposing faction capitals are now excluded explicitly from destination selection. +A large population (for example 1,000 concurrent bots), travel quality, class-role +combat behavior and visual gameplay have not been accepted by these technical +checks. The loader is restored to disabled/zero targets after testing. diff --git a/sql/custom/characters/2026_10_29_00_adventure_population.sql b/sql/custom/characters/2026_10_29_00_adventure_population.sql new file mode 100644 index 0000000000..a10a6ce214 --- /dev/null +++ b/sql/custom/characters/2026_10_29_00_adventure_population.sql @@ -0,0 +1,12 @@ +-- Explicit ownership of automatically created ordinary bot accounts. +CREATE TABLE IF NOT EXISTS vcore_adventure_bot_account ( + account_id INT UNSIGNED NOT NULL PRIMARY KEY, + faction TINYINT UNSIGNED NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Durable replacement journal. New native character and profile must exist +-- before the original temporary character can enter the deletion phase. +CREATE TABLE IF NOT EXISTS vcore_adventure_rotation ( + old_guid INT UNSIGNED NOT NULL PRIMARY KEY, + new_guid INT UNSIGNED NOT NULL, + UNIQUE KEY replacement(new_guid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/sql/custom/world/2026_10_29_00_adventure_population.sql b/sql/custom/world/2026_10_29_00_adventure_population.sql new file mode 100644 index 0000000000..4d88693298 --- /dev/null +++ b/sql/custom/world/2026_10_29_00_adventure_population.sql @@ -0,0 +1,22 @@ +-- Cataclysm outdoor leveling bands. Editable; repeated application preserves edits. +CREATE TABLE IF NOT EXISTS vcore_adventure_zone ( + zone_id INT UNSIGNED NOT NULL PRIMARY KEY, + min_level TINYINT UNSIGNED NOT NULL, + max_level TINYINT UNSIGNED NOT NULL, + enabled TINYINT UNSIGNED NOT NULL DEFAULT 1 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +INSERT IGNORE INTO vcore_adventure_zone (zone_id,min_level,max_level) VALUES +(1,1,10),(12,1,10),(14,1,10),(85,1,10),(141,1,10),(215,1,10), +(3430,1,10),(3524,1,10),(3433,10,20),(3525,10,20),(38,10,20),(40,10,15), +(130,10,20),(17,10,20),(148,10,20),(16,10,20),(44,15,20),(267,20,25), +(10,20,25),(331,20,25),(406,25,30),(11,20,25),(36,30,35),(45,25,30), +(33,25,30),(5287,30,35),(47,30,35),(210,77,80),(405,30,35),(357,35,40),(15,35,40),(4709,30,35), +(3,44,48),(51,47,51),(440,45,50),(400,40,45),(361,45,50), +(490,50,55),(46,49,52),(618,50,55),(28,35,40),(139,40,45), +(4,54,60),(1377,55,60),(3483,58,63),(3521,60,64),(3519,62,65), +(3518,64,67),(3522,65,68),(3523,67,70),(3520,67,70), +(3537,68,72),(495,68,72),(65,71,74),(394,73,75),(66,74,77),(3711,76,78), +(67,77,80),(2817,77,80),(4197,77,80), +(616,80,82),(4922,84,85),(5034,83,84),(5042,82,83),(5144,80,82), +(4815,80,82),(5145,80,82),(8,50,55),(1519,1,85),(1537,1,85),(1657,1,85), +(1637,1,85),(1638,1,85),(1497,1,85),(3487,1,85),(3557,1,85),(3703,60,85),(4395,68,85); diff --git a/src/server/database/Database/Implementation/LoginDatabase.cpp b/src/server/database/Database/Implementation/LoginDatabase.cpp index e12bc52f68..09ce6f169d 100644 --- a/src/server/database/Database/Implementation/LoginDatabase.cpp +++ b/src/server/database/Database/Implementation/LoginDatabase.cpp @@ -60,7 +60,7 @@ void LoginDatabaseConnection::DoPrepareStatements() PrepareStatement(LOGIN_DEL_REALM_CHARACTERS, "DELETE FROM realmcharacters WHERE acctid = ?", CONNECTION_ASYNC); PrepareStatement(LOGIN_REP_REALM_CHARACTERS, "REPLACE INTO realmcharacters (numchars, acctid, realmid) VALUES (?, ?, ?)", CONNECTION_ASYNC); PrepareStatement(LOGIN_SEL_SUM_REALM_CHARACTERS, "SELECT SUM(numchars) FROM realmcharacters WHERE acctid = ?", CONNECTION_ASYNC); - PrepareStatement(LOGIN_INS_ACCOUNT, "INSERT INTO account(username, salt, verifier, reg_mail, email, joindate) VALUES(?, ?, ?, ?, ?, NOW())", CONNECTION_SYNCH); + PrepareStatement(LOGIN_INS_ACCOUNT, "INSERT INTO account(username, salt, verifier, reg_mail, email, joindate) VALUES(?, ?, ?, ?, ?, NOW())", CONNECTION_BOTH); PrepareStatement(LOGIN_INS_REALM_CHARACTERS_INIT, "INSERT INTO realmcharacters (realmid, acctid, numchars) SELECT realmlist.id, account.id, 0 FROM realmlist, account LEFT JOIN realmcharacters ON acctid = account.id WHERE acctid IS NULL", CONNECTION_ASYNC); PrepareStatement(LOGIN_UPD_EXPANSION, "UPDATE account SET expansion = ? WHERE id = ?", CONNECTION_ASYNC); PrepareStatement(LOGIN_UPD_ACCOUNT_LOCK, "UPDATE account SET locked = ? WHERE id = ?", CONNECTION_ASYNC); diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index fe07adb82e..7bae9221cd 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -23215,7 +23215,8 @@ void Player::ApplyEquipCooldown(Item* pItem) for (ItemEffect const& effect : pItem->GetTemplate()->Effects) { // no spell - if (effect.SpellID <= 0) + SpellInfo const* itemSpell = sSpellMgr->GetSpellInfo(effect.SpellID); + if (!itemSpell) continue; // apply proc cooldown to equip auras if we have any @@ -23235,7 +23236,7 @@ void Player::ApplyEquipCooldown(Item* pItem) continue; // Don't replace longer cooldowns by equip cooldown if we have any. - if (GetSpellHistory()->GetRemainingCooldown(sSpellMgr->AssertSpellInfo(effect.SpellID)) > 30 * IN_MILLISECONDS) + if (GetSpellHistory()->GetRemainingCooldown(itemSpell) > 30 * IN_MILLISECONDS) continue; GetSpellHistory()->AddCooldown(effect.SpellID, pItem->GetEntry(), std::chrono::seconds(30)); diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index c7124421de..5254b863b6 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -186,17 +186,25 @@ void AdventureBehaviorMgr::Online(Player * player) TC_LOG_ERROR("server.adventurebot", "ADVENTURE_DISABLED guid=%u missing catalogs", player->GetGUID().GetCounter()); return; } - if (profile->CompanionSpell) - ClientlessCompanionMgr::Prepare(player,profile->CompanionSpell,profile->CompanionLeashItem); + for(auto const& pet:profile->Companions)ClientlessCompanionMgr::Prepare(player,pet.DisplayId,profile->CompanionLeashItem); + if (profile->CompanionSpell)ClientlessCompanionMgr::Prepare(player,profile->CompanionSpell,profile->CompanionLeashItem); Record r; r.Enabled = true; r.Schedule = profile->DailySchedule; r.Random.seed(player->GetGUID().GetCounter() ^ uint32(GameTime::GetGameTime())); auto & record = _records.insert_or_assign(player->GetGUID().GetCounter(), std::move(r)).first->second; + if(record.Schedule)Generate(player,record,DateOf(Local(GameTime::GetGameTime()))); if (RestoreInstance(player,record)) return; if (! record.Schedule && Known(profile->InitialIntent)) SetWanted(player, record, profile->InitialIntent, profile->InitialLocation, GameTime::GetGameTime() + profile->InitialDurationSeconds); } +void AdventureBehaviorMgr::RefreshDay(Player* player) +{ + auto it=_records.find(player->GetGUID().GetCounter());if(it==_records.end() || !it->second.Schedule)return; + auto date=DateOf(Local(GameTime::GetGameTime())); + if(it->second.Date!=date){Generate(player,it->second,date);it->second.Hour=-1;} +} + void AdventureBehaviorMgr::Offline(Player * player) { auto i = _records.find(player->GetGUID().GetCounter()); @@ -228,7 +236,10 @@ void AdventureBehaviorMgr::IntentChanged(Player * player, ClientlessIntent const bool AdventureBehaviorMgr::Allowed(Player * player, AdventureLocation const & location, std::string const & intent) const { auto map = sMapStore.LookupEntry(location.Point.MapId); - return map && map->MapType == MAP_COMMON && sAdventureLocationMgr->Weight(location, intent) > 0 &&(! location.Faction || location.Faction ==(player->GetTeamId() == TEAM_ALLIANCE ? 1 : 2)); + unsigned side=player->GetTeamId()==TEAM_ALLIANCE?1:2; + unsigned capital=AdventureGroupPolicy::CapitalFaction(location.ZoneId?location.ZoneId:location.AreaId); + return map && map->MapType==MAP_COMMON && (!capital || capital==side) && + sAdventureLocationMgr->Weight(location,intent)>0 && (!location.Faction || location.Faction==side); } std::string AdventureBehaviorMgr::PickLocation(Player * player, Record & record, std::string const & intent) @@ -243,10 +254,26 @@ std::string AdventureBehaviorMgr::PickLocation(Player * player, Record & record, { if (! Allowed(player, l, intent)) continue; choices.push_back(& l); - weights.push_back(sAdventureLocationMgr->Weight(l, intent) * Affinity(profile->RegionAffinity, l.Region, 0.2f) * Affinity(profile->ActivityAffinity, l.Point.Category, 1.0f)); + auto zone=profile->ZoneAffinity.find(l.ZoneId); + double region=zone==profile->ZoneAffinity.end()?Affinity(profile->RegionAffinity,l.Region,1.0f):zone->second; + float curiosity=Affinity(profile->Personality,"adventurousness",0.5f); + bool outing=intent=="adventure" || intent=="profession" || intent=="instance" || intent=="raid"; + double home=(!outing && (l.ZoneId==profile->HomeZone || l.ZoneId==profile->HomeCapital || l.Region==profile->HomeRegion))? + 1+12*Affinity(profile->Personality,"home_attachment",0.5f):1; + double setting=AdventureGroupPolicy::Capital(l.ZoneId)?Affinity(profile->Personality,"city_affinity",0.5f): + l.Point.Category=="tavern"?Affinity(profile->Personality,"tavern_affinity",0.5f): + l.IsArea?Affinity(profile->Personality,"wilderness_affinity",0.5f):0.5f; + weights.push_back(sAdventureLocationMgr->Weight(l,intent)*RegionWeight(region,curiosity)*home*(0.25+setting)* + LevelWeight(profile->Level?profile->Level:player->getLevel(),l.MinLevel,l.MaxLevel,outing)* + Affinity(profile->ActivityAffinity,l.Point.Category,1.0f)* + std::pow(Novelty(record.TargetHistory,l.Point.Id),0.5+curiosity)* + std::pow(Novelty(record.PlanRegions,l.Region),outing?0.25+curiosity:0.1)); } auto n = Choose(weights, record.Random); - return n < choices.size() ? choices[n]->Point.Id : ""; + if(n>=choices.size())return ""; + Remember(record.TargetHistory,choices[n]->Point.Id); + Remember(record.PlanRegions,choices[n]->Region); + return choices[n]->Point.Id; } void AdventureBehaviorMgr::Generate(Player * player, Record & record, uint32 date) @@ -257,7 +284,7 @@ void AdventureBehaviorMgr::Generate(Player * player, Record & record, uint32 dat if (auto rows = WorldDatabase.PQuery("SELECT hour_slot,intent,location_key FROM vcore_adventure_day WHERE character_guid=%u AND local_date=%u ORDER BY hour_slot", guid, date)) do { auto f = rows->Fetch(); - uint32 h = f[0].GetUInt32(); + uint32 h = f[0].GetUInt8(); if (h < 24 && Known(f[1].GetString())) { record.Plan[h] = @@ -268,7 +295,11 @@ void AdventureBehaviorMgr::Generate(Player * player, Record & record, uint32 dat } } while (rows->NextRow()); - if (loaded == 24) return; + if (loaded == 24) + { + WorldDatabase.PExecute("DELETE FROM vcore_adventure_day WHERE character_guid=%u AND local_date<>%u",guid,date); + return; + } auto profile = sClientlessLuaEngine->GetAdventureProfile(guid); if (! profile) return; auto pick =[&]() @@ -291,13 +322,14 @@ void AdventureBehaviorMgr::Generate(Player * player, Record & record, uint32 dat AdventureGroupPolicy::Prepare(record.Plan,record.Random,Affinity(profile->Personality,"sociability",0.5f), [&](){return PickLocation(player,record,"groupformation");}); auto tx = WorldDatabase.BeginTransaction(); + tx->PAppend("DELETE FROM vcore_adventure_day WHERE character_guid=%u",guid); for (unsigned h = 0; h < 24; ++ h) { auto key = record.Plan[h].Location; WorldDatabase.EscapeString(key); tx->PAppend("REPLACE INTO vcore_adventure_day (character_guid,local_date,hour_slot,intent,location_key) VALUES (%u,%u,%u,'%s','%s')", guid, date, h, record.Plan[h].Intent.c_str(), key.c_str()); } - WorldDatabase.DirectCommitTransaction(tx); + WorldDatabase.CommitTransaction(tx); TC_LOG_INFO("server.adventurebot", "DAY_PLAN guid=%u date=%u slots=24", guid, date); } @@ -573,7 +605,7 @@ bool AdventureBehaviorMgr::LeisureAvailable(Player* p,Record const& r,AdventureM } // A seat/meal may leave the bot inside. Foot outings must still be able // to navigate back outside; only mounted outings require an outdoor start. - if (a.Requirement=="pet_walk") return ClientlessCompanionMgr::Available(p,profile->CompanionSpell); + if (a.Requirement=="pet_walk") return (!profile->Companions.empty() || ClientlessCompanionMgr::Available(p,profile->CompanionSpell)); if (a.Requirement=="walk" || a.Requirement=="roam") return true; if (!p->IsOutdoors()) return false; if (a.Requirement=="ride") return profile->CanGroundMount && !profile->GroundMounts.empty(); @@ -721,7 +753,7 @@ bool AdventureBehaviorMgr::StartLeisure(Player* p,Record& r,AdventureMicroAction s.Route.push_back(s.Origin); } auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); - if (s.Kind=="pet_walk" && !ClientlessCompanionMgr::Start(p,s.Companion,profile->CompanionSpell,profile->CompanionLeashItem)) + if (s.Kind=="pet_walk" && !ClientlessCompanionMgr::Start(p,s.Companion,sClientlessLuaEngine->SelectCompanionSpell(p->GetGUID().GetCounter()),profile->CompanionLeashItem)) {s={};return false;} s.Active=true; r.Detail="leisure outing: "+s.Kind; @@ -1262,6 +1294,7 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) end.tm_min = 0; end.tm_sec = 0; end.tm_hour = local.tm_hour + 1; + while(end.tm_hour<24 && r.Plan[end.tm_hour].Intent==slot.Intent && r.Plan[end.tm_hour].Location==slot.Location)++end.tm_hour; end.tm_isdst = - 1; time_t deadline = mktime(& end); if (changed) SetWanted(player, r, slot.Intent, slot.Location, deadline); @@ -1868,7 +1901,7 @@ void AdventureBehaviorMgr::ResumePersonalPlan(Player* player) { auto local=Local(now);if(r.Date!=DateOf(local))Generate(player,r,DateOf(local)); auto const& slot=r.Plan[local.tm_hour]; - std::tm end=local;end.tm_min=0;end.tm_sec=0;++end.tm_hour;end.tm_isdst=-1; + std::tm end=local;end.tm_min=0;end.tm_sec=0;++end.tm_hour;while(end.tm_hour<24 && r.Plan[end.tm_hour].Intent==slot.Intent && r.Plan[end.tm_hour].Location==slot.Location)++end.tm_hour;end.tm_isdst=-1; r.Hour=local.tm_hour;SetWanted(player,r,slot.Intent,slot.Location,mktime(&end)); } else if(!r.PersonalDeadline || now ActionHistory, VariantHistory; - std::deque TargetHistory; + std::deque TargetHistory, PlanRegions; AdventureSocialScene Social; AdventureInstancePolicy::Visit Instance; RecoveryScene Recovery; diff --git a/src/server/game/World/AdventureBehaviorPolicy.h b/src/server/game/World/AdventureBehaviorPolicy.h index fe694d38e0..8eedafa1f1 100644 --- a/src/server/game/World/AdventureBehaviorPolicy.h +++ b/src/server/game/World/AdventureBehaviorPolicy.h @@ -49,6 +49,18 @@ inline std::size_t Choose(std::vector const& weights, std::mt19937& rand if (weights.empty() || std::none_of(weights.begin(), weights.end(), [](double w) { return w > 0 && std::isfinite(w); })) return weights.size(); return std::discrete_distribution(weights.begin(), weights.end())(random); } +inline double RegionWeight(double affinity,double adventurousness) +{ + // Curiosity flattens preferences but never removes another legal region. + return std::pow(std::max(0.0,affinity),1.0-0.8*std::clamp(adventurousness,0.0,1.0)); +} +inline double LevelWeight(unsigned level,unsigned minimum,unsigned maximum,bool outing) +{ + if(!minimum || !maximum)return outing?0.15:1.0; + if(level5?0.0:0.1; + if(level<=maximum)return 1.0; + return outing?std::max(0.03,1.0/(1.0+(level-maximum)*0.4)):1.0; +} struct Slot { std::string Intent, Location; diff --git a/src/server/game/World/AdventureCombatMgr.cpp b/src/server/game/World/AdventureCombatMgr.cpp index 0808b5a4b9..646afe8c26 100644 --- a/src/server/game/World/AdventureCombatMgr.cpp +++ b/src/server/game/World/AdventureCombatMgr.cpp @@ -160,6 +160,8 @@ Unit* AdventureCombatMgr::PvPTarget(Player* p) const {auto it=profile->Personality.find(key);return it==profile->Personality.end()?0.0f:it->second;}; auto chance=AdventureCombatPolicy::PvPWillingness(sAdventureBehaviorMgr->CombatIntent(p), trait("adventurousness"),trait("danger_tolerance")); + auto aggression=profile->Personality.find("aggression"); + if(aggression!=profile->Personality.end())chance*=std::clamp(aggression->second,0.0f,1.0f); if(chance<=0 || p->GetHealthPct()GetPowerType()==POWER_MANA && p->GetMaxPower(POWER_MANA) && 100.0f*p->GetPower(POWER_MANA)/p->GetMaxPower(POWER_MANA)GetAdventureProfile(p->GetGUID().GetCounter()); if(intent=="profession" && (!profile || !profile->PrimaryProfessions.count("skinning")))return nullptr; + if(profile) + { + auto aggression=profile->Personality.find("aggression"); + // Stable per-bot/per-target-window willingness, no repeated rolls each tick. + if(aggression!=profile->Personality.end()) + { + auto window=uint32(GameTime::GetGameTime()/30); + auto value=(p->GetGUID().GetCounter()*2654435761u+window*2246822519u)%1000; + if(value>=std::clamp(aggression->second,0.0f,1.0f)*1000)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) diff --git a/src/server/game/World/AdventureGeneratorMgr.cpp b/src/server/game/World/AdventureGeneratorMgr.cpp index e8395e4f66..9c1737d760 100644 --- a/src/server/game/World/AdventureGeneratorMgr.cpp +++ b/src/server/game/World/AdventureGeneratorMgr.cpp @@ -1,9 +1,12 @@ #include "AdventureGeneratorMgr.h" #include "AdventureCombatPreparationPolicy.h" #include "AdventureBehaviorMgr.h" +#include "AdventureGroupPolicy.h" #include "AdventureLocationMgr.h" #include "AdventureProfessionPolicy.h" #include "AdventureSupplyMgr.h" +#include "ClientlessCompanionMgr.h" +#include "Bag.h" #include "CharacterCache.h" #include "Chat.h" #include "ClientlessCharacterFactory.h" @@ -28,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -37,7 +41,7 @@ namespace { using AdventureGeneratorPolicy::Archetype; bool Schema() - {return bool(CharacterDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_generated_bot'"));} + {static bool ready=false;if(!ready)ready=bool(CharacterDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_generated_bot'"));return ready;} uint32 LearnedSkill(uint32 id,uint32& step) { if(auto rank=sSpellMgr->GetSpellLearnSkill(id)) @@ -66,9 +70,12 @@ namespace } void ClassTraining(Player* p) { - if(auto rows=WorldDatabase.Query("SELECT Id FROM trainer WHERE Type=0 ORDER BY Id"))do + static std::vector trainers; + if(trainers.empty())if(auto rows=WorldDatabase.Query("SELECT Id FROM trainer WHERE Type=0 ORDER BY Id"))do + {trainers.push_back(rows->Fetch()[0].GetUInt32());}while(rows->NextRow()); + for(auto trainerId:trainers) { - auto trainer=sObjectMgr->GetTrainer(rows->Fetch()[0].GetUInt32()); + auto trainer=sObjectMgr->GetTrainer(trainerId); if(!trainer || !trainer->IsTrainerValidForPlayer(p))continue; // Repeat for prerequisite chains without granting arbitrary class- // family NPC spells or abilities from a different specialization. @@ -78,7 +85,7 @@ namespace for(auto id:Taught(spell.SpellId)) if(!p->HasSpell(id) && Learnable(p,id))p->LearnSpell(id,false); } - }while(rows->NextRow()); + } for(auto ability:sSkillLineAbilityStore) { auto skill=sSkillLineStore.LookupEntry(ability->SkillLine); @@ -97,7 +104,7 @@ namespace { if(p->getLevel()<10)return true; p->InitTalentForLevel(); - if(!p->LearnPrimaryTalentSpecialization(a.Spec))return false; + if(!p->GetPrimaryTalentTree(p->GetActiveSpec()) && !p->LearnPrimaryTalentSpecialization(a.Spec))return false; auto tabs=sDBCManager.GetTalentTabPages(p->getClass());if(!tabs)return false; if(a.Class==CLASS_WARRIOR && a.Spec==0) AdventureCombatPreparationPolicy::SpendArms60([&](uint32 id,unsigned rank){return p->LearnTalent(id,rank);},[&](){return p->GetFreeTalentPoints();}); @@ -120,8 +127,10 @@ namespace } bool Profession(Player* p,unsigned wanted) { - struct Rank {uint32 Id,Step,Level,Required;};std::vector ranks; - if(auto rows=WorldDatabase.Query("SELECT DISTINCT SpellId,ReqLevel,ReqSkillRank FROM trainer_spell"))do + struct Rank {uint32 Id,Step,Level,Required;}; + static std::unordered_map> catalog; + auto& ranks=catalog[wanted]; + if(ranks.empty())if(auto rows=WorldDatabase.Query("SELECT DISTINCT SpellId,ReqLevel,ReqSkillRank FROM trainer_spell"))do { auto f=rows->Fetch(); for(auto id:Taught(f[0].GetUInt32())) @@ -145,7 +154,7 @@ namespace bool UsefulSpell(uint32 id,Archetype const& a,unsigned depth=0) { if(depth>3)return true; - auto spell=sSpellMgr->GetSpellInfo(id);if(!spell)return true; + auto spell=sSpellMgr->GetSpellInfo(id);if(!spell)return false; bool caster=a.Attribute==ITEM_MOD_INTELLECT; for(auto const& effect:spell->Effects) { @@ -182,47 +191,99 @@ namespace case EQUIPMENT_SLOT_BACK:return type==INVTYPE_CLOAK; case EQUIPMENT_SLOT_MAINHAND: if(a.Class==CLASS_ROGUE)return type==INVTYPE_WEAPON || type==INVTYPE_WEAPONMAINHAND; + if(std::string(AdventureGeneratorPolicy::Role(a))=="tank" && a.Class!=CLASS_DEATH_KNIGHT)return type==INVTYPE_WEAPON || type==INVTYPE_WEAPONMAINHAND; if(a.Attribute==ITEM_MOD_INTELLECT)return type==INVTYPE_2HWEAPON && item->GetSubClass()==ITEM_SUBCLASS_WEAPON_STAFF; return type==INVTYPE_2HWEAPON; - case EQUIPMENT_SLOT_OFFHAND:return a.Class==CLASS_ROGUE && (type==INVTYPE_WEAPON || type==INVTYPE_WEAPONOFFHAND); + case EQUIPMENT_SLOT_OFFHAND: + if(std::string(AdventureGeneratorPolicy::Role(a))=="tank" && a.Class!=CLASS_DEATH_KNIGHT)return type==INVTYPE_SHIELD; + return a.Class==CLASS_ROGUE && (type==INVTYPE_WEAPON || type==INVTYPE_WEAPONOFFHAND); case EQUIPMENT_SLOT_RANGED: return a.Class==CLASS_HUNTER?item->IsRangedWeapon():a.Armor==ITEM_SUBCLASS_ARMOR_CLOTH && item->GetClass()==ITEM_CLASS_WEAPON && item->GetSubClass()==ITEM_SUBCLASS_WEAPON_WAND; default:return false; } } - bool Equip(Player* p,Archetype const& a,std::mt19937& random,std::string& error) + std::string ProfileMovement(unsigned race,unsigned level,std::mt19937& random) + { + std::vector ground; + switch(race) + { + case 3:ground={6777,6898,6899};break; + case 4:ground={8394,10789,10793};break; + case 7:ground={10873,10969,17453};break; + case 11:ground={34406,35710,35711};break; + case 2:ground={580,6654,6653};break; + case 5:ground={17462,17463,17464};break; + case 6:ground={18989,18990,18991};break; + case 8:ground={10796,10799,8395};break; + case 10:ground={34795,35018,35020};break; + case 9:ground={87090};break; + case 22:ground={103195,103196};break; + default:ground={458,470,472};break; + } + std::ostringstream lua;lua<<"movement = { can_ground_mount = "<<(level>=20?"true":"false") + <<", ground_mount_speed_rate = "<<(level>=40?2.0:1.6)<<", ground_mounts = {"; + unsigned count=0; + if(level>=20)for(auto id:ground)if(auto spell=sSpellMgr->GetSpellInfo(id)) + for(auto const& effect:spell->Effects)if(effect.ApplyAuraName==SPELL_AURA_MOUNTED) + if(auto creature=sObjectMgr->GetCreatureTemplate(effect.MiscValue)) + if(auto model=creature->GetFirstValidModel()) + {lua<<"["<CreatureDisplayID<<"]="<<(1+random()%10)<<",";++count;break;} + lua<<"}, ground_mount_count = "<=60)for(auto id:AdventureGeneratorPolicy::Faction(race)==1?std::vector{32235,32239,32240}:std::vector{32243,32244,32245}) + if(sSpellMgr->GetSpellInfo(id)){lua<<"["<GetSpellInfo(id)){lua<<"["<GetSpellInfo(10673)){lua<<"[10673]=1,";++pets;} + lua<<"}, companion_count = "<::infinity(); + if(!Slot(item,slot,a) || + !AdventureGeneratorPolicy::EligibleItem(p->getLevel(),std::max(0,item->GetRequiredLevel()),item->GetQuality(),item->GetBaseItemLevel(), + item->GetDuration() || (item->GetFlags()&ITEM_FLAG_CONJURED),item->GetRequiredReputationFaction() || item->GetArea() || (item->GetMap()!=0 && item->GetMap()!=uint32(-1)) || item->GetHolidayID()!=HOLIDAY_NONE || item->GetRandomProperty() || item->GetRandomSuffix()) || + p->CanUseItem(item)!=EQUIP_ERR_OK)return invalid; + if(item->GetClass()==ITEM_CLASS_ARMOR && slot!=EQUIPMENT_SLOT_NECK && slot!=EQUIPMENT_SLOT_BACK && + slot!=EQUIPMENT_SLOT_FINGER1 && slot!=EQUIPMENT_SLOT_FINGER2 && slot!=EQUIPMENT_SLOT_TRINKET1 && slot!=EQUIPMENT_SLOT_TRINKET2 && slot!=EQUIPMENT_SLOT_OFFHAND && + item->GetSubClass()!=AdventureGeneratorPolicy::Armor(a,p->getLevel()))return invalid; + bool effects=true; + for(auto const& effect:item->Effects)if(effect.SpellID && !UsefulSpell(effect.SpellID,a))effects=false; + if(!effects)return invalid; + float stat=0,wrong=0; + for(unsigned index=0;indexGetItemStatType(index);float value=item->GetStatValue(index,p); + if(type==int(a.Attribute))stat+=value; + else if(type==ITEM_MOD_STRENGTH || type==ITEM_MOD_AGILITY || type==ITEM_MOD_INTELLECT || + (a.Attribute==ITEM_MOD_INTELLECT && (type==ITEM_MOD_ATTACK_POWER || type==ITEM_MOD_RANGED_ATTACK_POWER)) || + (a.Attribute!=ITEM_MOD_INTELLECT && (type==ITEM_MOD_SPIRIT || type==ITEM_MOD_MANA || type==ITEM_MOD_MANA_REGENERATION || type==ITEM_MOD_SPELL_POWER || type==ITEM_MOD_SPELL_PENETRATION)))wrong+=value; + } + if(wrong>0 && stat==0)return invalid; + return item->GetBaseItemLevel()+stat*2-wrong; + } + bool Equip(Player* p,Archetype const& a,std::mt19937& random,std::string& error,bool missingOnly=false) { for(unsigned slot=EQUIPMENT_SLOT_START;slotGetItemByPos(INVENTORY_SLOT_BAG_0,slot)) + if(std::isfinite(EquipmentScore(p,current->GetTemplate(),slot,a)))continue; if(slot==EQUIPMENT_SLOT_BODY || slot==EQUIPMENT_SLOT_TABARD || - (slot==EQUIPMENT_SLOT_OFFHAND && a.Class!=CLASS_ROGUE) || + (slot==EQUIPMENT_SLOT_OFFHAND && a.Class!=CLASS_ROGUE && (std::string(AdventureGeneratorPolicy::Role(a))!="tank" || a.Class==CLASS_DEATH_KNIGHT)) || (slot==EQUIPMENT_SLOT_RANGED && a.Class!=CLASS_HUNTER && a.Armor!=ITEM_SUBCLASS_ARMOR_CLOTH))continue; struct Candidate {uint32 Id;float Score;};std::vector candidates; uint32 salt=random(); for(auto const& pair:*sObjectMgr->GetItemTemplateStore()) { auto const* item=&pair.second; - if(!Slot(item,slot,a) || p->GetItemCount(pair.first)>0 || - !AdventureGeneratorPolicy::EligibleItem(p->getLevel(),std::max(0,item->GetRequiredLevel()),item->GetQuality(),item->GetBaseItemLevel(), - item->GetDuration() || (item->GetFlags()&ITEM_FLAG_CONJURED),item->GetRequiredReputationFaction() || item->GetArea() || (item->GetMap()!=0 && item->GetMap()!=uint32(-1)) || item->GetHolidayID()!=HOLIDAY_NONE || item->GetRandomProperty() || item->GetRandomSuffix()) || - p->CanUseItem(item)!=EQUIP_ERR_OK)continue; - if(item->GetClass()==ITEM_CLASS_ARMOR && slot!=EQUIPMENT_SLOT_NECK && slot!=EQUIPMENT_SLOT_BACK && - slot!=EQUIPMENT_SLOT_FINGER1 && slot!=EQUIPMENT_SLOT_FINGER2 && slot!=EQUIPMENT_SLOT_TRINKET1 && slot!=EQUIPMENT_SLOT_TRINKET2 && - item->GetSubClass()!=AdventureGeneratorPolicy::Armor(a,p->getLevel()))continue; - bool effects=true; - for(auto const& effect:item->Effects)if(effect.SpellID && !UsefulSpell(effect.SpellID,a))effects=false; - if(!effects)continue; - float stat=0,wrong=0; - for(unsigned index=0;indexGetItemStatType(index);float value=item->GetStatValue(index,p); - if(type==int(a.Attribute))stat+=value; - else if(type==ITEM_MOD_STRENGTH || type==ITEM_MOD_AGILITY || type==ITEM_MOD_INTELLECT || - (a.Attribute==ITEM_MOD_INTELLECT && (type==ITEM_MOD_ATTACK_POWER || type==ITEM_MOD_RANGED_ATTACK_POWER)) || - (a.Attribute!=ITEM_MOD_INTELLECT && (type==ITEM_MOD_SPIRIT || type==ITEM_MOD_MANA || type==ITEM_MOD_MANA_REGENERATION || type==ITEM_MOD_SPELL_POWER || type==ITEM_MOD_SPELL_PENETRATION)))wrong+=value; - } - if(wrong>0 && stat==0)continue; - candidates.push_back({pair.first,item->GetBaseItemLevel()+stat*2-wrong+float((pair.first*2654435761u+salt)%100)/100}); + if(p->GetItemCount(pair.first)>0)continue; + auto score=EquipmentScore(p,item,slot,a); + if(!std::isfinite(score))continue; + candidates.push_back({pair.first,score+float((pair.first*2654435761u+salt)%100)/100}); } std::sort(candidates.begin(),candidates.end(),[](Candidate const& x,Candidate const& y){return x.Score==y.Score?x.Idy.Score;}); if(!candidates.empty()) @@ -246,9 +307,10 @@ namespace if(!p->EquipItem(destination,item.get(),true)){error="Native equipment placement failed.";return false;} item.release();equipped=true;break; } - bool required=slot==EQUIPMENT_SLOT_MAINHAND || (a.Class==CLASS_HUNTER && slot==EQUIPMENT_SLOT_RANGED) || + bool required=slot==EQUIPMENT_SLOT_MAINHAND || (slot==EQUIPMENT_SLOT_OFFHAND && std::string(AdventureGeneratorPolicy::Role(a))=="tank" && a.Class!=CLASS_DEATH_KNIGHT) || (a.Class==CLASS_HUNTER && slot==EQUIPMENT_SLOT_RANGED) || slot==EQUIPMENT_SLOT_CHEST || slot==EQUIPMENT_SLOT_LEGS || slot==EQUIPMENT_SLOT_FEET || (p->getLevel()>=30 && slot!=EQUIPMENT_SLOT_RANGED && slot!=EQUIPMENT_SLOT_OFFHAND); + if(p->getLevel()<10)required=slot==EQUIPMENT_SLOT_MAINHAND; if(!equipped && required){error="No legal profile-matching equipment for slot "+std::to_string(slot);return false;} } return true; @@ -258,21 +320,50 @@ AdventureGeneratorMgr* AdventureGeneratorMgr::instance(){static AdventureGenerat bool AdventureGeneratorMgr::Profile(uint32 guid,std::string& lua) { if(!Schema())return false; - auto row=CharacterDatabase.PQuery("SELECT profile_lua FROM vcore_adventure_generated_bot WHERE character_guid=%u AND state='ready'",guid); + auto row=CharacterDatabase.PQuery("SELECT g.profile_lua,g.archetype,g.seed,c.race,c.level FROM vcore_adventure_generated_bot g JOIN characters c ON c.guid=g.character_guid WHERE g.character_guid=%u AND g.state='ready'",guid); if(!row)return false; - lua=row->Fetch()[0].GetString(); + auto f=row->Fetch();lua=f[0].GetString(); + if(!lua.empty() && lua.find("profile_version")==std::string::npos && sAdventureLocationMgr->EnsureLoaded()) + { + auto const& types=AdventureGeneratorPolicy::Archetypes(); + auto found=std::find_if(types.begin(),types.end(),[&](Archetype const& a){return f[1].GetString()==a.Key;}); + if(found!=types.end()) + { + auto level=f[4].GetUInt8(),race=f[3].GetUInt8();std::mt19937 random(f[2].GetUInt32()); + uint32 home=0;auto info=sObjectMgr->GetPlayerInfo(race==9?2:race==22?1:race,CLASS_WARRIOR); + if(info)if(auto area=sAreaTableStore.LookupEntry(info->areaId))home=area->ParentAreaID?area->ParentAreaID:area->ID; + std::vector choices; + for(auto const& l:sAdventureLocationMgr->Locations()) + if(l.IsArea && !AdventureGroupPolicy::Capital(l.ZoneId) && l.MinLevel && level>=l.MinLevel && level<=l.MaxLevel && + (!l.Faction || l.Faction==AdventureGeneratorPolicy::Faction(race)) && sAdventureLocationMgr->Weight(l,"adventure")>0)choices.push_back(&l); + auto favorite=choices.empty()?nullptr:choices[random()%choices.size()]; + std::ostringstream upgraded; + upgraded<<"local p=(function() "<ZoneId!=home)upgraded<<",["<ZoneId<<"]=5"; + if(home!=AdventureGeneratorPolicy::Capital(race))upgraded<<",["<SetFreePrimaryProfessions(0); // the generated profile owns exactly two primaries + for(auto skill:a.Skills)if(!Profession(p,skill) && p->getLevel()>=5){error="No native profession rank could be learned for skill "+std::to_string(skill);return false;} + if(p->getLevel()>=5)p->SetFreePrimaryProfessions(0); // the generated profile owns exactly two primaries if(!sAdventureSupplyMgr->PrepareCharacter(p)){error="Could not prepare secondary professions and bags.";return false;} return true; } -bool AdventureGeneratorMgr::Create(ChatHandler* handler,uint32 account,uint32 seed,uint8 level,unsigned faction,std::string const& key,bool persistent) +bool AdventureGeneratorMgr::Create(ChatHandler* handler,uint32 account,uint32 seed,uint8 level,unsigned faction,std::string const& key,bool persistent,Completion done) { std::mt19937 random(seed); struct Choice {Archetype const* Type;uint8 Race;};std::vector choices; @@ -283,8 +374,8 @@ bool AdventureGeneratorMgr::Create(ChatHandler* handler,uint32 account,uint32 se for(uint8 race:{1,2,3,4,5,6,7,8,9,10,11,22}) if((!faction || AdventureGeneratorPolicy::Faction(race)==faction) && sObjectMgr->GetPlayerInfo(race,a.Class))choices.push_back({&a,race}); } - if(choices.empty()){handler->SendSysMessage("No legal race/class archetype fits this request.");return false;} - auto choice=choices[random()%choices.size()];auto const& a=*choice.Type; + if(choices.empty()){if(handler)handler->SendSysMessage("No legal race/class archetype fits this request.");return false;} + auto choice=choices[random()%choices.size()];auto a=AdventureGeneratorPolicy::Personalize(*choice.Type,random); uint8 gender=random()%2;std::string name; for(unsigned attempt=0;attempt<1024;++attempt) { @@ -296,14 +387,14 @@ bool AdventureGeneratorMgr::Create(ChatHandler* handler,uint32 account,uint32 se auto path=std::filesystem::path(sClientlessLuaEngine->GetScriptRoot())/"adventure"/"profiles"/(lower+".lua"); if(std::filesystem::exists(path))continue; // reserve hand-written identities too std::string escaped=candidate;CharacterDatabase.EscapeString(escaped); - if(!CharacterDatabase.PQuery("SELECT 1 FROM characters WHERE name='%s' LIMIT 1",escaped.c_str())){name=candidate;break;} + if(done || !CharacterDatabase.PQuery("SELECT 1 FROM characters WHERE name='%s' LIMIT 1",escaped.c_str())){name=candidate;break;} } - if(name.empty()){handler->SendSysMessage("Roleplay name pool exhausted; no numeric suffix was substituted.");return false;} + if(name.empty()){if(handler)handler->SendSysMessage("Roleplay name pool exhausted; no numeric suffix was substituted.");return false;} // Mature goblin/worgen travelers have left their phased introduction. uint8 homeRace=choice.Race==9?2:choice.Race==22?1:choice.Race; auto info=sObjectMgr->GetPlayerInfo(homeRace,CLASS_WARRIOR); - if(!info){handler->SendSysMessage("Native racial home data unavailable.");return false;} - if(!sAdventureLocationMgr->EnsureLoaded()){handler->SendSysMessage("Adventure location catalog unavailable.");return false;} + if(!info){if(handler)handler->SendSysMessage("Native racial home data unavailable.");return false;} + if(!sAdventureLocationMgr->EnsureLoaded()){if(handler)handler->SendSysMessage("Adventure location catalog unavailable.");return false;} AdventureLocation const* home=nullptr;float best=1e30f; for(auto const& location:sAdventureLocationMgr->Locations()) { @@ -315,36 +406,95 @@ bool AdventureGeneratorMgr::Create(ChatHandler* handler,uint32 account,uint32 se float distance=dx*dx+dy*dy; if(distanceSendSysMessage("No faction-compatible home near the native racial starting area.");return false;} - std::string profile=AdventureGeneratorPolicy::Profile(name,a,choice.Race,home->Point.Id,home->Region,random); + if(!home){if(handler)handler->SendSysMessage("No faction-compatible home near the native racial starting area.");return false;} + std::vector favorites; + for(auto const& l:sAdventureLocationMgr->Locations()) + if(l.IsArea && !AdventureGroupPolicy::Capital(l.ZoneId) && l.MinLevel && level>=l.MinLevel && level<=l.MaxLevel && + (!l.Faction || l.Faction==AdventureGeneratorPolicy::Faction(choice.Race)) && + sAdventureLocationMgr->Weight(l,"adventure")>0)favorites.push_back(&l); + auto favorite=favorites.empty()?home:favorites[random()%favorites.size()]; + std::string movement=ProfileMovement(choice.Race,level,random); + std::string profile=AdventureGeneratorPolicy::Profile(name,a,choice.Race,home->Point.Id,home->Region,random, + level,home->ZoneId,favorite->ZoneId,favorite->Region,seed,movement); std::string escapedProfile=profile;CharacterDatabase.EscapeString(escapedProfile); ClientlessCharacterFactory::Definition definition{name,choice.Race,uint8(a.Class),gender,level,seed}; - auto result=ClientlessCharacterFactory::Create(account,definition, - [&](Player* player,std::string& error) + auto homeCopy=*home;auto rng=std::make_shared(random); + auto prepare=[homeCopy,a,rng,movement](Player* player,std::string& error) { - if(player->GetMapId()!=home->Point.MapId) - { - player->ResetMap();player->SetMap(sMapMgr->CreateMap(home->Point.MapId,player)); - } - player->Relocate(home->Point.X,home->Point.Y,home->Point.Z,home->Point.O); - if(!Prepare(player,a,error) || !Equip(player,a,random,error))return false; - // Save its bind row in the SAME - // transaction instead of calling SetHomebind's immediate UPDATE. + if(player->GetMapId()!=homeCopy.Point.MapId) + {player->ResetMap();player->SetMap(sMapMgr->CreateMap(homeCopy.Point.MapId,player));} + player->Relocate(homeCopy.Point.X,homeCopy.Point.Y,homeCopy.Point.Z,homeCopy.Point.O); + if(!sAdventureGeneratorMgr->Prepare(player,a,error) || !Equip(player,a,*rng,error))return false; + if(player->getLevel()>=20)player->SetSkill(SKILL_RIDING,player->getLevel()>=60?3:player->getLevel()>=40?2:1, + player->getLevel()>=60?225:player->getLevel()>=40?150:75,player->getLevel()>=60?225:player->getLevel()>=40?150:75); + if(player->getLevel()>=60 && Learnable(player,90267))player->LearnSpell(90267,false); + if(player->getLevel()>=68 && Learnable(player,54197))player->LearnSpell(54197,false); + if(player->getLevel()>=60)for(auto id:AdventureGeneratorPolicy::Faction(player->getRace())==1? + std::vector{32235,32239,32240}:std::vector{32243,32244,32245}) + if(Learnable(player,id))player->LearnSpell(id,false); return true; - }, - [&](CharacterDatabaseTransaction const& tx,Player* player) + }; + auto append=[homeCopy,account,a,seed,persistent,escapedProfile](CharacterDatabaseTransaction const& tx,Player* player) { tx->PAppend("INSERT INTO vcore_adventure_generated_bot (character_guid,account_id,archetype,seed,persistent,state,profile_lua,created_at) VALUES (%u,%u,'%s',%u,%u,'ready','%s',%llu)", player->GetGUID().GetCounter(),account,a.Key,seed,persistent?1:0,escapedProfile.c_str(),static_cast(GameTime::GetGameTime())); tx->PAppend("INSERT INTO character_homebind (guid,mapId,zoneId,posX,posY,posZ) VALUES (%u,%u,%u,%f,%f,%f)", - player->GetGUID().GetCounter(),home->Point.MapId,home->ZoneId,home->Point.X,home->Point.Y,home->Point.Z); - }); - if(!result.Guid){handler->PSendSysMessage("Generation failed name='%s': %s",name.c_str(),result.Error.c_str());return false;} - handler->PSendSysMessage("Generated %s guid=%u race=%u class=%u level=%u archetype=%s persistent=%u seed=%u home=%s (offline).", + player->GetGUID().GetCounter(),homeCopy.Point.MapId,homeCopy.ZoneId,homeCopy.Point.X,homeCopy.Point.Y,homeCopy.Point.Z); + }; + if(done) + { + ClientlessCharacterFactory::CreateAsync(account,definition,prepare,append,[done](ClientlessCharacterFactory::Result result) + {done(result.Guid,result.Error);}); + return true; + } + auto result=ClientlessCharacterFactory::Create(account,definition,prepare,append); + if(!result.Guid){if(handler)handler->PSendSysMessage("Generation failed name='%s': %s",name.c_str(),result.Error.c_str());return false;} + if(handler)handler->PSendSysMessage("Generated %s guid=%u race=%u class=%u level=%u archetype=%s persistent=%u seed=%u home=%s (offline).", name.c_str(),result.Guid,choice.Race,a.Class,level,a.Key,persistent?1:0,seed,home->Point.Id.c_str()); TC_LOG_INFO("server.adventurebot","BOT_GENERATED guid=%u name='%s' race=%u class=%u level=%u archetype='%s' seed=%u persistent=%u",result.Guid,name.c_str(),choice.Race,a.Class,level,a.Key,seed,persistent?1:0); return true; } +void AdventureGeneratorMgr::GenerateAsync(uint32 account,uint32 seed,uint8 level,unsigned faction,bool persistent,Completion done) +{ + if(!Create(nullptr,account,seed,level,faction,"any",persistent,done))done(0,"No valid name, archetype or home in loaded catalogs."); +} +bool AdventureGeneratorMgr::Reconcile(Player* player,std::string& error) +{ + auto profile=sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter()); + if(!profile || !profile->Level || profile->SourcePath.find("generated:")!=0)return true; + if(profile->Class!=player->getClass()){error="Profile class mismatch; no class conversion performed.";return false;} + auto found=std::find_if(AdventureGeneratorPolicy::Archetypes().begin(),AdventureGeneratorPolicy::Archetypes().end(), + [&](Archetype const& a){return a.Class==profile->Class;}); + if(found==AdventureGeneratorPolicy::Archetypes().end())return false; + auto a=*found;a.Spec=profile->Specialization; + if(profile->Role!=AdventureGeneratorPolicy::Role(a)){error="Profile role and specialization disagree.";return false;} + bool changedLevel=player->getLevel()!=profile->Level; + if(changedLevel) + { + player->SetLevel(profile->Level);player->InitStatsForLevel(true);player->InitTalentForLevel(); + player->InitTaxiNodesForLevel();player->InitGlyphsForLevel(); + std::vector remove; + for(auto const& pair:player->GetSpellMap())if(auto spell=sSpellMgr->GetSpellInfo(pair.first)) + if(spell->SpellLevel>profile->Level || spell->BaseLevel>profile->Level)remove.push_back(pair.first); + for(auto id:remove)player->RemoveSpell(id,false,false); + } + auto tabs=sDBCManager.GetTalentTabPages(player->getClass()); + if(changedLevel || (tabs && player->getLevel()>=10 && player->GetPrimaryTalentTree(player->GetActiveSpec())!=tabs[a.Spec]))player->ResetTalents(true); + if(!Prepare(player,a,error))return false; + std::mt19937 random(player->GetGUID().GetCounter()); + if(!Equip(player,a,random,error,true))return false; + for(auto const& pet:profile->Companions)ClientlessCompanionMgr::Prepare(player,pet.DisplayId,profile->CompanionLeashItem); + if(player->getLevel()>=20 && profile->CanGroundMount) + { + uint32 rank=player->getLevel()>=60 && profile->CanFreeFly?3:player->getLevel()>=40?2:1; + player->SetSkill(SKILL_RIDING,rank,rank==3?225:rank==2?150:75,rank==3?225:rank==2?150:75); + } + if(player->getLevel()>=60 && Learnable(player,90267) && !player->HasSpell(90267))player->LearnSpell(90267,false); + if(player->getLevel()>=68 && Learnable(player,54197) && !player->HasSpell(54197))player->LearnSpell(54197,false); + for(auto const& mount:profile->FlightMounts)if(Learnable(player,mount.DisplayId) && !player->HasSpell(mount.DisplayId))player->LearnSpell(mount.DisplayId,false); + player->SetUInt32Value(PLAYER_XP,0); + return true; +} bool AdventureGeneratorMgr::Command(ChatHandler* handler,char const* args) { if(!Schema()){handler->SendSysMessage("Apply the AdventureBot generator character migration first.");return false;} @@ -352,13 +502,13 @@ bool AdventureGeneratorMgr::Command(ChatHandler* handler,char const* args) if(op=="create") { uint32 account=0,count=0,level=60,seed=uint32(GameTime::GetGameTime());std::string side="any",key="any",mode="persistent",extra; - if(!(input>>account>>count)){handler->SendSysMessage("Usage: clientless generate create [level 10..85] [any|alliance|horde] [archetype|any] [persistent|temporary] [seed]");return false;} + if(!(input>>account>>count)){handler->SendSysMessage("Usage: clientless generate create [level 1..85] [any|alliance|horde] [archetype|any] [persistent|temporary] [seed]");return false;} input>>std::ws; if(!input.eof()) { if(!(input>>level>>side>>key>>mode>>seed) || input>>extra){handler->SendSysMessage("Optional arguments must be supplied together: level faction archetype persistence seed.");return false;} } - if(count<1 || count>10 || level<10 || level>85 || (side!="any" && side!="alliance" && side!="horde") || (mode!="persistent" && mode!="temporary"))return false; + if(count<1 || count>10 || level<1 || level>85 || (side!="any" && side!="alliance" && side!="horde") || (mode!="persistent" && mode!="temporary"))return false; unsigned created=0; for(uint32 i=0;iPSendSysMessage("Generation batch complete: %u/%u characters, each atomically saved; no existing characters modified.",created,count); @@ -386,9 +536,10 @@ bool AdventureGeneratorMgr::Command(ChatHandler* handler,char const* args) if(!player || !player->IsInWorld() || !player->IsAlive() || player->IsInCombat() || player->IsBeingTeleported() || player->GetTransport() || player->IsInFlight() || player->HasUnitState(UNIT_STATE_CASTING)) {handler->SendSysMessage("Equipment refresh requires an idle online generator-owned bot outside combat/transit/casting.");return false;} - for(auto const& archetype:AdventureGeneratorPolicy::Archetypes()) + for(auto archetype:AdventureGeneratorPolicy::Archetypes()) if(f[4].GetString()==archetype.Key && player->getClass()==archetype.Class) { + if(auto profile=sClientlessLuaEngine->GetAdventureProfile(guid))archetype.Spec=profile->Specialization; sAdventureBehaviorMgr->InterruptForInteraction(player); std::mt19937 random(f[5].GetUInt32());std::string error; if(!Equip(player,archetype,random,error)){handler->PSendSysMessage("Equipment refresh stopped: %s",error.c_str());return false;} diff --git a/src/server/game/World/AdventureGeneratorMgr.h b/src/server/game/World/AdventureGeneratorMgr.h index a698453829..5846116dc8 100644 --- a/src/server/game/World/AdventureGeneratorMgr.h +++ b/src/server/game/World/AdventureGeneratorMgr.h @@ -3,6 +3,7 @@ #include "Common.h" #include "AdventureGeneratorPolicy.h" #include +#include class Player; class ChatHandler; class TC_GAME_API AdventureGeneratorMgr @@ -13,9 +14,12 @@ public: // Lua profile stays in the character DB; hand-written named profiles retain // precedence. A generated identity is never inferred from account/name. static bool Profile(uint32 guid,std::string& lua); + using Completion=std::function; + void GenerateAsync(uint32 account,uint32 seed,uint8 level,unsigned faction,bool persistent,Completion done); + bool Reconcile(Player* player,std::string& error); private: bool Create(ChatHandler* handler,uint32 account,uint32 seed,uint8 level,unsigned faction, - std::string const& archetype,bool persistent); + std::string const& archetype,bool persistent,Completion done={}); bool Prepare(Player* player,AdventureGeneratorPolicy::Archetype const& archetype,std::string& error); }; #define sAdventureGeneratorMgr AdventureGeneratorMgr::instance() diff --git a/src/server/game/World/AdventureGeneratorPolicy.h b/src/server/game/World/AdventureGeneratorPolicy.h index 13c9aa92bf..78080e87d0 100644 --- a/src/server/game/World/AdventureGeneratorPolicy.h +++ b/src/server/game/World/AdventureGeneratorPolicy.h @@ -66,7 +66,10 @@ namespace AdventureGeneratorPolicy for(unsigned i=0;i=1 && quality<=3 && !temporary && !restricted && (required+15>=level || level<20) && itemLevel<=level*5+20; } + inline unsigned Capital(unsigned race) + {return race==3 || race==7?1537:race==4?1657:race==11?3557:race==5?1497:race==6?1638:race==10?3487:Faction(race)==1?1519:1637;} + inline char const* Role(Archetype const& a) + { + if ((a.Class==1 && a.Spec==2) || (a.Class==2 && a.Spec==1) || (a.Class==6 && a.Spec==0)) return "tank"; + if ((a.Class==5 && a.Spec!=2) || (a.Class==7 && a.Spec==2) || (a.Class==11 && a.Spec==2)) return "healer"; + return "damage"; + } + inline Archetype Personalize(Archetype a, std::mt19937& random) + { + if (random()%3==0) + { + if(a.Class==1)a.Spec=2; + if(a.Class==2)a.Spec=1; + if(a.Class==6)a.Spec=0; + if(a.Class==5)a.Spec=random()%2; + if(a.Class==7 || a.Class==11)a.Spec=2; + } + return a; + } inline std::string Profile(std::string const& name,Archetype const& a,unsigned race, - std::string const& location,std::string const& region,std::mt19937& random) + std::string const& location,std::string const& region,std::mt19937& random, + unsigned level=60,unsigned homeZone=0,unsigned favoriteZone=0,std::string const& favoriteRegion="",unsigned seed=0, + std::string const& movement="movement = { can_ground_mount = false, can_free_fly = false }") { auto quote=LuaString; std::ostringstream lua;lua<NextRow()); } + if(WorldDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_zone'")) + if(auto rows=WorldDatabase.Query("SELECT zone_id,min_level,max_level FROM vcore_adventure_zone WHERE enabled=1"))do + { + auto f=rows->Fetch(); + for(auto& l:_locations)if(l.ZoneId==f[0].GetUInt32() || (!l.ZoneId && l.AreaId==f[0].GetUInt32())) + {l.MinLevel=f[1].GetUInt8();l.MaxLevel=f[2].GetUInt8();} + }while(rows->NextRow()); if (WorldDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_instance'")) if(auto rows=WorldDatabase.Query("SELECT i.location_key,a.target_map FROM vcore_adventure_instance i JOIN areatrigger_teleport a ON a.ID=i.entry_trigger WHERE i.enabled=1")) do { diff --git a/src/server/game/World/AdventureLocationMgr.h b/src/server/game/World/AdventureLocationMgr.h index 643a0cb309..ab9dce57be 100644 --- a/src/server/game/World/AdventureLocationMgr.h +++ b/src/server/game/World/AdventureLocationMgr.h @@ -14,6 +14,7 @@ struct AdventureLocation uint32 AreaId = 0, ZoneId = 0, InstanceMap = 0; bool IsArea = false; uint8 Faction = 0; + uint8 MinLevel = 0, MaxLevel = 0; }; struct AdventureMicroAction { diff --git a/src/server/game/World/AdventurePopulationMgr.cpp b/src/server/game/World/AdventurePopulationMgr.cpp new file mode 100644 index 0000000000..f302088d30 --- /dev/null +++ b/src/server/game/World/AdventurePopulationMgr.cpp @@ -0,0 +1,282 @@ +#include "AdventurePopulationMgr.h" +#include "AdventureGeneratorMgr.h" +#include "AdventureBehaviorMgr.h" +#include "AdventureLocationMgr.h" +#include "ClientlessCharacterFactory.h" +#include "ClientlessLuaEngine.h" +#include "ClientlessPlayerMgr.h" +#include "AdventureSupplyMgr.h" +#include "AdventureCombatMgr.h" +#include "AccountMgr.h" +#include "Bag.h" +#include "Chat.h" +#include "Config.h" +#include "CryptoRandom.h" +#include "GameTime.h" +#include "Item.h" +#include "ItemTemplate.h" +#include "Map.h" +#include "Log.h" +#include "ObjectAccessor.h" +#include "Player.h" +#include "SRP6.h" +#include "Util.h" +#include "World.h" +#include "WorldSession.h" +#include +using namespace AdventurePopulationPolicy; +AdventurePopulationMgr* AdventurePopulationMgr::instance(){static AdventurePopulationMgr mgr;return &mgr;} +void AdventurePopulationMgr::Configure() +{ + _configured=true; + _enabled=sConfigMgr->GetBoolDefault("Clientless.Adventure.Loader.Enabled",false); + char const* names[]={"Alliance.Persistent","Alliance.Temporary","Horde.Persistent","Horde.Temporary"}; + for(unsigned i=0;i<4;++i)_targets[i]=std::clamp(sConfigMgr->GetIntDefault(std::string("Clientless.Adventure.Loader.")+names[i],0),0,5000); + _interval=std::clamp(sConfigMgr->GetIntDefault("Clientless.Adventure.Loader.IntervalMs",2000),250,60000); + _minLevel=std::clamp(sConfigMgr->GetIntDefault("Clientless.Adventure.Loader.MinLevel",1),1,85); + _maxLevel=std::clamp(sConfigMgr->GetIntDefault("Clientless.Adventure.Loader.MaxLevel",85),int(_minLevel),85); + _rotationAge=std::clamp(sConfigMgr->GetIntDefault("Clientless.Adventure.Loader.RotationMinAgeHours",24),0,8760)*3600; + _rotationChance=std::clamp(sConfigMgr->GetIntDefault("Clientless.Adventure.Loader.RotationPercent",15),0,100)/100.0; + _maintenance=std::clamp(sConfigMgr->GetIntDefault("Clientless.Adventure.Manager.IntervalSeconds",300),30,3600)*1000; + _timer=std::max(10000u,_interval); // first normal world updates, never startup initialization + if(_enabled)Snapshot(); +} +void AdventurePopulationMgr::Failed(std::string const& detail) +{ + _busy=false;_lastError=detail;_timer=30000; + TC_LOG_WARN("server.adventurebot","BOT_LOADER_RETRY detail='%s'",detail.c_str()); +} +void AdventurePopulationMgr::Snapshot() +{ + if(_busy)return; + _schema=false;_busy=true; + _queries.AddCallback(CharacterDatabase.AsyncQuery("SHOW TABLES LIKE 'vcore_adventure_rotation'").WithCallback([this](QueryResult schema) + { + if(!schema){Failed("Population character migration missing; loader suspended, world remains available.");return;} + _schema=true; + _queries.AddCallback(CharacterDatabase.AsyncQuery( + "SELECT g.character_guid,g.account_id,g.persistent,g.created_at,c.race FROM vcore_adventure_generated_bot g JOIN characters c ON c.guid=g.character_guid WHERE g.state='ready' AND c.deleteDate IS NULL ORDER BY g.character_guid").WithCallback([this](QueryResult rows) + { + std::vector bots; + if(rows)do + { + auto f=rows->Fetch();Bot b{f[0].GetUInt32(),f[1].GetUInt32(),AdventurePopulationPolicy::Pool(AdventureGeneratorPolicy::Faction(f[4].GetUInt8()),f[2].GetUInt8()!=0),f[3].GetUInt64()}; + auto old=std::find_if(_bots.begin(),_bots.end(),[&](Bot const& v){return v.Guid==b.Guid;}); + if(old!=_bots.end()){b.Failures=old->Failures;b.Retry=old->Retry;} + bots.push_back(b); + }while(rows->NextRow()); + _bots=std::move(bots); + _queries.AddCallback(CharacterDatabase.AsyncQuery( + "SELECT a.account_id,a.faction,COUNT(c.guid) FROM vcore_adventure_bot_account a LEFT JOIN characters c ON c.account=a.account_id AND c.deleteDate IS NULL GROUP BY a.account_id,a.faction ORDER BY a.account_id").WithCallback([this](QueryResult rows) + { + _accounts.clear();if(rows)do{auto f=rows->Fetch();_accounts.push_back({f[0].GetUInt32(),f[1].GetUInt8(),uint32(f[2].GetUInt64())});}while(rows->NextRow()); + _queries.AddCallback(CharacterDatabase.AsyncQuery( + "SELECT r.old_guid,r.new_guid,g.account_id FROM vcore_adventure_rotation r JOIN vcore_adventure_generated_bot g ON g.character_guid=r.old_guid AND g.persistent=0 JOIN vcore_adventure_generated_bot n ON n.character_guid=r.new_guid AND n.state='ready' JOIN characters c ON c.guid=n.character_guid").WithCallback([this](QueryResult rows) + { + _rotations.clear();if(rows)do{auto f=rows->Fetch();_rotations.push_back({f[0].GetUInt32(),f[1].GetUInt32(),f[2].GetUInt32(),false});}while(rows->NextRow()); + _busy=false;_refresh=60000; + })); + })); + })); + })); +} +void AdventurePopulationMgr::Update(uint32 diff) +{ + ClientlessCharacterFactory::Update(); + _queries.ProcessReadyCallbacks();_saves.ProcessReadyCallbacks(); + if(!_configured){Configure();return;} + if(!_enabled || sWorld->IsStopped())return; + if(_refresh>diff)_refresh-=diff;else _refresh=0; + if(_timer>diff){_timer-=diff;return;}_timer=_interval; + if(_busy)return; + if(!_schema || !_refresh){Snapshot();return;} + if(!_rotations.empty()){DeleteRotated();return;} + Step(); +} +void AdventurePopulationMgr::Step() +{ + auto now=GameTime::GetGameTime(); + for(unsigned count=0;count<4;++count) + { + unsigned pool=_cursor++%4,online=0,loading=0; + for(auto const& b:_bots)if(b.Pool==pool) + { + auto state=sWorld->GetClientlessPlayerState(b.Guid); + if(state==ClientlessPlayerState::Online)++online; + else if(state!=ClientlessPlayerState::Offline)++loading; + } + if(!Deficit(_targets[pool],online,loading))continue; + for(auto& b:_bots)if(b.Pool==pool && b.Retry<=now && sWorld->GetClientlessPlayerState(b.Guid)==ClientlessPlayerState::Offline && !ObjectAccessor::FindConnectedPlayer(ObjectGuid(HighGuid::Player,b.Guid))) + { + bool seen=_considered.count(b.Guid);_considered.insert(b.Guid); + if(Replace(Persistent(pool),true,seen,now-b.Created,_rotationAge,frand(0,1),_rotationChance)) + {Generate(pool,b.Guid);return;} + if(sClientlessPlayerMgr->StartPlayer(b.Account,b.Guid,CreateClientlessAdventureLuaController())) + {b.Retry=now+120;TC_LOG_INFO("server.adventurebot","BOT_LOADER_LOGIN guid=%u pool=%u",b.Guid,pool);} + else {++b.Failures;b.Retry=now+std::min(1800u,30u*(1u<getIntConfig(CONFIG_CHARACTERS_PER_REALM) && !sWorld->FindSession(a.Id);}); + if(available==_accounts.end()){CreateAccount(pool,replace);return;} + _busy=true;auto account=available->Id; + sAdventureGeneratorMgr->GenerateAsync(account,urand(0,0xFFFFFFFF),urand(_minLevel,_maxLevel),Faction(pool),Persistent(pool), + [this,pool,replace,account](uint32 guid,std::string error) + { + if(!guid){Failed(error);return;} + _considered.insert(guid); + _bots.push_back({guid,account,pool,uint64(GameTime::GetGameTime())}); + for(auto& a:_accounts)if(a.Id==account)++a.Count; + if(!replace){_busy=false;return;} + auto tx=CharacterDatabase.BeginTransaction(); + tx->PAppend("INSERT INTO vcore_adventure_rotation(old_guid,new_guid) SELECT character_guid,%u FROM vcore_adventure_generated_bot WHERE character_guid=%u AND persistent=0 AND state='ready'",guid,replace); + tx->PAppend("UPDATE vcore_adventure_generated_bot g JOIN vcore_adventure_rotation r ON r.old_guid=g.character_guid SET g.state='replacing' WHERE g.character_guid=%u AND g.persistent=0",replace); + auto save=CharacterDatabase.AsyncCommitTransaction(tx); + save.AfterComplete([this,replace,guid,account](bool ok) + { + if(!ok){Failed("Could not journal rotation; original character retained.");return;} + _bots.erase(std::remove_if(_bots.begin(),_bots.end(),[&](Bot const& b){return b.Guid==replace;}),_bots.end()); + _rotations.push_back({replace,guid,account,false});_busy=false; + });_saves.AddCallback(std::move(save)); + }); +} +void AdventurePopulationMgr::CreateAccount(unsigned pool,uint32 replace) +{ + _busy=true; + // Same native SRP6 registration and prepared account insert, with an unrecorded + // random password. Ownership is explicit; existing similarly named accounts are never adopted. + auto username="VCB"+ByteArrayToHexStr(Trinity::Crypto::GetRandomBytes<6>()); + auto password=ByteArrayToHexStr(Trinity::Crypto::GetRandomBytes<8>()); + auto credentials=Trinity::Crypto::SRP6::MakeRegistrationData(username,password); + auto tx=LoginDatabase.BeginTransaction();auto stmt=LoginDatabase.GetPreparedStatement(LOGIN_INS_ACCOUNT); + stmt->setString(0,username);stmt->setBinary(1,credentials.first);stmt->setBinary(2,credentials.second);stmt->setString(3,"");stmt->setString(4,"");tx->Append(stmt); + tx->PAppend("UPDATE account SET expansion=3 WHERE username='%s'",username.c_str()); + auto save=LoginDatabase.AsyncCommitTransaction(tx);save.AfterComplete([this,pool,replace,username](bool ok) + { + if(!ok){Failed("Dedicated bot account creation failed.");return;} + _queries.AddCallback(LoginDatabase.AsyncQuery(Trinity::StringFormat("SELECT id FROM account WHERE username='%s'",username.c_str()).c_str()).WithCallback([this,pool,replace](QueryResult rows) + { + if(!rows){Failed("New bot account could not be resolved.");return;} + auto account=rows->Fetch()[0].GetUInt32(); + auto tx=CharacterDatabase.BeginTransaction();tx->PAppend("INSERT INTO vcore_adventure_bot_account(account_id,faction) VALUES(%u,%u)",account,Faction(pool)); + auto saved=CharacterDatabase.AsyncCommitTransaction(tx);saved.AfterComplete([this,pool,replace,account](bool ok) + { + if(!ok){Failed("Could not register ownership of new bot account.");return;} + _accounts.push_back({account,Faction(pool),0});_busy=false;Generate(pool,replace); + });_saves.AddCallback(std::move(saved)); + })); + });_saves.AddCallback(std::move(save)); +} +void AdventurePopulationMgr::DeleteRotated() +{ + auto& r=_rotations.front();auto guid=r.Old; + if(sWorld->GetClientlessPlayerState(guid)!=ClientlessPlayerState::Offline || ObjectAccessor::FindConnectedPlayer(ObjectGuid(HighGuid::Player,guid)))return; + _busy=true; + // Recheck ownership and native offline state immediately before permanent native deletion. + _queries.AddCallback(CharacterDatabase.AsyncQuery(Trinity::StringFormat( + "SELECT g.account_id FROM vcore_adventure_generated_bot g JOIN vcore_adventure_rotation r ON r.old_guid=g.character_guid JOIN characters n ON n.guid=r.new_guid LEFT JOIN characters c ON c.guid=g.character_guid WHERE g.character_guid=%u AND g.persistent=0 AND g.state='replacing' AND COALESCE(c.online,0)=0",guid).c_str()).WithCallback([this,guid](QueryResult rows) + { + if(!rows){Failed("Rotation ownership/offline check failed; original retained.");return;} + if(sWorld->GetClientlessPlayerState(guid)!=ClientlessPlayerState::Offline || ObjectAccessor::FindConnectedPlayer(ObjectGuid(HighGuid::Player,guid))){_busy=false;return;} + auto account=rows->Fetch()[0].GetUInt32(); + Player::DeleteFromDB(ObjectGuid(HighGuid::Player,guid),account,true,true); + // Verify the native asynchronous deletion before deleting profile and plans. + auto barrier=CharacterDatabase.BeginTransaction();barrier->PAppend("DO 0"); + auto saved=CharacterDatabase.AsyncCommitTransaction(barrier);saved.AfterComplete([this,guid](bool ok) + { + if(!ok){Failed("Native deletion barrier failed; cleanup deferred.");return;} + _queries.AddCallback(CharacterDatabase.AsyncQuery(Trinity::StringFormat("SELECT guid FROM characters WHERE guid=%u",guid).c_str()).WithCallback([this,guid](QueryResult rows) + { + if(rows){Failed("Native deletion not complete; cleanup deferred.");return;} + auto world=WorldDatabase.BeginTransaction(); + world->PAppend("DELETE FROM vcore_adventure_day WHERE character_guid=%u",guid); + auto worldSaved=WorldDatabase.AsyncCommitTransaction(world); + worldSaved.AfterComplete([this,guid](bool ok) + { + if(!ok){Failed("Rotated day-plan cleanup failed; journal retained.");return;} + auto tx=CharacterDatabase.BeginTransaction(); + tx->PAppend("DELETE FROM vcore_adventure_supply_state WHERE character_guid=%u",guid); + tx->PAppend("DELETE FROM vcore_adventure_instance_visit WHERE character_guid=%u",guid); + tx->PAppend("DELETE FROM vcore_adventure_generated_bot WHERE character_guid=%u AND persistent=0 AND state='replacing'",guid); + tx->PAppend("DELETE FROM vcore_adventure_rotation WHERE old_guid=%u",guid); + auto saved=CharacterDatabase.AsyncCommitTransaction(tx);saved.AfterComplete([this,guid](bool ok) + { + if(!ok){Failed("Rotation metadata cleanup deferred.");return;} + + _rotations.erase(_rotations.begin());_busy=false;_refresh=0; + TC_LOG_INFO("server.adventurebot","BOT_ROTATED_REMOVED guid=%u",guid); + });_saves.AddCallback(std::move(saved)); + });_saves.AddCallback(std::move(worldSaved)); + })); + });_saves.AddCallback(std::move(saved)); + })); +} +void AdventurePopulationMgr::Online(Player* p) +{ + auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); + if(profile && profile->ProfileVersion>=2 && profile->SourcePath.find("generated:")==0)_checks[p->GetGUID().GetCounter()]=0; +} +void AdventurePopulationMgr::Offline(Player* p){_checks.erase(p->GetGUID().GetCounter());} +void AdventurePopulationMgr::CleanInventory(Player* p) +{ + if(p->GetFreeInventorySpace()>0)return; + auto profile=sClientlessLuaEngine->GetAdventureProfile(p->GetGUID().GetCounter()); + auto allowance=sAdventureSupplyMgr->InventoryAllowance(p); + auto clean=[&](uint8 bag,uint8 slot) + { + auto item=p->GetItemByPos(bag,slot);if(!item)return; + auto proto=item->GetTemplate(); + bool keep=proto->GetClass()==ITEM_CLASS_CONTAINER || + (proto->GetClass()==ITEM_CLASS_WEAPON && proto->GetSubClass()==ITEM_SUBCLASS_WEAPON_FISHING_POLE) || + (profile && (profile->KeepItems.count(item->GetEntry()) || item->GetEntry()==profile->CompanionLeashItem)); + if(auto it=allowance.find(item->GetEntry());it!=allowance.end()) + { + auto retain=std::min(it->second,item->GetCount());it->second-=retain; + if(!retain)p->DestroyItem(bag,slot,true); + else if(retainGetCount()) + {auto remove=item->GetCount()-retain;p->DestroyItemCount(item,remove,true);} + } + else if(!keep)p->DestroyItem(bag,slot,true); + }; + for(uint8 slot=INVENTORY_SLOT_ITEM_START;slotGetBagByPos(bag))for(uint8 slot=0;slotGetBagSize();++slot)clean(bag,slot); +} +void AdventurePopulationMgr::Maintain(Player* p,uint32 diff) +{ + sAdventureBehaviorMgr->RefreshDay(p); + auto it=_checks.find(p->GetGUID().GetCounter());if(it==_checks.end())return; + if(it->second>diff){it->second-=diff;return;} + if(!p->IsInWorld() || !p->IsAlive() || p->IsInCombat() || p->GetMap()->IsDungeon() || p->GetTransport() || p->IsInFlight() || p->IsFlying() || p->IsBeingTeleported() || p->HasUnitState(UNIT_STATE_CASTING))return; + it->second=_maintenance+urand(0,30000);CleanInventory(p); + std::string error; + if(!sAdventureGeneratorMgr->Reconcile(p,error))TC_LOG_WARN("server.adventurebot","BOT_MANAGER_CHECK guid=%u detail='%s'",p->GetGUID().GetCounter(),error.c_str()); + auto tx=CharacterDatabase.BeginTransaction();p->SaveToDB(tx);CharacterDatabase.CommitTransaction(tx); +} +bool AdventurePopulationMgr::Command(ChatHandler* handler,char const* args) +{ + std::string op=args?args:""; + if(op=="reload"){if(_busy){handler->SendSysMessage("Loader has a pending operation; retry reload shortly.");return false;}_configured=false;Configure();} + else if(!op.empty() && op!="status")return false; + handler->PSendSysMessage("Adventure loader enabled=%u schema=%u busy=%u pools=%u/%u/%u/%u known=%u rotations=%u manager=%u error='%s'",_enabled,_schema,_busy,_targets[0],_targets[1],_targets[2],_targets[3],uint32(_bots.size()),uint32(_rotations.size()),uint32(_checks.size()),_lastError.c_str()); + for(unsigned pool=0;pool<4;++pool) + { + unsigned online=0,loading=0,saved=0; + for(auto const& b:_bots)if(b.Pool==pool) + { + ++saved;auto state=sWorld->GetClientlessPlayerState(b.Guid); + if(state==ClientlessPlayerState::Online)++online; + else if(state!=ClientlessPlayerState::Offline)++loading; + } + handler->PSendSysMessage("pool=%u faction=%s persistent=%u target=%u online=%u pending=%u saved=%u",pool, + Faction(pool)==1?"alliance":"horde",Persistent(pool),_targets[pool],online,loading,saved); + } + return true; +} diff --git a/src/server/game/World/AdventurePopulationMgr.h b/src/server/game/World/AdventurePopulationMgr.h new file mode 100644 index 0000000000..d780fada6e --- /dev/null +++ b/src/server/game/World/AdventurePopulationMgr.h @@ -0,0 +1,49 @@ +#ifndef VCORE_ADVENTURE_POPULATION_MGR_H +#define VCORE_ADVENTURE_POPULATION_MGR_H +#include "Common.h" +#include "AsyncCallbackProcessor.h" +#include "DatabaseEnv.h" +#include "AdventurePopulationPolicy.h" +#include +#include +#include +#include +class Player; +class ChatHandler; +class TC_GAME_API AdventurePopulationMgr +{ +public: + static AdventurePopulationMgr* instance(); + void Update(uint32 diff); + void Online(Player* player); + void Offline(Player* player); + void Maintain(Player* player,uint32 diff); + bool Command(ChatHandler* handler,char const* args); +private: + struct Bot {uint32 Guid=0,Account=0,Pool=0;uint64 Created=0;uint32 Failures=0;time_t Retry=0;}; + struct Account {uint32 Id=0,Faction=0,Count=0;}; + struct Rotation {uint32 Old=0,New=0,Account=0;bool Submitted=false;}; + void Configure(); + void Snapshot(); + void Step(); + void Generate(unsigned pool,uint32 replace=0); + void CreateAccount(unsigned pool,uint32 replace); + void DeleteRotated(); + void Failed(std::string const& detail); + void CleanInventory(Player* player); + bool _configured=false,_enabled=false,_busy=false,_schema=false; + uint32 _timer=0,_interval=2000,_refresh=0,_cursor=0,_minLevel=1,_maxLevel=85; + uint32 _maintenance=300000,_rotationAge=86400; + double _rotationChance=0.15; + std::array _targets{}; + std::vector _bots; + std::vector _accounts; + std::vector _rotations; + std::unordered_set _considered; + std::unordered_map _checks; + QueryCallbackProcessor _queries; + AsyncCallbackProcessor _saves; + std::string _lastError; +}; +#define sAdventurePopulationMgr AdventurePopulationMgr::instance() +#endif diff --git a/src/server/game/World/AdventurePopulationPolicy.h b/src/server/game/World/AdventurePopulationPolicy.h new file mode 100644 index 0000000000..23dbde669b --- /dev/null +++ b/src/server/game/World/AdventurePopulationPolicy.h @@ -0,0 +1,17 @@ +#ifndef VCORE_ADVENTURE_POPULATION_POLICY_H +#define VCORE_ADVENTURE_POPULATION_POLICY_H +#include +#include +#include +namespace AdventurePopulationPolicy +{ + constexpr unsigned Pool(unsigned faction,bool persistent){return (faction==2?2:0)+(persistent?0:1);} + constexpr unsigned Faction(unsigned pool){return pool<2?1:2;} + constexpr bool Persistent(unsigned pool){return pool%2==0;} + inline bool Replace(bool persistent,bool offline,bool considered,std::int64_t age,std::int64_t minimumAge, + double sample,double chance) + {return !persistent && offline && !considered && age>=minimumAge && sampleonline+loading?target-online-loading:0;} +} +#endif diff --git a/src/server/game/World/AdventureSupplyMgr.cpp b/src/server/game/World/AdventureSupplyMgr.cpp index 6dbb3f6001..e84c41e8fd 100644 --- a/src/server/game/World/AdventureSupplyMgr.cpp +++ b/src/server/game/World/AdventureSupplyMgr.cpp @@ -313,3 +313,11 @@ bool AdventureSupplyMgr::PrepareCharacter(Player* player) { LoadCatalog();LearnSecondaries(player);return Bags(player); } + +std::unordered_map AdventureSupplyMgr::InventoryAllowance(Player* player) const +{ + std::unordered_map result; + for(auto role:{"bandage","food","drink","healing_potion","mana_potion"}) + if(auto item=Select(player,role))result[item->GetId()]=item->GetMaxStackSize(); + return result; +} diff --git a/src/server/game/World/AdventureSupplyMgr.h b/src/server/game/World/AdventureSupplyMgr.h index ab3135ea44..9531457f68 100644 --- a/src/server/game/World/AdventureSupplyMgr.h +++ b/src/server/game/World/AdventureSupplyMgr.h @@ -19,6 +19,7 @@ public: // True only while an out-of-combat recovery owns movement/intent execution. bool Update(Player* player,uint32 diff,bool combatReaction); void Report(ChatHandler* handler,Player* player); + std::unordered_map InventoryAllowance(Player* player) const; private: using Use=AdventureSupplyPolicy::Use; struct Supply {std::string Role;uint32 Item=0,MinLevel=0;}; diff --git a/src/server/game/World/ClientlessCharacterFactory.cpp b/src/server/game/World/ClientlessCharacterFactory.cpp index b8587e4586..a05a9a570e 100644 --- a/src/server/game/World/ClientlessCharacterFactory.cpp +++ b/src/server/game/World/ClientlessCharacterFactory.cpp @@ -1,5 +1,6 @@ #include "ClientlessCharacterFactory.h" #include "AccountMgr.h" +#include "AsyncCallbackProcessor.h" #include "CharacterCache.h" #include "DBCStores.h" #include "GameTime.h" @@ -98,3 +99,76 @@ bool ClientlessCharacterFactory::PrepareSkillRank(Player* player,uint32 skill,ui player->SetSkill(skill,step,maximum,maximum); return player->GetPureSkillValue(skill)==maximum && player->GetPureMaxSkillValue(skill)==maximum; } + +namespace +{ + auto FactoryQueries=std::make_unique(); + auto FactorySaves=std::make_unique>(); +} +void ClientlessCharacterFactory::Update() +{ + if(FactoryQueries)FactoryQueries->ProcessReadyCallbacks(); + if(FactorySaves)FactorySaves->ProcessReadyCallbacks(); +} +void ClientlessCharacterFactory::Shutdown() +{ + // Drop retained offline Players while map/DBC/session infrastructure is alive. + // Queued atomic DB transactions can finish; next startup rebuilds the cache. + FactoryQueries.reset();FactorySaves.reset(); +} +void ClientlessCharacterFactory::CreateAsync(uint32 account,Definition d,Prepare prepare,Append append,Completion done) +{ + if(!FactoryQueries || !FactorySaves || !account || sWorld->FindSession(account) || !sObjectMgr->GetPlayerInfo(d.Race,d.Class) || d.Gender>GENDER_FEMALE || + d.Level<1 || d.Level>sWorld->getIntConfig(CONFIG_MAX_PLAYER_LEVEL) || (d.Class==CLASS_DEATH_KNIGHT && d.Level<55)) + {done({0,"Invalid account/race/class/gender/level."});return;} + std::string name=d.Name; + if(!normalizePlayerName(name) || name!=d.Name || ObjectMgr::CheckPlayerName(name,LOCALE_enUS,true)!=CHAR_NAME_SUCCESS || + sObjectMgr->IsReservedName(name) || sCharacterCache->GetCharacterCacheByName(name)) + {done({0,"Invalid, reserved or occupied name."});return;} + FactoryQueries->AddCallback(LoginDatabase.AsyncQuery(Trinity::StringFormat( + "SELECT a.username,a.expansion,a.locale,CAST(COALESCE(MAX(x.SecurityLevel),0) AS UNSIGNED) FROM account a LEFT JOIN account_access x ON x.AccountID=a.id AND x.RealmID IN (-1,%u) WHERE a.id=%u GROUP BY a.id",realm.Id.Realm,account).c_str()).WithCallback( + [account,d,prepare,append,done](QueryResult acc) + { + if(!acc || acc->Fetch()[3].GetUInt64()!=SEC_PLAYER || sWorld->FindSession(account)) + {done({0,"Account missing, privileged or in use."});return;} + auto f=acc->Fetch();auto race=sChrRacesStore.LookupEntry(d.Race);auto cls=sChrClassesStore.LookupEntry(d.Class); + if(!race || !cls || race->Race_related>f[1].GetUInt8() || cls->Required_expansion>f[1].GetUInt8()) + {done({0,"Account expansion mismatch."});return;} + std::string name=d.Name;CharacterDatabase.EscapeString(name); + FactoryQueries->AddCallback(CharacterDatabase.AsyncQuery(Trinity::StringFormat( + "SELECT (SELECT COUNT(*) FROM characters WHERE account=%u AND deleteDate IS NULL), (SELECT COUNT(*) FROM characters WHERE name='%s')",account,name.c_str()).c_str()).WithCallback( + [account,d,prepare,append,done,acc](QueryResult rows) + { + if(!rows || rows->Fetch()[0].GetUInt64()>=sWorld->getIntConfig(CONFIG_CHARACTERS_PER_REALM) || rows->Fetch()[1].GetUInt64() || + sWorld->FindSession(account) || sCharacterCache->GetCharacterCacheByName(d.Name)) + {done({0,"Character capacity/name changed or database unavailable."});return;} + auto f=acc->Fetch(); + auto session=std::make_shared(account,f[0].GetString(),nullptr,SEC_PLAYER,f[1].GetUInt8(),0, + LocaleConstant(std::min(f[2].GetUInt8(),TOTAL_LOCALES-1)),0,false,WorldSession::WorldSessionMode::Clientless); + auto player=std::shared_ptr(new Player(session.get()),[session](Player* p){p->CleanupsBeforeDelete();delete p;}); + player->GetMotionMaster()->Initialize();CreateInfo info(d); + if(!player->Create(sObjectMgr->GetGenerator().Generate(),&info)) + {done({0,"Native creation failed."});return;} + player->setCinematic(1); + if(player->getLevel()!=d.Level) + {player->SetLevel(d.Level);player->InitStatsForLevel(true);player->InitTalentForLevel();player->InitTaxiNodesForLevel();player->InitGlyphsForLevel();} + player->SetCanModifyStats(true);player->SetMoney(0);player->LearnDefaultSkills();player->UpdateSkillsForLevel(); + std::string error; + if(prepare && !prepare(player.get(),error)){done({0,error});return;} + player->UpdateMaxHealth();player->UpdateMaxPower(POWER_MANA);player->SetFullHealth();player->SetFullPower(POWER_MANA); + auto tx=CharacterDatabase.BeginTransaction();player->SaveToDB(tx,true);if(append)append(tx,player.get()); + auto save=CharacterDatabase.AsyncCommitTransaction(tx); + save.AfterComplete([account,d,done,player,session,rows](bool ok) + { + if(!ok){done({0,"Atomic character save failed."});return;} + auto guid=player->GetGUID().GetCounter(); + sCharacterCache->AddCharacterCacheEntry(player->GetGUID(),account,d.Name,d.Gender,d.Race,d.Class,d.Level); + sScriptMgr->OnPlayerCreate(player.get()); + auto count=LoginDatabase.GetPreparedStatement(LOGIN_REP_REALM_CHARACTERS); + count->setUInt32(0,rows->Fetch()[0].GetUInt64()+1);count->setUInt32(1,account);count->setUInt32(2,realm.Id.Realm);LoginDatabase.Execute(count); + done({guid,{}}); + }); + FactorySaves->AddCallback(std::move(save)); + })); + })); +} diff --git a/src/server/game/World/ClientlessCharacterFactory.h b/src/server/game/World/ClientlessCharacterFactory.h index dacf2d9b99..1815278fb7 100644 --- a/src/server/game/World/ClientlessCharacterFactory.h +++ b/src/server/game/World/ClientlessCharacterFactory.h @@ -22,6 +22,12 @@ public: // Administrative preparation before world insertion; callers still gate // trainer/rank/level eligibility. Uses the native SkillRaceClass/SkillTiers cap. static bool PrepareSkillRank(Player* player,uint32 skill,uint32 step); + using Completion=std::function; + // World-thread native preparation, asynchronous preflight and atomic save. + // Poll once per world update; never wait on a database future in the loader. + static void CreateAsync(uint32 account,Definition definition,Prepare prepare,Append append,Completion completion); + static void Update(); + static void Shutdown(); static Result Create(uint32 account,Definition const& definition,Prepare prepare,Append append); }; #endif diff --git a/src/server/game/World/ClientlessLuaEngine.cpp b/src/server/game/World/ClientlessLuaEngine.cpp index 12d744c77f..adfe46ca04 100644 --- a/src/server/game/World/ClientlessLuaEngine.cpp +++ b/src/server/game/World/ClientlessLuaEngine.cpp @@ -8,6 +8,7 @@ #include "AdventureInteractionMgr.h" #include "AdventureGroupMgr.h" #include "AdventureGeneratorMgr.h" +#include "AdventurePopulationMgr.h" #include "AdventureSupplyMgr.h" #include "AdventureSocialActions.h" #include "Config.h" @@ -16,6 +17,7 @@ #include "Random.h" #include #include +#include #include #include #include @@ -201,7 +203,7 @@ std::vector ReadMountDictionary(lua_State* L, int movem { lua_Integer id = lua_tointeger(L, -2); lua_Number weight = lua_tonumber(L, -1); - if (id > 0 && id <= 0xFFFFFFFFll && weight > 0.0) + if (id > 0 && id <= 0xFFFFFFFFll && std::isfinite(weight) && weight > 0.0 && weight <= 1000000.0) mounts.push_back({ uint32(id), uint32(std::max(1.0, weight)) }); } lua_pop(L, 1); @@ -220,11 +222,13 @@ public: sClientlessLuaEngine->ExecuteAdventureScript(player); sAdventureBehaviorMgr->Online(player); sAdventureSupplyMgr->Online(player); + sAdventurePopulationMgr->Online(player); } void OnIntentChanged(Player* player, ClientlessIntent const* intent) override { sAdventureBehaviorMgr->IntentChanged(player, intent); } void Update(Player* player, uint32 diff) override { + sAdventurePopulationMgr->Maintain(player,diff); if(sAdventureSupplyMgr->Update(player,diff,sAdventureCombatMgr->Busy(player)))return; if(!sAdventureCombatMgr->Update(player,diff) && !sAdventureInteractionMgr->Update(player,diff)) sAdventureBehaviorMgr->Update(player,diff); @@ -238,7 +242,7 @@ public: void OnDamageTaken(Player* player, Unit* attacker, uint32 damage) override { sAdventureCombatMgr->DamageTaken(player, attacker, damage); } void OnPlayerLogout(Player* player) override - { sAdventureInteractionMgr->Offline(player); sAdventureSupplyMgr->Offline(player); sAdventureCombatMgr->Offline(player); sAdventureBehaviorMgr->Offline(player); } + { sAdventurePopulationMgr->Offline(player); sAdventureInteractionMgr->Offline(player); sAdventureSupplyMgr->Offline(player); sAdventureCombatMgr->Offline(player); sAdventureBehaviorMgr->Offline(player); } }; } @@ -394,6 +398,19 @@ bool ClientlessLuaEngine::LoadAdventureProfile(Player* player) profile.HomeRegion = ReadStringField(_state, -1, "home_region"); profile.Background = ReadStringField(_state, -1, "background"); profile.Vocation = ReadStringField(_state, -1, "vocation"); + profile.ProfileVersion = uint32(std::clamp(ReadFloatField(_state,-1,"profile_version",0),0.0f,100.0f)); + profile.Level = uint32(std::clamp(ReadFloatField(_state,-1,"level",0),0.0f,85.0f)); + profile.Class = uint32(std::clamp(ReadFloatField(_state,-1,"class",0),0.0f,11.0f)); + profile.Specialization = uint32(std::clamp(ReadFloatField(_state,-1,"specialization",0),0.0f,2.0f)); + lua_getfield(_state,-1,"seed"); + if(lua_isinteger(_state,-1)){auto value=lua_tointeger(_state,-1);if(value>=0 && value<=0xFFFFFFFFll)profile.Seed=uint32(value);} + lua_pop(_state,1); + profile.Role = ReadStringField(_state,-1,"role","damage"); + if(profile.Role!="tank" && profile.Role!="healer" && profile.Role!="damage")profile.Role="damage"; + profile.HomeZone = uint32(std::max(0.0f,ReadFloatField(_state,-1,"home_zone",0))); + profile.HomeCapital = uint32(std::max(0.0f,ReadFloatField(_state,-1,"home_capital",0))); + profile.FavoriteZone = uint32(std::max(0.0f,ReadFloatField(_state,-1,"favorite_zone",0))); + profile.FavoriteRegion = ReadStringField(_state,-1,"favorite_region"); } lua_pop(_state, 1); @@ -408,11 +425,38 @@ bool ClientlessLuaEngine::LoadAdventureProfile(Player* player) profile.GroundMounts = ReadMountDictionary(_state, -1); profile.CanFreeFly = ReadBoolField(_state, -1, "can_free_fly", false); profile.FlightMounts = ReadMountDictionary(_state, -1, "flight_mounts"); + profile.Companions = ReadMountDictionary(_state,-1,"companions"); profile.CompanionSpell = uint32(std::max(0.0f, ReadFloatField(_state, -1, "companion_spell", 0))); profile.CompanionLeashItem = uint32(std::max(0.0f, ReadFloatField(_state, -1, "companion_leash_item", 0))); } lua_pop(_state, 1); + lua_getfield(_state,rootTable,"inventory"); + if(lua_istable(_state,-1)) + { + lua_getfield(_state,-1,"keep_items"); + if(lua_istable(_state,-1))for(size_t i=1;i<=lua_rawlen(_state,-1);++i) + { + lua_rawgeti(_state,-1,i);auto id=lua_tointeger(_state,-1); + if(id>0 && id<=0xFFFFFFFFll)profile.KeepItems.insert(uint32(id)); + lua_pop(_state,1); + } + lua_pop(_state,1); + } + lua_pop(_state,1); + lua_getfield(_state,rootTable,"zone_affinity"); + if(lua_istable(_state,-1)) + { + lua_pushnil(_state); + while(lua_next(_state,-2)) + { + auto id=lua_tointeger(_state,-2);auto weight=lua_tonumber(_state,-1); + if(lua_isinteger(_state,-2) && lua_isnumber(_state,-1) && id>0 && id<=0xFFFFFFFFll && std::isfinite(weight)) + profile.ZoneAffinity[uint32(id)]=float(std::clamp(weight,0.0,100.0)); + lua_pop(_state,1); + } + } + lua_pop(_state,1); profile.Personality = ReadFloatDictionary(_state, rootTable, "personality"); profile.RegionAffinity = ReadFloatDictionary(_state, rootTable, "region_affinity"); profile.ActivityAffinity = ReadFloatDictionary(_state, rootTable, "activity_affinity"); @@ -501,6 +545,18 @@ uint32 ClientlessLuaEngine::SelectGroundMountDisplayId(uint32 characterGuid) con return profile->GroundMounts.back().DisplayId; } +uint32 ClientlessLuaEngine::SelectCompanionSpell(uint32 guid) const +{ + auto profile=GetAdventureProfile(guid); + if(!profile)return 0; + uint32 total=0; + for(auto const& pet:profile->Companions)total+=pet.Weight; + if(!total)return profile->CompanionSpell; + auto roll=urand(1,total); + for(auto const& pet:profile->Companions){if(roll<=pet.Weight)return pet.DisplayId;roll-=pet.Weight;} + return 0; +} + bool ClientlessLuaEngine::PrepareGroundMount(Player* player, float travelDistance, char const* context) { if (!player || !player->IsInWorld()) diff --git a/src/server/game/World/ClientlessLuaEngine.h b/src/server/game/World/ClientlessLuaEngine.h index 3ee63fc114..1b6e6d6d70 100644 --- a/src/server/game/World/ClientlessLuaEngine.h +++ b/src/server/game/World/ClientlessLuaEngine.h @@ -28,6 +28,12 @@ struct TC_GAME_API ClientlessAdventureProfile std::string CharacterName; std::string HomeRegion; std::string Background, Vocation; + uint32 ProfileVersion = 0, Level = 0, Class = 0, Specialization = 0, Seed = 0; + std::string Role = "damage", FavoriteRegion; + uint32 HomeZone = 0, HomeCapital = 0, FavoriteZone = 0; + std::unordered_map ZoneAffinity; + std::vector Companions; + std::unordered_set KeepItems; std::string SourcePath; bool CanGroundMount = false; @@ -63,6 +69,7 @@ public: bool LoadAdventureProfile(Player* player); ClientlessAdventureProfile const* GetAdventureProfile(uint32 characterGuid) const; uint32 SelectGroundMountDisplayId(uint32 characterGuid) const; + uint32 SelectCompanionSpell(uint32 characterGuid) const; bool PrepareGroundMount(Player* player, float travelDistance, char const* context); void DismountGroundMount(Player* player, char const* reason); diff --git a/src/server/game/World/World.cpp b/src/server/game/World/World.cpp index b6827be989..805e5723a8 100644 --- a/src/server/game/World/World.cpp +++ b/src/server/game/World/World.cpp @@ -21,6 +21,7 @@ #include "World.h" #include "ClientlessPlayerMgr.h" +#include "AdventurePopulationMgr.h" #include "ClientlessRouteRecorderMgr.h" #include "AccountMgr.h" #include "AchievementMgr.h" @@ -3393,6 +3394,7 @@ void World::UpdateSessions(uint32 diff) // High-level clientless controllers observe state before the low-level // lifecycle loop may destroy a Removing session. + sAdventurePopulationMgr->Update(diff); sClientlessPlayerMgr->Update(diff); sClientlessRouteRecorderMgr->Update(diff); diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index cd057067f6..830c252ed8 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -22,11 +22,14 @@ #include "AdventureCombatMgr.h" #include "AdventureInteractionMgr.h" #include "AdventureGeneratorMgr.h" +#include "AdventurePopulationMgr.h" #include "AdventureSupplyMgr.h" #include "ClientlessTravelGraphMgr.h" #include "ClientlessRouteRecorderMgr.h" #include "ClientlessLuaEngine.h" #include "DatabaseEnv.h" +#include "AsyncCallbackProcessor.h" +#include #include "DBCStores.h" #include "Creature.h" #include "Group.h" @@ -64,6 +67,8 @@ namespace { + bool Population(ChatHandler* handler,char const* args) + {return sAdventurePopulationMgr->Command(handler,args);} bool GenerateBot(ChatHandler* handler,char const* args) { return sAdventureGeneratorMgr->Command(handler,args); } bool InteractionStatus(ChatHandler* handler,char const* args) @@ -2525,6 +2530,7 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg { "combat", rbac::RBAC_PERM_COMMAND_GM, true, &ClientlessCombatPreparation::Command, "" }, { "interaction", rbac::RBAC_PERM_COMMAND_GM, true, &InteractionStatus, "" }, { "generate", rbac::RBAC_PERM_COMMAND_GM, true, &GenerateBot, "" }, + { "population", rbac::RBAC_PERM_COMMAND_GM, true, &Population, "" }, { "luareload", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLuaReload, "" }, { "transport", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessTransport, "" }, { "login", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessLogin, "" }, @@ -2595,16 +2601,47 @@ 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; - if (auto rows=CharacterDatabase.Query("SELECT c.account,c.guid FROM vcore_adventure_instance_visit v JOIN characters c ON c.guid=v.character_guid ORDER BY c.guid")) do - { - auto f=rows->Fetch(); - sClientlessPlayerMgr->StartPlayer(f[0].GetUInt32(),f[1].GetUInt32(),CreateClientlessAdventureLuaController()); - } while(rows->NextRow()); + // Native instance continuation must not bypass generated population + // quotas or perform synchronous login/database loops during startup. + _requested=false;_delay=10000;_resume.clear(); } + void OnUpdate(uint32 diff) override + { + ClientlessCombatPreparation::Update(diff); + _queries.ProcessReadyCallbacks(); + if(_delay>diff){_delay-=diff;return;} + _delay=2000; + if(!_requested) + { + _requested=true; + _queries.AddCallback(CharacterDatabase.AsyncQuery("SHOW TABLES LIKE 'vcore_adventure_instance_visit'").WithCallback([this](QueryResult visits) + { + if(!visits)return; + _queries.AddCallback(CharacterDatabase.AsyncQuery("SHOW TABLES LIKE 'vcore_adventure_generated_bot'").WithCallback([this](QueryResult generated) + { + std::string sql="SELECT c.account,c.guid FROM vcore_adventure_instance_visit v JOIN characters c ON c.guid=v.character_guid"; + if(generated)sql+=" LEFT JOIN vcore_adventure_generated_bot g ON g.character_guid=c.guid WHERE g.character_guid IS NULL"; + sql+=" ORDER BY c.guid"; + _queries.AddCallback(CharacterDatabase.AsyncQuery(sql.c_str()).WithCallback([this](QueryResult rows) + { + if(rows)do{auto f=rows->Fetch();_resume.emplace_back(f[0].GetUInt32(),f[1].GetUInt32());}while(rows->NextRow()); + })); + })); + })); + return; + } + if(_resume.empty())return; + auto next=_resume.front();_resume.pop_front(); + if(sWorld->GetClientlessPlayerState(next.second)==ClientlessPlayerState::Offline) + sClientlessPlayerMgr->StartPlayer(next.first,next.second,CreateClientlessAdventureLuaController()); + } +private: + uint32 _delay=10000; + bool _requested=false; + QueryCallbackProcessor _queries; + std::deque> _resume; }; void AddSC_viano_bot_commands() diff --git a/src/server/worldserver/Main.cpp b/src/server/worldserver/Main.cpp index 5e6d7d38b8..74f37c6fa1 100644 --- a/src/server/worldserver/Main.cpp +++ b/src/server/worldserver/Main.cpp @@ -1,3 +1,4 @@ +#include "ClientlessCharacterFactory.h" /* * This file is part of the TrinityCore Project. See AUTHORS file for Copyright information * @@ -306,6 +307,7 @@ extern int main(int argc, char** argv) std::shared_ptr mapManagementHandle(nullptr, [](void*) { + ClientlessCharacterFactory::Shutdown(); // unload battleground templates before different singletons destroyed sBattlegroundMgr->DeleteAllBattlegrounds(); diff --git a/src/server/worldserver/worldserver.conf.dist b/src/server/worldserver/worldserver.conf.dist index a5050bbcb1..c25118fd7c 100644 --- a/src/server/worldserver/worldserver.conf.dist +++ b/src/server/worldserver/worldserver.conf.dist @@ -4340,3 +4340,19 @@ Clientless.Flight.Clearance = 60 # the final node/virtual-exit -> location leg (Icecrown, Wintergrasp). Clientless.Flight.MinDistance = 600 Clientless.Flight.PreferredZones = "210 4197" + +# AdventureBot population. Four independent online targets; 0 loads none. +# Initial loading happens gradually during normal world updates after startup. +# Existing manually created characters are never adopted or rotated. +Clientless.Adventure.Loader.Enabled = 0 +Clientless.Adventure.Loader.Alliance.Persistent = 0 +Clientless.Adventure.Loader.Alliance.Temporary = 0 +Clientless.Adventure.Loader.Horde.Persistent = 0 +Clientless.Adventure.Loader.Horde.Temporary = 0 +Clientless.Adventure.Loader.IntervalMs = 2000 +Clientless.Adventure.Loader.MinLevel = 1 +Clientless.Adventure.Loader.MaxLevel = 85 +# Chance considered once per offline temporary bot per process, before login. +Clientless.Adventure.Loader.RotationPercent = 15 +Clientless.Adventure.Loader.RotationMinAgeHours = 24 +Clientless.Adventure.Manager.IntervalSeconds = 300 diff --git a/tests/clientless/test_adventure_async_database.py b/tests/clientless/test_adventure_async_database.py new file mode 100644 index 0000000000..92a4e240c5 --- /dev/null +++ b/tests/clientless/test_adventure_async_database.py @@ -0,0 +1,19 @@ +"""Validate database contracts used by the asynchronous native creation path.""" +from pathlib import Path +import re +root=Path(__file__).resolve().parents[2] +registration=(root/'src/server/database/Database/Implementation/LoginDatabase.cpp').read_text() +for name in ('LOGIN_INS_ACCOUNT','LOGIN_REP_REALM_CHARACTERS'): + match=re.search(r'PrepareStatement\('+name+r',\s*"([^"]+)",\s*(\w+)\)',registration) + assert match and match[2] in ('CONNECTION_ASYNC','CONNECTION_BOTH'),name+' unavailable on async connection' +factory=(root/'src/server/game/World/ClientlessCharacterFactory.cpp').read_text().split('void ClientlessCharacterFactory::CreateAsync(')[1] +assert 'x.AccountID=a.id' in factory and 'x.SecurityLevel' in factory +assert 'x.gmlevel' not in factory and 'x.id=a.id' not in factory +assert '.m_future.get(' not in factory and '.wait(' not in factory +assert 'FactorySaves->AddCallback' in factory and 'if(!ok)' in factory +assert factory.index('if(!ok)')Update(diff)' in world +print('Async creation DB contracts: prepared connection masks, native security column names, no future waits, successful-save cache publication and world-update-only loader hook passed') diff --git a/tests/clientless/test_adventure_day_persistence.py b/tests/clientless/test_adventure_day_persistence.py new file mode 100644 index 0000000000..bddc25cfa6 --- /dev/null +++ b/tests/clientless/test_adventure_day_persistence.py @@ -0,0 +1,64 @@ +"""Execute actual day generation/restoration with the real TINYINT hour schema.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() +def fn(sig): + a=s.index(sig);b=s.index('{',a);e=b+1;n=1 + while n:n+=(s[e]=='{')-(s[e]=='}');e+=1 + return s[a:e] +code=r''' +#include "AdventureBehaviorPolicy.h" +#include "AdventureGroupPolicy.h" +#include +#include +#include +using uint32=unsigned;using namespace AdventureBehaviorPolicy; +#define TC_LOG_INFO(...) ((void)0) +struct Guid{unsigned GetCounter(){return 99;}}; +struct Player{Guid GetGUID(){return {};}}; +struct Field{unsigned n=0;std::string s;unsigned GetUInt8(){return n;}unsigned GetUInt32(){return 0;}std::string GetString(){return s;}}; +struct Rows{std::vector> data;unsigned index=0;Field*Fetch(){return data[index].data();}bool NextRow(){return ++index rows;std::shared_ptr saved;unsigned oldCleanup=0; + auto PQuery(char const*,unsigned,unsigned){if(rows)rows->index=0;return rows;} + void PExecute(char const*,unsigned,unsigned){++oldCleanup;} + auto BeginTransaction(){return std::make_shared();}void CommitTransaction(std::shared_ptr t){saved=t;} +}WorldDatabase; +struct Profile{std::unordered_mapPersonality,ActivityAffinity;}profile; +struct Lua{Profile*GetAdventureProfile(unsigned){return &profile;}}lua;auto sClientlessLuaEngine=&lua; +struct AdventureBehaviorMgr{ + struct Record{uint32 Date=0;Day Plan;std::mt19937 Random{9};}; + int picks=0;float Affinity(std::unordered_mapconst&,std::string const&,float); + bool Known(std::string const& s){return s=="social" || s=="adventure" || s=="profession" || s=="rest" || s=="leisure" || s=="economy" || s=="instance" || s=="raid" || s=="groupformation";} + std::string PickLocation(Player*,Record&,std::string const& intent){++picks;return intent+"_"+std::to_string(picks);} + void Generate(Player*,Record&,uint32); +}; +'''+fn('float AdventureBehaviorMgr::Affinity(')+fn('void AdventureBehaviorMgr::Generate(')+r''' +int main(){ + assert(AdventureGroupPolicy::CapitalFaction(1537)==1 && AdventureGroupPolicy::CapitalFaction(1637)==2 && AdventureGroupPolicy::CapitalFaction(4395)==0); + Player p;AdventureBehaviorMgr mgr;AdventureBehaviorMgr::Record r; + auto rows=std::make_shared(); + for(unsigned h=0;h<24;++h)rows->data.push_back({Field{h,""},Field{0,h%2?"social":"adventure"},Field{0,"saved_"+std::to_string(h)}}); + WorldDatabase.rows=rows;mgr.Generate(&p,r,20261029); + assert(r.Date==20261029 && mgr.picks==0 && !WorldDatabase.saved && WorldDatabase.oldCleanup==1); + for(unsigned h=0;h<24;++h)assert(r.Plan[h].Location=="saved_"+std::to_string(h)); + // New login uses the same stored slots instead of rerolling the day. + AdventureBehaviorMgr::Record relog;mgr.Generate(&p,relog,20261029);assert(relog.Plan[23].Location==r.Plan[23].Location && mgr.picks==0); + // Missing/incomplete current day is completely replaced, deleting older dates. + rows->data.resize(4);mgr.Generate(&p,r,20261030); + assert(r.Date==20261030 && mgr.picks>0 && WorldDatabase.saved->slots==24 && WorldDatabase.saved->deletes==1); + for(auto const& slot:r.Plan)assert(!slot.Intent.empty() && !slot.Location.empty()); + // Geography remains weighted: curiosity reduces home bias without excluding it. + assert(RegionWeight(5,0)>RegionWeight(5,1));assert(RegionWeight(1,1)==1); + assert(LevelWeight(60,58,63,true)==1 && LevelWeight(60,80,82,true)==0); + assert(LevelWeight(60,1,10,true) +#include +#include +using uint32=unsigned;using Archetype=AdventureGeneratorPolicy::Archetype; +enum{EQUIP_ERR_OK=0,ITEM_FLAG_CONJURED=1,HOLIDAY_NONE=0,ITEM_CLASS_ARMOR=4, + EQUIPMENT_SLOT_NECK=1,EQUIPMENT_SLOT_BACK=14,EQUIPMENT_SLOT_FINGER1=10,EQUIPMENT_SLOT_FINGER2=11,EQUIPMENT_SLOT_TRINKET1=12,EQUIPMENT_SLOT_TRINKET2=13,EQUIPMENT_SLOT_OFFHAND=16, + ITEM_MOD_STRENGTH=4,ITEM_MOD_AGILITY=3,ITEM_MOD_INTELLECT=5,ITEM_MOD_ATTACK_POWER=38,ITEM_MOD_RANGED_ATTACK_POWER=39,ITEM_MOD_SPIRIT=6,ITEM_MOD_MANA=0,ITEM_MOD_MANA_REGENERATION=43,ITEM_MOD_SPELL_POWER=45,ITEM_MOD_SPELL_PENETRATION=47,MAX_ITEM_PROTO_STATS=1}; +struct Player{unsigned level=60;bool usable=true;unsigned getLevel(){return level;}templateunsigned CanUseItem(T*){return usable?0:1;}}; +struct Effect{unsigned SpellID;}; +struct ItemTemplate{ + bool fits=true;unsigned quality=3,ilevel=80,cls=4,sub=4,flags=0,holiday=0,map=0,duration=0,rep=0,area=0,prop=0,suffix=0;int req=60,stat=4;float value=10;std::vectorEffects; + int GetRequiredLevel()const{return req;}unsigned GetQuality()const{return quality;}unsigned GetBaseItemLevel()const{return ilevel;} + unsigned GetDuration()const{return duration;}unsigned GetFlags()const{return flags;}unsigned GetRequiredReputationFaction()const{return rep;}unsigned GetArea()const{return area;}unsigned GetMap()const{return map;}unsigned GetHolidayID()const{return holiday;}unsigned GetRandomProperty()const{return prop;}unsigned GetRandomSuffix()const{return suffix;} + unsigned GetClass()const{return cls;}unsigned GetSubClass()const{return sub;}int GetItemStatType(unsigned)const{return stat;}float GetStatValue(unsigned,Player*)const{return value;} +}; +bool Slot(ItemTemplate const*i,unsigned,Archetype const&){return i->fits;} +bool UsefulSpell(unsigned id,Archetype const&){return id==42;} +'''+s[a:e]+r''' +int main(){Player p;ItemTemplate item;auto a=AdventureGeneratorPolicy::Archetypes()[0]; + auto check=[&]{return EquipmentScore(&p,&item,4,a);};assert(check()==100); + item.sub=1;assert(!std::isfinite(check()));item.sub=4; // wearable cloth is wrong for this warrior + item.stat=ITEM_MOD_INTELLECT;assert(!std::isfinite(check()));item.stat=4; + item.Effects={{0xFFFFFFFFu}};assert(!std::isfinite(check()));item.Effects={{42}};assert(std::isfinite(check())); + item.req=61;assert(!std::isfinite(check()));item.req=60; + item.flags=ITEM_FLAG_CONJURED;assert(!std::isfinite(check()));item.flags=0; + item.rep=1;assert(!std::isfinite(check()));item.rep=0; + item.map=719;assert(!std::isfinite(check()));item.map=0; + item.holiday=1;assert(!std::isfinite(check()));item.holiday=0; + p.usable=false;assert(!std::isfinite(check()));p.usable=true; + item.fits=false;assert(!std::isfinite(check())); +} +''' +# Both birth candidate ranking and retention of already equipped items consume this exact rule. +assert 'EquipmentScore(p,current->GetTemplate(),slot,a)' in s +assert 'auto score=EquipmentScore(p,item,slot,a)' in s +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 gear maintenance: same birth/retention gate rejects wrong armor/stats, malformed effects, level, temporary, reputation, map, holiday, native-use and slot mismatches passed') diff --git a/tests/clientless/test_adventure_generator_equipment.py b/tests/clientless/test_adventure_generator_equipment.py index c1291f7ad5..02e038312d 100644 --- a/tests/clientless/test_adventure_generator_equipment.py +++ b/tests/clientless/test_adventure_generator_equipment.py @@ -29,7 +29,7 @@ int main(){ spells.data[7].Effects={{SPELL_AURA_MOD_SPELL_HIT_CHANCE,0,0,0}};assert(!UsefulSpell(7,warrior));assert(UsefulSpell(7,mage)); spells.data[8].Effects={{SPELL_AURA_MOD_DAMAGE_DONE,2,0,0}};assert(!UsefulSpell(8,warrior));assert(UsefulSpell(8,mage)); spells.data[9].Effects={{SPELL_AURA_MOD_DAMAGE_DONE,1,0,0}};assert(UsefulSpell(9,warrior));assert(!UsefulSpell(9,mage)); - assert(UsefulSpell(999,warrior)); // missing metadata never grants a spell + assert(!UsefulSpell(999,warrior));assert(!UsefulSpell(0xFFFFFFFFu,warrior)); // malformed proc metadata cannot reach native equip assertions } ''' with tempfile.TemporaryDirectory() as d: diff --git a/tests/clientless/test_adventure_generator_ownership.py b/tests/clientless/test_adventure_generator_ownership.py index 59bcf1709e..f25cfd586b 100644 --- a/tests/clientless/test_adventure_generator_ownership.py +++ b/tests/clientless/test_adventure_generator_ownership.py @@ -33,6 +33,7 @@ struct Behavior{void InterruptForInteraction(Player*){}}behavior;auto sAdventure #define TC_LOG_INFO(...) ((void)0) struct World{Session session;bool available=false;Session*FindClientlessSession(unsigned){return available?&session:nullptr;}ClientlessPlayerState state=ClientlessPlayerState::Offline;auto GetClientlessPlayerState(unsigned){return state;}}world;auto sWorld=&world; struct Core{int starts=0,stops=0;bool StartPlayer(unsigned,unsigned,int){++starts;return true;}bool StopPlayer(unsigned){++stops;return true;}}core;auto sClientlessPlayerMgr=&core;int CreateClientlessAdventureLuaController(){return 0;} +struct Profile{unsigned Specialization=0;}profile;struct Lua{Profile*GetAdventureProfile(unsigned){return &profile;}}lua;auto sClientlessLuaEngine=&lua; struct AdventureGeneratorMgr{ int creates=0;bool Command(ChatHandler*,char const*); bool Create(ChatHandler*,uint32,uint32,uint8,unsigned,std::string const&,bool){++creates;return true;} diff --git a/tests/clientless/test_adventure_generator_policy.py b/tests/clientless/test_adventure_generator_policy.py index 3e9e562a64..13ad9f6e95 100644 --- a/tests/clientless/test_adventure_generator_policy.py +++ b/tests/clientless/test_adventure_generator_policy.py @@ -39,6 +39,18 @@ int main(){ assert(EligibleItem(60,58,3,85,false,false));assert(!EligibleItem(60,61,3,85,false,false)); assert(!EligibleItem(60,58,5,85,false,false));assert(!EligibleItem(60,58,3,85,true,false));assert(!EligibleItem(60,58,3,85,false,true)); assert(!EligibleItem(60,1,3,85,false,false)); + for(unsigned level:{1,5,60,85})for(unsigned seed=0;seed<50;++seed)for(auto a:Archetypes()){ + std::mt19937 random(seed);a=Personalize(a,random); + auto text=Profile("Testname",a,3,"inn","Dun Morogh",random,level,1,3483,"Hellfire",seed); + assert(luaL_dostring(L,text.c_str())==LUA_OK); + lua_getfield(L,-1,"identity");lua_getfield(L,-1,"level");assert(lua_tointeger(L,-1)==level);lua_pop(L,1); + lua_getfield(L,-1,"class");assert(lua_tointeger(L,-1)==a.Class);lua_pop(L,1); + lua_getfield(L,-1,"specialization");assert(lua_tointeger(L,-1)==a.Spec);lua_pop(L,1); + lua_getfield(L,-1,"role");assert(std::string(lua_tostring(L,-1))==Role(a));lua_pop(L,1); + lua_getfield(L,-1,"home_capital");assert(lua_tointeger(L,-1)==1537);lua_pop(L,2); + lua_getfield(L,-1,"zone_affinity");for(unsigned zone:{1,1537,3483}){lua_rawgeti(L,-1,zone);assert(lua_tonumber(L,-1)>1);lua_pop(L,1);}lua_pop(L,1); + lua_getfield(L,-1,"behavior");lua_getfield(L,-1,"daily_schedule");assert(lua_toboolean(L,-1));lua_settop(L,0); + } lua_close(L); } ''' diff --git a/tests/clientless/test_adventure_instance_autoresume.py b/tests/clientless/test_adventure_instance_autoresume.py index c58c631f85..caa32bb85b 100644 --- a/tests/clientless/test_adventure_instance_autoresume.py +++ b/tests/clientless/test_adventure_instance_autoresume.py @@ -1,4 +1,4 @@ -"""Run the actual optional WorldScript startup recovery with stored visits.""" +"""Run the actual optional WorldScript deferred recovery with stored visits.""" from pathlib import Path import subprocess,tempfile root=Path(__file__).resolve().parents[2] @@ -9,27 +9,38 @@ code=r''' #include #include #include +#include +#include +#include using uint32=uint32_t; 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(); - std::shared_ptr Query(char const* sql){if(sql[0]=='S'&&sql[1]=='H')return schema?std::make_shared():nullptr;rows->index=0;return rows->data.empty()?nullptr:rows;} +using QueryResult=std::shared_ptr; +struct Callback{std::function run;}; +struct Query{QueryResult row;templateCallback WithCallback(F f){return {[f,row=row]{f(row);}};}}; +struct QueryCallbackProcessor{std::vector pending;void AddCallback(Callback c){pending.push_back(c);}void ProcessReadyCallbacks(){auto work=std::move(pending);pending.clear();for(auto&c:work)c.run();}}; +struct Database{bool schema=true;std::shared_ptr rows=std::make_shared();int queries=0; + Query AsyncQuery(char const* sql){++queries;std::string text=sql;if(text.find("SHOW TABLES")!=std::string::npos)return {text.find("generated_bot")==std::string::npos && schema?std::make_shared():nullptr};rows->index=0;return {rows->data.empty()?nullptr:rows};} }CharacterDatabase; +enum class ClientlessPlayerState{Offline,Online}; +struct World{ClientlessPlayerState GetClientlessPlayerState(unsigned){return ClientlessPlayerState::Offline;}}world;auto sWorld=&world; struct Manager{std::vector> started;void StartPlayer(uint32 account,uint32 guid,std::unique_ptr controller){assert(controller&&*controller==42);started.emplace_back(account,guid);}}manager; auto sClientlessPlayerMgr=&manager; std::unique_ptr CreateClientlessAdventureLuaController(){return std::make_unique(42);} '''+s+r''' -int main(){ClientlessInstanceVisitResume script; - CharacterDatabase.schema=false;script.OnStartup();assert(manager.started.empty()); - CharacterDatabase.schema=true;script.OnStartup();assert(manager.started.empty()); - CharacterDatabase.rows->data={{{6},{26}},{{7},{27}}};script.OnStartup(); +int main(){ + auto advance=[](ClientlessInstanceVisitResume& script){script.OnUpdate(10000);for(int i=0;i<5;++i)script.OnUpdate(2000);}; + CharacterDatabase.schema=false;ClientlessInstanceVisitResume absent;absent.OnStartup();assert(!CharacterDatabase.queries);advance(absent);assert(manager.started.empty()); + CharacterDatabase.schema=true;ClientlessInstanceVisitResume empty;empty.OnStartup();advance(empty);assert(manager.started.empty()); + CharacterDatabase.rows->data={{{6},{26}},{{7},{27}}};ClientlessInstanceVisitResume saved;saved.OnStartup();assert(manager.started.empty());advance(saved); assert((manager.started==std::vector>{{6,26},{7,27}})); + assert(ClientlessCombatPreparation::ticks>0); } ''' with tempfile.TemporaryDirectory() as tmp: src=Path(tmp)/'startup.cpp';exe=Path(tmp)/'startup';src.write_text(code) subprocess.run(['g++','-std=c++17',str(src),'-o',str(exe)],check=True) subprocess.run([str(exe)],check=True) -print('Instance startup script: no schema/no visits stays idle; saved visitors resume with the Adventure controller and native accounts.') +print('Deferred instance script: missing schema/visits stays idle; no login work on startup; saved visitors resume with Adventure controllers and native accounts.') diff --git a/tests/clientless/test_adventure_instance_loader.py b/tests/clientless/test_adventure_instance_loader.py new file mode 100644 index 0000000000..d635294519 --- /dev/null +++ b/tests/clientless/test_adventure_instance_loader.py @@ -0,0 +1,56 @@ +"""Execute production legacy instance recovery without bypassing bot quotas.""" +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 ClientlessInstanceVisitResume : public WorldScript');b=s.index('\nvoid AddSC_viano_bot_commands()',a) +code=r''' +#include +#include +#include +#include +#include +#include +#include +#include +using uint32=unsigned; +struct WorldScript{WorldScript(char const*){}virtual~WorldScript()=default;virtual void OnStartup(){}virtual void OnUpdate(unsigned){}}; +struct Field{unsigned v=0;unsigned GetUInt32(){return v;}}; +struct Rows{std::vector>data;unsigned index=0;Field f[2];Field*Fetch(){f[0].v=data[index].first;f[1].v=data[index].second;return f;}bool NextRow(){return ++index; +struct Callback{std::functionrun;}; +struct Query{QueryResult rows;templateCallback WithCallback(F f){return {[f,rows=rows]{f(rows);}};}}; +struct QueryCallbackProcessor{std::vectorwork;void AddCallback(Callback c){work.push_back(c);}void ProcessReadyCallbacks(){auto batch=std::move(work);work.clear();for(auto&c:batch)c.run();}}; +struct Database{ + bool visits=true,generated=true;std::vectorqueries; + Query AsyncQuery(char const*text){std::string s=text;queries.push_back(s);if(s.find("SHOW TABLES")!=std::string::npos){bool exists=s.find("generated_bot")!=std::string::npos?generated:visits;return {exists?std::make_shared():nullptr};} + auto r=std::make_shared();r->data={{6,26},{6,40}};if(s.find("g.character_guid IS NULL")==std::string::npos)r->data.push_back({8,36});return {r};} +}CharacterDatabase; +enum class ClientlessPlayerState{Offline,Online}; +struct World{std::unordered_mapstates;auto GetClientlessPlayerState(unsigned id){return states[id];}}world;auto sWorld=&world; +struct Core{std::vectorstarts;void StartPlayer(unsigned,unsigned guid,int){starts.push_back(guid);world.states[guid]=ClientlessPlayerState::Online;}}core;auto sClientlessPlayerMgr=&core; +int CreateClientlessAdventureLuaController(){return 0;} +namespace ClientlessCombatPreparation{unsigned updates=0;void Update(unsigned){++updates;}} +'''+s[a:b]+r''' +int main(){ + ClientlessInstanceVisitResume script;script.OnStartup();assert(CharacterDatabase.queries.empty()); + script.OnUpdate(9999);assert(CharacterDatabase.queries.empty());script.OnUpdate(1);assert(CharacterDatabase.queries.size()==1 && core.starts.empty()); + script.OnUpdate(1);script.OnUpdate(1);script.OnUpdate(1);assert(CharacterDatabase.queries.size()==3 && core.starts.empty()); + script.OnUpdate(1997);assert(core.starts==std::vector{26});script.OnUpdate(1999);assert(core.starts.size()==1);script.OnUpdate(1);assert((core.starts==std::vector{26,40})); + script.OnUpdate(2000);assert(core.starts.size()==2); // generated36 belongs exclusively to the population loader + assert(CharacterDatabase.queries.back().find("g.character_guid IS NULL")!=std::string::npos); + assert(ClientlessCombatPreparation::updates>0); + // Backward compatibility: a server without generator schema still resumes manual visits. + CharacterDatabase={};CharacterDatabase.generated=false;world={};core={}; + ClientlessInstanceVisitResume legacy;legacy.OnStartup();legacy.OnUpdate(10000);for(int i=0;i<5;++i)legacy.OnUpdate(2000); + assert((core.starts==std::vector{26,40,36})); + // Missing optional visit schema never blocks the world or starts characters. + CharacterDatabase={};CharacterDatabase.visits=false;world={};core={}; + ClientlessInstanceVisitResume absent;absent.OnStartup();absent.OnUpdate(10000);for(int i=0;i<5;++i)absent.OnUpdate(2000);assert(core.starts.empty()); +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['g++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Production instance recovery: no startup DB/login work, asynchronous delayed/paced manual recovery, generated-bot quota ownership, absent-schema compatibility and existing preparation ticks passed') diff --git a/tests/clientless/test_adventure_leisure.py b/tests/clientless/test_adventure_leisure.py index 8e2dd9321a..2ef03f30a8 100644 --- a/tests/clientless/test_adventure_leisure.py +++ b/tests/clientless/test_adventure_leisure.py @@ -72,8 +72,8 @@ struct ClientlessCompanionMgr{ static Creature* Resolve(Player*,ClientlessCompanionScene const&){return valid?&cat:nullptr;} static void Reset(Player*,ClientlessCompanionScene& s){if(s.Active)++resets;s={};} }; -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 Profile{bool CanGroundMount=true;float GroundMountMinDistance=45;std::vector GroundMounts{1},Companions;uint32 CompanionSpell=10673,CompanionLeashItem=37460;}profile; +struct Lua{uint32 SelectCompanionSpell(uint32){return profile.CompanionSpell;}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; diff --git a/tests/clientless/test_adventure_manager_inventory.py b/tests/clientless/test_adventure_manager_inventory.py new file mode 100644 index 0000000000..cba6d1f265 --- /dev/null +++ b/tests/clientless/test_adventure_manager_inventory.py @@ -0,0 +1,50 @@ +"""Execute production full-inventory cleanup and all protection decisions.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventurePopulationMgr.cpp').read_text() +a=s.index('void AdventurePopulationMgr::CleanInventory(');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 +using uint8=unsigned char;using uint32=unsigned; +constexpr unsigned ITEM_CLASS_CONTAINER=1,ITEM_CLASS_WEAPON=2,ITEM_SUBCLASS_WEAPON_FISHING_POLE=3; +constexpr unsigned INVENTORY_SLOT_ITEM_START=0,INVENTORY_SLOT_ITEM_END=4,INVENTORY_SLOT_BAG_START=4,INVENTORY_SLOT_BAG_END=6,INVENTORY_SLOT_BAG_0=255; +struct Proto{unsigned cls=0,sub=0;unsigned GetClass(){return cls;}unsigned GetSubClass(){return sub;}}; +struct Item{unsigned entry,count=1;Proto proto;Proto*GetTemplate(){return &proto;}unsigned GetEntry(){return entry;}unsigned GetCount(){return count;}}; +struct Bag{unsigned GetBagSize(){return 8;}}; +struct Guid{unsigned GetCounter(){return 50;}}; +struct Player{ + unsigned free=0,destroys=0;std::map,Item>items;Bag bag; + unsigned GetFreeInventorySpace(){return free;}Guid GetGUID(){return {};} + Item*GetItemByPos(unsigned b,unsigned s){auto it=items.find({b,s});return it==items.end()?nullptr:&it->second;} + Bag*GetBagByPos(unsigned b){return b==4?&bag:nullptr;} + void DestroyItem(unsigned b,unsigned s,bool){items.erase({b,s});++destroys;} + void DestroyItemCount(Item*item,unsigned& count,bool){assert(countcount);item->count-=count;++destroys;count=0;} +}; +struct Profile{std::unordered_setKeepItems{2901};unsigned CompanionLeashItem=37460;}profile; +struct Lua{Profile*GetAdventureProfile(unsigned){return &profile;}}lua;auto sClientlessLuaEngine=&lua; +struct Supply{std::unordered_mapInventoryAllowance(Player*){return {{100,20},{101,20}};}}supply;auto sAdventureSupplyMgr=&supply; +struct AdventurePopulationMgr{void CleanInventory(Player*);}; +'''+s[a:e]+r''' +int main(){ + Player p;AdventurePopulationMgr mgr; + p.items={{ {255,0},Item{100,15}},{{255,1},Item{999,1}},{{255,2},Item{2901,1}},{{255,3},Item{37460,1}}, + {{4,0},Item{100,10}},{{4,1},Item{100,20}},{{4,2},Item{101,20}},{{4,3},Item{6256,1,{ITEM_CLASS_WEAPON,ITEM_SUBCLASS_WEAPON_FISHING_POLE}}}, + {{4,4},Item{54443,1,{ITEM_CLASS_CONTAINER,0}}},{{4,5},Item{777,1,{ITEM_CLASS_WEAPON,0}}},{{0,10},Item{888,1}}}; + p.free=1;mgr.CleanInventory(&p);assert(!p.destroys && p.items.size()==11); + p.free=0;mgr.CleanInventory(&p); + assert(!p.GetItemByPos(255,1));assert(!p.GetItemByPos(4,1));assert(!p.GetItemByPos(4,5)); + assert(p.GetItemByPos(255,0)->count==15 && p.GetItemByPos(4,0)->count==5); + for(auto pos:{std::pair{255,2},std::pair{255,3},std::pair{4,2},std::pair{4,3},std::pair{4,4},std::pair{0,10}})assert(p.GetItemByPos(pos.first,pos.second)); +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['g++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Production inventory maintenance: only full backpacks, native stack trimming, level-appropriate supplies, tools, leash, fishing pole, containers and untouched equipped slots passed') diff --git a/tests/clientless/test_adventure_population.py b/tests/clientless/test_adventure_population.py new file mode 100644 index 0000000000..016e1d6fce --- /dev/null +++ b/tests/clientless/test_adventure_population.py @@ -0,0 +1,70 @@ +"""Execute the production loader selection, quotas, retry and rotation gates.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventurePopulationMgr.cpp').read_text() +def function(sig): + a=s.index(sig);b=s.index('{',a);e=b+1;depth=1 + while depth:depth+=(s[e]=='{')-(s[e]=='}');e+=1 + return s[a:e] +code=r''' +#include "AdventurePopulationPolicy.h" +#include +#include +#include +#include +#include +using uint32=unsigned;using uint64=unsigned long long; +using namespace AdventurePopulationPolicy; +namespace GameTime{time_t now=100000;time_t GetGameTime(){return now;}} +struct ObjectGuid{enum{Player};unsigned id;ObjectGuid(int,unsigned i):id(i){}}; +namespace HighGuid{constexpr int Player=0;} +namespace ObjectAccessor{std::unordered_setconnected;bool FindConnectedPlayer(ObjectGuid g){return connected.count(g.id);}} +enum class ClientlessPlayerState{Offline,Loading,Online,Removing}; +struct World{std::unordered_mapstates;auto GetClientlessPlayerState(unsigned id){return states[id];}}world;auto sWorld=&world; +struct Core{bool succeed=true;std::vectorstarted;bool StartPlayer(unsigned,unsigned guid,int){started.push_back(guid);if(succeed)world.states[guid]=ClientlessPlayerState::Loading;return succeed;}}core;auto sClientlessPlayerMgr=&core; +int CreateClientlessAdventureLuaController(){return 0;} +float sample=.5;float frand(int,int){return sample;} +#define TC_LOG_INFO(...) ((void)0) +struct AdventurePopulationMgr{ + struct Bot{uint32 Guid,Account,Pool;uint64 Created;uint32 Failures=0;time_t Retry=0;}; + unsigned _cursor=0,_rotationAge=86400;double _rotationChance=.15; + std::array_targets{};std::vector_bots;std::unordered_set_considered; + std::vector>generated; + void Step();void Generate(unsigned pool,unsigned replace=0){generated.push_back({pool,replace});} +}; +'''+function('void AdventurePopulationMgr::Step()')+r''' +int main(){ + assert(Pool(1,true)==0 && Pool(1,false)==1 && Pool(2,true)==2 && Pool(2,false)==3); + assert(!Replace(true,true,false,100,0,0,1));assert(!Replace(false,false,false,100,0,0,1)); + assert(!Replace(false,true,true,100,0,0,1));assert(!Replace(false,true,false,99,100,0,1)); + assert(Replace(false,true,false,100,100,.1,.2));assert(!Replace(false,true,false,100,0,.3,.2)); + assert(!Deficit(5,3,2) && Deficit(5,2,1)==2 && !Deficit(0,2,0)); + AdventurePopulationMgr mgr; + mgr.Step();assert(mgr.generated.empty() && core.started.empty()); + mgr._targets={1,2,3,4}; + mgr.Step();assert(mgr.generated.back().first==0);mgr.Step();assert(mgr.generated.back().first==1); + mgr.Step();assert(mgr.generated.back().first==2);mgr.Step();assert(mgr.generated.back().first==3); + mgr={};mgr._targets={1,0,0,0};mgr._bots={{10,6,0,0},{11,6,0,0},{12,6,2,0}}; + world.states[10]=ClientlessPlayerState::Online;mgr.Step();assert(core.started.empty()); + world.states[10]=ClientlessPlayerState::Loading;mgr.Step();assert(core.started.empty()); + world.states[10]=ClientlessPlayerState::Offline;mgr.Step();assert(core.started.back()==10);assert(mgr.generated.empty()); + world.states[10]=ClientlessPlayerState::Offline;core.succeed=false;core.started.clear(); + mgr._bots[0].Retry=0;mgr.Step();assert(core.started.size()==1 && mgr._bots[0].Failures==1); + mgr.Step();assert(core.started.back()==11 && mgr.generated.empty()); // retry other saved candidate + mgr.Step();assert(core.started.size()==2 && mgr.generated.empty()); // no unbounded regeneration + mgr={};world.states.clear();core.started.clear();core.succeed=true; + mgr._targets={0,1,0,0};mgr._bots={{20,6,1,1}};sample=0;mgr.Step(); + assert(mgr.generated.size()==1 && mgr.generated[0].second==20 && core.started.empty()); + mgr.Step();assert(core.started.back()==20); // considered once per boot, no replacement loop + mgr={};core.started.clear();world.states.clear();mgr._targets={1,0,0,0};mgr._bots={{30,6,0,1}}; + mgr._rotationChance=1;mgr.Step();assert(mgr.generated.empty() && core.started.back()==30); // persistence absolute + mgr={};core.started.clear();world.states.clear();mgr._targets={0,0,0,1};mgr._bots={{40,6,3,1}}; + ObjectAccessor::connected.insert(40);mgr.Step();assert(core.started.empty() && mgr.generated.empty()); // real connected player protected +} +''' +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 population: four independent faction/persistence targets, fair selection, pending reservations, retries, existing inventory, once-per-boot rotation and connected/persistent protection passed') diff --git a/tests/clientless/test_adventure_profile_selection.py b/tests/clientless/test_adventure_profile_selection.py new file mode 100644 index 0000000000..58fb4c0a06 --- /dev/null +++ b/tests/clientless/test_adventure_profile_selection.py @@ -0,0 +1,39 @@ +"""Execute actual Lua weighted dictionaries and companion selection.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/ClientlessLuaEngine.cpp').read_text() +def fn(sig): + a=s.index(sig);b=s.index('{',a);e=b+1;n=1 + while n:n+=(s[e]=='{')-(s[e]=='}');e+=1 + return s[a:e] +code=r''' +#include +#include +#include +#include +#include +#include +using uint32=unsigned; +struct ClientlessWeightedMount{uint32 DisplayId,Weight;}; +struct Profile{std::vectorCompanions;unsigned CompanionSpell=0;}profile; +unsigned roll=1;unsigned urand(unsigned low,unsigned high){assert(roll>=low && roll<=high);return roll;} +struct ClientlessLuaEngine{bool available=true;Profile const*GetAdventureProfile(unsigned)const{return available?&profile:nullptr;}unsigned SelectCompanionSpell(unsigned)const;}; +'''+fn('std::vector ReadMountDictionary(')+fn('uint32 ClientlessLuaEngine::SelectCompanionSpell(')+r''' +int main(){ + lua_State*L=luaL_newstate();luaL_openlibs(L); + assert(luaL_dostring(L,"return { companions = { [10673]=9,[10675]=1,[10676]=0,[10707]=-1,[123]=math.huge,[124]=0/0,['bad']=8,[125]=1e30 } }")==LUA_OK); + profile.Companions=ReadMountDictionary(L,-1,"companions");assert(profile.Companions.size()==2); + ClientlessLuaEngine engine;std::unordered_mapcounts; + for(roll=1;roll<=10;++roll)++counts[engine.SelectCompanionSpell(50)]; + assert(counts[10673]==9 && counts[10675]==1 && counts.size()==2); + profile.Companions.clear();profile.CompanionSpell=10707;assert(engine.SelectCompanionSpell(50)==10707); + engine.available=false;assert(!engine.SelectCompanionSpell(50));lua_close(L); +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'test.cpp').write_text(code) + flags=subprocess.check_output(['pkg-config','--cflags','--libs','lua5.4'],text=True).split() + subprocess.run(['g++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test'),*flags],check=True) + subprocess.run([str(p/'test')],check=True) +print('Production profile selection: real Lua ID dictionaries, exact 9:1 companion weighting, zero/negative/infinite/NaN/oversized weights rejected and legacy companion fallback passed') diff --git a/tests/clientless/test_adventure_rotation.py b/tests/clientless/test_adventure_rotation.py new file mode 100644 index 0000000000..68d82aee60 --- /dev/null +++ b/tests/clientless/test_adventure_rotation.py @@ -0,0 +1,62 @@ +"""Execute production rotation cleanup with failed ownership/save/delete phases.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventurePopulationMgr.cpp').read_text() +a=s.index('void AdventurePopulationMgr::DeleteRotated()');b=s.index('{',a);e=b+1;n=1 +while n:n+=(s[e]=='{')-(s[e]=='}');e+=1 +code=r''' +#include +#include +#include +#include +#include +#include +#include +using uint32=unsigned; +#define TC_LOG_INFO(...) ((void)0) +namespace Trinity{templatestd::string StringFormat(char const* f,A...args){char b[4096];std::snprintf(b,sizeof(b),f,args...);return b;}} +struct ObjectGuid{unsigned id;ObjectGuid(int,unsigned v):id(v){}}; +namespace HighGuid{constexpr int Player=0;} +namespace ObjectAccessor{bool connected=false;bool FindConnectedPlayer(ObjectGuid){return connected;}} +enum class ClientlessPlayerState{Offline,Online}; +struct World{ClientlessPlayerState state=ClientlessPlayerState::Offline;auto GetClientlessPlayerState(unsigned){return state;}}world;auto sWorld=&world; +struct Field{unsigned GetUInt32(){return 6;}}; +struct Row{Field f;Field*Fetch(){return &f;}};using QueryResult=std::shared_ptr; +struct Callback{std::function invoke;}; +struct Processor{std::vectorpending;void AddCallback(Callback c){pending.push_back(std::move(c));}void run(){auto batch=std::move(pending);pending.clear();for(auto&c:batch)c.invoke();}}; +struct Query{QueryResult row;templateCallback WithCallback(F f){return {[row=row,f]{f(row);}};}}; +struct Tx{std::vectorsql;templatevoid PAppend(char const*f,A...a){sql.push_back(Trinity::StringFormat(f,a...));}}; +struct Save{bool ok;std::functiondone;templatevoid AfterComplete(F f){done=f;}operator Callback(){return {[ok=ok,done=done]{done(ok);}};}}; +struct DB{ + bool world=false,owns=true,exists=true,ok=true;int queries=0;std::vectorcommitted; + Query AsyncQuery(char const*sql){++queries;bool present=std::string(sql).find("SELECT guid FROM characters")!=std::string::npos?exists:owns;return {present?std::make_shared():nullptr};} + auto BeginTransaction(){return std::make_shared();} + Save AsyncCommitTransaction(std::shared_ptrtx){if(ok)for(auto const&s:tx->sql)committed.push_back(s);return {ok,{}};} +}CharacterDatabase,WorldDatabase; +struct Player{static int deletes;static bool fail;static void DeleteFromDB(ObjectGuid,unsigned,bool,bool){++deletes;if(!fail)CharacterDatabase.exists=false;}};int Player::deletes=0;bool Player::fail=false; +struct AdventurePopulationMgr{ + struct Rotation{unsigned Old=10,New=11,Account=6;bool Submitted=false;};std::vector_rotations{{}}; + bool _busy=false;unsigned _refresh=99;Processor _queries,_saves;std::string error; + void Failed(std::string s){_busy=false;error=s;}void DeleteRotated(); + void drain(){for(int i=0;i<15;++i){_queries.run();_saves.run();}} +}; +'''+s[a:e]+r''' +int main(){ + AdventurePopulationMgr mgr;world.state=ClientlessPlayerState::Online;mgr.DeleteRotated();assert(!mgr._busy && !CharacterDatabase.queries && !Player::deletes); + world.state=ClientlessPlayerState::Offline;ObjectAccessor::connected=true;mgr.DeleteRotated();assert(!CharacterDatabase.queries);ObjectAccessor::connected=false; + CharacterDatabase.owns=false;mgr.DeleteRotated();mgr.drain();assert(!mgr.error.empty() && !Player::deletes && mgr._rotations.size()==1); + mgr={};CharacterDatabase.owns=true;Player::fail=true;mgr.DeleteRotated();mgr.drain();assert(Player::deletes==1 && !mgr.error.empty() && mgr._rotations.size()==1 && WorldDatabase.committed.empty()); + mgr={};Player::fail=false;WorldDatabase.ok=false;mgr.DeleteRotated();mgr.drain();assert(!CharacterDatabase.exists && mgr._rotations.size()==1 && !mgr.error.empty()); + assert(CharacterDatabase.committed.size()==2); // native deletion barriers only; profile/journal retained + mgr={};WorldDatabase.ok=true;mgr.DeleteRotated();mgr.drain();assert(mgr._rotations.empty() && !mgr._busy && !mgr._refresh); + auto contains=[](auto const& list,char const*text){return std::any_of(list.begin(),list.end(),[&](auto const&s){return s.find(text)!=std::string::npos;});}; + assert(contains(WorldDatabase.committed,"vcore_adventure_day")); + for(auto name:{"vcore_adventure_generated_bot","vcore_adventure_supply_state","vcore_adventure_instance_visit","vcore_adventure_rotation"})assert(contains(CharacterDatabase.committed,name)); +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['g++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Production rotation: connected/native-online protection, ownership refusal, native delete failure, world cleanup failure and journal-retained retry before successful profile/plan/state removal passed') diff --git a/tests/clientless/test_clientless_skill_preparation.py b/tests/clientless/test_clientless_skill_preparation.py index 24236c03f4..33a8467be1 100644 --- a/tests/clientless/test_clientless_skill_preparation.py +++ b/tests/clientless/test_clientless_skill_preparation.py @@ -19,7 +19,7 @@ struct Player{ void SetSkill(unsigned,unsigned s,unsigned v,unsigned c){++writes;step=s;value=v;cap=c;} }; struct ClientlessCharacterFactory{static bool PrepareSkillRank(Player*,unsigned,unsigned);}; -'''+s[a:]+r''' +'''+s[a:s.index('\nnamespace',a)]+r''' int main(){ Player p;using F=ClientlessCharacterFactory; assert(F::PrepareSkillRank(&p,186,1));assert(p.step==1 && p.value==75 && p.cap==75); diff --git a/tests/clientless/test_item_equip_cooldown.py b/tests/clientless/test_item_equip_cooldown.py new file mode 100644 index 0000000000..bd5e2a0880 --- /dev/null +++ b/tests/clientless/test_item_equip_cooldown.py @@ -0,0 +1,33 @@ +"""Execute native equip cooldowns with invalid/sentinel spell metadata.""" +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('void Player::ApplyEquipCooldown(');b=s.index('{',a);e=b+1;n=1 +while n:n+=(s[e]=='{')-(s[e]=='}');e+=1 +code=r''' +#include +#include +#include +using uint32=unsigned;using uint64=unsigned long long; +constexpr unsigned ITEM_FLAG_NO_EQUIP_COOLDOWN=1,ITEM_SPELLTRIGGER_ON_EQUIP=1,ITEM_SPELLTRIGGER_ON_USE=2,IN_MILLISECONDS=1000,SMSG_ITEM_COOLDOWN=1; +struct SpellInfo{unsigned id;};struct SpellProcEntry{}; +struct Spells{SpellInfo spell{42};SpellInfo const*GetSpellInfo(unsigned id){return id==42?&spell:nullptr;}SpellProcEntry const*GetSpellProcEntry(unsigned){return nullptr;}}spells;auto sSpellMgr=&spells; +struct ItemEffect{unsigned SpellID,Trigger;};struct Proto{unsigned flags=0;std::vectorEffects;unsigned GetFlags(){return flags;}}; +struct Item{Proto proto;Proto*GetTemplate(){return &proto;}unsigned GetEntry(){return 5;}unsigned GetGUID(){return 9;}}; +struct Aura{void AddProcCooldown(SpellProcEntry const*,std::chrono::steady_clock::time_point){}}; +struct WorldPacket{WorldPacket(unsigned,unsigned){}templateWorldPacket&operator<<(T){return *this;}}; +struct History{unsigned adds=0,remaining=0;unsigned GetRemainingCooldown(SpellInfo const*s){assert(s);return remaining;}void AddCooldown(unsigned id,unsigned,std::chrono::seconds){assert(id==42);++adds;}}; +struct Player{History history;unsigned packets=0;unsigned GetGUID(){return 1;}Aura*GetAura(unsigned,unsigned,unsigned){return nullptr;}History*GetSpellHistory(){return &history;}void SendDirectMessage(WorldPacket*){++packets;}void ApplyEquipCooldown(Item*);}; +'''+s[a:e]+r''' +int main(){Player p;Item item;item.proto.Effects={{0,2},{0xFFFFFFFFu,2},{999999,2},{42,0},{42,2}}; + p.ApplyEquipCooldown(&item);assert(p.history.adds==1 && p.packets==1); + p.history.remaining=31000;p.ApplyEquipCooldown(&item);assert(p.history.adds==1); + item.proto.flags=ITEM_FLAG_NO_EQUIP_COOLDOWN;p.ApplyEquipCooldown(&item);assert(p.history.adds==1); +} +''' +with tempfile.TemporaryDirectory() as d: + p=Path(d);(p/'test.cpp').write_text(code) + subprocess.run(['g++','-std=c++17',str(p/'test.cpp'),'-o',str(p/'test')],check=True) + subprocess.run([str(p/'test')],check=True) +print('Native item equip cooldown: zero/sentinel/missing spells safely skipped, valid use cooldown preserved and longer/disabled cooldowns untouched passed')