Add guild features, fix channel joining, and improve whisper reply

Guild: add disband, leader transfer, public/officer note commands with
roster context menu showing rank names and officer notes column. Auto-refresh
roster after guild events.

Channels: fix city/region channels not working by accepting SMSG_CHANNEL_NOTIFY
during ENTERING_WORLD state (server auto-joins before VERIFY_WORLD) and handling
PLAYER_ALREADY_MEMBER notification.

Whisper: /r now switches to whisper tab and sets target to last sender,
matching WoW behavior.

Camera: extend WMO collision raycasting to work outside WMOs too.
This commit is contained in:
Kelsi 2026-02-16 20:16:14 -08:00
parent ee5ab30826
commit 2ece1bf1cf
9 changed files with 271 additions and 9 deletions

View file

@ -340,6 +340,10 @@ public:
void leaveGuild(); void leaveGuild();
void inviteToGuild(const std::string& playerName); void inviteToGuild(const std::string& playerName);
void kickGuildMember(const std::string& playerName); void kickGuildMember(const std::string& playerName);
void disbandGuild();
void setGuildLeader(const std::string& name);
void setGuildPublicNote(const std::string& name, const std::string& note);
void setGuildOfficerNote(const std::string& name, const std::string& note);
void acceptGuildInvite(); void acceptGuildInvite();
void declineGuildInvite(); void declineGuildInvite();
void queryGuildInfo(uint32_t guildId); void queryGuildInfo(uint32_t guildId);
@ -353,6 +357,7 @@ public:
const std::string& getGuildName() const { return guildName_; } const std::string& getGuildName() const { return guildName_; }
const GuildRosterData& getGuildRoster() const { return guildRoster_; } const GuildRosterData& getGuildRoster() const { return guildRoster_; }
bool hasGuildRoster() const { return hasGuildRoster_; } bool hasGuildRoster() const { return hasGuildRoster_; }
const std::vector<std::string>& getGuildRankNames() const { return guildRankNames_; }
bool hasPendingGuildInvite() const { return pendingGuildInvite_; } bool hasPendingGuildInvite() const { return pendingGuildInvite_; }
const std::string& getPendingGuildInviterName() const { return pendingGuildInviterName_; } const std::string& getPendingGuildInviterName() const { return pendingGuildInviterName_; }
const std::string& getPendingGuildInviteGuildName() const { return pendingGuildInviteGuildName_; } const std::string& getPendingGuildInviteGuildName() const { return pendingGuildInviteGuildName_; }

View file

@ -117,6 +117,10 @@ enum class LogicalOpcode : uint16_t {
SMSG_GUILD_QUERY_RESPONSE, SMSG_GUILD_QUERY_RESPONSE,
SMSG_GUILD_INVITE, SMSG_GUILD_INVITE,
CMSG_GUILD_REMOVE, CMSG_GUILD_REMOVE,
CMSG_GUILD_DISBAND,
CMSG_GUILD_LEADER,
CMSG_GUILD_SET_PUBLIC_NOTE,
CMSG_GUILD_SET_OFFICER_NOTE,
SMSG_GUILD_EVENT, SMSG_GUILD_EVENT,
SMSG_GUILD_COMMAND_RESULT, SMSG_GUILD_COMMAND_RESULT,

View file

@ -1003,6 +1003,30 @@ public:
static network::Packet build(const std::string& playerName); static network::Packet build(const std::string& playerName);
}; };
/** CMSG_GUILD_DISBAND packet builder (empty body) */
class GuildDisbandPacket {
public:
static network::Packet build();
};
/** CMSG_GUILD_LEADER packet builder */
class GuildLeaderPacket {
public:
static network::Packet build(const std::string& playerName);
};
/** CMSG_GUILD_SET_PUBLIC_NOTE packet builder */
class GuildSetPublicNotePacket {
public:
static network::Packet build(const std::string& playerName, const std::string& note);
};
/** CMSG_GUILD_SET_OFFICER_NOTE packet builder */
class GuildSetOfficerNotePacket {
public:
static network::Packet build(const std::string& playerName, const std::string& note);
};
/** CMSG_GUILD_ACCEPT packet builder (empty body) */ /** CMSG_GUILD_ACCEPT packet builder (empty body) */
class GuildAcceptPacket { class GuildAcceptPacket {
public: public:

View file

@ -64,6 +64,10 @@ private:
bool showChatWindow = true; bool showChatWindow = true;
bool showPlayerInfo = false; bool showPlayerInfo = false;
bool showGuildRoster_ = false; bool showGuildRoster_ = false;
std::string selectedGuildMember_;
bool showGuildNoteEdit_ = false;
bool editingOfficerNote_ = false;
char guildNoteEditBuffer_[256] = {0};
bool refocusChatInput = false; bool refocusChatInput = false;
bool chatWindowLocked = true; bool chatWindowLocked = true;
ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f);

View file

@ -772,7 +772,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
break; break;
case Opcode::SMSG_CHANNEL_NOTIFY: case Opcode::SMSG_CHANNEL_NOTIFY:
if (state == WorldState::IN_WORLD) { // Accept during ENTERING_WORLD too — server auto-joins channels before VERIFY_WORLD
if (state == WorldState::IN_WORLD || state == WorldState::ENTERING_WORLD) {
handleChannelNotify(packet); handleChannelNotify(packet);
} }
break; break;
@ -4331,6 +4332,23 @@ void GameHandler::handleChannelNotify(network::Packet& packet) {
LOG_INFO("Left channel: ", data.channelName); LOG_INFO("Left channel: ", data.channelName);
break; break;
} }
case ChannelNotifyType::PLAYER_ALREADY_MEMBER: {
// Server says we're already in this channel (e.g. server auto-joined us)
// Still track it in our channel list
bool found = false;
for (const auto& ch : joinedChannels_) {
if (ch == data.channelName) { found = true; break; }
}
if (!found) {
joinedChannels_.push_back(data.channelName);
LOG_INFO("Already in channel: ", data.channelName);
}
break;
}
case ChannelNotifyType::NOT_IN_AREA: {
LOG_DEBUG("Cannot join channel ", data.channelName, " (not in area)");
break;
}
default: default:
LOG_DEBUG("Channel notify type ", static_cast<int>(data.notifyType), LOG_DEBUG("Channel notify type ", static_cast<int>(data.notifyType),
" for channel ", data.channelName); " for channel ", data.channelName);
@ -7172,6 +7190,34 @@ void GameHandler::kickGuildMember(const std::string& playerName) {
LOG_INFO("Kicking guild member: ", playerName); LOG_INFO("Kicking guild member: ", playerName);
} }
void GameHandler::disbandGuild() {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildDisbandPacket::build();
socket->send(packet);
LOG_INFO("Disbanding guild");
}
void GameHandler::setGuildLeader(const std::string& name) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildLeaderPacket::build(name);
socket->send(packet);
LOG_INFO("Setting guild leader: ", name);
}
void GameHandler::setGuildPublicNote(const std::string& name, const std::string& note) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildSetPublicNotePacket::build(name, note);
socket->send(packet);
LOG_INFO("Setting public note for ", name, ": ", note);
}
void GameHandler::setGuildOfficerNote(const std::string& name, const std::string& note) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildSetOfficerNotePacket::build(name, note);
socket->send(packet);
LOG_INFO("Setting officer note for ", name, ": ", note);
}
void GameHandler::acceptGuildInvite() { void GameHandler::acceptGuildInvite() {
if (state != WorldState::IN_WORLD || !socket) return; if (state != WorldState::IN_WORLD || !socket) return;
pendingGuildInvite_ = false; pendingGuildInvite_ = false;
@ -7291,6 +7337,20 @@ void GameHandler::handleGuildEvent(network::Packet& packet) {
chatMsg.message = msg; chatMsg.message = msg;
addLocalChatMessage(chatMsg); addLocalChatMessage(chatMsg);
} }
// Auto-refresh roster after membership/rank changes
switch (data.eventType) {
case GuildEvent::PROMOTION:
case GuildEvent::DEMOTION:
case GuildEvent::JOINED:
case GuildEvent::LEFT:
case GuildEvent::REMOVED:
case GuildEvent::LEADER_CHANGED:
if (hasGuildRoster_) requestGuildRoster();
break;
default:
break;
}
} }
void GameHandler::handleGuildInvite(network::Packet& packet) { void GameHandler::handleGuildInvite(network::Packet& packet) {

View file

@ -100,6 +100,10 @@ static const OpcodeNameEntry kOpcodeNames[] = {
{"SMSG_GUILD_QUERY_RESPONSE", LogicalOpcode::SMSG_GUILD_QUERY_RESPONSE}, {"SMSG_GUILD_QUERY_RESPONSE", LogicalOpcode::SMSG_GUILD_QUERY_RESPONSE},
{"SMSG_GUILD_INVITE", LogicalOpcode::SMSG_GUILD_INVITE}, {"SMSG_GUILD_INVITE", LogicalOpcode::SMSG_GUILD_INVITE},
{"CMSG_GUILD_REMOVE", LogicalOpcode::CMSG_GUILD_REMOVE}, {"CMSG_GUILD_REMOVE", LogicalOpcode::CMSG_GUILD_REMOVE},
{"CMSG_GUILD_DISBAND", LogicalOpcode::CMSG_GUILD_DISBAND},
{"CMSG_GUILD_LEADER", LogicalOpcode::CMSG_GUILD_LEADER},
{"CMSG_GUILD_SET_PUBLIC_NOTE", LogicalOpcode::CMSG_GUILD_SET_PUBLIC_NOTE},
{"CMSG_GUILD_SET_OFFICER_NOTE", LogicalOpcode::CMSG_GUILD_SET_OFFICER_NOTE},
{"SMSG_GUILD_EVENT", LogicalOpcode::SMSG_GUILD_EVENT}, {"SMSG_GUILD_EVENT", LogicalOpcode::SMSG_GUILD_EVENT},
{"SMSG_GUILD_COMMAND_RESULT", LogicalOpcode::SMSG_GUILD_COMMAND_RESULT}, {"SMSG_GUILD_COMMAND_RESULT", LogicalOpcode::SMSG_GUILD_COMMAND_RESULT},
{"MSG_RAID_READY_CHECK", LogicalOpcode::MSG_RAID_READY_CHECK}, {"MSG_RAID_READY_CHECK", LogicalOpcode::MSG_RAID_READY_CHECK},
@ -404,6 +408,10 @@ void OpcodeTable::loadWotlkDefaults() {
{LogicalOpcode::SMSG_GUILD_QUERY_RESPONSE, 0x052}, {LogicalOpcode::SMSG_GUILD_QUERY_RESPONSE, 0x052},
{LogicalOpcode::SMSG_GUILD_INVITE, 0x083}, {LogicalOpcode::SMSG_GUILD_INVITE, 0x083},
{LogicalOpcode::CMSG_GUILD_REMOVE, 0x08E}, {LogicalOpcode::CMSG_GUILD_REMOVE, 0x08E},
{LogicalOpcode::CMSG_GUILD_DISBAND, 0x08F},
{LogicalOpcode::CMSG_GUILD_LEADER, 0x090},
{LogicalOpcode::CMSG_GUILD_SET_PUBLIC_NOTE, 0x234},
{LogicalOpcode::CMSG_GUILD_SET_OFFICER_NOTE, 0x235},
{LogicalOpcode::SMSG_GUILD_EVENT, 0x092}, {LogicalOpcode::SMSG_GUILD_EVENT, 0x092},
{LogicalOpcode::SMSG_GUILD_COMMAND_RESULT, 0x093}, {LogicalOpcode::SMSG_GUILD_COMMAND_RESULT, 0x093},
{LogicalOpcode::MSG_RAID_READY_CHECK, 0x322}, {LogicalOpcode::MSG_RAID_READY_CHECK, 0x322},

View file

@ -1622,6 +1622,35 @@ network::Packet GuildRemovePacket::build(const std::string& playerName) {
return packet; return packet;
} }
network::Packet GuildDisbandPacket::build() {
network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DISBAND));
LOG_DEBUG("Built CMSG_GUILD_DISBAND");
return packet;
}
network::Packet GuildLeaderPacket::build(const std::string& playerName) {
network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_LEADER));
packet.writeString(playerName);
LOG_DEBUG("Built CMSG_GUILD_LEADER: ", playerName);
return packet;
}
network::Packet GuildSetPublicNotePacket::build(const std::string& playerName, const std::string& note) {
network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_SET_PUBLIC_NOTE));
packet.writeString(playerName);
packet.writeString(note);
LOG_DEBUG("Built CMSG_GUILD_SET_PUBLIC_NOTE: ", playerName, " -> ", note);
return packet;
}
network::Packet GuildSetOfficerNotePacket::build(const std::string& playerName, const std::string& note) {
network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_SET_OFFICER_NOTE));
packet.writeString(playerName);
packet.writeString(note);
LOG_DEBUG("Built CMSG_GUILD_SET_OFFICER_NOTE: ", playerName, " -> ", note);
return packet;
}
network::Packet GuildAcceptPacket::build() { network::Packet GuildAcceptPacket::build() {
network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ACCEPT)); network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ACCEPT));
LOG_DEBUG("Built CMSG_GUILD_ACCEPT"); LOG_DEBUG("Built CMSG_GUILD_ACCEPT");

View file

@ -955,8 +955,8 @@ void CameraController::update(float deltaTime) {
// Find max safe distance using raycast + sphere radius // Find max safe distance using raycast + sphere radius
collisionDistance = currentDistance; collisionDistance = currentDistance;
// WMO raycast collision: zoom in when camera would clip through walls // WMO raycast collision: zoom in when camera would clip through walls/floors
if (wmoRenderer && cachedInsideWMO && currentDistance > MIN_DISTANCE) { if (wmoRenderer && currentDistance > MIN_DISTANCE) {
glm::vec3 camRayOrigin = pivot; glm::vec3 camRayOrigin = pivot;
glm::vec3 camRayDir = camDir; glm::vec3 camRayDir = camDir;
float wmoHitDist = wmoRenderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance); float wmoHitDist = wmoRenderer->raycastBoundingBoxes(camRayOrigin, camRayDir, currentDistance);

View file

@ -1973,6 +1973,31 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
return; return;
} }
// /gdisband command
if (cmdLower == "gdisband" || cmdLower == "guilddisband") {
gameHandler.disbandGuild();
chatInputBuffer[0] = '\0';
return;
}
// /gleader command
if (cmdLower == "gleader" || cmdLower == "guildleader") {
if (spacePos != std::string::npos) {
std::string playerName = command.substr(spacePos + 1);
gameHandler.setGuildLeader(playerName);
chatInputBuffer[0] = '\0';
return;
}
game::MessageChatData msg;
msg.type = game::ChatType::SYSTEM;
msg.language = game::ChatLanguage::UNIVERSAL;
msg.message = "Usage: /gleader <player>";
gameHandler.addLocalChatMessage(msg);
chatInputBuffer[0] = '\0';
return;
}
// /readycheck command // /readycheck command
if (cmdLower == "readycheck" || cmdLower == "rc") { if (cmdLower == "readycheck" || cmdLower == "rc") {
gameHandler.initiateReadyCheck(); gameHandler.initiateReadyCheck();
@ -2019,8 +2044,26 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
// Reply command // Reply command
if (cmdLower == "r" || cmdLower == "reply") { if (cmdLower == "r" || cmdLower == "reply") {
std::string replyMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; std::string lastSender = gameHandler.getLastWhisperSender();
gameHandler.replyToLastWhisper(replyMsg); if (lastSender.empty()) {
game::MessageChatData errMsg;
errMsg.type = game::ChatType::SYSTEM;
errMsg.language = game::ChatLanguage::UNIVERSAL;
errMsg.message = "No one has whispered you yet.";
gameHandler.addLocalChatMessage(errMsg);
chatInputBuffer[0] = '\0';
return;
}
// Set whisper target to last whisper sender
strncpy(whisperTargetBuffer, lastSender.c_str(), sizeof(whisperTargetBuffer) - 1);
whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0';
if (spacePos != std::string::npos) {
// /r message — send reply immediately
std::string replyMsg = command.substr(spacePos + 1);
gameHandler.sendChatMessage(game::ChatType::WHISPER, replyMsg, lastSender);
}
// Switch to whisper tab
selectedChatType = 4;
chatInputBuffer[0] = '\0'; chatInputBuffer[0] = '\0';
return; return;
} }
@ -3710,8 +3753,8 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f; float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f; float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 300, screenH / 2 - 250), ImGuiCond_Once); ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(600, 500), ImGuiCond_Once); ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once);
std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Roster") : "Guild Roster"; std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Roster") : "Guild Roster";
bool open = showGuildRoster_; bool open = showGuildRoster_;
@ -3735,8 +3778,10 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
ImGui::Text("%d members (%d online)", (int)roster.members.size(), onlineCount); ImGui::Text("%d members (%d online)", (int)roster.members.size(), onlineCount);
ImGui::Separator(); ImGui::Separator();
const auto& rankNames = gameHandler.getGuildRankNames();
// Table // Table
if (ImGui::BeginTable("GuildRoster", 6, if (ImGui::BeginTable("GuildRoster", 7,
ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV |
ImGuiTableFlags_Sortable)) { ImGuiTableFlags_Sortable)) {
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort); ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_DefaultSort);
@ -3745,6 +3790,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f); ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthFixed, 70.0f);
ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 80.0f); ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 80.0f);
ImGui::TableSetupColumn("Note"); ImGui::TableSetupColumn("Note");
ImGui::TableSetupColumn("Officer Note");
ImGui::TableHeadersRow(); ImGui::TableHeadersRow();
// Online members first, then offline // Online members first, then offline
@ -3768,8 +3814,19 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::TextColored(textColor, "%s", m.name.c_str()); ImGui::TextColored(textColor, "%s", m.name.c_str());
// Right-click context menu
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
selectedGuildMember_ = m.name;
ImGui::OpenPopup("GuildMemberContext");
}
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::TextColored(textColor, "%u", m.rankIndex); // Show rank name instead of index
if (m.rankIndex < rankNames.size()) {
ImGui::TextColored(textColor, "%s", rankNames[m.rankIndex].c_str());
} else {
ImGui::TextColored(textColor, "Rank %u", m.rankIndex);
}
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::TextColored(textColor, "%u", m.level); ImGui::TextColored(textColor, "%u", m.level);
@ -3783,9 +3840,80 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) {
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::TextColored(textColor, "%s", m.publicNote.c_str()); ImGui::TextColored(textColor, "%s", m.publicNote.c_str());
ImGui::TableNextColumn();
ImGui::TextColored(textColor, "%s", m.officerNote.c_str());
} }
ImGui::EndTable(); ImGui::EndTable();
} }
// Context menu popup
if (ImGui::BeginPopup("GuildMemberContext")) {
ImGui::Text("%s", selectedGuildMember_.c_str());
ImGui::Separator();
if (ImGui::MenuItem("Promote")) {
gameHandler.promoteGuildMember(selectedGuildMember_);
}
if (ImGui::MenuItem("Demote")) {
gameHandler.demoteGuildMember(selectedGuildMember_);
}
if (ImGui::MenuItem("Kick")) {
gameHandler.kickGuildMember(selectedGuildMember_);
}
ImGui::Separator();
if (ImGui::MenuItem("Set Public Note...")) {
showGuildNoteEdit_ = true;
editingOfficerNote_ = false;
guildNoteEditBuffer_[0] = '\0';
// Pre-fill with existing note
for (const auto& mem : roster.members) {
if (mem.name == selectedGuildMember_) {
snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.publicNote.c_str());
break;
}
}
}
if (ImGui::MenuItem("Set Officer Note...")) {
showGuildNoteEdit_ = true;
editingOfficerNote_ = true;
guildNoteEditBuffer_[0] = '\0';
for (const auto& mem : roster.members) {
if (mem.name == selectedGuildMember_) {
snprintf(guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_), "%s", mem.officerNote.c_str());
break;
}
}
}
ImGui::Separator();
if (ImGui::MenuItem("Set as Leader")) {
gameHandler.setGuildLeader(selectedGuildMember_);
}
ImGui::EndPopup();
}
// Note edit modal
if (showGuildNoteEdit_) {
ImGui::OpenPopup("EditGuildNote");
showGuildNoteEdit_ = false;
}
if (ImGui::BeginPopupModal("EditGuildNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::Text("%s %s for %s:",
editingOfficerNote_ ? "Officer" : "Public", "Note", selectedGuildMember_.c_str());
ImGui::InputText("##guildnote", guildNoteEditBuffer_, sizeof(guildNoteEditBuffer_));
if (ImGui::Button("Save")) {
if (editingOfficerNote_) {
gameHandler.setGuildOfficerNote(selectedGuildMember_, guildNoteEditBuffer_);
} else {
gameHandler.setGuildPublicNote(selectedGuildMember_, guildNoteEditBuffer_);
}
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel")) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
} }
} }
ImGui::End(); ImGui::End();