From 530def48df375733f2dec551ebd0b533f937ff8c Mon Sep 17 00:00:00 2001 From: capyb2222 Date: Sun, 13 Sep 2026 10:26:33 -0700 Subject: [PATCH] feat(game): add a chat console --- proto/seeds.txt | 17 ++++ src/game/chat_handlers.cpp | 181 +++++++++++++++++++++++++++++++++++++ src/game/command.cpp | 162 +++++++++++++++++++++++++++++++++ src/game/command.h | 38 ++++++++ src/game/handlers.h | 1 + src/game/misc_handlers.cpp | 6 +- tests/test_flow.cpp | 104 +++++++++++++++++++++ 7 files changed, 504 insertions(+), 5 deletions(-) create mode 100644 src/game/chat_handlers.cpp create mode 100644 src/game/command.cpp create mode 100644 src/game/command.h diff --git a/proto/seeds.txt b/proto/seeds.txt index d8d4a5e..110531f 100644 --- a/proto/seeds.txt +++ b/proto/seeds.txt @@ -274,6 +274,23 @@ GetMarkItemListScRsp GetBigDataAllRecommendCsReq GetBigDataAllRecommendScRsp +# ---- friends / chat: the CapySR console ---- +GetFriendListInfoCsReq +GetFriendListInfoScRsp +GetFriendLoginInfoCsReq +GetFriendLoginInfoScRsp +GetChatEmojiListCsReq +GetChatEmojiListScRsp +GetChatFriendHistoryCsReq +GetChatFriendHistoryScRsp +GetLoginChatInfoCsReq +GetLoginChatInfoScRsp +GetPrivateChatHistoryCsReq +GetPrivateChatHistoryScRsp +SendMsgCsReq +SendMsgScRsp +RevcMsgScNotify + # ---- mission / tutorial ---- GetMissionStatusCsReq GetMissionStatusScRsp diff --git a/src/game/chat_handlers.cpp b/src/game/chat_handlers.cpp new file mode 100644 index 0000000..8f2d4a6 --- /dev/null +++ b/src/game/chat_handlers.cpp @@ -0,0 +1,181 @@ +// The CapySR console, as a friend. There is no GM panel on a private server, so the +// server puts itself in the friend list and reads whatever is typed at it: a line +// starting with '/' runs as a command, anything else is echoed back with a nudge. +#include +#include + +#include "core/config.h" +#include "core/logger.h" +#include "core/util.h" +#include "game/command.h" +#include "game/handlers.h" +#include "game/player.h" +#include "net/cmd_ids.h" +#include "net/handler.h" +#include "net/session.h" +#include "proto/gen/protos.h" + +namespace game { +namespace { + +constexpr uint32_t kConsoleUid = command::kConsoleUid; + +// One line of chat, attributed to `senderUid`. +proto::ChatMessageData chatLine(const std::string& text, uint32_t senderUid) { + proto::ChatMessageData message; + message.create_time = static_cast(util::nowSec()); + proto::MessageChatData& data = message.message_datas.emplace_back(); + data.message_type = proto::MsgType::MsgType_CustomText; + // A oneof scalar is only written when its case is set; without this the line goes + // out empty and the client shows a blank bubble. + proto::ChatData& chat = data.chat_data.emplace(); + chat.message_text = text; + chat.PGBLIFDKBHK_case = proto::ChatData::k_message_text; + + // Two unnamed fields carry the same {role type, uid} pair and both have to name the + // sender, in history and in a live push alike. Filling one renders nothing at all. + proto::EKNABKLPEEL side; + side.KPOBMNLKLOK = proto::HCMEILLLKBD::HCMEILLLKBD_JDOAIPKBIPE; + side.role_id = senderUid; + message.CKHPFFENOBE = side; + message.BKOALKHDLOB = side; + return message; +} + +void say(net::Session& session, const std::string& text) { + const Player* player = session.player(); + if (player == nullptr) return; + + proto::RevcMsgScNotify notify; + notify.chat_type = proto::ChatType::ChatType_Private; + // Named `source_uid` in the dump, but it is the side the message is *for*. With the + // sender's uid in it the client has no thread to file the line under, and shows + // nothing. + notify.source_uid = player->uid(); + notify.recv_message_data = chatLine(text, kConsoleUid); + session.send(cmd::RevcMsgScNotify, notify); +} + +// The client does not draw the line it just sent -- it waits to be handed it back. So +// the player's own message goes home the same way, from the player, addressed to the +// console. +void echo(net::Session& session, const std::string& text) { + const Player* player = session.player(); + if (player == nullptr) return; + + proto::RevcMsgScNotify notify; + notify.chat_type = proto::ChatType::ChatType_Private; + notify.source_uid = kConsoleUid; + notify.recv_message_data = chatLine(text, player->uid()); + session.send(cmd::RevcMsgScNotify, notify); +} + +void onGetFriendListInfo(net::Session& session, const proto::GetFriendListInfoCsReq&) { + const core::PlayerDefaults& defaults = core::Config::get().player; + proto::GetFriendListInfoScRsp rsp; + rsp.retcode = 0; + + proto::FriendSimpleInfo& friendInfo = rsp.friend_list.emplace_back(); + friendInfo.remark_name = "CapySR"; + friendInfo.is_marked = true; + friendInfo.create_time = 0; + friendInfo.playing_state = proto::PlayingState::PlayingState_None; + proto::PlayerSimpleInfo& info = friendInfo.player_info.emplace(); + info.uid = kConsoleUid; + info.nickname = "CapySR"; + info.signature = "type /help for the command list"; + info.level = defaults.level; + info.head_icon = defaults.headIcon; + info.online_status = proto::FriendOnlineStatus::FRIEND_ONLINE_STATUS_ONLINE; + info.last_active_time = static_cast(util::nowSec()); + // Without these two the client drops the entry from its own list the next time the + // friend screen opens, and never asks for the list again. + info.platform = proto::PlatformType::PC; + info.chat_bubble_id = 220005; + + session.send(cmd::GetFriendListInfoScRsp, rsp); +} + +void onGetFriendLoginInfo(net::Session& session, const proto::GetFriendLoginInfoCsReq&) { + proto::GetFriendLoginInfoScRsp rsp; + rsp.retcode = 0; + rsp._friend_uid_list.push_back(kConsoleUid); + rsp.is_allow_other_apply = true; + session.send(cmd::GetFriendLoginInfoScRsp, rsp); +} + +void onGetChatEmojiList(net::Session& session, const proto::GetChatEmojiListCsReq&) { + proto::GetChatEmojiListScRsp rsp; + rsp.retcode = 0; + session.send(cmd::GetChatEmojiListScRsp, rsp); +} + +// The chat tab's conversation list. Without the console in it the thread exists but +// there is nothing to tap to open it. +void onGetChatFriendHistory(net::Session& session, const proto::GetChatFriendHistoryCsReq&) { + proto::GetChatFriendHistoryScRsp rsp; + rsp.retcode = 0; + proto::FriendHistoryInfo& entry = rsp.friend_history_info.emplace_back(); + entry.contact_side = kConsoleUid; + entry.last_send_time = static_cast(util::nowSec()); + session.send(cmd::GetChatFriendHistoryScRsp, rsp); +} + +void onGetLoginChatInfo(net::Session& session, const proto::GetLoginChatInfoCsReq&) { + proto::GetLoginChatInfoScRsp rsp; + rsp.retcode = 0; + rsp.contact_id_list.push_back(kConsoleUid); + session.send(cmd::GetLoginChatInfoScRsp, rsp); +} + +void onGetPrivateChatHistory(net::Session& session, const proto::GetPrivateChatHistoryCsReq& req) { + proto::GetPrivateChatHistoryScRsp rsp; + rsp.retcode = 0; + rsp.target_side = req.target_side; + rsp.contact_side = kConsoleUid; + if (req.contact_side == kConsoleUid || req.target_side == kConsoleUid) { + for (const char* line : {"CapySR console.", "Type /help for the command list."}) { + rsp.chat_message_list.push_back(chatLine(line, kConsoleUid)); + } + } + session.send(cmd::GetPrivateChatHistoryScRsp, rsp); +} + +void onSendMsg(net::Session& session, const proto::SendMsgCsReq& req) { + std::string text; + if (req.message_datas && req.message_datas->chat_data) { + text = req.message_datas->chat_data->message_text; + } + bool toConsole = req.target_list.empty() || + std::find(req.target_list.begin(), req.target_list.end(), kConsoleUid) != + req.target_list.end(); + + // The replies go out before the ScRsp: the client stops listening for the thread + // once the request it sent has been answered. + if (toConsole && !text.empty()) { + echo(session, text); + if (text.front() == '/') { + for (const std::string& line : command::run(session, text)) say(session, line); + } else { + say(session, "type /help for the command list"); + } + } + + proto::SendMsgScRsp rsp; + rsp.retcode = 0; + session.send(cmd::SendMsgScRsp, rsp); +} + +} // namespace + +void registerChatHandlers() { + net::on(cmd::GetFriendListInfoCsReq, onGetFriendListInfo); + net::on(cmd::GetFriendLoginInfoCsReq, onGetFriendLoginInfo); + net::on(cmd::GetChatEmojiListCsReq, onGetChatEmojiList); + net::on(cmd::GetChatFriendHistoryCsReq, onGetChatFriendHistory); + net::on(cmd::GetLoginChatInfoCsReq, onGetLoginChatInfo); + net::on(cmd::GetPrivateChatHistoryCsReq, onGetPrivateChatHistory); + net::on(cmd::SendMsgCsReq, onSendMsg); +} + +} // namespace game diff --git a/src/game/command.cpp b/src/game/command.cpp new file mode 100644 index 0000000..1bb3b47 --- /dev/null +++ b/src/game/command.cpp @@ -0,0 +1,162 @@ +#include "game/command.h" + +#include +#include + +#include "core/logger.h" +#include "core/util.h" +#include "data/excel.h" +#include "game/inventory.h" +#include "game/player.h" +#include "game/rescue.h" +#include "net/cmd_ids.h" +#include "net/session.h" +#include "proto/gen/protos.h" + +namespace game::command { +namespace { + +using Args = std::vector; +using Reply = std::vector; + +Args split(std::string_view line) { + Args out; + size_t i = 0; + while (i < line.size()) { + while (i < line.size() && line[i] == ' ') ++i; + size_t start = i; + while (i < line.size() && line[i] != ' ') ++i; + if (i > start) out.push_back(line.substr(start, i - start)); + } + return out; +} + +// Parses a whole number, or nothing at all: a command must not act on a half-read id. +bool number(std::string_view text, uint32_t& out) { + if (text.empty()) return false; + uint32_t value = 0; + auto [end, ec] = std::from_chars(text.data(), text.data() + text.size(), value); + if (ec != std::errc{} || end != text.data() + text.size()) return false; + out = value; + return true; +} + +Reply onHelp(net::Session&, Player&, const Args&) { + Reply reply{"CapySR console. Commands:"}; + for (const Entry& entry : list()) { + std::string line = "/"; + line += entry.name; + if (!entry.usage.empty()) { + line += ' '; + line += entry.usage; + } + line += " - "; + line += entry.help; + reply.push_back(std::move(line)); + } + return reply; +} + +Reply onGive(net::Session& session, Player& player, const Args& args) { + uint32_t itemId = 0; + uint32_t count = 1; + if (args.empty() || !number(args[0], itemId)) return {"usage: /give [count]"}; + if (args.size() > 1 && !number(args[1], count)) return {"that count is not a number"}; + if (count == 0) return {"nothing to give"}; + + const data::ItemInfo* item = data::Tables::get().item(itemId); + bool wallet = itemId == inventory::kStellarJade || itemId == inventory::kCredit || + itemId == inventory::kOneiricShard; + if (item == nullptr && !wallet) return {"no item " + std::to_string(itemId)}; + + std::vector items{{itemId, count}}; + inventory::grant(player, items); + session.send(cmd::PlayerSyncScNotify, inventory::sync(player, items)); + player.saveNow(); + + std::string held = std::to_string(inventory::held(player, itemId)); + return {"gave " + std::to_string(count) + " x " + std::to_string(itemId) + ", you now hold " + held}; +} + +Reply onRefill(net::Session& session, Player& player, const Args&) { + const data::StaminaRules& rules = data::Tables::get().staminaRules(); + Inventory& bag = player.inventory(); + if (bag.stamina >= rules.max) return {"Trailblaze Power is already full"}; + + bag.stamina = rules.max; + bag.staminaUpdatedAt = static_cast(util::nowSec()); + session.send(cmd::StaminaInfoScNotify, inventory::staminaInfo(player)); + session.send(cmd::PlayerSyncScNotify, inventory::sync(player, {})); + player.saveNow(); + return {"Trailblaze Power refilled to " + std::to_string(rules.max)}; +} + +Reply onUnstuck(net::Session& session, Player&, const Args&) { + return {rescue::unstick(session, 0)}; +} + +Reply onTp(net::Session& session, Player&, const Args& args) { + uint32_t entryId = 0; + if (args.empty() || !number(args[0], entryId)) return {"usage: /tp "}; + if (data::Tables::get().entrances().count(entryId) == 0) { + return {"no entrance " + std::to_string(entryId)}; + } + return {rescue::unstick(session, entryId)}; +} + +Reply onWhere(net::Session&, Player& player, const Args&) { + const SceneLocation& at = player.location(); + const Position& pos = player.position(); + return {"uid " + std::to_string(player.uid()) + ", world level " + std::to_string(player.worldLevel()), + "entry " + std::to_string(at.entryId) + ", plane " + std::to_string(at.planeId) + ", floor " + + std::to_string(at.floorId), + "at " + std::to_string(pos.x / 1000) + " " + std::to_string(pos.y / 1000) + " " + + std::to_string(pos.z / 1000)}; +} + +using Handler = Reply (*)(net::Session&, Player&, const Args&); + +struct Command { + Entry entry; + Handler run; +}; + +const Command kCommands[] = { + {{"help", "", "this list"}, onHelp}, + {{"give", " [count]", "put items in the bag"}, onGive}, + {{"refill", "", "Trailblaze Power back to full"}, onRefill}, + {{"unstuck", "", "end whatever the client is waiting on and redraw the scene"}, onUnstuck}, + {{"tp", "", "move to an entrance"}, onTp}, + {{"where", "", "uid, world level and where you are standing"}, onWhere}, +}; + +} // namespace + +const std::vector& list() { + static const std::vector entries = [] { + std::vector out; + for (const Command& command : kCommands) out.push_back(command.entry); + return out; + }(); + return entries; +} + +std::vector run(net::Session& session, std::string_view line) { + Player* player = session.player(); + if (player == nullptr) return {"not logged in yet"}; + + if (!line.empty() && line.front() == '/') line.remove_prefix(1); + Args args = split(line); + if (args.empty()) return {"type /help for the command list"}; + + std::string_view name = args.front(); + args.erase(args.begin()); + for (const Command& command : kCommands) { + if (command.entry.name != name) continue; + logging::info("command", "uid {} ran /{}", player->uid(), name); + return command.run(session, *player, args); + } + return {"no command /" + std::string(name) + ", type /help for the list"}; +} + +} // namespace game::command diff --git a/src/game/command.h b/src/game/command.h new file mode 100644 index 0000000..8ec2b3d --- /dev/null +++ b/src/game/command.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include + +namespace net { +class Session; +} + +namespace game { + +// The chat console. A private server has no GM panel, so the one place a player can +// reach the server with a keyboard is the chat box: `src/game/chat_handlers.cpp` puts a +// friend named CapySR in the friend list and routes whatever is typed at it through +// here. +namespace command { + +// The console's own uid. Anything but the player's, so the client files the thread +// under a contact of its own. +constexpr uint32_t kConsoleUid = 2000; + +struct Entry { + std::string_view name; + std::string_view usage; // arguments only, empty for a command that takes none + std::string_view help; +}; + +// Every command, in the order /help lists them. +const std::vector& list(); + +// Runs one typed line, with or without its leading '/', and answers with the lines to +// send back. Never empty: an unknown command answers with the complaint to show. +std::vector run(net::Session& session, std::string_view line); + +} // namespace command +} // namespace game diff --git a/src/game/handlers.h b/src/game/handlers.h index 2ad296e..712e77f 100644 --- a/src/game/handlers.h +++ b/src/game/handlers.h @@ -26,6 +26,7 @@ void registerPeakHandlers(); void registerGachaHandlers(); void registerShopHandlers(); void registerCollectionHandlers(); +void registerChatHandlers(); void registerModuleHandlers(); void registerMiscHandlers(); diff --git a/src/game/misc_handlers.cpp b/src/game/misc_handlers.cpp index ef8791a..e6c110e 100644 --- a/src/game/misc_handlers.cpp +++ b/src/game/misc_handlers.cpp @@ -25,12 +25,8 @@ constexpr uint16_t kEmptyReplies[] = { cmd::GetAllServerPrefsDataCsReq, cmd::GetQuestRecordCsReq, cmd::GetRogueInfoCsReq, - cmd::GetFriendListInfoCsReq, cmd::GetFriendApplyListInfoCsReq, - cmd::GetFriendLoginInfoCsReq, cmd::GetFriendRecommendListInfoCsReq, - cmd::GetChatEmojiListCsReq, - cmd::GetPrivateChatHistoryCsReq, cmd::GetPhoneDataCsReq, cmd::GetMailCsReq, cmd::GetSecretKeyInfoCsReq, @@ -47,7 +43,6 @@ constexpr uint16_t kEmptyReplies[] = { cmd::GetRaidInfoCsReq, cmd::GetMuseumInfoCsReq, cmd::TextJoinQueryCsReq, - cmd::GetLoginChatInfoCsReq, cmd::QueryProductInfoCsReq, cmd::GetLoginActivityCsReq, cmd::GetTrialActivityDataCsReq, @@ -209,6 +204,7 @@ void registerAllHandlers() { registerGachaHandlers(); registerShopHandlers(); registerCollectionHandlers(); + registerChatHandlers(); registerModuleHandlers(); registerMiscHandlers(); logging::info("game", "{} packet handlers registered, {} answered empty", diff --git a/tests/test_flow.cpp b/tests/test_flow.cpp index 93ebc27..8bbc0db 100644 --- a/tests/test_flow.cpp +++ b/tests/test_flow.cpp @@ -12,12 +12,16 @@ #include "core/config.h" #include "core/util.h" +#include "game/command.h" #include "game/handlers.h" +#include "game/inventory.h" #include "game/lineup.h" +#include "game/player.h" #include "net/cmd_ids.h" #include "http/http_server.h" #include "net/gateway.h" #include "net/packet.h" +#include "net/session.h" #include "net/socket.h" #include "proto/gen/protos.h" #include "sdk/admin.h" @@ -1177,6 +1181,106 @@ void runFlowTests() { proto::MessageGroupStatus::MessageGroupStatus_MessageGroupFinish, "phone conversations read as finished"); + // The CapySR console: a friend to type commands at. + client.sendEmpty(cmd::GetFriendListInfoCsReq); + proto::GetFriendListInfoScRsp friends; + check(client.await(cmd::GetFriendListInfoScRsp, packet) && parseBody(packet, friends) && + friends.friend_list.size() == 1 && friends.friend_list[0].player_info && + friends.friend_list[0].player_info->uid == game::command::kConsoleUid && + friends.friend_list[0].player_info->nickname == "CapySR", + "the CapySR console is in the friend list"); + + proto::GetPrivateChatHistoryCsReq history; + history.contact_side = game::command::kConsoleUid; + client.send(cmd::GetPrivateChatHistoryCsReq, history); + proto::GetPrivateChatHistoryScRsp opened; + check(client.await(cmd::GetPrivateChatHistoryScRsp, packet) && parseBody(packet, opened) && + opened.retcode == 0 && !opened.chat_message_list.empty(), + "and opening the thread shows how to use it"); + + // Says what the console answered, one line per RevcMsgScNotify. `lastSaid` keeps the + // envelope of the final one so the routing fields can be checked too. + proto::RevcMsgScNotify lastSaid; + proto::RevcMsgScNotify echoed; + auto typed = [&](const std::string& text) { + proto::SendMsgCsReq msg; + msg.target_list.push_back(game::command::kConsoleUid); + msg.chat_type = proto::ChatType::ChatType_Private; + proto::MessageChatData& data = msg.message_datas.emplace(); + data.message_type = proto::MsgType::MsgType_CustomText; + proto::ChatData& chat = data.chat_data.emplace(); + chat.message_text = text; + chat.PGBLIFDKBHK_case = proto::ChatData::k_message_text; + client.send(cmd::SendMsgCsReq, msg); + + std::vector lines; + net::Packet reply; + proto::SendMsgScRsp sent; + if (!client.await(cmd::SendMsgScRsp, reply) || !parseBody(reply, sent) || sent.retcode != 0) { + return lines; + } + while (client.sawNotify(cmd::RevcMsgScNotify)) { + proto::RevcMsgScNotify said; + if (!client.await(cmd::RevcMsgScNotify, reply) || !parseBody(reply, said)) break; + if (said.recv_message_data && !said.recv_message_data->message_datas.empty() && + said.recv_message_data->message_datas[0].chat_data) { + // The player's own line comes back too, so the client can draw it; only + // the console's own answers are counted here. + bool fromConsole = said.recv_message_data->BKOALKHDLOB && + said.recv_message_data->BKOALKHDLOB->role_id == game::command::kConsoleUid; + if (fromConsole) { + lines.push_back(said.recv_message_data->message_datas[0].chat_data->message_text); + lastSaid = said; + } else { + echoed = said; + } + } + } + return lines; + }; + + std::vector helped = typed("/help"); + check(helped.size() == game::command::list().size() + 1 && + helped[0].find("CapySR console") != std::string::npos, + "/help answers with every command"); + + // The three fields that decide whether the client shows the line at all. `source_uid` + // is the side the message is for -- the player -- and both unnamed role fields have + // to name the sender. Get any of them wrong and the reply arrives and renders + // nothing, which is exactly what happened in game while this test still passed. + check(lastSaid.source_uid == core::Config::get().player.uid && lastSaid.recv_message_data && + lastSaid.recv_message_data->CKHPFFENOBE && lastSaid.recv_message_data->BKOALKHDLOB && + lastSaid.recv_message_data->CKHPFFENOBE->role_id == game::command::kConsoleUid && + lastSaid.recv_message_data->BKOALKHDLOB->role_id == game::command::kConsoleUid, + "and addresses them to the player, from the console"); + + check(echoed.source_uid == game::command::kConsoleUid && echoed.recv_message_data && + echoed.recv_message_data->BKOALKHDLOB && + echoed.recv_message_data->BKOALKHDLOB->role_id == core::Config::get().player.uid && + !echoed.recv_message_data->message_datas.empty() && + echoed.recv_message_data->message_datas[0].chat_data && + echoed.recv_message_data->message_datas[0].chat_data->message_text == "/help", + "the player's own line is handed back so the client can draw it"); + + auto bagCount = [&](uint32_t itemId) -> uint32_t { + std::vector> live = gateway.sessions(); + if (live.empty() || live[0]->player() == nullptr) return 0; + return static_cast(game::inventory::held(*live[0]->player(), itemId)); + }; + uint32_t heldBefore = bagCount(101); + std::vector gave = typed("/give 101 7"); + check(gave.size() == 1 && gave[0].find("gave 7") != std::string::npos && + bagCount(101) == heldBefore + 7, + "/give puts the items in the bag"); + + std::vector nowhere = typed("/nope"); + check(nowhere.size() == 1 && nowhere[0].find("no command") != std::string::npos, + "and an unknown command says so instead of going quiet"); + + std::vector chatter = typed("hello"); + check(chatter.size() == 1 && chatter[0].find("/help") != std::string::npos, + "plain chat is nudged towards /help"); + // An unimplemented request still has to complete, or the client hangs on it. This // one has no handler at all, so it exercises the name-derived fallback. client.sendEmpty(cmd::GetStarFightDataCsReq);