From 39a515dbd13ec4ad512d9e50bd8d9d7459ed8807 Mon Sep 17 00:00:00 2001 From: nico Date: Tue, 27 Oct 2026 23:56:43 +0100 Subject: [PATCH] Add AdventureBot self-care, supply restocking and secondary professions --- doc/clientless/SESSION-RESUME.md | 59 ++++ .../2026-10-27-supplies/live-events.txt | 26 ++ .../2026-10-27-supplies/validation.txt | 11 + doc/clientless/supplies.md | 81 +++++ .../2026_10_27_01_adventure_supplies.sql | 6 + .../2026_10_27_10_adventure_supplies.sql | 26 ++ src/server/game/Entities/Player/Player.cpp | 2 +- src/server/game/World/AdventureCombatMgr.cpp | 6 + src/server/game/World/AdventureCombatMgr.h | 1 + src/server/game/World/AdventureSupplyMgr.cpp | 308 ++++++++++++++++++ src/server/game/World/AdventureSupplyMgr.h | 46 +++ src/server/game/World/AdventureSupplyPolicy.h | 30 ++ src/server/game/World/ClientlessLuaEngine.cpp | 9 +- .../scripts/Custom/viano_bot_commands.cpp | 8 + tests/clientless/test_adventure_supplies.py | 97 ++++++ .../test_adventure_supply_catalog.py | 20 ++ .../clientless/test_adventure_supply_items.py | 122 +++++++ .../clientless/test_player_pure_max_skill.py | 34 ++ 18 files changed, 889 insertions(+), 3 deletions(-) create mode 100644 doc/clientless/checkpoints/2026-10-27-supplies/live-events.txt create mode 100644 doc/clientless/checkpoints/2026-10-27-supplies/validation.txt create mode 100644 doc/clientless/supplies.md create mode 100644 sql/custom/characters/2026_10_27_01_adventure_supplies.sql create mode 100644 sql/custom/world/2026_10_27_10_adventure_supplies.sql create mode 100644 src/server/game/World/AdventureSupplyMgr.cpp create mode 100644 src/server/game/World/AdventureSupplyMgr.h create mode 100644 src/server/game/World/AdventureSupplyPolicy.h create mode 100644 tests/clientless/test_adventure_supplies.py create mode 100644 tests/clientless/test_adventure_supply_catalog.py create mode 100644 tests/clientless/test_adventure_supply_items.py create mode 100644 tests/clientless/test_player_pure_max_skill.py diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 0f53487f55..ce2a136c40 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,62 @@ +# Aktuell: Selbstversorgung und gemeinsame Nebenberufe abgeschlossen + +Stand 27.10.2026. Branch feature/clientless-player-core, vorheriger Commit +c0c799c4b6. Nutzer hat den lokalen Commit der Selbstversorgung beauftragt; kein Push. +Debug-Build -j10 erfolgreich installiert, alle 55 Testskripte bestanden. +Testserver PID 49765 läuft; frisches Ready und Port 8085 bestätigt. + +AdventureSupplyMgr/Policy versorgt AdventureBots zu Tagesbeginn und nach +Kampfende bis zu jeweils einem nativen Stack: Verbände, Heil-/Manatränke, +Essen und Trinken. Vier Glutseidenstofftaschen54443 mit je22 Plätzen ergänzen +Arthoran. Alte Gegenstände blieben erhalten, Geld weiterhin0. +Migration world10 und characters01 angewendet und trotz SQL-Ignore gezielt +vorgemerkt. Persistierter Tagesstempel verhindert Nachschub bei jedem Login. + +Nutzerregeln umgesetzt: Selbstheilung beginnt erst bei mindestens10% fehlenden +HP. Außerhalb des Kampfes zuerst ein nutzbarer Verband; bei Sperre direkt Essen, +nach Verband Essen nur bei weiter mindestens10% Mangel. Mananutzer trinken bei +passendem Mangel. Im Kampf nur native Tränke. Echte Item-Anfragen, Verbrauch, +Abklingzeiten und Verbandssperre; keine direkte HP/Mana-Zuweisung außer dem +expliziten Diagnosebefehl testrecovery. Intents behalten ihre ursprüngliche Frist. + +Auf ausdrücklichen Nutzerwunsch erhalten alle AdventureBots die stufengerecht +verfügbaren Nebenberufe Erste Hilfe, Kochen, Angeln und Archäologie, innerhalb +nativer Traineranforderungen und Fertigkeitsgrenzen. Keine Hauptberufe geändert. +Arthoran gespeichert: ErsteHilfe375/375, Kochen525/525, Angeln525/525, +Archäologie375/375. Er verwendet schwere Netherstoffverbände21991. +Native Trainer erlauben Kochen/Angeln525 bei diesem Charakter. + +Notwendige native Fehlerkorrektur: Player::GetPureMaxSkillValue hatte eine +invertierte Prüfung und lieferte für gelernte Fertigkeiten null. Fehlendes ! +korrigiert, eigener nativer Regressionstest vorhanden. Nebenberufsausbildung +berücksichtigt sowohl Skill-Effekt118 als auch den nativen Lerneffekt44. + +Live bestätigt: +- Vier22er-Taschen und alle fünf Stacks20, vorhandene Item-GUIDs erhalten. +- Markierte8%-Testverletzung: kein Verbrauch. +- Markierte80%-Testverletzung: echter Verbandverbrauch, danach4961/5249HP; + weniger als10% fehlend, daher kein Essen. +- Erneute Testverletzung bei nativer Verbandssperre: echtes Essen, volleHP. +- Gleicher Tagesstempel beim erneuten Login: Verband19/Essen19 bleiben bestehen, + kein weiterer Tagesnachschub. +- Echter Bärenkampf gegen Spawn300425 mit markiertem anfänglichem30%-Defizit: + Heiltrank22829 im Kampf verbraucht. Danach exakt je ein Verband, Heiltrank + und Essen ergänzt; alle fünf Bestände20. GespeichertHP5249, Geld0. +- Manaspezifisches Verhalten produktiv mit Adaptern geprüft, nicht mit echtem + Manacharakter. Administrative Testpositionierung ist kein Fernreisebeleg. + +Arthoran bleibt angemeldet in den Verwüsteten Landen am Bären-Testplatz, +reagiert auf Gegner und versorgt sich selbst. Normales Lua-Profil bytegleich +restauriert; kein temporärer Override. Dokumentation: doc/clientless/supplies.md. +Belege: doc/clientless/checkpoints/2026-10-27-supplies/. +Logs: /tmp/vcore-supplies-build-final.log und /tmp/vcore-supplies-tests-final.log. +DB-Helfer /tmp/vcore-instance-db.py supplies_audit. Diagnose: +clientless combat supplies26, testrecovery26. +Die bisherigen Server-/Branchregeln und vertagte Pfadfindungsarbeit gelten weiter. + + +--- + # Aktuell: Training, echte Gegner und Todesverhalten abgeschlossen Stand 27.10.2026. Auf feature/clientless-player-core bleiben alle bisherigen diff --git a/doc/clientless/checkpoints/2026-10-27-supplies/live-events.txt b/doc/clientless/checkpoints/2026-10-27-supplies/live-events.txt new file mode 100644 index 0000000000..fce6d4189e --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-27-supplies/live-events.txt @@ -0,0 +1,26 @@ +TrinityCore rev. c0c799c4b686+ 2026-10-27 23:17:05 +0100 (feature/clientless-player-core branch) (Unix, Debug, Static) (worldserver-daemon) ready... +SUPPLY_SECONDARY guid=26 skill=356 value=525 max=525 +SUPPLY_SECONDARY guid=26 skill=794 value=375 max=375 +SUPPLY_SECONDARY guid=26 skill=129 value=375 max=375 +SUPPLY_SECONDARY guid=26 skill=185 value=525 max=525 +SUPPLY_TEST_INJURY guid=26 missingPercent=8 +SUPPLY_TEST_INJURY guid=26 missingPercent=80 +SUPPLY_USE_REQUEST guid=26 role='bandage' item=21991 spell=27031 combat=0 +SUPPLY_USED guid=26 role='bandage' item=21991 +SUPPLY_RECOVERY_END guid=26 health=4961 +SUPPLY_TEST_INJURY guid=26 missingPercent=30 +SUPPLY_USE_REQUEST guid=26 role='bandage' item=21991 spell=27031 combat=0 +SUPPLY_USE_REQUEST guid=26 role='food' item=27854 spell=27094 combat=0 +SUPPLY_USED guid=26 role='food' item=27854 +SUPPLY_RECOVERY_END guid=26 health=5249 +SUPPLY_TEST_INJURY guid=26 missingPercent=30 +COMBAT_START guid=26 target='GUID Full: 0xf130a56000000b1c Type: Creature Entry: 42336 Low: 2844' training=0 defending=0 until=0 +COMBAT_END guid=26 target='GUID Full: 0xf130a56000000b1c Type: Creature Entry: 42336 Low: 2844' training=0 +COMBAT_START guid=26 target='GUID Full: 0xf130a56000000b1c Type: Creature Entry: 42336 Low: 2844' training=0 defending=1 until=0 +SUPPLY_USE_REQUEST guid=26 role='healing_potion' item=22829 spell=28495 combat=1 +SUPPLY_USED guid=26 role='healing_potion' item=22829 +COMBAT_END guid=26 target='GUID Full: 0xf130a56000000b1c Type: Creature Entry: 42336 Low: 2844' training=0 +SUPPLY_TOPUP guid=26 role='bandage' item=21991 added=1 stack=20 +SUPPLY_TOPUP guid=26 role='healing_potion' item=22829 added=1 stack=20 +SUPPLY_TOPUP guid=26 role='food' item=27854 added=1 stack=20 +SUPPLY_RESTOCK guid=26 day=20261027 daily=0 afterCombat=1 diff --git a/doc/clientless/checkpoints/2026-10-27-supplies/validation.txt b/doc/clientless/checkpoints/2026-10-27-supplies/validation.txt new file mode 100644 index 0000000000..843b4f2e8b --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-27-supplies/validation.txt @@ -0,0 +1,11 @@ +Debug build -j10 installed;55 tests/clientless scripts passed. +Native skill getter corrected and regression-tested. +Arthoran native skill values: FirstAid375/375, Cooking525/525, Fishing525/525, Archaeology375/375. Trainer requirements permit these ranks at level60. +Four new EmbersilkBags54443,22 slots each. Existing equipment/items preserved. +Each supply role initially20; bandage21991, healing22829, mana22832, food27854, drink28399. Money0 unchanged. +Marked8% injury: no supply use. Marked80% injury: real heavy netherweave bandage consumed; remainingHP4961/5249, no food because deficit<10%. +Marked30% injury with native bandage lockout: food consumed, HP5249/5249. +Same-day relog: bandage19/food19 remained; no daily topup. +Real bear fight with marked initial30% deficit: healing potion consumed in combat. After native combat exit, exactly1 bandage,1 healing potion,1 food replenished to20 each; no duplicate stacks. +Mana-specific behavior tested with production adapters, not a live mana-using character. +Isolated test positions set administratively offline; no long-distance travel claim. diff --git a/doc/clientless/supplies.md b/doc/clientless/supplies.md new file mode 100644 index 0000000000..2e75534e24 --- /dev/null +++ b/doc/clientless/supplies.md @@ -0,0 +1,81 @@ +# Selbstversorgung und Nebenberufe + +AdventureBots erhalten bei Tagesbeginn und nach einem tatsächlich beendeten +Kampf passende Vorräte. Es werden vorhandene Bestände bis zu einem nativen +vollen Stack ergänzt, keine zusätzlichen Stacks angesammelt. Fünf Rollen: +Verbände, Heiltränke, Manatränke, Essen und Trinken. Die erlaubte kostenfreie +Beschaffung verwendet die native Gegenstandserzeugung und Inventarverwaltung. +Ein persistenter Tagesstempel verhindert erneute Tagesausgaben bei jedem Login. +Bei sehr niedrigen Stufen gibt es noch keinen nutzbaren Manatrank; andere +Vorräte werden trotzdem aufgefüllt. Bei Platzmangel wird später erneut versucht, +ohne vorhandene Gegenstände zu verwerfen. + +Vier Glutseidenstofftaschen (Item 54443, je 22 Plätze) ergänzen leere Taschenslots. +Kleinere Taschen werden über den nativen Taschentausch mit Inhaltserhaltung +ersetzt. Bereits mindestens gleich große Taschen bleiben erhalten. Alte Taschen +und sonstige Gegenstände werden nicht gelöscht. + +Alle AdventureBots erhalten die stufengerecht verfügbaren Ränge von Erste Hilfe, +Kochen, Angeln und Archäologie. Lehrzauber und Mindeststufen stammen aus den +nativen Trainerdaten, Skillstufen und Maxima aus der nativen Lernlogik. Fertigkeiten +werden innerhalb dieser Grenzen aufgefüllt. Das vergibt keine Hauptberufe und +ändert deren bestehende Profilfreigaben nicht. Zusätzliche Rezepte werden nur +insoweit gelernt, wie es die native automatische Fertigkeitsbelohnung vorsieht. + +Die Benutzung bleibt echt: native Item-Zauberanfragen mit tatsächlicher Tasche, +Slot und Item-GUID, Selbstziel, Benutzungsvoraussetzungen, Abklingzeiten, +Verbandssperre und Gegenstandsverbrauch. Keine direkten HP-/Mana-Zuweisungen +im Versorgungsablauf, kein Zurücksetzen von Cooldowns. Ein gesondert markierter +Diagnosebefehl kann für Tests einen HP-Mangel herstellen. + +## Heilungsreihenfolge + +- Unter 10 Prozent fehlenden HP beginnt keine Selbstheilung. +- Außerhalb des Kampfes zuerst einen tatsächlich nutzbaren Verband versuchen. + Eine laufende andere Zauberaktion oder globale Abklingzeit zählt nicht bereits + als fehlgeschlagener Verbandversuch. +- Nach dem Verband nur bei weiterhin mindestens 10 Prozent fehlenden HP essen. + Ist kein Verband erlaubt, direkt Essen versuchen. +- Eine bereits begonnene Mahlzeit darf bis zur vollständigen Auffrischung laufen. +- Bei Mananutzern und mindestens 10 Prozent fehlendem Mana trinken; Krieger + benutzen weder Wasser noch Manatränke. +- Im Kampf nur native Heil-/Manatränke verwenden, bei mindestens 10 Prozent + fehlender passender Ressource. HP hat bei gleichzeitigem Mangel Vorrang. + Gemeinsame native Trank-Cooldowns und Kampfbegrenzungen bleiben wirksam. +- Eingehender Kampf unterbricht eine eigene Erholung. Selbstverteidigung, Tod + und Transport erhalten Vorrang. Der ursprüngliche Intent wird danach mit + unveränderter Endzeit fortgesetzt. + +## Daten und Diagnose + +Migrationen: +`world/2026_10_27_10_adventure_supplies.sql` und +`characters/2026_10_27_01_adventure_supplies.sql` unter `sql/custom/`. +Der editierbare Katalog enthält ausdrückliche Rollen und Stufenschwellen; +native Item-Voraussetzungen bleiben maßgeblich. Bei Verbänden wird der beste +bereits nutzbare Rang bevorzugt. Wiederholte Migrationen erhalten Änderungen. + +`clientless combat supplies ` zeigt Fertigkeiten, ausgewählte Vorräte, +Bestände und Taschen. `clientless combat testrecovery ` +erzeugt ausschließlich bei einem lebenden, kampffreien verwalteten Bot eine +markierte Testverletzung (1–80 Prozent). Das ersetzt keinen echten Schadensfall. + +Automatisierte Prüfung: 55 Testskripte erfolgreich, einschließlich produktiver +Erholungssteuerung, exakter Zehn-Prozent-Grenze, nutzbarer Verbandränge, nativer +Item-Anfragen, Tagesstempel und stufengerechter Nebenberufsgrenzen. +Live erfolgreich geprüft: vier Glutseidenstofftaschen, Erste Hilfe375 und +schwere Netherstoffverbände, alle vier Nebenberufe, erhaltene Altgegenstände und +Geld0. Bei8%Testdefizit kein Verbrauch; bei80% zuerst ein tatsächlich verbrauchter +Verband. Anschließend weniger als10%HP fehlend, daher kein Essen. Bei erneuter +Testverletzung mit nativer Verbandssperre echtes Essen bis5249/5249HP. +Gleicher Tagesstempel beim erneuten Login: keine neue Ausgabe. Echter Bärenkampf +mit markiertem Anfangsdefizit: Heiltrank im Kampf verbraucht, danach fehlende +Vorräte exakt bis20 ergänzt. Die Anfangsverletzungen waren Diagnosefixtures; +Fernanreise und Manaverbrauch wurden damit nicht live geprüft. Belege unter +`checkpoints/2026-10-27-supplies/`. + +Die native Abfrage `Player::GetPureMaxSkillValue` hatte eine invertierte Prüfung +auf vorhandene Fertigkeitsränge und lieferte bei gelernten Fertigkeiten null. +Die Prüfung ist korrigiert; ein eigener nativer Regressionstest sichert reguläre +Werte sowie fehlende, gelöschte und leere Fertigkeiten ab. Nebenberufsränge mit +EffectLearnSkill44 und normalen SpellMgr-Skillknoten werden beide berücksichtigt. diff --git a/sql/custom/characters/2026_10_27_01_adventure_supplies.sql b/sql/custom/characters/2026_10_27_01_adventure_supplies.sql new file mode 100644 index 0000000000..38693b4558 --- /dev/null +++ b/sql/custom/characters/2026_10_27_01_adventure_supplies.sql @@ -0,0 +1,6 @@ +-- Last completed daily top-up survives logout and server restart. +CREATE TABLE IF NOT EXISTS vcore_adventure_supply_state ( + character_guid INT UNSIGNED NOT NULL, + last_day INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY(character_guid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/custom/world/2026_10_27_10_adventure_supplies.sql b/sql/custom/world/2026_10_27_10_adventure_supplies.sql new file mode 100644 index 0000000000..1841a84e94 --- /dev/null +++ b/sql/custom/world/2026_10_27_10_adventure_supplies.sql @@ -0,0 +1,26 @@ +-- Explicit roles and level tiers; actual native requirements, stack limits and +-- item-use checks remain authoritative. Secondary ranks follow native trainers. +CREATE TABLE IF NOT EXISTS vcore_adventure_supply ( + role VARCHAR(24) NOT NULL, + item_id INT UNSIGNED NOT NULL, + min_level TINYINT UNSIGNED NOT NULL DEFAULT 1, + enabled TINYINT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY(role,item_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +INSERT IGNORE INTO vcore_adventure_supply(role,item_id,min_level) VALUES + ('bandage',1251,1),('bandage',2581,5),('bandage',3530,10),('bandage',3531,15), + ('bandage',6450,20),('bandage',6451,25),('bandage',8544,30),('bandage',8545,35), + ('bandage',14529,40),('bandage',14530,45),('bandage',21990,55),('bandage',21991,60), + ('bandage',34721,70),('bandage',34722,75),('bandage',53049,80),('bandage',53051,85), + ('healing_potion',118,1),('healing_potion',858,3),('healing_potion',929,12), + ('healing_potion',1710,21),('healing_potion',3928,35),('healing_potion',13446,45), + ('healing_potion',22829,55),('healing_potion',33447,70),('healing_potion',57191,80), + ('mana_potion',2455,5),('mana_potion',3385,14),('mana_potion',3827,22), + ('mana_potion',6149,31),('mana_potion',13443,41),('mana_potion',13444,49), + ('mana_potion',22832,55),('mana_potion',33448,70),('mana_potion',57192,80), + ('food',117,1),('food',2287,5),('food',3770,15),('food',3771,25), + ('food',4599,35),('food',8952,45),('food',27854,55),('food',33454,65), + ('food',35953,75),('food',58260,80), + ('drink',159,1),('drink',1179,5),('drink',1205,15),('drink',1708,25), + ('drink',1645,35),('drink',8766,45),('drink',28399,60),('drink',33444,65), + ('drink',33445,75),('drink',58256,80); diff --git a/src/server/game/Entities/Player/Player.cpp b/src/server/game/Entities/Player/Player.cpp index 0e791443cb..fe07adb82e 100644 --- a/src/server/game/Entities/Player/Player.cpp +++ b/src/server/game/Entities/Player/Player.cpp @@ -5700,7 +5700,7 @@ uint16 Player::GetPureMaxSkillValue(uint32 skill) const return 0; SkillStatusMap::const_iterator itr = mSkillStatus.find(skill); - if (itr == mSkillStatus.end() || itr->second.uState == SKILL_DELETED || GetSkillRank(itr->second.pos)) + if (itr == mSkillStatus.end() || itr->second.uState == SKILL_DELETED || !GetSkillRank(itr->second.pos)) return 0; uint16 field = itr->second.pos / 2; diff --git a/src/server/game/World/AdventureCombatMgr.cpp b/src/server/game/World/AdventureCombatMgr.cpp index 8f51eb3916..01cb644712 100644 --- a/src/server/game/World/AdventureCombatMgr.cpp +++ b/src/server/game/World/AdventureCombatMgr.cpp @@ -285,3 +285,9 @@ bool AdventureCombatMgr::Update(Player* p,uint32 diff) p->StopMoving();r.AI->Tick(c,500); return true; } + +bool AdventureCombatMgr::Busy(Player* player) const +{ + auto it=_records.find(player->GetGUID().GetCounter()); + return it!=_records.end() && (it->second.AI || it->second.Dead || !it->second.Damagers.empty()); +} diff --git a/src/server/game/World/AdventureCombatMgr.h b/src/server/game/World/AdventureCombatMgr.h index ca3fce31a3..c88d671e9d 100644 --- a/src/server/game/World/AdventureCombatMgr.h +++ b/src/server/game/World/AdventureCombatMgr.h @@ -22,6 +22,7 @@ public: bool Train(Player* player,uint32 durationMs,time_t deadline=0); bool Attack(Player* player,uint32 creatureSpawnId); void Stop(Player* player); + bool Busy(Player* player) const; private: class Fighter; struct Record diff --git a/src/server/game/World/AdventureSupplyMgr.cpp b/src/server/game/World/AdventureSupplyMgr.cpp new file mode 100644 index 0000000000..4a51b8442d --- /dev/null +++ b/src/server/game/World/AdventureSupplyMgr.cpp @@ -0,0 +1,308 @@ +#include "AdventureSupplyMgr.h" +#include "AdventureBehaviorMgr.h" +#include "ClientlessLuaEngine.h" +#include "DatabaseEnv.h" +#include "DBCStores.h" +#include "Bag.h" +#include "Item.h" +#include "ItemTemplate.h" +#include "ObjectMgr.h" +#include "Player.h" +#include "Spell.h" +#include "SpellCastRequest.h" +#include "SpellHistory.h" +#include "SpellMgr.h" +#include "GameTime.h" +#include "Chat.h" +#include "Log.h" +#include +#include + +namespace +{ + constexpr uint32 EmbersilkBag=54443; + uint32 Day(time_t now) + { + std::tm local{};localtime_r(&now,&local); + return (local.tm_year+1900)*10000+(local.tm_mon+1)*100+local.tm_mday; + } + bool RoleMatches(ItemTemplate const* item,std::string const& role) + { + if(!item || item->GetClass()!=ITEM_CLASS_CONSUMABLE)return false; + if(role=="bandage")return item->GetSubClass()==ITEM_SUBCLASS_BANDAGE; + if(role=="food" || role=="drink")return item->GetSubClass()==ITEM_SUBCLASS_FOOD; + return (role=="healing_potion" || role=="mana_potion") && item->IsPotion(); + } +} +AdventureSupplyMgr* AdventureSupplyMgr::instance(){static AdventureSupplyMgr mgr;return &mgr;} +bool AdventureSupplyMgr::LoadCatalog() +{ + if(!_catalog.empty())return true; + auto rows=WorldDatabase.Query("SELECT role,item_id,min_level FROM vcore_adventure_supply WHERE enabled=1 ORDER BY min_level DESC,item_id DESC"); + if(!rows)return false; + do {auto f=rows->Fetch();_catalog.push_back({f[0].GetString(),f[1].GetUInt32(),f[2].GetUInt8()});}while(rows->NextRow()); + return true; +} +void AdventureSupplyMgr::Online(Player* p) +{ + auto& r=_records[p->GetGUID().GetCounter()];r={}; + LoadCatalog();LearnSecondaries(p); + if(auto row=CharacterDatabase.PQuery("SELECT last_day FROM vcore_adventure_supply_state WHERE character_guid=%u",p->GetGUID().GetCounter()))r.LastDay=row->Fetch()[0].GetUInt32(); + r.Loaded=true;r.WasInCombat=p->IsInCombat(); +} +void AdventureSupplyMgr::LearnSecondaries(Player* p) +{ + if(_secondaryRanks.empty()) + if(auto rows=WorldDatabase.Query("SELECT DISTINCT SpellId,ReqLevel,ReqSkillRank FROM trainer_spell"))do + { + auto f=rows->Fetch();auto trainerSpell=sSpellMgr->GetSpellInfo(f[0].GetUInt32()); + if(!trainerSpell)continue; + std::vector taught{trainerSpell->Id}; + for(auto const& effect:trainerSpell->Effects) + if(effect.Effect==SPELL_EFFECT_LEARN_SPELL && effect.TriggerSpell)taught.push_back(effect.TriggerSpell); + for(auto id:taught) + { + auto rank=sSpellMgr->GetSpellLearnSkill(id);auto spell=sSpellMgr->GetSpellInfo(id); + if(!spell)continue; + uint32 skill=rank?rank->skill:0,step=rank?rank->step:0; + // Profession ranks also use EffectLearnSkill (44), handled by + // Player::AddSpell, rather than SpellMgr's effect118 map. + if(!step)for(auto const& effect:spell->Effects) + if(effect.Effect==SPELL_EFFECT_SKILL_STEP && effect.CalcValue()>0) + {skill=effect.MiscValue;step=effect.CalcValue();break;} + if(!step || (skill!=SKILL_FIRST_AID && skill!=SKILL_COOKING && skill!=SKILL_FISHING && skill!=SKILL_ARCHAEOLOGY))continue; + uint32 minimum=std::max({f[1].GetUInt8(),spell->SpellLevel,spell->BaseLevel}); + auto existing=std::find_if(_secondaryRanks.begin(),_secondaryRanks.end(),[&](SecondaryRank const& value){return value.Spell==id;}); + if(existing==_secondaryRanks.end())_secondaryRanks.push_back({skill,id,step,minimum,f[2].GetUInt32()}); + else {existing->MinLevel=std::max(existing->MinLevel,minimum);existing->Required=std::max(existing->Required,f[2].GetUInt32());} + } + }while(rows->NextRow()); + std::sort(_secondaryRanks.begin(),_secondaryRanks.end(),[](SecondaryRank const& a,SecondaryRank const& b){return a.Stepp->getLevel() || rank.Required>p->GetPureSkillValue(rank.Skill) || + !p->IsSpellFitByClassAndRace(rank.Spell))continue; + auto rc=sDBCManager.GetSkillRaceClassInfo(rank.Skill,p->getRace(),p->getClass()); + auto info=sSpellMgr->GetSpellInfo(rank.Spell); + if(!rc || uint32(rc->MinLevel)>p->getLevel() || !info || !SpellMgr::IsSpellValid(info,p,false))continue; + if(!p->HasSpell(rank.Spell))p->LearnSpell(rank.Spell,false); + auto maximum=p->GetPureMaxSkillValue(rank.Skill); + if(maximum && p->GetPureSkillValue(rank.Skill)SetSkill(rank.Skill,p->GetSkillStep(rank.Skill),maximum,maximum); + TC_LOG_INFO("server.adventurebot","SUPPLY_SECONDARY guid=%u skill=%u value=%u max=%u",p->GetGUID().GetCounter(),rank.Skill,maximum,maximum); + } + } +} +ItemTemplate const* AdventureSupplyMgr::Select(Player* p,char const* role) const +{ + ItemTemplate const* fallback=nullptr; + for(auto const& row:_catalog) + { + if(row.Role!=role || row.MinLevel>p->getLevel())continue; + auto item=sObjectMgr->GetItemTemplate(row.Item); + if(!RoleMatches(item,row.Role) || item->GetRequiredLevel()>p->getLevel())continue; + if(p->CanUseItem(item)==EQUIP_ERR_OK)return item; + // Owning a level-appropriate bandage does not grant First Aid. Prefer + // the best usable tier, otherwise retain one appropriate future stack. + if(row.Role=="bandage" && !fallback)fallback=item; + } + return fallback; +} +bool AdventureSupplyMgr::Bags(Player* p) +{ + auto bagTemplate=sObjectMgr->GetItemTemplate(EmbersilkBag); + if(!bagTemplate || bagTemplate->GetContainerSlots()!=22 || p->CanUseItem(bagTemplate)!=EQUIP_ERR_OK)return false; + for(uint8 slot=INVENTORY_SLOT_BAG_START;slotGetBagByPos(slot); + if(current && current->GetBagSize()>=22)continue; + if(current) + { + // Native bag swapping transfers all contents and retains the old + // bag. Reuse a previous staged bag if a swap could not complete. + Item* replacement=nullptr; + for(uint8 pos=INVENTORY_SLOT_ITEM_START;posGetItemByPos(INVENTORY_SLOT_BAG_0,pos)) + if(item->GetEntry()==EmbersilkBag){replacement=item;break;} + if(!replacement) + { + ItemPosCountVec positions; + if(p->CanStoreNewItem(INVENTORY_SLOT_BAG_0,NULL_SLOT,positions,EmbersilkBag,1)!=EQUIP_ERR_OK)return false; + replacement=p->StoreNewItem(positions,EmbersilkBag,true); + } + if(!replacement)return false; + p->SwapItem(replacement->GetPos(),uint16(INVENTORY_SLOT_BAG_0)<<8|slot); + current=p->GetBagByPos(slot); + if(!current || current->GetBagSize()<22)return false; + } + else + { + auto dispose=[&](Item* item){if(item){RemoveItemFromUpdateQueueOf(item,p);delete item;}}; + std::unique_ptr item(Item::CreateItem(EmbersilkBag,1,p),dispose); + uint16 destination=0; + if(!item || p->CanEquipItem(slot,destination,item.get(),true)!=EQUIP_ERR_OK || !p->EquipItem(destination,item.get(),true))return false; + item.release(); + } + TC_LOG_INFO("server.adventurebot","SUPPLY_BAG guid=%u slot=%u item=%u slots=22",p->GetGUID().GetCounter(),slot,EmbersilkBag); + } + return true; +} +bool AdventureSupplyMgr::Stock(Player* p,Record& r,uint32 today) +{ + if(!LoadCatalog())return false; + LearnSecondaries(p); + if(!Bags(p))return false; + for(auto role:{"bandage","healing_potion","mana_potion","food","drink"}) + { + auto item=Select(p,role);if(!item)continue; // No legal mana potion exists at the lowest levels. + auto count=AdventureSupplyPolicy::TopUp(p->GetItemCount(item->GetId()),item->GetMaxStackSize()); + if(!count)continue; + ItemPosCountVec positions; + if(p->CanStoreNewItem(NULL_BAG,NULL_SLOT,positions,item->GetId(),count)!=EQUIP_ERR_OK || !p->StoreNewItem(positions,item->GetId(),true))return false; + TC_LOG_INFO("server.adventurebot","SUPPLY_TOPUP guid=%u role='%s' item=%u added=%u stack=%u",p->GetGUID().GetCounter(),role,item->GetId(),count,item->GetMaxStackSize()); + } + auto transaction=CharacterDatabase.BeginTransaction();p->SaveToDB(transaction); + transaction->PAppend("REPLACE INTO vcore_adventure_supply_state(character_guid,last_day) VALUES(%u,%u)",p->GetGUID().GetCounter(),today); + auto commit=CharacterDatabase.AsyncCommitTransaction(transaction); + if(!commit.m_future.get())return false; + TC_LOG_INFO("server.adventurebot","SUPPLY_RESTOCK guid=%u day=%u daily=%u afterCombat=%u",p->GetGUID().GetCounter(),today,r.LastDay!=today,r.AfterCombat); + r.LastDay=today;r.AfterCombat=false;return true; +} +AdventureSupplyMgr::UseResult AdventureSupplyMgr::TryUse(Player* p,Record& r,Use use) +{ + auto proto=Select(p,AdventureSupplyPolicy::Role(use)); + auto item=proto?p->GetItemByEntry(proto->GetId()):nullptr; + if(!item || p->CanUseItem(item)!=EQUIP_ERR_OK)return UseResult::Unavailable; + if(p->IsNonMeleeSpellCast(false) || p->HasUnitState(UNIT_STATE_CASTING))return UseResult::Busy; + for(auto const& effect:proto->Effects) + { + if(effect.Trigger!=ITEM_SPELLTRIGGER_ON_USE || effect.SpellID<=0)continue; + auto info=sSpellMgr->GetSpellInfo(effect.SpellID); + if(!info || (p->IsInCombat() && !info->CanBeUsedInCombat()))continue; + if(p->GetSpellHistory()->HasGlobalCooldown(info))return UseResult::Busy; + if(!p->GetSpellHistory()->IsReady(info,proto->GetId()) || !p->CanRequestSpellCast(info))continue; + auto probe=std::make_unique(p,info,TRIGGERED_NONE);probe->m_CastItem=item; + if(!probe->CanAutoCast(p))continue; + probe.reset(); + uint32 entry=proto->GetId(),spell=info->Id; + r.PendingItem=entry;r.PendingCount=p->GetItemCount(entry);r.PendingRole=use;r.Requested=GameTime::GetGameTime(); + WorldPackets::Spells::SpellCastRequest request;request.SpellID=spell; + request.Target.Flags=TARGET_FLAG_UNIT;request.Target.Unit=p->GetGUID(); + p->RequestSpellCast(std::make_unique(std::move(request),SpellCastRequestItemData(item->GetBagSlot(),item->GetSlot(),item->GetGUID())),info); + if(!p->IsInCombat()){r.Spell=spell;r.Active=use;} + TC_LOG_INFO("server.adventurebot","SUPPLY_USE_REQUEST guid=%u role='%s' item=%u spell=%u combat=%u",p->GetGUID().GetCounter(),AdventureSupplyPolicy::Role(use),entry,spell,p->IsInCombat()); + return UseResult::Started; + } + return UseResult::Unavailable; +} +void AdventureSupplyMgr::Cancel(Player* p,Record& r) +{ + if(r.Spell) + { + p->InterruptNonMeleeSpells(false,r.Spell); + p->RemoveAurasDueToSpell(r.Spell,p->GetGUID()); + } + if(r.Recovering && p->IsAlive())p->SetStandState(UNIT_STAND_STATE_STAND); + r.Spell=0;r.Active=Use::None;r.Recovering=false;r.BandageTried=false; +} +void AdventureSupplyMgr::Offline(Player* p) +{ + auto it=_records.find(p->GetGUID().GetCounter());if(it==_records.end())return; + Cancel(p,it->second);_records.erase(it); +} +bool AdventureSupplyMgr::Update(Player* p,uint32 diff,bool combatReaction) +{ + auto& r=_records[p->GetGUID().GetCounter()];if(!r.Loaded)Online(p); + bool combat=p->IsInCombat(); + if(r.WasInCombat && !combat)r.AfterCombat=true; + r.WasInCombat=combat; + if(r.Timer>diff){r.Timer-=diff;return r.Recovering && !combat && !combatReaction && p->IsAlive();} + r.Timer=500;auto now=GameTime::GetGameTime(); + if(r.PendingItem) + { + if(p->GetItemCount(r.PendingItem)GetGUID().GetCounter(),AdventureSupplyPolicy::Role(r.PendingRole),r.PendingItem); + r.PendingItem=0; + } + else if(now-r.Requested>5)r.PendingItem=0; + } + if(!p->IsAlive() || p->IsBeingTeleported() || p->GetTransport() || p->IsInFlight() || p->IsFlying() || p->IsCharmed()) + {if(r.Recovering)Cancel(p,r);return false;} + if(combat) + { + if(r.Recovering)Cancel(p,r); + bool health=AdventureSupplyPolicy::MissingTenPercent(p->GetHealth(),p->GetMaxHealth()); + bool mana=p->GetPowerType()==POWER_MANA && AdventureSupplyPolicy::MissingTenPercent(p->GetPower(POWER_MANA),p->GetMaxPower(POWER_MANA)); + if(!r.PendingItem) + { + auto use=AdventureSupplyPolicy::Choose(true,health,mana,false,false); + if(use!=Use::None)TryUse(p,r,use); + } + return false; // Native combat and attacks keep running. + } + if(combatReaction)return false; + if((r.LastDay!=Day(now) || r.AfterCombat) && now>=r.RetryStock) + { + if(!Stock(p,r,Day(now))) + { + TC_LOG_WARN("server.adventurebot","SUPPLY_RESTOCK_DEFERRED guid=%u",p->GetGUID().GetCounter()); + r.RetryStock=now+30; + } + else r.RetryStock=0; + } + if(p->IsFalling())return false; + if(r.Recovering && now-r.Started>=90) + {Cancel(p,r);r.RetryRecovery=now+5;return false;} + if(r.Spell) + { + auto cast=p->GetCurrentSpell(CURRENT_CHANNELED_SPELL); + if(!cast)cast=p->GetCurrentSpell(CURRENT_GENERIC_SPELL); + bool running=(cast && cast->GetSpellInfo()->Id==r.Spell) || p->HasAura(r.Spell,p->GetGUID()); + bool full=(r.Active==Use::Food && p->GetHealth()>=p->GetMaxHealth()) || + (r.Active==Use::Drink && p->GetPower(POWER_MANA)>=p->GetMaxPower(POWER_MANA)); + if(running && !full)return true; + if(!running && now-r.Requested<2)return true; + p->InterruptNonMeleeSpells(false,r.Spell);p->RemoveAurasDueToSpell(r.Spell,p->GetGUID()); + r.Spell=0;r.Active=Use::None; + } + bool health=AdventureSupplyPolicy::MissingTenPercent(p->GetHealth(),p->GetMaxHealth()); + bool mana=p->GetPowerType()==POWER_MANA && AdventureSupplyPolicy::MissingTenPercent(p->GetPower(POWER_MANA),p->GetMaxPower(POWER_MANA)); + if(!health && !mana) + { + if(r.Recovering){TC_LOG_INFO("server.adventurebot","SUPPLY_RECOVERY_END guid=%u health=%u",p->GetGUID().GetCounter(),p->GetHealth());Cancel(p,r);} + return false; + } + if(nowInterruptForCombat(p);sClientlessLuaEngine->DismountGroundMount(p,"supply-recovery"); + p->StopMoving();r.Recovering=true;r.BandageTried=false;r.Started=now; + } + auto use=AdventureSupplyPolicy::Choose(false,health,mana,r.BandageTried,true); + p->SetStandState(use==Use::Bandage?UNIT_STAND_STATE_STAND:UNIT_STAND_STATE_SIT); + auto result=TryUse(p,r,use); + if(use==Use::Bandage && result!=UseResult::Busy) + { + r.BandageTried=true; + if(result==UseResult::Unavailable) + { + p->SetStandState(UNIT_STAND_STATE_SIT); + result=TryUse(p,r,Use::Food); + } + } + if(result==UseResult::Unavailable){Cancel(p,r);r.RetryRecovery=now+5;return false;} + return true; +} +void AdventureSupplyMgr::Report(ChatHandler* handler,Player* p) +{ + auto& r=_records[p->GetGUID().GetCounter()]; + handler->PSendSysMessage("Supplies: lastDay=%u recovering=%u afterCombat=%u health=%u/%u",r.LastDay,r.Recovering,r.AfterCombat,p->GetHealth(),p->GetMaxHealth()); + for(uint32 skill:{uint32(SKILL_FIRST_AID),uint32(SKILL_COOKING),uint32(SKILL_FISHING),uint32(SKILL_ARCHAEOLOGY)}) + handler->PSendSysMessage("secondary=%u value=%u max=%u",skill,p->GetPureSkillValue(skill),p->GetPureMaxSkillValue(skill)); + for(auto role:{"bandage","healing_potion","mana_potion","food","drink"}) + if(auto item=Select(p,role))handler->PSendSysMessage("%s: item=%u count=%u stack=%u requiredLevel=%u requiredSkill=%u/%u usable=%u",role,item->GetId(),p->GetItemCount(item->GetId()),item->GetMaxStackSize(),item->GetRequiredLevel(),item->GetRequiredSkill(),item->GetRequiredSkillRank(),p->CanUseItem(item)==EQUIP_ERR_OK); + for(uint8 slot=INVENTORY_SLOT_BAG_START;slotGetBagByPos(slot))handler->PSendSysMessage("bagSlot=%u item=%u capacity=%u",slot,bag->GetEntry(),bag->GetBagSize()); +} diff --git a/src/server/game/World/AdventureSupplyMgr.h b/src/server/game/World/AdventureSupplyMgr.h new file mode 100644 index 0000000000..226fad80ec --- /dev/null +++ b/src/server/game/World/AdventureSupplyMgr.h @@ -0,0 +1,46 @@ +#ifndef VCORE_ADVENTURE_SUPPLY_MGR_H +#define VCORE_ADVENTURE_SUPPLY_MGR_H +#include "Common.h" +#include "AdventureSupplyPolicy.h" +#include +#include +#include +#include +class Player; +class ChatHandler; +struct ItemTemplate; +class TC_GAME_API AdventureSupplyMgr +{ +public: + static AdventureSupplyMgr* instance(); + void Online(Player* player); + void Offline(Player* player); + // 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); +private: + using Use=AdventureSupplyPolicy::Use; + struct Supply {std::string Role;uint32 Item=0,MinLevel=0;}; + struct Record + { + bool Loaded=false,WasInCombat=false,AfterCombat=false,Recovering=false,BandageTried=false; + uint32 LastDay=0,Timer=0,Spell=0; + Use Active=Use::None; + time_t RetryStock=0,RetryRecovery=0,Requested=0,Started=0; + uint32 PendingItem=0,PendingCount=0;Use PendingRole=Use::None; + }; + enum class UseResult {Unavailable,Busy,Started}; + bool LoadCatalog(); + void LearnSecondaries(Player* player); + ItemTemplate const* Select(Player* player,char const* role) const; + bool Bags(Player* player); + bool Stock(Player* player,Record& record,uint32 today); + UseResult TryUse(Player* player,Record& record,Use use); + void Cancel(Player* player,Record& record); + struct SecondaryRank {uint32 Skill=0,Spell=0,Step=0,MinLevel=0,Required=0;}; + std::vector _secondaryRanks; + std::vector _catalog; + std::unordered_map _records; +}; +#define sAdventureSupplyMgr AdventureSupplyMgr::instance() +#endif diff --git a/src/server/game/World/AdventureSupplyPolicy.h b/src/server/game/World/AdventureSupplyPolicy.h new file mode 100644 index 0000000000..e83d331f0f --- /dev/null +++ b/src/server/game/World/AdventureSupplyPolicy.h @@ -0,0 +1,30 @@ +#ifndef VCORE_ADVENTURE_SUPPLY_POLICY_H +#define VCORE_ADVENTURE_SUPPLY_POLICY_H +#include +namespace AdventureSupplyPolicy +{ + enum class Use { None, Bandage, Food, Drink, HealingPotion, ManaPotion }; + inline bool MissingTenPercent(std::uint32_t current,std::uint32_t maximum) + { return maximum && std::uint64_t(current)*100 <= std::uint64_t(maximum)*90; } + inline std::uint32_t TopUp(std::uint32_t count,std::uint32_t stack) + { return countExecuteAdventureScript(player); sAdventureBehaviorMgr->Online(player); + sAdventureSupplyMgr->Online(player); } void OnIntentChanged(Player* player, ClientlessIntent const* intent) override { sAdventureBehaviorMgr->IntentChanged(player, intent); } void Update(Player* player, uint32 diff) override - { if (!sAdventureCombatMgr->Update(player, diff)) sAdventureBehaviorMgr->Update(player, diff); } + { + if(sAdventureSupplyMgr->Update(player,diff,sAdventureCombatMgr->Busy(player)))return; + if(!sAdventureCombatMgr->Update(player,diff))sAdventureBehaviorMgr->Update(player,diff); + } void OnDamageTaken(Player* player, Unit* attacker, uint32 damage) override { sAdventureCombatMgr->DamageTaken(player, attacker, damage); } void OnPlayerLogout(Player* player) override - { sAdventureCombatMgr->Offline(player); sAdventureBehaviorMgr->Offline(player); } + { sAdventureSupplyMgr->Offline(player); sAdventureCombatMgr->Offline(player); sAdventureBehaviorMgr->Offline(player); } }; } diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index 51d732f5a0..3b66b22749 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -20,6 +20,7 @@ #include "ClientlessWorldPointMgr.h" #include "AdventureBehaviorMgr.h" #include "AdventureCombatMgr.h" +#include "AdventureSupplyMgr.h" #include "ClientlessTravelGraphMgr.h" #include "ClientlessRouteRecorderMgr.h" #include "ClientlessLuaEngine.h" @@ -259,6 +260,13 @@ namespace ClientlessCombatPreparation for(auto const& aura:c->GetAppliedAuras())handler->PSendSysMessage("Target aura=%u",aura.first); return true; } + if(op=="supplies"){sAdventureSupplyMgr->Report(handler,player);return true;} + if(op=="testrecovery" && source>=1 && source<=80 && player->IsAlive() && !player->IsInCombat() && !sAdventureCombatMgr->Busy(player) && !player->IsBeingTeleported() && !player->GetTransport()) + { + player->SetHealth(std::max(1u,player->GetMaxHealth()*(100-source)/100)); + TC_LOG_INFO("server.adventurebot","SUPPLY_TEST_INJURY guid=%u missingPercent=%u",guid,source); + return true; + } if(op=="testdeath" && player->IsAlive() && !player->IsBeingTeleported() && !player->GetTransport() && !player->InBattleground() && !player->InArena()) { TC_LOG_INFO("server.adventurebot","DEATH_TEST_REQUEST guid=%u",guid); diff --git a/tests/clientless/test_adventure_supplies.py b/tests/clientless/test_adventure_supplies.py new file mode 100644 index 0000000000..c4a56f705a --- /dev/null +++ b/tests/clientless/test_adventure_supplies.py @@ -0,0 +1,97 @@ +"""Execute the production recovery state machine with native-item adapters.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +source=(root/'src/server/game/World/AdventureSupplyMgr.cpp').read_text() +def function(signature): + a=source.index(signature);b=source.index('{',a);e=b+1;depth=1 + while depth:depth+=(source[e]=='{')-(source[e]=='}');e+=1 + return source[a:e] +header=(root/'src/server/game/World/AdventureSupplyMgr.h').read_text() +header='\n'.join(x for x in header.splitlines() if not x.startswith('#include')).replace('private:','public:') +code=r''' +#include "AdventureSupplyPolicy.h" +#include +#include +#include +#include +#include +#include +using uint32=unsigned; +#define TC_GAME_API +#define TC_LOG_INFO(...) ((void)0) +#define TC_LOG_WARN(...) ((void)0) +constexpr int POWER_MANA=0,POWER_RAGE=1,CURRENT_CHANNELED_SPELL=0,CURRENT_GENERIC_SPELL=1,UNIT_STAND_STATE_STAND=0,UNIT_STAND_STATE_SIT=1; +struct Guid{unsigned GetCounter(){return 26;}}; +struct Spell{struct Info{unsigned Id=1;}info;Info* GetSpellInfo(){return &info;}}; +struct Player{ + unsigned hp=100,maxhp=100,mana=100,maxmana=100,type=POWER_RAGE;bool alive=true,combat=false,casting=false,aura=false,bandageAllowed=true,busy=false,foodAllowed=true; + int posture=0,stops=0;Spell spell;std::unordered_map items; + Guid GetGUID(){return {};}bool IsInCombat(){return combat;}bool IsAlive(){return alive;}bool IsBeingTeleported(){return false;}bool GetTransport(){return false;}bool IsInFlight(){return false;}bool IsFlying(){return false;}bool IsCharmed(){return false;}bool IsFalling(){return false;} + unsigned GetHealth(){return hp;}unsigned GetMaxHealth(){return maxhp;}unsigned GetPower(int){return mana;}unsigned GetMaxPower(int){return maxmana;}unsigned GetPowerType(){return type;} + unsigned GetItemCount(unsigned entry){return items[entry];}void StopMoving(){++stops;}void SetStandState(int s){posture=s;} + Spell*GetCurrentSpell(int slot){return casting && slot==0?&spell:nullptr;} + bool HasAura(unsigned id,Guid){return aura && spell.info.Id==id;} + void InterruptNonMeleeSpells(bool,unsigned id){if(spell.info.Id==id)casting=false;} + void RemoveAurasDueToSpell(unsigned id,Guid){if(spell.info.Id==id)aura=false;} +}; +namespace GameTime{time_t now=100;time_t GetGameTime(){return now;}} +unsigned today=20261027;unsigned Day(time_t){return today;} +struct Behavior{int interrupts=0;void InterruptForCombat(Player*){++interrupts;}}behavior;auto sAdventureBehaviorMgr=&behavior; +struct Lua{void DismountGroundMount(Player*,char const*){}}lua;auto sClientlessLuaEngine=&lua; +'''+header+r''' +std::vector uses;int stocks=0;unsigned savedDay=0; +void AdventureSupplyMgr::Online(Player*p){auto&r=_records[26];r.Loaded=true;r.LastDay=savedDay;} +bool AdventureSupplyMgr::Stock(Player*,Record&r,unsigned date){++stocks;r.LastDay=date;r.AfterCombat=false;savedDay=date;return true;} +AdventureSupplyMgr::UseResult AdventureSupplyMgr::TryUse(Player*p,Record&r,Use use){ + uses.push_back(use);if(p->busy)return UseResult::Busy; + if((use==Use::Bandage && !p->bandageAllowed)||(use==Use::Food && !p->foodAllowed))return UseResult::Unavailable; + if(!p->combat){r.Active=use;r.Spell=unsigned(use);p->spell.info.Id=r.Spell;p->aura=true;r.Requested=GameTime::now;} + return UseResult::Started; +} +'''+function('void AdventureSupplyMgr::Cancel(')+'\n'+function('bool AdventureSupplyMgr::Update(')+r''' +int main(){ + using U=AdventureSupplyPolicy::Use;using namespace AdventureSupplyPolicy; + assert(!MissingTenPercent(91,100));assert(MissingTenPercent(90,100));assert(!MissingTenPercent(1,0)); + assert(!MissingTenPercent(4725,5249));assert(MissingTenPercent(4724,5249)); + assert(TopUp(19,20)==1 && TopUp(20,20)==0 && TopUp(21,20)==0); + AdventureSupplyMgr mgr;Player p;auto tick=[&]{GameTime::now+=3;return mgr.Update(&p,500,false);}; + assert(!tick() && stocks==1);assert(!tick() && stocks==1); + p.hp=91;assert(!tick() && uses.empty()); + // At the exact threshold, bandage has priority over food. + p.hp=90;assert(tick() && uses.back()==U::Bandage); + p.aura=false;p.hp=95;assert(!tick() && uses.size()==1); // no food below10% missing + // New recovery: bandage finishes but health deficit still10% or more. + p.hp=40;assert(tick() && uses.back()==U::Bandage); + p.aura=false;p.hp=75;assert(tick() && uses.back()==U::Food); + auto count=uses.size();p.hp=95;assert(tick() && uses.size()==count); // keep current meal to full + p.hp=100;assert(!tick() && !p.aura && p.posture==UNIT_STAND_STATE_STAND); + // Missing First Aid / native bandage lockout: immediate food fallback. + p.hp=80;p.bandageAllowed=false;uses.clear();assert(tick());assert((uses==std::vector{U::Bandage,U::Food})); + p.hp=100;assert(!tick()); + // A busy global cooldown must not count as a failed bandage attempt. + p.bandageAllowed=true;p.hp=80;p.busy=true;uses.clear();assert(tick() && uses.back()==U::Bandage); + assert(!mgr._records[26].BandageTried);p.busy=false;assert(tick() && uses.back()==U::Bandage); + // Incoming combat interrupts only owned recovery; combat never eats/bandages. + p.combat=true;uses.clear();assert(!tick() && uses.back()==U::HealingPotion && !p.aura); + p.hp=100;p.type=POWER_MANA;p.mana=89;uses.clear();assert(!tick() && uses.back()==U::ManaPotion); + // Exactly one top-up after the actual combat interval, not per enemy/tick. + auto previous=stocks;p.combat=false;assert(tick() && stocks==previous+1 && uses.back()==U::Drink); + assert(tick() && stocks==previous+1);p.mana=100;assert(!tick()); + // Warriors never drink; mana-only recovery starts at10% missing as well. + p.type=POWER_RAGE;p.mana=0;uses.clear();assert(!tick() && uses.empty()); + p.type=POWER_MANA;p.mana=91;assert(!tick());p.mana=90;assert(tick() && uses.back()==U::Drink); + p.mana=100;assert(!tick()); + // Persisted day blocks logout/login farming; next day allows a single top-up. + mgr._records.clear();uses.clear();previous=stocks;assert(!tick() && stocks==previous); + ++today;assert(!tick() && stocks==previous+1);assert(!tick() && stocks==previous+1); + // Dead or combat-reaction-controlled bots never start a meal. + p.hp=40;p.alive=false;uses.clear();assert(!tick() && uses.empty());p.alive=true; + GameTime::now+=3;assert(!mgr.Update(&p,500,true) && uses.empty()); +} +''' +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 supplies: exact10% boundary, bandage-before-food, native lockout fallback, recovery completion, combat potions, mana-only drinking and daily/post-combat top-ups passed.') diff --git a/tests/clientless/test_adventure_supply_catalog.py b/tests/clientless/test_adventure_supply_catalog.py new file mode 100644 index 0000000000..4f2a7a132b --- /dev/null +++ b/tests/clientless/test_adventure_supply_catalog.py @@ -0,0 +1,20 @@ +"""Validate additive supply catalogs and persistent daily stamps on SQL fixtures.""" +from pathlib import Path +import sqlite3,re +root=Path(__file__).resolve().parents[2] +def sqlite(sql): + sql=re.sub(r' ENGINE=InnoDB[^;]*','',sql) + return sql.replace('INSERT IGNORE','INSERT OR IGNORE').replace('REPLACE INTO','INSERT OR REPLACE INTO') +db=sqlite3.connect(':memory:') +world=(root/'sql/custom/world/2026_10_27_10_adventure_supplies.sql').read_text() +characters=(root/'sql/custom/characters/2026_10_27_01_adventure_supplies.sql').read_text() +db.executescript(sqlite(world));db.executescript(sqlite(characters)) +roles={r[0] for r in db.execute('SELECT DISTINCT role FROM vcore_adventure_supply')} +assert roles=={'bandage','healing_potion','mana_potion','food','drink'} +assert db.execute("SELECT min_level FROM vcore_adventure_supply WHERE role='bandage' AND item_id=21991").fetchone()==(60,) +for role in roles:assert db.execute('SELECT COUNT(*) FROM vcore_adventure_supply WHERE role=? AND min_level<=60',(role,)).fetchone()[0]>0 +db.execute('UPDATE vcore_adventure_supply SET enabled=0 WHERE item_id=22829') +db.execute('UPDATE vcore_adventure_supply SET min_level=59 WHERE item_id=21991') +db.execute('INSERT INTO vcore_adventure_supply_state VALUES(26,20261027)') +before=list(db.iterdump());db.executescript(sqlite(world));db.executescript(sqlite(characters));assert list(db.iterdump())==before +print('Supply migrations: all five roles, level60 netherweave tier, editable/disabled catalog preservation and persistent daily stamp passed.') diff --git a/tests/clientless/test_adventure_supply_items.py b/tests/clientless/test_adventure_supply_items.py new file mode 100644 index 0000000000..b63d750684 --- /dev/null +++ b/tests/clientless/test_adventure_supply_items.py @@ -0,0 +1,122 @@ +"""Execute production item selection/use and secondary-rank gates.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +source=(root/'src/server/game/World/AdventureSupplyMgr.cpp').read_text() +def function(signature): + a=source.index(signature);b=source.index('{',a);e=b+1;depth=1 + while depth:depth+=(source[e]=='{')-(source[e]=='}');e+=1 + return source[a:e] +header=(root/'src/server/game/World/AdventureSupplyMgr.h').read_text() +header='\n'.join(x for x in header.splitlines() if not x.startswith('#include')).replace('private:','public:') +code=r''' +#include "AdventureSupplyPolicy.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +using uint32=unsigned; +#define TC_GAME_API +#define TC_LOG_INFO(...) ((void)0) +constexpr int ITEM_CLASS_CONSUMABLE=0,ITEM_SUBCLASS_BANDAGE=7,ITEM_SUBCLASS_FOOD=5,ITEM_SUBCLASS_POTION=1,EQUIP_ERR_OK=0,UNIT_STATE_CASTING=1,ITEM_SPELLTRIGGER_ON_USE=0,TRIGGERED_NONE=0,TARGET_FLAG_UNIT=2,SPELL_EFFECT_LEARN_SPELL=36,SPELL_EFFECT_SKILL_STEP=44; +constexpr unsigned SKILL_FIRST_AID=129,SKILL_COOKING=185,SKILL_FISHING=356,SKILL_ARCHAEOLOGY=794; +struct Guid{unsigned id=26;unsigned GetCounter(){return id;}}; +struct Effect{int Trigger=0,SpellID=0,Effect=0;unsigned TriggerSpell=0;int MiscValue=0,value=0;int CalcValue()const{return value;}}; +struct ItemTemplate{ + unsigned id,level,subclass,skill=0,rank=0;std::vector Effects; + unsigned GetId()const{return id;}unsigned GetRequiredLevel()const{return level;}unsigned GetClass()const{return 0;}unsigned GetSubClass()const{return subclass;}bool IsPotion()const{return subclass==1;} +}; +struct Item{ItemTemplate*proto;unsigned count=20;unsigned GetBagSlot(){return 19;}unsigned GetSlot(){return 0;}Guid GetGUID(){return {proto->id};}}; +struct SpellInfo{unsigned Id=0,SpellLevel=0,BaseLevel=0;bool combat=true,autoCast=true;std::vectorEffects;bool CanBeUsedInCombat(){return combat;}}; +struct Rank{unsigned skill,step,maximum;}; +struct Player; +struct SpellMgr{ + std::unordered_mapspells;std::unordered_mapranks; + SpellInfo*GetSpellInfo(unsigned id){auto i=spells.find(id);return i==spells.end()?nullptr:&i->second;} + Rank*GetSpellLearnSkill(unsigned id){auto i=ranks.find(id);return i==ranks.end()?nullptr:&i->second;} + static bool IsSpellValid(SpellInfo*,Player*,bool){return true;} +}spellMgr;auto sSpellMgr=&spellMgr; +struct ObjectMgr{std::unordered_mapitems;ItemTemplate*GetItemTemplate(unsigned id){auto i=items.find(id);return i==items.end()?nullptr:&i->second;}}objects;auto sObjectMgr=&objects; +struct RC{int MinLevel=1;}rc; +struct DBC{RC*GetSkillRaceClassInfo(unsigned,unsigned,unsigned){return &rc;}}sDBCManager; +struct Field{unsigned value=0;std::string GetString(){return {};};unsigned GetUInt32(){return value;}unsigned GetUInt8(){return value;}unsigned GetUInt16(){return value;}}; +struct Row{std::vector> rows;size_t index=0;Field*Fetch(){return rows[index].data();}bool NextRow(){return ++indexskills,caps,steps;std::unordered_setknown; + std::unordered_map>inventory; + Guid GetGUID(){return {};}unsigned getLevel(){return level;}unsigned getRace(){return 1;}unsigned getClass(){return 1;} + bool IsSpellFitByClassAndRace(unsigned){return true;}bool HasSpell(unsigned id){return known.count(id);} + unsigned GetPureSkillValue(unsigned skill){return skills[skill];}unsigned GetPureMaxSkillValue(unsigned skill){return caps[skill];}unsigned GetSkillStep(unsigned skill){return steps[skill];} + void LearnSpell(unsigned id,bool){known.insert(id);auto rank=spellMgr.GetSpellLearnSkill(id);if(rank){caps[rank->skill]=rank->maximum;steps[rank->skill]=rank->step;} + else for(auto e:spellMgr.GetSpellInfo(id)->Effects)if(e.Effect==SPELL_EFFECT_SKILL_STEP){caps[e.MiscValue]=e.value*75;steps[e.MiscValue]=e.value;}} + void SetSkill(unsigned id,unsigned step,unsigned val,unsigned max){skills[id]=val;caps[id]=max;steps[id]=step;} + int CanUseItem(ItemTemplate const*p){return p->level<=level && (!p->skill || skills[p->skill]>=p->rank)?0:1;} + int CanUseItem(Item*i){return CanUseItem(i->proto);} + Item*GetItemByEntry(unsigned id){auto i=inventory.find(id);return i==inventory.end()?nullptr:i->second.get();} + unsigned GetItemCount(unsigned id){auto i=GetItemByEntry(id);return i?i->count:0;} + bool IsInCombat(){return combat;}bool IsNonMeleeSpellCast(bool){return casting;}bool HasUnitState(int){return casting;} + History*GetSpellHistory(){return &history;}bool CanRequestSpellCast(SpellInfo*){return true;} + void RequestSpellCast(std::unique_ptrr,SpellInfo*){ + assert(r->request.Target.Flags==TARGET_FLAG_UNIT && r->request.Target.Unit.id==26 && r->item.bag==19 && r->item.slot==0); + auto i=GetItemByEntry(r->item.guid.id);assert(i && i->count);++requests; + if(--i->count==0)inventory.erase(r->item.guid.id); // last item may disappear immediately + } + void Add(ItemTemplate*p,unsigned count=20){inventory[p->id]=std::make_unique(Item{p,count});} +}; +struct Spell{Player*p;SpellInfo*info;Item*m_CastItem=nullptr;Spell(Player*q,SpellInfo*i,int):p(q),info(i){}bool CanAutoCast(Player*q){assert(q==p && m_CastItem);return info->autoCast;}}; +'''+header+'\n'+function(' bool RoleMatches(')+'\n'+function('ItemTemplate const* AdventureSupplyMgr::Select(')+'\n'+function('AdventureSupplyMgr::UseResult AdventureSupplyMgr::TryUse(')+'\n'+function('void AdventureSupplyMgr::LearnSecondaries(')+r''' +int main(){ + using U=AdventureSupplyPolicy::Use;AdventureSupplyMgr mgr;Player p;auto&r=mgr._records[26]; + objects.items.emplace(21991,ItemTemplate{21991,0,7,129,325,{{0,100}}}); + objects.items.emplace(14530,ItemTemplate{14530,0,7,129,225,{{0,101}}}); + objects.items.emplace(22829,ItemTemplate{22829,55,1,0,0,{{0,102}}}); + objects.items.emplace(57191,ItemTemplate{57191,80,1,0,0,{{0,103}}}); + objects.items.emplace(27854,ItemTemplate{27854,55,5,0,0,{{0,104}}}); + mgr._catalog={{"healing_potion",57191,80},{"bandage",21991,60},{"bandage",14530,45},{"healing_potion",22829,55},{"food",27854,55}}; + for(unsigned id=100;id<=104;++id)spellMgr.spells[id].Id=id; + spellMgr.spells[104].combat=false; + assert(mgr.Select(&p,"bandage")->id==21991); // owning future tier never grants skill + p.Add(&objects.items[21991]);assert(mgr.TryUse(&p,r,U::Bandage)==AdventureSupplyMgr::UseResult::Unavailable && p.requests==0); + p.skills[129]=225;assert(mgr.Select(&p,"bandage")->id==14530); + p.skills[129]=375;assert(mgr.Select(&p,"bandage")->id==21991); + p.history.gcd=true;assert(mgr.TryUse(&p,r,U::Bandage)==AdventureSupplyMgr::UseResult::Busy);p.history.gcd=false; + p.history.cooldown=true;assert(mgr.TryUse(&p,r,U::Bandage)==AdventureSupplyMgr::UseResult::Unavailable);p.history.cooldown=false; + spellMgr.spells[100].autoCast=false;assert(mgr.TryUse(&p,r,U::Bandage)==AdventureSupplyMgr::UseResult::Unavailable && p.requests==0);spellMgr.spells[100].autoCast=true; + p.inventory[21991]->count=1;assert(mgr.TryUse(&p,r,U::Bandage)==AdventureSupplyMgr::UseResult::Started && p.requests==1 && p.GetItemCount(21991)==0); + p.Add(&objects.items[27854]);p.combat=true;assert(mgr.TryUse(&p,r,U::Food)==AdventureSupplyMgr::UseResult::Unavailable); + assert(mgr.Select(&p,"healing_potion")->id==22829);p.Add(&objects.items[22829]);assert(mgr.TryUse(&p,r,U::HealingPotion)==AdventureSupplyMgr::UseResult::Started && p.GetItemCount(22829)==19); + // Native trainer gates, native skill caps and explicit secondary-only ranks. + mgr._secondaryRanks.clear();p.skills.clear();p.caps.clear();p.steps.clear();p.known.clear(); + for(unsigned skill:{129u,185u,356u,794u})for(unsigned step=1;step<=6;++step){unsigned id=skill*10+step;spellMgr.ranks[id]={skill,step,step<=4?step*75:step==5?375u:450u};spellMgr.spells[id].Id=id;mgr._secondaryRanks.push_back({skill,id,step,step==6?65u:step==5?50u:1u,step==1?0u:(step-1)*75-25});} + mgr.LearnSecondaries(&p); + for(unsigned skill:{129u,185u,356u,794u})assert(p.GetPureSkillValue(skill)==375 && p.GetPureMaxSkillValue(skill)==375 && !p.HasSpell(skill*10+6)); + auto known=p.known;mgr.LearnSecondaries(&p);assert(p.known==known); + + // Real profession rank representation: trainer wrapper -> learned effect44. + Player fresh;AdventureSupplyMgr discovered; + SpellInfo wrapper;wrapper.Id=3279;wrapper.Effects={{0,0,36,3273}};spellMgr.spells[3279]=wrapper; + SpellInfo apprentice;apprentice.Id=3273;apprentice.Effects={{0,0,44,0,129,1}};spellMgr.spells[3273]=apprentice; + SpellInfo primary;primary.Id=2575;primary.Effects={{0,0,44,0,186,1}};spellMgr.spells[2575]=primary; + WorldDatabase.active=true;WorldDatabase.fixture.rows={{{{3279},{0},{0}}},{{{2575},{0},{0}}}}; + discovered.LearnSecondaries(&fresh); + assert(fresh.GetPureSkillValue(129)==75 && fresh.GetPureMaxSkillValue(129)==75); + assert(discovered._secondaryRanks.size()==1 && !fresh.skills.count(186)); + assert(!p.skills.count(186) && !p.skills.count(164)); // never invent primary professions +} +''' +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 native-item delegation: usable bandage tiers, native cooldown/lockout gates, real inventory consumption, self targeting and level60 secondary caps passed.') diff --git a/tests/clientless/test_player_pure_max_skill.py b/tests/clientless/test_player_pure_max_skill.py new file mode 100644 index 0000000000..6a4d460c29 --- /dev/null +++ b/tests/clientless/test_player_pure_max_skill.py @@ -0,0 +1,34 @@ +"""Execute the native pure-skill-cap getter; populated skills must not return0.""" +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('uint16 Player::GetPureMaxSkillValue(');b=s.index('{',a);e=b+1;depth=1 +while depth:depth+=(s[e]=='{')-(s[e]=='}');e+=1 +code=r''' +#include +#include +#include +using uint16=uint16_t;using uint8=uint8_t;using uint32=uint32_t; +constexpr int SKILL_DELETED=1,PLAYER_SKILL_MAX_RANK_0=100; +struct Status{unsigned pos=0;int uState=0;};using SkillStatusMap=std::unordered_map; +struct Player{ + SkillStatusMap mSkillStatus;unsigned rank=1;uint16 cap=375; + unsigned GetSkillRank(unsigned)const{return rank;} + uint16 GetUInt16Value(unsigned field,unsigned offset)const{assert(field==101 && offset==1);return cap;} + uint16 GetPureMaxSkillValue(uint32)const; +}; +'''+s[a:e]+r''' +int main(){ + Player p;assert(p.GetPureMaxSkillValue(0)==0);assert(p.GetPureMaxSkillValue(129)==0); + p.mSkillStatus[129]={3,0};assert(p.GetPureMaxSkillValue(129)==375); + p.cap=75;assert(p.GetPureMaxSkillValue(129)==75); + p.rank=0;assert(p.GetPureMaxSkillValue(129)==0);p.rank=1; + p.mSkillStatus[129].uState=SKILL_DELETED;assert(p.GetPureMaxSkillValue(129)==0); +} +''' +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 pure skill cap: learned skills return stored cap; missing/deleted/zero-rank skills return0.')