Skip to content

Feature request: turn bots toward the person they're answering, and let them play text emotes #167

Description

@opicron

Note

Reference implementation: Hokken/mod-llm-chatter (AGPL-3.0, same licence as this module).

Summary

When a bot replies today, the line appears in chat but the character doesn't move. Two small pieces of embodiment would make replies read as conversation rather than as a log:

  • Facing — the bot turns toward whoever it is answering, just before the line lands.
  • Text emotes — the bot optionally plays a real SMSG_TEXT_EMOTE (/wave, /nod, /shrug) alongside the line, so nearby players see both the animation and the "Bot waves at You." social text.
  • Inbound emote reactions (bots reacting when a player emotes at them)

Where this slots into mod-ollama-chat

Delivery happens in src/mod-ollama-chat_handler.cpptargetChannel->Say (L1502), botAI->SayToParty (L1544), botAI->Say (L1573, L1636), botAI->Yell (L1605).

Two things make this easy:

  • senderPtr is already in scope at the delivery point, so "who the bot is answering" needs no extra lookup — unlike mod-llm-chatter, which re-derives it from the database.
  • No explicit source list (AzerothCore globs modules/*/src/*.cpp), so new files need no build changes.

One thing makes it delicate:

Warning

That whole block runs inside a detached std::thread — spawned at L1414, detached at L1669. SetFacingToObject() and SendMessageToSet() mutate world state and must not be called from there. The PoC below does what mod-llm-chatter's delayed events do: schedules the work onto the bot's own EventProcessor (bot->m_Events), which is ticked from Player::Update() on the map thread.


PoC [AI-generated]

1. New file — src/mod-ollama-chat_expression.h
#ifndef MOD_OLLAMA_CHAT_EXPRESSION_H
#define MOD_OLLAMA_CHAT_EXPRESSION_H

#include "Player.h"
#include <string>

// Strips a "[emote:name]" tag out of the LLM response and returns
// the resolved TEXT_EMOTE_* id, or 0 when absent/unknown.
uint32 ExtractEmoteTag(std::string& response);

// Queues "face target, then play emote" on the bot's own
// EventProcessor so it runs on the map thread.
void ScheduleBotExpression(Player* bot, ObjectGuid targetGuid,
                           uint32 textEmoteId, uint32 delayMs);

#endif
2. New file — src/mod-ollama-chat_expression.cpp (click to expand)
#include "mod-ollama-chat_expression.h"
#include "mod-ollama-chat_config.h"

#include "DBCStores.h"
#include "MotionMaster.h"
#include "ObjectAccessor.h"
#include "Opcodes.h"
#include "SharedDefines.h"
#include "WorldPacket.h"

#include <algorithm>
#include <cctype>
#include <unordered_map>

namespace
{
// Starter set. mod-llm-chatter's table covers ~250 names;
// this is enough to prove the path end to end.
uint32 LookupTextEmoteId(std::string const& name)
{
    static std::unordered_map<std::string, uint32> const map = {
        {"wave",   TEXT_EMOTE_WAVE},   {"bow",    TEXT_EMOTE_BOW},
        {"nod",    TEXT_EMOTE_NOD},    {"shrug",  TEXT_EMOTE_SHRUG},
        {"laugh",  TEXT_EMOTE_LAUGH},  {"cheer",  TEXT_EMOTE_CHEER},
        {"salute", TEXT_EMOTE_SALUTE}, {"cry",    TEXT_EMOTE_CRY},
        {"flex",   TEXT_EMOTE_FLEX},   {"point",  TEXT_EMOTE_POINT},
        {"sigh",   TEXT_EMOTE_SIGH},   {"talk",   TEXT_EMOTE_TALK},
        {"thank",  TEXT_EMOTE_THANK},  {"agree",  TEXT_EMOTE_AGREE},
    };
    auto it = map.find(name);
    return it == map.end() ? 0u : it->second;
}

bool IsSafeForFacing(Unit* unit)
{
    if (!unit || !unit->IsInWorld() || !unit->IsAlive())
        return false;
    if (!unit->IsStopped() || unit->IsInCombat())
        return false;
    if (unit->IsInFlight() || unit->IsFlying() || unit->GetTransport())
        return false;
    if (Player* p = unit->ToPlayer())
        if (p->IsBeingTeleported())
            return false;

    MotionMaster* mm = unit->GetMotionMaster();
    return mm && mm->GetMotionSlotType(MOTION_SLOT_CONTROLLED)
                     == NULL_MOTION_TYPE;
}

void PlayEmoteAnimation(Unit* unit, uint32 textEmoteId)
{
    EmotesTextEntry const* em = sEmotesTextStore.LookupEntry(textEmoteId);
    if (!em)
        return;

    switch (em->textid)
    {
        // State emotes latch instead of playing once -- skip them.
        case EMOTE_STATE_SLEEP:
        case EMOTE_STATE_SIT:
        case EMOTE_STATE_KNEEL:
        case EMOTE_ONESHOT_NONE:
            break;
        case EMOTE_STATE_DANCE:
            unit->HandleEmoteCommand(EMOTE_ONESHOT_DANCESPECIAL);
            break;
        default:
            unit->HandleEmoteCommand(em->textid);
            break;
    }
}

void SendBotTextEmote(Player* bot, uint32 textEmoteId,
                      std::string const& targetName)
{
    PlayEmoteAnimation(bot, textEmoteId);

    WorldPacket data(SMSG_TEXT_EMOTE, 20 + targetName.size() + 1);
    data << bot->GetGUID();
    data << uint32(textEmoteId);
    data << uint32(0);                  // emoteNum
    data << uint32(targetName.size());  // excludes the null terminator
    if (!targetName.empty())
        data.append(targetName.c_str(), targetName.size() + 1);
    else
        data << uint8(0);

    bot->SendMessageToSet(&data, true);
}

class BotExpressionEvent : public BasicEvent
{
public:
    BotExpressionEvent(ObjectGuid botGuid, ObjectGuid targetGuid,
                       uint32 emoteId)
        : _botGuid(botGuid), _targetGuid(targetGuid), _emoteId(emoteId) {}

    bool Execute(uint64 /*time*/, uint32 /*diff*/) override
    {
        Player* bot = ObjectAccessor::FindConnectedPlayer(_botGuid);
        if (!bot || !bot->IsInWorld())
            return true;

        Player* target = ObjectAccessor::FindConnectedPlayer(_targetGuid);
        bool targetUsable = target && target->IsInWorld()
            && target->GetMapId() == bot->GetMapId();

        if (g_EnableBotFacing && targetUsable && IsSafeForFacing(bot))
            bot->SetFacingToObject(target);

        if (_emoteId && g_EnableBotEmotes)
            SendBotTextEmote(bot, _emoteId,
                             targetUsable ? target->GetName() : "");

        return true;
    }

private:
    ObjectGuid _botGuid;
    ObjectGuid _targetGuid;
    uint32     _emoteId;
};
} // namespace

uint32 ExtractEmoteTag(std::string& response)
{
    static std::string const open = "[emote:";

    size_t start = response.find(open);
    if (start == std::string::npos)
        return 0;
    size_t close = response.find(']', start);
    if (close == std::string::npos)
        return 0;

    size_t nameStart = start + open.size();
    std::string name = response.substr(nameStart, close - nameStart);
    std::transform(name.begin(), name.end(), name.begin(),
                   [](unsigned char c) { return std::tolower(c); });

    response.erase(start, close - start + 1);

    // Collapse whitespace the tag left behind.
    while (!response.empty()
           && std::isspace(static_cast<unsigned char>(response.front())))
        response.erase(response.begin());
    while (!response.empty()
           && std::isspace(static_cast<unsigned char>(response.back())))
        response.pop_back();

    return LookupTextEmoteId(name);
}

void ScheduleBotExpression(Player* bot, ObjectGuid targetGuid,
                           uint32 textEmoteId, uint32 delayMs)
{
    if (!bot)
        return;
    if (!g_EnableBotFacing && !(g_EnableBotEmotes && textEmoteId))
        return;

    bot->m_Events.AddEvent(
        new BotExpressionEvent(bot->GetGUID(), targetGuid, textEmoteId),
        bot->m_Events.CalculateTime(delayMs));
}
3. Config — src/mod-ollama-chat_config.h / .cpp
// mod-ollama-chat_config.h
extern bool     g_EnableBotFacing;
extern bool     g_EnableBotEmotes;
extern uint32_t g_BotExpressionDelayMs;
// mod-ollama-chat_config.cpp -- definitions
bool     g_EnableBotFacing      = true;
bool     g_EnableBotEmotes      = true;
uint32_t g_BotExpressionDelayMs = 400;

// mod-ollama-chat_config.cpp -- loader, next to the TypingSimulation block
g_EnableBotFacing      = sConfigMgr->GetOption<bool>("OllamaChat.EnableBotFacing", true);
g_EnableBotEmotes      = sConfigMgr->GetOption<bool>("OllamaChat.EnableBotEmotes", true);
g_BotExpressionDelayMs = sConfigMgr->GetOption<uint32_t>("OllamaChat.BotExpressionDelayMs", 400);
4. Call site — src/mod-ollama-chat_handler.cpp
@@ -1420,6 +1420,7 @@
                 std::string response = responseFuture.get();
+                uint32 expressionEmote = ExtractEmoteTag(response);
 
                 // Reacquire pointers by GUID.
                 Player* botPtr = ObjectAccessor::FindPlayer(ObjectGuid(botGuid));
@@ -1478,6 +1479,11 @@
                 // Route the response.
+                // Facing/emote are queued on the bot's EventProcessor because
+                // this lambda runs on a detached worker thread.
+                ScheduleBotExpression(botPtr, senderPtr->GetGUID(),
+                                      expressionEmote,
+                                      g_BotExpressionDelayMs);
+
                 if (channelId != 0 && !channelName.empty())
5. Prompt + conf/mod_ollama_chat.conf.dist

One line appended to g_ChatPromptTemplate:

You may end your reply with a single gesture tag such as [emote:wave],
[emote:nod], [emote:shrug] or [emote:laugh] when it fits. Omit it otherwise.
#    OllamaChat.EnableBotFacing
#        Turn the bot to face whoever it is replying to before the line lands.
#        Skipped while the bot is moving, mounted, flying, on a transport,
#        or in combat.
#        Default: 1
OllamaChat.EnableBotFacing = 1

#    OllamaChat.EnableBotEmotes
#        Allow bots to play a real text emote alongside a reply when the model
#        supplies an [emote:name] tag.
#        Default: 1
OllamaChat.EnableBotEmotes = 1

#    OllamaChat.BotExpressionDelayMs
#        Delay before the gesture fires, in milliseconds. A small delay keeps
#        it from looking instant and robotic.
#        Default: 400
OllamaChat.BotExpressionDelayMs = 400

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions