Add server info commands: /time, /played, and /who

- Add CMSG_QUERY_TIME (0x1CE) and SMSG_QUERY_TIME_RESPONSE (0x1CF) opcodes
- Add CMSG_REQUEST_PLAYED_TIME (0x1CC) and SMSG_PLAYED_TIME (0x1CD) opcodes
- Add CMSG_WHO (0x062) and SMSG_WHO (0x063) opcodes
- Implement /time command to query and display server time
- Implement /played command to show total and level playtime statistics
- Implement /who [name] command to list online players with level and guild
- Add packet builders: QueryTimePacket, RequestPlayedTimePacket, WhoPacket
- Add response parsers for all three server info packet types
- Add handlers that format and display responses in chat as system messages
- Format played time as "X days, Y hours, Z minutes" for readability
- Format server time as "YYYY-MM-DD HH:MM:SS" for readability
This commit is contained in:
kelsi davis 2026-02-07 12:43:32 -08:00
parent 8b8e32e716
commit f9c4cbddee
6 changed files with 287 additions and 0 deletions

View file

@ -205,6 +205,11 @@ public:
// Inspection
void inspectTarget();
// Server info commands
void queryServerTime();
void requestPlayedTime();
void queryWho(const std::string& playerName = "");
// ---- Phase 1: Name queries ----
void queryPlayerName(uint64_t guid);
void queryCreatureInfo(uint32_t entry, uint64_t guid);
@ -503,6 +508,11 @@ private:
void handleListInventory(network::Packet& packet);
void addMoneyCopper(uint32_t amount);
// ---- Server info handlers ----
void handleQueryTimeResponse(network::Packet& packet);
void handlePlayedTime(network::Packet& packet);
void handleWho(network::Packet& packet);
void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource);
void addSystemChatMessage(const std::string& message);

View file

@ -55,6 +55,14 @@ enum class Opcode : uint16_t {
CMSG_MESSAGECHAT = 0x095,
SMSG_MESSAGECHAT = 0x096,
// ---- Server Info Commands ----
CMSG_WHO = 0x062,
SMSG_WHO = 0x063,
CMSG_REQUEST_PLAYED_TIME = 0x1CC,
SMSG_PLAYED_TIME = 0x1CD,
CMSG_QUERY_TIME = 0x1CE,
SMSG_QUERY_TIME_RESPONSE = 0x1CF,
// ---- Phase 1: Foundation (Targeting, Queries) ----
CMSG_SET_SELECTION = 0x13D,
CMSG_NAME_QUERY = 0x050,

View file

@ -648,6 +648,58 @@ public:
*/
const char* getChatTypeString(ChatType type);
// ============================================================
// Server Info Commands
// ============================================================
/** CMSG_QUERY_TIME packet builder */
class QueryTimePacket {
public:
static network::Packet build();
};
/** SMSG_QUERY_TIME_RESPONSE data */
struct QueryTimeResponseData {
uint32_t serverTime = 0; // Unix timestamp
uint32_t timeOffset = 0; // Time until next daily reset
};
/** SMSG_QUERY_TIME_RESPONSE parser */
class QueryTimeResponseParser {
public:
static bool parse(network::Packet& packet, QueryTimeResponseData& data);
};
/** CMSG_REQUEST_PLAYED_TIME packet builder */
class RequestPlayedTimePacket {
public:
static network::Packet build(bool sendToChat = true);
};
/** SMSG_PLAYED_TIME data */
struct PlayedTimeData {
uint32_t totalTimePlayed = 0; // Total seconds played
uint32_t levelTimePlayed = 0; // Seconds played at current level
bool triggerMessage = false; // Whether to show in chat
};
/** SMSG_PLAYED_TIME parser */
class PlayedTimeParser {
public:
static bool parse(network::Packet& packet, PlayedTimeData& data);
};
/** CMSG_WHO packet builder */
class WhoPacket {
public:
static network::Packet build(uint32_t minLevel = 0, uint32_t maxLevel = 0,
const std::string& playerName = "",
const std::string& guildName = "",
uint32_t raceMask = 0xFFFFFFFF,
uint32_t classMask = 0xFFFFFFFF,
uint32_t zones = 0);
};
// ============================================================
// Phase 1: Foundation — Targeting, Name Queries
// ============================================================

View file

@ -7,6 +7,7 @@
#include <algorithm>
#include <cmath>
#include <cctype>
#include <ctime>
#include <random>
#include <chrono>
#include <filesystem>
@ -286,6 +287,24 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
break;
case Opcode::SMSG_QUERY_TIME_RESPONSE:
if (state == WorldState::IN_WORLD) {
handleQueryTimeResponse(packet);
}
break;
case Opcode::SMSG_PLAYED_TIME:
if (state == WorldState::IN_WORLD) {
handlePlayedTime(packet);
}
break;
case Opcode::SMSG_WHO:
if (state == WorldState::IN_WORLD) {
handleWho(packet);
}
break;
// ---- Phase 1: Foundation ----
case Opcode::SMSG_NAME_QUERY_RESPONSE:
handleNameQueryResponse(packet);
@ -1530,6 +1549,39 @@ void GameHandler::inspectTarget() {
LOG_INFO("Sent inspect request for player: ", name, " (GUID: 0x", std::hex, targetGuid, std::dec, ")");
}
void GameHandler::queryServerTime() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot query time: not in world or not connected");
return;
}
auto packet = QueryTimePacket::build();
socket->send(packet);
LOG_INFO("Requested server time");
}
void GameHandler::requestPlayedTime() {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot request played time: not in world or not connected");
return;
}
auto packet = RequestPlayedTimePacket::build(true);
socket->send(packet);
LOG_INFO("Requested played time");
}
void GameHandler::queryWho(const std::string& playerName) {
if (state != WorldState::IN_WORLD || !socket) {
LOG_WARNING("Cannot query who: not in world or not connected");
return;
}
auto packet = WhoPacket::build(0, 0, playerName);
socket->send(packet);
LOG_INFO("Sent WHO query", playerName.empty() ? "" : " for: " + playerName);
}
void GameHandler::releaseSpirit() {
if (!playerDead_) return;
if (socket && state == WorldState::IN_WORLD) {
@ -2792,6 +2844,97 @@ void GameHandler::addSystemChatMessage(const std::string& message) {
addLocalChatMessage(msg);
}
// ============================================================
// Server Info Command Handlers
// ============================================================
void GameHandler::handleQueryTimeResponse(network::Packet& packet) {
QueryTimeResponseData data;
if (!QueryTimeResponseParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_QUERY_TIME_RESPONSE");
return;
}
// Convert Unix timestamp to readable format
time_t serverTime = static_cast<time_t>(data.serverTime);
struct tm* timeInfo = localtime(&serverTime);
char timeStr[64];
strftime(timeStr, sizeof(timeStr), "%Y-%m-%d %H:%M:%S", timeInfo);
std::string msg = "Server time: " + std::string(timeStr);
addSystemChatMessage(msg);
LOG_INFO("Server time: ", data.serverTime, " (", timeStr, ")");
}
void GameHandler::handlePlayedTime(network::Packet& packet) {
PlayedTimeData data;
if (!PlayedTimeParser::parse(packet, data)) {
LOG_WARNING("Failed to parse SMSG_PLAYED_TIME");
return;
}
if (data.triggerMessage) {
// Format total time played
uint32_t totalDays = data.totalTimePlayed / 86400;
uint32_t totalHours = (data.totalTimePlayed % 86400) / 3600;
uint32_t totalMinutes = (data.totalTimePlayed % 3600) / 60;
// Format level time played
uint32_t levelDays = data.levelTimePlayed / 86400;
uint32_t levelHours = (data.levelTimePlayed % 86400) / 3600;
uint32_t levelMinutes = (data.levelTimePlayed % 3600) / 60;
std::string totalMsg = "Total time played: ";
if (totalDays > 0) totalMsg += std::to_string(totalDays) + " days, ";
if (totalHours > 0 || totalDays > 0) totalMsg += std::to_string(totalHours) + " hours, ";
totalMsg += std::to_string(totalMinutes) + " minutes";
std::string levelMsg = "Time played this level: ";
if (levelDays > 0) levelMsg += std::to_string(levelDays) + " days, ";
if (levelHours > 0 || levelDays > 0) levelMsg += std::to_string(levelHours) + " hours, ";
levelMsg += std::to_string(levelMinutes) + " minutes";
addSystemChatMessage(totalMsg);
addSystemChatMessage(levelMsg);
}
LOG_INFO("Played time: total=", data.totalTimePlayed, "s, level=", data.levelTimePlayed, "s");
}
void GameHandler::handleWho(network::Packet& packet) {
// Parse WHO response
uint32_t displayCount = packet.readUInt32();
uint32_t onlineCount = packet.readUInt32();
LOG_INFO("WHO response: ", displayCount, " players displayed, ", onlineCount, " total online");
if (displayCount == 0) {
addSystemChatMessage("No players found.");
return;
}
addSystemChatMessage(std::to_string(onlineCount) + " player(s) online:");
for (uint32_t i = 0; i < displayCount; ++i) {
std::string playerName = packet.readString();
std::string guildName = packet.readString();
uint32_t level = packet.readUInt32();
uint32_t classId = packet.readUInt32();
uint32_t raceId = packet.readUInt32();
packet.readUInt8(); // gender (unused)
packet.readUInt32(); // zoneId (unused)
std::string msg = " " + playerName;
if (!guildName.empty()) {
msg += " <" + guildName + ">";
}
msg += " - Level " + std::to_string(level);
addSystemChatMessage(msg);
LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId);
}
}
uint32_t GameHandler::generateClientSeed() {
// Generate cryptographically random seed
std::random_device rd;

View file

@ -1181,6 +1181,55 @@ network::Packet InspectPacket::build(uint64_t targetGuid) {
return packet;
}
// ============================================================
// Server Info Commands
// ============================================================
network::Packet QueryTimePacket::build() {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_QUERY_TIME));
LOG_DEBUG("Built CMSG_QUERY_TIME");
return packet;
}
bool QueryTimeResponseParser::parse(network::Packet& packet, QueryTimeResponseData& data) {
data.serverTime = packet.readUInt32();
data.timeOffset = packet.readUInt32();
LOG_DEBUG("Parsed SMSG_QUERY_TIME_RESPONSE: time=", data.serverTime, " offset=", data.timeOffset);
return true;
}
network::Packet RequestPlayedTimePacket::build(bool sendToChat) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_REQUEST_PLAYED_TIME));
packet.writeUInt8(sendToChat ? 1 : 0);
LOG_DEBUG("Built CMSG_REQUEST_PLAYED_TIME: sendToChat=", sendToChat);
return packet;
}
bool PlayedTimeParser::parse(network::Packet& packet, PlayedTimeData& data) {
data.totalTimePlayed = packet.readUInt32();
data.levelTimePlayed = packet.readUInt32();
data.triggerMessage = packet.readUInt8() != 0;
LOG_DEBUG("Parsed SMSG_PLAYED_TIME: total=", data.totalTimePlayed, " level=", data.levelTimePlayed);
return true;
}
network::Packet WhoPacket::build(uint32_t minLevel, uint32_t maxLevel,
const std::string& playerName,
const std::string& guildName,
uint32_t raceMask, uint32_t classMask,
uint32_t zones) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_WHO));
packet.writeUInt32(minLevel);
packet.writeUInt32(maxLevel);
packet.writeString(playerName);
packet.writeString(guildName);
packet.writeUInt32(raceMask);
packet.writeUInt32(classMask);
packet.writeUInt32(zones); // Number of zones
LOG_DEBUG("Built CMSG_WHO: player=", playerName);
return packet;
}
network::Packet NameQueryPacket::build(uint64_t playerGuid) {
network::Packet packet(static_cast<uint16_t>(Opcode::CMSG_NAME_QUERY));
packet.writeUInt64(playerGuid);

View file

@ -963,6 +963,31 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
return;
}
// /time command
if (cmdLower == "time") {
gameHandler.queryServerTime();
chatInputBuffer[0] = '\0';
return;
}
// /played command
if (cmdLower == "played") {
gameHandler.requestPlayedTime();
chatInputBuffer[0] = '\0';
return;
}
// /who command
if (cmdLower == "who") {
std::string playerName;
if (spacePos != std::string::npos) {
playerName = command.substr(spacePos + 1);
}
gameHandler.queryWho(playerName);
chatInputBuffer[0] = '\0';
return;
}
// Chat channel slash commands
bool isChannelCommand = false;
if (cmdLower == "s" || cmdLower == "say") {