Add AdventureBot self-care, supply restocking and secondary professions

This commit is contained in:
Nico committed 2026-10-27 23:56:43 +01:00
1 parent c0c799c4b6
commit 39a515dbd1
18 files changed
+889 -3

No files matched your search

+59
View File
@@ -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<fehlende-Prozent1..80>.
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
@@ -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
@@ -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.
+81
View File
@@ -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 <guid>` zeigt Fertigkeiten, ausgewählte Vorräte,
Bestände und Taschen. `clientless combat testrecovery <guid> <fehlende-Prozent>`
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.
@@ -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;
@@ -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);
+1 -1
View File
@@ -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;
@@ -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());
}
@@ -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
@@ -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 <algorithm>
#include <memory>
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<uint32> 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<uint32>({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<uint32>(existing->Required,f[2].GetUInt32());}
}
}while(rows->NextRow());
std::sort(_secondaryRanks.begin(),_secondaryRanks.end(),[](SecondaryRank const& a,SecondaryRank const& b){return a.Step<b.Step;});
for(auto const& rank:_secondaryRanks)
{
if(rank.MinLevel>p->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)<maximum)
{
p->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;slot<INVENTORY_SLOT_BAG_END;++slot)
{
auto current=p->GetBagByPos(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;pos<INVENTORY_SLOT_ITEM_END;++pos)
if(auto item=p->GetItemByPos(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,decltype(dispose)> 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<Spell>(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<PendingSpellCastRequest>(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)<r.PendingCount)
{
TC_LOG_INFO("server.adventurebot","SUPPLY_USED guid=%u role='%s' item=%u",p->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(now<r.RetryRecovery)return false;
if(!r.Recovering)
{
sAdventureBehaviorMgr->InterruptForCombat(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;slot<INVENTORY_SLOT_BAG_END;++slot)
if(auto bag=p->GetBagByPos(slot))handler->PSendSysMessage("bagSlot=%u item=%u capacity=%u",slot,bag->GetEntry(),bag->GetBagSize());
}
@@ -0,0 +1,46 @@
#ifndef VCORE_ADVENTURE_SUPPLY_MGR_H
#define VCORE_ADVENTURE_SUPPLY_MGR_H
#include "Common.h"
#include "AdventureSupplyPolicy.h"
#include <ctime>
#include <string>
#include <unordered_map>
#include <vector>
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<SecondaryRank> _secondaryRanks;
std::vector<Supply> _catalog;
std::unordered_map<uint32,Record> _records;
};
#define sAdventureSupplyMgr AdventureSupplyMgr::instance()
#endif
@@ -0,0 +1,30 @@
#ifndef VCORE_ADVENTURE_SUPPLY_POLICY_H
#define VCORE_ADVENTURE_SUPPLY_POLICY_H
#include <cstdint>
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 count<stack?stack-count:0; }
inline Use Choose(bool combat,bool health,bool mana,bool bandageTried,bool bandageAllowed)
{
if(combat)return health?Use::HealingPotion:mana?Use::ManaPotion:Use::None;
if(health)return !bandageTried && bandageAllowed?Use::Bandage:Use::Food;
return mana?Use::Drink:Use::None;
}
inline char const* Role(Use use)
{
switch(use)
{
case Use::Bandage:return "bandage";
case Use::Food:return "food";
case Use::Drink:return "drink";
case Use::HealingPotion:return "healing_potion";
case Use::ManaPotion:return "mana_potion";
default:return "";
}
}
}
#endif
@@ -5,6 +5,7 @@
#include "ClientlessTravelMgr.h"
#include "AdventureBehaviorMgr.h"
#include "AdventureCombatMgr.h"
#include "AdventureSupplyMgr.h"
#include "AdventureSocialActions.h"
#include "Config.h"
#include "Log.h"
@@ -215,15 +216,19 @@ public:
AdventureSocialActions::Prepare(player);
sClientlessLuaEngine->ExecuteAdventureScript(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); }
};
}
@@ -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);
@@ -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 <algorithm>
#include <cassert>
#include <ctime>
#include <string>
#include <vector>
#include <unordered_map>
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<unsigned,unsigned> 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<AdventureSupplyPolicy::Use> 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>{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.')
@@ -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.')
@@ -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 <algorithm>
#include <array>
#include <cassert>
#include <memory>
#include <ctime>
#include <string>
#include <vector>
#include <unordered_map>
#include <unordered_set>
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<Effect> 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::vector<Effect>Effects;bool CanBeUsedInCombat(){return combat;}};
struct Rank{unsigned skill,step,maximum;};
struct Player;
struct SpellMgr{
std::unordered_map<unsigned,SpellInfo>spells;std::unordered_map<unsigned,Rank>ranks;
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_map<unsigned,ItemTemplate>items;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<std::array<Field,3>> rows;size_t index=0;Field*Fetch(){return rows[index].data();}bool NextRow(){return ++index<rows.size();}};
struct Database{bool active=false;Row fixture;Row*Query(char const*){fixture.index=0;return active?&fixture:nullptr;}}WorldDatabase;
namespace GameTime{time_t GetGameTime(){return 100;}}
struct History{bool cooldown=false,gcd=false;bool HasGlobalCooldown(SpellInfo*){return gcd;}bool IsReady(SpellInfo*,unsigned){return !cooldown;}};
namespace WorldPackets{namespace Spells{struct SpellCastRequest{unsigned SpellID=0;struct{unsigned Flags=0;Guid Unit;}Target;};}}
struct SpellCastRequestItemData{unsigned bag,slot;Guid guid;SpellCastRequestItemData(unsigned b,unsigned s,Guid g):bag(b),slot(s),guid(g){}};
struct PendingSpellCastRequest{WorldPackets::Spells::SpellCastRequest request;SpellCastRequestItemData item;PendingSpellCastRequest(WorldPackets::Spells::SpellCastRequest&&r,SpellCastRequestItemData i):request(r),item(i){}};
struct Player{
unsigned level=60;bool combat=false,casting=false;History history;int requests=0;std::unordered_map<unsigned,unsigned>skills,caps,steps;std::unordered_set<unsigned>known;
std::unordered_map<unsigned,std::unique_ptr<Item>>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_ptr<PendingSpellCastRequest>r,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>(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.')
@@ -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 <cassert>
#include <cstdint>
#include <unordered_map>
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<unsigned,Status>;
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.')