game,ui: add ContactEntry struct and Friends tab in social frame

Store structured friend data (online status, level, area, class) that
was previously discarded in handleFriendList/handleContactList. New
ContactEntry struct lives in game_handler.hpp; getContacts() exposes it.

UI: the O-key Social window (formerly guild-only) now has a Friends tab.
- Shows online/offline status dot, name, level, and AFK/DND label
- Pressing O when not in a guild opens Social directly on the Friends tab
- The window title changed from "Guild" to "Social" for accuracy
- Non-guild players no longer get a "not in a guild" rejection on O press
This commit is contained in:
Kelsi 2026-03-10 05:46:03 -07:00
parent f98cc32947
commit 7cd8e86d3b
4 changed files with 125 additions and 22 deletions

View file

@ -55,6 +55,24 @@ enum class QuestGiverStatus : uint8_t {
REWARD = 10 // ? (yellow)
};
/**
* A single contact list entry (friend, ignore, or mute).
*/
struct ContactEntry {
uint64_t guid = 0;
std::string name;
std::string note;
uint32_t flags = 0; // 0x1=friend, 0x2=ignore, 0x4=mute
uint8_t status = 0; // 0=offline, 1=online, 2=AFK, 3=DND
uint32_t areaId = 0;
uint32_t level = 0;
uint32_t classId = 0;
bool isFriend() const { return (flags & 0x1) != 0; }
bool isIgnored() const { return (flags & 0x2) != 0; }
bool isOnline() const { return status != 0; }
};
/**
* World connection state
*/
@ -800,6 +818,7 @@ public:
void leaveGroup();
bool isInGroup() const { return !partyData.isEmpty(); }
const GroupListData& getPartyData() const { return partyData; }
const std::vector<ContactEntry>& getContacts() const { return contacts_; }
bool hasPendingGroupInvite() const { return pendingGroupInvite; }
const std::string& getPendingInviterName() const { return pendingInviterName; }
@ -1662,11 +1681,12 @@ private:
std::unordered_map<uint32_t, GameObjectQueryResponseData> gameObjectInfoCache_;
std::unordered_set<uint32_t> pendingGameObjectQueries_;
// ---- Friend list cache ----
// ---- Friend/contact list cache ----
std::unordered_map<std::string, uint64_t> friendsCache; // name -> guid
std::unordered_set<uint64_t> friendGuids_; // all known friend GUIDs (for name backfill)
uint32_t lastContactListMask_ = 0;
uint32_t lastContactListCount_ = 0;
std::vector<ContactEntry> contacts_; // structured contact list (friends + ignores)
// ---- World state and faction initialization snapshots ----
uint32_t worldStateMapId_ = 0;

View file

@ -64,6 +64,7 @@ private:
bool showChatWindow = true;
bool showNameplates_ = true; // V key toggles nameplates
bool showPlayerInfo = false;
bool showSocialFrame_ = false; // O key toggles social/friends list
bool showGuildRoster_ = false;
std::string selectedGuildMember_;
bool showGuildNoteEdit_ = false;
@ -219,6 +220,7 @@ private:
void renderSharedQuestPopup(game::GameHandler& gameHandler);
void renderItemTextWindow(game::GameHandler& gameHandler);
void renderBuffBar(game::GameHandler& gameHandler);
void renderSocialFrame(game::GameHandler& gameHandler);
void renderLootWindow(game::GameHandler& gameHandler);
void renderGossipWindow(game::GameHandler& gameHandler);
void renderQuestDetailsWindow(game::GameHandler& gameHandler);

View file

@ -483,6 +483,7 @@ void GameHandler::disconnect() {
playerNameCache.clear();
pendingNameQueries.clear();
friendGuids_.clear();
contacts_.clear();
transportAttachments_.clear();
serverUpdatedTransportGuids_.clear();
requiresWarden_ = false;
@ -16425,6 +16426,11 @@ void GameHandler::handleFriendList(network::Packet& packet) {
if (rem() < 1) return;
uint8_t count = packet.readUInt8();
LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries");
// Rebuild friend contacts (keep ignores from previous contact_ entries)
contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(),
[](const ContactEntry& e){ return e.isFriend(); }), contacts_.end());
for (uint8_t i = 0; i < count && rem() >= 9; ++i) {
uint64_t guid = packet.readUInt64();
uint8_t status = packet.readUInt8();
@ -16434,18 +16440,28 @@ void GameHandler::handleFriendList(network::Packet& packet) {
level = packet.readUInt32();
classId = packet.readUInt32();
}
(void)area; (void)level; (void)classId;
// Track as a friend GUID; resolve name via name query
friendGuids_.insert(guid);
auto nit = playerNameCache.find(guid);
std::string name;
if (nit != playerNameCache.end()) {
friendsCache[nit->second] = guid;
LOG_INFO(" Friend: ", nit->second, " status=", (int)status);
name = nit->second;
friendsCache[name] = guid;
LOG_INFO(" Friend: ", name, " status=", (int)status);
} else {
LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec,
" status=", (int)status, " (name pending)");
queryPlayerName(guid);
}
ContactEntry entry;
entry.guid = guid;
entry.name = name;
entry.flags = 0x1; // friend
entry.status = status;
entry.areaId = area;
entry.level = level;
entry.classId = classId;
contacts_.push_back(std::move(entry));
}
}
@ -16469,19 +16485,23 @@ void GameHandler::handleContactList(network::Packet& packet) {
}
lastContactListMask_ = packet.readUInt32();
lastContactListCount_ = packet.readUInt32();
contacts_.clear();
for (uint32_t i = 0; i < lastContactListCount_ && rem() >= 8; ++i) {
uint64_t guid = packet.readUInt64();
if (rem() < 4) break;
uint32_t flags = packet.readUInt32();
std::string note = packet.readString(); // may be empty
(void)note;
uint8_t status = 0;
uint32_t areaId = 0;
uint32_t level = 0;
uint32_t classId = 0;
if (flags & 0x1) { // SOCIAL_FLAG_FRIEND
if (rem() < 1) break;
uint8_t status = packet.readUInt8();
status = packet.readUInt8();
if (status != 0 && rem() >= 12) {
packet.readUInt32(); // area
packet.readUInt32(); // level
packet.readUInt32(); // class
areaId = packet.readUInt32();
level = packet.readUInt32();
classId = packet.readUInt32();
}
friendGuids_.insert(guid);
auto nit = playerNameCache.find(guid);
@ -16492,6 +16512,17 @@ void GameHandler::handleContactList(network::Packet& packet) {
}
}
// ignore / mute entries: no additional fields beyond guid+flags+note
ContactEntry entry;
entry.guid = guid;
entry.flags = flags;
entry.note = std::move(note);
entry.status = status;
entry.areaId = areaId;
entry.level = level;
entry.classId = classId;
auto nit = playerNameCache.find(guid);
if (nit != playerNameCache.end()) entry.name = nit->second;
contacts_.push_back(std::move(entry));
}
LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_,
" count=", lastContactListCount_);

View file

@ -5530,12 +5530,10 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) {
showGuildRoster_ = !showGuildRoster_;
if (showGuildRoster_) {
// Open friends tab directly if not in guild
if (!gameHandler.isInGuild()) {
gameHandler.addLocalChatMessage(game::MessageChatData{
game::ChatType::SYSTEM, game::ChatLanguage::UNIVERSAL, 0, "", 0, "", "You are not in a guild.", "", 0});
showGuildRoster_ = false;
return;
}
guildRosterTab_ = 2; // Friends tab
} else {
// Re-query guild name if we have guildId but no name yet
if (gameHandler.getGuildName().empty()) {
const auto* ch = gameHandler.getActiveCharacter();
@ -5547,6 +5545,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
gameHandler.requestGuildInfo();
}
}
}
// Petition creation dialog (shown when NPC sends SMSG_PETITION_SHOWLIST)
if (gameHandler.hasPetitionShowlist()) {
@ -5595,7 +5594,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once);
std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Guild") : "Guild";
std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Social") : "Social";
bool open = showGuildRoster_;
if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) {
// Tab bar: Roster | Guild Info
@ -5899,6 +5898,57 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
ImGui::EndTabItem();
}
// ---- Friends tab ----
if (ImGui::BeginTabItem("Friends")) {
guildRosterTab_ = 2;
const auto& contacts = gameHandler.getContacts();
// Filter to friends only
int friendCount = 0;
for (const auto& c : contacts) {
if (!c.isFriend()) continue;
++friendCount;
// Status dot
ImU32 dotColor = c.isOnline()
? IM_COL32(80, 200, 80, 255)
: IM_COL32(120, 120, 120, 255);
ImVec2 cursor = ImGui::GetCursorScreenPos();
ImGui::GetWindowDrawList()->AddCircleFilled(
ImVec2(cursor.x + 6.0f, cursor.y + 8.0f), 5.0f, dotColor);
ImGui::Dummy(ImVec2(14.0f, 0.0f));
ImGui::SameLine();
// Name
const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str();
ImVec4 nameCol = c.isOnline()
? ImVec4(1.0f, 1.0f, 1.0f, 1.0f)
: ImVec4(0.55f, 0.55f, 0.55f, 1.0f);
ImGui::TextColored(nameCol, "%s", displayName);
// Level and status on same line (right-aligned)
if (c.isOnline()) {
ImGui::SameLine();
const char* statusLabel =
(c.status == 2) ? "(AFK)" :
(c.status == 3) ? "(DND)" : "";
if (c.level > 0) {
ImGui::TextDisabled("Lv %u %s", c.level, statusLabel);
} else if (*statusLabel) {
ImGui::TextDisabled("%s", statusLabel);
}
}
}
if (friendCount == 0) {
ImGui::TextDisabled("No friends online.");
}
ImGui::Separator();
ImGui::TextDisabled("Right-click a player's name in chat to add friends.");
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
}