diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 581181d2..a638ccdb 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -459,6 +459,11 @@ if(HEAVENCLIENT_BUILD_TESTS) add_test(NAME block_reason COMMAND block_reason_tests) add_executable(account_grade_tests tests/account_grade.cpp) add_test(NAME account_grade COMMAND account_grade_tests) + add_executable(admin_result_tests tests/admin_result.cpp Net/InPacket.cpp Net/Handlers/Helpers/AdminResult.cpp) + target_include_directories(admin_result_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME admin_result COMMAND admin_result_tests) + add_executable(gm_command_tests tests/gm_command.cpp IO/GmCommand.cpp) + add_test(NAME gm_command COMMAND gm_command_tests) endif() include_directories(.) @@ -1103,6 +1108,10 @@ add_executable(HeavenClient IO/FamilyActions.h IO/MarriageActions.cpp IO/MarriageActions.h + IO/GmCommand.cpp + IO/GmCommand.h + IO/GmCommands.cpp + IO/GmCommands.h IO/GuildCommands.cpp IO/GuildCommands.h IO/ChatCommand.h @@ -1130,6 +1139,8 @@ add_executable(HeavenClient Net/Handlers/Helpers/CharacterParser.h Net/Handlers/Helpers/ItemParser.cpp Net/Handlers/Helpers/ItemParser.h + Net/Handlers/Helpers/AdminResult.cpp + Net/Handlers/Helpers/AdminResult.h Net/Handlers/Helpers/LoginParser.cpp Net/Handlers/Helpers/LoginParser.h Net/Handlers/Helpers/MovementParser.cpp diff --git a/HeavenClient/IO/GmCommand.cpp b/HeavenClient/IO/GmCommand.cpp new file mode 100644 index 00000000..ae6870d1 --- /dev/null +++ b/HeavenClient/IO/GmCommand.cpp @@ -0,0 +1,126 @@ +#include "GmCommand.h" + +#include "ChatCommand.h" + +#include + +namespace ms +{ + namespace + { + struct Broadcast + { + const char* command; + bool argument; + uint8_t scope; + uint8_t type; + }; + + // In v83's order (0x52cf45..0x52d39c); an `x` sends no text, which + // takes the slide down. + constexpr Broadcast BROADCASTS[] = { + // StringPool 0x6e9, 0x6eb, 0x104, 0x6e7 + { "/alertch", true, GmCommand::CHANNEL_SCOPE, GmCommand::ALERT }, + { "/alertmap", true, GmCommand::MAP, GmCommand::ALERT }, + { "/alertm", true, GmCommand::MAP, GmCommand::ALERT }, + { "/alertall", true, GmCommand::WORLD, GmCommand::ALERT }, + // StringPool 0x6e8, 0x6ea, 0x6ec, 0x105 + { "/noticeall", true, GmCommand::WORLD, GmCommand::NOTICE }, + { "/noticech", true, GmCommand::CHANNEL_SCOPE, GmCommand::NOTICE }, + { "/noticemap", true, GmCommand::MAP, GmCommand::NOTICE }, + { "/noticem", true, GmCommand::MAP, GmCommand::NOTICE }, + // StringPool 0xaff, 0xb06, 0xb00, 0xb07, 0xb01, 0xb08 + { "/slideall", true, GmCommand::WORLD, GmCommand::SLIDE }, + { "/slideallx", false, GmCommand::WORLD, GmCommand::SLIDE }, + { "/slidech", true, GmCommand::CHANNEL_SCOPE, GmCommand::SLIDE }, + { "/slidechx", false, GmCommand::CHANNEL_SCOPE, GmCommand::SLIDE }, + { "/slidemap", true, GmCommand::MAP, GmCommand::SLIDE }, + { "/slidemapx", false, GmCommand::MAP, GmCommand::SLIDE } + }; + + GmCommand of(GmCommand::Kind kind) + { + GmCommand command; + command.kind = kind; + return command; + } + } + + GmCommand GmCommand::parse(const std::string& input, int rank) + { + std::string rest; + + // StringPool 0x6e6 + if (rank <= 4 && ChatCommand::with_argument(input, "/h", rest)) + { + GmCommand command = of(Kind::HIDE); + command.on = std::atoi(rest.c_str()) != 0; + return command; + } + + if (rank <= 3) + { + // StringPool 0x6e4, 0xbf + if (ChatCommand::with_argument(input, "/ch", rest)) + { + GmCommand command = of(Kind::CHANNEL); + command.channel = std::atoi(rest.c_str()) - 1; + return command; + } + + if (ChatCommand::same_text(input, "/u")) + return of(Kind::USERS); + + // StringPool 0x700: a name, a space, and the rest (FUN_004a8f57). + if (ChatCommand::with_argument(input, "/w", rest)) + { + size_t space = rest.find(' '); + + if (space != std::string::npos && space > 0 && space + 1 < rest.size()) + { + GmCommand command = of(Kind::WARN); + command.name = rest.substr(0, space); + command.text = rest.substr(space + 1); + return command; + } + } + + // StringPool 0x6e5 + if (ChatCommand::with_argument(input, "/ban", rest)) + { + GmCommand command = of(Kind::BAN); + command.name = rest; + return command; + } + + // StringPool 0x6df + if (ChatCommand::same_text(input, "/uclip")) + return of(Kind::UCLIP); + } + + if (rank <= 2) + { + for (const Broadcast& broadcast : BROADCASTS) + { + bool matched = broadcast.argument + ? ChatCommand::with_argument(input, broadcast.command, rest) + : ChatCommand::same_text(input, broadcast.command); + + if (!matched) + continue; + + GmCommand command = of(Kind::BROADCAST); + command.scope = broadcast.scope; + command.type = broadcast.type; + command.text = broadcast.argument ? rest : ""; + return command; + } + + // StringPool 0xb09 + if (ChatCommand::same_text(input, "/slideax")) + return of(Kind::SLIDE_AX); + } + + return GmCommand(); + } +} diff --git a/HeavenClient/IO/GmCommand.h b/HeavenClient/IO/GmCommand.h new file mode 100644 index 00000000..d722d48a --- /dev/null +++ b/HeavenClient/IO/GmCommand.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +namespace ms +{ + // A GM's slash command, as v83's chat bar reads it (FUN_0052c40c) before + // any of the players' commands. + struct GmCommand + { + enum class Kind + { + // Not a GM command, or not one this rank may use. + NONE, + // /h N: ADMIN_COMMAND 0x10, hidden when N isn't 0. + HIDE, + // /ch N: a change to channel `channel` (from 0), asked as the + // player's own. + CHANNEL, + // /u: ADMIN_COMMAND 0x11 0, the users on the map. + USERS, + // /w NAME MSG: ADMIN_COMMAND 0x1E, a warning. + WARN, + // /ban NAME: ADMIN_COMMAND 0x03. + BAN, + // /uclip: ADMIN_COMMAND 0x11 0x0C. + UCLIP, + // /alert*, /notice* and /slide*: ADMIN_BROADCAST, `type` to + // `scope`. + BROADCAST, + // /slideax: 0x86. + SLIDE_AX + }; + + // ADMIN_BROADCAST's scopes and types. + static constexpr uint8_t WORLD = 0; + static constexpr uint8_t CHANNEL_SCOPE = 1; + static constexpr uint8_t MAP = 2; + static constexpr uint8_t NOTICE = 0; + static constexpr uint8_t ALERT = 1; + static constexpr uint8_t SLIDE = 4; + + Kind kind = Kind::NONE; + std::string name; + std::string text; + bool on = false; + int32_t channel = 0; + uint8_t scope = 0; + uint8_t type = 0; + + // The command `input` is for an account of v83's GM `rank` + // (AccountGrade::rank). Each is matched as v83 matches it: a name + // alone, or a name, a space and more. + static GmCommand parse(const std::string& input, int rank); + }; +} diff --git a/HeavenClient/IO/GmCommands.cpp b/HeavenClient/IO/GmCommands.cpp new file mode 100644 index 00000000..7d3697aa --- /dev/null +++ b/HeavenClient/IO/GmCommands.cpp @@ -0,0 +1,60 @@ +#include "GmCommands.h" + +#include "GmCommand.h" + +#include "../Configuration.h" +#include "../Gameplay/Stage.h" +#include "../Net/Packets/GameplayPackets.h" + +namespace ms +{ + namespace GmCommands + { + bool run(const std::string& input) + { + const AccountGrade& grade = Configuration::get().get_grade(); + + // v83 reads the GM commands only for these grades (0x52c9cd). + if (!grade.slash_commands()) + return false; + + GmCommand command = GmCommand::parse(input, grade.rank()); + + switch (command.kind) + { + case GmCommand::Kind::NONE: + return false; + case GmCommand::Kind::HIDE: + AdminHidePacket(command.on).dispatch(); + break; + case GmCommand::Kind::CHANNEL: + // FUN_005304af, as the channel menu asks, with no check of + // the number. + Stage::get().change_channel(static_cast(command.channel)); + break; + case GmCommand::Kind::USERS: + AdminEnterMapPacket(AdminEnterMapPacket::Operation::SHOW_USERS).dispatch(); + break; + case GmCommand::Kind::WARN: + AdminWarnPacket(command.name, command.text).dispatch(); + break; + case GmCommand::Kind::BAN: + AdminBanPacket(command.name).dispatch(); + break; + case GmCommand::Kind::UCLIP: + AdminEnterMapPacket(AdminEnterMapPacket::Operation::ALERT_ADMINS).dispatch(); + break; + case GmCommand::Kind::BROADCAST: + AdminBroadcastPacket(command.scope, command.type, command.text).dispatch(); + break; + case GmCommand::Kind::SLIDE_AX: + GmSlidePacket().dispatch(); + break; + } + + // Every GM command is logged, as typed (0x52e28f). + AdminLogPacket(input).dispatch(); + return true; + } + } +} diff --git a/HeavenClient/IO/GmCommands.h b/HeavenClient/IO/GmCommands.h new file mode 100644 index 00000000..eb1ade34 --- /dev/null +++ b/HeavenClient/IO/GmCommands.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace ms +{ + // v83's GM slash commands, for an account whose grade bytes allow them. + namespace GmCommands + { + // Runs input if it is a GM command the account may use, and logs + // it to the server (ADMIN_LOG); returns whether it was one. + bool run(const std::string& input); + } +} diff --git a/HeavenClient/IO/UITypes/UIChatBar.cpp b/HeavenClient/IO/UITypes/UIChatBar.cpp index 29816933..32dfa7f7 100644 --- a/HeavenClient/IO/UITypes/UIChatBar.cpp +++ b/HeavenClient/IO/UITypes/UIChatBar.cpp @@ -31,6 +31,7 @@ #include "../Components/MapleButton.h" #include "../BuddyCommands.h" +#include "../GmCommands.h" #include "../GuildCommands.h" #include "../PartyCommands.h" @@ -335,8 +336,13 @@ namespace ms if (msg[0] != '/' && chat_target == Target::WHISPER && whisper_name.empty()) send_chatline("There is no one to whisper to.", LineType::SKILL); + // A GM's commands come first, so a GM's /w warns + // (0x52c9cd). + if (msg[0] == '/' && GmCommands::run(msg)) + { + } // HC's own: v83 has no command to whisper one line. - if (whisper_result == WhisperParseResult::ValidWhisper + else if (whisper_result == WhisperParseResult::ValidWhisper && same_name(target, Stage::get().get_player().get_stats().get_name())) { send_chatline("You can't whisper to yourself.", LineType::SKILL); diff --git a/HeavenClient/Net/Handlers/Helpers/AdminResult.cpp b/HeavenClient/Net/Handlers/Helpers/AdminResult.cpp new file mode 100644 index 00000000..9c5271ec --- /dev/null +++ b/HeavenClient/Net/Handlers/Helpers/AdminResult.cpp @@ -0,0 +1,114 @@ +#include "AdminResult.h" + +namespace ms +{ + namespace + { + AdminResult chat_line(std::string text, uint8_t chat) + { + AdminResult result; + result.kind = AdminResult::Kind::LINE; + result.line = std::move(text); + result.chat = chat; + return result; + } + + // Mode 0x1d, which 0x13 falls into (0x535903): v83 builds SP 0xab + // ("Your request failed.") but never shows it, and logs an empty + // line for any other byte. + AdminResult request_failed(InPacket& recv, AdminResult shown) + { + if (recv.read_byte() != 0 && shown.kind == AdminResult::Kind::NONE) + return chat_line("", 9); + + return shown; + } + } + + AdminResult AdminResult::read(InPacket& recv) + { + switch (static_cast(recv.read_byte())) + { + case 0x04: + recv.read_byte(); + // StringPool 0xa2 + return chat_line("You have successfully blocked access.", 9); + case 0x05: + recv.read_byte(); + // StringPool 0xa4 + return chat_line("The unblocking has been successful", 9); + case 0x06: + // StringPool 0xa7, 0xa8 + return recv.read_byte() > 0 + ? chat_line("You have entered an invalid character name.", 9) + : chat_line("You have successfully removed the name from the ranks.", 9); + case 0x09: + { + std::string npc = recv.read_string(); + + // StringPool 0xaa + if (npc.empty()) + return chat_line("You have either entered a wrong NPC name or", 0xc); + + std::string key = recv.read_string(); + std::string value = recv.read_string(); + + return chat_line("[" + npc + ":" + key + "] = " + value, 0xc); + } + case 0x10: + { + AdminResult result; + result.kind = Kind::HIDDEN; + result.hidden = recv.read_byte() != 0; + return result; + } + case 0x13: + { + AdminResult result; + + if (recv.read_byte() == 0) + { + // StringPool 0xae + result.kind = Kind::MERCHANT_MAP; + result.line = "Hired Merchant located at : "; + result.map_id = recv.read_int(); + result.chat = 0xc; + } + else + { + uint8_t channel = static_cast(recv.read_byte()); + + // StringPool 0xae, 0xaf + result = channel >= 0xFE + ? chat_line("Unable to find the hired merchant.", 0xc) + : chat_line("Hired Merchant located at : Channel " + std::to_string(channel + 1), 0xc); + } + + return request_failed(recv, result); + } + case 0x1b: + { + AdminResult result; + result.kind = Kind::MINIMAP_OFF; + return result; + } + case 0x1c: + { + AdminResult result; + result.kind = Kind::MINIMAP_ON; + return result; + } + case 0x1d: + return request_failed(recv, AdminResult()); + case 0x1e: + // StringPool 0xbb4, 0xbb5 + return recv.read_byte() == 0 + ? chat_line("Unable to send the message. Please enter the user's name before warning.", 9) + : chat_line("Your warning has been successfully sent.", 9); + case 0x26: + return chat_line(recv.read_string(), 0xb); + default: + return AdminResult(); + } + } +} diff --git a/HeavenClient/Net/Handlers/Helpers/AdminResult.h b/HeavenClient/Net/Handlers/Helpers/AdminResult.h new file mode 100644 index 00000000..a70d9b35 --- /dev/null +++ b/HeavenClient/Net/Handlers/Helpers/AdminResult.h @@ -0,0 +1,38 @@ +#pragma once + +#include "../../InPacket.h" + +#include +#include + +namespace ms +{ + // ADMIN_RESULT (144): how a GM's slash command went, read as v83 reads + // it (FUN_005352e9, report b175 s5). + struct AdminResult + { + enum class Kind + { + // Nothing to show. + NONE, + // A line in chat of v83's type `chat`. + LINE, + // The hide state (mode 0x10). + HIDDEN, + // A hired merchant found on map `map_id`, whose name follows `line`. + MERCHANT_MAP, + // The minimap off or on (modes 0x1B and 0x1C). + MINIMAP_OFF, + MINIMAP_ON + }; + + Kind kind = Kind::NONE; + std::string line; + // v83's chat type: 9, 0xb or 0xc. + uint8_t chat = 0; + bool hidden = false; + int32_t map_id = 0; + + static AdminResult read(InPacket& recv); + }; +} diff --git a/HeavenClient/Net/Handlers/MessagingHandlers.cpp b/HeavenClient/Net/Handlers/MessagingHandlers.cpp index 6bab642e..0a25ac1b 100644 --- a/HeavenClient/Net/Handlers/MessagingHandlers.cpp +++ b/HeavenClient/Net/Handlers/MessagingHandlers.cpp @@ -17,6 +17,7 @@ ////////////////////////////////////////////////////////////////////////////////// #include "MessagingHandlers.h" +#include "Helpers/AdminResult.h" #include "Helpers/LoginParser.h" #include "Helpers/UserEffect.h" @@ -41,6 +42,7 @@ #include "../../IO/UITypes/UIScriptMessage.h" #include "../../IO/UITypes/UIChatBar.h" #include "../../IO/UITypes/UIInviteCard.h" +#include "../../IO/UITypes/UIMiniMap.h" #include "../../IO/UITypes/UIMessenger.h" #include "../../IO/UITypes/UINpcTalk.h" #include "../../IO/UITypes/UINotice.h" @@ -459,14 +461,51 @@ namespace ms lines->show(text); } + void AdminResultHandler::handle(InPacket& recv) const + { + AdminResult result = AdminResult::read(recv); + + switch (result.kind) + { + case AdminResult::Kind::MERCHANT_MAP: + // v83 formats the name with SP 0x6c2 (0x5cf792). + result.line += NxHelper::Map::get_map_info_by_id(result.map_id).name; + [[fallthrough]]; + case AdminResult::Kind::LINE: + if (auto chatbar = UI::get().get_element()) + { + // v83's chat types 9, 0xb and 0xc. + auto type = result.chat == 9 ? UIChatBar::LineType::LIGHT_YELLOW + : result.chat == 0xc ? UIChatBar::LineType::SKILL + : UIChatBar::LineType::BLUE; + chatbar->send_chatline(result.line, type); + } + break; + case AdminResult::Kind::MINIMAP_OFF: + UIMiniMap::switch_on(false); + break; + case AdminResult::Kind::MINIMAP_ON: + UIMiniMap::switch_on(true); + break; + default: + // Hide (0x10) only tells the GM what they asked for; the + // server hides them from the others. + break; + } + } + void ServerMessageHandler::handle(InPacket& recv) const { // v83 reads a flag first only for the scrolling notice, which has no // message when it is off (FUN_00a22785). int8_t type = recv.read_byte(); + // A scrolling notice turned off takes the one showing down. if (type == 4 && !recv.read_bool()) + { + UI::get().set_scrollnotice(""); return; + } std::string message = recv.read_string(); auto chatbar = UI::get().get_element(); diff --git a/HeavenClient/Net/Handlers/MessagingHandlers.h b/HeavenClient/Net/Handlers/MessagingHandlers.h index 3fb73931..3ac774b9 100644 --- a/HeavenClient/Net/Handlers/MessagingHandlers.h +++ b/HeavenClient/Net/Handlers/MessagingHandlers.h @@ -57,6 +57,13 @@ namespace ms void handle(InPacket& recv) const override; }; + // How a GM's slash command went (FUN_005352e9) + // Opcode: ADMIN_RESULT(144) + class AdminResultHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // An avatar megaphone was refused: 'S' while the line waits too long, 'T' below level 11 (v83's CWvsContext // handler for 110). The megaphone is kept. // Opcode: AVATAR_MEGAPHONE_RESULT(110) diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index 8e1950fd..9451a098 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -154,14 +154,17 @@ namespace ms USE_ITEM_REWARD = 112, ITEM_MAKE = 113, WATER_OF_LIFE = 117, + ADMIN_BROADCAST = 118, PARTY_OPERATION = 124, DENY_PARTY_REQUEST = 125, GUILD_OPERATION = 126, DENY_GUILD_REQUEST = 127, ADMIN_COMMAND = 128, + ADMIN_LOG = 129, BUDDYLIST_MODIFY = 130, NOTE_ACTION = 131, USE_DOOR = 133, + GM_SLIDE = 134, ALLIANCE_OPERATION = 143, DENY_ALLIANCE_REQUEST = 144, OPEN_FAMILY_PEDIGREE = 145, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index 9a3d0636..7e674c94 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -144,6 +144,7 @@ namespace ms FIELD_OBSTACLE_ALL_RESET = 141, BLOW_WEATHER = 142, PLAY_JUKEBOX = 143, + ADMIN_RESULT = 144, OX_QUIZ = 145, GMEVENT_INSTRUCTIONS = 146, CLOCK = 147, @@ -515,6 +516,7 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index 6e7cd5eb..0d4d9af6 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -1080,7 +1080,10 @@ namespace ms public: enum Mode : int8_t { - ENTER_MAP = 0x11 + BAN = 0x03, + HIDE = 0x10, + ENTER_MAP = 0x11, + WARN = 0x1E }; protected: @@ -1106,6 +1109,72 @@ namespace ms } }; + // /h N + class AdminHidePacket : public AdminCommandPacket + { + public: + AdminHidePacket(bool hidden) : AdminCommandPacket(AdminCommandPacket::Mode::HIDE) + { + write_byte(hidden); + } + }; + + // /w NAME MSG, a GM's warning + class AdminWarnPacket : public AdminCommandPacket + { + public: + AdminWarnPacket(const std::string& name, const std::string& message) : AdminCommandPacket(AdminCommandPacket::Mode::WARN) + { + write_string(name); + write_string(message); + } + }; + + // /ban NAME + class AdminBanPacket : public AdminCommandPacket + { + public: + AdminBanPacket(const std::string& name) : AdminCommandPacket(AdminCommandPacket::Mode::BAN) + { + write_string(name); + } + }; + + // A GM's notice, alert or slide to the world, the channel or the map + // Opcode: ADMIN_BROADCAST(118) + class AdminBroadcastPacket : public OutPacket + { + public: + AdminBroadcastPacket(uint8_t scope, uint8_t type, const std::string& text) : OutPacket(OutPacket::Opcode::ADMIN_BROADCAST) + { + write_byte(scope); + write_byte(type); + write_string(text); + } + }; + + // /slideax + // Opcode: GM_SLIDE(134) + class GmSlidePacket : public OutPacket + { + public: + GmSlidePacket() : OutPacket(OutPacket::Opcode::GM_SLIDE) + { + write_byte(0); + } + }; + + // A GM's slash command, as typed + // Opcode: ADMIN_LOG(129) + class AdminLogPacket : public OutPacket + { + public: + AdminLogPacket(const std::string& line) : OutPacket(OutPacket::Opcode::ADMIN_LOG) + { + write_string(line); + } + }; + // Opcode: MOVE_MONSTER(188) class MoveMobPacket : public MovementPacket { diff --git a/HeavenClient/Util/AccountGrade.h b/HeavenClient/Util/AccountGrade.h index c7197b28..9ad8f6c8 100644 --- a/HeavenClient/Util/AccountGrade.h +++ b/HeavenClient/Util/AccountGrade.h @@ -31,6 +31,31 @@ namespace ms { return subgrade & 0x80; } + + // Whether the chat bar reads the GM slash commands (0x52c9cd and + // FUN_0052f000). + bool slash_commands() const + { + return gm() || (subgrade & 0xE0); + } + + // The GM rank (FUN_0052f031): 0 may do the most, 5 is a player's. + int rank() const + { + if (grade & 0x20) + return 0; + + if (grade & 0x01) + return 1; + + if (subgrade & 0x20) + return 2; + + if (subgrade & 0x40) + return 3; + + return subgrade & 0x80 ? 4 : 5; + } }; namespace account_grade diff --git a/HeavenClient/tests/account_grade.cpp b/HeavenClient/tests/account_grade.cpp index 5d48e3e1..cb629d39 100644 --- a/HeavenClient/tests/account_grade.cpp +++ b/HeavenClient/tests/account_grade.cpp @@ -19,6 +19,14 @@ int main() require(AccountGrade{ 0x11, 0 }.gm() && AccountGrade{ 0x11, 0 }.admin(), "0x11 is the admin account, a GM's too"); require(!AccountGrade{ 0x01, 0x40 }.alerts_admins(), "the sub-grade's 0x40 doesn't tell the server"); require(AccountGrade{ 0, 0x80 }.alerts_admins() && !AccountGrade{ 0, 0x80 }.gm(), "its 0x80 does, without the GM bit"); + + require(AccountGrade{}.rank() == 5 && !AccountGrade{}.slash_commands(), "a player has no GM commands"); + require(AccountGrade{ 0x21, 0x80 }.rank() == 0, "grade 0x20 ranks first"); + require(AccountGrade{ 0x01, 0x80 }.rank() == 1 && AccountGrade{ 0x11, 0 }.rank() == 1, "then the GM bit"); + require(AccountGrade{ 0, 0x60 }.rank() == 2 && AccountGrade{ 0, 0x40 }.rank() == 3, "then sub 0x20, then 0x40"); + require(AccountGrade{ 0, 0x80 }.rank() == 4 && AccountGrade{ 0, 0x80 }.slash_commands(), "then sub 0x80"); + require(AccountGrade{ 0x20, 0 }.rank() == 0 && !AccountGrade{ 0x20, 0 }.slash_commands(), + "grade 0x20 alone ranks first but can't use the commands"); } catch (const std::exception& e) { diff --git a/HeavenClient/tests/admin_result.cpp b/HeavenClient/tests/admin_result.cpp new file mode 100644 index 00000000..187884b7 --- /dev/null +++ b/HeavenClient/tests/admin_result.cpp @@ -0,0 +1,101 @@ +#include "../Net/Handlers/Helpers/AdminResult.h" + +#include +#include +#include +#include +#include + +namespace +{ + void require(bool condition, const char* message) + { + if (!condition) + throw std::runtime_error(message); + } + + struct Writer + { + std::vector bytes; + + template + Writer& put(T value) + { + int8_t raw[sizeof(T)]; + std::memcpy(raw, &value, sizeof(T)); + bytes.insert(bytes.end(), raw, raw + sizeof(T)); + return *this; + } + + Writer& str(const std::string& text) + { + put(static_cast(text.size())); + bytes.insert(bytes.end(), text.begin(), text.end()); + return *this; + } + + ms::AdminResult read() const + { + ms::InPacket packet(bytes.data(), bytes.size()); + ms::AdminResult result = ms::AdminResult::read(packet); + require(packet.length() == 0, "the whole packet is read"); + return result; + } + }; + + using Kind = ms::AdminResult::Kind; +} + +int main() +{ + try + { + ms::AdminResult sent = Writer().put(0x1e).put(1).read(); + require(sent.kind == Kind::LINE && sent.chat == 9, "a warning sent is a type 9 line"); + require(sent.line == "Your warning has been successfully sent.", "and says so"); + + ms::AdminResult unsent = Writer().put(0x1e).put(0).read(); + require(unsent.line.rfind("Unable to send the message.", 0) == 0, "a warning with no name is refused"); + + ms::AdminResult hidden = Writer().put(0x10).put(1).read(); + require(hidden.kind == Kind::HIDDEN && hidden.hidden, "hide reads its state"); + + ms::AdminResult npc = Writer().put(0x09).str("Cody").str("quest").str("1").read(); + require(npc.kind == Kind::LINE && npc.chat == 0xc && npc.line == "[Cody:quest] = 1", "an NPC value is shown"); + + ms::AdminResult no_npc = Writer().put(0x09).str("").read(); + require(no_npc.chat == 0xc && no_npc.line == "You have either entered a wrong NPC name or", "an NPC name that isn't found"); + + // 0x13 reads 0x1d's byte after its own. + ms::AdminResult map = Writer().put(0x13).put(0).put(910000001).put(0).read(); + require(map.kind == Kind::MERCHANT_MAP && map.map_id == 910000001, "a merchant found on a map"); + + ms::AdminResult channel = Writer().put(0x13).put(1).put(2).put(1).read(); + require(channel.line == "Hired Merchant located at : Channel 3", "a merchant found on a channel"); + + ms::AdminResult missing = Writer().put(0x13).put(1).put(0xFF).put(0).read(); + require(missing.line == "Unable to find the hired merchant.", "a merchant not found"); + + require(Writer().put(0x1d).put(0).read().kind == Kind::NONE, "a quiet failure shows nothing"); + ms::AdminResult failed = Writer().put(0x1d).put(1).read(); + require(failed.kind == Kind::LINE && failed.line.empty(), "any other byte logs an empty line"); + + require(Writer().put(0x1b).read().kind == Kind::MINIMAP_OFF, "the minimap off"); + require(Writer().put(0x1c).read().kind == Kind::MINIMAP_ON, "the minimap on"); + + ms::AdminResult text = Writer().put(0x26).str("hello").read(); + require(text.chat == 0xb && text.line == "hello", "0x26 shows its string"); + + require(Writer().put(0x04).put(0).read().line == "You have successfully blocked access.", "a block"); + require(Writer().put(0x06).put(1).read().line == "You have entered an invalid character name.", "a bad name"); + require(Writer().put(0x07).read().kind == Kind::NONE, "other modes are ignored"); + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } + + std::cout << "admin_result tests passed\n"; + return 0; +} diff --git a/HeavenClient/tests/gm_command.cpp b/HeavenClient/tests/gm_command.cpp new file mode 100644 index 00000000..78c64f16 --- /dev/null +++ b/HeavenClient/tests/gm_command.cpp @@ -0,0 +1,65 @@ +#include "../IO/GmCommand.h" + +#include +#include + +namespace +{ + void require(bool condition, const char* message) + { + if (!condition) + throw std::runtime_error(message); + } + + using ms::GmCommand; + using Kind = GmCommand::Kind; +} + +int main() +{ + try + { + // A GM account (grade 0x01) is rank 1, a player's 5. + GmCommand hide = GmCommand::parse("/h 1", 4); + require(hide.kind == Kind::HIDE && hide.on, "/h 1 hides"); + require(!GmCommand::parse("/H 0", 1).on && GmCommand::parse("/H 0", 1).kind == Kind::HIDE, "/h 0 shows, in any case"); + require(GmCommand::parse("/h", 1).kind == Kind::NONE, "/h needs its number"); + require(GmCommand::parse("/h 1", 5).kind == Kind::NONE, "a player can't hide"); + + GmCommand channel = GmCommand::parse("/ch 3", 3); + require(channel.kind == Kind::CHANNEL && channel.channel == 2, "/ch counts channels from 1"); + require(GmCommand::parse("/ch 3", 4).kind == Kind::NONE, "rank 4 only hides"); + require(GmCommand::parse("/u", 1).kind == Kind::USERS, "/u lists the map's users"); + require(GmCommand::parse("/uclip", 1).kind == Kind::UCLIP, "/uclip"); + + GmCommand warn = GmCommand::parse("/w Bob stop that now", 1); + require(warn.kind == Kind::WARN && warn.name == "Bob" && warn.text == "stop that now", "a warning"); + require(GmCommand::parse("/w Bob", 1).kind == Kind::NONE, "a warning needs its text"); + + GmCommand ban = GmCommand::parse("/ban Bob", 1); + require(ban.kind == Kind::BAN && ban.name == "Bob", "a ban"); + + GmCommand notice = GmCommand::parse("/noticeall hello there", 2); + require(notice.kind == Kind::BROADCAST && notice.scope == GmCommand::WORLD + && notice.type == GmCommand::NOTICE && notice.text == "hello there", "a notice to the world"); + GmCommand alert = GmCommand::parse("/alertm hi", 1); + require(alert.scope == GmCommand::MAP && alert.type == GmCommand::ALERT, "/alertm is /alertmap"); + GmCommand slide = GmCommand::parse("/slidech hi", 1); + require(slide.scope == GmCommand::CHANNEL_SCOPE && slide.type == GmCommand::SLIDE, "a slide to the channel"); + GmCommand off = GmCommand::parse("/slidemapx", 1); + require(off.kind == Kind::BROADCAST && off.scope == GmCommand::MAP && off.text.empty(), "/slidemapx sends no text"); + require(GmCommand::parse("/slidemapx hi", 1).kind == Kind::NONE, "and takes none"); + require(GmCommand::parse("/noticeall", 1).kind == Kind::NONE, "a notice needs its text"); + require(GmCommand::parse("/noticeall hi", 3).kind == Kind::NONE, "rank 3 can't broadcast"); + require(GmCommand::parse("/slideax", 2).kind == Kind::SLIDE_AX, "/slideax"); + require(GmCommand::parse("/party", 0).kind == Kind::NONE, "players' commands aren't GM ones"); + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } + + std::cout << "gm_command tests passed\n"; + return 0; +} diff --git a/agent_docs/client.md b/agent_docs/client.md index c93e1099..1f358ba5 100644 --- a/agent_docs/client.md +++ b/agent_docs/client.md @@ -660,6 +660,7 @@ side yet. | Curse filter (`CurseData`) | As v83 does (FUN_007a06f3): only text of 0x3ff bytes or more, or with a control character other than a line break, is refused ("Cursing is not allowed."); curse words are starred out and the text goes on. Notes, the plain, super and avatar megaphones, guild BBS threads and replies, macro names, pet names and the weather item's line work so. The triple megaphone's lines go as typed, as nothing shows v83 censoring them | | General chat while a dialog is open | Refused in the chat log with "You can't chat right now." (FUN_0052c315) while any of v83's dialogs is open: a trade or personal shop (which has its own chat), an NPC shop, storage, Duey, the item megaphone's, Item Guard's, Karma's, the hammer's, Vega's or the kite's window (`UI::dialog_open`, v83's 0xbe7910; report c013) | | Account grade (`AccountGrade`) | As v83 does (report b156): the login result's grade and sub-grade bytes are kept. A GM's account (grade bit 0) refuses trades and opening a store with the chat line "Not available with an Admin account." and sends its unknown `/` lines as chat; the admin account (grade 0x11) also refuses dropping items ("The admin can't drop items.") and mesos ("The Admin account can't throw away the money."); ADMIN_COMMAND 0x11 goes out on each map change only for the sub-grade's 0x80. Not done: the sub-grade 0x80's refusal of attacks ("MWLB"), which the server never sends | +| GM slash commands (`GmCommand`, `GmCommands`) | As v83 does (report b175): for grade bit 0 or a sub-grade of 0x20, 0x40 or 0x80, and by the rank `AccountGrade::rank` gives as FUN_0052f031 does, the chat bar reads `/h N`, `/ch N`, `/u`, `/w NAME MSG` (a warning, so a GM's `/w` doesn't whisper), `/ban NAME`, `/uclip`, the twelve `/alert*`, `/notice*` and `/slide*` broadcasts with `/alertm` and `/noticem`, and `/slideax`, before the players' commands; each is followed by ADMIN_LOG with the line (see "The client's slash commands" in `server-architecture.md`). ADMIN_RESULT (144, `AdminResult`) shows the server's answers as v83's FUN_005352e9 does: lines for modes 4, 5, 6, 9, 0x13 (a hired merchant's map or channel, which reads 0x1d's byte after), 0x1d, 0x1e and 0x26, and the minimap off and on for 0x1b and 0x1c. Not done: `/flag`, `/block`'s dialog, `/c`, `/clip`, and `/u N` | | Player shops, mini-games, memo/mail | No UI, no handlers | | Reactors | Spawning, swings, hits and breaking; no skill hits, touch or quest gates; see `combat.md` | | Pets | Summoning, following, others' pets, map changes, chat commands, idle actions, level-up effect, food, cash food and hunger; see `pets.md` | diff --git a/agent_docs/server-architecture.md b/agent_docs/server-architecture.md index 17a1e21e..a5557f25 100644 --- a/agent_docs/server-architecture.md +++ b/agent_docs/server-architecture.md @@ -148,9 +148,10 @@ The login result carries two grade bytes, which v83 keeps for the session (`build_successful_login_packet`, reports b156 and a006b): - The grade has bit 0 (a GM's account) when `gm_level` is 3 or more - (`policy::client_gm`). The client then sends the `/` lines it doesn't know - as chat (GM events' `/start`), and refuses to trade or open a store ("Not - available with an Admin account."). + (`policy::client_gm`). The client then has its GM slash commands (below), + sends the `/` lines it doesn't know as chat (GM events' `/start`), and + refuses to trade or open a store ("Not available with an Admin + account."). - With `accounts.test_admin` the grade is 0x11 as well: v83's admin account, which can't drop items ("The admin can't drop items.") or mesos either. Only SQL sets it (`UPDATE accounts SET test_admin = true WHERE ...`); @@ -202,6 +203,30 @@ tier of `gm_level` (`gm_commands::tier`, levels above 3 count as 3): shows that target. `!givenx` and `!coupon` log their results there too. - `RUSTMS_GM_COMMANDS=0` turns every command off, so each line is chat. +### The client's slash commands + +v83's client has GM commands of its own, read from the chat bar before the +players' commands (report b175) and only for the grades that rank a GM: +grade bit 0, or a sub-grade of 0x20, 0x40 or 0x80. The rank (0 may do the +most, 5 is a player's) sets which: `/h` from 4, `/ch`, `/u`, `/w`, `/ban` +and `/uclip` from 3, the broadcasts from 2. Our GMs, with grade bit 0, are +rank 1 and have them all. Each sends its packet, and then ADMIN_LOG (0x81) +with the line as typed, which the server logs under `gm_audit`. + +| Command | Packet | Server | +|---|---|---| +| `/h N` | ADMIN_COMMAND 0x10, hidden when N isn't 0 | helpers: hides or shows the GM, ADMIN_RESULT 0x10 | +| `/w NAME MSG` | ADMIN_COMMAND 0x1E | helpers: an alert (BROADCAST_MSG 1) to NAME, wherever they are; ADMIN_RESULT 0x1E 1, or 0 for no one by that name | +| `/u`, `/uclip` | ADMIN_COMMAND 0x11 0 and 0x0C | nothing yet | +| `/ban NAME` | ADMIN_COMMAND 0x03 | nothing yet | +| `/alertall`, `/alertch`, `/alertmap`, `/noticeall`, `/noticech`, `/noticemap`, `/slideall`, `/slidech`, `/slidemap` TEXT | ADMIN_BROADCAST (0x76): scope (0 world, 1 channel, 2 map), type (0 notice, 1 alert, 4 slide), text | GMs: BROADCAST_MSG of that type to that scope | +| `/slideallx`, `/slidechx`, `/slidemapx` | ADMIN_BROADCAST with no text | GMs: takes the slide down | +| `/slideax` | 0x86 | nothing | +| `/ch N` | the channel menu's request | | + +`/flag` (0x72, rank 4 only), `/block` (ADMIN_COMMAND 0x04, through a +dialog), `/c` and `/clip` aren't in HeavenClient. + ## Packet encoding Structures that recur across packets are encoded once in `net/src/codec/`, diff --git a/integration-harness/tests/gm_slash.rs b/integration-harness/tests/gm_slash.rs new file mode 100644 index 00000000..a516ecae --- /dev/null +++ b/integration-harness/tests/gm_slash.rs @@ -0,0 +1,99 @@ +//! v83's GM slash commands, as the client sends them: `/noticeall` is an +//! ADMIN_BROADCAST the world sees, and `/w` an ADMIN_COMMAND 0x1E warning +//! that pops up for the player named. A player's are ignored. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::set_gm_level; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{barrier, read, send, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use net::packet::op::{RecvOpcode, SendOpcode}; +use packet::io::read::PktRead; +use packet::io::write::PktWrite; +use packet::Packet; +use std::io::Cursor; +use tokio::time::Duration; + +const FIELD: i32 = 101_020_001; + +fn notice_all(text: &str) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::AdminBroadcast as i16).unwrap(); + p.write_byte(0).unwrap(); + p.write_byte(0).unwrap(); + p.write_str_with_length(text).unwrap(); + p +} + +fn warn(name: &str, text: &str) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::AdminCommand as i16).unwrap(); + p.write_byte(0x1E).unwrap(); + p.write_str_with_length(name).unwrap(); + p.write_str_with_length(text).unwrap(); + p +} + +/// The next BROADCAST_MSG `c` receives. +async fn next_broadcast(c: &mut MapleTestConnection) -> (u8, String) { + let mut trace = Trace::default(); + while trace.broadcasts.is_empty() { + read(c, &mut trace).await; + } + trace.broadcasts.remove(0) +} + +/// The value of the next ADMIN_RESULT 0x1E `c` receives. +async fn warn_result(c: &mut MapleTestConnection) -> u8 { + let mut trace = Trace::default(); + loop { + let p = read(c, &mut trace).await; + if p.opcode() == SendOpcode::AdminResult as i16 && p.bytes[2] == 0x1E { + let mut r = Cursor::new(&p.bytes[3..]); + return r.read_byte().unwrap(); + } + } +} + +#[tokio::test] +async fn a_gms_notice_and_warning_reach_players() { + let config = load_harness_config_or_fail().await; + login_to_world_session(&config) + .await + .expect("first login failed"); + drop(relogin(&config).await); + let other_config = load_harness_config_or_fail().await; + login_to_world_session(&other_config) + .await + .expect("second login failed"); + drop(relogin(&other_config).await); + tokio::time::sleep(Duration::from_millis(500)).await; + set_gm_level(&config.username, 3).await.expect("make a GM"); + + let mut gm = relogin(&config).await; + let mut player = relogin(&other_config).await; + let (g, p) = (&mut gm.connection, &mut player.connection); + let (mut gt, mut pt) = (Trace::default(), Trace::default()); + assert_eq!(warp(g, FIELD, "", &mut gt).await, FIELD); + assert_eq!(warp(p, FIELD, "", &mut pt).await, FIELD); + tokio::time::sleep(Duration::from_millis(300)).await; + + // The player's own go nowhere. + let (mut gt, mut pt) = (Trace::default(), Trace::default()); + send(p, notice_all("from a player")).await; + send(p, warn(&config.character_name, "from a player")).await; + barrier(p, "player done", &mut pt).await; + barrier(g, "gm done", &mut gt).await; + assert!(pt.broadcasts.is_empty(), "{:?}", pt.broadcasts); + assert!(gt.broadcasts.is_empty(), "{:?}", gt.broadcasts); + + send(g, notice_all("hello world")).await; + assert_eq!(next_broadcast(p).await, (0, "hello world".to_string())); + + send(g, warn(&other_config.character_name, "stop that")).await; + assert_eq!(next_broadcast(p).await, (1, "stop that".to_string())); + assert_eq!(warn_result(g).await, 1); + + send(g, warn("nobody_here", "stop that")).await; + assert_eq!(warn_result(g).await, 0); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 7c214b73..43ae8996 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -1030,6 +1030,9 @@ fn world_routes() -> &'static HashMap { let table = [ (Op::PlayerLoggedIn, PreGame(&world::PlayerLoggedInHandler)), (Op::AdminCommand, InGame(&world::AdminCommandHandler)), + (Op::AdminLog, InGame(&world::AdminLogHandler)), + (Op::AdminBroadcast, InGame(&world::AdminBroadcastHandler)), + (Op::GmSlide, InGame(&world::GmSlideHandler)), (Op::PlayerMove, InGame(&world::PlayerMoveHandler)), (Op::ChangeChannel, InGame(&world::ChangeChannelHandler)), (Op::ChangeMap, InGame(&world::ChangeMapHandler)), @@ -1269,7 +1272,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 131); + assert_eq!(world_routes().len(), 134); } #[test] diff --git a/net/src/packet/build/world/messaging.rs b/net/src/packet/build/world/messaging.rs index 7ff3f9c4..4f196afa 100644 --- a/net/src/packet/build/world/messaging.rs +++ b/net/src/packet/build/world/messaging.rs @@ -50,6 +50,8 @@ pub enum Broadcast { /// BROADCAST_MSG kinds that carry the sender's channel and ear. const SUPER_MEGAPHONE: u8 = 3; +/// BROADCAST_MSG kind for the scrolling notice, with an on flag first. +const SLIDE: u8 = 4; /// BROADCAST_MSG kind for a chat line that links an item. const ITEM_LINE: u8 = 6; const ITEM_MEGAPHONE: u8 = 8; @@ -65,6 +67,20 @@ pub fn build_broadcast(kind: Broadcast, message: &str) -> Result) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::BroadcastMsg as i16)?; + packet.write_byte(SLIDE)?; + packet.write_byte(u8::from(message.is_some()))?; + if let Some(message) = message { + packet.write_str_with_length(message)?; + } + Ok(packet) +} + /// A line in chat that links an item, or none for 0 (v83 FUN_00a22785 /// reads the item after the message). pub fn build_item_line(message: &str, item_id: i32) -> Result { diff --git a/net/src/packet/build/world/skill.rs b/net/src/packet/build/world/skill.rs index 2389ee30..ec8b3ca0 100644 --- a/net/src/packet/build/world/skill.rs +++ b/net/src/packet/build/world/skill.rs @@ -106,10 +106,16 @@ pub fn build_throw_grenade( /// ADMIN_RESULT 0x10: whether the GM is now hidden. The client keeps the /// byte (FUN_005352e9); others stop seeing the GM through the field. pub fn build_hidden(hidden: bool) -> Result { + build_admin_result(0x10, u8::from(hidden)) +} + +/// ADMIN_RESULT 0x90: how a GM's slash command `mode` went, in one byte +/// (FUN_005352e9). +pub fn build_admin_result(mode: u8, value: u8) -> Result { let mut packet = Packet::new_empty(); packet.write_short(SendOpcode::AdminResult as i16)?; - packet.write_byte(0x10)?; - packet.write_byte(u8::from(hidden))?; + packet.write_byte(mode)?; + packet.write_byte(value)?; Ok(packet) } diff --git a/net/src/packet/handle/world/admin_command.rs b/net/src/packet/handle/world/admin_command.rs index ad87efb9..0595a6dc 100644 --- a/net/src/packet/handle/world/admin_command.rs +++ b/net/src/packet/handle/world/admin_command.rs @@ -1,14 +1,21 @@ -use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult}; +use crate::gm_commands::{tier, GM, HELPER}; +use crate::handler::{ + BroadcastScope, FieldRequest, HandlerResult, WhisperDelivery, WorldAction, WorldContext, + WorldHandler, WorldResult, +}; +use crate::packet::build::world::messaging::{build_broadcast, build_slide, Broadcast}; use crate::packet::build::world::{skill, stat}; use packet::{io::read::PktRead, Packet}; use std::io::Cursor; -use tracing::debug; +use tracing::{debug, info}; /// `/h`: hide (1) or show (0) the GM. const HIDE: u8 = 0x10; /// `/u` (0) lists the map's users; the client also sends 12 on every map /// change. const USERS: u8 = 0x11; +/// `/w NAME TEXT`: a warning, shown to the player in a dialog. +const WARN: u8 = 0x1E; /// The artifact ranking, which does nothing. const ARTIFACT_RANKING: u8 = 0x24; @@ -27,6 +34,11 @@ impl WorldHandler for AdminCommandHandler { match mode { HIDE => set_hidden(ctx.player, reader.read_byte()? == 1), USERS | ARTIFACT_RANKING => Ok(HandlerResult::empty()), + WARN if gated(ctx, HELPER) => { + let name = reader.read_str_with_length()?; + let text = reader.read_str_with_length()?; + warn(ctx, name, &text) + } _ => { debug!(mode, "Admin command not supported"); Ok(HandlerResult::empty()) @@ -35,6 +47,117 @@ impl WorldHandler for AdminCommandHandler { } } +/// Whether the GM may use a command of `needs` tier; the commands that +/// reach others are off with `RUSTMS_GM_COMMANDS=0`, as the chat commands +/// are. +fn gated(ctx: &WorldContext, needs: i16) -> bool { + crate::policy::gm_commands_enabled() && tier(ctx.player.gm_level) >= needs +} + +/// `/w`: `text` in a dialog for the player named `name`, wherever they +/// are; the GM hears whether it got there (SP 0xbb5 or 0xbb4). +fn warn(ctx: &WorldContext, name: String, text: &str) -> WorldResult { + info!( + target: "gm_audit", + character = %ctx.player.stats.name, + gm_level = ctx.player.gm_level, + command = "/w", + target = name, + text, + ); + if name.is_empty() { + return Ok(HandlerResult::reply(skill::build_admin_result(WARN, 0)?)); + } + Ok( + HandlerResult::empty().with(WorldAction::Whisper(WhisperDelivery { + target_name: name, + recipient_packet: build_broadcast(Broadcast::Popup, text)?, + sender_success_packet: Some(skill::build_admin_result(WARN, 1)?), + sender_failure_packet: Some(skill::build_admin_result(WARN, 0)?), + })), + ) +} + +/// ADMIN_LOG 0x81: the line of every GM slash command, which the client +/// sends after the command's own packet (0x52e28f). Logged for the audit; +/// what the command did is logged where it is done. +pub struct AdminLogHandler; + +impl WorldHandler for AdminLogHandler { + fn handle(&self, packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { + if ctx.player.gm_level <= 0 { + return Ok(HandlerResult::empty()); + } + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + let line = reader.read_str_with_length()?; + info!( + target: "gm_audit", + character_id = ctx.player.stats.id, + character = %ctx.player.stats.name, + gm_level = ctx.player.gm_level, + line, + "Slash command" + ); + Ok(HandlerResult::empty()) + } +} + +/// ADMIN_BROADCAST 0x76: `/notice*`, `/alert*` and `/slide*` (report +/// b175 s1): the scope (0 the world, 1 the channel, 2 the map), the kind (0 +/// a notice in chat, 1 a dialog, 4 the scrolling notice) and the text. The +/// `x` slides send no text, which takes the scrolling notice down. +pub struct AdminBroadcastHandler; + +impl WorldHandler for AdminBroadcastHandler { + fn handle(&self, packet: &mut Packet, ctx: &mut WorldContext) -> WorldResult { + if !gated(ctx, GM) { + return Ok(HandlerResult::empty()); + } + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + let scope = reader.read_byte()?; + let kind = reader.read_byte()?; + let text = reader.read_str_with_length()?; + let packet = match kind { + 0 if !text.is_empty() => build_broadcast(Broadcast::Notice, &text)?, + 1 if !text.is_empty() => build_broadcast(Broadcast::Popup, &text)?, + 4 => build_slide(Some(text.as_str()).filter(|text| !text.is_empty()))?, + _ => return Ok(HandlerResult::empty()), + }; + info!( + target: "gm_audit", + character = %ctx.player.stats.name, + gm_level = ctx.player.gm_level, + scope, + kind, + text, + "Broadcast" + ); + let action = |scope| WorldAction::Broadcast { + scope, + packet: packet.clone(), + }; + Ok(match scope { + 0 => HandlerResult::empty().with(action(BroadcastScope::World)), + 1 => HandlerResult::empty().with(action(BroadcastScope::Channel(ctx.channel_id))), + 2 => HandlerResult::reply(packet.clone()) + .with(WorldAction::Field(FieldRequest::Broadcast(packet))), + _ => HandlerResult::empty(), + }) + } +} + +/// GM_SLIDE 0x86 (`/slideax`, report b175 s2): one byte, 0, whose meaning +/// no server of the time gives; taken and dropped. +pub struct GmSlideHandler; + +impl WorldHandler for GmSlideHandler { + fn handle(&self, _: &mut Packet, _: &mut WorldContext) -> WorldResult { + Ok(HandlerResult::empty()) + } +} + /// Hide the GM from those who are not GMs, or show them again; tells the /// GM either way. Shared by the Hide skill, `/h` and `!hide`. pub fn set_hidden(player: &mut game::Player, hidden: bool) -> WorldResult { @@ -95,6 +218,100 @@ mod tests { assert!(!player.hidden && result.actions.is_empty()); } + #[test] + fn a_warning_goes_to_the_player_named_in_a_dialog() { + let mut player = crate::codec::stats::sample_player(); + player.gm_level = 1; + let mut bytes = vec![WARN]; + bytes.extend([4, 0]); + bytes.extend(b"Bob1"); + bytes.extend([5, 0]); + bytes.extend(b"Stop!"); + let result = handle(&mut player, &bytes); + let [WorldAction::Whisper(delivery)] = &result.actions[..] else { + panic!("{:?}", result.actions); + }; + assert_eq!(delivery.target_name, "Bob1"); + assert_eq!( + delivery.recipient_packet.bytes, + build_broadcast(Broadcast::Popup, "Stop!").unwrap().bytes + ); + let answer = |delivered: &Option| delivered.as_ref().unwrap().bytes[2..].to_vec(); + assert_eq!(answer(&delivery.sender_success_packet), [WARN, 1]); + assert_eq!(answer(&delivery.sender_failure_packet), [WARN, 0]); + // No name: v83's "Please enter the user's name" (SP 0xbb4). + let result = handle(&mut player, &[WARN, 0, 0, 2, 0, b'h', b'i']); + assert_eq!(result.actions.len(), 1); + + player.gm_level = 0; + assert!(handle(&mut player, &bytes).actions.is_empty()); + } + + fn broadcast( + player: &mut game::Player, + scope: u8, + kind: u8, + text: &str, + ) -> HandlerResult { + let mut packet = Packet::new_empty(); + packet + .write_short(RecvOpcode::AdminBroadcast as i16) + .unwrap(); + packet.write_byte(scope).unwrap(); + packet.write_byte(kind).unwrap(); + packet.write_str_with_length(text).unwrap(); + let mut ctx = WorldContext { + client_id: 1, + channel_id: 2, + player, + }; + AdminBroadcastHandler.handle(&mut packet, &mut ctx).unwrap() + } + + #[test] + fn broadcasts_reach_their_scope() { + let mut player = crate::codec::stats::sample_player(); + player.gm_level = 3; + let notice = build_broadcast(Broadcast::Notice, "hi").unwrap(); + let result = broadcast(&mut player, 0, 0, "hi"); + let [WorldAction::Broadcast { + scope: BroadcastScope::World, + packet, + }] = &result.actions[..] + else { + panic!("{:?}", result.actions); + }; + assert_eq!(packet.bytes, notice.bytes); + let result = broadcast(&mut player, 1, 1, "hi"); + let [WorldAction::Broadcast { + scope: BroadcastScope::Channel(2), + packet, + }] = &result.actions[..] + else { + panic!("{:?}", result.actions); + }; + assert_eq!( + packet.bytes, + build_broadcast(Broadcast::Popup, "hi").unwrap().bytes + ); + // The map: the GM, and the others through the field. + let result = broadcast(&mut player, 2, 4, ""); + let slide_off = build_slide(None).unwrap(); + let [WorldAction::Reply(own), WorldAction::Field(FieldRequest::Broadcast(theirs))] = + &result.actions[..] + else { + panic!("{:?}", result.actions); + }; + assert_eq!( + (&own.bytes, &theirs.bytes), + (&slide_off.bytes, &slide_off.bytes) + ); + // An empty notice, and anyone below GM, send nothing. + assert!(broadcast(&mut player, 0, 0, "").actions.is_empty()); + player.gm_level = 1; + assert!(broadcast(&mut player, 0, 0, "hi").actions.is_empty()); + } + #[test] fn the_map_change_notice_does_nothing() { let mut player = crate::codec::stats::sample_player(); diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index c4ecc67c..790035c3 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -82,7 +82,9 @@ mod vega; mod weather; mod whisper; -pub use self::admin_command::{set_hidden, AdminCommandHandler}; +pub use self::admin_command::{ + set_hidden, AdminBroadcastHandler, AdminCommandHandler, AdminLogHandler, GmSlideHandler, +}; pub use self::alliance::{AllianceOperationHandler, DenyAllianceRequestHandler}; pub use self::berserk::CalcDamageStatHandler; pub use self::buddy::BuddyListModifyHandler; diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index 52fcb7ec..acb91fb7 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -45,12 +45,18 @@ pub enum RecvOpcode { CloseChalkboard = 0x32, /// A Water of Life was double-clicked (FUN_00a1dce6). WaterOfLife = 0x75, + /// A GM's `/notice*`, `/alert*` or `/slide*` (report b175). + AdminBroadcast = 0x76, MultiChat = 0x77, PartyOperation = 0x7C, AdminCommand = 0x80, + /// The line of each GM slash command, after its packet (0x52e28f). + AdminLog = 0x81, BuddyListModify = 0x82, NoteAction = 0x83, UseDoor = 0x85, + /// A GM's `/slideax` (report b175). + GmSlide = 0x86, /// The Rock, Paper, Scissors window's buttons (report b038). RpsAction = 0x88, /// Proposals with an engagement ring box, by mode (report b052).