From e18a416bf44535bb0d59b4930710ff2c40ab303f Mon Sep 17 00:00:00 2001 From: nico Date: Tue, 27 Oct 2026 20:10:13 +0100 Subject: [PATCH] Add persistent instance visits and validate sleeping spaces --- doc/clientless/SESSION-RESUME.md | 199 ++++++++ doc/clientless/instance-intent.md | 212 ++++++++ doc/clientless/intent-katalog.md | 6 +- doc/clientless/mikroaktions-katalog.md | 8 +- doc/clientless/rest-intent.md | 101 +++- ...2026_10_27_00_adventure_instance_visit.sql | 16 + .../2026_10_27_07_adventure_instances.sql | 30 ++ .../game/World/AdventureBehaviorMgr.cpp | 461 +++++++++++++++++- src/server/game/World/AdventureBehaviorMgr.h | 10 + .../game/World/AdventureInstancePolicy.h | 28 ++ src/server/game/World/AdventureRestSpace.h | 67 +++ .../scripts/Custom/viano_bot_commands.cpp | 17 + tests/clientless/test_adventure_instance.py | 122 +++++ .../test_adventure_instance_autoresume.py | 34 ++ .../test_adventure_instance_begin.py | 42 ++ .../test_adventure_instance_catalog.py | 39 ++ .../test_adventure_instance_persistence.py | 91 ++++ .../clientless/test_adventure_intent_state.py | 2 + tests/clientless/test_adventure_leisure.py | 5 +- tests/clientless/test_adventure_rest.py | 43 +- tests/clientless/test_adventure_rest_space.py | 51 ++ .../clientless/test_adventure_rest_targets.py | 88 ++++ 22 files changed, 1631 insertions(+), 41 deletions(-) create mode 100644 doc/clientless/instance-intent.md create mode 100644 sql/custom/characters/2026_10_27_00_adventure_instance_visit.sql create mode 100644 sql/custom/world/2026_10_27_07_adventure_instances.sql create mode 100644 src/server/game/World/AdventureInstancePolicy.h create mode 100644 src/server/game/World/AdventureRestSpace.h create mode 100644 tests/clientless/test_adventure_instance.py create mode 100644 tests/clientless/test_adventure_instance_autoresume.py create mode 100644 tests/clientless/test_adventure_instance_begin.py create mode 100644 tests/clientless/test_adventure_instance_catalog.py create mode 100644 tests/clientless/test_adventure_instance_persistence.py create mode 100644 tests/clientless/test_adventure_rest_space.py create mode 100644 tests/clientless/test_adventure_rest_targets.py diff --git a/doc/clientless/SESSION-RESUME.md b/doc/clientless/SESSION-RESUME.md index 348268f5cc..3af2b76619 100644 --- a/doc/clientless/SESSION-RESUME.md +++ b/doc/clientless/SESSION-RESUME.md @@ -1,3 +1,202 @@ +# Aktuell: Instanzintent implementiert und technisch live geprüft + +Stand 27.10.2026, letzter Testserverprozess PID **35865**, frisches Ready beim +letzten Restart und Port 8085 geprüft. Nutzer beauftragte Umsetzung einschließlich +zufälliger Verletzung (0–50 Prozent Max-HP) nach Rückkehr. Umsetzung fertig, +Der Nutzer hat ausdrücklich auf eine weitere visuelle Abnahme verzichtet. +Alle vorhandenen Änderungen sollen gemeinsam lokal committet werden; kein Push. +Details: `instance-intent.md`. + +Implementierung: +- Intent `instance`, eigener Tagesplanwert (Default .5), feste Phasen statt + zufälliger Innenraum-Mikroaktionen. Anreise verbraucht Blockzeit; zehn Sekunden + Vorbereitung; tatsächlicher nativer Eintritt; Warten direkt dahinter; Rückkehr + zu Blockende minus fünf Minuten; Nachruhe bis zum ursprünglichen Blockende. +- Neue native Außenwelt-Aliase `instance_visit_at_` und editierbare + Ein-/Ausgangszuordnung. 108 Eingänge in Test-DB, insgesamt 2.973 Locations / + 3.966 Worldpoints. Innenräume bleiben reine native Karten/Graphknoten. +- Charakterpersistenz enthält Phase, Trigger, Instanz-ID, absolute Frist, + einmal gezogenen Verletzungswert und injury_applied. Gemeinsame Transaktion + mit Player::SaveToDB über ASYNC-Worker, bestätigter Commit vor Fortschritt. +- Optionales WorldScript in viano_bot_commands.cpp meldet nur gespeicherte + Besucher beim Serverstart automatisch mit dem Adventure-Controller an. + Wiederaufnahme hat Vorrang vor normalem Lua-Initialintent. +- Getrennten Hinterausgang aus nativen Daten bevorzugen, expliziter Override + möglich. Verborgene Dungeon-Durchquerung wird innen bis zum echten Ausgang + abstrahiert, danach nativer Trigger. Fehlender Ausgang: Eingang. Keine Kämpfe, + Beute/Inventar-/Geldänderung, keine DungeonBot-Abhängigkeit. + +Geprüft: +- Debug-Build -j10, installiert, **44 Testskripte im vollständigen abschließenden + Lauf bestanden** (/tmp/vcore-instance-tests-final.log). +- Beide additiven Migrationen angewendet. Tabellen-Collation unicode_ci passt + zum bestehenden Schema; Dateien wegen *.sql-Ignore gezielt mit git add -f + vorgemerkt, alle übrigen Änderungen uncommittet. Kein Push. +- Verlies-Eingang 101, Ausgang 503, Karte34/Instanz-ID1 tatsächlich betreten. + Echter Serverneustart während Phase3: automatischer Login, gleiche Instanz-ID1, + ursprüngliche absolute Frist beibehalten. Normales Lua-Profil war restauriert. +- Für schnelle Rückkehrprüfung wurde ausschließlich nach sauberem Serverstopp + die Test-Besuchsfrist verkürzt. Original in + /tmp/vcore-instance-live-original-deadline.tsv. Keine normale Codefrist geändert. +- Native Rückkehr nach Karte0 (-8766.11,845.499,87.9952), zunächst zweimal echte + 0%-Würfe, nächster regulärer kurzer Testblock 17%: **2165/2609 HP**. + Charakter-DB bestätigte Phase5, loss_percent17, injury_applied1 und HP2165. +- Erneuter Logout/Login während Phase5: gleiche Frist, injuryApplied1, kein + Neuwurf/keine zweite Verletzung. Aktuell Nachruhe vor dem Verlies bis + Deadline1793128202; anschließend endet der manuelle Testauftrag. Zustand bei + Wiederaufnahme neu prüfen; keinen weiteren Test automatisch starten. +- Normales Profil in Quelle UND Installation bytegleich normal (SHA256 + 26b11c3108e6ecc5dbc59d1852b4103ca4e818148603a34c02a470c969dc659b). + Kein temporärer Lua-Override. Nach saveall Geld-/Inventar-SHA256 unverändert + 8d40931782d05219466ba9fd077ce7acd3e02bd53eb63f6e96115042c620d26a. + +Wichtig: Fernanreise NICHT live erfolgreich verifiziert. Vorhandene Graph- +Recovery-Schleifen führten erst nach Goldhain, dann durch Sturmwind. Nutzer hat +Pfadfindung/Hinderniserkennung ausdrücklich auf später verschoben. Daher wurde +für den isolierten Instanztest nur der ausgeloggte Testbot administrativ an den +realen Außeneingang gestellt; anschließender lokaler MMAP-Anschluss und Eintritt +waren regulär. Kein Instanzteleport als erfolgreicher Eintritt ausgegeben. +Todesminen-Hinterausgang und zwei unabhängig gleichzeitig betretene Kopien nur +produktive Adaptertests/Quellprüfung, noch kein entsprechender Livevergleich. + +Erster Liveversuch fand meinen falschen SYNCH-Transaktionsweg; Debugserver +beendete sich vor der Anreise. Fehler korrigiert auf AsyncCommitTransaction mit +Commitresultat. Nachfolgende native Saves/Neustarts erfolgreich, Fehlerlog +/tmp/vcore-instance-first-live-DBErrors.log erhalten. Nicht als aktuelles Problem +missverstehen. Aktuell keine neuen Instanz-SQL-/Startfehler beobachtet. + +Alle Schlaf-WIP-Änderungen von vorher erhalten. Bett-/Hinderniserkennung bleibt +auf Nutzerwunsch zurückgestellt. Nur feature/clientless-player-core verwenden. + +--- + +# Aktueller Auftrag: Instanzbesuche – Nutzervorgaben konkretisiert + +Wirtschaftstest vom Nutzer als ausreichend beendet; nächster Intent sind +Instanzbesuche. Details verbindlich in `instance-intent.md` festgehalten. +Rest des gewählten Location-Zeitblocks, letzte fünf Minuten Nachruhe draußen. +Beispiel 60 Minuten: 40 Anreise, 15 innen, 5 Nachruhe. Tatsächlicher Eintritt, +Warten direkt hinter dem Eingang in nativer eigener Instanzkopie, keine +Dungeon-Durchquerung/Kampf. Neustart: am Eingang innen wieder einloggen, +ursprüngliche absolute Frist weiterverwenden. Rückkehr eigener Ausgang, +sonst Eingang. Instanzinnenräume NICHT zu normalen Locations machen. + +World-DB geprüft: areatrigger_teleport enthält 78 Deadmines Entrance Target, +119 Exit Front Target, 121 Rear Exit Target. Alle sechs source/target-Knoten +in vcore_clientless_travel_node vorhanden, Kommentare nur Triggernummern. +Semantik aus nativen Namen und DBC-Quell-/DB-Zielkarten ableiten. + +Noch keine Implementierung/Instanzbetretung in dieser Vorprüfung. Core-Befund: +Online startet heute flüchtigen Behavior-Record/Profil neu; dauerhafte +Besuchsphase/Frist und Wiederaufnahme müssen ergänzt werden. Native Instanz-ID, +Solobindung und Instanzspeicherung für Restart berücksichtigen und testen. +Eigener Ausgang hinten erfordert Rückkehrbehandlung ohne Dungeon-Durchquerung. +Randfall Ankunft mit weniger als fünf Minuten: noch keine ausdrückliche +Nutzerentscheidung, sinnvolle Ableitung ist Eintritt auslassen/Nachruhe. + +Keine Code-/DB-/Serveränderung bei dieser Vorprüfung. Vorheriger Wirtschaftsintent +kann auf dem Testserver weiterhin laufen; vor Implementierung Zustand prüfen. +Schlaf-/Hindernisthemen bleiben ausdrücklich vertagt. Vorhandenes WIP bewahren. + +--- + +# Aktueller Nutzerauftrag: freier Wirtschaftsintent im Handelsdistrikt + +Bett-/Geometriesuche ausdrücklich vom Nutzer zurückgestellt. Ebenso spätere +Überarbeitung der Pfadfindung/Hinderniserkennung (beobachtetes Durchlaufen von +Zäunen und Erhöhungen). Diese offenen Themen sollen die weitere Entwicklung +NICHT blockieren; jetzt daran nicht weiterarbeiten. + +Arthoran GUID 26 hat auf Nutzerwunsch den autonomen Auftrag: +`clientless adventure intent 26 economy poi_tele_954 3600`. +`poi_tele_954` ist der vorhandene allgemeine Sturmwind-Anker im Handelsdistrikt +(-8833.07,622.778,93.9317), Kategorie landmark; keine Händler-/Bank-/AH-Rollensperre +wie bei den spezialisierten Testankern. Kein gezielter Mikroaktionszwang. +INTENT_ARRIVED economy und selbst gewählter Start `economy_vendor_visit` +gegen echten Händler Entry 277, GUID Low 1325, sind bestätigt. Szene läuft. +Kein erneuter Build/Restart oder Lua-Profilwechsel für diesen Auftrag. + +Vorher `saveall`; Geld-/Inventar-SHA256 weiterhin +8d40931782d05219466ba9fd077ce7acd3e02bd53eb63f6e96115042c620d26a. +Wirtschaften bleibt rein performativ: keine echten Transaktionen. +Nutzer beobachtet nun die freie Ausführung im Spiel; keine neue visuelle +Abnahme behaupten. Schlafboden am ersten Anker war vom Nutzer bestätigt. + +Alle vorherigen WIP-Änderungen erhalten, Branch feature/clientless-player-core, +kein Push. Normales Lua-Profil weiterhin unverändert. Schlaf-Testanker und +bisheriger Ausbau bleiben erhalten, siehe historische Notizen unten. + +--- + +# Neuer Liveauftrag: Suche bei Allitesters Position + +Nach dem Flächenausbau wollte der Nutzer eine erneute Suche nahe den beiden +Betten an seiner aktuellen Position. `saveall`, danach Allitester GUID 21: +Map 0, (-8877.360, 673.489, 104.950), O=0.649831. Separater aktiver manueller +Suchanker `gilded_rose_sleep_room_test` in Test-DB angelegt, Kategorie rest_sleep, +Metadata von Sturmwind; bestehende Punkte nicht verschoben. + +Neustart war nötig: adventure reload erneuert NICHT den Reisepunkt-Cache, +weshalb der erste Anreiseversuch den neuen Punkt nicht fand und zurückfiel. +Server jetzt PID 32004, frisches Ready nach elf Sekunden, Port 8085 geprüft. +Einmaliges Loginprofil rest/room_test danach bytegleich restauriert, installierte +Profilkopie ebenfalls normal. Arthoran erreichte den neuen Anker regulär. + +Erneute Suche dort: beds=0 raised=0 floor=46 (erster Lauf floor=34). +REST_CONFIRMED am Anker (-8877.360,673.489,104.950), O=.650, zuletzt 143 Sekunden. +Die sichtbaren Betten werden also weiterhin NICHT als Bett/erhöhte Fläche erkannt; +Ursache der konkreten geometrischen Ablehnung ist NICHT gemessen. Nicht behaupten, +dass nur der bisher zu kleine Radius schuld war. + +Die gewichtete Auswahl wählte zwischenzeitlich wieder den alten Schlafanker. +Für den gezielten Versuch wurde NUR `gilded_rose_sleep_upstairs` kurz enabled=0 +gesetzt, Katalog geladen, micro rest_sleep ausgeführt, dann enabled=1 restauriert +und Katalog erneut geladen. Kein Punkt blieb vorübergehend deaktiviert. +DB-Helfer /tmp/vcore-sleep-room-test.py, Positionssnapshot daneben als JSON. +Nutzer hat neuen Bodenplatz am alten Anker sichtbar bestätigt; Bettdarstellung +und tatsächliche Bettnutzung weiterhin offen. Kein neuer Code/Build in diesem +Folgeversuch. Vor weiterer Arbeit laufende Szene/Position neu prüfen. + +--- + +# Aktuell: Schlafplatzsuche am 27.10.2026 + +Dieser Abschnitt hat Vorrang vor den historischen Checkpoints unten. +Der Nutzer nahm den Schlaftest wieder auf, sah Arthoran mitten im oberen Stuhl +und beauftragte eine Flächensuche: Betten/geeignete erhöhte Flächen bevorzugen, +sonst freier Gasthausboden, auch für Gasthäuser ohne Betten. + +Implementiert, gebaut (`-j10`), installiert und live getestet, noch uncommittet: +`AdventureRestSpace.h`, Schlafadapter in `AdventureBehaviorMgr.cpp`, gespeichertes +Szenenziel in `.h`; zwei neue Regressionen und erweiterter Ruhetest. Insgesamt +39 Testskripte erfolgreich (38er-Lauf plus neuer nativer Adaptertest; danach +betroffene erweiterte Tests erneut erfolgreich). Details: `rest-intent.md`. + +Schlafanker sind jetzt Suchbereiche, keine ungeprüften Liegepunkte. Native +Bettobjekte vor erhöhten Flächen mit abrupten Seitenkanten vor freiem Boden. +Geometrie ist keine sichere Bettsemantik; passende Tische können ähnlich sein. +Modellabhängige Liegefläche, Höhen-/Kollisionsraster, native Stuhlsperre, +Spieler/Kreaturenbelegung, Innenraum und Stockwerksgrenze. Tatsächlicher +MMAP-Endpunkt muss passen, kein Teleport/Klettern auf unerreichbare Möbel. +Erneute Prüfung vor Hinlegen und alle zwei Sekunden. Keine neue SQL-Migration. + +Live: Prozess 31446, frisches Ready nach elf Sekunden, Port 8085 bestätigt. +Arthoran GUID 26 im Ruheauftrag `gilded_rose_sleep_upstairs`. Die neue Suche +fand `beds=0 raised=0 floor=18`; er ging 4,03 Yards zu +(-8868.534, 682.800, 104.949), Ausrichtung 3,740, Endpunktfehler null. +`REST_CONFIRMED` für 243 Sekunden. Die sichtbare Abnahme des neuen Bodenplatzes +ist offen, ebenso ein positiver Live-Betttest; Fixturetests decken Betten ab. +Normales Lua-Profil in Quelle UND Installation bytegleich wiederhergestellt, +mining/blacksmithing unverändert. Kein temporäres Override, kein Push. + +Die zuvor bereits vorhandene Änderung an `rest-intent.md` zur ersten +Schlafvorführung wurde erhalten und ergänzt. Branch ausschließlich +`feature/clientless-player-core`. Build/Install/Testlogs: `/tmp/vcore-sleep-*.log`. +Prozess-/Szenenzustand beim Fortsetzen neu prüfen. Nicht automatisch den +abgeschlossenen Berufs-Ausbau oder die vertagte Instanzarbeit beginnen. + +--- + # Fortsetzung am 27.10.2026 – aktueller Stand hat Vorrang Der unten erhaltene Sitzungswechsel-Checkpoint ist historisch. Der Berufs-Ausbau diff --git a/doc/clientless/instance-intent.md b/doc/clientless/instance-intent.md new file mode 100644 index 0000000000..eb1bc6676f --- /dev/null +++ b/doc/clientless/instance-intent.md @@ -0,0 +1,212 @@ +# Instanzbesuche + +Stand: 27.10.2026. Verbindliche Nutzervorgaben und Umsetzung des Intents `instance`. Eigenständiger AdventureBot-Intent, +getrennt vom offenen Abenteuer und von DungeonBots. Keine Kampf-KI nötig. + +## Zeitmodell + +Die Besuchsdauer ergibt sich aus dem verbleibenden Zeitblock, für den der +Instanzeingang als Location gewählt wurde. Es gibt keine zusätzliche volle +Aufenthaltsdauer ab Ankunft. Anreise und Vorbereitung verbrauchen Blockzeit. +Die letzten fünf Minuten sind für Rückkehr, scheinbare Orientierung und Rast +nach dem Besuch reserviert. + +Nutzerbeispiel: ein Stundenblock, 40 Minuten Anreise, 15 Minuten Aufenthalt +in der Instanz, anschließend fünf Minuten Orientierung/Rast draußen. +Maßgeblich bleiben absolute Zeitpunkte: Blockende und Beginn der Nachruhe +(Blockende minus 300 Sekunden). Serverausfall/Offlinezeit verlängern den Block +nicht; ein Neustart startet den Besuch nicht erneut. + +Noch technisch auszugestaltender Randfall: Ist bei Ankunft bereits die +Nachruhephase erreicht, darf kein negativer oder künstlich verlängerter +Instanzaufenthalt entstehen. Naheliegende Umsetzung: Eintritt auslassen und +verbleibende Zeit zur Nachruhe nutzen. Das ist eine Implementierungsableitung, +keine zusätzliche ausdrücklich beantwortete Nutzerentscheidung. + +## Sichtbarer und verborgener Ablauf + +Reguläre Anreise zum Eingang, kurze passende Vorbereitung, tatsächlicher +bestätigter Eintritt über den vorhandenen Übergang. Fehlgeschlagener Eintritt +startet keine verborgene Aufenthaltsphase. + +Arthoran wartet direkt hinter dem Eingang in seiner nativen Instanzkopie. +Er durchläuft den Dungeon nicht, kämpft nicht und simuliert keine Beute oder +Erfolge. Unabhängige Spieler/Gruppen sollen ihn beim eigenen Eintritt nicht +sehen. Native Instanzzuweisung und vorhandene Bindungen beachten; bloß die +Karten-ID zu vergleichen genügt nicht, die Instanz-ID ist ebenfalls relevant. +Keine gemeinsame Bot-Gruppe oder sonstige geteilte Bindung für diesen Solobesuch +neu erzeugen. Bestehende DungeonBots bleiben unabhängig. + +Beim Neustart loggt er am gespeicherten Instanzstandort wieder ein und wartet +nur bis zum ursprünglichen Rückkehrzeitpunkt. Dazu müssen Besuchsphase, +Blockende, Instanz-/Eingangs-/Ausgangszuordnung erhalten bleiben; ist der +Zeitpunkt inzwischen verstrichen, folgt die Rückkehr ohne neue Wartezeit. + +## Orte und Rückkehr + +Die Instanzinnenräume werden keine normalen Aufenthaltslocations. +Die vorhandenen Eingangs-/Ausgangsknoten des TravelGraph liefern die +Übergänge; der Außenwelteingang ist das wählbare Reiseziel. + +Hat die Instanz einen eigenen Ausgang, ergibt sich die Rückkehrposition aus +diesem Ausgang. Fehlt ein gesonderter Ausgang, erfolgt die Rückkehr am Eingang. +Kein willkürlicher Stadt-/Heimatteleport. Ein entfernter Hinterausgang benötigt +bei abstrahiertem Aufenthalt eine ausdrückliche technische Rückkehrbehandlung; +eine vollständige MMAP-Durchquerung des Dungeons ist nicht Teil des Auftrags. + +## Bestätigte Daten und bestehender Core + +Die Test-World-DB `areatrigger_teleport` enthält lesbare Bezeichnungen: + +| Trigger | Name | Zielkarte | Zielposition | +|---|---|---|---| +| 78 | Deadmines Entrance Target | 36 | -14.5732, -385.475, 62.4561 | +| 119 | Deadmines Exit Front Target | 0 | -11208, 1676.41, 24.5716 | +| 121 | Deadmines Rear Exit Target | 0 | -11341, 1576.55, 93.7284 | + +Alle sechs Knoten `portal_at_{78,119,121}_{source,target}` sind bereits in +`vcore_clientless_travel_node` vorhanden. Ihre Kommentare enthalten derzeit +nur `Auto-imported AreaTrigger source/target`. Die semantischen Namen +bleiben in der nativen World-Tabelle verfügbar. Quelle der Triggerpositionen +ist zusätzlich `AreaTrigger.dbc`; Quelle/Zielkarte dienen zur belastbaren +Richtungszuordnung. Namen sind Hinweise, keine alleinige Zugangsfreigabe. + +`MapManager::CreateMapForPlayer` verwendet vorhandene Spieler-/Gruppenbindungen +und sonst eine neue Instanz-ID. Bei Login wird eine gespeicherte Instanz-ID +nur mit verfügbarer Karte beziehungsweise passender Instanzspeicherung +wiederhergestellt. `InstanceMap::AddPlayerToMap` erzeugt native Instanzspeicher +und Solobindungen. Neustart und abgelaufene Instanzspeicherung müssen deshalb +auch mit einem echten Wiederanmeldetest geprüft werden. + +Vor diesem Ausbau erzeugte `AdventureBehaviorMgr::Online` einen neuen +flüchtigen Record und startete gegebenenfalls das Lua-Initialintent. Jetzt +hat die dauerhafte Wiederaufnahme des Besuchs davor Vorrang. + +Der Phasen-/Persistenzplan und die gezielten Tests sind im folgenden +Implementierungsabschnitt beschrieben. Der historische Datenbefund allein +belegt keine Gameplay-Abnahme. + + +## Implementierung und Bedienung + +Der Verhaltensmanager führt feste Phasen statt zufälliger Dungeon-Mikroaktionen +aus: Anreise, zehn Sekunden Vorbereitung am echten Eingang, Eintritt, Aufenthalt, +Rückkehr und Nachruhe. Es gibt keine Kampf-/Beute-/Dungeon-Routenlogik. +Eine zu späte Ankunft überspringt den Eintritt und nutzt die verbleibende Zeit +zur Rast. Während eines Aufenthalts wird ein Intentwechsel erst nach der +Rückkehr ausgeführt. Die ursprüngliche Besuchsfrist wird dadurch nicht verlängert. + +`sql/custom/world/2026_10_27_07_adventure_instances.sql` erzeugt aus vorhandenen +nativen Teleporten, Instanzvorlagen und Graphquellknoten Außenwelt-Eingangsorte +`instance_visit_at_` sowie einen editierbaren Zuordnungskatalog +`vcore_adventure_instance`. Auf der Test-DB ergaben sich 108 Eingangsaliase. +Die alten Portalpunkte/Graphknoten bleiben unverändert. Instanzkarten werden +weiterhin nicht als normale Aufenthaltsorte ausgewählt. + +`exit_trigger=0` lässt den Laufzeitadapter einen passenden nativen Ausgang zur +Außenkarte des Eingangs suchen. Benannte Hinterausgänge haben Vorrang vor dem +Vorderausgang; Quelle/Zielkarte und vorhandene Graphknoten werden geprüft. +Eine ausdrückliche Triggerzuordnung im Katalog hat Vorrang vor der Namensheuristik. +Native Zugangsbedingungen bleiben wirksam. Gruppierte Bots sowie bereits mit +anderen Spielern oder Gruppen geteilte Instanzbindungen werden zurückgewiesen. + +Die verborgene Durchquerung bis zu einem entfernten Ausgang wird abstrahiert: +Am Rückkehrzeitpunkt wird innerhalb derselben Instanzkopie zum realen +Ausgangstrigger umgesetzt und dessen nativer Handler ausgeführt. Das ist kein +behaupteter MMAP-Lauf durch den Dungeon. Ohne Ausgang oder bei nativer +Wiederanmelde-Rettung aus einer abgelaufenen Instanz wird die zugeordnete +Außenposition verwendet. Ein verweigerter Ausgang gilt nicht als Rückkehr. + +Nach bestätigter Rückkehr wird einmal ein bei Besuchsbeginn gezogener +Zufallswert von 0–50 Prozent der maximalen HP dargestellt. Bei vorher voller +Gesundheit bleiben 50–100 Prozent HP. Bereits vorhandene Verletzungen werden +nicht geheilt; die Darstellung tötet den Bot nicht. Normale Regeneration bleibt +aktiv. Der Wert wird gespeichert und beim Neustart nicht neu gewürfelt. + +Die additive Charakter-Migration +`sql/custom/characters/2026_10_27_00_adventure_instance_visit.sql` speichert +Besuchsphase, absolute Frist, Trigger, Instanz-ID und Verletzungsmarker. +Charakterzustand und Besuchsphase/Verletzungsmarker werden gemeinsam über die +native asynchrone Transaktionsverbindung gespeichert. Erst deren bestätigter +Abschluss erlaubt das Fortschreiten. Die synchrone Verbindung kann die +ASYNC-only-Statements des nativen Charakterspeichers nicht ausführen. + +Ein WorldScript im optionalen Adventure-Skript meldet beim Serverstart nur +Charaktere mit gespeichertem Besuch erneut mit dem Adventure-Controller an. +Die Wiederaufnahme hat Vorrang vor dem normalen Lua-Initialintent. Fehlt die +Besuchstabelle, bleibt diese Wiederanmeldung inaktiv; andere Clientless-Systeme +werden davon nicht abhängig. + +Serverkonsole: + +```text +clientless adventure intent 26 instance instance_visit_at_101 3600 +clientless adventure status 26 +``` + +Trigger 101 ist das Verlies in Sturmwind, Trigger 78 die Todesminen. +Der Tagesplan kennt `instance` mit eigener Intentgewichtung (Standard 0,5); +bestehende gespeicherte Tagespläne werden nicht umgeschrieben. + +Regressionen: `test_adventure_instance_begin.py` prüft die produktive Anreise +über den vorhandenen Ortsplaner mit lokalem MMAP-Anschluss; +`test_adventure_instance.py` führt den produktiven Phasenablauf +mit nativen Übergangsadaptern aus; `test_adventure_instance_catalog.py` führt +die Migrationen auf Schemafixtures aus; `test_adventure_instance_persistence.py` +führt produktives Speichern/Wiederherstellen mit Transaktionsadapter aus; +`test_adventure_instance_autoresume.py` führt das tatsächliche Startup-Skript aus. + + +## Technischer Live-Test am 27.10.2026 + +- Debug-Build mit `-j10` und Installation erfolgreich. Abschließender vollständiger + Lauf: **44 Testskripte bestanden**, Log `/tmp/vcore-instance-tests-final.log`. +- Beide Migrationen auf Test-DB angewendet; Collation der neuen Tabellen stimmt + mit `utf8mb4_unicode_ci` der vorhandenen Ortsdaten überein. 108 neue Aliase, + insgesamt 2.973 geladene Locations / 3.966 Worldpoints. +- Die anfängliche Instanzanreise über den isolierten Portalnode führte in + vorhandene Graph-Recovery-Schleifen. Die Anreise verwendet jetzt den bestehenden + Ortsplaner mit lokalem MMAP-Anschluss. Auch dort trat auf dieser Strecke eine + bestehende Graphschleife auf. Dieses Reiseproblem wurde ausdrücklich nicht + repariert. **Die reguläre Fernanreise ist nicht erfolgreich live verifiziert.** +- Für den isolierten Instanztest wurde ausschließlich der ausgeloggte Testbot + administrativ am echten Außenwelt-Knoten `portal_at_101_source` positioniert + (-8761.85, 848.557, 87.8052). Danach liefen lokaler MMAP-Anschluss, + zehn Sekunden Vorbereitung und der tatsächliche native Trigger 101 regulär. +- `INSTANCE_ENTERED guid=26 map=34 instance=1` bestätigt das Verlies. Gespeicherter + Charakterstand direkt hinter dem Eingang: (48.9849, 0.483882, -16.3942). +- Echter geordneter Serverneustart während Phase 3. Der Startup-Hook meldete + Arthoran automatisch mit normalem Lua-Profil wieder an; `INSTANCE_RESUME` + bestätigte erneut Karte 34, **Instanz-ID 1**, dieselbe absolute Frist und + unveränderten Verletzungsmarker. Keine manuelle Anmeldung für diesen Restart. +- Für die zeitnahe Rückkehrprüfung wurde nach sauberem Serverstopp nur das + gespeicherte Blockende des bestätigten Innenaufenthalts auf einen verkürzten + Testblock gesetzt. Ursprünglicher Wert in + `/tmp/vcore-instance-live-original-deadline.tsv`. Der Produktivablauf verlängert + oder verkürzt normale Nutzerblöcke nicht. +- Rückkehr über nativen Ausgang 503 auf Karte 0, Position + (-8766.11, 845.499, 87.9952). Zuerst zweimal gültiger Zufallswert 0 Prozent; + ein weiterer regulär gestarteter sechsminütiger Testblock ergab **17 Prozent**: + `INSTANCE_RETURNED ... lossPercent=17 health=2165 maxHealth=2609`. + Die Charakter-DB bestätigte gleichzeitig Phase 5, `injury_applied=1` und 2165 HP. +- Erneute Anmeldung während der Nachruhe bestätigte `INSTANCE_RESUME phase=5`, + dieselbe Frist und `injuryApplied=1`, ohne Neuwurf oder erneute Verletzung. Normale Gesundheitsregeneration + bleibt aktiv. Nachruhe wird bei neuem explizitem Auftrag sauber beendet; + die ersten beiden Nachruhen wurden für die weiteren Tests bewusst unterbrochen. +- Normales Profil in Quelle und Installation ist bytegleich restauriert + (SHA256 `26b11c3108e6ecc5dbc59d1852b4103ca4e818148603a34c02a470c969dc659b`). + Keine temporären Lua-Overrides. Geld/Inventar nach abschließendem `saveall` unverändert: SHA256 + `8d40931782d05219466ba9fd077ce7acd3e02bd53eb63f6e96115042c620d26a`. + +Beim ersten Liveversuch wurde ein falscher Transaktionsweg erkannt: nativer +Charakterspeicher plus synchrone Verbindung führte zum Debug-Abbruch. Dieser +Fehler ist behoben: `AsyncCommitTransaction` führt die native Transaktion auf der +passenden Worker-Verbindung aus und liefert den bestätigten Abschluss zurück. +Regression und nachfolgende echte Speicher-/Neustarttests bestanden. Historisches +Fehlerlog: `/tmp/vcore-instance-first-live-DBErrors.log`. + +Der Nutzer verzichtet ausdrücklich auf eine weitere visuelle Abnahme. +Ein echter Todesminen-Hinterausgang und +zwei voneinander unabhängige gleichzeitig betretene Instanzkopien wurden noch +nicht live getestet; Quell-/Zielzuordnung, geteilte Bindungssperre, Hinterausgang +und fehlender Ausgang sind durch produktive Adaptertests abgedeckt. diff --git a/doc/clientless/intent-katalog.md b/doc/clientless/intent-katalog.md index 9550e0cacc..800c174fec 100644 --- a/doc/clientless/intent-katalog.md +++ b/doc/clientless/intent-katalog.md @@ -31,13 +31,13 @@ wirtschaftlichen Besuche und Aktivitäten. ## Implementierungsstand und offene Ausgestaltung Der bestehende Verhaltensmanager kennt aktuell die technischen Intentnamen -`social`, `adventure`, `profession`, `rest`, `leisure` und `economy`. Der [Ruhe-Intent](rest-intent.md) +`social`, `adventure`, `profession`, `rest`, `leisure`, `economy` und `instance`. 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. 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 +und progressive Gebietssuche. Der [Instanzintent](instance-intent.md) ergänzt bestätigten Eintritt, +gespeicherten Aufenthalt, native Rückkehr und einmalige Verletzungsdarstellung. Der ausführliche bisherige [Mikroaktionskatalog](mikroaktions-katalog.md) bleibt als Grundlage erhalten. Der Geselligkeitsausbau ist implementiert. Die Sitzposition auf nativen diff --git a/doc/clientless/mikroaktions-katalog.md b/doc/clientless/mikroaktions-katalog.md index d30ed3827b..60ad680f90 100644 --- a/doc/clientless/mikroaktions-katalog.md +++ b/doc/clientless/mikroaktions-katalog.md @@ -178,8 +178,12 @@ keine zufällig neben anderen Gesten auswählbare Mikroaktion. Vorgeschlagener Ablauf: Anreise → Vorbereitung → bestätigter Eintritt → abstrahierter Aufenthalt → Rückkehr → kurze Nachbereitung. -Zeitmodell, technische Unterbringung, Neustartverhalten und Rückkehrposition -werden vor der Umsetzung festgelegt. Ein fehlgeschlagener Eintritt darf die +Zeitmodell, Unterbringung, Neustartverhalten und Rückkehrposition wurden am +27.10.2026 vom Nutzer konkretisiert: verbleibender Location-Zeitblock mit fünf +Minuten Nachruhe, Warten direkt hinter dem Eingang in der eigenen Instanzkopie, +Wiederaufnahme mit ursprünglicher Frist und Rückkehr über den zugehörigen +Ausgang beziehungsweise Eingang. Details und technische Vorprüfung stehen in +[instance-intent.md](instance-intent.md). Ein fehlgeschlagener Eintritt darf die Aufenthaltsphase nicht starten. ## Ruhe diff --git a/doc/clientless/rest-intent.md b/doc/clientless/rest-intent.md index 736891a2d7..56f5d8247d 100644 --- a/doc/clientless/rest-intent.md +++ b/doc/clientless/rest-intent.md @@ -13,7 +13,7 @@ Ruhemöglichkeit löst die Suche nach einer Ersatzlocation aus. | Schlüssel | Ausführung | |---|---| | `rest_seat` | Erreichbaren nativen Stuhl oder eine Bank aufsuchen, regulär benutzen und 60–100 Sekunden sitzen. | -| `rest_sleep` | Einen ausdrücklich markierten Schlafplatz aufsuchen und 120–300 Sekunden mit nativer Schlafhaltung ruhen. | +| `rest_sleep` | Einen geprüften Schlafplatz nahe dem Schlafanker oder im Gasthaus aufsuchen und 120–300 Sekunden mit nativer Schlafhaltung ruhen. | | `rest_ground` | Einen ausdrücklich markierten Bodenrastplatz aufsuchen und 60–120 Sekunden sitzen. | | `rest_drink` | Eine vorhandene native Sitzgelegenheit benutzen und Trinken darstellen. | | `rest_eat` | Eine vorhandene native Sitzgelegenheit benutzen und Essen darstellen. | @@ -55,10 +55,11 @@ Die vorhandene Weltpunkt-Tabelle bleibt der Ortskatalog. Zusätzliche Kategorien - `rest_sleep`: ausdrücklich festgelegter Schlafplatz mit Position und Ausrichtung. - `rest_ground`: ausdrücklich festgelegter Bodenrastplatz. -Ein Schlafplatz ist keine automatische Bettinteraktion. Der Bot läuft zur -markierten Position und nimmt dort die native Schlafhaltung ein. Dekorative -Betten werden weder als benutzbar vorausgesetzt noch durch Teleports betreten. -Neue Schlafplätze müssen räumlich und visuell geprüft werden. +Ein Schlafanker ist keine automatische Bettinteraktion. Der Bot sucht heute +eine freie Liegefläche in seiner Nähe, läuft regulär dorthin und nimmt die native +Schlafhaltung ein (Details und Grenzen im Abschnitt zur Flächensuche unten). +Dekorative Betten werden weder als benutzbar vorausgesetzt noch durch Teleports +betreten. Neue Schlafplätze benötigen weiterhin eine visuelle Abnahme. Die Migration ergänzt einen Ruheplatz im Obergeschoss der Goldhainer Taverne (`goldshire_sleep_upstairs`) und einen Anlaufpunkt bei einer vorhandenen @@ -188,3 +189,93 @@ Stadtkennzeichen unter anderem für Sturmwind und deren Vererbung an das selbst unmarkierte Untergebiet 1617. Der Testserver erreichte erneut `ready...`; Prozess und Port 8085 wurden geprüft. Arthoran ist wieder im Ruhe-Intent in der Gilded Rose, sein temporär angepasstes Startprofil ist zurückgesetzt. + +## Schlafvorführung am 27.10.2026 + +Auf ausdrücklichen Nutzerwunsch wurde die Schlafprüfung wieder aufgenommen. +Arthoran reiste regulär von Elwynn zur Gilded Rose. Der alte Schlafmarker lag +1,72 Yards vom nativen MMAP-Endpunkt entfernt und wurde deshalb von der strengen +lokalen Ankunftsprüfung zurückgewiesen. Die native Navigation erreichte über +die Treppe (-8865.066, 684.800, 105.517); der Marker wurde gezielt dorthin +korrigiert (Migration `_06`, auf Test-DB bereits ausgeführt). Keine Lockerung +der Ankunftsprüfung, kein Teleport. Der kurz verwendete Lua-Testoverride wurde +bytegleich wiederhergestellt. + +`REST_CONFIRMED guid=26 point='gilded_rose_sleep_upstairs' posture='sleep' +durationMs=168000` bestätigt die gestartete native Schlafhaltung. Die sichtbare +Abnahme durch den Nutzer steht noch aus. Der Ruheauftrag gilt für diesen +Testlauf; das normale Lua-Profil blieb unverändert. + + +## Flächensuche statt ungeprüfter Schlafposition + +Die Sichtprüfung vom 27.10.2026 zeigte Arthoran mitten im oberen Stuhl. +`REST_CONFIRMED` belegte zuvor nur Navigation und Haltung, keine freie Liegefläche. +Die Migration `_06` wird deshalb nicht weiter als räumlich abgenommener Platz +betrachtet. Ihr Punkt dient jetzt als Suchanker; vorhandene Koordinaten bleiben erhalten. + +Schlaf sucht bis acht Yards um einen geeigneten `rest_sleep`-Anker; ohne nutzbaren +Schlafanker dürfen freigegebene Tavernen als Suchanker dienen. Explizite +Schlafanker haben Vorrang. Die Suche bleibt innerhalb von 1,5 Yards zur +Ankerhöhe und im Innenraum. Sie ersetzt keine Anreise und teleportiert nicht. + +Reihenfolge innerhalb der Suche: + +1. Phasenrichtige, gespawnte native Bettobjekte, deren Name ein vollständiges + Bett-/Bedroll-/Bunk-/Cot-Wort enthält. Ein eigener nativer Betttyp existiert + nicht; auch Betten vom Typ Chair werden nicht als Stuhl benutzt. +2. Geometrisch mögliche erhöhte Liegeflächen mit abrupten Kanten auf beiden + Seiten (mindestens 0,35 Yards Abfall auf 0,15 Yards Strecke, höchstens ein + Yard Höhendifferenz). Das ist eine Heuristik, keine sichere Bettklassifikation; + ein passender Tisch kann geometrisch gleich aussehen. +3. Ein freier ebener Bodenplatz, auch in Gasthäusern ohne Bett. + +Für jede Ausrichtung wird eine modellgrößenabhängige Körperfläche geprüft: +mindestens 3,2 × 1,3 Yards, größere Charaktermodelle entsprechend größer. +45 Stützstellen müssen eine gemeinsame ebene, trockene Fläche ergeben +(maximal 0,18 Yards Abweichung). Native VMAP-/dynamische Sichtlinien auf +mehreren Körperhöhen und senkrechte Abfragen prüfen Möbel, Wände und niedrige +Überbauten. Native Stühle werden zusätzlich mit Sicherheitsabstand ausgeschlossen, +auch wenn ihr Modell keine nutzbare Kollision besitzt. Andere Spieler und +Kreaturen dürfen die Körperfläche nicht belegen. Dekoration ohne Kollisionsdaten +und ohne eigene erkennbare Objektkennung bleibt technisch nicht sicher erfassbar. + +Der gewählte Platz und seine Ausrichtung werden getrennt vom Suchanker in der +Szene gespeichert. MMAP muss ihn vollständig und unverdächtig erreichen; +maximale Endpunktabweichung 0,3 Yards, tatsächliche Ankunft höchstens 0,35 Yards. +Ein Pfad auf dem Boden unter einer Bettfläche gilt damit nicht als Bettankunft. +Die Flächenprüfung wiederholt sich unmittelbar vor der Schlafhaltung und alle +zwei Sekunden während des Schlafens. Änderungen beenden die Szene über das +vorhandene Aufräumen. Bodenrast (`rest_ground`) behält ihr bisheriges Verhalten. + +Neue Regressionen: `test_adventure_rest_space.py` (produktive Flächen-/Kantenregeln), +`test_adventure_rest_targets.py` (produktive native Objektauswahl und Suchadapter). +`test_adventure_rest.py` prüft zusätzlich den vom Anker abweichenden Zielpunkt, +die erneute Flächenprüfung und den Abbruch bei nachträglicher Belegung. + + +Liveprüfung des Flächenausbaus am 27.10.2026: +- Debug-Build (`-j10`) erfolgreich, sauber gestoppt, installiert und neu gestartet. + Frische Ready-Meldung nach elf Sekunden, Prozess 31446 und Port 8085 bestätigt. +- Alle 38 zunächst vorhandenen/ergänzten Testskripte bestanden; der danach + ergänzte native Zieladaptertest ebenfalls, insgesamt 39. Die anschließend + erweiterten Ruhe-, Flächen- und Zieladaptertests erneut erfolgreich. +- Arthoran erreichte den oberen Suchanker regulär. Suche: `beds=0 raised=0 floor=18`. + Gewählt wurde `(-8868.534, 682.800, 104.949)`, Ausrichtung 3,740. + Native Geh-Anreise über 4,03 Yards, Endpunktfehler 0,00, danach + `REST_CONFIRMED` mit 243 Sekunden Schlafdauer. Kein Teleport/Objektbenutzen. +- Source- und installierte Profilkopie entsprechen wieder dem unveränderten + Normalprofil (SHA256 `26b11c3108e6ecc5dbc59d1852b4103ca4e818148603a34c02a470c969dc659b`). +- Bett-/erhöhte-Flächen-Präferenz ist durch produktive Adapterfixtures geprüft, + noch nicht positiv live an einem realen Bett. Sichtprüfung des neuen + Bodenplatzes durch den Nutzer bleibt offen. Keine neue DB-Migration nötig. + + +Erneute Suche auf Nutzerwunsch bei Allitester: separater Testanker +`gilded_rose_sleep_room_test`, (-8877.360, 673.489, 104.950), O=0,649831. +Arthoran erreichte den Punkt nach frischem Laden des Reisepunkt-Caches regulär. +Auch hier `beds=0 raised=0`, zuletzt `floor=46`; Schlafhaltung am Boden bestätigt +(143 Sekunden). Die sichtbaren Zimmerbetten sind damit weiterhin nicht erkannt; +der genaue geometrische Ablehnungsgrund wurde noch nicht instrumentiert. +Der ältere Anker wurde für die gezielte Auswahl kurz ausgeblendet und danach +vollständig wieder aktiviert. Normalprofil unverändert wiederhergestellt. diff --git a/sql/custom/characters/2026_10_27_00_adventure_instance_visit.sql b/sql/custom/characters/2026_10_27_00_adventure_instance_visit.sql new file mode 100644 index 0000000000..67498b5340 --- /dev/null +++ b/sql/custom/characters/2026_10_27_00_adventure_instance_visit.sql @@ -0,0 +1,16 @@ +-- Same database/transaction as characters.health and saved instance position. +CREATE TABLE IF NOT EXISTS vcore_adventure_instance_visit ( + character_guid int unsigned NOT NULL, + location_key varchar(100) NOT NULL, + entry_trigger int unsigned NOT NULL, + exit_trigger int unsigned NOT NULL, + instance_map int unsigned NOT NULL, + instance_id int unsigned NOT NULL DEFAULT 0, + block_end bigint unsigned NOT NULL, + prepare_until bigint unsigned NOT NULL DEFAULT 0, + phase tinyint unsigned NOT NULL, + loss_percent tinyint unsigned NOT NULL, + injury_applied tinyint unsigned NOT NULL DEFAULT 0, + schedule tinyint unsigned NOT NULL DEFAULT 0, + PRIMARY KEY(character_guid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/sql/custom/world/2026_10_27_07_adventure_instances.sql b/sql/custom/world/2026_10_27_07_adventure_instances.sql new file mode 100644 index 0000000000..3f968d0a8c --- /dev/null +++ b/sql/custom/world/2026_10_27_07_adventure_instances.sql @@ -0,0 +1,30 @@ +-- Native outside entrance aliases only. Instance interiors stay graph nodes. +CREATE TABLE IF NOT EXISTS vcore_adventure_instance ( + location_key varchar(100) NOT NULL, + entry_trigger int unsigned NOT NULL, + exit_trigger int unsigned NOT NULL DEFAULT 0 COMMENT '0: native exit discovery, separate exit preferred', + enabled tinyint unsigned NOT NULL DEFAULT 1, + PRIMARY KEY(location_key), UNIQUE KEY(entry_trigger) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO vcore_clientless_world_point + (point_key,category,map,position_x,position_y,position_z,orientation,enabled,comment) +SELECT CONCAT('instance_visit_at_',a.ID),'instance_entrance',w.map, + w.position_x,w.position_y,w.position_z,w.orientation,1, + CONCAT('Native instance entrance: ',a.Name) +FROM areatrigger_teleport a +JOIN instance_template i ON i.map=a.target_map +JOIN vcore_clientless_travel_node n ON n.node_key=CONCAT('portal_at_',a.ID,'_source') AND n.enabled=1 +JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key AND w.enabled=1 +LEFT JOIN instance_template inside_source ON inside_source.map=w.map +WHERE inside_source.map IS NULL; + +INSERT IGNORE INTO vcore_adventure_location (point_key,display_name,source_type,source_id) +SELECT CONCAT('instance_visit_at_',a.ID),a.Name,'instance_entrance',a.ID +FROM areatrigger_teleport a +JOIN vcore_clientless_world_point w ON w.point_key=CONCAT('instance_visit_at_',a.ID) AND w.category='instance_entrance'; +INSERT IGNORE INTO vcore_adventure_instance(location_key,entry_trigger) +SELECT point_key,source_id FROM vcore_adventure_location WHERE source_type='instance_entrance'; +INSERT IGNORE INTO vcore_adventure_location_intent(category,intent,weight) + VALUES ('instance_entrance','instance',5); +-- Other intent/category weights and existing plans are deliberately preserved. diff --git a/src/server/game/World/AdventureBehaviorMgr.cpp b/src/server/game/World/AdventureBehaviorMgr.cpp index dd0ec12439..0480874fb3 100644 --- a/src/server/game/World/AdventureBehaviorMgr.cpp +++ b/src/server/game/World/AdventureBehaviorMgr.cpp @@ -1,6 +1,7 @@ #include "AdventureBehaviorMgr.h" #include "AdventureLocationMgr.h" #include "AdventureEconomyPolicy.h" +#include "AdventureRestSpace.h" #include "ClientlessLuaEngine.h" #include "ClientlessPlayerMgr.h" #include "ClientlessActionExecutorMgr.h" @@ -23,6 +24,10 @@ #include "TerrainMgr.h" #include "World.h" #include "WorldSession.h" +#include "ObjectMgr.h" +#include "InstanceSaveMgr.h" +#include "Opcodes.h" +#include "WorldPacket.h" #include #include #include @@ -39,9 +44,95 @@ namespace } return action; } + bool SleepSpace(Player* player, Position const& destination) + { + std::vector objects; + player->GetGameObjectListWithEntryInGrid(objects, 0, 45); + std::vector people; + player->GetPlayerListInGrid(people, 45); + std::vector creatures; + player->GetCreatureListWithEntryInGrid(creatures, 0, 45); + auto map = player->GetMap(); + return AdventureRestSpace::Clear({destination.GetPositionX(), destination.GetPositionY(), + destination.GetPositionZ(), destination.GetOrientation()}, player->GetCollisionHeight(), + [&](float x, float y, float z) + { + float h = map->GetHeight(player->GetPhaseShift(), x, y, z, true, 3); + return map->GetWaterLevel(player->GetPhaseShift(), x, y) > h + 0.1f ? INVALID_HEIGHT : h; + }, + [&](float x, float y, float z, float a, float b, float c) + { + return map->isInLineOfSight(player->GetPhaseShift(), x,y,z,a,b,c, + LINEOFSIGHT_ALL_CHECKS, VMAP::ModelIgnoreFlags::Nothing); + }, + [&](float x, float y, float z) + { + for (auto go : objects) + if (go->isSpawned() && player->IsInPhase(go) && go->GetGoType() == GAMEOBJECT_TYPE_CHAIR && + !AdventureRestSpace::BedName(go->GetName()) && std::fabs(go->GetPositionZ()-z) < 1.8f && + go->GetExactDist2d(x,y) < std::max(0.9f,0.9f*go->GetObjectScale())) return true; + for (auto other : people) + if (other != player && player->IsInPhase(other) && std::fabs(other->GetPositionZ()-z) < 2 && + other->GetExactDist2d(x,y) < 0.8f) return true; + for (auto other : creatures) + if (player->IsInPhase(other) && std::fabs(other->GetPositionZ()-z) < 2 && + other->GetExactDist2d(x,y) < std::max(0.8f, other->GetBoundaryRadius())) return true; + return false; + }); + } + + std::vector SleepCandidates(Player* player, Position const& anchor, bool indoor) + { + std::vector beds, raised, floor; + std::vector objects; + player->GetGameObjectListWithEntryInGrid(objects,0,40); + auto add = [&](float x,float y,float probe,float o,bool bed) + { + if (anchor.GetExactDist(x,y,probe) > 10) return; + float z=player->GetMap()->GetHeight(player->GetPhaseShift(),x,y,probe,true,3); + if (z <= INVALID_HEIGHT+1 || std::fabs(z-anchor.GetPositionZ()) > 1.5f) return; + PositionFullTerrainStatus terrain; + player->GetMap()->GetTerrain()->GetFullTerrainStatusForPosition(player->GetPhaseShift(), + player->GetMapId(),x,y,z+0.2f,terrain); + if (indoor && terrain.outdoors) return; + // A raised surface needs abrupt low edges on both sides. It is only + // a geometric candidate, never a claim that static geometry is a bed. + float left=player->GetMap()->GetHeight(player->GetPhaseShift(),x-std::sin(o)*1.2f,y+std::cos(o)*1.2f,z+0.2f,true,3); + float right=player->GetMap()->GetHeight(player->GetPhaseShift(),x+std::sin(o)*1.2f,y-std::cos(o)*1.2f,z+0.2f,true,3); + bool elevated=AdventureRestSpace::Raised({x,y,z,o},[&](float a,float b,float c) + {return player->GetMap()->GetHeight(player->GetPhaseShift(),a,b,c,true,3);}); + // Never accept an arbitrary table/chair top as the floor fallback. + if (!bed && !elevated && (z-left>0.25f || z-right>0.25f)) return; + Position p(x,y,z,o); + if (!SleepSpace(player,p)) return; + (bed?beds:elevated?raised:floor).push_back(p); + }; + for (auto go : objects) + if (go->isSpawned() && player->IsInPhase(go) && AdventureRestSpace::BedName(go->GetName())) + for (unsigned turn=0;turn<4;++turn) + add(go->GetPositionX(),go->GetPositionY(),go->GetPositionZ()+1.2f, + go->GetOrientation()+turn*1.5707963f,true); + // Stay on the designated storey. Bounded sampling cannot select the + // room below through a floor or wander outside the inn for a fallback. + for (unsigned ring=0;ring<=4;++ring) + for (unsigned spoke=0;spoke<(ring?12u:1u);++spoke) + { + float angle=spoke*6.2831853f/12; + float x=anchor.GetPositionX()+ring*2*std::cos(angle); + float y=anchor.GetPositionY()+ring*2*std::sin(angle); + for (unsigned turn=0;turn<4;++turn) + add(x,y,anchor.GetPositionZ()+1.1f,anchor.GetOrientation()+turn*1.5707963f,false); + } + TC_LOG_INFO("server.adventurebot", "REST_SPACE_SEARCH guid=%u beds=%u raised=%u floor=%u", + player->GetGUID().GetCounter(),uint32(beds.size()),uint32(raised.size()),uint32(floor.size())); + beds.insert(beds.end(),raised.begin(),raised.end()); + beds.insert(beds.end(),floor.begin(),floor.end()); + return beds; + } + bool Known(std::string const & intent) { - return intent == "social" || intent == "adventure" || intent == "profession" || intent == "rest" || intent == "leisure" || intent == "economy"; + return intent == "social" || intent == "adventure" || intent == "profession" || intent == "rest" || intent == "leisure" || intent == "economy" || intent == "instance"; } bool Busy(uint32 guid) { @@ -98,6 +189,7 @@ void AdventureBehaviorMgr::Online(Player * player) r.Schedule = profile->DailySchedule; r.Random.seed(player->GetGUID().GetCounter() ^ uint32(GameTime::GetGameTime())); auto & record = _records.insert_or_assign(player->GetGUID().GetCounter(), std::move(r)).first->second; + if (RestoreInstance(player,record)) return; if (! record.Schedule && Known(profile->InitialIntent)) SetWanted(player, record, profile->InitialIntent, profile->InitialLocation, GameTime::GetGameTime() + profile->InitialDurationSeconds); } @@ -106,6 +198,7 @@ void AdventureBehaviorMgr::Offline(Player * player) auto i = _records.find(player->GetGUID().GetCounter()); if (i != _records.end()) { + if (i->second.Instance.Active) SaveInstance(player,i->second); ResetMicro(player, i->second); _records.erase(i); } @@ -178,14 +271,14 @@ void AdventureBehaviorMgr::Generate(Player * player, Record & record, uint32 dat { std::vector < double > weights { - Affinity(profile->ActivityAffinity, "social", 1) * Affinity(profile->Personality, "sociability", 0.5f) + Affinity(profile->ActivityAffinity, "tavern", 0.5f) * Affinity(profile->Personality, "tavern_affinity", 0.5f), Affinity(profile->ActivityAffinity, "adventure", 1) * Affinity(profile->Personality, "adventurousness", 0.5f), Affinity(profile->ActivityAffinity, "profession", 0.5f), Affinity(profile->ActivityAffinity, "rest", 0.5f), Affinity(profile->ActivityAffinity, "leisure", 0.5f), Affinity(profile->ActivityAffinity, "economy", 0.5f) + Affinity(profile->ActivityAffinity, "social", 1) * Affinity(profile->Personality, "sociability", 0.5f) + Affinity(profile->ActivityAffinity, "tavern", 0.5f) * Affinity(profile->Personality, "tavern_affinity", 0.5f), Affinity(profile->ActivityAffinity, "adventure", 1) * Affinity(profile->Personality, "adventurousness", 0.5f), Affinity(profile->ActivityAffinity, "profession", 0.5f), Affinity(profile->ActivityAffinity, "rest", 0.5f), Affinity(profile->ActivityAffinity, "leisure", 0.5f), Affinity(profile->ActivityAffinity, "economy", 0.5f), Affinity(profile->ActivityAffinity, "instance", 0.5f) }; auto i = Choose(weights, record.Random); - return std::array < std::string, 6 > + return std::array < std::string, 7 > { - "social", "adventure", "profession", "rest", "leisure", "economy" + "social", "adventure", "profession", "rest", "leisure", "economy", "instance" } - [std::min(i, std::size_t(5))]; + [std::min(i, std::size_t(6))]; }; record.Plan = AdventureBehaviorPolicy::Generate(record.Random, Affinity(profile->Personality, "wanderlust", 0.3f), pick,[&](std::string const & intent) { @@ -209,6 +302,17 @@ void AdventureBehaviorMgr::SetWanted(Player * player, Record & r, std::string co r.Deadline = deadline; r.Dirty = true; r.Tried.clear(); + if (r.Instance.Active) + { + if (r.Instance.Stage>=AdventureInstancePolicy::Phase::Entering && + r.Instance.Stage<=AdventureInstancePolicy::Phase::Returning) + { + r.Instance.Abort=true; + r.Detail="returning from instance before intent change"; + return; + } + FinishInstance(player,r); + } // A sightseeing flight must land and return before a new intent takes over. if (r.Leisure.Active && r.Leisure.Kind=="flight") { @@ -291,7 +395,12 @@ void AdventureBehaviorMgr::Begin(Player * player, Record & r) intent.Parameters["location"] = r.Location; sClientlessPlayerMgr->SetIntent(player->GetGUID().GetCounter(), std::move(intent)); r.Publishing = false; - if (l->IsArea) + if (r.Intent=="instance") + { + if (!ResolveInstance(player,r)) {Fallback(player,r);return;} + Queue(player,r,"travel_to_location","id",r.Location,true); + } + else if (l->IsArea) { auto node = AreaNode(player, * l); if (node.empty()) @@ -309,6 +418,7 @@ void AdventureBehaviorMgr::Begin(Player * player, Record & r) void AdventureBehaviorMgr::Fallback(Player * player, Record & r) { + if (r.Instance.Active && r.Instance.Stage==AdventureInstancePolicy::Phase::Travel) FinishInstance(player,r); r.Tried.insert(r.Location); AdventureLocation const * nearest = nullptr; double best = std::numeric_limits < double >::infinity(); @@ -777,7 +887,7 @@ bool AdventureBehaviorMgr::RecoveryAvailable(Player* player, Record const& r, Ad for (auto const& location : sAdventureLocationMgr->Locations()) { auto const& p = location.Point; - if (p.Category != category || !Allowed(player, location, "rest") || p.MapId != r.AnchorMap) continue; + if ((p.Category != category && !(action.Requirement=="sleep" && p.Category=="tavern")) || !Allowed(player, location, "rest") || p.MapId != r.AnchorMap) continue; if (r.Anchor.GetExactDist(p.X, p.Y, p.Z) <= 30 && std::fabs(p.Z-r.Anchor.GetPositionZ()) <= 12) return true; } return false; @@ -792,29 +902,52 @@ bool AdventureBehaviorMgr::StartRecovery(Player* player, Record& r, AdventureMic for (auto const& location : sAdventureLocationMgr->Locations()) { auto const& p = location.Point; - if (p.Category != category || !Allowed(player, location, "rest") || p.MapId != r.AnchorMap || + if ((p.Category != category && !(action.Requirement=="sleep" && p.Category=="tavern")) || !Allowed(player, location, "rest") || p.MapId != r.AnchorMap || r.Anchor.GetExactDist(p.X, p.Y, p.Z) > 30 || std::fabs(p.Z-r.Anchor.GetPositionZ()) > 12) continue; choices.push_back(&location); weights.push_back(Novelty(r.TargetHistory, "rest:"+p.Id)/(1+player->GetExactDist(p.X,p.Y,p.Z))); } while (!choices.empty()) { - auto i = Choose(weights, r.Random); + auto preferred=weights; + if (action.Requirement=="sleep" && std::any_of(choices.begin(),choices.end(), + [](AdventureLocation const* l){return l->Point.Category=="rest_sleep";})) + for (std::size_t n=0;nPoint.Category=="tavern") preferred[n]=0; + auto i = Choose(preferred, r.Random); if (i >= choices.size()) break; auto const& p = choices[i]->Point; - Position destination(p.X,p.Y,p.Z,p.O); - std::vector occupants; - player->GetPlayerListInGrid(occupants, 60); - bool occupied = false; - for (auto other : occupants) - if (other != player && player->IsInPhase(other) && other->GetExactDist(destination) < 1.5f) occupied = true; - if (!occupied && LocalMove(player, r, destination, 120)) + Position designated(p.X,p.Y,p.Z,p.O); + auto candidates = action.Requirement=="sleep" ? SleepCandidates(player,designated,true) : std::vector{designated}; + for (Position const& destination : candidates) { - r.Recovery = {true,true,p.Id,action.Requirement,duration,0}; - Remember(r.TargetHistory, "rest:"+p.Id); - r.Detail = "walking to designated resting spot"; - TC_LOG_INFO("server.adventurebot", "REST_APPROACH guid=%u point='%s' posture='%s'", player->GetGUID().GetCounter(),p.Id.c_str(),action.Requirement.c_str()); - return true; + std::vector occupants; + player->GetPlayerListInGrid(occupants, 60); + bool occupied = false; + for (auto other : occupants) + if (other != player && player->IsInPhase(other) && other->GetExactDist(destination) < 1.5f) occupied = true; + if (occupied) continue; + if (action.Requirement=="sleep") + { + ClientlessPathDiagnostics path; + // Reject a mesh endpoint on the floor beneath an elevated surface. + if (!sClientlessTravelMgr->AnalyzePath(player,destination,path) || !path.Complete || + path.Suspicious || path.FinalDistance>0.3f) continue; + } + if (LocalMove(player, r, destination, 120)) + { + r.Recovery = {true,true,p.Id,action.Requirement,duration,0}; + r.Recovery.Destination=destination; + r.Recovery.HasDestination=true; + if (action.Requirement=="sleep") + TC_LOG_INFO("server.adventurebot", "REST_SPACE_SELECTED guid=%u anchor='%s' pos=(%.3f %.3f %.3f) orientation=%.3f", + player->GetGUID().GetCounter(),p.Id.c_str(),destination.GetPositionX(),destination.GetPositionY(), + destination.GetPositionZ(),destination.GetOrientation()); + Remember(r.TargetHistory, "rest:"+p.Id); + r.Detail = "walking to designated resting spot"; + TC_LOG_INFO("server.adventurebot", "REST_APPROACH guid=%u point='%s' posture='%s'", player->GetGUID().GetCounter(),p.Id.c_str(),action.Requirement.c_str()); + return true; + } } choices.erase(choices.begin()+i); weights.erase(weights.begin()+i); @@ -828,28 +961,42 @@ bool AdventureBehaviorMgr::UpdateRecovery(Player* player, Record& r, uint32 diff auto location = sAdventureLocationMgr->Find(scene.Point); if (!scene.Active || !location || player->GetMapId() != r.AnchorMap || player->IsInWater() || player->IsFalling() || !Allowed(player,*location,"rest") || - location->Point.Category != (scene.Posture=="sleep" ? "rest_sleep" : "rest_ground")) return false; + (location->Point.Category != (scene.Posture=="sleep" ? "rest_sleep" : "rest_ground") && + !(scene.Posture=="sleep" && location->Point.Category=="tavern"))) return false; auto const& p = location->Point; + Position designated(p.X,p.Y,p.Z,p.O); + Position const& destination=scene.HasDestination?scene.Destination:designated; + if (destination.GetExactDist(designated)>10) return false; if (scene.Approaching) { scene.ApproachElapsed += diff; if (scene.ApproachElapsed > 60000) return false; auto travel = sClientlessTravelMgr->GetStatus(player->GetGUID().GetCounter()); if (travel && travel->State == ClientlessTravelState::Moving) return true; - if (!travel || travel->State != ClientlessTravelState::Arrived || player->GetExactDist(p.X,p.Y,p.Z) > 1) return false; + if (!travel || travel->State != ClientlessTravelState::Arrived || player->GetExactDist(destination) > (scene.Posture=="sleep"?0.35f:1.0f)) return false; std::vector occupants; player->GetPlayerListInGrid(occupants, 3); for (auto other : occupants) - if (other != player && player->IsInPhase(other) && other->GetExactDist(p.X,p.Y,p.Z) < 1.5f) return false; - player->SetFacingTo(p.O); + if (other != player && player->IsInPhase(other) && other->GetExactDist(destination) < 1.5f) return false; + if (scene.Posture=="sleep" && !SleepSpace(player,destination)) return false; + player->SetFacingTo(destination.GetOrientation()); player->SetStandState(scene.Posture=="sleep" ? UNIT_STAND_STATE_SLEEP : UNIT_STAND_STATE_SIT); scene.Approaching = false; r.Detail = scene.Posture=="sleep" ? "sleeping at designated resting spot" : "resting at designated ground spot"; TC_LOG_INFO("server.adventurebot", "REST_CONFIRMED guid=%u point='%s' posture='%s' durationMs=%u",player->GetGUID().GetCounter(),p.Id.c_str(),scene.Posture.c_str(),scene.Remaining); return true; } - if (player->GetExactDist(p.X,p.Y,p.Z) > 1 || + if (player->GetExactDist(destination) > (scene.Posture=="sleep"?0.35f:1.0f) || player->GetStandState() != (scene.Posture=="sleep" ? UNIT_STAND_STATE_SLEEP : UNIT_STAND_STATE_SIT)) return false; + if (scene.Posture=="sleep") + { + if (scene.SpaceCheckMs<=diff) + { + if (!SleepSpace(player,destination)) return false; + scene.SpaceCheckMs=2000; + } + else scene.SpaceCheckMs-=diff; + } if (scene.Remaining <= diff) return false; scene.Remaining -= diff; return true; @@ -1085,6 +1232,7 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) } r.Timer = 1000; time_t now = GameTime::GetGameTime(); + if (r.Instance.Active && UpdateInstance(player,r,now)) return; auto local = Local(now); uint32 date = DateOf(local); if (r.Schedule) @@ -1177,6 +1325,7 @@ void AdventureBehaviorMgr::Update(Player * player, uint32 diff) } if (r.State == Phase::OnSite) { + if (r.Intent=="instance") {UpdateInstance(player,r,now);return;} if (r.Recovery.Active) { if (UpdateRecovery(player,r,1000)) return; @@ -1330,3 +1479,263 @@ bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args) handler->SendSysMessage("Usage: clientless adventure import|repairimport|reload|status |plan |schedule on/off|intent [seconds]|micro |stop "); return false; } + +bool AdventureBehaviorMgr::SaveInstance(Player* player, Record& r) +{ + auto const& v=r.Instance; + if (!v.Active || player->IsBeingTeleported()) return false; + std::string key=v.Location; + CharacterDatabase.EscapeString(key); + auto tx=CharacterDatabase.BeginTransaction(); + player->SaveToDB(tx); + tx->PAppend("REPLACE INTO vcore_adventure_instance_visit " + "(character_guid,location_key,entry_trigger,exit_trigger,instance_map,instance_id,block_end,prepare_until,phase,loss_percent,injury_applied,schedule) " + "VALUES (%u,'%s',%u,%u,%u,%u,%llu,%llu,%u,%u,%u,%u)", + player->GetGUID().GetCounter(),key.c_str(),v.Entry,v.Exit,v.Map,v.InstanceId, + static_cast(v.BlockEnd),static_cast(v.PrepareUntil), + uint32(v.Stage),v.LossPercent,v.InjuryApplied?1:0,r.Schedule?1:0); + // Player::SaveToDB uses ASYNC-only prepared statements. Execute on that + // worker connection, and wait for its transaction result before moving on. + auto commit=CharacterDatabase.AsyncCommitTransaction(tx); + bool saved=commit.m_future.get(); + if (!saved) TC_LOG_ERROR("server.adventurebot","INSTANCE_SAVE_FAILED guid=%u",player->GetGUID().GetCounter()); + return saved; +} + +bool AdventureBehaviorMgr::RestoreInstance(Player* player, Record& r) +{ + if (!CharacterDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_instance_visit'")) return false; + auto row=CharacterDatabase.PQuery("SELECT location_key,entry_trigger,exit_trigger,instance_map,instance_id,block_end,prepare_until,phase,loss_percent,injury_applied,schedule " + "FROM vcore_adventure_instance_visit WHERE character_guid=%u",player->GetGUID().GetCounter()); + if (!row) return false; + auto f=row->Fetch(); + auto& v=r.Instance; + if (f[7].GetUInt8()>uint8(AdventureInstancePolicy::Phase::Rest) || f[8].GetUInt8()>50) return false; + v.Active=true;v.Location=f[0].GetString();v.Entry=f[1].GetUInt32();v.Exit=f[2].GetUInt32(); + v.Map=f[3].GetUInt32();v.InstanceId=f[4].GetUInt32();v.BlockEnd=time_t(f[5].GetUInt64()); + v.PrepareUntil=time_t(f[6].GetUInt64());v.Stage=AdventureInstancePolicy::Phase(f[7].GetUInt8()); + v.LossPercent=f[8].GetUInt8();v.InjuryApplied=f[9].GetUInt8()!=0;r.Schedule=f[10].GetUInt8()!=0; + r.Intent=r.WantedIntent="instance";r.Location=r.WantedLocation=v.Location;r.Deadline=v.BlockEnd; + r.State=v.Stage==AdventureInstancePolicy::Phase::Travel?Phase::Idle:Phase::OnSite; + r.Dirty=v.Stage==AdventureInstancePolicy::Phase::Travel; + r.Detail="resuming saved instance visit"; + TC_LOG_INFO("server.adventurebot","INSTANCE_RESUME guid=%u phase=%u map=%u instance=%u deadline=%llu injuryApplied=%u", + player->GetGUID().GetCounter(),uint32(v.Stage),player->GetMapId(),player->GetInstanceId(), + static_cast(v.BlockEnd),v.InjuryApplied?1:0); + return true; +} + +void AdventureBehaviorMgr::FinishInstance(Player* player, Record& r) +{ + if (!r.Instance.Active) return; + auto tx=CharacterDatabase.BeginTransaction(); + player->SetStandState(UNIT_STAND_STATE_STAND); + player->SaveToDB(tx); + tx->PAppend("DELETE FROM vcore_adventure_instance_visit WHERE character_guid=%u",player->GetGUID().GetCounter()); + auto commit=CharacterDatabase.AsyncCommitTransaction(tx); + if (!commit.m_future.get()) + { + r.Detail="instance final save failed; retrying"; + return; + } + TC_LOG_INFO("server.adventurebot","INSTANCE_VISIT_END guid=%u phase=%u",player->GetGUID().GetCounter(),uint32(r.Instance.Stage)); + r.Instance={}; + if (r.Dirty) r.State=Phase::Idle; +} + +bool AdventureBehaviorMgr::ResolveInstance(Player* player, Record& r) +{ + if (r.Instance.Active) return r.Instance.Location==r.Location; + auto key=r.Location;WorldDatabase.EscapeString(key); + auto row=WorldDatabase.PQuery("SELECT entry_trigger,exit_trigger FROM vcore_adventure_instance WHERE location_key='%s' AND enabled=1",key.c_str()); + if (!row) {r.Detail="instance entrance mapping unavailable";return false;} + uint32 entry=row->Fetch()[0].GetUInt32(),exit=row->Fetch()[1].GetUInt32(); + auto source=sAreaTriggerStore.LookupEntry(entry); + auto target=sObjectMgr->GetAreaTrigger(entry); + auto map=target?sMapStore.LookupEntry(target->target_mapId):nullptr; + auto outside=source?sMapStore.LookupEntry(source->ContinentID):nullptr; + sClientlessTravelGraphMgr->EnsureLoaded(); + if (!source || !map || !map->IsDungeon() || !outside || outside->MapType!=MAP_COMMON || + !sClientlessTravelGraphMgr->FindNode("portal_at_"+std::to_string(entry)+"_source") || + !sClientlessTravelGraphMgr->FindNode("portal_at_"+std::to_string(entry)+"_target") || player->GetGroup()) + {r.Detail="solo instance entrance is not a valid native graph transition";return false;} + auto validExit=[&](uint32 id) + { + auto at=sAreaTriggerStore.LookupEntry(id);auto dest=sObjectMgr->GetAreaTrigger(id); + return at && dest && uint32(at->ContinentID)==target->target_mapId && + dest->target_mapId==uint32(source->ContinentID) && + sClientlessTravelGraphMgr->FindNode("portal_at_"+std::to_string(id)+"_source") && + sClientlessTravelGraphMgr->FindNode("portal_at_"+std::to_string(id)+"_target"); + }; + if (exit && !validExit(exit)) {r.Detail="configured native instance exit invalid";return false;} + if (!exit) + { + // Native map pairing is authoritative. Names only prioritize a separate + // rear exit over the front door; an explicit catalog override wins. + if (auto rows=WorldDatabase.Query("SELECT ID FROM areatrigger_teleport ORDER BY " + "(LOWER(Name) REGEXP 'rear|back|hinter') DESC,ID")) do + { + auto id=rows->Fetch()[0].GetUInt32(); + if (validExit(id)) {exit=id;break;} + } while(rows->NextRow()); + } + r.Instance.Active=true;r.Instance.Location=r.Location;r.Instance.Entry=entry;r.Instance.Exit=exit; + r.Instance.Map=target->target_mapId;r.Instance.BlockEnd=r.Deadline; + r.Instance.LossPercent=std::uniform_int_distribution(0,50)(r.Random); + if (!SaveInstance(player,r)) {r.Instance={};return false;} + TC_LOG_INFO("server.adventurebot","INSTANCE_PLAN guid=%u entry=%u exit=%u map=%u deadline=%llu lossPercent=%u", + player->GetGUID().GetCounter(),entry,exit,target->target_mapId, + static_cast(r.Deadline),r.Instance.LossPercent); + return true; +} + +bool AdventureBehaviorMgr::UpdateInstance(Player* player, Record& r, time_t now) +{ + using Stage=AdventureInstancePolicy::Phase; + auto& v=r.Instance; + if (!v.Active) return false; + if (Unsafe(player)) {r.Detail="instance visit waiting for safe native state";return true;} + auto entry=sAreaTriggerStore.LookupEntry(v.Entry); + auto entryTarget=sObjectMgr->GetAreaTrigger(v.Entry); + auto exit=v.Exit?sAreaTriggerStore.LookupEntry(v.Exit):nullptr; + auto exitTarget=v.Exit?sObjectMgr->GetAreaTrigger(v.Exit):nullptr; + if (!entry || !entryTarget || (v.Exit && (!exit || !exitTarget))) + {r.Detail="saved native instance transition unavailable";return true;} + if (v.Stage==Stage::Travel) + { + if (now>=AdventureInstancePolicy::ReturnAt(v.BlockEnd)) + { + sClientlessPlayerMgr->ClearQueuedActions(player->GetGUID().GetCounter()); + sClientlessActionExecutorMgr->Clear(player->GetGUID().GetCounter()); + sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter()); + v.Stage=Stage::Rest;r.State=Phase::OnSite;r.Dirty=false; + if (!SaveInstance(player,r)) return true; + r.Detail="instance arrival too late; resting without entering"; + } + else if (r.State!=Phase::OnSite) return false; + else + { + v.Stage=Stage::Preparing;v.PrepareUntil=now+10; + if (!SaveInstance(player,r)) return true; + player->SetFacingTo(player->GetAngle(entry->Pos.X,entry->Pos.Y)); + player->HandleEmoteCommand(EMOTE_ONESHOT_KNEEL); + r.Detail="looking at instance entrance and preparing equipment"; + TC_LOG_INFO("server.adventurebot","INSTANCE_PREPARE guid=%u entry=%u",player->GetGUID().GetCounter(),v.Entry); + } + } + if (v.Stage==Stage::Preparing) + { + if (!AdventureInstancePolicy::MayEnter(now,v.BlockEnd)) + {v.Stage=Stage::Rest;if (!SaveInstance(player,r)) return true;} + else if (nowGetMapId()==v.Map && player->GetInstanceId() && + player->GetExactDist(entryTarget->target_X,entryTarget->target_Y,entryTarget->target_Z)<=5) + { + v.InstanceId=player->GetInstanceId();v.Stage=Stage::Inside; + if (!SaveInstance(player,r)) {v.Stage=Stage::Entering;return true;} + r.Detail="waiting just inside private instance entrance"; + TC_LOG_INFO("server.adventurebot","INSTANCE_ENTERED guid=%u map=%u instance=%u returnAt=%llu", + player->GetGUID().GetCounter(),v.Map,v.InstanceId, + static_cast(AdventureInstancePolicy::ReturnAt(v.BlockEnd))); + return true; + } + if (v.Abort || !AdventureInstancePolicy::MayEnter(now,v.BlockEnd)) + {v.Stage=Stage::Rest;if (!SaveInstance(player,r)) return true;} + else + { + auto map=sMapStore.LookupEntry(v.Map); + auto bind=player->GetBoundInstance(v.Map,player->GetDifficulty(map->IsRaid())); + bool shared=false; + if (bind && bind->save) + { + uint32 id=bind->save->GetInstanceId(); + shared=bind->save->GetPlayerCount()>1 || bind->save->GetGroupCount()>0 || + bool(CharacterDatabase.PQuery("SELECT guid FROM character_instance WHERE instance=%u AND guid<>%u LIMIT 1",id,player->GetGUID().GetCounter())) || + bool(CharacterDatabase.PQuery("SELECT guid FROM group_instance WHERE instance=%u LIMIT 1",id)); + } + if (player->GetGroup() || shared || player->GetMapId()!=uint32(entry->ContinentID) || !player->IsInAreaTriggerRadius(entry)) + { + r.Detail="native instance entrance not reached or private solo binding unavailable"; + v.Stage=Stage::Rest;SaveInstance(player,r);return true; + } + if (!SaveInstance(player,r)) return true; + WorldPacket packet(CMSG_AREATRIGGER,4);packet<DismountGroundMount(player,"instance-entry"); + player->GetSession()->HandleAreaTriggerOpcode(packet); + if (player->IsBeingTeleported()) player->GetSession()->CompleteClientlessTeleport(); + if (player->GetMapId()!=v.Map || !player->GetInstanceId() || + player->GetExactDist(entryTarget->target_X,entryTarget->target_Y,entryTarget->target_Z)>5) + { + TC_LOG_WARN("server.adventurebot","INSTANCE_ENTRY_DENIED guid=%u entry=%u",player->GetGUID().GetCounter(),v.Entry); + v.Stage=Stage::Rest;SaveInstance(player,r);r.Detail="native instance entry denied; no visit started"; + } + return true; + } + } + if (v.Stage==Stage::Inside) + { + if (!v.Abort && !player->GetGroup() && nowGetMapId()==v.Map && player->GetInstanceId()==v.InstanceId) + {r.Detail="waiting just inside private instance entrance";return true;} + v.Stage=Stage::Returning; + if (!SaveInstance(player,r)) {v.Stage=Stage::Inside;return true;} + } + if (v.Stage==Stage::Returning) + { + uint32 outside=exitTarget?exitTarget->target_mapId:uint32(entry->ContinentID); + Position destination=exitTarget?Position(exitTarget->target_X,exitTarget->target_Y,exitTarget->target_Z,exitTarget->target_Orientation): + Position(entry->Pos.X,entry->Pos.Y,entry->Pos.Z,0); + bool arrived=player->GetMapId()==outside && player->GetExactDist(destination)<=5; + if (!arrived) + { + if (player->GetMapId()==v.Map && exit) + { + // The hidden stay abstracts the dungeon traversal. Stage only + // at the real exit inside this same instance, then use its + // normal trigger and checks. Never claim a walked dungeon route. + if (!player->IsInAreaTriggerRadius(exit)) + { + TC_LOG_INFO("server.adventurebot","INSTANCE_ABSTRACT_EXIT_STAGE guid=%u exit=%u instance=%u",player->GetGUID().GetCounter(),v.Exit,player->GetInstanceId()); + player->TeleportTo(v.Map,exit->Pos.X,exit->Pos.Y,exit->Pos.Z,0); + if (player->IsBeingTeleported()) player->GetSession()->CompleteClientlessTeleport(); + } + if (player->IsBeingTeleported() || !player->IsInAreaTriggerRadius(exit)) return true; + WorldPacket packet(CMSG_AREATRIGGER,4);packet<GetSession()->HandleAreaTriggerOpcode(packet); + } + else + { + // No separate exit, or native login recovery already moved the + // player out of an expired instance: restore the mapped exit. + TC_LOG_INFO("server.adventurebot","INSTANCE_RETURN_RECOVERY guid=%u outside=%u",player->GetGUID().GetCounter(),outside); + player->TeleportTo(outside,destination.GetPositionX(),destination.GetPositionY(),destination.GetPositionZ(),destination.GetOrientation()); + } + if (player->IsBeingTeleported()) player->GetSession()->CompleteClientlessTeleport(); + arrived=player->IsInWorld() && !player->IsBeingTeleported() && player->GetMapId()==outside && player->GetExactDist(destination)<=5; + } + if (!arrived) {r.Detail="waiting for confirmed native instance exit";return true;} + if (!v.InjuryApplied) + { + player->SetHealth(AdventureInstancePolicy::ReturnHealth(player->GetHealth(),player->GetMaxHealth(),v.LossPercent)); + v.InjuryApplied=true; + } + v.Stage=Stage::Rest; + if (!SaveInstance(player,r)) {v.Stage=Stage::Returning;return true;} + TC_LOG_INFO("server.adventurebot","INSTANCE_RETURNED guid=%u exit=%u lossPercent=%u health=%u maxHealth=%u deadline=%llu", + player->GetGUID().GetCounter(),v.Exit,v.LossPercent,player->GetHealth(),player->GetMaxHealth(),static_cast(v.BlockEnd)); + r.Detail="outside instance: orientation and recovery rest"; + } + if (v.Stage==Stage::Rest) + { + if (v.Abort || now>=v.BlockEnd) + {FinishInstance(player,r);return false;} + player->SetStandState(UNIT_STAND_STATE_SIT); + r.Detail="outside instance: orientation and recovery rest"; + return true; + } + return true; +} diff --git a/src/server/game/World/AdventureBehaviorMgr.h b/src/server/game/World/AdventureBehaviorMgr.h index 568b51d1e0..2aab7494a5 100644 --- a/src/server/game/World/AdventureBehaviorMgr.h +++ b/src/server/game/World/AdventureBehaviorMgr.h @@ -5,6 +5,7 @@ #include "ObjectGuid.h" #include "AdventureBehaviorPolicy.h" #include "AdventureSocialActions.h" +#include "AdventureInstancePolicy.h" #include "ClientlessCompanionMgr.h" #include #include @@ -35,6 +36,9 @@ private: bool Active=false, Approaching=false; std::string Point, Posture; uint32 Remaining=0, ApproachElapsed=0; + Position Destination; + bool HasDestination=false; + uint32 SpaceCheckMs=0; }; struct Record { @@ -49,6 +53,7 @@ private: std::deque ActionHistory, VariantHistory; std::deque TargetHistory; AdventureSocialScene Social; + AdventureInstancePolicy::Visit Instance; RecoveryScene Recovery; struct LeisureScene { @@ -67,6 +72,11 @@ private: uint32 WorkEmote=0; bool WorkPending=false; }; + bool ResolveInstance(Player* player, Record& record); + bool SaveInstance(Player* player, Record& record); + bool RestoreInstance(Player* player, Record& record); + bool UpdateInstance(Player* player, Record& record, time_t now); + void FinishInstance(Player* player, Record& record); void Generate(Player* player, Record& record, uint32 date); void SetWanted(Player* player, Record& record, std::string const& intent, std::string const& location, time_t deadline); void Begin(Player* player, Record& record); diff --git a/src/server/game/World/AdventureInstancePolicy.h b/src/server/game/World/AdventureInstancePolicy.h new file mode 100644 index 0000000000..1fe0309043 --- /dev/null +++ b/src/server/game/World/AdventureInstancePolicy.h @@ -0,0 +1,28 @@ +#ifndef VCORE_ADVENTURE_INSTANCE_POLICY_H +#define VCORE_ADVENTURE_INSTANCE_POLICY_H +#include +#include +#include +#include + +namespace AdventureInstancePolicy +{ + enum class Phase : uint8_t { Travel, Preparing, Entering, Inside, Returning, Rest }; + struct Visit + { + bool Active=false, InjuryApplied=false, Abort=false; + Phase Stage=Phase::Travel; + std::string Location; + uint32_t Entry=0, Exit=0, Map=0, InstanceId=0, LossPercent=0; + time_t BlockEnd=0, PrepareUntil=0; + }; + inline time_t ReturnAt(time_t end) {return end>300?end-300:0;} + inline bool MayEnter(time_t now,time_t end) {return now +#include +#include +#include + +namespace AdventureRestSpace +{ + struct Spot { float X, Y, Z, O; }; + + inline bool BedName(std::string name) + { + for (char& c : name) c = std::isalpha(static_cast(c)) ? + char(std::tolower(static_cast(c))) : ' '; + name = " " + name + " "; + return name.find(" bed ") != std::string::npos || name.find(" bunk ") != std::string::npos || + name.find(" bedroll ") != std::string::npos || name.find(" cot ") != std::string::npos; + } + + // Both side edges must drop abruptly, not gradually down a slope. This + // discovers possible static furniture surfaces without inventing object IDs. + template + bool Raised(Spot const& p, Height height) + { + for (float side : {-1.0f, 1.0f}) + { + float previous=p.Z; + bool edge=false; + for (unsigned step=0;step<=5;++step) + { + float offset=side*(0.75f+step*0.15f); + float z=height(p.X-std::sin(p.O)*offset,p.Y+std::cos(p.O)*offset,p.Z+0.2f); + if (!std::isfinite(z) || std::fabs(z-p.Z)>1.0f) return false; + if (step && previous-z>=0.35f && p.Z-z>=0.35f) {edge=true;break;} + previous=z; + } + if (!edge) return false; + } + return true; + } + + // A conservative full-body rectangle, independent of the standing collision + // cylinder. Height samples alone miss chair backs and table undersides. + template + bool Clear(Spot const& p, float bodyHeight, Height height, Ray ray, Blocked blocked) + { + float length = std::max(1.6f, bodyHeight * 0.9f); + float width = std::max(0.65f, bodyHeight * 0.3f); + float co = std::cos(p.O), si = std::sin(p.O); + for (int i = -4; i <= 4; ++i) + for (int j = -2; j <= 2; ++j) + { + float along = length * i / 4, across = width * j / 2; + float x = p.X + co * along - si * across; + float y = p.Y + si * along + co * across; + float z = height(x, y, p.Z + 0.3f); + if (!std::isfinite(z) || std::fabs(z - p.Z) > 0.18f || blocked(x, y, p.Z)) return false; + for (float level : {0.15f, 0.45f, 0.9f}) + if (!ray(p.X, p.Y, p.Z + level, x, y, p.Z + level)) return false; + if (!ray(x, y, p.Z + 0.12f, x, y, p.Z + 1.1f)) return false; + } + return true; + } +} +#endif diff --git a/src/server/scripts/Custom/viano_bot_commands.cpp b/src/server/scripts/Custom/viano_bot_commands.cpp index cb94730675..602809d9b7 100644 --- a/src/server/scripts/Custom/viano_bot_commands.cpp +++ b/src/server/scripts/Custom/viano_bot_commands.cpp @@ -2302,7 +2302,24 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg }; } +// Restart recovery belongs to the optional AdventureBot script layer. +class ClientlessInstanceVisitResume : public WorldScript +{ +public: + ClientlessInstanceVisitResume() : WorldScript("ClientlessInstanceVisitResume") { } + void OnStartup() override + { + if (!CharacterDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_instance_visit'")) return; + if (auto rows=CharacterDatabase.Query("SELECT c.account,c.guid FROM vcore_adventure_instance_visit v JOIN characters c ON c.guid=v.character_guid ORDER BY c.guid")) do + { + auto f=rows->Fetch(); + sClientlessPlayerMgr->StartPlayer(f[0].GetUInt32(),f[1].GetUInt32(),CreateClientlessAdventureLuaController()); + } while(rows->NextRow()); + } +}; + void AddSC_viano_bot_commands() { new VianoBotCommands::VianoBotCommandScript(); + new ClientlessInstanceVisitResume(); } diff --git a/tests/clientless/test_adventure_instance.py b/tests/clientless/test_adventure_instance.py new file mode 100644 index 0000000000..d3349fa1c0 --- /dev/null +++ b/tests/clientless/test_adventure_instance.py @@ -0,0 +1,122 @@ +"""Execute production instance phases with native-transfer/persistence adapters.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() +start=s.index('bool AdventureBehaviorMgr::UpdateInstance(') +code=r''' +#include "AdventureInstancePolicy.h" +#include +#include +#include +#include +#include +using uint32=uint32_t;using uint8=uint8_t; +#define TC_LOG_INFO(...) ((void)0) +#define TC_LOG_WARN(...) ((void)0) +constexpr int UNIT_STAND_STATE_STAND=0,UNIT_STAND_STATE_SIT=1,EMOTE_ONESHOT_KNEEL=16,CMSG_AREATRIGGER=1; +struct Position{float x=0,y=0,z=0,o=0;Position()=default;Position(float a,float b,float c,float d=0):x(a),y(b),z(c),o(d){}float GetPositionX()const{return x;}float GetPositionY()const{return y;}float GetPositionZ()const{return z;}float GetOrientation()const{return o;}}; +struct Trigger{uint32 ContinentID=0;struct{float X=0,Y=0,Z=0;}Pos;}; +struct Target{uint32 target_mapId=36;float target_X=0,target_Y=0,target_Z=0,target_Orientation=0;}; +struct Triggers{Trigger entrance,exit;Triggers(){exit.ContinentID=36;exit.Pos.X=100;}Trigger* LookupEntry(uint32 id){return id==78?&entrance:id==121?&exit:nullptr;}}sAreaTriggerStore; +struct Targets{Target entrance,exit;Targets(){exit.target_mapId=0;exit.target_X=500;}Target* GetAreaTrigger(uint32 id){return id==78?&entrance:id==121?&exit:nullptr;}}targets;auto sObjectMgr=&targets; +struct MapEntry{bool IsRaid(){return false;}}mapEntry; +struct Maps{auto LookupEntry(uint32){return &mapEntry;}}sMapStore; +struct WorldPacket{uint32 trigger=0;WorldPacket(int,int){}WorldPacket& operator<<(uint32 id){trigger=id;return *this;}}; +struct Save{uint32 GetInstanceId(){return 7;}unsigned GetPlayerCount(){return 1;}unsigned GetGroupCount(){return 0;}}save; +struct Bind{Save* save=nullptr;}; +struct Session; +struct Player:Position{ + uint32 map=0,instance=0,health=100,maximum=100;int stand=0,damages=0,entryCalls=0,exitCalls=0,teleports=0; + bool safe=true,group=false,deny=false;Bind bind;Session* session=nullptr; + struct Guid{uint32 GetCounter(){return 26;}};Guid GetGUID(){return {};} + uint32 GetMapId(){return map;}uint32 GetInstanceId(){return instance;}bool IsInWorld(){return true;} + bool IsBeingTeleported(){return false;}bool GetGroup(){return group;} + Bind* GetBoundInstance(uint32,int){return bind.save?&bind:nullptr;}int GetDifficulty(bool){return 0;} + bool IsInAreaTriggerRadius(Trigger* t){return map==t->ContinentID&&std::fabs(x-t->Pos.X)<3;} + float GetAngle(float,float){return 0;}void SetFacingTo(float){}void HandleEmoteCommand(int){} + void SetStandState(int v){stand=v;}uint32 GetHealth(){return health;}uint32 GetMaxHealth(){return maximum;} + void SetHealth(uint32 h){health=h;++damages;}Session* GetSession(){return session;} + float GetExactDist(float a,float b,float c){return std::sqrt((x-a)*(x-a)+(y-b)*(y-b)+(z-c)*(z-c));} + float GetExactDist(Position const& p){return GetExactDist(p.x,p.y,p.z);} + bool TeleportTo(uint32 m,float a,float b,float c,float o){++teleports;map=m;instance=m==36?7:0;x=a;y=b;z=c;this->o=o;return true;} +}; +struct Session{Player* p;void CompleteClientlessTeleport(){}void HandleAreaTriggerOpcode(WorldPacket const& packet){ + if(packet.trigger==78){++p->entryCalls;if(!p->deny){p->map=36;p->instance=7;p->x=0;}} + else{++p->exitCalls;if(!p->deny){p->map=0;p->instance=0;p->x=500;}} +}}; +bool Unsafe(Player* p){return !p->safe;} +struct Database{bool shared=false;templatebool PQuery(char const*,T...){return shared;}}CharacterDatabase; +struct Manager{void ClearQueuedActions(uint32){}}manager;auto sClientlessPlayerMgr=&manager; +struct Executor{void Clear(uint32){}}executor;auto sClientlessActionExecutorMgr=&executor; +struct Travel{void CancelTravel(uint32){}}travel;auto sClientlessTravelMgr=&travel; +struct Lua{void DismountGroundMount(Player*,char const*){}}lua;auto sClientlessLuaEngine=&lua; +struct AdventureBehaviorMgr{ + enum class Phase{Idle,Travel,OnSite}; + struct Record{AdventureInstancePolicy::Visit Instance;Phase State=Phase::OnSite;bool Dirty=false;std::string Detail;}; + int saves=0,finishes=0;bool saveOK=true; + bool SaveInstance(Player*,Record&){++saves;return saveOK;} + void FinishInstance(Player* p,Record&r){++finishes;r.Instance={};p->SetStandState(0);} + bool UpdateInstance(Player*,Record&,time_t); +}; +'''+s[start:]+r''' +using Stage=AdventureInstancePolicy::Phase; +AdventureBehaviorMgr::Record fresh(){AdventureBehaviorMgr::Record r;r.Instance.Active=true;r.Instance.Entry=78;r.Instance.Exit=121;r.Instance.Map=36;r.Instance.BlockEnd=3600;r.Instance.LossPercent=50;return r;} +int main(){ + assert(AdventureInstancePolicy::ReturnAt(3600)==3300); + assert(AdventureInstancePolicy::MayEnter(3299,3600)&&!AdventureInstancePolicy::MayEnter(3300,3600)); + assert(AdventureInstancePolicy::ReturnHealth(100,100,0)==100); + assert(AdventureInstancePolicy::ReturnHealth(100,100,50)==50); + assert(AdventureInstancePolicy::ReturnHealth(40,100,50)==40); // no healing + assert(AdventureInstancePolicy::ReturnHealth(UINT32_MAX,UINT32_MAX,50)==2147483647u); + AdventureBehaviorMgr mgr;auto r=fresh();Player p;Session session{&p};p.session=&session; + // 40 minutes' travel leaves 15 minutes inside and 5 outside. + assert(mgr.UpdateInstance(&p,r,2400)&&r.Instance.Stage==Stage::Preparing&&p.entryCalls==0); + assert(mgr.UpdateInstance(&p,r,2409)&&p.entryCalls==0); + assert(mgr.UpdateInstance(&p,r,2410)&&p.entryCalls==1&&p.map==36); + assert(mgr.UpdateInstance(&p,r,2411)&&r.Instance.Stage==Stage::Inside&&r.Instance.InstanceId==7); + int transfers=p.teleports; + assert(mgr.UpdateInstance(&p,r,3299)&&p.teleports==transfers&&p.exitCalls==0); + assert(mgr.UpdateInstance(&p,r,3300)&&r.Instance.Stage==Stage::Rest&&p.map==0&&p.x==500); + assert(p.exitCalls==1&&p.health==50&&p.damages==1&&r.Instance.InjuryApplied); + assert(mgr.UpdateInstance(&p,r,3400)&&p.damages==1); + assert(!mgr.UpdateInstance(&p,r,3600)&&mgr.finishes==1&&!r.Instance.Active); + // Persisted inside state on restart keeps the absolute return time. + r=fresh();r.Instance.Stage=Stage::Inside;r.Instance.InstanceId=7;p.map=36;p.instance=7;p.x=0; + assert(mgr.UpdateInstance(&p,r,3299)&&p.map==36); + assert(!mgr.UpdateInstance(&p,r,3700)&&p.map==0&&!r.Instance.Active); + // If native login ejected an expired copy, return to its associated exit. + r=fresh();r.Instance.Stage=Stage::Inside;r.Instance.InstanceId=7;p.map=0;p.x=999; + assert(mgr.UpdateInstance(&p,r,3400)&&p.x==500&&r.Instance.InjuryApplied); + // Replaying a saved return cannot compound damage. + r=fresh();r.Instance.Stage=Stage::Returning;r.Instance.InjuryApplied=true;p.x=500; + int damages=p.damages;assert(mgr.UpdateInstance(&p,r,3400)&&p.damages==damages); + // A failed durable entering save must not start a portal transfer. + r=fresh();r.Instance.Stage=Stage::Entering;p.map=0;p.x=0;mgr.saveOK=false; + int prevented=p.entryCalls;assert(mgr.UpdateInstance(&p,r,100)&&p.entryCalls==prevented);mgr.saveOK=true; + // A denied entrance is not a visit; no exit or damage is invented. + r=fresh();r.Instance.Stage=Stage::Entering;p.map=0;p.x=0;p.deny=true;damages=p.damages; + assert(mgr.UpdateInstance(&p,r,100)&&r.Instance.Stage==Stage::Rest&&p.damages==damages); + p.deny=false; + // Late arrival never enters, nor applies a fake injury. + r=fresh();r.State=AdventureBehaviorMgr::Phase::Travel;int calls=p.entryCalls; + assert(mgr.UpdateInstance(&p,r,3350)&&r.Instance.Stage==Stage::Rest&&p.entryCalls==calls&&p.damages==damages); + // Shared or grouped copies are not accepted for this solo performance. + r=fresh();r.Instance.Stage=Stage::Entering;p.group=true; + assert(mgr.UpdateInstance(&p,r,100)&&p.entryCalls==calls);p.group=false; + r=fresh();r.Instance.Stage=Stage::Entering;p.bind.save=&save;CharacterDatabase.shared=true; + assert(mgr.UpdateInstance(&p,r,100)&&p.entryCalls==calls);CharacterDatabase.shared=false;p.bind.save=nullptr; + // A denied exit remains pending and cannot wound a player still inside. + r=fresh();r.Instance.Stage=Stage::Returning;p.map=36;p.instance=7;p.x=0;p.deny=true; + assert(mgr.UpdateInstance(&p,r,3400)&&r.Instance.Stage==Stage::Returning&&p.damages==damages); + p.deny=false; + // With no separate native exit, use the original entrance location. + r=fresh();r.Instance.Stage=Stage::Returning;r.Instance.Exit=0;p.map=36;p.instance=7;p.x=50; + assert(mgr.UpdateInstance(&p,r,3400)&&p.map==0&&p.x==0); +} +''' +with tempfile.TemporaryDirectory() as tmp: + src=Path(tmp)/'instance.cpp';exe=Path(tmp)/'instance';src.write_text(code) + subprocess.run(['g++','-std=c++17','-I'+str(root/'src/server/game/World'),str(src),'-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) +print('Production instance lifecycle: time budget, true entry, denied/shared entry, native rear exit, restart, expired copy, late arrival, HP and return replay passed.') diff --git a/tests/clientless/test_adventure_instance_autoresume.py b/tests/clientless/test_adventure_instance_autoresume.py new file mode 100644 index 0000000000..f9736fc859 --- /dev/null +++ b/tests/clientless/test_adventure_instance_autoresume.py @@ -0,0 +1,34 @@ +"""Run the actual optional WorldScript startup recovery with stored visits.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/scripts/Custom/viano_bot_commands.cpp').read_text() +s=s[s.index('class ClientlessInstanceVisitResume :'):s.index('\nvoid AddSC_viano_bot_commands()')] +code=r''' +#include +#include +#include +#include +using uint32=uint32_t; +struct WorldScript{WorldScript(char const*){}virtual void OnStartup(){}}; +struct Field{uint32 value;uint32 GetUInt32(){return value;}}; +struct Rows{std::vector> data;unsigned index=0;Field* Fetch(){return data[index].data();}bool NextRow(){return ++index rows=std::make_shared(); + std::shared_ptr Query(char const* sql){if(sql[0]=='S'&&sql[1]=='H')return schema?std::make_shared():nullptr;rows->index=0;return rows->data.empty()?nullptr:rows;} +}CharacterDatabase; +struct Manager{std::vector> started;void StartPlayer(uint32 account,uint32 guid,std::unique_ptr controller){assert(controller&&*controller==42);started.emplace_back(account,guid);}}manager; +auto sClientlessPlayerMgr=&manager; +std::unique_ptr CreateClientlessAdventureLuaController(){return std::make_unique(42);} +'''+s+r''' +int main(){ClientlessInstanceVisitResume script; + CharacterDatabase.schema=false;script.OnStartup();assert(manager.started.empty()); + CharacterDatabase.schema=true;script.OnStartup();assert(manager.started.empty()); + CharacterDatabase.rows->data={{{6},{26}},{{7},{27}}};script.OnStartup(); + assert((manager.started==std::vector>{{6,26},{7,27}})); +} +''' +with tempfile.TemporaryDirectory() as tmp: + src=Path(tmp)/'startup.cpp';exe=Path(tmp)/'startup';src.write_text(code) + subprocess.run(['g++','-std=c++17',str(src),'-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) +print('Instance startup script: no schema/no visits stays idle; saved visitors resume with the Adventure controller and native accounts.') diff --git a/tests/clientless/test_adventure_instance_begin.py b/tests/clientless/test_adventure_instance_begin.py new file mode 100644 index 0000000000..0d45e4bd3d --- /dev/null +++ b/tests/clientless/test_adventure_instance_begin.py @@ -0,0 +1,42 @@ +"""Entrance visits use ordinary location travel, retaining the local MMAP leg.""" +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('void AdventureBehaviorMgr::Begin(');b=s.index('\nvoid AdventureBehaviorMgr::Fallback(',a) +code=r''' +#include +#include +#include +#include +#define TC_LOG_INFO(...) ((void)0) +struct Player{struct Guid{int GetCounter(){return 26;}};Guid GetGUID(){return {};}}; +struct ClientlessIntent{std::string Name;std::unordered_map Parameters;}; +struct Location{bool IsArea=false;}location; +struct Store{bool exists=true;Location* Find(std::string const&){return exists?&location:nullptr;}}store;auto sAdventureLocationMgr=&store; +struct Manager{void ClearIntent(int){}void SetIntent(int,ClientlessIntent){}}manager;auto sClientlessPlayerMgr=&manager; +struct AdventureBehaviorMgr{ + enum class Phase{Idle,Travel}; + struct Record{bool Dirty=true,Enabled=true,Publishing=false,Queued=false;std::string Intent,Location,WantedIntent="instance",WantedLocation="instance_visit_at_101",Detail;Phase State=Phase::Idle;std::unordered_setTried;}; + bool mapped=true,allowed=true;int fallback=0;std::string action,parameter,target;bool hard=false; + void Begin(Player*,Record&); + bool Allowed(Player*,Location const&,std::string const&){return allowed;} + void Fallback(Player*,Record&){++fallback;} + bool ResolveInstance(Player*,Record&){return mapped;} + std::string AreaNode(Player*,Location const&){return "reachable";} + void Queue(Player*,Record&,std::string const&a,std::string const&p,std::string const&t,bool h=false){action=a;parameter=p;target=t;hard=h;} +}; +'''+s[a:b]+r''' +int main(){Player p;AdventureBehaviorMgr mgr;AdventureBehaviorMgr::Record r; + mgr.Begin(&p,r);assert(mgr.action=="travel_to_location"&&mgr.parameter=="id"&&mgr.target=="instance_visit_at_101"&&mgr.hard); + assert(r.State==AdventureBehaviorMgr::Phase::Travel&&!r.Dirty); + mgr.mapped=false;mgr.Begin(&p,r);assert(mgr.fallback==1); + mgr.mapped=true;r.WantedIntent="social";mgr.Begin(&p,r);assert(mgr.action=="travel_to_location"&&!mgr.hard); + location.IsArea=true;mgr.Begin(&p,r);assert(mgr.action=="travel_to_node"&&mgr.target=="reachable"&&mgr.hard); +} +''' +with tempfile.TemporaryDirectory() as tmp: + src=Path(tmp)/'begin.cpp';exe=Path(tmp)/'begin';src.write_text(code) + subprocess.run(['g++','-std=c++17',str(src),'-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) +print('Production entrance travel uses the existing location planner, requires mapping and retains normal social/area travel.') diff --git a/tests/clientless/test_adventure_instance_catalog.py b/tests/clientless/test_adventure_instance_catalog.py new file mode 100644 index 0000000000..2526d879b8 --- /dev/null +++ b/tests/clientless/test_adventure_instance_catalog.py @@ -0,0 +1,39 @@ +"""Execute the entrance migration against native portal/graph fixtures.""" +from pathlib import Path +import re,sqlite3 +root=Path(__file__).resolve().parents[2] +db=sqlite3.connect(':memory:');db.create_function('CONCAT',-1,lambda *a:''.join(map(str,a))) +db.executescript(''' +CREATE TABLE instance_template(map INTEGER PRIMARY KEY); +CREATE TABLE areatrigger_teleport(ID INTEGER PRIMARY KEY,Name TEXT,target_map INTEGER); +CREATE TABLE vcore_clientless_travel_node(node_key TEXT PRIMARY KEY,world_point_key TEXT,enabled INTEGER); +CREATE TABLE vcore_clientless_world_point(point_key TEXT PRIMARY KEY,category TEXT,map INTEGER,position_x REAL,position_y REAL,position_z REAL,orientation REAL,enabled INTEGER,comment TEXT); +CREATE TABLE vcore_adventure_location(point_key TEXT PRIMARY KEY,display_name TEXT,source_type TEXT,source_id INTEGER); +CREATE TABLE vcore_adventure_location_intent(category TEXT,intent TEXT,weight REAL,PRIMARY KEY(category,intent)); +INSERT INTO instance_template VALUES(36); +INSERT INTO areatrigger_teleport VALUES(78,'Deadmines Entrance Target',36),(119,'Front exit',0),(121,'Rear exit',0),(527,'City portal',1),(999,'Disabled entrance',36); +INSERT INTO vcore_clientless_world_point VALUES + ('s78','travel_portal',0,1,2,3,0,1,'native'),('s119','travel_portal',36,4,5,6,0,1,'native'), + ('s121','travel_portal',36,7,8,9,0,1,'native'),('s527','travel_portal',0,10,11,12,0,1,'native'), + ('s999','travel_portal',0,13,14,15,0,1,'native'); +INSERT INTO vcore_clientless_travel_node VALUES + ('portal_at_78_source','s78',1),('portal_at_119_source','s119',1),('portal_at_121_source','s121',1), + ('portal_at_527_source','s527',1),('portal_at_999_source','s999',0); +''') +sql=(root/'sql/custom/world/2026_10_27_07_adventure_instances.sql').read_text() +sql=re.sub(r" COMMENT '[^']*'",'',sql) +sql=re.sub(r'UNIQUE KEY\(entry_trigger\)','UNIQUE(entry_trigger)',sql) +sql=re.sub(r'\) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;',');',sql).replace('INSERT IGNORE','INSERT OR IGNORE') +db.executescript(sql) +assert db.execute('SELECT location_key,entry_trigger,exit_trigger FROM vcore_adventure_instance').fetchall()==[('instance_visit_at_78',78,0)] +assert db.execute("SELECT category,map FROM vcore_clientless_world_point WHERE point_key='instance_visit_at_78'").fetchone()==('instance_entrance',0) +assert db.execute("SELECT category,map FROM vcore_clientless_world_point WHERE point_key='s78'").fetchone()==('travel_portal',0) +db.executescript("UPDATE vcore_adventure_instance SET exit_trigger=121,enabled=0; UPDATE vcore_adventure_location_intent SET weight=2.5; UPDATE vcore_clientless_world_point SET position_x=77 WHERE point_key='instance_visit_at_78';") +before=list(db.iterdump());db.executescript(sql);assert list(db.iterdump())==before +characters=(root/'sql/custom/characters/2026_10_27_00_adventure_instance_visit.sql').read_text() +characters=re.sub(r'\) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;',');',characters) +db.executescript(characters) +db.execute("INSERT INTO vcore_adventure_instance_visit VALUES(26,'instance_visit_at_78',78,121,36,123,3600,10,3,50,0,0)") +db.executescript(characters) +assert db.execute('SELECT block_end,instance_id,phase FROM vcore_adventure_instance_visit').fetchone()==(3600,123,3) +print('Instance migrations: outside entrances only, native graph preserved, disabled/missing transitions excluded, edits and saved visits preserved.') diff --git a/tests/clientless/test_adventure_instance_persistence.py b/tests/clientless/test_adventure_instance_persistence.py new file mode 100644 index 0000000000..e5ce0c9e7d --- /dev/null +++ b/tests/clientless/test_adventure_instance_persistence.py @@ -0,0 +1,91 @@ +"""Run production save/restore with a transactional character-DB adapter.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +s=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() +def function(signature): + a=s.index(signature);b=s.index('{',a);n=b+1;depth=1 + while depth: + depth+=(s[n]=='{')-(s[n]=='}');n+=1 + return s[a:n] +code=r''' +#include "AdventureInstancePolicy.h" +#include +#include +#include +#include +#include +using uint32=uint32_t;using uint64=uint64_t;using uint8=uint8_t; +#define TC_LOG_INFO(...) ((void)0) +#define TC_LOG_ERROR(...) ((void)0) +constexpr int UNIT_STAND_STATE_STAND=0; +std::string str(char const* s){return s;}templatestd::string str(T n){return std::to_string(n);} +struct Field{std::string value;std::string GetString(){return value;}uint8 GetUInt8(){return std::stoul(value);}uint32 GetUInt32(){return std::stoul(value);}uint64 GetUInt64(){return std::stoull(value);}}; +struct Row{std::vector fields;Field* Fetch(){return fields.data();}}; +using Result=std::shared_ptr; +struct Operation{std::string sql;std::vector args;}; +struct Transaction{std::vector ops;templatevoid PAppend(char const* sql,A... args){ops.push_back({sql,{str(args)...}});}}; +using Tx=std::shared_ptr; +struct DB{ + bool fail=false,table=true;std::vector row;std::vector character;std::vector last; + Tx BeginTransaction(){return std::make_shared();}void EscapeString(std::string&){} + struct Future{bool success;bool get(){return success;}}; + struct Commit{Future m_future;}; + Commit AsyncCommitTransaction(Tx& tx){last=tx->ops;if(fail)return {{false}};for(auto const& op:tx->ops){ + if(op.sql=="CHARACTER_SAVE")character=op.args; + else if(op.sql.find("REPLACE INTO")==0)row=op.args; + else if(op.sql.find("DELETE FROM")==0)row.clear(); + }return {{true}};} + Result Query(char const*){return table?std::make_shared():nullptr;} + Result PQuery(char const* sql,uint32){if(row.empty())return nullptr;auto r=std::make_shared(); + if(std::string(sql).find("SELECT phase,")==0)for(auto i:{8,6,10})r->fields.push_back({row.at(i)}); + else for(unsigned i=1;ifields.push_back({row[i]}); + return r; + } +}CharacterDatabase; +struct Player{ + uint32 health=100,map=36,instance=7;bool teleport=false;int stand=0; + struct Guid{uint32 GetCounter(){return 26;}};Guid GetGUID(){return {};} + bool IsBeingTeleported(){return teleport;}uint32 GetMapId(){return map;}uint32 GetInstanceId(){return instance;} + void SaveToDB(Tx& tx){tx->PAppend("CHARACTER_SAVE",health,map,instance);} + void SetStandState(int v){stand=v;} +}; +struct AdventureBehaviorMgr{ + enum class Phase{Idle,OnSite}; + struct Record{AdventureInstancePolicy::Visit Instance;bool Schedule=false,Dirty=false;Phase State=Phase::Idle; + std::string Intent,WantedIntent,Location,WantedLocation,Detail;time_t Deadline=0;}; + bool SaveInstance(Player*,Record&);bool RestoreInstance(Player*,Record&);void FinishInstance(Player*,Record&); +}; +''' +for signature in ('bool AdventureBehaviorMgr::SaveInstance(', 'bool AdventureBehaviorMgr::RestoreInstance(', 'void AdventureBehaviorMgr::FinishInstance('):code+=function(signature)+'\n' +code+=r''' +int main(){ + AdventureBehaviorMgr mgr;AdventureBehaviorMgr::Record r;Player p; + assert(!mgr.RestoreInstance(&p,r)); + auto& v=r.Instance;v.Active=true;v.Location="instance_visit_at_78";v.Entry=78;v.Exit=121;v.Map=36;v.InstanceId=7; + v.BlockEnd=3600;v.PrepareUntil=2410;v.Stage=AdventureInstancePolicy::Phase::Inside;v.LossPercent=37;r.Schedule=true; + assert(mgr.SaveInstance(&p,r));assert(CharacterDatabase.last.size()==2); + assert(CharacterDatabase.character[0]=="100"&&CharacterDatabase.character[2]=="7"); + // Restart creates an empty behavior record, then restores the actual visit. + AdventureBehaviorMgr::Record restored;assert(mgr.RestoreInstance(&p,restored)); + assert(restored.Intent=="instance"&&restored.Location==v.Location&&restored.Deadline==3600&&restored.Schedule); + assert(restored.Instance.Stage==AdventureInstancePolicy::Phase::Inside&&restored.Instance.InstanceId==7); + assert(restored.Instance.LossPercent==37&&!restored.Instance.InjuryApplied); + // Character health and the once-only marker commit together, never separately. + v.Stage=AdventureInstancePolicy::Phase::Rest;v.InjuryApplied=true;p.health=63;p.map=0;p.instance=0; + CharacterDatabase.fail=true;assert(!mgr.SaveInstance(&p,r)); + assert(CharacterDatabase.character[0]=="100");assert(mgr.RestoreInstance(&p,restored)&&!restored.Instance.InjuryApplied); + CharacterDatabase.fail=false;assert(mgr.SaveInstance(&p,r)); + assert(CharacterDatabase.character[0]=="63"&&CharacterDatabase.character[1]=="0"); + assert(mgr.RestoreInstance(&p,restored)&&restored.Instance.InjuryApplied&&restored.Deadline==3600); + // Clear the visit atomically with final character state, and release a queued intent. + r.Dirty=true;r.State=AdventureBehaviorMgr::Phase::OnSite;mgr.FinishInstance(&p,r); + assert(!r.Instance.Active&&r.State==AdventureBehaviorMgr::Phase::Idle&&CharacterDatabase.row.empty()); + assert(!mgr.RestoreInstance(&p,restored)); +} +''' +with tempfile.TemporaryDirectory() as tmp: + src=Path(tmp)/'save.cpp';exe=Path(tmp)/'save';src.write_text(code) + subprocess.run(['g++','-std=c++17','-I'+str(root/'src/server/game/World'),str(src),'-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) +print('Production instance persistence: phases, original deadline, instance ID, transactional HP marker, failed commit and intent release passed.') diff --git a/tests/clientless/test_adventure_intent_state.py b/tests/clientless/test_adventure_intent_state.py index 2d0e0522d0..579bbc6ba9 100644 --- a/tests/clientless/test_adventure_intent_state.py +++ b/tests/clientless/test_adventure_intent_state.py @@ -55,6 +55,7 @@ public: time_t Deadline=1000;uint32 Date=20261026,Timer=0,MicroTimer=0,GestureTimer=0,RetryTimer=0;int Hour=5; struct{bool Active=false;}Social; struct{bool Active=false;}Recovery; + struct{bool Active=false;}Instance; struct{bool Active=false,Flying=false,Progressive=false;std::vector Route;size_t Step=0;}Leisure; AdventureBehaviorPolicy::Day Plan;Position Anchor;uint32 AnchorMap=0;int WorkObject=0;uint32 WorkEmote=0;bool WorkPending=false; }; @@ -67,6 +68,7 @@ public: void Fallback(Player*,Record&){++fallbacks;} void ResetMicro(Player*,Record&r){++resets;r.Micro.clear();} void Micro(Player*,Record&){++micros;} + bool UpdateInstance(Player*,Record&,time_t){return false;} bool UpdateLeisure(Player*,Record&,uint32){++leisureUpdates;return true;} bool UpdateSocial(Player*,Record&,uint32){return true;} bool UpdateRecovery(Player*,Record&,uint32){return true;} diff --git a/tests/clientless/test_adventure_leisure.py b/tests/clientless/test_adventure_leisure.py index 6575e47f8d..4fcdf64e64 100644 --- a/tests/clientless/test_adventure_leisure.py +++ b/tests/clientless/test_adventure_leisure.py @@ -9,6 +9,7 @@ def function(signature): return source[a:e] code=r''' #include +#include "AdventureInstancePolicy.h" #include #include #include @@ -86,6 +87,7 @@ struct Executor{bool RequestStopAtNode(uint32){return false;}}executor;auto sCli class AdventureBehaviorMgr{public: enum class Phase{Idle,OnSite,Switching}; struct Record{ + AdventureInstancePolicy::Visit Instance; std::string Intent="leisure",Location="city",WantedIntent,WantedLocation,Detail;Position Anchor;uint32 AnchorMap=0,MicroTimer=240000;std::mt19937 Random{26}; time_t Deadline=0;bool Dirty=false;std::unordered_set Tried;Phase State=Phase::OnSite; struct LeisureScene{bool Active=false,Flying=false,Moving=false,StopRequested=false,Returning=false,PlannedWalk=false,Progressive=false;std::string Kind;Position Origin;std::vector Route,WalkTrace;std::size_t Step=0,OutboundSteps=0;uint32 Elapsed=0,LegElapsed=0,PauseMs=0,CompanionWait=0,CompletedLegs=0;ClientlessCompanionScene Companion;}Leisure; @@ -93,6 +95,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);} + void FinishInstance(Player*,Record&r){r.Instance={};} bool workAvailable=false;bool ProfessionWorkAvailable(Player*,Record&){return workAvailable;} bool FishingDirection(Player*,float&)const; bool LeisureAvailable(Player*,Record const&,AdventureMicroAction const&)const; @@ -215,6 +218,6 @@ int main(){ ''' with tempfile.TemporaryDirectory(prefix='vcore-leisure-') 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(['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('Leisure: substantial curved MMAP routes, mirrored return, interrupted return, rejected corridors, actual arrival, companion follow, riding, flight and fishing passed.') diff --git a/tests/clientless/test_adventure_rest.py b/tests/clientless/test_adventure_rest.py index 2000d663e0..f2248e338a 100644 --- a/tests/clientless/test_adventure_rest.py +++ b/tests/clientless/test_adventure_rest.py @@ -17,6 +17,7 @@ def function(signature): code = r''' #include +#include #include #include #include @@ -25,13 +26,17 @@ using uint32=uint32_t; #define TC_LOG_INFO(...) ((void)0) constexpr int UNIT_STAND_STATE_SLEEP=3,UNIT_STAND_STATE_SIT=1; struct Position { - float x=0,y=0,z=0; + float x=0,y=0,z=0,o=0; + Position()=default; Position(float a,float b,float c,float d=0):x(a),y(b),z(c),o(d){} + float GetOrientation()const{return o;} + float GetExactDist(Position const& p)const{return GetExactDist(p.x,p.y,p.z);} float GetPositionZ()const{return z;} float GetExactDist(float a,float b,float c)const{return std::sqrt((x-a)*(x-a)+(y-b)*(y-b)+(z-c)*(z-c));} }; struct Guid{uint32 GetCounter(){return 26;}}; struct Player:Position { uint32 map=0;bool water=false,falling=false,phase=true;int stand=0,poses=0; + bool space=true; std::vector occupants; uint32 GetMapId(){return map;}Guid GetGUID(){return {};} bool IsInWater(){return water;}bool IsFalling(){return falling;} @@ -40,6 +45,13 @@ struct Player:Position { void SetFacingTo(float){}void SetStandState(int value){stand=value;++poses;} int GetStandState(){return stand;} }; +bool SleepSpace(Player* p,Position const&){return p->space;} +std::vector candidates; +std::vector SleepCandidates(Player*,Position const&,bool){return candidates;} +struct ClientlessPathDiagnostics{bool Complete=true,Suspicious=false;float FinalDistance=0;}; +double Novelty(std::vector const&,std::string const&){return 1;} +void Remember(std::vector& h,std::string const& s){h.push_back(s);} +std::size_t Choose(std::vector const& weights,int&){for(std::size_t i=0;i0)return i;return weights.size();} struct AdventureMicroAction{std::string Requirement;}; struct AdventureLocation { struct {std::string Id="bed",Category="rest_sleep";uint32 MapId=0;float X=0,Y=0,Z=0,O=0;}Point; @@ -54,14 +66,19 @@ auto sAdventureLocationMgr=&locations; enum class ClientlessTravelState{Moving,Arrived,PathRejected}; struct Travel { struct Status{ClientlessTravelState State=ClientlessTravelState::Moving;}status; + float endpointError=0;bool suspicious=false; + bool AnalyzePath(Player*,Position const&,ClientlessPathDiagnostics& d){d.FinalDistance=endpointError;d.Suspicious=suspicious;return true;} bool present=true;Status const* GetStatus(uint32){return present?&status:nullptr;} }travel; auto sClientlessTravelMgr=&travel; class AdventureBehaviorMgr { public: - struct RecoveryScene{bool Active=true,Approaching=true;std::string Point="bed",Posture="sleep";uint32 Remaining=3000,ApproachElapsed=0;}; - struct Record{Position Anchor;uint32 AnchorMap=0;RecoveryScene Recovery;std::string Detail;}; + struct RecoveryScene{bool Active=true,Approaching=true;std::string Point="bed",Posture="sleep";uint32 Remaining=3000,ApproachElapsed=0;Position Destination;bool HasDestination=false;uint32 SpaceCheckMs=0;}; + struct Record{Position Anchor;uint32 AnchorMap=0;RecoveryScene Recovery;std::string Detail;std::vector TargetHistory;int Random=0;}; bool Allowed(Player*,AdventureLocation const& p,std::string const&)const{return p.allowed;} + std::vector moves;bool moveOK=true; + bool LocalMove(Player*,Record&,Position const& p,int){moves.push_back(p);return moveOK;} + bool StartRecovery(Player*,Record&,AdventureMicroAction const&,uint32); bool RecoveryAvailable(Player*,Record const&,AdventureMicroAction const&)const; bool UpdateRecovery(Player*,Record&,uint32); }; @@ -69,6 +86,7 @@ public: # Avoid a class/member name collision in the adapter. code = code.replace('struct Locations {', 'struct LocationStore {') code += function('bool AdventureBehaviorMgr::RecoveryAvailable(') +code += function('bool AdventureBehaviorMgr::StartRecovery(') code += function('bool AdventureBehaviorMgr::UpdateRecovery(') code += r''' int main(){ @@ -78,8 +96,20 @@ int main(){ locations.points.push_back({}); assert(mgr.RecoveryAvailable(&p,r,sleep)); assert(!mgr.RecoveryAvailable(&p,r,ground)&&!mgr.RecoveryAvailable(&p,r,unknown)); + // Reject endpoints beneath an elevated surface before starting movement. + candidates={Position(3,0,0)};travel.endpointError=0.7f; + assert(!mgr.StartRecovery(&p,r,sleep,7000)&&mgr.moves.empty()); + travel.endpointError=0;assert(mgr.StartRecovery(&p,r,sleep,7000)); + assert(r.Recovery.HasDestination && r.Recovery.Destination.x==3 && mgr.moves.size()==1); + assert(r.Recovery.Remaining==7000); + mgr.moves.clear();r=AdventureBehaviorMgr::Record{}; + // Even if a tavern is first in the catalog, explicit sleep anchors have priority. + locations.points.insert(locations.points.begin(),AdventureLocation{});locations.points[0].Point.Id="inn"; + locations.points[0].Point.Category="tavern"; + assert(mgr.StartRecovery(&p,r,sleep,7000)&&r.Recovery.Point=="bed"); + locations.points.erase(locations.points.begin());r=AdventureBehaviorMgr::Record{}; auto& point=locations.points[0]; - point.Point.Category="tavern";assert(!mgr.RecoveryAvailable(&p,r,sleep)); // no arbitrary tavern floor + point.Point.Category="tavern";assert(mgr.RecoveryAvailable(&p,r,sleep)); // bounded indoor floor search point.Point.Category="rest_sleep";point.Point.MapId=1;assert(!mgr.RecoveryAvailable(&p,r,sleep)); point.Point.MapId=0;point.allowed=false;assert(!mgr.RecoveryAvailable(&p,r,sleep)); point.allowed=true;point.Point.X=31;assert(!mgr.RecoveryAvailable(&p,r,sleep)); @@ -94,6 +124,11 @@ int main(){ assert(mgr.UpdateRecovery(&p,r,1000)&&p.stand==UNIT_STAND_STATE_SLEEP&&!r.Recovery.Approaching); assert(mgr.UpdateRecovery(&p,r,1000));assert(mgr.UpdateRecovery(&p,r,1000)); assert(!mgr.UpdateRecovery(&p,r,1000)); // full duration starts after arrival + r.Recovery={};p.space=false;assert(!mgr.UpdateRecovery(&p,r,1000)&&r.Recovery.Approaching);p.space=true; + r.Recovery.Destination=Position(4,0,0);r.Recovery.HasDestination=true; + assert(!mgr.UpdateRecovery(&p,r,1000));p.x=4; + assert(mgr.UpdateRecovery(&p,r,1000)); // chosen offset, not the old obstructed marker + p.space=false;r.Recovery.SpaceCheckMs=0;assert(!mgr.UpdateRecovery(&p,r,1000));p.space=true;p.x=0; r.Recovery={};travel.status.State=ClientlessTravelState::PathRejected; assert(!mgr.UpdateRecovery(&p,r,1000)); travel.status.State=ClientlessTravelState::Moving;r.Recovery.ApproachElapsed=60000; diff --git a/tests/clientless/test_adventure_rest_space.py b/tests/clientless/test_adventure_rest_space.py new file mode 100644 index 0000000000..0143b33b41 --- /dev/null +++ b/tests/clientless/test_adventure_rest_space.py @@ -0,0 +1,51 @@ +"""Run the production lie-down footprint against controlled collision geometry.""" +from pathlib import Path +import subprocess +import tempfile +root = Path(__file__).resolve().parents[2] +code = r''' +#include "AdventureRestSpace.h" +#include +using namespace AdventureRestSpace; +int main() +{ + Spot p{0,0,10,0}; + auto flat=[](float,float,float){return 10.0f;}; + auto open=[](float,float,float,float,float,float){return true;}; + auto empty=[](float,float,float){return false;}; + assert(Clear(p,2,flat,open,empty)); + auto platform=[](float,float y,float){return std::fabs(y)<1?10.0f:9.3f;}; + auto slope=[](float,float y,float){return 10.0f-std::fabs(y)*0.5f;}; + assert(Raised(p,platform));assert(!Raised(p,slope));assert(!Raised(p,flat)); + // A chair can be non-colliding: native chair occupancy must still veto it. + auto chair=[](float x,float y,float){return std::fabs(x-1.0f)<0.5f && std::fabs(y)<0.5f;}; + assert(!Clear(p,2,flat,open,chair)); + // Standing cylinder is clear; a wall at the sleeping body's foot is not. + auto wall=[](float,float,float,float x,float,float){return x<1.5f;}; + assert(!Clear(p,2,flat,wall,empty)); + auto underTable=[](float,float,float z,float,float,float c){return !(z<10.7f && c>10.7f);}; + assert(!Clear(p,2,flat,underTable,empty)); + auto ledge=[](float x,float,float){return x>1?8.0f:10.0f;}; + assert(!Clear(p,2,ledge,open,empty)); + auto stairs=[](float x,float,float){return x>0.5f?10.3f:10.0f;}; + assert(!Clear(p,2,stairs,open,empty)); + auto missing=[](float,float,float){return -100000.0f;}; + assert(!Clear(p,2,missing,open,empty)); + auto nan=[](float,float,float){return NAN;}; + assert(!Clear(p,2,nan,open,empty)); + // Rotate the whole body into a corridor; the unrotated pose hits its wall. + auto corridor=[](float,float,float,float x,float,float){return std::fabs(x)<0.8f;}; + assert(!Clear(p,2,flat,corridor,empty));p.O=1.5707963f; + assert(Clear(p,2,flat,corridor,empty)); + // A larger model requires proportionally more space. + assert(!Clear(p,4,flat,corridor,empty)); + assert(BedName("Fancy Bed") && BedName("Hospital Bed") && BedName("Bedroll")); + assert(!BedName("Flowerbed") && !BedName("Wooden Chair") && !BedName("Bedside Table")); +} +''' +with tempfile.TemporaryDirectory() as tmp: + src=Path(tmp)/'space.cpp';src.write_text(code) + exe=Path(tmp)/'space' + subprocess.run(['g++','-std=c++17','-Wall','-Wextra','-Werror','-I'+str(root/'src/server/game/World'),str(src),'-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) +print('Sleep footprint: furniture, body extent, overhead collision, ledges, stairs, missing heights, rotation and scale passed.') diff --git a/tests/clientless/test_adventure_rest_targets.py b/tests/clientless/test_adventure_rest_targets.py new file mode 100644 index 0000000000..b409e2fc81 --- /dev/null +++ b/tests/clientless/test_adventure_rest_targets.py @@ -0,0 +1,88 @@ +"""Exercise native sleep-space and candidate adapters with map/object fixtures.""" +from pathlib import Path +import subprocess,tempfile +root=Path(__file__).resolve().parents[2] +source=(root/'src/server/game/World/AdventureBehaviorMgr.cpp').read_text() +def function(signature): + start=source.index(signature);body=source.index('{',start);end=body+1;depth=1 + while depth: + depth+=(source[end]=='{')-(source[end]=='}');end+=1 + return source[start:end] +code=r''' +#include "AdventureRestSpace.h" +#include +#include +#include +using uint32=uint32_t; +#define TC_LOG_INFO(...) ((void)0) +constexpr float INVALID_HEIGHT=-100000; +constexpr int GAMEOBJECT_TYPE_CHAIR=7, LINEOFSIGHT_ALL_CHECKS=0; +namespace VMAP{enum class ModelIgnoreFlags{Nothing};} +struct Position{ + float x=0,y=0,z=10,o=0; + Position()=default;Position(float a,float b,float c,float d=0):x(a),y(b),z(c),o(d){} + float GetPositionX()const{return x;}float GetPositionY()const{return y;} + float GetPositionZ()const{return z;}float GetOrientation()const{return o;} + float GetExactDist(float a,float b,float c)const{return std::sqrt((x-a)*(x-a)+(y-b)*(y-b)+(z-c)*(z-c));} + float GetExactDist2d(float a,float b)const{return std::hypot(x-a,y-b);} +}; +struct GameObject:Position{ + std::string name="Wooden Chair";bool phase=true,spawned=true;int type=7; + bool isSpawned(){return spawned;}auto GetName(){return name;}int GetGoType(){return type;} + float GetObjectScale(){return 1;} +}; +struct Creature:Position{bool phase=true;float GetBoundaryRadius(){return 0.5f;}}; +struct PositionFullTerrainStatus{bool outdoors=false;}; +struct Terrain{ + bool outdoors=false; + void GetFullTerrainStatusForPosition(int,int,float,float,float,PositionFullTerrainStatus& t){t.outdoors=outdoors;} +}; +struct Map{ + Terrain terrain;bool water=false,bed=false;float level=10; + float GetHeight(int,float x,float y,float probe,bool,float){ + float h=bed && std::fabs(x-4)<2.1f && std::fabs(y)<0.95f?10.7f:level; + return probe>=h?h:level; + } + float GetWaterLevel(int,float,float){return water?12:-100000;} + bool isInLineOfSight(int,float,float,float,float,float,float,int,VMAP::ModelIgnoreFlags){return true;} + Terrain* GetTerrain(){return &terrain;} +}; +struct Player:Position{ + bool phase=true;Map map;std::vector objects;std::vector people;std::vector creatures; + struct Guid{uint32 GetCounter(){return 26;}};Guid GetGUID(){return {};} + Map* GetMap(){return ↦}int GetPhaseShift(){return 0;}int GetMapId(){return 0;} + float GetCollisionHeight(){return 2;} + templatebool IsInPhase(T* p){return p->phase;} + void GetGameObjectListWithEntryInGrid(std::vector& v,int,float){v=objects;} + void GetPlayerListInGrid(std::vector& v,float){v=people;} + void GetCreatureListWithEntryInGrid(std::vector& v,int,float){v=creatures;} +}; +''' +code+=function(' bool SleepSpace(')+'\n'+function(' std::vector SleepCandidates(') +code+=r''' +int main(){ + Player p;Position anchor(0,0,10);GameObject chair;p.objects={&chair}; + assert(!SleepSpace(&p,anchor)); + chair.phase=false;assert(SleepSpace(&p,anchor));chair.phase=true; + chair.spawned=false;assert(SleepSpace(&p,anchor));chair.spawned=true; + chair.z=3;assert(SleepSpace(&p,anchor));chair.z=10; + auto candidates=SleepCandidates(&p,anchor,true); + assert(!candidates.empty()); // shift off the chair, rather than merely reject the marker + for(auto const& c:candidates){assert(c.GetExactDist2d(0,0)>1);assert(std::fabs(c.z-10)<0.01f);} + p.map.terrain.outdoors=true;assert(SleepCandidates(&p,anchor,true).empty());p.map.terrain.outdoors=false; + p.map.water=true;assert(SleepCandidates(&p,anchor,true).empty());p.map.water=false; + p.map.level=3;assert(SleepCandidates(&p,anchor,true).empty());p.map.level=10; // not the storey below + p.objects.clear();Player visitor;visitor.x=1;p.people={&visitor};assert(!SleepSpace(&p,anchor));p.people.clear(); + Creature npc;npc.x=1;p.creatures={&npc};assert(!SleepSpace(&p,anchor));p.creatures.clear(); + GameObject bed;bed.name="Hospital Bed";bed.type=5;bed.x=4;p.objects={&bed};p.map.bed=true; + candidates=SleepCandidates(&p,anchor,true);assert(!candidates.empty()); + assert(candidates.front().x==4 && std::fabs(candidates.front().z-10.7f)<0.01f); // real bed before floor + p.objects.clear();candidates=SleepCandidates(&p,anchor,true);assert(!candidates.empty()); + assert(std::fabs(candidates.front().z-10.7f)<0.01f); // static raised surface before floor +} +''' +with tempfile.TemporaryDirectory() as tmp: + src=Path(tmp)/'targets.cpp';src.write_text(code);exe=Path(tmp)/'targets' + subprocess.run(['g++','-std=c++17','-Wall','-Wextra','-Werror','-I'+str(root/'src/server/game/World'),str(src),'-o',str(exe)],check=True) + subprocess.run([str(exe)],check=True) +print('Native sleep targets: chair exclusion, phase, despawn, floor search, indoors, water, storey, occupancy, bed/surface preference passed.')