diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index f3999b96af..348268f5cc 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,54 @@ +# Fortsetzung am 27.10.2026 – aktueller Stand hat Vorrang + +Der unten erhaltene Sitzungswechsel-Checkpoint ist historisch. Der Berufs-Ausbau +ist jetzt vollständig gebaut (`-j10`), auf dem Testserver installiert und die +Migration `_05` ist auf der Test-World-DB angewendet. **37 Testskripte bestanden**. +Keine Änderungen wurden gepusht. Branch bleibt `feature/clientless-player-core`. + +Seit dem Checkpoint behoben: +- Berufsarbeit an GOs nähert sich nach Verdrängung erneut, bevor weitere Gesten laufen. +- Native unsichtbare Arbeitsplatzmarker verwenden VMAP-Sichtprüfung zur Arbeitsfläche + (mindestens 1,5 Yards über dem Marker); Bodenmarker im Amboss blockierten zuvor die Szene. +- Location-Abfrage liest numerische native Spalten ohne COALESCE-Widening. Debug verlor + zuvor `is_area`/`faction`; Elwynn wird live jetzt korrekt als `area=1` geladen. + +Zusätzliche Tests: echter Lua-5.4-Professionsparser mit Arthoran/generic und ungültigen +Profilen; native Lock-/Station-/Kadaver-/Trainerklassifikation; Szene erst nach Ankunft, +Profilentzug, Wiederannäherung; SQL-Fixtures mit erhaltenen Gewichten/Freigaben; +erreichbare Gebietsknoten; progressive Suche und Uferbedingungen; echte Debug-Fieldgetter +und VMAP-Sichtlinienadapter. Siehe `tests/clientless/test_adventure_profession_*.py`, +`test_adventure_area_node.py` und erweiterte bestehende Harnesses. + +Live bestätigt: +- Frische Ready-Meldung, laufender Prozess und Port 8085 nach Installation. +- Arthoran lädt zwei Hauptberufe; Profil ist bytegleich wiederhergestellt: + `social` / `goldshire_tavern`, mining + blacksmithing. Kein temporäres Profil liegenlassen. +- Reguläre Anreise zu `poi_guard_541` über Graph, Taxi und Bodenwege. +- `smithing_show` wählt echtes Anvil-GO **201415**, Low **556**; Work-Emote **233**; + `PROFESSION_WORK_COMPLETE` und `PROFESSION_VISIT_COMPLETE` nach Abschluss. +- `profession_herbalism` live als nicht verfügbar zurückgewiesen. Die isolierte + Profilprüfung auch bei vorhandener Pflanze ist durch den produktiven Zieltest belegt. +- Nach `saveall` unveränderte Geld-/Inventar-Prüfsumme: + `8d40931782d05219466ba9fd077ce7acd3e02bd53eb63f6e96115042c620d26a`. +- Elwynn-Auftrag `profession poi_tele_338` lädt `area=1`, startet `travel_to_node`, + erreicht mit bestehendem Free-flight-Fallback den Gebietsknoten und meldet `INTENT_ARRIVED`. + Lokale `profession_search` absolvierte danach alle sechs progressiven MMAP-Wegpunkte + (ca. 104 Yards, `LEISURE_COMPLETE`, legs=6). +- Letzter geprüfter Testserverprozess: PID 28577, frische Ready-Meldung und Port 8085. + Beim Wiederaufnehmen immer den tatsächlichen Zustand neu prüfen. + +Offener Befund: Der erste Intentwechsel während aktiver Goldshire-Anreise geriet in +Graph-Handover-/Recovery-Schleifen zum nächstgelegenen `portal_at_109_target`. +Für die gezielten Tests wurde deshalb nur beim Login die Startvorgabe temporär +geändert und danach bytegleich restauriert. Dieses Travel-Handover ist NICHT als +behoben zu betrachten. Navigationslog enthält die Vorgänge ab 17:43. + +Dokumentation: [profession-intent.md](profession-intent.md). Visuelle Abnahme bleibt +beim Nutzer; Freizeit-Ausritt/Rundflug und Schlafdarstellung bleiben zurückgestellt. +Alle ursprünglichen Regeln und fachlichen Festlegungen unten gelten weiterhin. + +--- + # Gesicherter Arbeitsstand – hier fortsetzen Stand: 27.10.2026. Der Nutzer hat die Implementierung ausdrücklich für einen diff --git a/doc/clientless/checkpoints/2026-10-27-profession/profession-live-area.log b/doc/clientless/checkpoints/2026-10-27-profession/profession-live-area.log new file mode 100644 index 0000000000..6303b8c4ab --- /dev/null +++ b/doc/clientless/checkpoints/2026-10-27-profession/profession-live-area.log @@ -0,0 +1,14 @@ +TrinityCore rev. 722b513ee01b+ 2026-10-27 17:02:01 +0100 (feature/clientless-player-core branch) (Unix, Debug, Static) (worldserver-daemon) ready... +PROFILE_PROFESSIONS guid=26 primary=2 +ClientlessLua[26]: Arthoran ready; no automatic test journey queued +INTENT_TRAVEL guid=26 intent='profession' location='poi_tele_338' area=1 +INTENT_ARRIVED guid=26 intent='profession' location='poi_tele_338' +PROFESSION_WORK_COMPLETE guid=26 kind='profession_tools' target='' +LEISURE_WALK_PLAN guid=26 kind='work_search' span=103.64 outbound=6 origin=(-8888.98 -0.54 94.39) goal=(-8978.85 -52.09 91.55) +LEISURE_WAYPOINT guid=26 kind='work_search' reached=1/6 +LEISURE_WAYPOINT guid=26 kind='work_search' reached=2/6 +LEISURE_WAYPOINT guid=26 kind='work_search' reached=3/6 +LEISURE_WAYPOINT guid=26 kind='work_search' reached=4/6 +LEISURE_WAYPOINT guid=26 kind='work_search' reached=5/6 +LEISURE_WAYPOINT guid=26 kind='work_search' reached=6/6 +LEISURE_COMPLETE guid=26 kind='work_search' legs=6 diff --git a/doc/clientless/intent-katalog.md b/doc/clientless/intent-katalog.md index b29fe36180..9550e0cacc 100644 --- a/doc/clientless/intent-katalog.md +++ b/doc/clientless/intent-katalog.md @@ -34,7 +34,9 @@ Der bestehende Verhaltensmanager kennt aktuell die technischen Intentnamen `social`, `adventure`, `profession`, `rest`, `leisure` und `economy`. Der [Ruhe-Intent](rest-intent.md) ist ergänzt, ebenso der [Freizeit-Intent](leisure-intent.md) und der [Wirtschaften-Intent](economy-intent.md). Der [Abenteuer-Ausbau](adventure-intent.md) -ergänzt Erkundung und Objektuntersuchung. Instanzbesuche bleiben konzeptionell +ergänzt Erkundung und Objektuntersuchung. Der [Berufs-Ausbau](profession-intent.md) +ergänzt profilgebundene Arbeitsdarstellungen, echte Ressourcen-/Arbeitsplatzprüfung +und progressive Gebietssuche. Instanzbesuche bleiben konzeptionell festgehalten; ihr technischer Name und Detailablauf sind noch nicht umgesetzt. Der ausführliche bisherige [Mikroaktionskatalog](mikroaktions-katalog.md) bleibt als Grundlage erhalten. diff --git a/doc/clientless/last.txt b/doc/clientless/last.txt index 537ee40e6e..ba36692b09 100644 --- a/doc/clientless/last.txt +++ b/doc/clientless/last.txt @@ -1,3 +1,12 @@ +27.10.2026 – Berufs-Ausbau fortgesetzt, gebaut und auf Testserver installiert. +Migration _05 angewendet; 37 Testskripte bestanden. Native Arbeitsplatz-Sichtlinie +und numerische Location-Feldtypen korrigiert. Live: echter Amboss 201415, +Work-Emote 233, Abschluss/Wegtreten; Kräuteraktion verweigert; Geld/Inventar +unverändert. Elwynn als Gebiet über travel_to_node erreicht, sechs lokale +progressive Suchwegpunkte vollständig absolviert. Visuelle Abnahme und echtes +Ressourcenziel in freier Wildbahn offen. Separater Travel-Handover-Loop dokumentiert. +Details: profession-intent.md und SESSION-RESUME.md. Kein Push. + › OK stop, er findet ja den weg schon wieder nicht. Es sieht so aus, als würde er den virtuellen entry auf der Kante sturmwind Hafen - sturmwind gate verwenden. Er reitet diese auch lang aber nach einem Stück bricht er ab. Warum? Er mü… 1. Der Planer bewertet den restlichen Weg zur Taverne zunächst nur anhand der Luftlinienentfernung. Ob MMAP ihn tatsächlich laufen kann, prüft er erst am Ausstieg. diff --git a/doc/clientless/mikroaktions-katalog.md b/doc/clientless/mikroaktions-katalog.md index 2ee63d6afa..d30ed3827b 100644 --- a/doc/clientless/mikroaktions-katalog.md +++ b/doc/clientless/mikroaktions-katalog.md @@ -7,8 +7,8 @@ ergänzt. Die neuen Detailvorschläge sind eine Arbeitsgrundlage zur Abstimmung. Der bisherige Katalog vom 26.10.2026 beschreibt die vereinbarten Aktionen. Geselligkeitsaktionen sowie die Ausbaustufen für Ruhe, Freizeit, Wirtschaften -und [Abenteuer-Erkundung](adventure-intent.md) sind implementiert. Weitere -Berufsaktionen und echte Kampfhandlungen bleiben Ausbaustufen. Den geprüften Implementierungsstand +und [Abenteuer-Erkundung](adventure-intent.md) sind implementiert. Der [Berufs-Ausbau](profession-intent.md) ergänzt profilgebundene, dauerhaft +performative Arbeit an realen Zielen. Echte Kampfhandlungen bleiben eine spätere Ausbaustufe. Den geprüften Implementierungsstand beschreibt [autonomous-behavior.md](autonomous-behavior.md). Die Aktionen werden nach den sieben Intents gewichtet und über @@ -107,7 +107,7 @@ den Abenteuer-Intent tragen. Berufsaktionen sollten sich nach dem Charakterprofil richten. Ein angehender Schmied besucht andere Orte und Objekte als ein Kräuterkundiger. Die eigentliche -Arbeit bleibt wie besprochen zunächst Darstellung. +Arbeit bleibt dauerhaft rein performativ; Hauptberufe benötigen eine ausdrückliche Lua-Profilfreigabe. | Aktion | Darstellung und Bedingungen | |---|---| diff --git a/doc/clientless/profession-intent.md b/doc/clientless/profession-intent.md new file mode 100644 index 0000000000..e1b6162fc0 --- /dev/null +++ b/doc/clientless/profession-intent.md @@ -0,0 +1,67 @@ +# Beruf (`profession`) + +Berufsaktionen bleiben dauerhaft rein performativ. Der Bot bewegt sich mit +TravelGraph und MMAP zu echten Ressourcen, Arbeitsplätzen oder Ansprechpartnern +und stellt die Tätigkeit mit nativen Gesten dar. Er benutzt keine Quellen, +erzeugt keine Beute, verarbeitet keine Materialien, erhöht keine Fertigkeiten, +lernt nichts und heilt nicht tatsächlich. + +Hauptberufe werden ausschließlich im Lua-Profil über `professions.primary` +freigegeben. Arthoran besitzt `mining` und `blacksmithing`; das generische Profil +besitzt keine Hauptberufe. Unbekannte Namen gewähren keine Berechtigung. +Nebenberufe benötigen keine Hauptberufsfreigabe. Die Freigabe wird auch während +einer laufenden Szene geprüft. + +Der Tagesplan wählt den Intent unabhängig vom Ortsbestand. Danach wird ein +geeigneter importierter Ort oder ein Gebiet ausgewählt. Erst vor Ort bestimmen +reale Ziele und Profilrechte die möglichen Mikroaktionen. Eine Workshop- oder +Gebietszuordnung ersetzt keinen Amboss und gewährt keine Hauptberufe. + +Erz und Pflanzen werden anhand nativer Lock-Skills erkannt, nicht anhand ihrer +Namen. Quellen müssen gespawnt, bereit, sichtbar und in der passenden Phase sein. +Kürschnerei setzt einen vorhandenen toten, kürschnerbaren Tierkadaver voraus. +Arbeitsplatzrollen stehen im editierbaren Katalog `vcore_adventure_workstation`. +Die Migration `2026_10_27_05_adventure_profession.sql` ergänzt native Fokusrollen +und erkennbare Arbeitsobjekte. Amboss und Esse haben getrennte Rollen; deaktivierte +Rollen und bearbeitete Gewichte bleiben bei erneuter Migration erhalten. +Trainer werden anhand nativer Tradeskill- und SkillLine-Daten zugeordnet. + +Eine Szene nähert sich dem Ziel über normale Navigation, zeigt die Arbeit erst +nach bestätigter Ankunft, betrachtet das Ergebnis und tritt kurz zurück. +Verlust von Profilrecht, Ziel oder Arbeitsplatz beendet die Darstellung. +Kürzlich vollständig dargestellte Sammelziele werden über die Zielhistorie +vorübergehend gemieden. Reines Erzbetrachten setzt diese Bearbeitungssperre nicht. + +Gebietsaufträge reisen zu einem erreichbaren Graphknoten im Zielgebiet. Die lokale +Suche nutzt fortschreitende Bodenrouten und prüft an bestätigten Wegpunkten erneut +auf zulässige Arbeit. Nach abgeschlossenem Fortschritt verschiebt sich der lokale +Anker. Angeln verlangt eine ausgerüstete Angel und tatsächliches nahes Wasser; +es wird kein Angelzauber ausgeführt und kein Fisch gefangen. + +Automatisierte Prüfungen führen den produktiven Profilparser mit Lua 5.4, +die native Zielklassifikation und die Szenensteuerung mit Server-Doubles aus. +Katalogfixtures prüfen Wiederholbarkeit und Erhalt bearbeiteter Einstellungen. +Diese Prüfungen ersetzen nicht die visuelle Abnahme im Spiel. + +## Technischer Teststand am 27.10.2026 + +37 Clientless-Testskripte bestanden; Debug-Build mit `-j10` installiert, +Migration `_05` auf der Test-DB angewendet. Frische Ready-Meldung, Prozess und +Port 8085 geprüft. Arthoran erreichte den importierten Schmiedeort regulär +über Graph, Taxi und MMAP und stellte am echten Amboss 201415 (Low 556) +`smithing_show` dar. Work-Emote 233, Ergebnisbetrachtung und Wegtreten wurden +im Serverlog bestätigt. Eine angeforderte Kräuterszene wurde zurückgewiesen. +Nach `saveall` blieb die Geld-/Inventar-Prüfsumme unverändert. + +Elwynn (`poi_tele_338`) wurde als Gebiet geladen, über `travel_to_node` samt +bestehendem Free-flight-Fallback erreicht und mit `INTENT_ARRIVED` bestätigt. +Die anschließende lokale `profession_search` absolvierte alle sechs progressiven +MMAP-Wegpunkte mit etwa 104 Yards Ausdehnung (`LEISURE_COMPLETE`, legs=6). Ressourcenabbau in freier Wildbahn, +weitere Berufe und die sichtbare Darstellung sind damit noch nicht live abgenommen. + +Zwei im Test gefundene Fehler wurden behoben: VMAP-Sichtlinien zu unsichtbaren +Arbeitsplatzmarkern prüfen jetzt die Arbeitsfläche oberhalb des Bodenmarkers; +die Location-Abfrage erhält die nativen numerischen Spaltentypen, damit Debug +Gebietsflag und Fraktion korrekt liest. Ein während aktiver Reise beobachteter +Graph-Handover-/Recovery-Loop zu `portal_at_109_target` bleibt ein separater +unbehobener Befund. diff --git a/src/server/game/World/AdventureLocationMgr.cpp b/src/server/game/World/AdventureLocationMgr.cpp index 32499d5061..96d4f633a1 100644 --- a/src/server/game/World/AdventureLocationMgr.cpp +++ b/src/server/game/World/AdventureLocationMgr.cpp @@ -29,7 +29,7 @@ bool AdventureLocationMgr::Reload() _dialogs.clear(); _loaded = false; if (! WorldDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_location'")) return false; - if (auto rows = WorldDatabase.Query("SELECT w.id,w.point_key,w.category,w.map,w.position_x,w.position_y,w.position_z,w.orientation," "COALESCE(a.display_name,w.point_key),COALESCE(a.region_key,''),COALESCE(a.area_id,0)," "COALESCE(a.zone_id,0),COALESCE(a.is_area,0),COALESCE(a.faction,0) " "FROM vcore_clientless_world_point w LEFT JOIN vcore_adventure_location a ON a.point_key=w.point_key " "WHERE w.enabled=1 AND w.category NOT LIKE 'travel%' ORDER BY w.point_key")) + if (auto rows = WorldDatabase.Query("SELECT w.id,w.point_key,w.category,w.map,w.position_x,w.position_y,w.position_z,w.orientation," "COALESCE(a.display_name,w.point_key),COALESCE(a.region_key,''),a.area_id," "a.zone_id,a.is_area,a.faction " "FROM vcore_clientless_world_point w LEFT JOIN vcore_adventure_location a ON a.point_key=w.point_key " "WHERE w.enabled=1 AND w.category NOT LIKE 'travel%' ORDER BY w.point_key")) { do { diff --git a/src/server/game/World/AdventureSocialActions.cpp b/src/server/game/World/AdventureSocialActions.cpp index 4ee97a78be..912d48fec9 100644 --- a/src/server/game/World/AdventureSocialActions.cpp +++ b/src/server/game/World/AdventureSocialActions.cpp @@ -49,7 +49,7 @@ namespace // participants can see each other's head. Keep native VMAP geometry. return player->GetMap()->isInLineOfSight(player->GetPhaseShift(), player->GetPositionX(),player->GetPositionY(),player->GetPositionZ()+player->GetCollisionHeight(), - target->GetPositionX(),target->GetPositionY(),target->GetPositionZ()+target->GetCollisionHeight(), + target->GetPositionX(),target->GetPositionY(),target->GetPositionZ()+(AdventureProfessionPolicy::Recognized(kind) && target->ToGameObject()?std::max(1.5f,target->GetCollisionHeight()):target->GetCollisionHeight()), LINEOFSIGHT_ALL_CHECKS,VMAP::ModelIgnoreFlags::Nothing); } std::vector < Unit * > People(Player * player, std::string const & kind, AdventureSocialContext & c) @@ -189,7 +189,7 @@ namespace if(floor<=INVALID_HEIGHT+1||std::fabs(floor-target->GetPositionZ())>3)continue; p.m_positionZ=floor; if(!player->GetMap()->isInLineOfSight(player->GetPhaseShift(),p.GetPositionX(),p.GetPositionY(),floor+((AdventureEconomyPolicy::Economic(scene.Kind) || AdventureProfessionPolicy::Recognized(scene.Kind))?player->GetCollisionHeight():1.5f), - target->GetPositionX(),target->GetPositionY(),target->GetPositionZ()+((AdventureEconomyPolicy::Economic(scene.Kind) || AdventureProfessionPolicy::Recognized(scene.Kind))?target->GetCollisionHeight():1.5f), + target->GetPositionX(),target->GetPositionY(),target->GetPositionZ()+(AdventureProfessionPolicy::Recognized(scene.Kind) && target->ToGameObject()?std::max(1.5f,target->GetCollisionHeight()):AdventureEconomyPolicy::Economic(scene.Kind)?target->GetCollisionHeight():1.5f), LINEOFSIGHT_ALL_CHECKS,VMAP::ModelIgnoreFlags::Nothing))continue; if(!c.Move(p,(AdventureEconomyPolicy::Economic(scene.Kind) || AdventureExplorationPolicy::Inspection(scene.Kind) || AdventureProfessionPolicy::Recognized(scene.Kind))?350:55))continue; scene.Destination=p;scene.Approaching=true;return true; @@ -566,6 +566,13 @@ bool AdventureSocialActions::Update(Player * p, AdventureSocialScene & s, uint32 { if (! InBounds(p, target, c)) return false; if(AdventureProfessionPolicy::Recognized(s.Kind) && !AdventureProfessionTargets::Valid(p,target,s.Kind,c))return false; + if (AdventureProfessionPolicy::Recognized(s.Kind) && target->ToGameObject() && !s.Approaching && + (p->GetExactDist(target)>4 || !TargetVisible(p,target,s.Kind))) + { + if (++s.Repaths>3 || !Approach(p,s,target,c)) return false; + // Do not advance a work cue while returning to the workplace. + return true; + } if (AdventureExplorationPolicy::Inspection(s.Kind)) { auto object=target->ToGameObject(); diff --git a/tests/clientless/test_adventure_area_node.py b/tests/clientless/test_adventure_area_node.py new file mode 100644 index 0000000000..8c75cec484 --- /dev/null +++ b/tests/clientless/test_adventure_area_node.py @@ -0,0 +1,39 @@ +"""Area destinations select a reachable native graph node inside the area.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() +a=s.index('std::string AdventureBehaviorMgr::AreaNode(');b=s.index('\nvoid AdventureBehaviorMgr::Begin',a) +code=r''' +#include +#include +#include +#include +#include +#include +using uint32=uint32_t; +struct Position {float x=0;void Relocate(float a,float,float){x=a;}float GetPositionX(){return x;}float GetPositionY(){return 0;}}; +struct Player{int map=0;int GetPhaseShift(){return 0;}int GetMapId(){return map;}float GetExactDist(Position p){return p.x;}}; +struct AdventureLocation{struct{int MapId=0;float X=0,Y=0;}Point;uint32 AreaId=12;}; +struct Field{float value=0;std::string text;float GetFloat(){return value;}std::string GetString(){return text;}}; +struct Rows{std::vector> data;unsigned row=0;operator bool(){return !data.empty();}Rows* operator->(){return this;}Field* Fetch(){return data[row].data();}bool NextRow(){return ++row tried;bool PlanAdaptiveRoute(Player*,std::string const&key,int,int,std::vector&,bool& complete,std::string&,std::string&){tried.push_back(key);complete=key=="reachable";return true;}}graph;auto sClientlessTravelGraphMgr=&graph; +struct AdventureBehaviorMgr{std::string AreaNode(Player*,AdventureLocation const&);}; +'''+s[a:b]+r''' +int main(){ + WorldDatabase.rows.data={{{0,"outside"},{1,""},{},{}},{{0,"blocked"},{2,""},{},{}},{{0,"reachable"},{3,""},{},{}}}; + Player p;AdventureLocation l;AdventureBehaviorMgr mgr; + assert(mgr.AreaNode(&p,l)=="reachable");assert((graph.tried==std::vector{"blocked","reachable"})); + sTerrainMgr.available=false;assert(mgr.AreaNode(&p,l).empty());sTerrainMgr.available=true; + l.AreaId=42;graph.tried.clear();assert(mgr.AreaNode(&p,l).empty()&&graph.tried.empty()); +} +''' +with tempfile.TemporaryDirectory(prefix='vcore-area-node-') as folder: + cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code) + subprocess.run(['c++','-std=c++17',str(cpp),'-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True) +print('Production area destination excludes outside and unreachable graph nodes.') diff --git a/tests/clientless/test_adventure_economy.py b/tests/clientless/test_adventure_economy.py index 0fb14a5137..fdef66001e 100644 --- a/tests/clientless/test_adventure_economy.py +++ b/tests/clientless/test_adventure_economy.py @@ -20,6 +20,7 @@ enum class HighGuid{Unit}; struct Guid{int value=1;Guid(int v=1):value(v){}Guid(HighGuid,unsigned,unsigned low):value(low){}unsigned GetCounter(){return value;}bool IsEmpty(){return value==0;}}; using ObjectGuid=Guid; struct WorldObject{ + void* ToGameObject(){return nullptr;} bool inWorld=true,phase=true;unsigned map=0;float x=0,z=0; bool IsInWorld()const{return inWorld;}unsigned GetMapId()const{return map;}float GetPositionX()const{return x;}float GetPositionY()const{return 0;}float GetPositionZ()const{return z;}float GetCollisionHeight()const{return 2;} }; diff --git a/tests/clientless/test_adventure_leisure.py b/tests/clientless/test_adventure_leisure.py index 787fea6859..6575e47f8d 100644 --- a/tests/clientless/test_adventure_leisure.py +++ b/tests/clientless/test_adventure_leisure.py @@ -93,7 +93,7 @@ class AdventureBehaviorMgr{public: int moves=0,resets=0;bool lastRiding=false,moveAllowed=true;Position requested; bool LocalMove(Player*,Record&,Position const& target,float,bool riding=false){++moves;lastRiding=riding;requested=target;travel.status.State=ClientlessTravelState::Moving;return moveAllowed;} void ResetMicro(Player* p,Record&r){++resets;ResetLeisure(p,r);} - bool ProfessionWorkAvailable(Player*,Record&){return false;} + bool workAvailable=false;bool ProfessionWorkAvailable(Player*,Record&){return workAvailable;} bool FishingDirection(Player*,float&)const; bool LeisureAvailable(Player*,Record const&,AdventureMicroAction const&)const; bool PlanLeisureWalk(Player*,Record&); @@ -192,6 +192,25 @@ int main(){ assert(mgr.StartLeisure(&p,r,{"explore"})&&r.Leisure.PlannedWalk&&r.Leisure.Route.size()>=6); mgr.ResetLeisure(&p,r);p.outdoors=false;assert(!mgr.LeisureAvailable(&p,r,{"explore"})); + // Profession area search progresses outward and stops only at confirmed arrival. + p.outdoors=true;p.Relocate(0,0,0);r.Intent="profession";r.MicroTimer=180000; + location.IsArea=true;location.AreaId=1519; + assert(mgr.StartLeisure(&p,r,{"work_search"})&&r.Leisure.Progressive); + assert(r.Leisure.Route.size()==r.Leisure.OutboundSteps); + assert(mgr.UpdateLeisure(&p,r,1000)&&r.Leisure.Moving); + mgr.workAvailable=true; + assert(mgr.UpdateLeisure(&p,r,1000)&&r.Leisure.Step==0); + p.Relocate(mgr.requested.GetPositionX(),mgr.requested.GetPositionY(),mgr.requested.GetPositionZ()); + travel.status.State=ClientlessTravelState::Arrived; + assert(!mgr.UpdateLeisure(&p,r,1000)&&r.Leisure.Step==1&&r.Leisure.Route.size()==1); + mgr.ResetLeisure(&p,r);mgr.workAvailable=false; + // Profession fishing needs real shore water and a rod, without a fishing location key. + location.Point.Category="zone";map.water=-1000;p.hasRod=true; + assert(!mgr.LeisureAvailable(&p,r,{"fish"}));map.water=2; + assert(mgr.LeisureAvailable(&p,r,{"fish"}));p.hasRod=false; + assert(!mgr.LeisureAvailable(&p,r,{"fish"}));p.hasRod=true;p.water=true; + assert(!mgr.LeisureAvailable(&p,r,{"fish"}));p.water=false; + } ''' with tempfile.TemporaryDirectory(prefix='vcore-leisure-') as folder: diff --git a/tests/clientless/test_adventure_location_import.py b/tests/clientless/test_adventure_location_import.py index a2d0445a8f..b08014c27d 100644 --- a/tests/clientless/test_adventure_location_import.py +++ b/tests/clientless/test_adventure_location_import.py @@ -15,16 +15,16 @@ code=r''' #include #include #include -using uint16=uint16_t;using uint32=uint32_t; +using uint8=uint8_t;using uint16=uint16_t;using uint32=uint32_t; #define TRINITY_DEBUG - enum class DatabaseFieldTypes{Int16,Int32}; + enum class DatabaseFieldTypes{Int8,Int16,Int32}; struct Field{ struct{char const* value;bool raw=false;}data;DatabaseFieldTypes type;mutable int errors=0; bool IsType(DatabaseFieldTypes t)const{return type==t;}void LogWrongType(char const*)const{++errors;} - uint16 GetUInt16()const;uint32 GetUInt32()const; + uint8 GetUInt8()const;bool GetBool()const{return GetUInt8()==1;}uint16 GetUInt16()const;uint32 GetUInt32()const; }; ''' -code+=function(field,'uint16 Field::GetUInt16() const')+'\n'+function(field,'uint32 Field::GetUInt32() const') +code+=function(field,'uint8 Field::GetUInt8() const')+'\n'+function(field,'uint16 Field::GetUInt16() const')+'\n'+function(field,'uint32 Field::GetUInt32() const') code+=r''' struct AdventureLocation{struct{uint32 MapId=0;std::string Category;}Point;bool IsArea=false;}; struct AdventureLocationMgr{std::unordered_map> _types;float Weight(AdventureLocation const&,std::string const&)const;}; @@ -33,8 +33,17 @@ code+=function(source,'float AdventureLocationMgr::Weight(') reload_map=re.search(r'l.Point.MapId = f\[3\].*?;',source).group() tele_map=re.search(r's.Map = f\[2\].*?;',source).group() npc_map=re.search(r's.Map = f\[3\].*?;',source).group() +# Native nullable numeric columns retain their schema width. COALESCE with an +# integer literal promotes the tiny area/faction fields and fails Debug getters. +query=source[source.index('SELECT w.id,'):source.index('ORDER BY w.point_key')+len('ORDER BY w.point_key')] +for column in ('area_id','zone_id','is_area','faction'): + assert 'COALESCE(a.'+column not in query code+=r''' int main(){ + Field nativeArea{{"1"},DatabaseFieldTypes::Int8};assert(nativeArea.GetBool()); + Field widenedArea{{"1"},DatabaseFieldTypes::Int32};assert(!widenedArea.GetBool()&&widenedArea.errors==1); + Field missingArea{{nullptr},DatabaseFieldTypes::Int8};assert(!missingArea.GetBool()&&missingArea.errors==0); + for(auto value:{"0","1","530","571"}){ Field f[4]={{{value},DatabaseFieldTypes::Int16},{{value},DatabaseFieldTypes::Int16},{{value},DatabaseFieldTypes::Int16},{{value},DatabaseFieldTypes::Int16}}; struct{uint32 Map=0;}s;AdventureLocation l; diff --git a/tests/clientless/test_adventure_profession_catalog.py b/tests/clientless/test_adventure_profession_catalog.py new file mode 100644 index 0000000000..c5e9db94bb --- /dev/null +++ b/tests/clientless/test_adventure_profession_catalog.py @@ -0,0 +1,28 @@ +"""Apply profession catalog to schema fixtures and preserve administrator edits.""" +from pathlib import Path +import re,sqlite3 +root=Path(__file__).resolve().parents[2] +db=sqlite3.connect(':memory:') +base=(root/'sql/custom/world/2026_10_26_03_adventure_behavior.sql').read_text() +for table in ('vcore_adventure_location_intent','vcore_adventure_action','vcore_adventure_action_intent','vcore_adventure_dialog'): + body=re.search(r'CREATE TABLE IF NOT EXISTS '+table+r' \((.*?)\) ENGINE=',base,re.S)[1] + db.execute('CREATE TABLE '+table+' ('+body+')') +db.executescript('''CREATE TABLE gameobject_template(entry INTEGER PRIMARY KEY,name TEXT,type INTEGER,data0 INTEGER); + INSERT INTO gameobject_template VALUES(1,'Native anvil',8,1),(2,'Native forge',8,3),(3,'Fire',8,4),(4,'Lab',8,663),(5,'Lexicon',8,1589),(6,'Anvil',5,0),(7,'Forge',5,0),(8,'Runeforge',5,0),(9,'Workshop',5,0),(10,'Copper Vein',3,0),(11,'Loom',5,0),(12,'Special anvil',8,623),(13,'Special forge',8,543); + INSERT INTO vcore_adventure_action_intent VALUES('explore','profession',2); +''') +sql=(root/'sql/custom/world/2026_10_27_05_adventure_profession.sql').read_text() +sql=re.sub(r'\) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;',');',sql).replace('INSERT IGNORE','INSERT OR IGNORE') +db.executescript(sql) +roles=set(db.execute('SELECT gameobject_entry,role FROM vcore_adventure_workstation')) +assert {(1,'anvil'),(2,'forge'),(3,'cooking'),(4,'alchemy'),(5,'inscription'),(6,'anvil'),(7,'forge'),(11,'sewing'),(12,'anvil'),(13,'forge')}<=roles +assert not any(entry in (8,9,10) for entry,role in roles) +assert (1,'forge') not in roles and (2,'anvil') not in roles +assert db.execute("SELECT kind,requirement FROM vcore_adventure_action WHERE action_key='profession_search'").fetchone()==('outing','work_search') +assert db.execute("SELECT weight FROM vcore_adventure_action_intent WHERE action_key='explore'").fetchone()==(0,) +db.executescript("""UPDATE vcore_adventure_workstation SET enabled=0 WHERE gameobject_entry=1; + UPDATE vcore_adventure_action_intent SET weight=9.5 WHERE action_key='profession_search'; + UPDATE vcore_adventure_location_intent SET weight=7 WHERE category='zone'; + UPDATE vcore_adventure_action_intent SET weight=4.5 WHERE action_key='explore';""") +before=list(db.iterdump());db.executescript(sql);assert list(db.iterdump())==before +print('Profession catalog: native workstation roles, no workshop/resource inference, idempotence and preserved edits passed.') diff --git a/tests/clientless/test_adventure_profession_profile.py b/tests/clientless/test_adventure_profession_profile.py new file mode 100644 index 0000000000..a07b6afd92 --- /dev/null +++ b/tests/clientless/test_adventure_profession_profile.py @@ -0,0 +1,54 @@ +"""Execute the production profession parser with real Lua and saved profiles.""" +from pathlib import Path +import subprocess +import tempfile +root = Path(__file__).resolve().parents[2] +source = (root/'src/server/game/World/ClientlessLuaEngine.cpp').read_text() +a = source.index(' lua_getfield(_state,rootTable,"professions");') +b = source.index(' uint32 const guid', a) +code = r''' +#include +#include "AdventureProfessionPolicy.h" +#include +#define TC_LOG_WARN(...) ((void)0) +struct Profile { AdventureProfessionPolicy::Professions PrimaryProfessions; }; +Profile parse(lua_State* _state) { + Profile profile;int rootTable=lua_gettop(_state); +'''+source[a:b]+r''' + assert(lua_gettop(_state)==rootTable);return profile; +} +void check(Profile const& p,bool mining,bool herbs) { + using namespace AdventureProfessionPolicy; + assert(Allowed("work_mining",p.PrimaryProfessions)==mining); + assert(Allowed("ore_inspect",p.PrimaryProfessions)==mining); + assert(Allowed("work_smelting",p.PrimaryProfessions)==mining); + assert(Allowed("work_blacksmithing",p.PrimaryProfessions)==mining); + assert(Allowed("work_herbalism",p.PrimaryProfessions)==herbs); + assert(Allowed("work_alchemy",p.PrimaryProfessions)==herbs); + for(auto kind:{"work_first_aid","work_cooking","work_wood","profession_tools"})assert(Allowed(kind,p.PrimaryProfessions)); + assert(SkillAllowed(356,p.PrimaryProfessions));assert(!SkillAllowed(99999,p.PrimaryProfessions)); +} +int main(int argc,char** argv) { + lua_State* L=luaL_newstate();luaL_openlibs(L); + assert(luaL_dofile(L,argv[1])==LUA_OK);check(parse(L),true,false);lua_settop(L,0); + assert(luaL_dofile(L,argv[2])==LUA_OK);check(parse(L),false,false);lua_settop(L,0); + for(auto text:{"return {}","return {professions={primary={}}}", + "return {professions={primary={'Mining','unknown',17,false,{}}}}", + "return {professions={primary='mining'}}","return {professions=false}"}) { + assert(luaL_dostring(L,text)==LUA_OK);check(parse(L),false,false);lua_settop(L,0); + } + assert(luaL_dostring(L,"return {professions={primary={'herbalism','alchemy','alchemy'}}}")==LUA_OK); + auto herbs=parse(L);check(herbs,false,true);assert(herbs.PrimaryProfessions.size()==2); + using namespace AdventureProfessionPolicy; + assert(Legacy("ore")=="work_mining"&&Legacy("forge")=="work_blacksmithing"&&Legacy("unknown").empty()); + assert(!Allowed(Legacy("ore"),herbs.PrimaryProfessions)); + lua_close(L); +} +''' +with tempfile.TemporaryDirectory(prefix='vcore-profession-profile-') as folder: + cpp=Path(folder)/'test.cpp'; binary=Path(folder)/'test';cpp.write_text(code) + flags=subprocess.check_output(['pkg-config','--cflags','--libs','lua5.4'],text=True).split() + subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary),*flags],check=True) + profiles=root/'src/server/scripts/Custom/clientless/adventure/profiles' + subprocess.run([str(binary),str(profiles/'arthoran.lua'),str(profiles/'generic.lua')],check=True) +print('Production profession parser, real Lua profiles, primary restrictions and secondary permissions passed.') diff --git a/tests/clientless/test_adventure_profession_sightline.py b/tests/clientless/test_adventure_profession_sightline.py new file mode 100644 index 0000000000..912684879c --- /dev/null +++ b/tests/clientless/test_adventure_profession_sightline.py @@ -0,0 +1,13 @@ +"""Native workstation sightlines use the working surface and still reject blocked geometry.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureSocialActions.cpp').read_text() +a=s.index(' bool TargetVisible(');b=s.index(' std::vector < Unit * > People',a) +head='#include "AdventureProfessionPolicy.h"\n#include "AdventureEconomyPolicy.h"\n#include \nconstexpr int LINEOFSIGHT_ALL_CHECKS=1;\nnamespace VMAP {enum class ModelIgnoreFlags{Nothing};}\nstruct GameObject{};\nstruct WorldObject {bool object=false;float height=0;GameObject* ToGameObject(){return object?reinterpret_cast(this):nullptr;}float GetPositionX(){return 0;}float GetPositionY(){return 0;}float GetPositionZ(){return 95;}float GetCollisionHeight(){return height;}};\nstruct Map{float endpoint=0;bool visible=true;bool isInLineOfSight(int,float,float,float,float,float,float z,int,VMAP::ModelIgnoreFlags){endpoint=z;return visible;}}map;\nstruct Player:WorldObject{Map* GetMap(){return ↦}int GetPhaseShift(){return 0;}bool IsWithinLOSInMap(WorldObject*){return false;}};\n' +main='int main(){Player p;WorldObject target;target.object=true;\nassert(TargetVisible(&p,&target,"work_blacksmithing")&&map.endpoint==96.5f);\nmap.visible=false;assert(!TargetVisible(&p,&target,"work_blacksmithing"));\nmap.visible=true;target.object=false;target.height=2;\nassert(TargetVisible(&p,&target,"profession_trainer")&&map.endpoint==97);\nassert(!TargetVisible(&p,&target,"inspect"));\n}' +with tempfile.TemporaryDirectory(prefix='vcore-profession-los-') as folder: + cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(head+s[a:b]+main) + subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True) +print('Native workstation working-surface sightline and blocked geometry rejection passed.') diff --git a/tests/clientless/test_adventure_profession_targets.py b/tests/clientless/test_adventure_profession_targets.py new file mode 100644 index 0000000000..290f06884e --- /dev/null +++ b/tests/clientless/test_adventure_profession_targets.py @@ -0,0 +1,78 @@ +"""Run native profession classification with deterministic world-data doubles.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureProfessionTargets.cpp').read_text() +code=r''' +#include "AdventureProfessionPolicy.h" +#include +#include +#include +#include +constexpr int CORPSE=1,CREATURE_TYPE_BEAST=1,UNIT_FIELD_FLAGS=0,UNIT_FLAG_SKINNABLE=1; +constexpr int GAMEOBJECT_TYPE_CHEST=3,GAMEOBJECT_TYPE_SPELL_FOCUS=8,GO_READY=0,LOCK_KEY_SKILL=2,MAX_LOCK_CASE=2; +using LockType=unsigned;unsigned SkillByLockType(unsigned type){return type==1?186:type==2?182:0;} +struct Creature;struct GameObject; +struct Guid{std::string ToString(){return "source";}bool IsEmpty(){return true;}}; +struct WorldObject{virtual ~WorldObject()=default;bool world=true;int map=0;float z=0;bool IsInWorld(){return world;}int GetMapId(){return map;}float GetPositionZ(){return z;}virtual Creature* ToCreature(){return nullptr;}virtual GameObject* ToGameObject(){return nullptr;}Guid GetGUID(){return {};}}; +struct Creature:WorldObject{ + bool alive=true,combat=false,skin=false,trainer=false,vendor=false;int death=CORPSE,type=CREATURE_TYPE_BEAST; + Creature* ToCreature()override{return this;}bool IsTrainer(){return trainer;}bool IsAlive(){return alive;}int getDeathState(){return death;}int GetCreatureType(){return type;}bool HasFlag(int,int){return skin;}bool IsInCombat(){return combat;}bool IsVendor(){return vendor;}Guid GetCharmerOrOwnerGUID(){return {};}int GetEntry(){return 1;} +}; +struct GameObject:WorldObject{ + int type=GAMEOBJECT_TYPE_CHEST,loot=GO_READY,entry=1;bool spawned=true; + struct Info{unsigned lock=1;unsigned GetLockId(){return lock;}}info; + GameObject* ToGameObject()override{return this;}bool isSpawned(){return spawned;}int GetGoType(){return type;}int getLootState(){return loot;}Info* GetGOInfo(){return &info;}int GetEntry(){return entry;} +}; +struct Position{float distance=0,z=0;float GetExactDist(WorldObject*)const{return distance;}float GetPositionZ()const{return z;}}; +struct AdventureSocialContext{std::string Intent="profession";AdventureProfessionPolicy::Professions PrimaryProfessions{"mining","blacksmithing"};int MapId=0;Position Anchor;std::deque TargetHistory;}; +std::vector creatures;std::vector objects; +struct Player{bool phase=true,visible=true,los=true,friendly=true;bool IsInPhase(WorldObject*){return phase;}bool CanSeeOrDetect(WorldObject*){return visible;}bool IsWithinLOSInMap(WorldObject*){return los;}bool IsFriendlyTo(Creature*){return friendly;}void GetCreatureListWithEntryInGrid(std::vector& v,int,int){v=creatures;}void GetGameObjectListWithEntryInGrid(std::vector&v,int,int){v=objects;}}; +namespace Trainer{enum class Type{Tradeskill,Other};struct Spell{unsigned ReqSkillLine=0,SpellId=1;};struct Data{Type type=Type::Tradeskill;std::vector spells;Type GetTrainerType(){return type;}auto const& GetSpells(){return spells;}};} +struct ObjectMgr{Trainer::Data trainer;int GetCreatureDefaultTrainer(int){return 1;}auto GetTrainer(int){return &trainer;}}objectMgr;auto sObjectMgr=&objectMgr; +struct Ability{unsigned SkillLine=0;}ability; +struct SpellMgr{std::vector> entries;auto GetSkillLineAbilityMapBounds(unsigned){return std::make_pair(entries.begin(),entries.end());}}spellMgr;auto sSpellMgr=&spellMgr; +struct Lock{unsigned Type[2]={LOCK_KEY_SKILL,0},Index[2]={1,0};}lockEntry; +struct LockStore{Lock* LookupEntry(unsigned id){return id?&lockEntry:nullptr;}}sLockStore; +struct Locations{std::string station="anvil";bool enabled=true;bool IsWorkstation(int,std::string const& role){return enabled&&role==station;}}locations;auto sAdventureLocationMgr=&locations; +struct AdventureProfessionTargets{static bool Valid(Player*,WorldObject*,std::string const&,AdventureSocialContext const&);static std::vector Find(Player*,std::string const&,AdventureSocialContext&);}; +'''+s[s.index('namespace\n{'):]+r''' +int main(){ + Player p;GameObject go;AdventureSocialContext c;objects={&go}; + auto valid=[&](char const* kind){return AdventureProfessionTargets::Valid(&p,&go,kind,c);}; + assert(valid("work_mining"));assert(!valid("work_herbalism")); + lockEntry.Index[0]=2;assert(!valid("work_mining")); + c.PrimaryProfessions={"herbalism","alchemy"};assert(valid("work_herbalism"));assert(!valid("work_blacksmithing")); + c.PrimaryProfessions={"mining","blacksmithing"};lockEntry.Index[0]=1; + p.phase=false;assert(!valid("work_mining"));p.phase=true; + p.visible=false;assert(!valid("work_mining"));p.visible=true; + go.spawned=false;assert(!valid("work_mining"));go.spawned=true; + go.loot=1;assert(!valid("work_mining"));go.loot=0; + go.info.lock=0;assert(!valid("work_mining"));go.info.lock=1; + c.Anchor.distance=201;assert(!valid("work_mining"));c.Anchor.distance=0; + go.type=GAMEOBJECT_TYPE_SPELL_FOCUS;p.visible=false; + assert(valid("work_blacksmithing"));assert(!valid("work_smelting")); + locations.station="forge";assert(valid("work_smelting"));assert(!valid("work_blacksmithing")); + locations.enabled=false;assert(!valid("work_smelting"));locations.enabled=true; + go.type=GAMEOBJECT_TYPE_CHEST;p.visible=true; + assert(AdventureProfessionTargets::Find(&p,"work_mining",c).size()==1); + c.TargetHistory.push_back("work:source");assert(AdventureProfessionTargets::Find(&p,"work_mining",c).empty()); + Creature npc;creatures={&npc};c.PrimaryProfessions={"skinning"}; + assert(!AdventureProfessionTargets::Valid(&p,&npc,"work_skinning",c));npc.alive=false;npc.skin=true; + assert(AdventureProfessionTargets::Valid(&p,&npc,"work_skinning",c));npc.type=2; + assert(!AdventureProfessionTargets::Valid(&p,&npc,"work_skinning",c));npc.type=1;npc.death=2; + assert(!AdventureProfessionTargets::Valid(&p,&npc,"work_skinning",c)); + npc.alive=true;npc.trainer=true;objectMgr.trainer.spells={{186,1}};c.PrimaryProfessions={"mining"}; + assert(AdventureProfessionTargets::Valid(&p,&npc,"profession_trainer",c)); + c.PrimaryProfessions.clear();assert(!AdventureProfessionTargets::Valid(&p,&npc,"profession_trainer",c)); + objectMgr.trainer.spells={{185,1}};assert(AdventureProfessionTargets::Valid(&p,&npc,"profession_trainer",c)); + objectMgr.trainer.spells={{0,1}};spellMgr.entries={{1,&ability}};ability.SkillLine=182; + assert(!AdventureProfessionTargets::Valid(&p,&npc,"profession_trainer",c));c.PrimaryProfessions={"herbalism"}; + assert(AdventureProfessionTargets::Valid(&p,&npc,"profession_trainer",c)); +} +''' +with tempfile.TemporaryDirectory(prefix='vcore-profession-targets-') as folder: + cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code) + subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True) +print('Production profession targets: native locks, phase/spawn/loot, stations, corpses, history and trainers passed.') diff --git a/tests/clientless/test_adventure_social.py b/tests/clientless/test_adventure_social.py index d1829f6d16..ac02288b27 100644 --- a/tests/clientless/test_adventure_social.py +++ b/tests/clientless/test_adventure_social.py @@ -59,7 +59,7 @@ struct Player:Unit{ bool IsBeingTeleported(){return false;}bool IsSitState(){return sit;}bool IsNonMeleeSpellCast(bool){return casting;} float GetExactDist(Position const&){return distance;}float GetExactDist(WorldObject*){return distance;}void SetFacingToObject(WorldObject*){++faces;} std::vector headings;void SetFacingTo(float f){heading=f;headings.push_back(f);}int chairStand=5;uint32 GetStandState(){return sit?chairStand:0;} - void SetSelection(ObjectGuid g){selection=g;}void SetUInt32Value(int,int){} + std::vector states;void SetSelection(ObjectGuid g){selection=g;}void SetUInt32Value(int,int state){states.push_back(state);} void HandleEmoteCommand(int id){emotes.push_back(id);}void SendPlaySpellVisualKit(int,int,int){} void SetStandState(int){sit=true;}int CastSpell(Player*,int,bool){++casts;casting=true;return SPELL_CAST_OK;} bool IsWithinLOSInMap(WorldObject*){return los;}bool IsWithinLOS(float,float,float){return los;}bool GetGameObjectIfCanInteractWith(ObjectGuid){return true;}Session* GetSession(){return &session;} @@ -191,6 +191,27 @@ int main(){ active=true;for(int i=0;i<60&&active;++i) active=AdventureSocialActions::Update(&p,s,1000,c); assert(!active&&p.headings.size()==2&&std::fabs(p.headings[0]-0.45f)<0.001&&std::fabs(p.headings[1]-1.95f)<0.001); + // Profession gestures wait for arrival and never invoke use/cast handlers. + c.Intent="profession";c.PrimaryProfessions={"mining","blacksmithing"}; + target=&chair;p.distance=2;p.visible=p.los=true;p.states.clear(); + s={};s.Kind="work_mining";s.TargetKey="2";s.Target.id=2;s.Approaching=true; + s.Sequence.Start(Steps(s.Kind,20000));travel.status.State=ClientlessTravelState::Moving; + oldUses=p.session.uses;oldCasts=p.casts; + assert(AdventureSocialActions::Update(&p,s,1000,c)&&p.states.empty()); + travel.status.State=ClientlessTravelState::Arrived; + active=true;for(int i=0;i<60&&active;++i) active=AdventureSocialActions::Update(&p,s,1000,c); + assert(!active&&p.session.uses==oldUses&&p.casts==oldCasts); + assert(std::find(p.states.begin(),p.states.end(),EMOTE_STATE_WORK_MINING)!=p.states.end()); + assert(std::find(c.TargetHistory.begin(),c.TargetHistory.end(),"work:2")!=c.TargetHistory.end()); + // Losing profile authorization stops a scene before the next cue. + s={};s.Kind="work_mining";s.Target.id=2;s.Sequence.Start(Steps(s.Kind,20000)); + p.states.clear();c.PrimaryProfessions.clear(); + assert(!AdventureSocialActions::Update(&p,s,1000,c)&&p.states.empty()); + c.PrimaryProfessions={"mining"}; + // Displacement while working must reapproach without advancing the cue. + p.distance=8;s.Approaching=false;travel.status.State=ClientlessTravelState::Moving; + assert(AdventureSocialActions::Update(&p,s,1000,c)&&s.Approaching&&p.states.empty()); + } ''' with tempfile.TemporaryDirectory(prefix='vcore-social-test-') as folder: