Implement autonomous AdventureBot intents and social micro actions
clang PCH / build (push) Canceled after 0s
GCC PCH / build (push) Canceled after 0s

Add profile-driven daily schedules, semantic location import and editable catalogs, flexible intent execution and safe graph handovers. Implement coherent social scenes, native chair use and campfires, frontal conversation approaches and walking. Fix clientless near-teleport observer identity and native flight area queries. Include migrations, regression tests and implementation documentation.
This commit is contained in:
Nico committed 2026-10-26 22:45:38 +01:00
1 parent ca656403bb
commit 119d63ed1c
36 files changed
+3221 -10

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
# Autonomes Verhalten: Implementierungsplan und Vertrag
Dieser Stand ersetzt die Lua-Tagesroutinen-Annahme aus `mikroaction.md`.
Lua beschreibt Profile; der C++-Planer erzeugt daraus einen Tagesplan.
- 24 Stundenplätze nach lokaler Serverzeit, Reisezeit eingeschlossen. Ein Intent
belegt 1–4 zusammenhängende Stunden; Ziele dürfen stündlich wechseln oder bleiben.
Aufenthalte verlängern die festen Zeitfenster nicht.
- Gewichtete Auswahl berücksichtigt Persönlichkeit, Aktivitäten und Regionen.
- Locationtypen und Botaktionen werden in bearbeitbaren Katalogen mehreren Intents
zugeordnet. Unklare Orte bleiben `landmark`, erkennbare Namen werden großzügig
klassifiziert. Importierte Definitionen und manuelle Korrekturen werden erhalten.
- Der Reiseexecutor nähert sich unerreichbaren Zielen bestmöglich über den Graphen;
anschließend wird die nächste passende Ersatzlocation gesucht, ohne Entfernungsgrenze.
- Ein Gebietsintent benötigt einen erreichbaren Graphnode innerhalb des Gebiets.
Die lokale Erkundung darf das Gebiet nicht verlassen.
- Bei einem Planwechsel wird eine laufende Fernreise bis zum nächsten sicheren
regulären Graphnode beendet; Taxi, Portal, Schiff und Flug werden nicht gewaltsam
unterbrochen. Danach wird das neue Ziel geplant.
- Mikroaktionen: umsehen, erkunden, sitzen/rasten, trinken, essen, Emote, sprechen,
Arbeit darstellen. Aktions- und Variantenhistorien senken Wiederholungsgewichte.
Arbeit verändert weder Ressourcen noch Inventar. Echte Kampf-KI bleibt später.
- Der erste Test ist `social` in `goldshire_tavern`; Arthorans Tagesplan bleibt aus.
## Umsetzungsschritte
1. Ortsimport und Katalogmigration mit Herkunft, Gebiets-/Zonenmetadaten.
2. Profilgesteuerter Tagesplan, gespeicherte Stundenplätze und autonome Intent-Steuerung.
3. Sichere Reiseübergabe, Ersatzziele, kontextabhängige Mikroaktionen und Diagnose.
4. Automatisierte Prüfungen, Build, Installation und gezielter Tavernen-Servertest.
Visuelle Abnahme erfolgt durch den Nutzer.
## Bedienung
Einmalig die additive Migration auf der Test-Weltdatenbank ausführen:
`sql/custom/world/2026_10_26_03_adventure_behavior.sql`.
Nach Installation des Builds in der Serverkonsole (ingame mit führendem Punkt):
```text
clientless adventure import
clientless adventure reload
clientless adventure status 26
clientless adventure plan 26
clientless adventure intent 26 social goldshire_tavern 3600
clientless adventure schedule 26 on
clientless adventure schedule 26 off
clientless adventure stop 26
```
`plan` erzeugt beziehungsweise zeigt den Tagesplan, aktiviert ihn aber nicht.
`intent` fordert selbstständige Reise und anschließende Mikroaktionen an;
seine Dauer dient gezielten Tests und startet mit dem Auftrag. Die normale
Tagesplanung verwendet dagegen feste Stundenfenster. `stop` und Planwechsel
warten sichere Reisegrenzen ab. Weder Ressourcen noch Inventar werden für
Mikroaktionen verändert. Arbeit wird nur an sichtbaren passenden Objekten mit
geprüftem lokalen Anlauf dargestellt. Echte Kämpfe sind noch kein Teil des Katalogs.
Lua-Profile erhalten zusätzlich:
```lua
behavior = {
enabled = true,
daily_schedule = false,
initial_intent = "social",
initial_location = "goldshire_tavern",
initial_duration_seconds = 3600,
locale = "deDE",
},
micro_action_affinity = { drink = 1.5, rest = 1.2 },
```
`daily_schedule = true` startet stattdessen den Profilplaner. Ohne explizites
`behavior.enabled` bleiben bisherige Clientless-Controller unverändert.
`intend("social", {location="goldshire_tavern"})` bleibt als semantischer
manueller Auftrag möglich; Lua berechnet weder Tagesplan noch Mikroaktionen.
## Daten und Korrekturen
- `vcore_clientless_world_point`: Kategorie, Position, enabled wie bisher.
- `vcore_adventure_location`: Herkunft, Anzeigename, Gebiet, Zone, Region,
Gebietsmarkierung und optionaler Fraktionsfilter (0 beliebig, 1 Allianz, 2 Horde).
- `vcore_adventure_location_intent`: mehrere Intents mit Gewichten je Kategorie.
- `vcore_adventure_action` und `vcore_adventure_action_intent`: Dauer,
Umgebungsbedingung, Darstellung und mehrere passende Intents.
- `vcore_adventure_dialog`: gemeinsam verwendete Textvarianten pro Sprache und Intent.
- `vcore_adventure_day`: 24 gespeicherte Stundenplätze je Charakter und lokalem Datum.
Importer und Migration verwenden für Kataloge und Ortsdefinitionen `INSERT IGNORE`.
Damit bleiben manuelle Positions-, Typ-, Gewichts- und enabled-Korrekturen erhalten.
Anschließend `clientless adventure reload` ausführen; bei geänderten Worldpoints
zusätzlich deren bestehende Reload-Funktion verwenden oder den Import wiederholen.
Die Quellen sind `game_tele`, Wachposten-POIs und Gasthaus-/Berufsausbilder-Spawns.
Wachposten-POIs besitzen nur X/Y: Ihre Karte wird aus dem räumlich nächsten
Teleport-Ort geschätzt, ihre Höhe stammt aus der nativen Terrain-/VMAP-Abfrage.
Diese Einträge sind als `guard_poi_inferred` gekennzeichnet und dürfen später
korrigiert werden. Benannte Gesamtgebiete werden über native Gebietsmetadaten
erkannt. Ziele in Instanzkarten werden importiert, jedoch nicht als autonome
Aufenthaltsorte gewählt. Bekannte Instanzeingänge auf Außenweltkarten bleiben
gewöhnliche Reiseziele; eine vollständige Dungeon-Aktivität ist ein späterer Schritt.
## Prüfung
```bash
python3 tests/clientless/test_adventure_behavior_policy.py
python3 tests/clientless/test_adventure_intent_state.py
python3 tests/clientless/test_flight_area_query.py
```
Weitere Regressionen prüfen Lua-Reload, Graphplanung, Flug, Portale und Transporte.
Neue Logereignisse: `ADVENTURE_CATALOG`, `DAY_PLAN`, `INTENT_TRAVEL`,
`INTENT_FALLBACK`, `INTENT_ARRIVED`, `MICRO_START`.
Der erste Live-Test aktiviert ausschließlich Arthorans Geselligkeit in Goldhain.
Die sichtbare Abnahme sowie die weiteren Intent-/Tagesplan-Tests folgen mit dem Nutzer.
## Bisherige Servertests am 26.10.2026
- Import: 2.631 Definitionen (1.531 Teleport-Orte, 372 rekonstruierte
Wachposten-POIs, 728 Gasthaus-/Berufsausbilder-Spawns). Zusammen mit neun
bestehenden semantischen Locations stehen 2.640 Locations im Katalog.
Darunter 33 Gesamtgebietsziele. 231 POIs ohne brauchbare Koordinaten/Höhe
wurden übersprungen. Ein Wiederholungsimport erzeugte keine Duplikate.
- Bestehende Goldhainer Taverne unverändert; native Zone 12, Region `elwynn`.
- Arthoran reiste eigenständig von Goldhains Startpunkt in die Taverne.
Reiseabschluss, `INTENT_ARRIVED` und wiederholte `MICRO_START` bestätigt.
Beobachtete Aktionen im Log: sprechen, sitzen, winken, trinken, erkunden,
essen, lachen und nicken. Dies bestätigt serverseitige Ausführung, keine
visuelle Abnahme der Animationen.
- Tagesplan-Vorschau: genau 24 persistierte Stundenplätze mit mehrstündigen
Aufenthalten und wechselnden Zielen. `daily_schedule` bleibt für Arthoran aus.
- Die native Gebietsanfrage der Fluglogik verwendete die Argumentreihenfolge
des TerrainMgr statt TerrainInfo. Korrigiert und mit einer eigenen
Produktionsmethoden-Regression für Karte/Phase, Gebiet/Zone und Cache geprüft.
Weitere Reise-/Ersatzziel-, Gebiets-, Arbeitsdarstellungs- und Stundenwechseltests
bleiben für die nächsten gemeinsamen Testschritte vorgesehen. Automatisierte
Tests decken die Zustandsübergänge und Auswahlregeln bereits ab.
Zusätzliche Regressionen für Reisewechsel und Mikroaktionsbereinigung:
```bash
python3 tests/clientless/test_adventure_graph_handover.py
python3 tests/clientless/test_adventure_micro_reset.py
```
Die Bereinigung stoppt die tatsächliche lokale Spline, statt nur ihren
Navigationsdatensatz zu entfernen. Laufende Schiffsfahrten, Taxiflüge,
freie Flüge und Portaltransfers werden dabei weiterhin nicht abgebrochen.
## Vollständiger Geselligkeitskatalog
Migration nach der Grundmigration:
`sql/custom/world/2026_10_26_04_adventure_social.sql`.
Die neuen `social_*`-Szenen ergänzen Essen, Trinken, Umsehen, Platzwechsel und
kurze Emotes: NPC- und Botgespräch, Sitzplatzsuche, Tresen, Bestellung,
Begrüßung, Zuhören, Gesprächsreaktion, Anstoßen, Abschied, Kommentar,
seltenes Tanzen und Lagerfeuer mit Rast. Gesprächspartner werden selbstständig
angelaufen und anvisiert; `/reden`, Zuhören und Abschied bilden einen Ablauf.
NPCs und andere Bots werden dabei nicht künstlich zu Antworten gezwungen.
Menschliche Spieler sind keine gezielt ausgewählten Gesprächspartner.
Aktionsart, Variante, Dialogzeile und Ziel-GUID erhalten Wiederholungsdämpfung.
Sitzplätze werden mit der nativen GameObject-Benutzung verwendet; der Bot läuft
zuerst heran und wartet danach auf bestätigtes Sitzen. Möbel mit ausgelösten
Events werden nicht verwendet. Essen, Trinken und Rasten versuchen ebenfalls,
einen verfügbaren echten Stuhl zu nutzen; sonst bleibt die bisherige Darstellung.
Alle AdventureBots erhalten bei ihrem normalen Controller-Login den Spielerzauber
**818 – Cooking Fire**, unabhängig von Berufsprofil oder Kochkunstprüfung.
Ein eigenes Feuer wird ausschließlich über den normalen Zauber erzeugt und erst
nach Auftauchen des nativen Feuerobjekts bestätigt. Native Castbedingungen und
Cooldown gelten. Neue Feuer werden nur draußen, an einer kleinen ebenen Stelle
und außerhalb von Wasser erzeugt; vorhandene geeignete Feuer können auch ohne
neuen Cast genutzt werden. Die normale Lebensdauer des Feuerobjekts bleibt erhalten.
Gezielter Szenentest bei einem bereits am Ziel angekommenen AdventureBot:
```text
clientless adventure micro 26 social_npc
clientless adventure micro 26 social_order
clientless adventure micro 26 social_seat
clientless adventure micro 26 social_make_fire
clientless adventure status 26
```
Der Testbefehl verwendet dieselben Umgebungs-, Ziel-, Navigations- und nativen
Interaktionsprüfungen wie die automatische Auswahl. Ungeeignete oder fehlende
Ziele werden abgelehnt. Die automatische Tagesplanung wird dadurch nicht aktiviert.
Neue Diagnoseereignisse: `SOCIAL_START`, `SOCIAL_CUE`, `SOCIAL_SEATED`,
`SOCIAL_FIRE_CAST`, `SOCIAL_FIRE_CONFIRMED`, `SOCIAL_END`.
```bash
python3 tests/clientless/test_adventure_social.py
```
Die Regression führt die produktive Szenensteuerung aus: kein Reden während
des Anlaufs, vollständige Gesprächsfolge, Abbruch bei verschwundenen/feindlichen
Zielen, native Stuhlbenutzung mit Sitzbestätigung und Lagerfeuer-Cast ohne
erfundene Erfolgsmeldung bei fehlendem Feuerobjekt.
Die Möbel-Suche verlangt noch keine freie Sicht vom ursprünglichen Standpunkt:
Stühle in angrenzenden Räumen dürfen über einen vollständigen lokalen Laufweg
gefunden werden. Am gewählten Anlaufpunkt und unmittelbar vor der Benutzung
wird die native Sichtlinie geprüft. Gesprächspartner dürfen beim Umrunden von
Hindernissen kurz aus dem Blick geraten; Gesten beginnen erst nach Ankunft.
Dynamische Lagerfeuer werden zusätzlich über die native erzeugende Zauber-ID
818 erkannt, unabhängig vom lokalisierten Objektnamen.
Zusätzliche Prüfung:
```bash
python3 tests/clientless/test_adventure_social_fire.py
```
## Servertest Geselligkeitsausbau am 26.10.2026
- Nativer NPC-Gesprächsanlauf und vollständige Folge aus Begrüßung, `/reden`,
Zuhören, Reaktion, erneutem Reden und Abschied bestätigt.
- Bestellung und Tresenaufenthalt beim tatsächlichen Goldhainer Gastwirt
(Creature-Entry 295) bestätigt.
- Echte Stuhlbenutzung mit `SOCIAL_SEATED` bestätigt, unter anderem
GameObject-Entries 22812 und 177498. Trinken auf dem nativen Stuhl ebenfalls
serverseitig ausgeführt. Keine künstliche Sitzposition gesetzt.
- Gemeinsamer Lagerfeuerzauber 818 bei Arthoran als gelernt gespeichert.
- Außenversuch: `SOCIAL_FIRE_CAST` und `SOCIAL_FIRE_CONFIRMED`, natives
GameObject-Entry 29784. Kein direkter GameObject-Spawn durch die Botsteuerung,
keine Kochkunstprüfung und keine Umgehung des normalen Cooldowns.
- Der temporäre Außen-Testpunkt ist kein Graphnode und wird nach dem Test
entfernt; Arthoran kehrt zur Goldhainer Taverne zurück.
Die visuelle Abnahme der neuen Szenen erfolgt durch den Nutzer. Ein tatsächlicher
Dialog zwischen mehreren AdventureBots wurde mit dem einzelnen Testbot noch
nicht im Spiel geprüft; die Auswahl und einseitige Ansprache sind implementiert.
Abschließender Teststand: Rasten am bereits vorhandenen Feuer während des
normalen Zaubercooldowns bestätigt (`social_fire`, Rast-Cue 14). Arthoran ist
über normale Navigation zur Goldhainer Taverne zurückgekehrt. Der temporäre
Außenpunkt wurde entfernt und die Ortsdaten erneut abgeglichen. Der Tagesplan
bleibt deaktiviert; der neue Geselligkeitskatalog läuft zur Sichtprüfung.
## Gesprächsposition und Gehmodus
Gesprächsplätze liegen vor der tatsächlichen Orientierung des Gegenübers.
Bevorzugt werden 2,2 Yards Abstand, bei Hindernissen auch 3,2 Yards und
leicht schräge Positionen (etwa ±16 beziehungsweise ±32 Grad). Ein naher Platz
seitlich oder hinter dem NPC gilt nicht als fertiger Gesprächsanlauf. Dreht sich
der NPC, wird die Position vor den nächsten Gesten erneut geprüft und bei Bedarf
begrenzt nachgeführt. Ist kein frontaler Platz erreichbar, wird die Szene beendet,
statt von hinten weiterzureden. NPCs werden dabei nicht künstlich gedreht.
Lokale Bewegungen während eines Geselligkeits-Intents verwenden den nativen
Gehmodus. Er bleibt bei Neuplanung und mehreren lokalen Splineabschnitten erhalten;
für diese Bewegungen wird kein Bodenmount verwendet. Andere Reiseaufträge behalten
ihre bisherige Fortbewegungswahl. Das Navigationslog zeigt `walk=1`, das Soziallog
mit `SOCIAL_FRONT` den Winkel und Abstand zum Gegenüber.
```bash
python3 tests/clientless/test_adventure_walk.py
python3 tests/clientless/test_adventure_social.py
```
Servertest der Korrektur: Ein NPC-Gespräch wurde mit frontalem Winkelversatz
0,000 und 2,20 Yards Abstand ausgeführt. Sowohl der Weg zum Stuhl als auch der
Gesprächsanlauf wurden als `walk=1` über die native Spline ausgeführt. Build mit
`-j10` und die zugehörigen Regressionen bestanden. Visuelle Bestätigung erfolgt
anschließend durch den Nutzer.
## Sitzposition für beobachtende Spieler
Ein clientloser Spieler sendet keine normalen Client-Bewegungspakete. Dadurch
konnte seine `MovementInfo.guid` leer bleiben. Die native Nahteleport-Bestätigung
benutzte diese Struktur für die Nachricht an Beobachter: Die Sitzpose konnte
sichtbar werden, während die kleine Versetzung auf den Stuhl beim Beobachter
fehlte. `CompleteNearTeleport` setzt deshalb die tatsächliche Mover-GUID vor
dem Senden ausdrücklich; clientlose Sessions erhalten einen frischen Zeitstempel.
Normale Clients behalten ihren Bewegungszeitstempel.
`SOCIAL_SEATED` erfordert nun den tatsächlichen Stuhl- beziehungsweise Bankslot
(maximal 0,15 Yards horizontaler Fehler), passende Höhe, Ausrichtung und die
zur Stuhlhöhe gehörende native Sitzanimation. Bodensitzen oder eine Sitzpose
neben dem Stuhl gelten nicht als Erfolg. Die Prüfung bleibt während der
Stuhlszene aktiv. Das Log enthält Sitzposition, Slotfehler, Sitzstatus und
Bewegungs-GUID.
```bash
python3 tests/clientless/test_clientless_near_teleport_observer.py
python3 tests/clientless/test_adventure_social.py
```
Erneuter Servertest: Stuhl-Entry 22812, Sitzposition
`(-9475.510, 29.792, 56.343)`, Slotabweichung `0.000`, native Sitzhaltung `5`,
Bewegungs-GUID `26`. Die Beobachternachricht und die genaue Sitzprüfung sind
installiert; die visuelle Bestätigung erfolgt durch den Nutzer.
+84
View File
@@ -0,0 +1,84 @@
Wir hatten die Tagesroutinenplanung und die Mikroaktionen als zwei aufeinander aufbauende Systeme für die AdventureBots vorgesehen. Dabei war uns besonders wichtig, dass AdventureBots keine vollständig autonomen Spieler simulieren müssen, sondern vor allem glaubwürdige, sichtbare Abenteurer in der Spielwelt darstellen sollen.
1. Tagesroutinenplanung
Der Tagesroutinenplaner soll festlegen, was ein AdventureBot im Laufe seines Tages unternehmen möchte, ohne jeden einzelnen Handlungsschritt vorzugeben.
Wir hatten dafür serverseitige Lua-Skripte vorgesehen:
- scripts/clientless/adventure/generic.lua als allgemeines Skript für AdventureBots mit abwechslungsreichem, teilweise zufälligem Tagesablauf.
- Individuelle Overrides, beispielsweise overrides/arthoran.lua, um bestimmten Charakteren eigene Verhaltensweisen zu geben.
- Einen getrennten Bereich für Testskripte.
Ein Tagesablauf könnte beispielsweise beinhalten, dass Arthoran zunächst nach Eisenschmiede reist, dort eine Taverne besucht, später ein Abenteuer unternimmt und anschließend zu einem anderen Ort weiterzieht.
Die Tagesroutine beschreibt dabei lediglich die größeren Vorhaben, nicht jeden einzelnen Bewegungsschritt oder jede Animation.
2. Intents als Verbindung zwischen Planung und Handlung
Wir hatten ausdrücklich zwischen einem Intent (Absicht) und den daraus entstehenden Actions (Aktionen) unterschieden.
Die Lua-Schnittstelle sollte dafür eine semantische Anweisung wie intend anbieten.
Beispiele:
Intent Bedeutung
Eine Taverne in Eisenschmiede besuchen Dort einkehren und Bier trinken
In die Todesminen gehen Ein Abenteuer erleben und gegen Gegner kämpfen
Einen anderen Ort besuchen Dorthin reisen und eine entsprechende Tätigkeit ausüben
Lua beschreibt das Vorhaben. Der Core entscheidet, welche konkreten Aktionen zur Erfüllung erforderlich sind.
Die Lua-Skripte sollen also weder den Reiseweg selbst berechnen noch Animationen, Getränke, Kämpfe oder einzelne Interaktionsschritte manuell koordinieren müssen.
3. Mikroaktionen während eines Intents
Mikroaktionen sind die kleinen Handlungen, durch die der Bot sein übergeordnetes Vorhaben glaubwürdig ausführt.
Wir hatten insbesondere folgende Beispiele besprochen:
Situation Mögliche Mikroaktionen
Taverne besuchen Hinsetzen, trinken, essen, aufstehen
Aufenthalt in einer Siedlung Sich umsehen, Emotes verwenden, mit NPCs oder anderen Bots sprechen
Abenteuer unternehmen Auf Monster treffen, kämpfen, sich zwischendurch ausruhen
Unterwegs sein Pausieren, rasten und situationsabhängige Handlungen ausführen
Der wichtige Grundsatz war, dass Mikroaktionen zum aktuellen Intent und zur Umgebung passen müssen.
Ein Bot, der eine Taverne besucht, soll dort nicht einfach nur stehen bleiben. Er soll durch passende Handlungen vermitteln, dass er tatsächlich eingekehrt ist.
Genauso soll ein Bot, der auf Abenteuerreise ist, nicht bloß von Location zu Location laufen, sondern auch entsprechende Aktivitäten ausführen können.
4. Glaubwürdige Darstellung statt vollständiger Simulation
Hier hatten wir eine wesentliche Einschränkung festgelegt:
AdventureBots sind in erster Linie RP-Darsteller, keine vollständig simulierten menschlichen Spieler.
Daraus ergeben sich unterschiedliche Anforderungen an die Ausführung.
Sichtbare Aktivitäten: Reisen, Tavernenszenen, Gespräche und andere Handlungen in der offenen Spielwelt sollen für echte Spieler möglichst glaubwürdig stattfinden.
Abstrahierte Aktivitäten: Nicht alles muss vollständig durchgespielt werden. Als Beispiel hatten wir die Todesminen besprochen: Ein AdventureBot könnte sichtbar den Dungeon betreten, sein Abenteuer anschließend intern abstrahiert absolvieren und später an einem plausiblen Ort wieder erscheinen.
Das bedeutet auch, dass ein Tavernengast nicht zwangsläufig ein vollständiges NPC-Handelssystem durchlaufen muss, nur um ein Bier zu trinken.
Bei Kämpfen gegen Monster hatten wir sie als Bestandteil von Abenteuer-Intents vorgesehen. Eine detaillierte Kampf-KI mit festgelegten Fähigkeiten, Taktiken und Entscheidungsregeln hatten wir in diesem Zusammenhang allerdings noch nicht ausgearbeitet.
5. Dialoge und soziale Mikroaktionen
Für Tavernenszenen hatten wir außerdem gemeinsame, lokalisierte Standarddialoge vorgesehen.
Ein Beispiel war:
„Noch ein Bier bitte.“
Die Bots sollen solche Dialoge und passende Emotes situationsabhängig verwenden können, statt für jede Szene individuelle Texte zu benötigen.
Da sie echte clientlose Player verwenden, sind serverseitige Chatfunktionen grundsätzlich verfügbar.
Komplexere direkte Interaktionen mit menschlichen Spielern, etwa Gruppenspiel oder eine umfangreichere soziale KI, hatten wir ausdrücklich auf später verschoben.
6. Entwicklungsreihenfolge
Die Reihenfolge war ebenfalls klar:
Clientless Player Core
Stabile Reiseplanung und Navigation
Intent-System und Tagesroutinen
Kontextabhängige Mikroaktionen
Das Navigationssystem ist dabei das Rückgrat. Es soll einen Bot zuverlässig zu den Orten bringen können, die seine Tagesroutine auswählt.
Erst darauf baut die eigentliche Verhaltenssteuerung auf.
Zusammengefasst
Unser bisheriges Konzept lautet:
Der Tagesroutinenplaner bestimmt, was der Bot unternehmen möchte. Ein Intent beschreibt das konkrete Vorhaben. Der Core organisiert die Umsetzung, und Mikroaktionen sorgen dafür, dass diese Tätigkeit in der Spielwelt glaubwürdig dargestellt wird.
Dabei müssen nur die für andere Spieler relevanten Aktivitäten tatsächlich sichtbar simuliert werden. Aufwendige Vorgänge können abstrahiert werden, wenn das dem Zweck der AdventureBots nicht widerspricht.
+150
View File
@@ -0,0 +1,150 @@
# Vorschlag: Mikroaktionskatalog der AdventureBots
Stand: 26.10.2026. Dieser Katalog beschreibt die vereinbarten Aktionen.
Die Geselligkeitsaktionen sind implementiert; weitere Abenteuer- und
Berufsaktionen bleiben Ausbaustufen. Den geprüften Implementierungsstand
beschreibt [autonomous-behavior.md](autonomous-behavior.md).
Die Aktionen werden nach den drei Intents gewichtet und über
Umgebungsbedingungen freigegeben. Einige Aktionen dürfen in allen drei
Katalogen vorkommen, erhalten aber jeweils andere Gewichte und Varianten.
## Gemeinsame Grundfähigkeit: Lagerfeuer entzünden
Alle AdventureBots sollen selbst ein Lagerfeuer entzünden können. Diese
Fähigkeit gehört unabhängig vom Berufsprofil zur gemeinsamen Grundausstattung;
die Mikroaktionsauswahl prüft dafür weder Kochkunst noch eine Berufsstufe.
Bei der Ausstattung der Bots wird der reguläre Spielerzauber **818 – Cooking
Fire (Kochfeuer)** sichergestellt. Die ID wurde am 26.10.2026 in den vom
Testserver geladenen nativen Zauberdaten bestätigt. Die SQL-Tabelle `spell_dbc`
enthält hierfür keinen eigenen Datensatz; der Zauber stammt aus den DBC-Daten.
Ein Rastablauf kann damit lauten: geeigneten Rastplatz aufsuchen → vorhandenes
Feuer nutzen oder selbst ein Lagerfeuer entzünden → hinsetzen → essen, trinken
oder pausieren → weiterziehen. Vorhandene Feuer sind eine Möglichkeit, keine
Voraussetzung. Das eigene Feuer verwendet den normalen Zauber und dessen
native Lebensdauer. Die Geselligkeitsumsetzung verwendet dafür den nativen Zauber und prüft
das tatsächlich entstandene Feuerobjekt.
## Trinkfreude und Geselligkeit
Als Ergänzung ist das Ansprechen naher NPCs vorgesehen: Der Bot sucht einen
nahen, freundlichen NPC, läuft über lokale Navigation auf Gesprächsabstand
heran, richtet sich zu ihm aus und führt `/reden` aus. Anschließend bleibt er
kurz stehen, zeigt gegebenenfalls eine weitere Gesprächsgeste und
verabschiedet sich wieder. Der NPC muss dafür keine Antwort simulieren.
| Aktion | Darstellung und Bedingungen |
|---|---|
| **Trinken** | Ein Getränk trinken, anschließend eine Pause einlegen. In Tavernen besonders häufig. |
| **Essen** | Eine kleine Mahlzeit darstellen, bevorzugt während eines längeren Aufenthalts. |
| **Einen Sitzplatz aufsuchen** | Zu einer erreichbaren Sitzgelegenheit gehen und sich setzen. Falls keine geeignete vorhanden ist, stehen bleiben oder einen passenden Rastplatz verwenden. |
| **Zum Tresen gehen** | Einen erreichbaren Platz beim Gastwirt aufsuchen und dort verweilen. |
| **Ein Getränk bestellen** | Zum Gastwirt schauen, `/reden` ausführen und gelegentlich eine passende Bestellzeile sagen. Kein tatsächlicher Kauf erforderlich. |
| **Mit einem NPC sprechen** | Zu einem nahen freundlichen NPC gehen, ausrichten und `/reden` ausführen. Gesprächspartner nicht ständig wechseln. |
| **Einen anderen Bot ansprechen** | Wie beim NPC-Gespräch; zunächst ohne aufwendige gegenseitige Dialogsteuerung. |
| **Jemanden begrüßen** | Bei einem passenden Gegenüber winken oder sich verbeugen. |
| **Einem Gespräch zuhören** | Zum Gegenüber schauen und ruhig stehen bleiben, gelegentlich nicken. |
| **Auf ein Gespräch reagieren** | Situationsabhängig lachen, zustimmen, eine Fragegeste oder eine nachdenkliche Geste zeigen. |
| **Anstoßen oder jubeln** | Seltene gesellige Reaktion mit passendem Emote beziehungsweise kurzer Textzeile. |
| **Sich im Raum umsehen** | Blickrichtung wechseln und andere Gäste oder den Raum betrachten. |
| **Den Platz wechseln** | Eine kurze Strecke innerhalb der Location laufen und an einem anderen geeigneten Punkt verweilen. |
| **Lagerfeuer entzünden** | An einem geeigneten Rastplatz mit dem gemeinsamen Grundzauber 818 ein eigenes Feuer entzünden; keine Kochkunst-Prüfung. |
| **Am Feuer sitzen** | Ein vorhandenes geeignetes Feuer nutzen oder selbst eines entzünden, sich hinsetzen und rasten. |
| **Tanzen** | Selten und bevorzugt bei Festen; in einer gewöhnlichen Taverne deutlich niedriger gewichtet. |
| **Sich verabschieden** | Ein kurzer Abschiedsgruß vor dem Verlassen der Location oder nach einem Gespräch. |
Für den Tavernenaufenthalt sollten **Sitzen, Trinken, Essen und Gespräche** den
Hauptanteil bilden. Tanzen, Jubeln und auffällige Emotes bleiben selten, damit
der Bot nicht wie ein dauernd laufendes Animationsprogramm wirkt.
## Abenteuer
Hier wird zwischen Erkundung und späteren tatsächlichen Kampfhandlungen
unterschieden.
| Aktion | Darstellung und Bedingungen |
|---|---|
| **Die Umgebung erkunden** | Unterschiedliche nahe Punkte über lokale Navigation aufsuchen. Bei Gebietszielen innerhalb des ausgewählten Gebiets bleiben. |
| **Eine Aussicht betrachten** | An einem geeigneten Punkt stehen bleiben und den Blick über die Umgebung schweifen lassen. |
| **Ein auffälliges Objekt untersuchen** | Sich einer Ruine, einem Denkmal, einem Wagen oder einem anderen geeigneten Objekt nähern und es betrachten. |
| **Etwas genauer betrachten** | Zum Objekt ausrichten und eine passende Frage-, Zeige- oder Untersuchungsgeste ausführen. |
| **Nach Gefahren Ausschau halten** | Kurz innehalten und mehrere Richtungen betrachten. Besonders passend bei Ruinen, Höhlen oder gefährlichen Gebieten. |
| **Einen Wegabschnitt abschreiten** | Innerhalb des Aufenthaltsgebiets einige erreichbare Punkte nacheinander besuchen. |
| **An einer Ruine oder Höhle verweilen** | Einen erreichbaren Punkt beim Eingang oder im sicheren Außenbereich untersuchen. |
| **Einen freundlichen Reisenden ansprechen** | Zu einem passenden NPC gehen und `/reden` ausführen, etwa bei Wachen, Forschern oder anderen Abenteurern. |
| **Auf etwas zeigen** | Ein sichtbares Objekt oder einen markanten Punkt mit einer Zeigegeste hervorheben. |
| **Eine Entdeckung kommentieren** | Selten eine passende kurze Zeile sagen; vorzugsweise bei anwesenden Gesprächspartnern. |
| **Eine Rast einlegen** | An einem geeigneten Punkt sitzen und pausieren. |
| **Am Lagerfeuer rasten** | Ein vorhandenes Feuer nutzen oder am Rastplatz selbst eines entzünden, sitzen, essen oder trinken. |
| **Proviant essen oder trinken** | Eine Pause während längerer Erkundungen darstellen. |
| **Ausrüstung prüfen** | Eine kurze, ruhige Vorbereitungspause mit einer passenden verfügbaren Animation darstellen. |
| **Vor einem Abenteuer vorbereiten** | Beim Dungeon- oder Höhleneingang verweilen, umsehen und gegebenenfalls einen kurzen Vorbereitungssatz sagen. |
| **Nach einem Abenteuer ausruhen** | Nach Rückkehr eine längere Rast, Essen oder Trinken bevorzugen. |
### Spätere Kampferweiterung
| Aktion | Noch festzulegende Ausführung |
|---|---|
| **Ein geeignetes Monster jagen** | Ziel auswählen, annähern und tatsächlich kämpfen. |
| **Einen Kampf abbrechen und fliehen** | Bei Überforderung einen erreichbaren Rückzugsort suchen. |
| **Sich nach einem Kampf erholen** | Erst nach Ende der Gefahr rasten und regenerieren. |
| **Eine Niederlage bewältigen** | Verhalten bei Tod und anschließender Rückkehr festlegen. |
**Monsterjagd sollte erst aktiviert werden, wenn diese zusammengehörigen
Verhaltensweisen definiert sind.** Die Erkundungsaktionen können bereits vorher
den Abenteuer-Intent tragen.
## Beruf
Berufsaktionen sollten sich nach dem Charakterprofil richten. Ein angehender
Schmied besucht andere Orte und Objekte als ein Kräuterkundiger. Die eigentliche
Arbeit bleibt wie besprochen zunächst Darstellung.
| Aktion | Darstellung und Bedingungen |
|---|---|
| **Eine Arbeitsstelle aufsuchen** | Zu einem passenden erreichbaren Objekt oder Arbeitsplatz gehen. |
| **Eine Erzquelle untersuchen** | An eine sichtbare Erzquelle herantreten und sie betrachten. |
| **Erzabbau darstellen** | Zur Erzquelle ausrichten und die Bergbauanimation ausführen. Keine Beute, keine Erschöpfung oder Entfernung der Quelle. |
| **Kräutersammeln darstellen** | Zu einer passenden Pflanze gehen und eine Sammelbewegung ausführen. Die Pflanze bleibt erhalten. |
| **Holzarbeit darstellen** | An geeignetem Holz, einem Holzstapel oder Arbeitsplatz eine Arbeitsanimation zeigen. Keine Veränderung des Objekts. |
| **An einer Schmiede arbeiten** | Einen erreichbaren Amboss oder eine Schmiedestelle aufsuchen und eine passende Arbeitsanimation ausführen. |
| **Schmelzarbeit darstellen** | Bei einer geeigneten Esse beziehungsweise einem Ofen verweilen und Arbeit darstellen. |
| **An einer Werkbank arbeiten** | Einen passenden Werkstattplatz aufsuchen und dort eine verfügbare Arbeitsanimation ausführen. |
| **Alchemistische Arbeit darstellen** | Bei einem geeigneten Labortisch arbeiten; ohne Gegenstände oder Tränke zu erzeugen. |
| **Schneider- oder Lederarbeit darstellen** | An einem passenden Arbeitsplatz eine geeignete Handarbeitsanimation verwenden, soweit sie verfügbar ist. |
| **Angeln darstellen** | An einem erreichbaren Ufer mit geeignetem Wasser und einer passenden Darstellung angeln. Kein Fang und keine Veränderung des Inventars. |
| **Werkzeug oder Material prüfen** | Den Arbeitsablauf durch eine kurze Prüfungspause unterbrechen. |
| **Mit einem Ausbilder sprechen** | Zu einem passenden Berufsausbilder gehen und `/reden` ausführen. Kein tatsächliches Erlernen erforderlich. |
| **Mit einem Händler sprechen** | Einen passenden Händler aufsuchen und ein kurzes Gespräch darstellen. Kein Kauf notwendig. |
| **Die Arbeitsstelle wechseln** | Einen anderen passenden Arbeitsplatz oder eine andere nahe Ressource aufsuchen. |
| **Eine Arbeitspause einlegen** | Sich umsehen, sitzen, essen oder trinken. |
| **Die Arbeit abschließen** | Die Arbeitsanimation beenden, kurz das Ergebnis betrachten und sich vom Arbeitsplatz lösen. |
Bei Berufen sollten **nur Darstellungen freigegeben werden, für die eine passende
Animation und Umgebung vorhanden sind**. Fehlt beispielsweise eine überzeugende
Schneideranimation, kann der Bot dort zunächst untersuchen, mit dem Ausbilder
sprechen und pausieren.
## Kleine Abläufe statt einzelner zufälliger Gesten
Manche Mikroaktionen benötigen einen kurzen zusammenhängenden Ablauf.
**Gespräch:** Gesprächspartner wählen → hinlaufen → ausrichten → `/reden` →
kurz zuhören → verabschieden.
**Arbeit:** Arbeitsobjekt wählen → hinlaufen → ausrichten → arbeiten →
Ergebnis betrachten → Pause.
Der gewichtete Zufall wählt solche Abläufe und ihre Varianten. Während eines
begonnenen Ablaufs würfelt der Bot nicht unvermittelt eine unpassende andere
Handlung aus. Zusätzlich werden Wiederholungen von **Aktionsart, Variante und
Zielobjekt beziehungsweise Gesprächspartner** gedämpft. Dadurch geht Arthoran
beispielsweise nicht nach jedem zweiten Getränk wieder zum selben NPC.
## Priorisierung des Ausbaus
Die Geselligkeits-Ausbaustufe umfasst NPC-/Bot-Gespräche, Sitzplatzsuche,
Tresen/Gastwirt und situationsbezogene Gesprächsreaktionen. Objektuntersuchung
und die weiteren Abenteuer- und Berufsaktionen können darauf aufbauen,
ohne bereits die Kampf-KI vorauszusetzen.
+2
View File
@@ -3,3 +3,5 @@
!2026_10_26_00_clientless_disable_taxi_585.sql !2026_10_26_00_clientless_disable_taxi_585.sql
!2026_10_26_01_clientless_stormwind_mage_tower.sql !2026_10_26_01_clientless_stormwind_mage_tower.sql
!2026_10_26_02_clientless_portal_use.sql !2026_10_26_02_clientless_portal_use.sql
!2026_10_26_03_adventure_behavior.sql
!2026_10_26_04_adventure_social.sql
@@ -0,0 +1,73 @@
-- Additive catalogs and metadata; reapplying preserves all manual corrections.
CREATE TABLE IF NOT EXISTS vcore_adventure_location (
point_key VARCHAR(100) NOT NULL PRIMARY KEY,
display_name VARCHAR(255) NOT NULL DEFAULT '',
source_type VARCHAR(32) NOT NULL DEFAULT 'manual', source_id INT UNSIGNED NOT NULL DEFAULT 0,
area_id INT UNSIGNED NOT NULL DEFAULT 0, zone_id INT UNSIGNED NOT NULL DEFAULT 0,
region_key VARCHAR(100) NOT NULL DEFAULT '', is_area TINYINT UNSIGNED NOT NULL DEFAULT 0,
faction TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '0 any, 1 alliance, 2 horde',
KEY zone_id(zone_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS vcore_adventure_location_intent (
category VARCHAR(64) NOT NULL, intent VARCHAR(32) NOT NULL,
weight FLOAT NOT NULL DEFAULT 1, PRIMARY KEY(category,intent)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO vcore_adventure_location_intent VALUES
('tavern','social',5),('festival','social',3),('settlement','social',1),
('mine','profession',3),('mine','adventure',2),('workshop','profession',4),
('service','profession',1),('ruins','adventure',3),('dungeon','adventure',3),
('landmark','adventure',1),('wilderness','adventure',3),('zone','adventure',2),
('settlement','adventure',0.5),('bank','profession',0.5);
CREATE TABLE IF NOT EXISTS vcore_adventure_action (
action_key VARCHAR(64) NOT NULL PRIMARY KEY, kind VARCHAR(32) NOT NULL,
min_seconds INT UNSIGNED NOT NULL, max_seconds INT UNSIGNED NOT NULL,
requirement VARCHAR(32) NOT NULL DEFAULT '', emote_id INT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO vcore_adventure_action VALUES
('look','look',6,14,'',0),('explore','wander',12,30,'',0),
('rest','sit',20,60,'',0),('campfire_rest','sit',25,65,'campfire',0),
('drink','drink',15,35,'',0),('eat','eat',15,35,'',0),
('wave','emote',8,18,'audience',3),('nod','emote',8,18,'audience',273),
('laugh','emote',10,24,'audience',11),('cheer','emote',10,24,'audience',4),
('speak','say',12,30,'audience',1),
('mining_show','work',15,35,'ore',233),('smithing_show','work',15,35,'forge',173);
CREATE TABLE IF NOT EXISTS vcore_adventure_action_intent (
action_key VARCHAR(64) NOT NULL,intent VARCHAR(32) NOT NULL,
weight FLOAT NOT NULL DEFAULT 1, PRIMARY KEY(action_key,intent)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO vcore_adventure_action_intent VALUES
('look','social',1),('explore','social',0.5),('rest','social',2),('campfire_rest','social',3),
('drink','social',5),('eat','social',2),('wave','social',1),('nod','social',1),
('laugh','social',1),('cheer','social',0.5),('speak','social',2),
('look','adventure',2),('explore','adventure',5),('rest','adventure',1),
('campfire_rest','adventure',3),('drink','adventure',0.5),('eat','adventure',1),
('speak','adventure',0.5),('look','profession',1),('explore','profession',2),
('rest','profession',1),('eat','profession',0.5),('speak','profession',1),
('mining_show','profession',5),('smithing_show','profession',5);
CREATE TABLE IF NOT EXISTS vcore_adventure_dialog (
line_key VARCHAR(64) NOT NULL,locale VARCHAR(8) NOT NULL,intent VARCHAR(32) NOT NULL,
text VARCHAR(255) NOT NULL,weight FLOAT NOT NULL DEFAULT 1,
PRIMARY KEY(line_key,locale)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO vcore_adventure_dialog VALUES
('another_drink','deDE','social','Noch ein Bier bitte.',1),
('good_rest','deDE','social','Eine gute Gelegenheit für eine Pause.',1),
('good_company','deDE','social','In guter Gesellschaft vergeht die Zeit schnell.',1),
('long_road','deDE','social','Heute war ich schon eine Weile unterwegs.',1),
('adventure_road','deDE','adventure','Mal sehen, was es hier zu entdecken gibt.',1),
('adventure_rest','deDE','adventure','Eine kurze Rast, dann geht es weiter.',1),
('work_care','deDE','profession','Gute Arbeit braucht ihre Zeit.',1),
('work_tools','deDE','profession','Mit dem richtigen Werkzeug geht es leichter.',1),
('another_drink','enUS','social','Another drink, please.',1),
('good_rest','enUS','social','A good time for a little rest.',1),
('good_company','enUS','social','Time passes quickly in good company.',1),
('long_road','enUS','social','I have been on the road for a while today.',1),
('adventure_road','enUS','adventure','Let us see what there is to discover here.',1),
('adventure_rest','enUS','adventure','A short rest before moving on.',1),
('work_care','enUS','profession','Good work takes time.',1),
('work_tools','enUS','profession','The right tools make the job easier.',1);
CREATE TABLE IF NOT EXISTS vcore_adventure_day (
character_guid INT UNSIGNED NOT NULL,local_date INT UNSIGNED NOT NULL,
hour_slot TINYINT UNSIGNED NOT NULL,intent VARCHAR(32) NOT NULL,location_key VARCHAR(100) NOT NULL,
PRIMARY KEY(character_guid,local_date,hour_slot)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,63 @@
-- Apply after 2026_10_26_03. Seed new social scenes without replacing manual weights.
INSERT IGNORE INTO vcore_adventure_action VALUES
('social_npc','social',20,35,'npc',0),
('social_bot','social',20,35,'bot',0),
('social_seat','social',30,75,'seat',0),
('social_counter','social',15,30,'counter',0),
('social_order','social',20,30,'order',0),
('social_greet','social',6,10,'greet',0),
('social_listen','social',12,20,'listen',0),
('social_react','social',6,12,'react',0),
('social_toast','social',8,14,'toast',0),
('social_farewell','social',4,8,'farewell',0),
('social_comment','social',8,14,'comment',0),
('social_dance','social',12,25,'dance',0),
('social_fire','social',25,50,'fire',0),
('social_make_fire','social',8,15,'make_fire',0);
INSERT IGNORE INTO vcore_adventure_action_intent VALUES
('social_npc','social',3),('social_bot','social',1.5),('social_seat','social',3),
('social_counter','social',1.2),('social_order','social',2),('social_greet','social',0.7),
('social_listen','social',1),('social_react','social',1),('social_toast','social',0.4),
('social_farewell','social',0.4),('social_comment','social',0.6),('social_dance','social',0.3),
('social_fire','social',1),('social_make_fire','social',0.4);
-- Upgrade only the unchanged original seed; intentional edits to that row survive.
UPDATE vcore_adventure_action SET kind='social',requirement='npc'
WHERE action_key='speak' AND kind='say' AND requirement='audience' AND emote_id=1;
INSERT IGNORE INTO vcore_adventure_dialog VALUES
('greet_hello','deDE','social','Seid gegrüßt!',1),
('greet_evening','deDE','social','Wie geht es Euch heute?',1),
('greet_rest','deDE','social','Darf ich Euch kurz Gesellschaft leisten?',1),
('talk_road','deDE','social','Auf meinen Reisen gibt es immer etwas zu erzählen.',1),
('talk_news','deDE','social','Gibt es Neuigkeiten aus der Gegend?',1),
('talk_weather','deDE','social','Hier lässt es sich eine Weile aushalten.',1),
('talk_company','deDE','social','Ein gutes Gespräch gehört zu einer guten Rast.',1),
('order_beer','deDE','social','Noch ein Bier bitte.',1),
('order_drink','deDE','social','Etwas zu trinken wäre jetzt genau richtig.',1),
('order_please','deDE','social','Ein Getränk, wenn Ihr einen Moment Zeit habt.',1),
('farewell_road','deDE','social','Macht es gut. Wir sehen uns bestimmt wieder.',1),
('farewell_thanks','deDE','social','Danke für die Gesellschaft!',1),
('farewell_later','deDE','social','Bis später!',1),
('toast_health','deDE','social','Auf Euer Wohl!',1),
('toast_road','deDE','social','Auf gute Reisen und sichere Heimkehr!',1),
('comment_rest','deDE','social','Eine angenehme Pause.',1),
('comment_company','deDE','social','In guter Gesellschaft vergeht die Zeit schnell.',1),
('greet_hello','enUS','social','Greetings!',1),
('greet_evening','enUS','social','How are you today?',1),
('greet_rest','enUS','social','May I join you for a moment?',1),
('talk_road','enUS','social','There is always a story to tell after a journey.',1),
('talk_news','enUS','social','Any news from around here?',1),
('talk_weather','enUS','social','This is a pleasant place to spend some time.',1),
('talk_company','enUS','social','A good conversation makes a good rest.',1),
('order_beer','enUS','social','Another beer, please.',1),
('order_drink','enUS','social','Something to drink would be just right.',1),
('order_please','enUS','social','A drink when you have a moment, please.',1),
('farewell_road','enUS','social','Farewell. I am sure we will meet again.',1),
('farewell_thanks','enUS','social','Thank you for the company!',1),
('farewell_later','enUS','social','See you later!',1),
('toast_health','enUS','social','To your health!',1),
('toast_road','enUS','social','To good journeys and safe returns!',1),
('comment_rest','enUS','social','A pleasant little rest.',1),
('comment_company','enUS','social','Time passes quickly in good company.',1);
-- Existing campfire rest can also create its fire; no Cooking skill gate.
UPDATE vcore_adventure_action SET kind='social',requirement='fire'
WHERE action_key='campfire_rest' AND kind='sit' AND requirement='campfire' AND emote_id=0;
@@ -270,6 +270,12 @@ void WorldSession::CompleteNearTeleport(Player* plMover)
WorldPackets::Movement::MoveUpdateTeleport moveUpdateTeleport; WorldPackets::Movement::MoveUpdateTeleport moveUpdateTeleport;
moveUpdateTeleport.Status = &plMover->m_movementInfo; moveUpdateTeleport.Status = &plMover->m_movementInfo;
// Real clients populate the movement GUID with their movement packets.
// A clientless player may never have sent one; observers still need the
// actual mover identity when a native chair/portal near-teleports them.
moveUpdateTeleport.Status->guid = plMover->GetGUID();
if (IsClientless())
moveUpdateTeleport.Status->time = GameTime::GetGameTimeMS();
moveUpdateTeleport.Status->pos.Relocate(dest); moveUpdateTeleport.Status->pos.Relocate(dest);
plMover->SendMessageToSet(moveUpdateTeleport.Write(), false); plMover->SendMessageToSet(moveUpdateTeleport.Write(), false);
@@ -0,0 +1,786 @@
#include "AdventureBehaviorMgr.h"
#include "AdventureLocationMgr.h"
#include "ClientlessLuaEngine.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessActionExecutorMgr.h"
#include "ClientlessTravelMgr.h"
#include "ClientlessTravelGraphMgr.h"
#include "ClientlessPerceptionMgr.h"
#include "ClientlessFlightMgr.h"
#include "Chat.h"
#include "DatabaseEnv.h"
#include "DBCStores.h"
#include "GameObject.h"
#include "GameTime.h"
#include "Log.h"
#include "Player.h"
#include "Map.h"
#include "PathGenerator.h"
#include "TerrainMgr.h"
#include "World.h"
#include "WorldSession.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <sstream>
namespace
{
using namespace AdventureBehaviorPolicy;
bool Known(std::string const & intent)
{
return intent == "social" || intent == "adventure" || intent == "profession";
}
bool Busy(uint32 guid)
{
auto s = sClientlessActionExecutorMgr->GetStatus(guid);
return s &&(s->State == ClientlessActionExecutionState::Moving || s->State == ClientlessActionExecutionState::SelectingTarget);
}
bool Unsafe(Player * player)
{
return ! player->IsAlive() || player->IsInCombat() || player->GetTransport() || player->IsInFlight() || player->IsFlying() || player->IsBeingTeleported();
}
uint32 DateOf(std::tm const & local)
{
return(local.tm_year + 1900) * 10000 +(local.tm_mon + 1) * 100 + local.tm_mday;
}
std::tm Local(time_t now)
{
std::tm local
{
};
localtime_r(& now, & local);
return local;
}
Language Speech(Player * player)
{
return player->GetTeamId() == TEAM_ALLIANCE ? LANG_COMMON : LANG_ORCISH;
}
}
AdventureBehaviorMgr * AdventureBehaviorMgr::instance()
{
static AdventureBehaviorMgr mgr;
return & mgr;
}
float AdventureBehaviorMgr::Affinity(std::unordered_map < std::string, float > const & values, std::string const & key, float fallback)
{
auto i = values.find(key);
return i == values.end() ? fallback : std::clamp(i->second, 0.0f, 100.0f);
}
void AdventureBehaviorMgr::Online(Player * player)
{
auto profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter());
if (! profile || ! profile->AutonomousBehavior) return;
if (! sAdventureLocationMgr->EnsureLoaded())
{
TC_LOG_ERROR("server.adventurebot", "ADVENTURE_DISABLED guid=%u missing catalogs", player->GetGUID().GetCounter());
return;
}
Record r;
r.Enabled = true;
r.Schedule = profile->DailySchedule;
r.Random.seed(player->GetGUID().GetCounter() ^ uint32(GameTime::GetGameTime()));
auto & record = _records.insert_or_assign(player->GetGUID().GetCounter(), std::move(r)).first->second;
if (! record.Schedule && Known(profile->InitialIntent)) SetWanted(player, record, profile->InitialIntent, profile->InitialLocation, GameTime::GetGameTime() + profile->InitialDurationSeconds);
}
void AdventureBehaviorMgr::Offline(Player * player)
{
auto i = _records.find(player->GetGUID().GetCounter());
if (i != _records.end())
{
ResetMicro(player, i->second);
_records.erase(i);
}
}
void AdventureBehaviorMgr::IntentChanged(Player * player, ClientlessIntent const * intent)
{
auto i = _records.find(player->GetGUID().GetCounter());
if (i == _records.end() || i->second.Publishing) return;
if (! intent)
{
SetWanted(player, i->second, "", "", 0);
return;
}
if (! Known(intent->Name)) return;
auto location = intent->Parameters.find("location");
auto & r = i->second;
r.Enabled = true;
r.Schedule = false;
SetWanted(player, r, intent->Name, location == intent->Parameters.end() ? PickLocation(player, r, intent->Name) : location->second, GameTime::GetGameTime() + 3600);
}
bool AdventureBehaviorMgr::Allowed(Player * player, AdventureLocation const & location, std::string const & intent) const
{
auto map = sMapStore.LookupEntry(location.Point.MapId);
return map && map->MapType == MAP_COMMON && sAdventureLocationMgr->Weight(location, intent) > 0 &&(! location.Faction || location.Faction ==(player->GetTeamId() == TEAM_ALLIANCE ? 1 : 2));
}
std::string AdventureBehaviorMgr::PickLocation(Player * player, Record & record, std::string const & intent)
{
auto profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter());
if (! profile) return
{
};
std::vector < AdventureLocation const * > choices;
std::vector < double > weights;
for (auto const & l : sAdventureLocationMgr->Locations())
{
if (! Allowed(player, l, intent)) continue;
choices.push_back(& l);
weights.push_back(sAdventureLocationMgr->Weight(l, intent) * Affinity(profile->RegionAffinity, l.Region, 0.2f) * Affinity(profile->ActivityAffinity, l.Point.Category, 1.0f));
}
auto n = Choose(weights, record.Random);
return n < choices.size() ? choices[n]->Point.Id : "";
}
void AdventureBehaviorMgr::Generate(Player * player, Record & record, uint32 date)
{
uint32 guid = player->GetGUID().GetCounter();
record.Date = date;
unsigned loaded = 0;
if (auto rows = WorldDatabase.PQuery("SELECT hour_slot,intent,location_key FROM vcore_adventure_day WHERE character_guid=%u AND local_date=%u ORDER BY hour_slot", guid, date)) do
{
auto f = rows->Fetch();
uint32 h = f[0].GetUInt32();
if (h < 24 && Known(f[1].GetString()))
{
record.Plan[h] =
{
f[1].GetString(), f[2].GetString()
};
++ loaded;
}
}
while (rows->NextRow());
if (loaded == 24) return;
auto profile = sClientlessLuaEngine->GetAdventureProfile(guid);
if (! profile) return;
auto pick =[&]()
{
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)
};
auto i = Choose(weights, record.Random);
return std::array < std::string, 3 >
{
"social", "adventure", "profession"
}
[std::min(i, std::size_t(2))];
};
record.Plan = AdventureBehaviorPolicy::Generate(record.Random, Affinity(profile->Personality, "wanderlust", 0.3f), pick,[&](std::string const & intent)
{
return PickLocation(player, record, intent);
});
auto tx = WorldDatabase.BeginTransaction();
for (unsigned h = 0; h < 24; ++ h)
{
auto key = record.Plan[h].Location;
WorldDatabase.EscapeString(key);
tx->PAppend("REPLACE INTO vcore_adventure_day (character_guid,local_date,hour_slot,intent,location_key) VALUES (%u,%u,%u,'%s','%s')", guid, date, h, record.Plan[h].Intent.c_str(), key.c_str());
}
WorldDatabase.DirectCommitTransaction(tx);
TC_LOG_INFO("server.adventurebot", "DAY_PLAN guid=%u date=%u slots=24", guid, date);
}
void AdventureBehaviorMgr::SetWanted(Player * player, Record & r, std::string const & intent, std::string const & location, time_t deadline)
{
r.WantedIntent = intent;
r.WantedLocation = location;
r.Deadline = deadline;
r.Dirty = true;
r.Tried.clear();
ResetMicro(player, r);
uint32 guid = player->GetGUID().GetCounter();
sClientlessPlayerMgr->ClearQueuedActions(guid);
if (sClientlessActionExecutorMgr->RequestStopAtNode(guid)) r.State = Phase::Switching;
else r.State = Phase::Idle;
}
void AdventureBehaviorMgr::Queue(Player * player, Record & r, std::string const & action, std::string const & key, std::string const & value, bool hard)
{
ClientlessAction a;
a.Name = action;
a.Parameters[key] = value;
a.Parameters["hard_destination"] = hard ? "true" : "false";
r.Queued = sClientlessPlayerMgr->QueueAction(player->GetGUID().GetCounter(), std::move(a));
}
std::string AdventureBehaviorMgr::AreaNode(Player * player, AdventureLocation const & l)
{
std::vector < std::pair < float, std::string >> candidates;
auto terrain = sTerrainMgr.LoadTerrain(l.Point.MapId);
if (! terrain) return
{
};
if (auto rows = WorldDatabase.PQuery("SELECT n.node_key,w.position_x,w.position_y,w.position_z FROM vcore_clientless_travel_node n JOIN vcore_clientless_world_point w ON w.point_key=n.world_point_key WHERE n.enabled=1 AND w.enabled=1 AND w.map=%u ORDER BY n.node_key", l.Point.MapId)) do
{
auto f = rows->Fetch();
uint32 zone = 0, area = 0;
Position p;
p.Relocate(f[1].GetFloat(), f[2].GetFloat(), f[3].GetFloat());
terrain->GetZoneAndAreaId(player->GetPhaseShift(), l.Point.MapId, zone, area, p);
if (l.AreaId != area && l.AreaId != zone) continue;
float d = player->GetMapId() == l.Point.MapId ? player->GetExactDist(p) : std::hypot(p.GetPositionX() - l.Point.X, p.GetPositionY() - l.Point.Y);
candidates.emplace_back(d, f[0].GetString());
}
while (rows->NextRow());
std::sort(candidates.begin(), candidates.end());
for (auto const & candidate : candidates)
{
std::vector < ClientlessTravelRouteStep > route;
bool complete = false;
std::string reached, error;
if (sClientlessTravelGraphMgr->PlanAdaptiveRoute(player, candidate.second, 500, 200, route, complete, reached, error) && complete) return candidate.second;
}
return
{
};
}
void AdventureBehaviorMgr::Begin(Player * player, Record & r)
{
r.Dirty = false;
r.Intent = r.WantedIntent;
r.Location = r.WantedLocation;
if (r.Intent.empty())
{
r.Enabled = false;
r.State = Phase::Idle;
r.Detail = "stopped";
r.Publishing = true;
sClientlessPlayerMgr->ClearIntent(player->GetGUID().GetCounter());
r.Publishing = false;
return;
}
auto l = sAdventureLocationMgr->Find(r.Location);
if (! l || ! Allowed(player, * l, r.Intent))
{
Fallback(player, r);
return;
}
r.Tried.insert(r.Location);
r.Publishing = true;
ClientlessIntent intent;
intent.Name = r.Intent;
intent.Parameters["location"] = r.Location;
sClientlessPlayerMgr->SetIntent(player->GetGUID().GetCounter(), std::move(intent));
r.Publishing = false;
if (l->IsArea)
{
auto node = AreaNode(player, * l);
if (node.empty())
{
Fallback(player, r);
return;
}
Queue(player, r, "travel_to_node", "node", node, true);
}
else Queue(player, r, "travel_to_location", "id", r.Location);
r.State = Phase::Travel;
r.Detail = "travelling to " + r.Location;
TC_LOG_INFO("server.adventurebot", "INTENT_TRAVEL guid=%u intent='%s' location='%s' area=%u", player->GetGUID().GetCounter(), r.Intent.c_str(), r.Location.c_str(), l->IsArea ? 1 : 0);
}
void AdventureBehaviorMgr::Fallback(Player * player, Record & r)
{
r.Tried.insert(r.Location);
AdventureLocation const * nearest = nullptr;
double best = std::numeric_limits < double >::infinity();
for (auto const & l : sAdventureLocationMgr->Locations())
{
if (r.Tried.count(l.Point.Id) || ! Allowed(player, l, r.Intent)) continue;
double d = std::hypot(l.Point.X - player->GetPositionX(), l.Point.Y - player->GetPositionY());
if (l.Point.MapId != player->GetMapId()) d += 100000;
if (d < best)
{
best = d;
nearest = & l;
}
}
if (nearest)
{
r.WantedLocation = nearest->Point.Id;
r.Dirty = true;
r.State = Phase::Idle;
r.RetryTimer = 1000;
TC_LOG_WARN("server.adventurebot", "INTENT_FALLBACK guid=%u from='%s' to='%s' tried=%u", player->GetGUID().GetCounter(), r.Location.c_str(), nearest->Point.Id.c_str(), uint32(r.Tried.size()));
}
else
{
r.State = Phase::Idle;
r.Detail = "no remaining matching location; waiting for next schedule";
r.RetryTimer = 60000;
r.Dirty = false;
}
}
void AdventureBehaviorMgr::ResetMicro(Player * player, Record & r)
{
AdventureSocialActions::Reset(player,r.Social);
if (! r.Micro.empty())
{
player->SetUInt32Value(UNIT_NPC_EMOTESTATE, 0);
if (player->IsAlive()) player->SetStandState(UNIT_STAND_STATE_STAND);
if (! player->GetTransport() && ! player->IsInFlight() && ! player->IsFlying() &&
! player->IsBeingTeleported() && ! Busy(player->GetGUID().GetCounter()))
{
sClientlessTravelMgr->CancelTravel(player->GetGUID().GetCounter());
player->StopMoving();
}
}
r.Micro.clear();
r.MicroTimer = 0;
r.GestureTimer = 0;
r.WorkObject.Clear();
r.WorkEmote = 0;
r.WorkPending = false;
}
bool AdventureBehaviorMgr::LocalMove(Player * player, Record & r, Position const & target, float maxPath)
{
auto l = sAdventureLocationMgr->Find(r.Location);
if (! l || player->GetMapId() != r.AnchorMap || r.Anchor.GetExactDist(target) > 60) return false;
if (l->IsArea)
{
auto terrain = player->GetMap()->GetTerrain();
uint32 zone = 0, area = 0;
terrain->GetZoneAndAreaId(player->GetPhaseShift(), player->GetMapId(), zone, area, target);
if (area != l->AreaId && zone != l->AreaId) return false;
}
ClientlessPathDiagnostics path;
if (! sClientlessTravelMgr->AnalyzePath(player, target, path) || ! path.Complete || path.Suspicious || path.FinalDistance > 1.5f || path.PathLength > maxPath) return false;
if (l->IsArea)
{
PathGenerator navigation(player);
if (! navigation.CalculatePath(target)) return false;
for (auto const & point : navigation.GetPath())
{
uint32 zone = 0, area = 0;
player->GetMap()->GetTerrain()->GetZoneAndAreaId(player->GetPhaseShift(), player->GetMapId(), zone, area, Position(point.x, point.y, point.z));
if (area != l->AreaId && zone != l->AreaId) return false;
}
}
return sClientlessTravelMgr->StartTravel(player, target, false, false, r.Intent=="social");
}
AdventureSocialContext AdventureBehaviorMgr::SocialContext(Player* player,Record& r)
{
auto profile=sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter());
auto location=sAdventureLocationMgr->Find(r.Location);
return {r.Anchor,r.AnchorMap,location&&location->Point.Category=="festival",
profile?profile->Locale:"enUS",r.Intent,r.Random,r.TargetHistory,r.VariantHistory,
[this,player,&r](Position const& target,float maxPath){return LocalMove(player,r,target,maxPath);}};
}
bool AdventureBehaviorMgr::UpdateSocial(Player* player,Record& r,uint32 diff)
{
auto context=SocialContext(player,r);
return AdventureSocialActions::Update(player,r.Social,diff,context);
}
void AdventureBehaviorMgr::Micro(Player * player, Record & r)
{
auto profile = sClientlessLuaEngine->GetAdventureProfile(player->GetGUID().GetCounter());
if (! profile) return;
auto l = sAdventureLocationMgr->Find(r.Location);
if (! l) return;
auto social=SocialContext(player,r);
sClientlessLuaEngine->DismountGroundMount(player, "intent-micro-action");
GameObject * fire = nullptr, * ore = nullptr, * forge = nullptr;
std::vector < GameObject * > props;
player->GetGameObjectListWithEntryInGrid(props, 0, 15);
for (auto object : props)
{
if (! object->isSpawned() || ! player->IsInPhase(object) || ! player->IsWithinLOSInMap(object)) continue;
auto name = Normalize(object->GetName());
auto nearest =[&](GameObject * & current)
{
if (! current || player->GetExactDist(object) < player->GetExactDist(current)) current = object;
};
if (name.find("campfire") != std::string::npos || name.find("lagerfeuer") != std::string::npos) nearest(fire);
if (object->GetGoType() == GAMEOBJECT_TYPE_CHEST &&(name.find("vein") != std::string::npos || name.find("deposit") != std::string::npos || name.find("ore") != std::string::npos || name.find("vorkommen") != std::string::npos || name.find("ader") != std::string::npos)) nearest(ore);
if (name.find("forge") != std::string::npos || name.find("anvil") != std::string::npos || name.find("schmiede") != std::string::npos || name.find("amboss") != std::string::npos) nearest(forge);
}
sClientlessPerceptionMgr->ScanNow(player, 20);
auto snapshot = sClientlessPerceptionMgr->GetSnapshot(player->GetGUID().GetCounter());
bool audience = false;
if (snapshot) for (auto const & entity : snapshot->Entities) if (entity.Alive && entity.InLineOfSight && entity.Relation != ClientlessEntityRelation::Hostile && entity.Distance < 15)
{
audience = true;
break;
}
std::vector < AdventureMicroAction const * > choices;
std::vector < double > weights;
for (auto const & a : sAdventureLocationMgr->Actions())
{
auto w = a.Intents.find(r.Intent);
if (w == a.Intents.end()) continue;
if(a.Kind=="social"&&!AdventureSocialActions::Available(player,a,social))continue;
if ((a.Requirement == "campfire" && ! fire) ||(a.Requirement == "ore" && ! ore) ||(a.Requirement == "forge" && ! forge) ||(a.Requirement == "audience" && ! audience)) continue;
choices.push_back(& a);
float contextWeight=(a.Requirement=="dance"&&l->Point.Category!="festival")?0.12f:1.0f;
weights.push_back(contextWeight*w->second * Affinity(profile->MicroActionAffinity, a.Key, 1) * Novelty(r.ActionHistory, a.Kind+":"+a.Requirement) * Novelty(r.VariantHistory, "action:" + a.Key));
}
auto n = Choose(weights, r.Random);
if (n >= choices.size())
{
r.MicroTimer = 5000;
return;
}
auto const & a = * choices[n];
r.Micro = a.Key;
r.MicroTimer = std::uniform_int_distribution < uint32 >(a.MinSeconds, a.MaxSeconds)(r.Random) * 1000;
Remember(r.ActionHistory, a.Kind+":"+a.Requirement);
Remember(r.VariantHistory, "action:" + a.Key);
if(r.Intent=="social"&&(a.Kind=="drink"||a.Kind=="eat"||(a.Kind=="sit"&&a.Requirement.empty())))
{
auto seated=a;
seated.Requirement=a.Kind=="drink"?"drink_seated":a.Kind=="eat"?"eat_seated":"seat";
if(AdventureSocialActions::Available(player,seated,social))
AdventureSocialActions::Start(player,r.Social,seated,r.MicroTimer,social);
}
if(r.Social.Active)r.Detail="social scene: "+r.Social.Kind;
else if(a.Kind=="social")
{
if(!AdventureSocialActions::Start(player,r.Social,a,r.MicroTimer,social))r.MicroTimer=3000;
else r.Detail="social scene: "+a.Requirement;
}
else if (a.Kind == "look") player->SetFacingTo(player->GetOrientation() + std::uniform_real_distribution < float >(- 1.0f, 1.0f)(r.Random));
else if (a.Kind == "wander")
{
bool moved = false;
for (unsigned i = 0; i < 8 && ! moved; ++ i)
{
float angle = std::uniform_real_distribution < float >(0, 6.2831853f)(r.Random), radius = std::uniform_real_distribution < float >(3, l->IsArea ? 40.0f : 8.0f)(r.Random);
Position p;
p.Relocate(r.Anchor.GetPositionX() + std::cos(angle) * radius, r.Anchor.GetPositionY() + std::sin(angle) * radius, r.Anchor.GetPositionZ());
float ground = player->GetMap()->GetHeight(player->GetPhaseShift(), p.GetPositionX(), p.GetPositionY(), r.Anchor.GetPositionZ() + 5, true, 15);
if (ground <= INVALID_HEIGHT + 1) continue;
p.m_positionZ = ground;
moved = LocalMove(player, r, p, l->IsArea ? 120 : 20);
}
if (! moved) r.MicroTimer = 5000;
}
else if (a.Kind == "sit")
{
if (a.Requirement == "campfire" && fire) player->SetFacingToObject(fire);
player->SetStandState(UNIT_STAND_STATE_SIT);
}
else if (a.Kind == "drink")
{
player->SetStandState(UNIT_STAND_STATE_SIT);
player->SendPlaySpellVisualKit(SPELL_VISUAL_KIT_DRINK, 0, 0);
r.GestureTimer = 7000;
}
else if (a.Kind == "eat") player->SetUInt32Value(UNIT_NPC_EMOTESTATE, EMOTE_STATE_EAT);
else if (a.Kind == "emote") player->HandleEmoteCommand(a.Emote);
else if (a.Kind == "say")
{
std::vector < AdventureDialog const * > lines;
std::vector < double > lineWeights;
auto collect =[&](std::string const & locale)
{
for (auto const & line : sAdventureLocationMgr->Dialogs()) if (line.Intent == r.Intent && line.Locale == locale)
{
lines.push_back(& line);
lineWeights.push_back(line.Weight * Novelty(r.VariantHistory, "line:" + line.Key));
}
};
collect(profile->Locale);
if (lines.empty()) collect("enUS");
auto line = Choose(lineWeights, r.Random);
if (line < lines.size())
{
player->Say(lines[line]->Text, Speech(player));
Remember(r.VariantHistory, "line:" + lines[line]->Key);
player->HandleEmoteCommand(EMOTE_ONESHOT_TALK);
}
}
else if (a.Kind == "work")
{
auto object = a.Requirement == "ore" ? ore : forge;
if (! object)
{
r.MicroTimer = 3000;
return;
}
player->SetFacingToObject(object);
r.WorkObject = object->GetGUID();
r.WorkEmote = a.Emote;
if (player->GetExactDist(object) > 4)
{
Position p;
p.Relocate(object);
float angle = object->GetAngle(player);
p.m_positionX += std::cos(angle) * 2;
p.m_positionY += std::sin(angle) * 2;
if (! LocalMove(player, r, p, 35))
{
r.MicroTimer = 3000;
return;
}
r.WorkPending = true;
r.Detail = "approaching work prop";
}
else player->SetUInt32Value(UNIT_NPC_EMOTESTATE, a.Emote);
}
TC_LOG_INFO("server.adventurebot", "MICRO_START guid=%u intent='%s' location='%s' action='%s' durationMs=%u", player->GetGUID().GetCounter(), r.Intent.c_str(), r.Location.c_str(), a.Key.c_str(), r.MicroTimer);
}
void AdventureBehaviorMgr::Update(Player * player, uint32 diff)
{
uint32 guid = player->GetGUID().GetCounter();
auto entry = _records.find(guid);
if (entry == _records.end() || ! entry->second.Enabled || ! player->IsInWorld()) return;
auto & r = entry->second;
r.MicroTimer = r.MicroTimer > diff ? r.MicroTimer - diff : 0;
r.GestureTimer = r.GestureTimer > diff ? r.GestureTimer - diff : 0;
r.RetryTimer = r.RetryTimer > diff ? r.RetryTimer - diff : 0;
if (r.Timer > diff)
{
r.Timer -= diff;
return;
}
r.Timer = 1000;
time_t now = GameTime::GetGameTime();
auto local = Local(now);
uint32 date = DateOf(local);
if (r.Schedule)
{
if (r.Date != date)
{
Generate(player, r, date);
r.Hour = - 1;
}
if (r.Hour != local.tm_hour)
{
auto const & slot = r.Plan[local.tm_hour];
bool changed = r.Hour < 0 || r.Plan[r.Hour].Intent != slot.Intent || r.Plan[r.Hour].Location != slot.Location;
r.Hour = local.tm_hour;
std::tm end = local;
end.tm_min = 0;
end.tm_sec = 0;
end.tm_hour = local.tm_hour + 1;
end.tm_isdst = - 1;
time_t deadline = mktime(& end);
if (changed) SetWanted(player, r, slot.Intent, slot.Location, deadline);
else r.Deadline = deadline;
}
}
else if (r.Deadline && now >= r.Deadline) SetWanted(player, r, "", "", 0);
if (Unsafe(player))
{
if (player->IsInCombat() && ! r.Micro.empty()) ResetMicro(player, r);
r.Detail = "waiting for safe player/transport state";
return;
}
if (r.RetryTimer) return;
if (r.State == Phase::Switching)
{
if (Busy(guid)) return;
r.State = Phase::Idle;
// Complete the handover at a real graph node even after a location leg.
std::string node;
float distance = 0;
sClientlessTravelGraphMgr->EnsureLoaded();
if (sClientlessTravelGraphMgr->FindNearestNodeToPosition(player->GetMapId(), player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), node, distance) && distance > 3)
{
Queue(player, r, "travel_to_node", "node", node, true);
r.State = Phase::Handover;
r.Detail = "reaching graph node before schedule switch";
return;
}
}
if (r.State == Phase::Handover)
{
if (r.Queued && sClientlessPlayerMgr->GetQueuedActionCount(guid)) return;
r.Queued = false;
if (Busy(guid)) return;
auto status = sClientlessActionExecutorMgr->GetStatus(guid);
if (! status || status->State != ClientlessActionExecutionState::Completed)
{
r.Detail = "graph handover failed; retrying later";
r.State = Phase::Switching;
r.RetryTimer = 30000;
return;
}
r.State = Phase::Idle;
}
if (r.State == Phase::Idle && r.Dirty)
{
Begin(player, r);
return;
}
if (r.State == Phase::Travel)
{
if (r.Queued && sClientlessPlayerMgr->GetQueuedActionCount(guid)) return;
r.Queued = false;
if (Busy(guid)) return;
auto status = sClientlessActionExecutorMgr->GetStatus(guid);
auto location = sAdventureLocationMgr->Find(r.Location);
bool arrived = status && status->State == ClientlessActionExecutionState::Completed && location && player->GetMapId() == location->Point.MapId;
if (arrived && location->IsArea) arrived = location->AreaId &&(player->GetAreaId() == location->AreaId || player->GetZoneId() == location->AreaId) && status->Detail != "best-effort travel completed at nearest useful reachable point";
else if (arrived) arrived = player->GetExactDist(location->Point.X, location->Point.Y, location->Point.Z) <= 6;
if (! arrived)
{
Fallback(player, r);
return;
}
r.Anchor.Relocate(player);
r.AnchorMap = player->GetMapId();
r.State = Phase::OnSite;
r.Detail = "performing intent at destination";
TC_LOG_INFO("server.adventurebot", "INTENT_ARRIVED guid=%u intent='%s' location='%s'", guid, r.Intent.c_str(), r.Location.c_str());
}
if (r.State == Phase::OnSite)
{
if(r.Social.Active)
{
if(UpdateSocial(player,r,1000))return;
ResetMicro(player,r);r.MicroTimer=3000;return;
}
if (r.Micro.empty() && r.MicroTimer) return;
if (player->GetMapId() != r.AnchorMap)
{
Fallback(player, r);
return;
}
auto travel = sClientlessTravelMgr->GetStatus(guid);
bool moving = travel && travel->State == ClientlessTravelState::Moving;
if (! r.Micro.empty())
{
if (! r.GestureTimer && r.Micro == "drink")
{
player->SendPlaySpellVisualKit(SPELL_VISUAL_KIT_DRINK, 0, 0);
r.GestureTimer = 7000;
}
if (! moving && r.WorkPending)
{
auto object = player->GetMap()->GetGameObject(r.WorkObject);
if (! object || player->GetExactDist(object) > 4 || ! player->IsWithinLOSInMap(object))
{
ResetMicro(player, r);
r.MicroTimer = 3000;
return;
}
player->SetFacingToObject(object);
player->SetUInt32Value(UNIT_NPC_EMOTESTATE, r.WorkEmote);
r.WorkPending = false;
r.Detail = "performing work demonstration";
}
if (r.MicroTimer || moving) return;
ResetMicro(player, r);
}
Micro(player, r);
}
}
bool AdventureBehaviorMgr::Command(ChatHandler * handler, char const * args)
{
std::istringstream in(args ? args : "");
std::string op;
uint32 guid = 0;
in >> op;
if (op == "import") return sAdventureLocationMgr->Import(handler);
if (op == "reload")
{
bool ok = sAdventureLocationMgr->Reload();
handler->SendSysMessage(ok ? "Adventure catalogs reloaded." : "Adventure catalog reload failed; check migration.");
return ok;
}
in >> guid;
auto session = sWorld->FindClientlessSession(guid);
auto player = session ? session->GetPlayer() : nullptr;
if (! player || ! player->IsInWorld())
{
handler->SendSysMessage("Use an online clientless character GUID.");
return false;
}
auto i = _records.find(guid);
if (i == _records.end())
{
if (! sAdventureLocationMgr->EnsureLoaded()) return false;
Record r;
r.Random.seed(guid ^ uint32(GameTime::GetGameTime()));
i = _records.emplace(guid, std::move(r)).first;
}
auto & r = i->second;
if (op == "status")
{
handler->PSendSysMessage("Adventure GUID %u: enabled=%u schedule=%u phase=%u intent='%s' location='%s' micro='%s' deadline=%llu detail='%s'", guid, r.Enabled ? 1 : 0, r.Schedule ? 1 : 0, uint32(r.State), r.Intent.c_str(), r.Location.c_str(), r.Micro.c_str(), static_cast < unsigned long long >(r.Deadline), r.Detail.c_str());
return true;
}
if(op=="micro")
{
std::string key;in>>key;
if(!r.Enabled||r.State!=Phase::OnSite||Unsafe(player)||Busy(guid))
{handler->SendSysMessage("Use an idle on-site AdventureBot, without combat or transit.");return false;}
auto context=SocialContext(player,r);
for(auto const& action:sAdventureLocationMgr->Actions())
{
if(action.Key!=key||action.Kind!="social"||!action.Intents.count(r.Intent))continue;
if(!AdventureSocialActions::Available(player,action,context))
{handler->SendSysMessage("Social action unavailable in the current environment.");return false;}
ResetMicro(player,r);r.Micro=key;
r.MicroTimer=std::uniform_int_distribution<uint32>(action.MinSeconds,action.MaxSeconds)(r.Random)*1000;
Remember(r.ActionHistory,action.Kind+":"+action.Requirement);
Remember(r.VariantHistory,"action:"+action.Key);
bool started=AdventureSocialActions::Start(player,r.Social,action,r.MicroTimer,context);
if(!started){ResetMicro(player,r);r.MicroTimer=3000;}
handler->SendSysMessage(started?"Social scene started through normal navigation and native interactions.":"No reachable social target or suitable resting spot.");
return started;
}
handler->SendSysMessage("Unknown or unsuitable social action key.");return false;
}
if (op == "plan")
{
auto local = Local(GameTime::GetGameTime());
if (r.Date != DateOf(local)) Generate(player, r, DateOf(local));
for (unsigned h = 0; h < 24; ++ h) handler->PSendSysMessage("%02u:00-%02u:00 %s %s", h, h + 1, r.Plan[h].Intent.c_str(), r.Plan[h].Location.c_str());
return true;
}
if (op == "schedule")
{
std::string mode;
in >> mode;
if (mode != "on" && mode != "off") return false;
r.Schedule = mode == "on";
r.Enabled = true;
r.Hour = - 1;
if (! r.Schedule) SetWanted(player, r, "", "", 0);
handler->SendSysMessage("Schedule mode updated; changes use a safe graph handover.");
return true;
}
if (op == "intent")
{
std::string intent, location;
uint32 seconds = 3600;
in >> intent >> location;
if (in >> seconds) seconds = std::clamp(seconds, 30u, 86400u);
auto l = sAdventureLocationMgr->Find(location);
if (! Known(intent) || ! l || ! Allowed(player, * l, intent))
{
handler->SendSysMessage("Use social/adventure/profession and a matching enabled location.");
return false;
}
r.Schedule = false;
r.Enabled = true;
SetWanted(player, r, intent, location, GameTime::GetGameTime() + seconds);
handler->SendSysMessage("Intent requested; travel and micro actions are autonomous.");
return true;
}
if (op == "stop")
{
r.Schedule = false;
SetWanted(player, r, "", "", 0);
handler->SendSysMessage("Behavior stop requested at a safe point.");
return true;
}
handler->SendSysMessage("Usage: clientless adventure import|reload|status <guid>|plan <guid>|schedule <guid> on/off|intent <guid> <intent> <location> [seconds]|micro <guid> <social-action>|stop <guid>");
return false;
}
@@ -0,0 +1,68 @@
#ifndef VCORE_ADVENTURE_BEHAVIOR_MGR_H
#define VCORE_ADVENTURE_BEHAVIOR_MGR_H
#include "Common.h"
#include "Position.h"
#include "ObjectGuid.h"
#include "AdventureBehaviorPolicy.h"
#include "AdventureSocialActions.h"
#include <ctime>
#include <random>
#include <string>
#include <unordered_map>
#include <unordered_set>
class Player;
class ChatHandler;
class GameObject;
struct ClientlessIntent;
struct AdventureLocation;
struct ClientlessAdventureProfile;
class TC_GAME_API AdventureBehaviorMgr
{
public:
static AdventureBehaviorMgr* instance();
void Online(Player* player);
void Offline(Player* player);
void IntentChanged(Player* player, ClientlessIntent const* intent);
void Update(Player* player, uint32 diff);
bool Command(ChatHandler* handler, char const* args);
private:
enum class Phase {Idle, Travel, OnSite, Switching, Handover};
struct Record
{
bool Enabled=false, Schedule=false, Publishing=false, Queued=false, Dirty=true;
Phase State=Phase::Idle;
std::string Intent, Location, WantedIntent, WantedLocation, Detail, Micro;
time_t Deadline=0;
uint32 Date=0, Timer=0, MicroTimer=0, GestureTimer=0, RetryTimer=0;
int Hour=-1;
AdventureBehaviorPolicy::Day Plan;
std::mt19937 Random;
std::deque<std::string> ActionHistory, VariantHistory;
std::deque<std::string> TargetHistory;
AdventureSocialScene Social;
std::unordered_set<std::string> Tried;
Position Anchor;
uint32 AnchorMap=0;
ObjectGuid WorkObject;
uint32 WorkEmote=0;
bool WorkPending=false;
};
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);
void Fallback(Player* player, Record& record);
void Queue(Player* player, Record& record, std::string const& action, std::string const& parameter, std::string const& value, bool hard = false);
void ResetMicro(Player* player, Record& record);
void Micro(Player* player, Record& record);
AdventureSocialContext SocialContext(Player* player, Record& record);
bool UpdateSocial(Player* player, Record& record, uint32 diff);
bool LocalMove(Player* player, Record& record, Position const& target, float maxPath);
std::string AreaNode(Player* player, AdventureLocation const& location);
std::string PickLocation(Player* player, Record& record, std::string const& intent);
bool Allowed(Player* player, AdventureLocation const& location, std::string const& intent) const;
static float Affinity(std::unordered_map<std::string,float> const& values, std::string const& key, float fallback);
std::unordered_map<uint32,Record> _records;
};
#define sAdventureBehaviorMgr AdventureBehaviorMgr::instance()
#endif
@@ -0,0 +1,77 @@
#ifndef VCORE_ADVENTURE_BEHAVIOR_POLICY_H
#define VCORE_ADVENTURE_BEHAVIOR_POLICY_H
#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <deque>
#include <random>
#include <string>
#include <vector>
namespace AdventureBehaviorPolicy
{
inline std::string Normalize(std::string text)
{
std::string result;
for (unsigned char c : text)
if (std::isalnum(c)) result += char(std::tolower(c));
return result;
}
inline std::string Classify(std::string const& name)
{
auto text = Normalize(name);
auto has = [&](char const* s) { return text.find(s) != std::string::npos; };
if (has("tavern") || has("gasthaus") || has("taverne") || has("lionspride") || has("gildedrose") || (has("inn") && !has("pinnacle") && !has("binding") && !has("inner"))) return "tavern";
if (has("deadmines") || has("dungeon") || has("dungeons")) return "dungeon";
if (has("mine") || has("mining") || has("quarry") || has("steinbruch")) return "mine";
if (has("forge") || has("smith") || has("anvil") || has("workshop") || has("schmiede")) return "workshop";
if (has("bank")) return "bank";
if (has("forest") || has("woods") || has("wald") || has("marsh") || has("swamp")) return "wilderness";
if (has("ruin") || has("crypt") || has("tomb") || has("cave") || has("cavern")) return "ruins";
if (has("village") || has("town") || has("camp") || has("settlement")) return "settlement";
return "landmark";
}
inline double Novelty(std::deque<std::string> const& history, std::string const& key)
{
double weight = 1;
for (std::size_t i = 0; i < history.size(); ++i)
if (history[i] == key) weight *= i + 1 == history.size() ? 0.08 : 0.45;
return weight;
}
inline void Remember(std::deque<std::string>& history, std::string key)
{
history.push_back(std::move(key));
if (history.size() > 12) history.pop_front();
}
inline std::size_t Choose(std::vector<double> const& weights, std::mt19937& random)
{
if (weights.empty() || std::none_of(weights.begin(), weights.end(), [](double w) { return w > 0 && std::isfinite(w); })) return weights.size();
return std::discrete_distribution<std::size_t>(weights.begin(), weights.end())(random);
}
struct Slot
{
std::string Intent, Location;
};
using Day = std::array<Slot, 24>;
// Dependency-free generation kernel. Callers provide weighted intent/location selection.
template<class IntentPicker, class LocationPicker>
Day Generate(std::mt19937& random, float wanderlust, IntentPicker intent, LocationPicker location)
{
Day day;
for (unsigned hour = 0; hour < 24; )
{
std::string chosen = intent();
unsigned end = std::min(24u, hour + std::uniform_int_distribution<unsigned>(1, 4)(random));
std::string target = location(chosen);
for (unsigned h = hour; h < end; ++h)
{
if (h != hour && std::bernoulli_distribution(std::clamp(double(wanderlust), 0.0, 1.0))(random)) target = location(chosen);
day[h] = {chosen, target};
}
hour = end;
}
return day;
}
}
#endif
@@ -0,0 +1,279 @@
#include "AdventureLocationMgr.h"
#include "AdventureBehaviorPolicy.h"
#include "Chat.h"
#include "DatabaseEnv.h"
#include "DBCStores.h"
#include "Log.h"
#include "PhaseShift.h"
#include "TerrainMgr.h"
#include <cmath>
#include <limits>
#include <memory>
AdventureLocationMgr * AdventureLocationMgr::instance()
{
static AdventureLocationMgr mgr;
return & mgr;
}
bool AdventureLocationMgr::EnsureLoaded()
{
return _loaded || Reload();
}
bool AdventureLocationMgr::Reload()
{
_locations.clear();
_types.clear();
_actions.clear();
_dialogs.clear();
_loaded = false;
if (! WorldDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_location'")) return false;
if (auto rows = WorldDatabase.Query("SELECT w.id,w.point_key,w.category,w.map,w.position_x,w.position_y,w.position_z,w.orientation," "COALESCE(a.display_name,w.point_key),COALESCE(a.region_key,''),COALESCE(a.area_id,0)," "COALESCE(a.zone_id,0),COALESCE(a.is_area,0),COALESCE(a.faction,0) " "FROM vcore_clientless_world_point w LEFT JOIN vcore_adventure_location a ON a.point_key=w.point_key " "WHERE w.enabled=1 AND w.category NOT LIKE 'travel%' ORDER BY w.point_key"))
{
do
{
auto f = rows->Fetch();
AdventureLocation l;
l.Point.DatabaseId = f[0].GetUInt32();
l.Point.Id = f[1].GetString();
l.Point.Category = f[2].GetString();
l.Point.MapId = f[3].GetUInt32();
l.Point.X = f[4].GetFloat();
l.Point.Y = f[5].GetFloat();
l.Point.Z = f[6].GetFloat();
l.Point.O = f[7].GetFloat();
l.Name = f[8].GetString();
l.Region = f[9].GetString();
l.AreaId = f[10].GetUInt32();
l.ZoneId = f[11].GetUInt32();
l.IsArea = f[12].GetBool();
l.Faction = f[13].GetUInt8();
_locations.push_back(std::move(l));
}
while (rows->NextRow());
}
if (auto rows = WorldDatabase.Query("SELECT category,intent,weight FROM vcore_adventure_location_intent WHERE weight>0")) do
{
auto f = rows->Fetch();
_types[f[0].GetString()][f[1].GetString()] = f[2].GetFloat();
}
while (rows->NextRow());
if (auto rows = WorldDatabase.Query("SELECT action_key,kind,min_seconds,max_seconds,requirement,emote_id FROM vcore_adventure_action ORDER BY action_key")) do
{
auto f = rows->Fetch();
AdventureMicroAction a;
a.Key = f[0].GetString();
a.Kind = f[1].GetString();
a.MinSeconds = std::clamp(f[2].GetUInt32(), 1u, 3600u);
a.MaxSeconds = std::clamp(f[3].GetUInt32(), a.MinSeconds, 3600u);
a.Requirement = f[4].GetString();
a.Emote = f[5].GetUInt32();
if (! a.Emote || sEmotesStore.LookupEntry(a.Emote)) _actions.push_back(std::move(a));
}
while (rows->NextRow());
if (auto rows = WorldDatabase.Query("SELECT action_key,intent,weight FROM vcore_adventure_action_intent WHERE weight>0")) do
{
auto f = rows->Fetch();
for (auto & a : _actions) if (a.Key == f[0].GetString()) a.Intents[f[1].GetString()] = f[2].GetFloat();
}
while (rows->NextRow());
if (auto rows = WorldDatabase.Query("SELECT line_key,locale,intent,text,weight FROM vcore_adventure_dialog WHERE weight>0 ORDER BY line_key,locale")) do
{
auto f = rows->Fetch();
_dialogs.push_back(
{
f[0].GetString(), f[1].GetString(), f[2].GetString(), f[3].GetString(), f[4].GetFloat()
});
}
while (rows->NextRow());
_loaded = true;
TC_LOG_INFO("server.adventurebot", "ADVENTURE_CATALOG locations=%u actions=%u lines=%u", uint32(_locations.size()), uint32(_actions.size()), uint32(_dialogs.size()));
return true;
}
AdventureLocation const * AdventureLocationMgr::Find(std::string const & key) const
{
for (auto const & location : _locations) if (location.Point.Id == key) return & location;
return nullptr;
}
float AdventureLocationMgr::Weight(AdventureLocation const & location, std::string const & intent) const
{
auto type = _types.find(location.Point.Category);
if (type == _types.end()) return 0;
auto weight = type->second.find(intent);
return weight == type->second.end() ? 0 : weight->second;
}
bool AdventureLocationMgr::Import(ChatHandler * handler)
{
using AdventureBehaviorPolicy::Classify;
using AdventureBehaviorPolicy::Normalize;
if (! WorldDatabase.Query("SHOW TABLES LIKE 'vcore_adventure_location'"))
{
handler->SendSysMessage("Apply sql/custom/world/2026_10_26_03_adventure_behavior.sql first.");
return false;
}
struct Seed
{
std::string Key, Name, Type, Source;
uint32 Id, Map;
float X, Y, Z, O;
uint8 Faction = 0;
};
std::vector < Seed > seeds, tele;
if (auto rows = WorldDatabase.Query("SELECT id,name,map,position_x,position_y,position_z,orientation FROM game_tele ORDER BY id")) do
{
auto f = rows->Fetch();
Seed s;
s.Id = f[0].GetUInt32();
s.Name = f[1].GetString();
s.Map = f[2].GetUInt32();
s.X = f[3].GetFloat();
s.Y = f[4].GetFloat();
s.Z = f[5].GetFloat();
s.O = f[6].GetFloat();
s.Key = "poi_tele_" + std::to_string(s.Id);
s.Source = "game_tele";
s.Type = Classify(s.Name);
tele.push_back(s);
seeds.push_back(s);
}
while (rows->NextRow());
// Service NPCs supply real map/Z coordinates; innkeepers are direct tavern evidence.
if (auto rows = WorldDatabase.Query("SELECT c.guid,t.name,COALESCE(t.subname,''),c.map,c.position_x,c.position_y,c.position_z,c.orientation," "COALESCE(NULLIF(c.npcflag,0),t.npcflag),t.trainer_type FROM creature c JOIN creature_template t ON t.entry=c.id " "WHERE (COALESCE(NULLIF(c.npcflag,0),t.npcflag)&65536)!=0 OR ((COALESCE(NULLIF(c.npcflag,0),t.npcflag)&16)!=0 AND t.trainer_type=2) ORDER BY c.guid")) do
{
auto f = rows->Fetch();
Seed s;
s.Id = f[0].GetUInt32();
s.Name = f[1].GetString() + " " + f[2].GetString();
s.Map = f[3].GetUInt32();
s.X = f[4].GetFloat();
s.Y = f[5].GetFloat();
s.Z = f[6].GetFloat();
s.O = f[7].GetFloat();
s.Source = "service_npc";
s.Key = "poi_npc_" + std::to_string(s.Id);
s.Type =(f[8].GetUInt32() & 65536) ? "tavern" : "service";
seeds.push_back(s);
}
while (rows->NextRow());
std::unordered_map < uint32, std::shared_ptr < TerrainInfo >> terrains;
PhaseShift phase;
auto areaAt =[&](uint32 map, float x, float y, float z)
{
auto & t = terrains[map];
if (! t) t = sTerrainMgr.LoadTerrain(map);
return t ? t->GetAreaId(phase, map, x, y, z) : 0u;
};
uint32 inferred = 0, skipped = 0;
if (auto rows = WorldDatabase.Query("SELECT ID,Name,PositionX,PositionY FROM points_of_interest ORDER BY ID")) do
{
auto f = rows->Fetch();
Seed s;
s.Id = f[0].GetUInt32();
s.Name = f[1].GetString();
s.X = f[2].GetFloat();
s.Y = f[3].GetFloat();
s.O = 0;
s.Key = "poi_guard_" + std::to_string(s.Id);
s.Source = "guard_poi_inferred";
s.Type = Classify(s.Name);
Seed const * anchor = nullptr;
float best = std::numeric_limits < float >::infinity();
for (auto const & t : tele)
{
auto map = sMapStore.LookupEntry(t.Map);
if (! map || map->MapType != MAP_COMMON) continue;
float d = std::hypot(s.X - t.X, s.Y - t.Y);
if (d < best)
{
best = d;
anchor = & t;
}
}
if (! anchor)
{
++ skipped;
continue;
}
s.Map = anchor->Map;
auto & t = terrains[s.Map];
if (! t) t = sTerrainMgr.LoadTerrain(s.Map);
s.Z = t ? t->GetStaticHeight(phase, s.Map, s.X, s.Y, MAX_HEIGHT, true, MAX_HEIGHT) : INVALID_HEIGHT;
if (! std::isfinite(s.Z) || s.Z <= INVALID_HEIGHT + 1)
{
++ skipped;
continue;
}
seeds.push_back(s);
++ inferred;
}
while (rows->NextRow());
auto transaction = WorldDatabase.BeginTransaction();
uint32 count = 0, areas = 0;
auto metadata =[&](Seed const & s, bool isArea, uint32 area, uint32 zone)
{
std::string key = s.Key, name = s.Name, source = s.Source, region;
if (auto a = sAreaTableStore.LookupEntry(zone)) region = Normalize(a->AreaName ? a->AreaName : "");
// Existing profiles already use these short region keys.
if (zone == 12) region = "elwynn";
else if (zone == 1) region = "dun_morogh";
else if (zone == 1519) region = "stormwind";
else if (zone == 4) region = "blasted_lands";
WorldDatabase.EscapeString(key);
WorldDatabase.EscapeString(name);
WorldDatabase.EscapeString(source);
WorldDatabase.EscapeString(region);
transaction->PAppend("INSERT IGNORE INTO vcore_adventure_location (point_key,display_name,source_type,source_id,area_id,zone_id,region_key,is_area,faction) VALUES ('%s','%s','%s',%u,%u,%u,'%s',%u,%u)", key.c_str(), name.c_str(), source.c_str(), s.Id, area, zone, region.c_str(), isArea ? 1u : 0u, uint32(s.Faction));
};
for (auto & s : seeds)
{
auto map = sMapStore.LookupEntry(s.Map);
if (! map || ! Trinity::IsValidMapCoord(s.X, s.Y, s.Z, s.O))
{
++ skipped;
continue;
}
uint32 area = areaAt(s.Map, s.X, s.Y, s.Z), zone = area;
if (auto a = sAreaTableStore.LookupEntry(area)) if (a->ParentAreaID) zone = a->ParentAreaID;
bool isArea = false;
if (auto a = sAreaTableStore.LookupEntry(zone)) isArea = Normalize(s.Name) == Normalize(a->AreaName ? a->AreaName : "");
if (isArea)
{
s.Type = "zone";
area = zone;
++ areas;
}
metadata(s, isArea, area, zone);
std::string name = s.Name;
WorldDatabase.EscapeString(name);
transaction->PAppend("INSERT IGNORE INTO vcore_clientless_world_point (point_key,category,map,position_x,position_y,position_z,orientation,enabled,comment) VALUES ('%s','%s',%u,%.6f,%.6f,%.6f,%.6f,1,'%s')", s.Key.c_str(), s.Type.c_str(), s.Map, s.X, s.Y, s.Z, s.O, name.c_str());
++ count;
}
// Preserve manually authored category/position; add metadata only.
if (auto rows = WorldDatabase.Query("SELECT point_key,category,map,position_x,position_y,position_z,orientation FROM vcore_clientless_world_point WHERE category NOT LIKE 'travel%'")) do
{
auto f = rows->Fetch();
Seed s;
s.Key = f[0].GetString();
s.Name = s.Key;
s.Type = f[1].GetString();
s.Source = "manual";
s.Id = 0;
s.Map = f[2].GetUInt32();
s.X = f[3].GetFloat();
s.Y = f[4].GetFloat();
s.Z = f[5].GetFloat();
s.O = f[6].GetFloat();
uint32 area = areaAt(s.Map, s.X, s.Y, s.Z), zone = area;
if (auto a = sAreaTableStore.LookupEntry(area)) if (a->ParentAreaID) zone = a->ParentAreaID;
metadata(s, false, area, zone);
}
while (rows->NextRow());
WorldDatabase.DirectCommitTransaction(transaction);
sClientlessWorldPointMgr->Reload();
Reload();
handler->PSendSysMessage("Location import: %u seeds, %u area destinations, %u inferred guard POIs, %u unusable coordinates skipped. Existing records preserved. Active semantic locations: %u.", count, areas, inferred, skipped, uint32(_locations.size()));
return true;
}
@@ -0,0 +1,48 @@
#ifndef VCORE_ADVENTURE_LOCATION_MGR_H
#define VCORE_ADVENTURE_LOCATION_MGR_H
#include "Common.h"
#include "ClientlessWorldPointMgr.h"
#include <string>
#include <unordered_map>
#include <vector>
class ChatHandler;
struct AdventureLocation
{
ClientlessWorldPoint Point;
std::string Name, Region;
uint32 AreaId = 0, ZoneId = 0;
bool IsArea = false;
uint8 Faction = 0;
};
struct AdventureMicroAction
{
std::string Key, Kind, Requirement;
uint32 MinSeconds = 5, MaxSeconds = 15, Emote = 0;
std::unordered_map<std::string, float> Intents;
};
struct AdventureDialog
{
std::string Key, Locale, Intent, Text;
float Weight = 1;
};
class TC_GAME_API AdventureLocationMgr
{
public:
static AdventureLocationMgr* instance();
bool Reload();
bool EnsureLoaded();
bool Import(ChatHandler* handler);
std::vector<AdventureLocation> const& Locations() const { return _locations; }
std::vector<AdventureMicroAction> const& Actions() const { return _actions; }
std::vector<AdventureDialog> const& Dialogs() const { return _dialogs; }
AdventureLocation const* Find(std::string const& key) const;
float Weight(AdventureLocation const& location, std::string const& intent) const;
private:
bool _loaded = false;
std::vector<AdventureLocation> _locations;
std::vector<AdventureMicroAction> _actions;
std::vector<AdventureDialog> _dialogs;
std::unordered_map<std::string, std::unordered_map<std::string, float>> _types;
};
#define sAdventureLocationMgr AdventureLocationMgr::instance()
#endif
@@ -0,0 +1,564 @@
#include "AdventureSocialActions.h"
#include "AdventureLocationMgr.h"
#include "AdventureBehaviorPolicy.h"
#include "ClientlessPlayerMgr.h"
#include "ClientlessTravelMgr.h"
#include "Creature.h"
#include "GameObject.h"
#include "GameObjectData.h"
#include "GameTime.h"
#include "Log.h"
#include "Map.h"
#include "ObjectAccessor.h"
#include "Opcodes.h"
#include "Player.h"
#include "SpellHistory.h"
#include "SpellInfo.h"
#include "SpellMgr.h"
#include "WorldPacket.h"
#include "WorldSession.h"
#include <algorithm>
#include <cmath>
namespace
{
using namespace AdventureBehaviorPolicy;
using namespace AdventureSocialFlow;
constexpr uint32 CookingFire = 818;
bool InBounds(Player * player, WorldObject const * target, AdventureSocialContext const & c)
{
return target && target->IsInWorld() && target->GetMapId() == c.MapId && c.Anchor.GetExactDist(target) <= 30 && std::fabs(target->GetPositionZ() - c.Anchor.GetPositionZ()) <= 6 && player->IsInPhase(target);
}
bool Friendly(Player * player, Unit * unit, AdventureSocialContext const & c)
{
return unit != player && InBounds(player, unit, c) && unit->IsAlive() && ! unit->IsInCombat() && ! unit->IsInFlight() && ! unit->IsFlying() && ! unit->GetTransport() && player->IsFriendlyTo(unit) && ! unit->IsCritter() && ! unit->IsTotem() && unit->GetCharmerOrOwnerGUID().IsEmpty();
}
std::vector < Unit * > People(Player * player, std::string const & kind, AdventureSocialContext & c)
{
std::vector < Unit * > people;
if (kind != "bot")
{
std::vector < Creature * > creatures;
player->GetCreatureListWithEntryInGrid(creatures, 0, 25);
for (auto unit : creatures) if (Friendly(player, unit, c) && player->IsWithinLOSInMap(unit) && ((kind != "counter" && kind != "order") || unit->IsInnkeeper())) people.push_back(unit);
}
if (kind != "npc" && kind != "counter" && kind != "order")
{
std::vector < Player * > players;
player->GetPlayerListInGrid(players, 25);
for (auto unit : players) if (sClientlessPlayerMgr->IsManaged(unit->GetGUID().GetCounter()) && Friendly(player, unit, c) && player->IsWithinLOSInMap(unit) && ! unit->IsSplineEnabled()) people.push_back(unit);
}
return people;
}
std::vector < GameObject * > Objects(Player * player, GameobjectTypes type, AdventureSocialContext & c)
{
std::vector < GameObject * > all, result;
player->GetGameObjectListWithEntryInGrid(all, 0, 25);
for (auto go : all) if (go->isSpawned() && go->GetGoType() == type && InBounds(player, go, c)) result.push_back(go);
return result;
}
GameObject * Fire(Player * player, AdventureSocialContext & c)
{
std::vector < GameObject * > objects;
player->GetGameObjectListWithEntryInGrid(objects, 0, 25);
GameObject * best = nullptr;
for (auto go : objects)
{
if (! go->isSpawned() || ! InBounds(player, go, c)) continue;
auto name = Normalize(go->GetName());
if (go->GetSpellId()!=CookingFire && name.find("campfire") == std::string::npos && name.find("cookingfire") == std::string::npos && name.find("lagerfeuer") == std::string::npos && name.find("kochfeuer") == std::string::npos && name.find("fireplace") == std::string::npos) continue;
if (! best || player->GetExactDist(go) < player->GetExactDist(best)) best = go;
}
return best;
}
bool CanMakeFire(Player * p)
{
return p->HasSpell(CookingFire) && p->IsOutdoors() && ! p->IsInWater() && ! p->IsFalling() && ! p->GetSpellHistory()->HasCooldown(CookingFire) && ! p->IsNonMeleeSpellCast(false);
}
WorldObject * Resolve(Player * p, ObjectGuid guid)
{
if (guid.IsGameObject()) return p->GetMap()->GetGameObject(guid);
if (guid.IsCreature()) return ObjectAccessor::GetCreature(* p, guid);
if (guid.IsPlayer()) return ObjectAccessor::GetPlayer(* p, guid);
return nullptr;
}
template < class T > T * Pick(Player * p, std::vector < T * > const & choices, AdventureSocialContext & c)
{
std::vector < double > weights;
for (auto v : choices) weights.push_back(Novelty(c.TargetHistory, v->GetGUID().ToString()) /(1 + 0.08 * p->GetExactDist(v)));
auto index = Choose(weights, c.Random);
return index < choices.size() ? choices[index] : nullptr;
}
bool AtFront(Player* player,WorldObject* target)
{
return !target->ToUnit() || ConversationFront(target->GetPositionX(),target->GetPositionY(),
target->GetOrientation(),player->GetPositionX(),player->GetPositionY());
}
bool Approach(Player* player,AdventureSocialScene& scene,WorldObject* target,AdventureSocialContext& c)
{
if(!InBounds(player,target,c))return false;
if(player->GetExactDist(target)<=3.8f && AtFront(player,target) &&
player->IsWithinLOS(target->GetPositionX(),target->GetPositionY(),target->GetPositionZ()+1.5f))
{
scene.Approaching=false;
return true;
}
// People are approached in front of their actual facing, not from the
// bot's current bearing. Furniture may still be reached from any side.
bool person=target->ToUnit()!=nullptr;
float bearing=person?target->GetOrientation():target->GetAngle(player);
std::vector<float> offsets=person?std::vector<float>{0,0.28f,-0.28f,0.55f,-0.55f}:
std::vector<float>{0,0.7f,-0.7f,1.4f,-1.4f,3.1415926f};
for(float radius:{2.2f,3.2f})for(float offset:offsets)
{
Position p;
p.Relocate(target->GetPositionX()+std::cos(bearing+offset)*radius,
target->GetPositionY()+std::sin(bearing+offset)*radius,target->GetPositionZ());
float floor=player->GetMap()->GetHeight(player->GetPhaseShift(),p.GetPositionX(),p.GetPositionY(),target->GetPositionZ()+3,true,8);
if(floor<=INVALID_HEIGHT+1||std::fabs(floor-target->GetPositionZ())>3)continue;
p.m_positionZ=floor;
if(!player->GetMap()->isInLineOfSight(player->GetPhaseShift(),p.GetPositionX(),p.GetPositionY(),floor+1.5f,
target->GetPositionX(),target->GetPositionY(),target->GetPositionZ()+1.5f,
LINEOFSIGHT_ALL_CHECKS,VMAP::ModelIgnoreFlags::Nothing))continue;
if(!c.Move(p,55))continue;
scene.Destination=p;scene.Approaching=true;return true;
}
return false;
}
bool OnChair(Player* player,GameObject* chair,float& error)
{
if(!chair||chair->GetGoType()!=GAMEOBJECT_TYPE_CHAIR)return false;
auto info=chair->GetGOInfo();
uint32 slots=info->chair.chairslots;
if(!slots||slots>5||info->chair.chairheight>2 ||
player->GetStandState()!=UNIT_STAND_STATE_SIT_LOW_CHAIR+info->chair.chairheight)return false;
error=1000000;
float side=chair->GetOrientation()+1.5707963f;
for(uint32 i=0;i<slots;++i)
{
float offset=info->size*(float(i)-float(slots-1)*0.5f);
float x=chair->GetPositionX()+std::cos(side)*offset,y=chair->GetPositionY()+std::sin(side)*offset;
error=std::min(error,std::hypot(player->GetPositionX()-x,player->GetPositionY()-y));
}
return error<=0.15f && std::fabs(player->GetPositionZ()-chair->GetPositionZ())<=0.25f &&
std::fabs(std::remainder(player->GetOrientation()-chair->GetOrientation(),6.2831853f))<=0.15f;
}
void Say(Player * player, std::string const & topic, AdventureSocialContext & c)
{
std::vector < AdventureDialog const * > choices;
std::vector < double > weights;
auto collect =[&](std::string const & locale)
{
for (auto const & line : sAdventureLocationMgr->Dialogs()) if (line.Intent == c.Intent && line.Locale == locale && line.Key.rfind(topic + "_", 0) == 0)
{
choices.push_back(& line);
weights.push_back(line.Weight * Novelty(c.VariantHistory, "line:" + line.Key));
}
};
collect(c.Locale);
if (choices.empty()) collect("enUS");
auto n = Choose(weights, c.Random);
if (n < choices.size())
{
player->Say(choices[n]->Text, player->GetTeamId() == TEAM_ALLIANCE ? LANG_COMMON : LANG_ORCISH);
Remember(c.VariantHistory, "line:" + choices[n]->Key);
}
}
uint32 Reaction(AdventureSocialContext & c)
{
std::vector < uint32 > emotes
{
EMOTE_ONESHOT_LAUGH, EMOTE_ONESHOT_QUESTION, EMOTE_ONESHOT_EXCLAMATION, EMOTE_ONESHOT_YES
};
std::vector < double > weights;
for (auto id : emotes) weights.push_back(Novelty(c.VariantHistory, "gesture:" + std::to_string(id)));
auto i = Choose(weights, c.Random);
auto id = emotes[i];
Remember(c.VariantHistory, "gesture:" + std::to_string(id));
return id;
}
std::vector < Step > Steps(std::string const & kind, uint32 duration)
{
if (kind == "npc" || kind == "bot") return Conversation();
if (kind == "counter") return
{
{
Cue::Look, 5000
},
{
Cue::Listen, 8000
},
{
Cue::Look, 6000
}
};
if (kind == "order") return
{
{
Cue::Greet, 2000
},
{
Cue::Order, 4000
},
{
Cue::Listen, 6000
},
{
Cue::Drink, 6000
},
{
Cue::Farewell, 2000
}
};
if (kind == "greet") return
{
{
Cue::Greet, 3000
},
{
Cue::Look, 4000
}
};
if (kind == "listen") return
{
{
Cue::Listen, 6000
},
{
Cue::Nod, 2500
},
{
Cue::Listen, 7000
}
};
if (kind == "react") return
{
{
Cue::React, 3000
},
{
Cue::Listen, 5000
}
};
if (kind == "toast") return
{
{
Cue::Toast, 3000
},
{
Cue::Drink, 6000
}
};
if (kind == "farewell") return
{
{
Cue::Farewell, 4000
}
};
if (kind == "comment") return
{
{
Cue::Comment, 3500
},
{
Cue::Listen, 5000
}
};
if (kind == "seat") return
{
{
Cue::Seat, duration
}
};
if (kind == "drink_seated") return
{
{
Cue::Seat, 1000
},
{
Cue::Drink, 7000
},
{
Cue::Listen, duration
}
};
if (kind == "eat_seated") return
{
{
Cue::Seat, 1000
},
{
Cue::Eat, 7000
},
{
Cue::Listen, duration
}
};
if (kind == "dance") return
{
{
Cue::Dance, duration
}
};
if (kind == "make_fire") return
{
{
Cue::Fire, 1000
},
{
Cue::Look, 4000
}
};
if (kind == "fire") return
{
{
Cue::Fire, 1000
},
{
Cue::Rest, duration / 2
},
{
Cue::Drink, 6000
},
{
Cue::Rest, duration / 2
}
};
return
{
};
}
}
void AdventureSocialActions::Prepare(Player * player)
{
// Shared AdventureBot equipment, independent of profession/profile weights.
if (! player->HasSpell(CookingFire) && sSpellMgr->GetSpellInfo(CookingFire)) player->LearnSpell(CookingFire, false);
}
bool AdventureSocialActions::Available(Player * p, AdventureMicroAction const & action, AdventureSocialContext & c)
{
auto const & kind = action.Requirement;
if (kind == "seat" || kind == "drink_seated" || kind == "eat_seated") return ! Objects(p, GAMEOBJECT_TYPE_CHAIR, c).empty();
if (kind == "dance") return c.Festival || ! People(p, "audience", c).empty();
if (kind == "fire") return Fire(p, c) || CanMakeFire(p);
if (kind == "make_fire") return ! Fire(p, c) && CanMakeFire(p);
if (kind == "farewell")
{
for (auto unit : People(p, kind, c)) if (std::find(c.TargetHistory.begin(), c.TargetHistory.end(), unit->GetGUID().ToString()) != c.TargetHistory.end()) return true;
return false;
}
return ! People(p, kind, c).empty();
}
bool AdventureSocialActions::Start(Player * p, AdventureSocialScene & s, AdventureMicroAction const & a, uint32 duration, AdventureSocialContext & c)
{
s =
{
};
s.Action = a.Key;
s.Kind = a.Requirement;
s.PreviousSelection = p->GetTarget();
WorldObject * target = nullptr;
bool approached=false;
if (s.Kind == "seat" || s.Kind == "drink_seated" || s.Kind == "eat_seated")
{
auto chairs=Objects(p,GAMEOBJECT_TYPE_CHAIR,c);
while(!chairs.empty())
{
auto chair=Pick(p,chairs,c);
if(!chair)break;
if(!chair->GetGOInfo()->chair.triggeredEvent&&Approach(p,s,chair,c))
{target=chair;approached=true;break;}
chairs.erase(std::remove(chairs.begin(),chairs.end(),chair),chairs.end());
}
}
else if (s.Kind == "fire" || s.Kind == "make_fire") target = Fire(p, c);
else if (s.Kind == "farewell")
{
auto people = People(p, s.Kind, c);
for (auto i = c.TargetHistory.rbegin(); i != c.TargetHistory.rend() && ! target; ++ i) for (auto unit : people) if (unit->GetGUID().ToString() == * i)
{
target = unit;
break;
}
}
else if (s.Kind != "dance") target = Pick(p, People(p, s.Kind, c), c);
if (target)
{
s.Target = target->GetGUID();
s.TargetKey = s.Target.ToString();
if (! approached && ! Approach(p, s, target, c)) return false;
Remember(c.TargetHistory, s.TargetKey);
}
else if (s.Kind == "fire" || s.Kind == "make_fire")
{
if (! CanMakeFire(p)) return false;
// Native cast places the campfire beside the grounded caster. Require a
// small flat open patch, not water, a ledge, doorway or a room interior.
for (unsigned i = 0; i < 8; ++ i)
{
float angle = float(i) * 6.2831853f / 8;
float x = p->GetPositionX() + std::cos(angle) * 2, y = p->GetPositionY() + std::sin(angle) * 2;
float z = p->GetMap()->GetHeight(p->GetPhaseShift(), x, y, p->GetPositionZ() + 2, true, 6);
if (z <= INVALID_HEIGHT + 1 || std::fabs(z - p->GetPositionZ()) > 1.0f || p->GetMap()->GetWaterLevel(p->GetPhaseShift(), x, y) > z + 0.2f || ! p->IsWithinLOS(x, y, z + 1)) return false;
}
}
else if (s.Kind != "dance") return false;
s.Sequence.Start(Steps(s.Kind, duration));
s.Active = s.Sequence.Active();
TC_LOG_INFO("server.adventurebot", "SOCIAL_START guid=%u action='%s' target='%s' approach=%u", p->GetGUID().GetCounter(), s.Action.c_str(), s.Target.ToString().c_str(), s.Approaching ? 1 : 0);
return s.Active;
}
bool AdventureSocialActions::Update(Player * p, AdventureSocialScene & s, uint32 diff, AdventureSocialContext & c)
{
if (! s.Active) return false;
s.Elapsed += diff;
if (s.Elapsed > 120000) return false;
auto target = Resolve(p, s.Target);
if (! s.Target.IsEmpty())
{
if (! InBounds(p, target, c)) return false;
if (auto unit = target->ToUnit())
{
if (! Friendly(p, unit, c)) return false;
if ((p->GetExactDist(unit) > 5 || !AtFront(p,unit) || !p->IsWithinLOSInMap(unit)) && ! s.Approaching)
{
if (++ s.Repaths > 3 || ! Approach(p, s, unit, c)) return false;
}
}
}
if(target&&target->ToUnit()&&!s.Approaching&&!p->IsWithinLOSInMap(target))return false;
if (s.Approaching)
{
auto travel = sClientlessTravelMgr->GetStatus(p->GetGUID().GetCounter());
if (travel && travel->State == ClientlessTravelState::Moving) return true;
if (! travel || travel->State != ClientlessTravelState::Arrived || ! target) return false;
s.Approaching = false;
if (p->GetExactDist(target) > 4 || !AtFront(p,target))
{
if (++ s.Repaths > 3 || ! Approach(p, s, target, c)) return false;
return true;
}
}
if (s.WaitingChair)
{
if (p->IsBeingTeleported()) return true;
float error=0;
if(!target||!OnChair(p,target->ToGameObject(),error))return false;
s.WaitingChair=false;s.Seated=true;
TC_LOG_INFO("server.adventurebot","SOCIAL_SEATED guid=%u chair='%s' position=(%.3f %.3f %.3f) slotError=%.3f stand=%u movementGuid=%llu",
p->GetGUID().GetCounter(),s.Target.ToString().c_str(),p->GetPositionX(),p->GetPositionY(),p->GetPositionZ(),error,
uint32(p->GetStandState()),static_cast<unsigned long long>(p->m_movementInfo.guid.GetCounter()));
}
if(s.Seated&&(s.Kind=="seat"||s.Kind=="drink_seated"||s.Kind=="eat_seated"))
{
float error=0;
if(!target||!OnChair(p,target->ToGameObject(),error))return false;
}
if (s.Casting)
{
s.CastElapsed += diff;
if (p->IsNonMeleeSpellCast(false)) return s.CastElapsed < 10000;
if (auto fire = Fire(p, c))
{
s.Casting = false;
s.Target = fire->GetGUID();
s.TargetKey = s.Target.ToString();
TC_LOG_INFO("server.adventurebot", "SOCIAL_FIRE_CONFIRMED guid=%u spell=818 object='%s'", p->GetGUID().GetCounter(), s.TargetKey.c_str());
if (! Approach(p, s, fire, c)) return false;
return true;
}
return s.CastElapsed < 10000;
}
if (target && ! s.Seated)
{
p->SetFacingToObject(target);
if (target->ToUnit()) p->SetSelection(target->GetGUID());
}
if (auto step = s.Sequence.Take())
{
if(target&&target->ToUnit())
TC_LOG_INFO("server.adventurebot","SOCIAL_FRONT guid=%u target='%s' offset=%.3f distance=%.2f",
p->GetGUID().GetCounter(),target->GetGUID().ToString().c_str(),
std::remainder(target->GetAngle(p)-target->GetOrientation(),6.2831853f),p->GetExactDist(target));
p->SetUInt32Value(UNIT_NPC_EMOTESTATE, 0);
switch (step->Action)
{
case Cue::Greet : p->HandleEmoteCommand(EMOTE_ONESHOT_WAVE);
Say(p, "greet", c);
break;
case Cue::Talk : p->HandleEmoteCommand(EMOTE_ONESHOT_TALK);
Say(p, "talk", c);
break;
case Cue::Order : p->HandleEmoteCommand(EMOTE_ONESHOT_TALK);
Say(p, "order", c);
break;
case Cue::Comment : p->HandleEmoteCommand(EMOTE_ONESHOT_TALK);
Say(p, "comment", c);
break;
case Cue::Listen : break;
case Cue::React : p->HandleEmoteCommand(Reaction(c));
break;
case Cue::Nod : p->HandleEmoteCommand(EMOTE_ONESHOT_YES);
break;
case Cue::Farewell : p->HandleEmoteCommand(EMOTE_ONESHOT_WAVE);
Say(p, "farewell", c);
break;
case Cue::Look : p->SetFacingTo(p->GetOrientation() + 0.3f);
break;
case Cue::Toast : p->HandleEmoteCommand(EMOTE_ONESHOT_CHEER);
Say(p, "toast", c);
break;
case Cue::Drink : p->SendPlaySpellVisualKit(SPELL_VISUAL_KIT_DRINK, 0, 0);
break;
case Cue::Eat : p->SendPlaySpellVisualKit(SPELL_VISUAL_KIT_FOOD, 0, 0);
break;
case Cue::Dance : p->SetUInt32Value(UNIT_NPC_EMOTESTATE, EMOTE_STATE_DANCE);
break;
case Cue::Rest : p->SetStandState(UNIT_STAND_STATE_SIT);
s.Seated = true;
break;
case Cue::Fire : if (! Fire(p, c))
{
if (! CanMakeFire(p) || p->CastSpell(p, CookingFire, false) != SPELL_CAST_OK) return false;
s.Casting = true;
s.CastElapsed = 0;
TC_LOG_INFO("server.adventurebot", "SOCIAL_FIRE_CAST guid=%u spell=818", p->GetGUID().GetCounter());
return true;
}
break;
case Cue::Seat :
{
auto chair = target ? target->ToGameObject() : nullptr;
if (! chair || chair->GetGoType() != GAMEOBJECT_TYPE_CHAIR || ! p->GetGameObjectIfCanInteractWith(chair->GetGUID()) ||
!p->IsWithinLOS(chair->GetPositionX(),chair->GetPositionY(),chair->GetPositionZ()+1.5f)) return false;
auto info = chair->GetGOInfo();
if (info->chair.triggeredEvent) return false;
// furniture with scripted events is not an RP prop
WorldPacket packet(CMSG_GAMEOBJ_USE, 8);
packet << chair->GetGUID();
p->GetSession()->HandleGameObjectUseOpcode(packet);
s.WaitingChair = true;
return true;
}
}
TC_LOG_INFO("server.adventurebot", "SOCIAL_CUE guid=%u action='%s' cue=%u target='%s'", p->GetGUID().GetCounter(), s.Action.c_str(), uint32(step->Action), s.Target.ToString().c_str());
return true;
}
s.Sequence.Update(diff);
return s.Sequence.Active();
}
void AdventureSocialActions::Reset(Player * p, AdventureSocialScene & s)
{
if (s.Active)
{
if (s.Casting) p->InterruptNonMeleeSpells(false, CookingFire);
if (p->GetTarget() == s.Target) p->SetSelection(s.PreviousSelection);
TC_LOG_INFO("server.adventurebot", "SOCIAL_END guid=%u action='%s' elapsedMs=%u", p->GetGUID().GetCounter(), s.Action.c_str(), s.Elapsed);
}
s =
{
};
}
@@ -0,0 +1,43 @@
#ifndef VCORE_ADVENTURE_SOCIAL_ACTIONS_H
#define VCORE_ADVENTURE_SOCIAL_ACTIONS_H
#include "Common.h"
#include "ObjectGuid.h"
#include "Position.h"
#include "AdventureSocialFlow.h"
#include <deque>
#include <functional>
#include <random>
#include <string>
class Player;
struct AdventureMicroAction;
struct AdventureLocation;
struct AdventureSocialScene
{
bool Active=false, Approaching=false, Casting=false, WaitingChair=false, Seated=false;
std::string Action, Kind, TargetKey;
ObjectGuid Target, PreviousSelection;
Position Destination;
uint32 Elapsed=0, CastElapsed=0, CheckTimer=0, Repaths=0;
AdventureSocialFlow::Sequence Sequence;
};
struct AdventureSocialContext
{
Position Anchor;
uint32 MapId=0;
bool Festival=false;
std::string Locale, Intent;
std::mt19937& Random;
std::deque<std::string>& TargetHistory;
std::deque<std::string>& VariantHistory;
std::function<bool(Position const&,float)> Move;
};
class TC_GAME_API AdventureSocialActions
{
public:
static void Prepare(Player* player);
static bool Available(Player* player,AdventureMicroAction const& action,AdventureSocialContext& context);
static bool Start(Player* player,AdventureSocialScene& scene,AdventureMicroAction const& action,uint32 duration,AdventureSocialContext& context);
static bool Update(Player* player,AdventureSocialScene& scene,uint32 diff,AdventureSocialContext& context);
static void Reset(Player* player,AdventureSocialScene& scene);
};
#endif
@@ -0,0 +1,50 @@
#ifndef VCORE_ADVENTURE_SOCIAL_FLOW_H
#define VCORE_ADVENTURE_SOCIAL_FLOW_H
#include <cstdint>
#include <cstddef>
#include <utility>
#include <cmath>
#include <initializer_list>
#include <vector>
namespace AdventureSocialFlow
{
// A comfortable conversational distance inside a roughly 100-degree front cone.
inline bool ConversationFront(float x,float y,float heading,float otherX,float otherY)
{
float dx=otherX-x,dy=otherY-y,distance=std::hypot(dx,dy);
return distance>=1.1f&&distance<=3.8f &&
(dx*std::cos(heading)+dy*std::sin(heading))/distance>=std::cos(0.85f);
}
enum class Cue { Greet, Talk, Order, Comment, Listen, React, Nod, Farewell, Look, Toast, Drink, Seat, Dance, Fire, Rest, Eat };
struct Step { Cue Action; uint32_t Duration; };
// A cue fires once; waiting for navigation, native casts or a chair ACK happens
// outside this clock. That prevents animations from being executed while walking.
class Sequence
{
public:
void Start(std::vector<Step> steps) { _steps=std::move(steps); _index=0; _remaining=0; _pending=true; }
bool Active() const { return _index<_steps.size(); }
Step const* Take()
{
if(!Active()||!_pending)return nullptr;
_pending=false;_remaining=_steps[_index].Duration;return &_steps[_index];
}
void Update(uint32_t diff)
{
if(!Active()||_pending)return;
if(diff<_remaining){_remaining-=diff;return;}
++_index;_pending=true;
}
void Clear() { _steps.clear();_index=0;_remaining=0;_pending=false; }
private:
std::vector<Step> _steps;
std::size_t _index=0;
uint32_t _remaining=0;
bool _pending=false;
};
inline std::vector<Step> Conversation()
{
return {{Cue::Greet,2500},{Cue::Talk,4000},{Cue::Listen,6000},{Cue::React,3000},{Cue::Talk,4000},{Cue::Farewell,2500}};
}
}
#endif
@@ -173,6 +173,14 @@ char const* ClientlessActionExecutorMgr::StateName(ClientlessActionExecutionStat
return "Unknown"; return "Unknown";
} }
bool ClientlessActionExecutorMgr::RequestStopAtNode(uint32 guid)
{
auto itr = _records.find(guid);
if (itr == _records.end() || !itr->second.Active) return false;
itr->second.StopAtNode = true;
return true;
}
ClientlessActionExecutionStatus const* ClientlessActionExecutorMgr::GetStatus(uint32 characterGuid) const ClientlessActionExecutionStatus const* ClientlessActionExecutorMgr::GetStatus(uint32 characterGuid) const
{ {
auto itr = _records.find(characterGuid); auto itr = _records.find(characterGuid);
@@ -181,6 +189,7 @@ ClientlessActionExecutionStatus const* ClientlessActionExecutorMgr::GetStatus(ui
bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction const& action, Record& record) bool ClientlessActionExecutorMgr::StartAction(Player* player, ClientlessAction const& action, Record& record)
{ {
record.StopAtNode = false;
record.TransportActive = false; record.TransportActive = false;
record.FlightActive = false; record.FlightActive = false;
record.FlightApproachActive = false; record.FlightApproachActive = false;
@@ -861,6 +870,18 @@ bool ClientlessActionExecutorMgr::StartRouteStep(
uint32 characterGuid, uint32 characterGuid,
Record& record) Record& record)
{ {
if (record.StopAtNode && record.RouteStep > 0)
{
auto const& reached = record.Route[record.RouteStep - 1];
if (reached.NodeKey != "flight_location" && !player->GetTransport() && !player->IsInFlight() &&
!player->IsFlying() && !player->IsBeingTeleported())
{
record.Active = false;
record.Status.State = ClientlessActionExecutionState::Completed;
record.Status.Detail = "schedule handover at graph node: " + reached.NodeKey;
return true;
}
}
if (record.RouteStep >= record.Route.size()) if (record.RouteStep >= record.Route.size())
{ {
if (record.FinalLocalLegPending) if (record.FinalLocalLegPending)
@@ -41,6 +41,7 @@ public:
void Update(uint32 diff); void Update(uint32 diff);
void Clear(uint32 characterGuid); void Clear(uint32 characterGuid);
bool RequestStopAtNode(uint32 characterGuid);
ClientlessActionExecutionStatus const* GetStatus(uint32 characterGuid) const; ClientlessActionExecutionStatus const* GetStatus(uint32 characterGuid) const;
static char const* StateName(ClientlessActionExecutionState state); static char const* StateName(ClientlessActionExecutionState state);
@@ -50,6 +51,7 @@ private:
{ {
ClientlessActionExecutionStatus Status; ClientlessActionExecutionStatus Status;
bool Active = false; bool Active = false;
bool StopAtNode = false;
std::vector<ClientlessTravelRouteStep> Route; std::vector<ClientlessTravelRouteStep> Route;
std::size_t RouteStep = 0; std::size_t RouteStep = 0;
@@ -88,7 +88,7 @@ std::pair < uint32, uint32 > ClientlessFlightMgr::AreaZone(Player const * player
auto & terrain = _planningTerrain[map]; auto & terrain = _planningTerrain[map];
if (! terrain) terrain = sTerrainMgr.LoadTerrain(map); if (! terrain) terrain = sTerrainMgr.LoadTerrain(map);
uint32 zone = 0, area = 0; uint32 zone = 0, area = 0;
if (terrain) terrain->GetZoneAndAreaId(player->GetPhaseShift(), zone, area, map, point); if (terrain) terrain->GetZoneAndAreaId(player->GetPhaseShift(), map, zone, area, point);
return _planningAreas.emplace(key, std::make_pair(area, zone)).first->second; return _planningAreas.emplace(key, std::make_pair(area, zone)).first->second;
} }
@@ -2,6 +2,8 @@
#include "ClientlessPlayerMgr.h" #include "ClientlessPlayerMgr.h"
#include "ClientlessActionExecutorMgr.h" #include "ClientlessActionExecutorMgr.h"
#include "ClientlessTravelMgr.h" #include "ClientlessTravelMgr.h"
#include "AdventureBehaviorMgr.h"
#include "AdventureSocialActions.h"
#include "Config.h" #include "Config.h"
#include "Log.h" #include "Log.h"
#include "Player.h" #include "Player.h"
@@ -208,8 +210,16 @@ public:
void OnPlayerOnline(Player* player) override void OnPlayerOnline(Player* player) override
{ {
sClientlessLuaEngine->LoadAdventureProfile(player); sClientlessLuaEngine->LoadAdventureProfile(player);
AdventureSocialActions::Prepare(player);
sClientlessLuaEngine->ExecuteAdventureScript(player); sClientlessLuaEngine->ExecuteAdventureScript(player);
sAdventureBehaviorMgr->Online(player);
} }
void OnIntentChanged(Player* player, ClientlessIntent const* intent) override
{ sAdventureBehaviorMgr->IntentChanged(player, intent); }
void Update(Player* player, uint32 diff) override
{ sAdventureBehaviorMgr->Update(player, diff); }
void OnPlayerLogout(Player* player) override
{ sAdventureBehaviorMgr->Offline(player); }
}; };
} }
@@ -379,6 +389,18 @@ bool ClientlessLuaEngine::LoadAdventureProfile(Player* player)
profile.Personality = ReadFloatDictionary(_state, rootTable, "personality"); profile.Personality = ReadFloatDictionary(_state, rootTable, "personality");
profile.RegionAffinity = ReadFloatDictionary(_state, rootTable, "region_affinity"); profile.RegionAffinity = ReadFloatDictionary(_state, rootTable, "region_affinity");
profile.ActivityAffinity = ReadFloatDictionary(_state, rootTable, "activity_affinity"); profile.ActivityAffinity = ReadFloatDictionary(_state, rootTable, "activity_affinity");
profile.MicroActionAffinity = ReadFloatDictionary(_state, rootTable, "micro_action_affinity");
lua_getfield(_state, rootTable, "behavior");
if (lua_istable(_state, -1))
{
profile.AutonomousBehavior = ReadBoolField(_state, -1, "enabled", false);
profile.DailySchedule = ReadBoolField(_state, -1, "daily_schedule", false);
profile.InitialIntent = ReadStringField(_state, -1, "initial_intent");
profile.InitialLocation = ReadStringField(_state, -1, "initial_location");
profile.Locale = ReadStringField(_state, -1, "locale", "enUS");
profile.InitialDurationSeconds = uint32(std::clamp(ReadFloatField(_state, -1, "initial_duration_seconds", 3600), 30.0f, 86400.0f));
}
lua_pop(_state, 1);
uint32 const guid = player->GetGUID().GetCounter(); uint32 const guid = player->GetGUID().GetCounter();
_profiles[guid] = std::move(profile); _profiles[guid] = std::move(profile);
@@ -39,6 +39,11 @@ struct TC_GAME_API ClientlessAdventureProfile
std::unordered_map<std::string, float> Personality; std::unordered_map<std::string, float> Personality;
std::unordered_map<std::string, float> RegionAffinity; std::unordered_map<std::string, float> RegionAffinity;
std::unordered_map<std::string, float> ActivityAffinity; std::unordered_map<std::string, float> ActivityAffinity;
std::unordered_map<std::string, float> MicroActionAffinity;
bool AutonomousBehavior = false;
bool DailySchedule = false;
std::string InitialIntent, InitialLocation, Locale = "enUS";
uint32 InitialDurationSeconds = 3600;
}; };
class TC_GAME_API ClientlessLuaEngine class TC_GAME_API ClientlessLuaEngine
@@ -136,6 +136,12 @@ uint32 ClientlessPlayerMgr::GetQueuedActionCount(uint32 characterGuid) const
return itr != _records.end() ? uint32(itr->second.Actions.size()) : 0u; return itr != _records.end() ? uint32(itr->second.Actions.size()) : 0u;
} }
void ClientlessPlayerMgr::ClearQueuedActions(uint32 characterGuid)
{
auto itr = _records.find(characterGuid);
if (itr != _records.end()) itr->second.Actions.clear();
}
std::deque<ClientlessAction> const* ClientlessPlayerMgr::GetQueuedActions(uint32 characterGuid) const std::deque<ClientlessAction> const* ClientlessPlayerMgr::GetQueuedActions(uint32 characterGuid) const
{ {
auto itr = _records.find(characterGuid); auto itr = _records.find(characterGuid);
@@ -55,6 +55,7 @@ public:
bool QueueAction(uint32 characterGuid, ClientlessAction action); bool QueueAction(uint32 characterGuid, ClientlessAction action);
bool PopNextAction(uint32 characterGuid, ClientlessAction& action); bool PopNextAction(uint32 characterGuid, ClientlessAction& action);
void ClearQueuedActions(uint32 characterGuid);
uint32 GetQueuedActionCount(uint32 characterGuid) const; uint32 GetQueuedActionCount(uint32 characterGuid) const;
std::deque<ClientlessAction> const* GetQueuedActions(uint32 characterGuid) const; std::deque<ClientlessAction> const* GetQueuedActions(uint32 characterGuid) const;
@@ -409,17 +409,17 @@ bool ClientlessTravelMgr::LaunchPath(Player* player, TravelRecord& record)
sClientlessLuaEngine->DismountGroundMount(player, "local-ground-disabled"); sClientlessLuaEngine->DismountGroundMount(player, "local-ground-disabled");
player->GetMotionMaster()->MoveSmoothPath( player->GetMotionMaster()->MoveSmoothPath(
1, record.ActiveSpline.data(), record.ActiveSpline.size(), false); 1, record.ActiveSpline.data(), record.ActiveSpline.size(), record.Walk);
Position const& segmentEnd = record.ActiveSpline.back(); Position const& segmentEnd = record.ActiveSpline.back();
TC_LOG_INFO("server.adventurebot.navigation", TC_LOG_INFO("server.adventurebot.navigation",
"NAV_EXEC seq=%llu guid=%u name='%s' segment=%u splinePoints=%u " "NAV_EXEC seq=%llu guid=%u name='%s' segment=%u splinePoints=%u "
"splineLength=%.2f segmentEnd=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f)", "splineLength=%.2f segmentEnd=(%.3f %.3f %.3f) target=(%.3f %.3f %.3f) walk=%u",
static_cast<unsigned long long>(record.Status.NavSequence), static_cast<unsigned long long>(record.Status.NavSequence),
player->GetGUID().GetCounter(), player->GetName().c_str(), player->GetGUID().GetCounter(), player->GetName().c_str(),
record.Status.SegmentIndex, uint32(record.ActiveSpline.size()), accumulated, record.Status.SegmentIndex, uint32(record.ActiveSpline.size()), accumulated,
segmentEnd.GetPositionX(), segmentEnd.GetPositionY(), segmentEnd.GetPositionZ(), segmentEnd.GetPositionX(), segmentEnd.GetPositionY(), segmentEnd.GetPositionZ(),
target.GetPositionX(), target.GetPositionY(), target.GetPositionZ()); target.GetPositionX(), target.GetPositionY(), target.GetPositionZ(), record.Walk ? 1u : 0u);
record.Status.State = ClientlessTravelState::Moving; record.Status.State = ClientlessTravelState::Moving;
record.LastDistance = player->GetDistance( record.LastDistance = player->GetDistance(
@@ -432,7 +432,7 @@ bool ClientlessTravelMgr::LaunchPath(Player* player, TravelRecord& record)
return true; return true;
} }
bool ClientlessTravelMgr::StartTravel(Player* player, Position const& target, bool allowGroundMount, bool portalApproach) bool ClientlessTravelMgr::StartTravel(Player* player, Position const& target, bool allowGroundMount, bool portalApproach, bool walk)
{ {
if (!player || !player->IsInWorld()) if (!player || !player->IsInWorld())
return false; return false;
@@ -444,7 +444,8 @@ bool ClientlessTravelMgr::StartTravel(Player* player, Position const& target, bo
record.Status.State = ClientlessTravelState::Moving; record.Status.State = ClientlessTravelState::Moving;
record.Status.MapId = player->GetMapId(); record.Status.MapId = player->GetMapId();
record.Status.Target = target; record.Status.Target = target;
record.AllowGroundMount = allowGroundMount; record.AllowGroundMount = allowGroundMount && !walk;
record.Walk = walk;
record.PortalApproach = portalApproach; record.PortalApproach = portalApproach;
LaunchPath(player, record); LaunchPath(player, record);
+2 -1
View File
@@ -57,7 +57,7 @@ class TC_GAME_API ClientlessTravelMgr
public: public:
static ClientlessTravelMgr* instance(); static ClientlessTravelMgr* instance();
bool StartTravel(Player* player, Position const& target, bool allowGroundMount = false, bool portalApproach = false); bool StartTravel(Player* player, Position const& target, bool allowGroundMount = false, bool portalApproach = false, bool walk = false);
bool AnalyzePath(Player* player, Position const& target, ClientlessPathDiagnostics& diagnostics) const; bool AnalyzePath(Player* player, Position const& target, ClientlessPathDiagnostics& diagnostics) const;
void CancelTravel(uint32 characterGuid); void CancelTravel(uint32 characterGuid);
ClientlessTravelStatus const* GetStatus(uint32 characterGuid) const; ClientlessTravelStatus const* GetStatus(uint32 characterGuid) const;
@@ -76,6 +76,7 @@ private:
uint32 CheckTimerMs = 0; uint32 CheckTimerMs = 0;
std::vector<Position> ActiveSpline; std::vector<Position> ActiveSpline;
bool AllowGroundMount = false; bool AllowGroundMount = false;
bool Walk = false;
// VCORE_VIRTUAL_EDGE_V11_PORTAL_APPROACH: only actual portal source steps. // VCORE_VIRTUAL_EDGE_V11_PORTAL_APPROACH: only actual portal source steps.
bool PortalApproach = false; bool PortalApproach = false;
}; };
@@ -1,3 +1,2 @@
-- Generic AdventureBot routine: semantic intent only. -- Profiles configure behavior. The Core planner generates schedules and actions.
log("generic adventure routine loaded for " .. character_name) log("adventure profile controller ready for " .. character_name)
intend("idle", { routine = "generic", purpose = "await_planner" })
@@ -6,6 +6,17 @@ return {
home_region = "elwynn", home_region = "elwynn",
}, },
behavior = {
enabled = true,
daily_schedule = false, -- First acceptance: social intent, planner remains inactive.
initial_intent = "social",
initial_location = "goldshire_tavern",
initial_duration_seconds = 3600,
locale = "deDE",
},
micro_action_affinity = { drink = 1.5, rest = 1.2, speak = 1.0 },
movement = { movement = {
can_ground_mount = true, can_ground_mount = true,
can_free_fly = true, can_free_fly = true,
@@ -18,6 +18,7 @@
#include "ClientlessPerceptionMgr.h" #include "ClientlessPerceptionMgr.h"
#include "ClientlessActionExecutorMgr.h" #include "ClientlessActionExecutorMgr.h"
#include "ClientlessWorldPointMgr.h" #include "ClientlessWorldPointMgr.h"
#include "AdventureBehaviorMgr.h"
#include "ClientlessTravelGraphMgr.h" #include "ClientlessTravelGraphMgr.h"
#include "ClientlessRouteRecorderMgr.h" #include "ClientlessRouteRecorderMgr.h"
#include "ClientlessLuaEngine.h" #include "ClientlessLuaEngine.h"
@@ -2278,6 +2279,7 @@ static bool HandleClientlessTravelReload(ChatHandler* handler, char const* /*arg
{ "import", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiImport, "" }, { "import", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiImport, "" },
{ "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiInfo, "" }, { "info", rbac::RBAC_PERM_COMMAND_GM, false, &HandleClientlessTaxiInfo, "" },
} }, } },
{ "adventure", rbac::RBAC_PERM_COMMAND_GM, true, +[](ChatHandler* handler, char const* args) { return sAdventureBehaviorMgr->Command(handler,args); }, "" },
{ "flight", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessFlight, "" }, { "flight", rbac::RBAC_PERM_COMMAND_GM, true, &HandleClientlessFlight, "" },
{ "portal", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", { { "portal", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", {
{ "object", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", { { "object", rbac::RBAC_PERM_COMMAND_GM, true, nullptr, "", {
@@ -0,0 +1,43 @@
"""Compile the shared production day-planning/classification/history policy."""
from pathlib import Path
import subprocess
import tempfile
root=Path(__file__).resolve().parents[2]
harness=r'''
#include "AdventureBehaviorPolicy.h"
#include <cassert>
#include <iostream>
#include <set>
using namespace AdventureBehaviorPolicy;
int main(){
assert(Classify("WorldsEndTavern")=="tavern");
assert(Classify("ShadyRestInn")=="tavern");
assert(Classify("JasperlodeMine")=="mine");
assert(Classify("GolBolarQuarry")=="mine");
assert(Classify("TheVortexPinnacle")!="tavern");
assert(Classify("UnclearPlace")=="landmark");
assert(Classify("AbandonedTavern")=="tavern"); // deliberately broad, later editable
std::mt19937 random(26);unsigned intents=0,locations=0;
auto day=Generate(random,0,[&](){return ++intents%2?"social":"adventure";},[&](auto const& i){++locations;return i+std::to_string(locations);});
assert(locations==intents&&intents>=6&&intents<=24);
unsigned run=0;std::string last;
for(unsigned h=0;h<24;++h){assert(!day[h].Intent.empty()&&!day[h].Location.empty());if(day[h].Location==last)++run;else{run=1;last=day[h].Location;}assert(run<=4);}
intents=locations=0;
auto roaming=Generate(random,1,[&](){++intents;return "adventure";},[&](auto const&){return "zone"+std::to_string(++locations);});
assert(locations==24);for(unsigned h=1;h<24;++h)assert(roaming[h].Location!=roaming[h-1].Location);
std::deque<std::string> history;
assert(Novelty(history,"laugh")==1);Remember(history,"laugh");assert(Novelty(history,"laugh")==0.08);
Remember(history,"wave");assert(Novelty(history,"laugh")<1&&Novelty(history,"laugh")>0.08);
for(unsigned n=0;n<30;++n)Remember(history,"other");assert(history.size()==12&&Novelty(history,"laugh")==1);
assert(Choose({},random)==0);assert(Choose({0,0},random)==2);
// With equal base weights, recent repetition actually changes the distribution.
history.clear();unsigned repeats=0;std::string previous;
for(unsigned n=0;n<10000;++n){std::vector<double>w{Novelty(history,"laugh"),Novelty(history,"wave"),Novelty(history,"nod")};auto i=Choose(w,random);auto key=std::array<std::string,3>{"laugh","wave","nod"}[i];repeats+=key==previous;Remember(history,key);previous=key;}
assert(repeats<1800);
std::cout<<"24-hour generation, shared intents with changing destinations, broad location classification and repetition penalties passed.\n";
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-adventure-policy-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(harness)
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)
@@ -0,0 +1,50 @@
"""Exercise the production executor's safe-node stopping guard."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/ClientlessActionExecutorMgr.cpp').read_text()
a=s.index('bool ClientlessActionExecutorMgr::RequestStopAtNode(')
b=s.index('\n}\n',a)+3
request=s[a:b]
a=s.index('bool ClientlessActionExecutorMgr::StartRouteStep(')
b=s.index(' if (record.RouteStep >= record.Route.size())',a)
guard=s[a:b]+' return false;\n}\n'
code=r'''
#include <cassert>
#include <cstdint>
#include <string>
#include <vector>
#include <unordered_map>
using uint32=uint32_t;
enum class ClientlessActionExecutionState{Moving,Completed};
struct Player {bool transport=false,taxi=false,flying=false,teleport=false;bool GetTransport(){return transport;}bool IsInFlight(){return taxi;}bool IsFlying(){return flying;}bool IsBeingTeleported(){return teleport;}};
struct Record {
bool Active=true,StopAtNode=false;std::size_t RouteStep=1;
struct Step{std::string NodeKey;};std::vector<Step> Route{{"goldshire"}};
struct{ClientlessActionExecutionState State=ClientlessActionExecutionState::Moving;std::string Detail;}Status;
};
class ClientlessActionExecutorMgr {public:
std::unordered_map<uint32,Record> _records{{26,{}}};
bool RequestStopAtNode(uint32);bool StartRouteStep(Player*,uint32,Record&);
};
'''
code+=request+guard+r'''
int main(){
ClientlessActionExecutorMgr mgr;Player p;auto& r=mgr._records[26];
assert(mgr.RequestStopAtNode(26));assert(r.Active); // never cancels transit on request
p.transport=true;assert(!mgr.StartRouteStep(&p,26,r));assert(r.Active);p.transport=false;
p.taxi=true;assert(!mgr.StartRouteStep(&p,26,r));p.taxi=false;
p.flying=true;assert(!mgr.StartRouteStep(&p,26,r));p.flying=false;
p.teleport=true;assert(!mgr.StartRouteStep(&p,26,r));p.teleport=false;
assert(mgr.StartRouteStep(&p,26,r));assert(!r.Active&&r.Status.State==ClientlessActionExecutionState::Completed);
assert(!mgr.RequestStopAtNode(26)&&!mgr.RequestStopAtNode(99));
r.Active=true;r.Route[0].NodeKey="virtual_ground_exit";
assert(mgr.StartRouteStep(&p,26,r)); // finish at a safe road boundary before a stale flight
r.Active=true;r.Route[0].NodeKey="flight_location";assert(!mgr.StartRouteStep(&p,26,r));
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-graph-handover-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code)
subprocess.run(['c++','-std=c++17',str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
print('Production graph handover waits for ship/taxi/flight/teleport and stops at safe road/node boundaries.')
@@ -0,0 +1,103 @@
"""Execute the production intent state machine with deterministic clock/travel doubles."""
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::Update(');b=s.index('bool AdventureBehaviorMgr::Command(',a)
code=r'''
#include "AdventureBehaviorPolicy.h"
#include <cassert>
#include <cstdint>
#include <ctime>
#include <string>
#include <unordered_map>
using uint32=uint32_t;using uint64=uint64_t;
#define TC_LOG_INFO(...) ((void)0)
constexpr int SPELL_VISUAL_KIT_DRINK=438,UNIT_NPC_EMOTESTATE=0;
struct Position {float x=0;void Relocate(void const*){}};
struct Guid{uint32 GetCounter(){return 26;}};
struct Object{} object;
struct Map{Object* GetGameObject(int){return &object;}} map;
struct Player {
bool combat=false;uint32 area=87,zone=12,mapId=0;
Guid GetGUID(){return {};}bool IsInWorld(){return true;}bool IsInCombat(){return combat;}
uint32 GetMapId(){return mapId;}uint32 GetAreaId(){return area;}uint32 GetZoneId(){return zone;}
float GetPositionX(){return 0;}float GetPositionY(){return 0;}float GetPositionZ(){return 0;}
float GetExactDist(float,float,float){return 0;}float GetExactDist(Object*){return 0;}
void SendPlaySpellVisualKit(int,int,int){}void SetUInt32Value(int,uint32){}
bool IsWithinLOSInMap(Object*){return true;}void SetFacingToObject(Object*){}
Map* GetMap(){return &map;}
} player;
bool unsafe=false;bool Unsafe(Player*){return unsafe;}
bool busy=false;bool Busy(uint32){return busy;}
time_t now=100;int hour=5;
namespace GameTime {time_t GetGameTime(){return now;}}
std::tm Local(time_t){std::tm t{};t.tm_year=126;t.tm_mon=9;t.tm_mday=26;t.tm_hour=hour;return t;}
uint32 DateOf(std::tm const&){return 20261026;}
enum class ClientlessActionExecutionState{Idle,Moving,Completed,Failed};
struct Status {ClientlessActionExecutionState State=ClientlessActionExecutionState::Completed;std::string Detail;};
struct Executor{Status status;auto GetStatus(uint32){return &status;}} executor;auto sClientlessActionExecutorMgr=&executor;
struct Manager{uint32 queued=0;uint32 GetQueuedActionCount(uint32){return queued;}} manager;auto sClientlessPlayerMgr=&manager;
struct Graph{
bool nearest=true;void EnsureLoaded(){}bool FindNearestNodeToPosition(uint32,float,float,float,std::string& key,float& distance){key="node";distance=10;return nearest;}
} graph;auto sClientlessTravelGraphMgr=&graph;
struct AdventureLocation{struct{uint32 MapId=0;float X=0,Y=0,Z=0;}Point;bool IsArea=false;uint32 AreaId=12;} location;
struct Locations{auto Find(std::string const&){return &location;}} locations;auto sAdventureLocationMgr=&locations;
enum class ClientlessTravelState{Moving,Arrived};
struct Travel{struct{ClientlessTravelState State=ClientlessTravelState::Arrived;}status;auto GetStatus(uint32){return &status;}}travel;auto sClientlessTravelMgr=&travel;
class AdventureBehaviorMgr {
public:
enum class Phase{Idle,Travel,OnSite,Switching,Handover};
struct Record{
bool Enabled=true,Schedule=false,Queued=false,Dirty=false;
Phase State=Phase::Travel;
std::string Intent="social",Location="tavern",WantedIntent="social",WantedLocation="tavern",Detail,Micro;
time_t Deadline=1000;uint32 Date=20261026,Timer=0,MicroTimer=0,GestureTimer=0,RetryTimer=0;int Hour=5;
struct{bool Active=false;}Social;
AdventureBehaviorPolicy::Day Plan;Position Anchor;uint32 AnchorMap=0;int WorkObject=0;uint32 WorkEmote=0;bool WorkPending=false;
};
std::unordered_map<uint32,Record> _records{{26,{}}};
int begun=0,fallbacks=0,micros=0,resets=0,switches=0,queues=0;
void Update(Player*,uint32);
void Generate(Player*,Record&,uint32){}
void SetWanted(Player*,Record& r,std::string const&i,std::string const&l,time_t d){++switches;r.WantedIntent=i;r.WantedLocation=l;r.Deadline=d;r.Dirty=true;r.State=Phase::Idle;}
void Begin(Player*,Record&){++begun;}
void Fallback(Player*,Record&){++fallbacks;}
void ResetMicro(Player*,Record&r){++resets;r.Micro.clear();}
void Micro(Player*,Record&){++micros;}
bool UpdateSocial(Player*,Record&,uint32){return true;}
void Queue(Player*,Record&,std::string const&,std::string const&,std::string const&,bool=false){++queues;}
};
'''
code+=s[a:b]+r'''
int main(){
AdventureBehaviorMgr mgr;auto& r=mgr._records.at(26);
// A completed travel at the exact location opens micro actions.
mgr.Update(&player,1000);assert(r.State==AdventureBehaviorMgr::Phase::OnSite&&mgr.micros==1);
// Best-effort graph progress must not pretend an area has been reached.
r.State=AdventureBehaviorMgr::Phase::Travel;location.IsArea=true;
executor.status.Detail="best-effort travel completed at nearest useful reachable point";
mgr.Update(&player,1000);assert(mgr.fallbacks==1);
location.IsArea=false;executor.status.Detail="";
// A failed route triggers replacement selection only after the executor finishes.
executor.status.State=ClientlessActionExecutionState::Failed;
mgr.Update(&player,1000);assert(mgr.fallbacks==2);
busy=true;mgr.Update(&player,1000);assert(mgr.fallbacks==2);busy=false;
// Hour switch occurs despite an active transport/flight, without starting a new trip.
r.Schedule=true;r.Plan[6]={"profession","mine"};hour=6;unsafe=true;
mgr.Update(&player,1000);assert(mgr.switches==1&&mgr.begun==0);
unsafe=false;r.Schedule=false;r.Deadline=1000;
// Safe handover waits for active travel, then queues a hard node destination.
r.State=AdventureBehaviorMgr::Phase::Switching;busy=true;
mgr.Update(&player,1000);assert(mgr.queues==0);busy=false;
mgr.Update(&player,1000);assert(mgr.queues==1&&r.State==AdventureBehaviorMgr::Phase::Handover);
// Deadline discards the old intent rather than extending the visit.
now=1001;r.State=AdventureBehaviorMgr::Phase::OnSite;
mgr.Update(&player,1000);assert(mgr.switches==2&&r.WantedIntent.empty()&&mgr.begun==1);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-adventure-state-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code)
subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
print('Production intent arrival, failed/partial travel, deadlines and safe schedule handover passed.')
@@ -0,0 +1,45 @@
"""Production micro cleanup cancels the physical spline without interrupting transit."""
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::ResetMicro(');b=s.index('bool AdventureBehaviorMgr::LocalMove(',a)
code=r'''
#include <cassert>
#include <cstdint>
#include <string>
using uint32=uint32_t;
constexpr int UNIT_NPC_EMOTESTATE=0,UNIT_STAND_STATE_STAND=0;
struct Guid{uint32 GetCounter(){return 26;}};
struct Player{
bool alive=true,transport=false,taxi=false,flying=false,teleport=false;
int stops=0,stands=0,emotes=0;
bool IsAlive(){return alive;}bool GetTransport(){return transport;}bool IsInFlight(){return taxi;}bool IsFlying(){return flying;}bool IsBeingTeleported(){return teleport;}
void SetStandState(int){++stands;}void SetUInt32Value(int,int){++emotes;}void StopMoving(){++stops;}
Guid GetGUID(){return {};}
};
bool busy=false;bool Busy(uint32){return busy;}
struct Travel{int cancels=0;void CancelTravel(uint32){++cancels;}} travel;auto sClientlessTravelMgr=&travel;
struct ObjectGuid{void Clear(){}};
struct SocialScene{};
struct AdventureSocialActions{static void Reset(Player*,SocialScene&){}};
class AdventureBehaviorMgr{public:
struct Record{SocialScene Social;std::string Micro="explore";int MicroTimer=100,GestureTimer=100;ObjectGuid WorkObject;int WorkEmote=0;bool WorkPending=false;};
void ResetMicro(Player*,Record&);
};
'''
code+=s[a:b]+r'''
int main(){
AdventureBehaviorMgr mgr;AdventureBehaviorMgr::Record r;Player p;
mgr.ResetMicro(&p,r);assert(p.stops==1&&travel.cancels==1&&r.Micro.empty());
r.Micro="drink";p.transport=true;mgr.ResetMicro(&p,r);assert(p.stops==1&&travel.cancels==1);
p.transport=false;p.flying=true;r.Micro="look";mgr.ResetMicro(&p,r);assert(p.stops==1);
p.flying=false;busy=true;r.Micro="look";mgr.ResetMicro(&p,r);assert(p.stops==1);
busy=false;p.alive=false;r.Micro="rest";int stands=p.stands;mgr.ResetMicro(&p,r);assert(p.stands==stands);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-micro-reset-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code)
subprocess.run(['c++','-std=c++17',str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
print('Micro cleanup ends physical local movement, preserves transit and does not stand up dead players.')
+132
View File
@@ -0,0 +1,132 @@
"""Run production social cues with native-adapter doubles and the real sequence clock."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
source=(root/'src/server/game/World/AdventureSocialActions.cpp').read_text()
def function(signature):
a=source.index(signature);b=source.index('{',a);depth=1;c=b+1
while depth:
depth+=(source[c]=='{')-(source[c]=='}');c+=1
return source[a:c]
code=r'''
#include "AdventureSocialFlow.h"
#include <cassert>
#include <string>
#include <vector>
#include <cstdint>
using uint32=uint32_t;
using namespace AdventureSocialFlow;
#define TC_LOG_INFO(...) ((void)0)
constexpr int CookingFire=818,UNIT_NPC_EMOTESTATE=0,UNIT_STAND_STATE_SIT=1,UNIT_STAND_STATE_SIT_LOW_CHAIR=4,CMSG_GAMEOBJ_USE=1;
constexpr int EMOTE_ONESHOT_WAVE=3,EMOTE_ONESHOT_TALK=1,EMOTE_ONESHOT_YES=273,EMOTE_ONESHOT_CHEER=4,EMOTE_STATE_DANCE=10;
constexpr int SPELL_VISUAL_KIT_DRINK=438,SPELL_VISUAL_KIT_FOOD=406,GAMEOBJECT_TYPE_CHAIR=7,SPELL_CAST_OK=0;
struct ObjectGuid{int id=0;uint32 GetCounter(){return id;}bool IsEmpty()const{return !id;}std::string ToString()const{return std::to_string(id);}};
struct Unit;struct GameObject;
struct WorldObject{
int kind=0;ObjectGuid guid{1};virtual ~WorldObject()=default;
float x=0,y=0,z=0,heading=0;float GetPositionX(){return x;}float GetOrientation(){return heading;}float GetPositionY(){return y;}float GetPositionZ(){return z;}
Unit* ToUnit();GameObject* ToGameObject();ObjectGuid GetGUID(){return guid;}
};
struct Unit:WorldObject{Unit(){kind=1;}};
struct GameObject:WorldObject{
struct Info{float size=1;struct{int triggeredEvent=0;uint32 chairslots=1,chairheight=1;}chair;}info;
GameObject(){kind=2;guid.id=2;}
int GetGoType(){return GAMEOBJECT_TYPE_CHAIR;}Info* GetGOInfo(){return &info;}
};
Unit* WorldObject::ToUnit(){return kind==1?static_cast<Unit*>(this):nullptr;}
GameObject* WorldObject::ToGameObject(){return kind==2?static_cast<GameObject*>(this):nullptr;}
struct WorldPacket{WorldPacket(int,int){}WorldPacket& operator<<(ObjectGuid){return *this;}};
struct Session{int uses=0;void HandleGameObjectUseOpcode(WorldPacket&){++uses;}};
struct Player:Unit{
Player(){x=2;}
std::vector<int> emotes;int faces=0,casts=0;bool casting=false,sit=false,los=true;float distance=2;Session session;
ObjectGuid selection;
bool IsBeingTeleported(){return false;}bool IsSitState(){return sit;}bool IsNonMeleeSpellCast(bool){return casting;}
float GetExactDist(WorldObject*){return distance;}void SetFacingToObject(WorldObject*){++faces;}
void SetFacingTo(float){}int chairStand=5;uint32 GetStandState(){return sit?chairStand:0;}
void SetSelection(ObjectGuid g){selection=g;}void SetUInt32Value(int,int){}
void HandleEmoteCommand(int id){emotes.push_back(id);}void SendPlaySpellVisualKit(int,int,int){}
void SetStandState(int){sit=true;}int CastSpell(Player*,int,bool){++casts;casting=true;return SPELL_CAST_OK;}
bool IsWithinLOSInMap(WorldObject*){return los;}bool IsWithinLOS(float,float,float){return los;}bool GetGameObjectIfCanInteractWith(ObjectGuid){return true;}Session* GetSession(){return &session;}
};
struct Position{};
struct AdventureSocialContext{};
struct AdventureSocialScene{
bool Active=true,Approaching=false,Casting=false,WaitingChair=false,Seated=false;
std::string Action="social_npc",Kind="npc",TargetKey;ObjectGuid Target{1},PreviousSelection;Position Destination;
uint32 Elapsed=0,CastElapsed=0,CheckTimer=0,Repaths=0;Sequence Sequence;
};
WorldObject* target=nullptr;GameObject* fire=nullptr;bool friendly=true,approach=true;
WorldObject* Resolve(Player*,ObjectGuid g){return g.id?target:nullptr;}
bool InBounds(Player*,WorldObject* t,AdventureSocialContext&){return t!=nullptr;}
bool Friendly(Player*,Unit*,AdventureSocialContext&){return friendly;}
bool Approach(Player*,AdventureSocialScene& s,WorldObject*,AdventureSocialContext&){s.Approaching=true;return approach;}
GameObject* Fire(Player*,AdventureSocialContext&){return fire;}
bool CanMakeFire(Player*){return true;}
void Say(Player*,std::string const&,AdventureSocialContext&){}
uint32 Reaction(AdventureSocialContext&){return 11;}
enum class ClientlessTravelState{Moving,Arrived};
struct Travel{struct{ClientlessTravelState State=ClientlessTravelState::Moving;}status;auto GetStatus(uint32){return &status;}}travel;auto sClientlessTravelMgr=&travel;
class AdventureSocialActions{public:static bool Update(Player*,AdventureSocialScene&,uint32,AdventureSocialContext&);};
'''.replace('Sequence Sequence;', 'AdventureSocialFlow::Sequence Sequence;')
code+=function('bool OnChair(')+'\n'+function('bool AtFront(')+'\n'+function('std::vector < Step > Steps(')+'\n'+function('bool AdventureSocialActions::Update(')
code+=r'''
int main(){
for(float heading:{0.0f,1.5707963f,3.1415926f,4.7123890f,-3.1415926f})
{
for(float offset:{0.0f,0.28f,-0.28f,0.55f,-0.55f})
assert(ConversationFront(10,20,heading,10+2.2f*std::cos(heading+offset),20+2.2f*std::sin(heading+offset)));
assert(!ConversationFront(10,20,heading,10-2.2f*std::cos(heading),20-2.2f*std::sin(heading)));
assert(!ConversationFront(10,20,heading,10+2.2f*std::cos(heading+1.5707963f),20+2.2f*std::sin(heading+1.5707963f)));
}
assert(!ConversationFront(0,0,0,0.2f,0)&&!ConversationFront(0,0,0,8,0));
AdventureSocialContext c;Unit npc;GameObject chair;Player p;AdventureSocialScene s;target=&npc;
s.Sequence.Start(Steps("npc",30000));s.Approaching=true;
assert(AdventureSocialActions::Update(&p,s,1000,c)&&p.emotes.empty());
travel.status.State=ClientlessTravelState::Arrived;
assert(AdventureSocialActions::Update(&p,s,1000,c)&&p.emotes.size()==1&&p.emotes[0]==EMOTE_ONESHOT_WAVE);
bool active=true;for(unsigned i=0;i<60&&active;++i)active=AdventureSocialActions::Update(&p,s,1000,c);
assert(!active);int talks=0,waves=0;for(auto id:p.emotes){talks+=id==1;waves+=id==3;}assert(talks==2&&waves==2);
// Being close behind a partner must reposition, not start talking there.
s={};s.Sequence.Start(Steps("npc",30000));p.x=-2;auto before=p.emotes.size();
assert(AdventureSocialActions::Update(&p,s,1000,c)&&p.emotes.size()==before&&s.Repaths>0);p.x=2;
// Missing or newly hostile partners terminate the scene without more emotes.
s={};s.Sequence.Start(Steps("npc",30000));target=nullptr;auto count=p.emotes.size();
assert(!AdventureSocialActions::Update(&p,s,1000,c)&&p.emotes.size()==count);
target=&npc;friendly=false;assert(!AdventureSocialActions::Update(&p,s,1000,c));friendly=true;
// Chairs go through the native use handler once and must confirm sitting.
s={};s.Target.id=2;s.Sequence.Start(Steps("seat",3000));target=&chair;
assert(AdventureSocialActions::Update(&p,s,1000,c)&&p.session.uses==1&&s.WaitingChair);
assert(!AdventureSocialActions::Update(&p,s,1000,c)); // no fabricated successful seat
s={};s.Target.id=2;s.Sequence.Start(Steps("seat",3000));p.sit=true;p.x=0;
assert(AdventureSocialActions::Update(&p,s,1000,c));assert(AdventureSocialActions::Update(&p,s,1000,c)&&s.Seated);
// Native chair animation beside the actual slot is not successful seating.
float seatError=0;
assert(OnChair(&p,&chair,seatError));p.x=2;assert(!OnChair(&p,&chair,seatError));p.x=0;
p.chairStand=UNIT_STAND_STATE_SIT;assert(!OnChair(&p,&chair,seatError));p.chairStand=5;
p.z=1;assert(!OnChair(&p,&chair,seatError));p.z=0;
chair.heading=1.5707963f;assert(!OnChair(&p,&chair,seatError));p.heading=chair.heading;assert(OnChair(&p,&chair,seatError));chair.heading=p.heading=0;
chair.info.chair.chairslots=3;p.y=1;assert(OnChair(&p,&chair,seatError));p.y=0.5f;assert(!OnChair(&p,&chair,seatError));p.y=0;chair.info.chair.chairslots=1;
// Furniture with event triggers must never be clicked by these scenes.
s={};s.Target.id=2;s.Sequence.Start(Steps("seat",3000));chair.info.chair.triggeredEvent=1;int uses=p.session.uses;
assert(!AdventureSocialActions::Update(&p,s,1000,c)&&p.session.uses==uses);
// Native interaction range alone must not allow seating through a wall.
chair.info.chair.triggeredEvent=0;p.los=false;s={};s.Target.id=2;s.Sequence.Start(Steps("seat",3000));uses=p.session.uses;
assert(!AdventureSocialActions::Update(&p,s,1000,c)&&p.session.uses==uses);p.los=true;
// Fire cast uses a normal native spell once; absence of an object times out.
s={};s.Target.id=0;s.Sequence.Start(Steps("fire",30000));target=nullptr;fire=nullptr;p.casting=false;
assert(AdventureSocialActions::Update(&p,s,1000,c)&&p.casts==1&&s.Casting);
assert(AdventureSocialActions::Update(&p,s,1000,c)&&p.casts==1);
p.casting=false;active=true;for(unsigned i=0;i<12&&active;++i)active=AdventureSocialActions::Update(&p,s,1000,c);
assert(!active&&p.casts==1);
// Conversely, a real newly spawned object is confirmed and approached.
s={};s.Target.id=0;s.Sequence.Start(Steps("fire",30000));
assert(AdventureSocialActions::Update(&p,s,1000,c));p.casting=false;fire=&chair;
assert(AdventureSocialActions::Update(&p,s,1000,c)&&s.Target.id==2&&s.Approaching&&!s.Casting);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-social-test-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code)
subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
print('Production social flow: approach before talk, coherent greeting/talk/listen/farewell, lost targets, native chair confirmation and real fire confirmation passed.')
@@ -0,0 +1,49 @@
"""Compile native fire detection/provisioning and casting prerequisites from production."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/AdventureSocialActions.cpp').read_text()
def function(signature):
a=s.index(signature);b=s.index('{',a);depth=1;e=b+1
while depth:depth+=(s[e]=='{')-(s[e]=='}');e+=1
return s[a:e]
code=r'''
#include "AdventureBehaviorPolicy.h"
#include <cassert>
#include <cstdint>
using uint32=uint32_t;
using AdventureBehaviorPolicy::Normalize;
constexpr uint32 CookingFire=818;
struct GameObject{bool active=true,bounds=true;uint32 spell=818;std::string name="Localised unknown name";float distance=2;bool isSpawned(){return active;}std::string GetName(){return name;}uint32 GetSpellId(){return spell;}};
struct AdventureSocialContext{};
struct History{bool cooldown=false;bool HasCooldown(uint32 id){assert(id==818);return cooldown;}};
struct Player {
bool learned=false,outdoors=true,water=false,falling=false,casting=false;int learns=0;History history;std::vector<GameObject*> objects;
bool HasSpell(uint32 id){assert(id==818);return learned;}void LearnSpell(uint32 id,bool dependent){assert(id==818&&!dependent);learned=true;++learns;}
bool IsOutdoors(){return outdoors;}bool IsInWater(){return water;}bool IsFalling(){return falling;}bool IsNonMeleeSpellCast(bool){return casting;}
History* GetSpellHistory(){return &history;}float GetExactDist(GameObject* go){return go->distance;}
void GetGameObjectListWithEntryInGrid(std::vector<GameObject*>& out,int,float){out=objects;}
};
bool InBounds(Player*,GameObject* go,AdventureSocialContext&){return go->bounds;}
struct Spells{bool exists=true;void* GetSpellInfo(uint32 id){assert(id==818);return exists?this:nullptr;}}spells;auto sSpellMgr=&spells;
class AdventureSocialActions{public:static void Prepare(Player*);};
'''
code+=function('GameObject * Fire(')+'\n'+function('bool CanMakeFire(')+'\n'+function('void AdventureSocialActions::Prepare(')
code+=r'''
int main(){
Player p;AdventureSocialContext c;GameObject fire;
assert(!CanMakeFire(&p));AdventureSocialActions::Prepare(&p);assert(p.learned&&p.learns==1&&CanMakeFire(&p));
AdventureSocialActions::Prepare(&p);assert(p.learns==1); // idempotent, no profession query available
p.history.cooldown=true;assert(!CanMakeFire(&p));p.history.cooldown=false;
p.outdoors=false;assert(!CanMakeFire(&p));p.outdoors=true;
p.water=true;assert(!CanMakeFire(&p));p.water=false;p.falling=true;assert(!CanMakeFire(&p));p.falling=false;
p.objects={&fire};assert(Fire(&p,c)==&fire); // native spell ID works regardless of locale/name
fire.spell=0;assert(Fire(&p,c)==nullptr);fire.name="Basic Campfire";assert(Fire(&p,c)==&fire);
fire.active=false;assert(Fire(&p,c)==nullptr);fire.active=true;fire.bounds=false;assert(Fire(&p,c)==nullptr);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-social-fire-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code)
subprocess.run(['c++','-std=c++17','-I',str(root/'src/server/game/World'),str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
print('Native fire detection by spell ID, idempotent shared spell provisioning, cooldown and outdoor/water checks passed without a profession gate.')
+54
View File
@@ -0,0 +1,54 @@
"""Compile production travel setup and native spline forwarding for per-trip walking."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/ClientlessTravelMgr.cpp').read_text()
a=s.index('bool ClientlessTravelMgr::StartTravel(');b=s.index('void ClientlessTravelMgr::CancelTravel(',a)
start=s[a:b]
a=s.index(' player->GetMotionMaster()->MoveSmoothPath(');b=s.index(' Position const& segmentEnd',a)
launch=s[a:b]
code=r'''
#include <cassert>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
using uint32=uint32_t;
#define TC_LOG_INFO(...) ((void)0)
struct Position{float GetPositionX()const{return 0;}float GetPositionY()const{return 0;}float GetPositionZ()const{return 0;}};
struct Guid{uint32 GetCounter(){return 26;}};
struct Motion{bool walk=false;int calls=0;void MoveSmoothPath(uint32,Position const*,std::size_t,bool w){walk=w;++calls;}}motion;
struct Player{
bool IsInWorld(){return true;}Guid GetGUID(){return {};}uint32 GetMapId(){return 0;}Motion* GetMotionMaster(){return &motion;}
};
enum class ClientlessTravelState{Moving};
class ClientlessTravelMgr{
public:
struct TravelRecord{
struct{ClientlessTravelState State;uint32 MapId;Position Target;float RemainingDistance=0;}Status;
bool Walk=false,AllowGroundMount=false,PortalApproach=false;
std::vector<Position> ActiveSpline;
};
std::unordered_map<uint32,TravelRecord> _records;
bool StartTravel(Player*,Position const&,bool=false,bool=false,bool=false);
bool LaunchPath(Player* player,TravelRecord& record){
'''
code+=launch+r'''
return true;}
};
'''+start+r'''
int main(){
ClientlessTravelMgr mgr;Player p;Position goal;
mgr.StartTravel(&p,goal,false,false,true);
assert(motion.walk&&mgr._records[26].Walk&&!mgr._records[26].AllowGroundMount);
mgr.LaunchPath(&p,mgr._records[26]);assert(motion.walk); // repath preserves gait
mgr.StartTravel(&p,goal,true,false,true);assert(motion.walk&&!mgr._records[26].AllowGroundMount);
mgr.StartTravel(&p,goal,true);assert(!motion.walk&&!mgr._records[26].Walk&&mgr._records[26].AllowGroundMount);
mgr.StartTravel(&p,goal,false,true);assert(!motion.walk&&mgr._records[26].PortalApproach);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-walk-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code)
subprocess.run(['c++','-std=c++17',str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
print('Production travel gait reaches native spline, survives repaths, prevents mounting and preserves legacy running defaults.')
@@ -0,0 +1,43 @@
"""Production near-teleport broadcast must identify a clientless mover without incoming movement packets."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/Handlers/MovementHandler.cpp').read_text()
a=s.index(' WorldPackets::Movement::MoveUpdateTeleport moveUpdateTeleport;')
b=s.index(' plMover->UpdatePosition(dest, true);',a)
fragment=s[a:b]
code=r'''
#include <cassert>
#include <cstdint>
using uint32=uint32_t;
struct Position{float x=0,y=0,z=0;void Relocate(Position const& p){*this=p;}};
struct MovementInfo{uint32 guid=0,time=0;Position pos;};
struct Packet{uint32 guid,time;Position pos;};
namespace GameTime{uint32 GetGameTimeMS(){return 123456;}}
namespace WorldPackets{namespace Movement{
struct MoveUpdateTeleport{MovementInfo* Status=nullptr;Packet packet;Packet* Write(){packet={Status->guid,Status->time,Status->pos};return &packet;}};
}}
struct Player{
MovementInfo m_movementInfo;Packet received{};
uint32 GetGUID(){return 26;}void SendMessageToSet(Packet* p,bool self){assert(!self);received=*p;}
};
class WorldSession{public:bool clientless=true;bool IsClientless(){return clientless;}void Broadcast(Player* plMover,Position const& dest){
'''
code+=fragment+r'''
}};
int main(){
WorldSession session;Player p;Position chair{10,20,30};
assert(p.m_movementInfo.guid==0);session.Broadcast(&p,chair);
assert(p.received.guid==26&&p.received.time==123456&&p.received.pos.x==10&&p.received.pos.y==20&&p.received.pos.z==30);
// A stale/incorrect movement GUID must never name another observed player.
p.m_movementInfo.guid=99;session.Broadcast(&p,chair);assert(p.received.guid==26);
// Regular clients retain their own movement timestamp.
session.clientless=false;p.m_movementInfo.guid=26;p.m_movementInfo.time=789;
session.Broadcast(&p,chair);assert(p.received.guid==26&&p.received.time==789);
}
'''
with tempfile.TemporaryDirectory(prefix='vcore-near-observer-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code)
subprocess.run(['c++','-std=c++17',str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
print('Production near-teleport observer packet includes actual mover GUID and destination; clientless timestamp refreshed, regular client timestamp preserved.')
@@ -0,0 +1,32 @@
"""Production phased area lookup must use TerrainInfo's map/zone/area order."""
from pathlib import Path
import subprocess,tempfile
root=Path(__file__).resolve().parents[2]
s=(root/'src/server/game/World/ClientlessFlightMgr.cpp').read_text()
a=s.index('std::pair < uint32, uint32 > ClientlessFlightMgr::AreaZone(')
b=s.index('bool ClientlessFlightMgr::Enabled(',a)
code=r'''
#include <cassert>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <memory>
using uint32=uint32_t;
struct Position{float GetPositionX()const{return 1;}float GetPositionY()const{return 2;}float GetPositionZ()const{return 3;}};
struct Player{int GetPhaseShift()const{return 17;}};
struct TerrainInfo{int calls=0;void GetZoneAndAreaId(int phase,uint32 map,uint32& zone,uint32& area,Position const&){assert(phase==17&&map==530);++calls;zone=3430;area=3460;}};
struct TerrainMgr{std::shared_ptr<TerrainInfo> value=std::make_shared<TerrainInfo>();auto LoadTerrain(uint32 map){assert(map==530);return value;}} sTerrainMgr;
class ClientlessFlightMgr{public:
mutable std::unordered_map<std::string,std::pair<uint32,uint32>> _planningAreas;
mutable std::unordered_map<uint32,std::shared_ptr<TerrainInfo>> _planningTerrain;
std::pair<uint32,uint32> AreaZone(Player const*,uint32,Position const&)const;
};
'''
code+=s[a:b]+r'''
int main(){ClientlessFlightMgr mgr;Player p;Position pos;auto r=mgr.AreaZone(&p,530,pos);assert(r.first==3460&&r.second==3430);assert(mgr.AreaZone(&p,530,pos)==r);assert(sTerrainMgr.value->calls==1);}
'''
with tempfile.TemporaryDirectory(prefix='vcore-area-query-') as folder:
cpp=Path(folder)/'test.cpp';binary=Path(folder)/'test';cpp.write_text(code)
subprocess.run(['c++','-std=c++17',str(cpp),'-o',str(binary)],check=True)
subprocess.run([str(binary)],check=True)
print('Native terrain query map/phase, area/zone ordering and cache passed.')