#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));
}
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:
SMSG_TEXT_EMOTE(/wave,/nod,/shrug) alongside the line, so nearby players see both the animation and the "Bot waves at You." social text.Where this slots into
mod-ollama-chatDelivery happens in
src/mod-ollama-chat_handler.cpp—targetChannel->Say(L1502),botAI->SayToParty(L1544),botAI->Say(L1573, L1636),botAI->Yell(L1605).Two things make this easy:
senderPtris 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.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()andSendMessageToSet()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 ownEventProcessor(bot->m_Events), which is ticked fromPlayer::Update()on the map thread.PoC [AI-generated]
1. New file —
src/mod-ollama-chat_expression.h2. New file —
src/mod-ollama-chat_expression.cpp(click to expand)3. Config —
src/mod-ollama-chat_config.h/.cpp4. Call site —
src/mod-ollama-chat_handler.cpp5. Prompt +
conf/mod_ollama_chat.conf.distOne line appended to
g_ChatPromptTemplate: