feat(game): add a chat console

This commit is contained in:
capyb2222 committed 2026-09-13 10:26:33 -07:00
1 parent 49a9a7d30d
commit 530def48df
7 files changed
+504 -5

No files matched your search

+17
View File
@@ -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
+181
View File
@@ -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 <string>
#include <vector>
#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<uint64_t>(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<int64_t>(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<int64_t>(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<proto::GetFriendListInfoCsReq>(cmd::GetFriendListInfoCsReq, onGetFriendListInfo);
net::on<proto::GetFriendLoginInfoCsReq>(cmd::GetFriendLoginInfoCsReq, onGetFriendLoginInfo);
net::on<proto::GetChatEmojiListCsReq>(cmd::GetChatEmojiListCsReq, onGetChatEmojiList);
net::on<proto::GetChatFriendHistoryCsReq>(cmd::GetChatFriendHistoryCsReq, onGetChatFriendHistory);
net::on<proto::GetLoginChatInfoCsReq>(cmd::GetLoginChatInfoCsReq, onGetLoginChatInfo);
net::on<proto::GetPrivateChatHistoryCsReq>(cmd::GetPrivateChatHistoryCsReq, onGetPrivateChatHistory);
net::on<proto::SendMsgCsReq>(cmd::SendMsgCsReq, onSendMsg);
}
} // namespace game
+162
View File
@@ -0,0 +1,162 @@
#include "game/command.h"
#include <algorithm>
#include <charconv>
#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<std::string_view>;
using Reply = std::vector<std::string>;
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 <item id> [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<data::ItemStack> 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<int64_t>(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 <entrance id>"};
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", "<item id> [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", "<entrance id>", "move to an entrance"}, onTp},
{{"where", "", "uid, world level and where you are standing"}, onWhere},
};
} // namespace
const std::vector<Entry>& list() {
static const std::vector<Entry> entries = [] {
std::vector<Entry> out;
for (const Command& command : kCommands) out.push_back(command.entry);
return out;
}();
return entries;
}
std::vector<std::string> 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
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
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<Entry>& 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<std::string> run(net::Session& session, std::string_view line);
} // namespace command
} // namespace game
+1
View File
@@ -26,6 +26,7 @@ void registerPeakHandlers();
void registerGachaHandlers();
void registerShopHandlers();
void registerCollectionHandlers();
void registerChatHandlers();
void registerModuleHandlers();
void registerMiscHandlers();
+1 -5
View File
@@ -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",
+104
View File
@@ -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<std::string> 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<std::string> 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<std::shared_ptr<net::Session>> live = gateway.sessions();
if (live.empty() || live[0]->player() == nullptr) return 0;
return static_cast<uint32_t>(game::inventory::held(*live[0]->player(), itemId));
};
uint32_t heldBefore = bagCount(101);
std::vector<std::string> 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<std::string> 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<std::string> 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);