feat: parse SMSG_GMTICKET_GETTICKET/SYSTEMSTATUS and SMSG_SPELLINSTAKILLLOG

Previously SMSG_GMTICKET_GETTICKET and SMSG_GMTICKET_SYSTEMSTATUS were
silently consumed. Now both are fully parsed:
- SMSG_GMTICKET_GETTICKET decodes all four status codes (no ticket,
  open ticket, closed, suspended), extracts ticket text, age and
  server-estimated wait time, and stores them on GameHandler.
- SMSG_GMTICKET_SYSTEMSTATUS shows a chat message when GM support
  goes offline/online.
- Added requestGmTicket() (sends CMSG_GMTICKET_GETTICKET) called
  automatically when the GM Ticket UI window is opened, so the player
  sees their existing open ticket text and wait time on first open.
- GM Ticket UI window now shows current-ticket status bar, estimated
  wait time, and hides the Delete button when no ticket is active.

Also implements SMSG_SPELLINSTAKILLLOG (previously silently consumed):
parses caster/victim/spellId for all expansions and emits combat text
when the local player is involved in an instant-kill spell event (e.g.
Execute, Obliterate).
This commit is contained in:
Kelsi 2026-03-12 22:14:46 -07:00
parent 9b60108fa6
commit dd38026b23
4 changed files with 150 additions and 8 deletions

View file

@ -494,6 +494,13 @@ public:
// GM Ticket // GM Ticket
void submitGmTicket(const std::string& text); void submitGmTicket(const std::string& text);
void deleteGmTicket(); void deleteGmTicket();
void requestGmTicket(); ///< Send CMSG_GMTICKET_GETTICKET to query open ticket
// GM ticket status accessors
bool hasActiveGmTicket() const { return gmTicketActive_; }
const std::string& getGmTicketText() const { return gmTicketText_; }
bool isGmSupportAvailable() const { return gmSupportAvailable_; }
float getGmTicketWaitHours() const { return gmTicketWaitHours_; }
void queryGuildInfo(uint32_t guildId); void queryGuildInfo(uint32_t guildId);
void createGuild(const std::string& guildName); void createGuild(const std::string& guildName);
void addGuildRank(const std::string& rankName); void addGuildRank(const std::string& rankName);
@ -3021,6 +3028,12 @@ private:
// ---- Quest completion callback ---- // ---- Quest completion callback ----
QuestCompleteCallback questCompleteCallback_; QuestCompleteCallback questCompleteCallback_;
// ---- GM Ticket state (SMSG_GMTICKET_GETTICKET / SMSG_GMTICKET_SYSTEMSTATUS) ----
bool gmTicketActive_ = false; ///< True when an open ticket exists on the server
std::string gmTicketText_; ///< Text of the open ticket (from SMSG_GMTICKET_GETTICKET)
float gmTicketWaitHours_ = 0.0f; ///< Server-estimated wait time in hours
bool gmSupportAvailable_ = true; ///< GM support system online (SMSG_GMTICKET_SYSTEMSTATUS)
}; };
} // namespace game } // namespace game

View file

@ -438,7 +438,8 @@ private:
void renderEquipSetWindow(game::GameHandler& gameHandler); void renderEquipSetWindow(game::GameHandler& gameHandler);
// GM Ticket window // GM Ticket window
bool showGmTicketWindow_ = false; bool showGmTicketWindow_ = false;
bool gmTicketWindowWasOpen_ = false; ///< Previous frame state; used to fire one-shot query
char gmTicketBuf_[2048] = {}; char gmTicketBuf_[2048] = {};
void renderGmTicketWindow(game::GameHandler& gameHandler); void renderGmTicketWindow(game::GameHandler& gameHandler);

View file

@ -5761,10 +5761,70 @@ void GameHandler::handlePacket(network::Packet& packet) {
} }
break; break;
} }
case Opcode::SMSG_GMTICKET_GETTICKET: case Opcode::SMSG_GMTICKET_GETTICKET: {
case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: // WotLK 3.3.5a format:
// uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended
// If status == 6 (GMTICKET_STATUS_HASTEXT):
// cstring ticketText
// uint32 ticketAge (seconds old)
// uint32 daysUntilOld (days remaining before escalation)
// float waitTimeHours (estimated GM wait time)
if (packet.getSize() - packet.getReadPos() < 1) { packet.setReadPos(packet.getSize()); break; }
uint8_t gmStatus = packet.readUInt8();
// Status 6 = GMTICKET_STATUS_HASTEXT — open ticket with text
if (gmStatus == 6 && packet.getSize() - packet.getReadPos() >= 1) {
gmTicketText_ = packet.readString();
uint32_t ageSec = (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0;
/*uint32_t daysLeft =*/ (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0;
gmTicketWaitHours_ = (packet.getSize() - packet.getReadPos() >= 4)
? packet.readFloat() : 0.0f;
gmTicketActive_ = true;
char buf[256];
if (ageSec < 60) {
std::snprintf(buf, sizeof(buf),
"You have an open GM ticket (submitted %us ago). Estimated wait: %.1f hours.",
ageSec, gmTicketWaitHours_);
} else {
uint32_t ageMin = ageSec / 60;
std::snprintf(buf, sizeof(buf),
"You have an open GM ticket (submitted %um ago). Estimated wait: %.1f hours.",
ageMin, gmTicketWaitHours_);
}
addSystemChatMessage(buf);
LOG_INFO("SMSG_GMTICKET_GETTICKET: open ticket age=", ageSec,
"s wait=", gmTicketWaitHours_, "h");
} else if (gmStatus == 3) {
gmTicketActive_ = false;
gmTicketText_.clear();
addSystemChatMessage("Your GM ticket has been closed.");
LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket closed");
} else if (gmStatus == 10) {
gmTicketActive_ = false;
gmTicketText_.clear();
addSystemChatMessage("Your GM ticket has been suspended.");
LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket suspended");
} else {
// Status 1 = no open ticket (default/no ticket)
gmTicketActive_ = false;
gmTicketText_.clear();
LOG_DEBUG("SMSG_GMTICKET_GETTICKET: no open ticket (status=", (int)gmStatus, ")");
}
packet.setReadPos(packet.getSize()); packet.setReadPos(packet.getSize());
break; break;
}
case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: {
// uint32 status: 1 = GM support available, 0 = offline/unavailable
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t sysStatus = packet.readUInt32();
gmSupportAvailable_ = (sysStatus != 0);
addSystemChatMessage(gmSupportAvailable_
? "GM support is currently available."
: "GM support is currently unavailable.");
LOG_INFO("SMSG_GMTICKET_SYSTEMSTATUS: available=", gmSupportAvailable_);
}
packet.setReadPos(packet.getSize());
break;
}
// ---- DK rune tracking ---- // ---- DK rune tracking ----
case Opcode::SMSG_CONVERT_RUNE: { case Opcode::SMSG_CONVERT_RUNE: {
@ -5975,7 +6035,33 @@ void GameHandler::handlePacket(network::Packet& packet) {
packet.setReadPos(packet.getSize()); packet.setReadPos(packet.getSize());
break; break;
} }
case Opcode::SMSG_SPELLINSTAKILLLOG: case Opcode::SMSG_SPELLINSTAKILLLOG: {
// Sent when a unit is killed by a spell with SPELL_ATTR_EX2_INSTAKILL (e.g. Execute, Obliterate, etc.)
// WotLK: packed_guid caster + packed_guid victim + uint32 spellId
// TBC/Classic: full uint64 caster + full uint64 victim + uint32 spellId
const bool ikTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc");
auto ik_rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; }
uint64_t ikCaster = ikTbcLike
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; }
uint64_t ikVictim = ikTbcLike
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
uint32_t ikSpell = (ik_rem() >= 4) ? packet.readUInt32() : 0;
// Show kill/death feedback for the local player
if (ikCaster == playerGuid) {
// We killed a target instantly — show a KILL combat text hit
addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, true);
} else if (ikVictim == playerGuid) {
// We were instantly killed — show a large incoming hit
addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, false);
addSystemChatMessage("You were killed by an instant-kill effect.");
}
LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster,
" victim=0x", ikVictim, std::dec, " spell=", ikSpell);
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_SPELLLOGEXECUTE: case Opcode::SMSG_SPELLLOGEXECUTE:
case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK:
case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS:
@ -16153,9 +16239,19 @@ void GameHandler::deleteGmTicket() {
if (state != WorldState::IN_WORLD || !socket) return; if (state != WorldState::IN_WORLD || !socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET)); network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET));
socket->send(pkt); socket->send(pkt);
gmTicketActive_ = false;
gmTicketText_.clear();
LOG_INFO("Deleting GM ticket"); LOG_INFO("Deleting GM ticket");
} }
void GameHandler::requestGmTicket() {
if (state != WorldState::IN_WORLD || !socket) return;
// CMSG_GMTICKET_GETTICKET has no payload — server responds with SMSG_GMTICKET_GETTICKET
network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_GETTICKET));
socket->send(pkt);
LOG_DEBUG("Sent CMSG_GMTICKET_GETTICKET — querying open ticket status");
}
void GameHandler::queryGuildInfo(uint32_t guildId) { void GameHandler::queryGuildInfo(uint32_t guildId) {
if (state != WorldState::IN_WORLD || !socket) return; if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildQueryPacket::build(guildId); auto packet = GuildQueryPacket::build(guildId);

View file

@ -20162,9 +20162,15 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) {
// ─── GM Ticket Window ───────────────────────────────────────────────────────── // ─── GM Ticket Window ─────────────────────────────────────────────────────────
void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
// Fire a one-shot query when the window first becomes visible
if (showGmTicketWindow_ && !gmTicketWindowWasOpen_) {
gameHandler.requestGmTicket();
}
gmTicketWindowWasOpen_ = showGmTicketWindow_;
if (!showGmTicketWindow_) return; if (!showGmTicketWindow_) return;
ImGui::SetNextWindowSize(ImVec2(400, 260), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(440, 320), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver);
if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_, if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_,
@ -20173,10 +20179,33 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
return; return;
} }
// Show GM support availability
if (!gameHandler.isGmSupportAvailable()) {
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "GM support is currently unavailable.");
ImGui::Spacing();
}
// Show existing open ticket if any
if (gameHandler.hasActiveGmTicket()) {
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "You have an open GM ticket.");
const std::string& existingText = gameHandler.getGmTicketText();
if (!existingText.empty()) {
ImGui::TextWrapped("Current ticket: %s", existingText.c_str());
}
float waitHours = gameHandler.getGmTicketWaitHours();
if (waitHours > 0.0f) {
char waitBuf[64];
std::snprintf(waitBuf, sizeof(waitBuf), "Estimated wait: %.1f hours", waitHours);
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.4f, 1.0f), "%s", waitBuf);
}
ImGui::Separator();
ImGui::Spacing();
}
ImGui::TextWrapped("Describe your issue and a Game Master will contact you."); ImGui::TextWrapped("Describe your issue and a Game Master will contact you.");
ImGui::Spacing(); ImGui::Spacing();
ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_), ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_),
ImVec2(-1, 160)); ImVec2(-1, 120));
ImGui::Spacing(); ImGui::Spacing();
bool hasText = (gmTicketBuf_[0] != '\0'); bool hasText = (gmTicketBuf_[0] != '\0');
@ -20193,8 +20222,11 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
showGmTicketWindow_ = false; showGmTicketWindow_ = false;
} }
ImGui::SameLine(); ImGui::SameLine();
if (ImGui::Button("Delete Ticket", ImVec2(100, 0))) { if (gameHandler.hasActiveGmTicket()) {
gameHandler.deleteGmTicket(); if (ImGui::Button("Delete Ticket", ImVec2(110, 0))) {
gameHandler.deleteGmTicket();
showGmTicketWindow_ = false;
}
} }
ImGui::End(); ImGui::End();