Files
CapySR/tests/test_flow.cpp
T

1332 lines
64 KiB
C++

// Drives the real gateway over a real socket: handshake, KCP, framing, handlers.
// Everything below the game logic is the same code the client talks to.
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <string>
#include <thread>
#include <vector>
#include <ikcp.h>
#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"
#include "tests/harness.h"
namespace {
using testing::check;
constexpr uint16_t kTestPort = 23399;
constexpr uint16_t kAdminPort = 21098;
constexpr int kMtu = 1400;
constexpr uint32_t kConnectHead = 0x000000FF;
constexpr uint32_t kConnectTail = 0xFFFFFFFF;
constexpr uint32_t kAcceptHead = 0x00000145;
void writeBE32(char* p, uint32_t v) {
p[0] = static_cast<char>((v >> 24) & 0xFF);
p[1] = static_cast<char>((v >> 16) & 0xFF);
p[2] = static_cast<char>((v >> 8) & 0xFF);
p[3] = static_cast<char>(v & 0xFF);
}
uint32_t readBE32(const char* p) {
const uint8_t* u = reinterpret_cast<const uint8_t*>(p);
return (static_cast<uint32_t>(u[0]) << 24) | (static_cast<uint32_t>(u[1]) << 16) |
(static_cast<uint32_t>(u[2]) << 8) | static_cast<uint32_t>(u[3]);
}
// The client half of the protocol, just enough of it to hold a conversation.
class TestClient {
public:
~TestClient() {
if (kcp_ != nullptr) ikcp_release(kcp_);
if (socket_ != net::kInvalidSocket) net::closeSocket(socket_);
}
bool connect(uint16_t port) {
net::initSockets();
socket_ = net::udpBind("127.0.0.1", 0);
if (socket_ == net::kInvalidSocket) return false;
net::setRecvTimeout(socket_, 200);
server_.port = port;
net::UdpAddress local;
// 127.0.0.1 in network byte order.
uint8_t loopback[4] = {127, 0, 0, 1};
std::memcpy(&server_.ip, loopback, 4);
(void)local;
char handshake[20];
writeBE32(handshake, kConnectHead);
writeBE32(handshake + 4, 0);
writeBE32(handshake + 8, 0);
writeBE32(handshake + 12, 1234);
writeBE32(handshake + 16, kConnectTail);
for (int attempt = 0; attempt < 20; ++attempt) {
net::udpSend(socket_, handshake, sizeof(handshake), server_);
char reply[64];
net::UdpAddress from;
int n = net::udpRecv(socket_, reply, sizeof(reply), &from);
if (n != 20) continue;
if (readBE32(reply) != kAcceptHead) continue;
conv_ = readBE32(reply + 4);
token_ = readBE32(reply + 8);
break;
}
if (conv_ == 0) return false;
kcp_ = ikcp_create(conv_, this);
ikcp_settoken(kcp_, token_);
ikcp_setoutput(kcp_, &TestClient::output);
ikcp_nodelay(kcp_, 1, 10, 2, 1);
ikcp_wndsize(kcp_, 256, 256);
ikcp_setmtu(kcp_, kMtu);
return true;
}
template <class T>
void send(uint16_t cmdId, const T& message) {
queue(net::encodePacket(cmdId, {}, message.serialize()));
}
void sendEmpty(uint16_t cmdId) { queue(net::encodePacket(cmdId, {}, {})); }
// ikcp_flush stamps segments from kcp->current, which only ikcp_update sets, so
// never flush without updating first -- the same rule the server follows.
void queue(const std::string& packet) {
ikcp_send(kcp_, packet.data(), static_cast<int>(packet.size()));
ikcp_update(kcp_, static_cast<uint32_t>(util::nowMs()));
ikcp_flush(kcp_);
}
// Pumps until a packet with `cmdId` arrives; everything else is queued for later.
bool await(uint16_t cmdId, net::Packet& out, int timeoutMs = 4000) {
uint64_t deadline = util::nowMs() + static_cast<uint64_t>(timeoutMs);
while (true) {
for (size_t i = 0; i < inbox_.size(); ++i) {
if (inbox_[i].cmdId != cmdId) continue;
out = std::move(inbox_[i]);
inbox_.erase(inbox_.begin() + static_cast<long>(i));
return true;
}
if (util::nowMs() > deadline) return false;
pump();
}
}
bool sawNotify(uint16_t cmdId) {
for (const net::Packet& packet : inbox_) {
if (packet.cmdId == cmdId) return true;
}
return false;
}
// "waited for X, got Y and Z" is the difference between a one-line diagnosis and
// a guessing game when a handler answers on the wrong cmd id.
void reportInbox(uint16_t wanted) const {
std::printf(" waited for %s (%u), inbox held:", cmd::name(wanted).c_str(), wanted);
if (inbox_.empty()) std::printf(" nothing");
for (const net::Packet& packet : inbox_) {
std::printf(" %s(%u)", cmd::name(packet.cmdId).c_str(), packet.cmdId);
}
std::printf("\n");
}
void pump() {
ikcp_update(kcp_, static_cast<uint32_t>(util::nowMs()));
char datagram[2048];
net::UdpAddress from;
int n = net::udpRecv(socket_, datagram, sizeof(datagram), &from);
if (n > 0) ikcp_input(kcp_, datagram, n);
while (true) {
int peek = ikcp_peeksize(kcp_);
if (peek <= 0) break;
std::vector<char> buffer(static_cast<size_t>(peek));
int got = ikcp_recv(kcp_, buffer.data(), peek);
if (got <= 0) break;
std::vector<net::Packet> packets;
std::string error;
if (!net::decodePackets(std::string_view(buffer.data(), static_cast<size_t>(got)),
packets, &error)) {
std::printf("FAIL client could not decode a packet (%s)\n", error.c_str());
break;
}
for (net::Packet& packet : packets) inbox_.push_back(std::move(packet));
}
}
private:
static int output(const char* buf, int len, IKCPCB* kcp, void* user) {
(void)kcp;
auto* self = static_cast<TestClient*>(user);
net::udpSend(self->socket_, buf, static_cast<size_t>(len), self->server_);
return 0;
}
uintptr_t socket_ = net::kInvalidSocket;
net::UdpAddress server_;
IKCPCB* kcp_ = nullptr;
uint32_t conv_ = 0;
uint32_t token_ = 0;
std::vector<net::Packet> inbox_;
};
// Minimal HTTP/1.1 GET, for the admin routes.
std::string httpGet(uint16_t port, const std::string& target) {
uintptr_t sock = net::tcpConnect("127.0.0.1", port);
if (sock == net::kInvalidSocket) return {};
std::string req = "GET " + target + " HTTP/1.1\r\nHost: 127.0.0.1\r\n";
req += "Content-Length: 0\r\nConnection: close\r\n\r\n";
net::tcpSendAll(sock, req.data(), req.size());
std::string reply;
char buffer[4096];
while (true) {
int n = net::tcpRecv(sock, buffer, sizeof(buffer));
if (n <= 0) break;
reply.append(buffer, static_cast<size_t>(n));
}
net::closeSocket(sock);
return reply;
}
template <class T>
bool parseBody(const net::Packet& packet, T& out) {
return out.parse(reinterpret_cast<const uint8_t*>(packet.body.data()), packet.body.size());
}
} // namespace
void runFlowTests() {
// Keep the test off the real save file, and start it fresh: stamina and claimed
// rewards carry over between runs otherwise.
core::Config::get().paths.playerFile = "build/test-player.json";
std::error_code ec;
std::filesystem::remove(core::Config::get().paths.playerFile, ec);
game::registerAllHandlers();
net::Gateway gateway;
if (!gateway.start("127.0.0.1", kTestPort)) {
std::printf("SKIP flow tests (udp %d is busy)\n", kTestPort);
return;
}
TestClient client;
if (!client.connect(kTestPort)) {
std::printf("FAIL flow tests could not complete the handshake\n");
gateway.stop();
return;
}
check(true, "kcp handshake");
net::Packet packet;
proto::PlayerGetTokenCsReq tokenReq;
tokenReq.account_uid = "capybara";
client.send(cmd::PlayerGetTokenCsReq, tokenReq);
if (!client.await(cmd::PlayerGetTokenScRsp, packet)) {
std::printf("FAIL no PlayerGetTokenScRsp\n");
gateway.stop();
return;
}
proto::PlayerGetTokenScRsp tokenRsp;
check(parseBody(packet, tokenRsp), "token response parses");
check(tokenRsp.retcode == 0, "token accepted");
check(tokenRsp.uid == core::Config::get().player.uid, "token carries the configured uid");
proto::PlayerLoginCsReq loginReq;
loginReq.login_random = 0x1122334455667788ull;
loginReq.client_res_version = 51;
client.send(cmd::PlayerLoginCsReq, loginReq);
check(client.await(cmd::PlayerLoginScRsp, packet), "login answered");
proto::PlayerLoginScRsp loginRsp;
check(parseBody(packet, loginRsp), "login response parses");
check(loginRsp.retcode == 0, "login accepted");
check(loginRsp.login_random == loginReq.login_random, "login_random echoed");
check(loginRsp.basic_info && loginRsp.basic_info->nickname == core::Config::get().player.name,
"basic info carries the player name");
client.sendEmpty(cmd::PlayerLoginFinishCsReq);
check(client.await(cmd::PlayerLoginFinishScRsp, packet), "login finish answered");
check(client.sawNotify(cmd::ContentPackageSyncDataScNotify), "content packages pushed");
// Avatars, gear and the party.
proto::GetAvatarDataCsReq avatarReq;
avatarReq.is_get_all = true;
client.send(cmd::GetAvatarDataCsReq, avatarReq);
check(client.await(cmd::GetAvatarDataScRsp, packet), "avatar data answered");
proto::GetAvatarDataScRsp avatarRsp;
check(parseBody(packet, avatarRsp), "avatar data parses");
check(avatarRsp.is_get_all, "is_get_all echoed");
check(!avatarRsp.avatar_list.empty(), "the roster is not empty");
check(!avatarRsp.avatar_path_data_info_list.empty(), "path data is sent too");
client.sendEmpty(cmd::GetBagCsReq);
check(client.await(cmd::GetBagScRsp, packet), "bag answered");
proto::GetBagScRsp bagRsp;
check(parseBody(packet, bagRsp), "bag parses");
check(!bagRsp.material_list.empty(), "warp currency is stocked");
client.sendEmpty(cmd::GetCurLineupDataCsReq);
check(client.await(cmd::GetCurLineupDataScRsp, packet), "lineup answered");
proto::GetCurLineupDataScRsp lineupRsp;
check(parseBody(packet, lineupRsp), "lineup parses");
check(lineupRsp.lineup && !lineupRsp.lineup->avatar_list.empty(), "the party is not empty");
uint32_t firstMember = lineupRsp.lineup ? lineupRsp.lineup->avatar_list[0].id : 0;
// The scene, which is the packet the whole login exists to produce.
client.sendEmpty(cmd::GetCurSceneInfoCsReq);
check(client.await(cmd::GetCurSceneInfoScRsp, packet), "scene answered");
proto::GetCurSceneInfoScRsp sceneRsp;
check(parseBody(packet, sceneRsp), "scene parses");
check(sceneRsp.scene && sceneRsp.scene->entry_id != 0, "the scene has an entry");
check(sceneRsp.scene && sceneRsp.scene->leader_entity_id != 0, "and a leader to control");
bool sceneLoaded = sceneRsp.scene && !sceneRsp.scene->entity_group_list.empty();
if (!sceneLoaded) {
std::printf("SKIP scene-dependent flow (no scene dump loaded)\n");
}
// Walking around: the position is taken from entity 0.
proto::SceneEntityMoveCsReq move;
auto& motion = move.entity_motion_list.emplace_back();
motion.entity_id = 0;
auto& pos = motion.motion.emplace().pos.emplace();
pos.x = 111;
pos.y = 222;
pos.z = 333;
client.send(cmd::SceneEntityMoveCsReq, move);
check(client.await(cmd::SceneEntityMoveScRsp, packet), "movement acknowledged");
// A party edit has to push the refresh and the lineup notify before its response.
if (firstMember != 0) {
proto::JoinLineupCsReq join;
join.OPCJCDKMJEI = firstMember;
join.index = 0;
join.slot = 0;
client.send(cmd::JoinLineupCsReq, join);
bool joined = client.await(cmd::JoinLineupScRsp, packet);
check(joined, "join answered");
if (!joined) client.reportInbox(cmd::JoinLineupScRsp);
check(client.sawNotify(cmd::SyncLineupNotify), "lineup notify pushed with the edit");
check(client.sawNotify(cmd::SceneGroupRefreshScNotify), "scene refresh pushed too");
}
if (sceneLoaded) {
// Enter a scene by teleport and fight whatever is standing there.
proto::EnterSceneCsReq enter;
enter.entry_id = 1000002;
client.send(cmd::EnterSceneCsReq, enter);
check(client.await(cmd::EnterSceneScRsp, packet), "enter scene answered");
proto::EnterSceneScRsp enterRsp;
check(parseBody(packet, enterRsp), "enter scene parses");
if (enterRsp.retcode == 0) {
check(client.sawNotify(cmd::EnterSceneByServerScNotify), "the scene was pushed");
net::Packet scenePacket;
check(client.await(cmd::EnterSceneByServerScNotify, scenePacket), "and can be read");
proto::EnterSceneByServerScNotify pushed;
check(parseBody(scenePacket, pushed), "pushed scene parses");
std::vector<uint32_t> monsters;
if (pushed.scene) {
for (const proto::SceneEntityGroupInfo& group : pushed.scene->entity_group_list) {
for (const proto::SceneEntityInfo& entity : group.entity_list) {
if (entity.npc_monster) monsters.push_back(entity.entity_id);
}
}
}
check(!monsters.empty(), "the scene has a monster in it");
size_t partySize = pushed.lineup ? pushed.lineup->avatar_list.size() : 0;
if (!monsters.empty()) {
// As the real client sends it: cast_entity_id is a per-cast counter, the
// actor that swung is attacked_by_entity_id, the monster is an assist.
uint32_t swinger = partySize >= 2 ? 2 : 1;
proto::SceneCastSkillCsReq cast;
cast.cast_entity_id = 42;
cast.attacked_by_entity_id = swinger;
cast.skill_index = 0;
cast.assist_monster_entity_id_list.push_back(monsters[0]);
client.send(cmd::SceneCastSkillCsReq, cast);
check(client.await(cmd::SceneCastSkillScRsp, packet), "cast skill answered");
proto::SceneCastSkillScRsp castRsp;
check(parseBody(packet, castRsp), "cast skill parses");
check(castRsp.cast_entity_id == 42, "the cast counter is echoed");
check(castRsp.battle_info.has(), "a fight was started");
check(castRsp.monster_battle_info.size() == 1 &&
castRsp.monster_battle_info[0].target_monster_entity_id == monsters[0] &&
castRsp.monster_battle_info[0].monster_battle_type ==
proto::MonsterBattleType::MONSTER_BATTLE_TYPE_TRIGGER_BATTLE,
"and the struck monster is reported as joining it");
if (castRsp.battle_info) {
bool ambush = false;
bool entryBuffOnSwinger = false;
for (const proto::BattleBuff& buff : castRsp.battle_info->buff_list) {
if (buff.id == 1000102) ambush = true;
if (buff.id >= 1000111 && buff.id <= 1000117) {
entryBuffOnSwinger = buff.owner_index == swinger - 1;
}
}
check(!ambush, "striking first is not an ambush");
check(entryBuffOnSwinger, "the entry buff goes to the actor that swung");
check(castRsp.battle_info->battle_id != 0, "the fight has an id");
check(!castRsp.battle_info->battle_avatar_list.empty(), "with the party in it");
check(!castRsp.battle_info->monster_wave_list.empty(), "and monsters to beat");
proto::PVEBattleResultCsReq result;
result.battle_id = castRsp.battle_info->battle_id;
result.stage_id = castRsp.battle_info->stage_id;
result.end_status = proto::BattleEndStatus::BATTLE_END_WIN;
client.send(cmd::PVEBattleResultCsReq, result);
check(client.await(cmd::PVEBattleResultScRsp, packet), "battle result answered");
proto::PVEBattleResultScRsp resultRsp;
check(parseBody(packet, resultRsp), "battle result parses");
check(resultRsp.retcode == 0, "the win was accepted");
check(resultRsp.battle_id == result.battle_id, "battle id echoed");
check(resultRsp.end_status == proto::BattleEndStatus::BATTLE_END_WIN,
"end status echoed");
// LOADED here throws in the client and leaves the monster standing.
// Earlier refreshes (the lineup edit's) are still queued, so skip to
// the one that deletes.
bool deleted = false;
net::Packet refreshPacket;
while (!deleted && client.await(cmd::SceneGroupRefreshScNotify, refreshPacket, 500)) {
proto::SceneGroupRefreshScNotify refresh;
if (!parseBody(refreshPacket, refresh)) break;
for (const proto::GroupRefreshInfo& group : refresh.group_refresh_list) {
for (const proto::SceneEntityRefreshInfo& change : group.refresh_entity) {
if (change.delete_entity != monsters[0]) continue;
deleted = group.refresh_type ==
proto::SceneGroupRefreshType::SCENE_GROUP_REFRESH_TYPE_AFIBFMAFNCC;
}
}
}
check(deleted, "the beaten monster is deleted the way the client accepts");
}
}
// A monster that gets the first hit in names itself as the attacker.
if (monsters.size() >= 2) {
proto::SceneCastSkillCsReq cast;
cast.cast_entity_id = 43;
cast.attacked_by_entity_id = monsters[1];
cast.hit_target_entity_id_list.push_back(1);
client.send(cmd::SceneCastSkillCsReq, cast);
check(client.await(cmd::SceneCastSkillScRsp, packet), "an ambush answers");
proto::SceneCastSkillScRsp castRsp;
check(parseBody(packet, castRsp), "the ambush parses");
check(castRsp.battle_info.has(), "a monster striking first starts a fight");
bool ambush = false;
if (castRsp.battle_info) {
for (const proto::BattleBuff& buff : castRsp.battle_info->buff_list) {
if (buff.id == 1000102 && buff.wave_flag == 1) ambush = true;
}
}
check(ambush, "and it opens as an ambush");
check(!castRsp.monster_battle_info.empty() &&
castRsp.monster_battle_info[0].target_monster_entity_id == monsters[1],
"led by the monster that struck");
}
// A swing at nothing, as the real client sends one on the Express.
proto::SceneCastSkillCsReq miss;
miss.cast_entity_id = 44;
miss.attacked_by_entity_id = 1;
client.send(cmd::SceneCastSkillCsReq, miss);
check(client.await(cmd::SceneCastSkillScRsp, packet), "a miss answers");
proto::SceneCastSkillScRsp missRsp;
check(parseBody(packet, missRsp), "the miss parses");
check(missRsp.retcode == 0 && !missRsp.battle_info.has() &&
missRsp.monster_battle_info.empty(),
"and starts nothing");
}
}
// Every npc the client asks about comes back as already met, under the id it asked
// about: an empty reply is what leaves an npc with no talk option.
{
proto::GetFirstTalkNpcCsReq talk;
talk.npc_id_list = {1001, 1002, 1003};
client.send(cmd::GetFirstTalkNpcCsReq, talk);
check(client.await(cmd::GetFirstTalkNpcScRsp, packet), "the npc meet query answers");
proto::GetFirstTalkNpcScRsp talkRsp;
check(parseBody(packet, talkRsp), "the npc meet query parses");
check(talkRsp.npc_meet_status_list.size() == 3, "one status per npc asked about");
bool echoed = talkRsp.npc_meet_status_list.size() == 3;
for (size_t i = 0; i < talkRsp.npc_meet_status_list.size(); ++i) {
if (talkRsp.npc_meet_status_list[i].npc_id != talk.npc_id_list[i] ||
!talkRsp.npc_meet_status_list[i].is_meet) {
echoed = false;
}
}
check(echoed, "each carries its own id and is already met");
proto::GetNpcTakenRewardCsReq reward;
reward.npc_id = 1003;
client.send(cmd::GetNpcTakenRewardCsReq, reward);
check(client.await(cmd::GetNpcTakenRewardScRsp, packet), "the npc reward query answers");
proto::GetNpcTakenRewardScRsp rewardRsp;
check(parseBody(packet, rewardRsp), "the npc reward query parses");
check(rewardRsp.npc_id == 1003, "about the npc that was asked for");
}
// A calyx, the way the Survival Index starts one: no prop to walk up to, just a
// cocoon id and how many runs were bought.
{
proto::QuickStartCocoonStageCsReq quick;
quick.cocoon_id = 1001;
quick.wave = 2;
quick.world_level = 6;
client.send(cmd::QuickStartCocoonStageCsReq, quick);
check(client.await(cmd::QuickStartCocoonStageScRsp, packet), "the calyx answers");
proto::QuickStartCocoonStageScRsp cocoonRsp;
check(parseBody(packet, cocoonRsp), "the calyx parses");
check(cocoonRsp.retcode == 0, "and was accepted");
check(cocoonRsp.cocoon_id == 1001, "for the calyx that was asked for");
check(cocoonRsp.battle_info.has(), "with a fight in it");
if (cocoonRsp.battle_info) {
check(cocoonRsp.battle_info->stage_id != 0, "on a real stage");
check(cocoonRsp.battle_info->monster_wave_list.size() >= 2,
"and one wave per run bought");
check(cocoonRsp.battle_info->world_level == 6, "at the world level asked for");
}
}
// A Stagnant Shadow, from the Survival Index and from the overworld.
{
proto::QuickStartFarmElementCsReq shadow;
shadow.PAOFHFLFFHD = 1012011;
shadow.world_level = 1;
client.send(cmd::QuickStartFarmElementCsReq, shadow);
check(client.await(cmd::QuickStartFarmElementScRsp, packet), "the shadow answers");
proto::QuickStartFarmElementScRsp shadowRsp;
check(parseBody(packet, shadowRsp), "the shadow parses");
check(shadowRsp.retcode == 0 && shadowRsp.battle_info.has(), "with a fight in it");
check(shadowRsp.battle_info && shadowRsp.battle_info->stage_id == 1012011,
"on the shadow's own stage");
check(shadowRsp.world_level == 1, "at the world level asked for");
proto::ActiveFarmElementCsReq activate;
activate.entity_id = 1234;
activate.world_level = 3;
client.send(cmd::ActiveFarmElementCsReq, activate);
check(client.await(cmd::ActiveFarmElementScRsp, packet), "activating a shadow answers");
proto::ActiveFarmElementScRsp activateRsp;
check(parseBody(packet, activateRsp), "the activation parses");
check(activateRsp.entity_id == 1234 && activateRsp.world_level == 3,
"and hands the shadow back");
}
// Two login replies the client reads instead of only waiting on.
{
client.sendEmpty(cmd::GetGachaInfoCsReq);
check(client.await(cmd::GetGachaInfoScRsp, packet), "gacha info answered");
proto::GetGachaInfoScRsp gachaRsp;
check(parseBody(packet, gachaRsp), "gacha info parses");
check(!gachaRsp.gacha_info_list.empty() && gachaRsp.gacha_info_list[0].gacha_id == 1001,
"the standard pool is offered");
check(!gachaRsp.gacha_info_list.empty() && gachaRsp.gacha_info_list[0].gacha_ceiling &&
!gachaRsp.gacha_info_list[0].gacha_ceiling->avatar_list.empty(),
"with its ceiling picks");
client.sendEmpty(cmd::GetMissionDataCsReq);
check(client.await(cmd::GetMissionDataScRsp, packet), "mission data answered");
proto::GetMissionDataScRsp missionRsp;
check(parseBody(packet, missionRsp), "mission data parses");
check(!missionRsp.finished_main_mission_id_list.empty(), "with the main missions done");
client.sendEmpty(cmd::GetActivityScheduleConfigCsReq);
check(client.await(cmd::GetActivityScheduleConfigScRsp, packet), "activity schedule answered");
proto::GetActivityScheduleConfigScRsp scheduleRsp;
check(parseBody(packet, scheduleRsp), "activity schedule parses");
bool gridFight = false;
for (const proto::ActivityScheduleData& data : scheduleRsp.schedule_data) {
gridFight |= data.activity_id == 7100101 || data.activity_id == 7100501;
}
check(!scheduleRsp.schedule_data.empty() && !gridFight, "without Grid Fight in it");
}
// The CapySR watermark rides on every pause toggle.
{
proto::SetClientPausedCsReq pause;
pause.paused = true;
client.send(cmd::SetClientPausedCsReq, pause);
check(client.await(cmd::SetClientPausedScRsp, packet), "pausing answers");
net::Packet markPacket;
check(client.await(cmd::ClientDownloadDataScNotify, markPacket), "and pushes the watermark");
proto::ClientDownloadDataScNotify mark;
check(parseBody(markPacket, mark), "the watermark parses");
std::string lua = mark.download_data ? mark.download_data->data : std::string();
check(lua.find("VersionText") != std::string::npos, "it rewrites the version label");
check(lua.find(">C<") != std::string::npos && lua.find(">R<") != std::string::npos,
"letter by letter");
check(lua.find("uid") == std::string::npos, "with nothing but the name");
}
// A whole Memory of Chaos floor: start it, fight its node, settle it, leave.
{
proto::StartChallengeCsReq start;
start.challenge_id = 1;
start.first_lineup = {8001, 1001};
start.second_lineup = {8001, 1001};
client.send(cmd::StartChallengeCsReq, start);
check(client.await(cmd::StartChallengeScRsp, packet), "starting a challenge answers");
proto::StartChallengeScRsp startRsp;
check(parseBody(packet, startRsp), "the start parses");
check(startRsp.retcode == 0, "and was accepted");
check(startRsp.cur_challenge.has(), "with the run in it");
check(startRsp.lineup_list.size() == 2, "and both teams");
check(client.sawNotify(cmd::ChallengeLineupNotify), "the challenge lineup was pushed");
net::Packet arenaPacket;
check(client.await(cmd::EnterSceneByServerScNotify, arenaPacket), "the arena was pushed");
proto::EnterSceneByServerScNotify arena;
check(parseBody(arenaPacket, arena), "the arena parses");
uint32_t challengeMonster = 0;
size_t arenaMonsters = 0;
if (arena.scene) {
check(arena.scene->entry_id == 3000101, "and it is the floor's own map entrance");
for (const proto::SceneEntityGroupInfo& group : arena.scene->entity_group_list) {
for (const proto::SceneEntityInfo& entity : group.entity_list) {
if (!entity.npc_monster) continue;
++arenaMonsters;
challengeMonster = entity.entity_id;
}
}
}
// The floor plants exactly one; the dump's own placeholders stay switched off.
check(arenaMonsters == 1, "the arena holds only the monster the floor names");
if (challengeMonster != 0) {
proto::SceneCastSkillCsReq cast;
cast.cast_entity_id = 1;
cast.hit_target_entity_id_list.push_back(challengeMonster);
client.send(cmd::SceneCastSkillCsReq, cast);
check(client.await(cmd::SceneCastSkillScRsp, packet), "the challenge fight answers");
proto::SceneCastSkillScRsp castRsp;
check(parseBody(packet, castRsp), "the challenge fight parses");
check(castRsp.battle_info.has(), "a fight was started");
if (castRsp.battle_info) {
// The stage comes off the maze config's event id, not off PlaneEvent.
check(castRsp.battle_info->stage_id == 30001011, "against the floor's own stage");
check(castRsp.battle_info->rounds_limit == 20, "with the floor's cycle limit");
check(castRsp.battle_info->battle_avatar_list.size() == 2,
"and the team the challenge was started with");
proto::PVEBattleResultCsReq result;
result.battle_id = castRsp.battle_info->battle_id;
result.stage_id = castRsp.battle_info->stage_id;
result.end_status = proto::BattleEndStatus::BATTLE_END_WIN;
result.stt.emplace().round_cnt = 4;
client.send(cmd::PVEBattleResultCsReq, result);
check(client.await(cmd::PVEBattleResultScRsp, packet), "the result answers");
net::Packet settlePacket;
check(client.await(cmd::ChallengeSettleNotify, settlePacket),
"clearing the last node settles the floor");
proto::ChallengeSettleNotify settle;
check(parseBody(settlePacket, settle), "the settle parses");
check(settle.is_win, "as a win");
check(settle.challenge_id == 1, "for the floor that was started");
// 20 cycles less the 4 used still clears both cycle targets, and
// nobody died, so all three stars.
check(settle.star == 7, "with all three stars");
}
}
client.sendEmpty(cmd::LeaveChallengeCsReq);
check(client.await(cmd::LeaveChallengeScRsp, packet), "leaving answers");
check(client.await(cmd::EnterSceneByServerScNotify, packet), "and puts the player back");
client.sendEmpty(cmd::GetCurChallengeCsReq);
check(client.await(cmd::GetCurChallengeScRsp, packet), "the run is queryable");
proto::GetCurChallengeScRsp curRsp;
check(parseBody(packet, curRsp), "the run parses");
check(!curRsp.cur_challenge.has(), "and there is no longer one going");
}
// The three-node floor: the packet 4.6 starts Memory of Chaos with. Unlike the older
// one it carries its arena in the reply rather than in a notify.
{
proto::StartChallengeTierceCsReq start;
start.challenge_id = 5213;
for (uint32_t stage = 0; stage < 3; ++stage) {
proto::ChallengeTierceStageLineupInfo info;
proto::AvatarIdentifier one;
one.id = 8001;
proto::AvatarIdentifier two;
two.id = 1001;
info.lineup.push_back(one);
info.lineup.push_back(two);
start.stage_info_list.push_back(std::move(info));
}
client.send(cmd::StartChallengeTierceCsReq, start);
check(client.await(cmd::StartChallengeTierceScRsp, packet), "starting the floor answers");
proto::StartChallengeTierceScRsp startRsp;
check(parseBody(packet, startRsp), "the start parses");
check(startRsp.retcode == 0, "and was accepted");
check(startRsp.scene.has(), "with the arena in the reply itself");
check(startRsp.challenge_tierce_info.has(), "and the run");
if (startRsp.challenge_tierce_info) {
check(startRsp.challenge_tierce_info->challenge_id == 5213, "for the floor asked for");
check(startRsp.challenge_tierce_info->stage_index == 0, "starting on its first node");
check(startRsp.challenge_tierce_info->lineup_list.size() == 3, "with all three teams");
}
uint32_t nodeMonster = 0;
size_t nodeMonsters = 0;
if (startRsp.scene) {
check(startRsp.scene->entry_id == 3014002, "on the floor's own map entrance");
for (const proto::SceneEntityGroupInfo& group : startRsp.scene->entity_group_list) {
for (const proto::SceneEntityInfo& entity : group.entity_list) {
if (!entity.npc_monster) continue;
++nodeMonsters;
nodeMonster = entity.entity_id;
}
}
}
check(nodeMonsters == 1, "holding only the node's own monster");
if (nodeMonster != 0) {
proto::SceneCastSkillCsReq cast;
cast.cast_entity_id = 1;
cast.hit_target_entity_id_list.push_back(nodeMonster);
client.send(cmd::SceneCastSkillCsReq, cast);
check(client.await(cmd::SceneCastSkillScRsp, packet), "the node's fight answers");
proto::SceneCastSkillScRsp castRsp;
check(parseBody(packet, castRsp), "the node's fight parses");
check(castRsp.battle_info.has(), "a fight was started");
if (castRsp.battle_info) {
// The cycle pool is the tierce row's 45, not the floor's own 30.
check(castRsp.battle_info->rounds_limit == 45, "with the floor-wide cycle pool");
bool mazeBuff = false;
for (const proto::BattleBuff& buff : castRsp.battle_info->buff_list) {
if (buff.id == 3030146) mazeBuff = true;
}
check(mazeBuff, "and the maze buff of the floor it extends");
proto::PVEBattleResultCsReq result;
result.battle_id = castRsp.battle_info->battle_id;
result.stage_id = castRsp.battle_info->stage_id;
result.end_status = proto::BattleEndStatus::BATTLE_END_WIN;
result.stt.emplace().round_cnt = 5;
client.send(cmd::PVEBattleResultCsReq, result);
check(client.await(cmd::PVEBattleResultScRsp, packet), "the result answers");
net::Packet syncPacket;
check(client.await(cmd::ChallengeTierceSyncNotify, syncPacket),
"clearing a node reports it");
}
}
client.sendEmpty(cmd::StartNextChallengeTierceCsReq);
check(client.await(cmd::StartNextChallengeTierceScRsp, packet), "the next node answers");
proto::StartNextChallengeTierceScRsp nextRsp;
check(parseBody(packet, nextRsp), "the next node parses");
check(nextRsp.retcode == 0, "and was accepted");
check(nextRsp.scene.has(), "carrying its arena too");
check(nextRsp.challenge_tierce_info.has() &&
nextRsp.challenge_tierce_info->stage_index == 1,
"on the second node now");
client.sendEmpty(cmd::LeaveChallengeTierceCsReq);
check(client.await(cmd::LeaveChallengeTierceScRsp, packet), "leaving answers");
check(client.await(cmd::EnterSceneByServerScNotify, packet), "and puts the player back");
client.sendEmpty(cmd::GetChallengeTierceDataCsReq);
check(client.await(cmd::GetChallengeTierceDataScRsp, packet), "the history answers");
proto::GetChallengeTierceDataScRsp dataRsp;
check(parseBody(packet, dataRsp), "the history parses");
bool remembered = false;
for (const proto::ChallengeTierceData& entry : dataRsp.challenge_info_list) {
if (entry.challenge_id != 5213) continue;
remembered = true;
check(!entry.result_list.empty(), "with what the run did on it");
}
check(remembered, "and the floor is in it");
}
// Anomaly Arbitration: a knight on its own arena -- team, start, fight, settle, retry,
// leave -- then season ten's boss on hard, whose arena is newer than the scene dump.
{
client.sendEmpty(cmd::GetChallengePeakDataCsReq);
check(client.await(cmd::GetChallengePeakDataScRsp, packet), "arbitration data answered");
proto::GetChallengePeakDataScRsp peakData;
check(parseBody(packet, peakData), "arbitration data parses");
check(peakData.challenge_peak_groups.size() >= 11, "with every season in it");
check(peakData.current_peak_group_id == 11, "and the newest on show");
proto::SetChallengePeakMobLineupAvatarCsReq teams;
teams.peak_group_id = 9;
proto::ChallengePeakLineup knightTeam;
knightTeam.peak_id = 901;
knightTeam.peak_avatar_id_list = {8001, 1001};
teams.lineup_list.push_back(knightTeam);
client.send(cmd::SetChallengePeakMobLineupAvatarCsReq, teams);
check(client.await(cmd::SetChallengePeakMobLineupAvatarScRsp, packet),
"setting a knight's team answers");
net::Packet groupPacket;
check(client.await(cmd::ChallengePeakGroupDataUpdateScNotify, groupPacket),
"and re-sends the season");
proto::ChallengePeakGroupDataUpdateScNotify groupUpdate;
check(parseBody(groupPacket, groupUpdate), "the season parses");
bool teamKept = false;
if (groupUpdate.challenge_peak_group) {
for (const proto::ChallengePeak& peak : groupUpdate.challenge_peak_group->peaks) {
if (peak.peak_id == 901 && peak.peak_avatar_id_list.size() == 2) teamKept = true;
}
}
check(teamKept, "with the team in the knight's slot");
// Sent without a team, the start falls back on the one just set.
proto::StartChallengePeakCsReq start;
start.peak_id = 901;
client.send(cmd::StartChallengePeakCsReq, start);
check(client.await(cmd::StartChallengePeakScRsp, packet), "starting a knight answers");
proto::StartChallengePeakScRsp startRsp;
check(parseBody(packet, startRsp), "the start parses");
check(startRsp.retcode == 0, "and was accepted");
auto arenaOf = [&](const char* what, uint32_t& monster, size_t& monsters, size_t& actors,
uint32_t& entryId) {
net::Packet arenaPacket;
check(client.await(cmd::EnterSceneByServerScNotify, arenaPacket), what);
proto::EnterSceneByServerScNotify arena;
check(parseBody(arenaPacket, arena), "the arena parses");
monster = 0;
monsters = actors = 0;
entryId = arena.scene ? arena.scene->entry_id : 0;
if (!arena.scene) return;
for (const proto::SceneEntityGroupInfo& group : arena.scene->entity_group_list) {
for (const proto::SceneEntityInfo& entity : group.entity_list) {
if (entity.actor) ++actors;
if (!entity.npc_monster) continue;
++monsters;
monster = entity.entity_id;
}
}
};
uint32_t knight = 0;
size_t monsters = 0;
size_t actors = 0;
uint32_t entryId = 0;
arenaOf("the arena went out before the response", knight, monsters, actors, entryId);
check(entryId == 3014501, "on the season's own arena");
check(monsters == 1, "with the knight and nothing else in it");
check(actors == 2, "and the fight's team standing in it, not the squad");
auto fight = [&](uint32_t monster, uint32_t rounds, proto::SceneBattleInfo& battle) {
proto::SceneCastSkillCsReq cast;
cast.cast_entity_id = 1;
cast.hit_target_entity_id_list.push_back(monster);
client.send(cmd::SceneCastSkillCsReq, cast);
check(client.await(cmd::SceneCastSkillScRsp, packet), "the fight answers");
proto::SceneCastSkillScRsp castRsp;
check(parseBody(packet, castRsp), "the fight parses");
check(castRsp.battle_info.has(), "a fight was started");
if (!castRsp.battle_info) return false;
battle = *castRsp.battle_info;
proto::PVEBattleResultCsReq result;
result.battle_id = battle.battle_id;
result.stage_id = battle.stage_id;
result.end_status = proto::BattleEndStatus::BATTLE_END_WIN;
result.stt.emplace().round_cnt = rounds;
client.send(cmd::PVEBattleResultCsReq, result);
check(client.await(cmd::PVEBattleResultScRsp, packet), "the result answers");
return true;
};
auto targetsIn = [](const proto::SceneBattleInfo& battle) {
auto slot = battle.battle_target_info.find(5);
return slot == battle.battle_target_info.end() ? size_t{0}
: slot->second.battle_target_list.size();
};
proto::SceneBattleInfo battle;
if (knight != 0 && fight(knight, 3, battle)) {
check(battle.stage_id == 30509011, "against the knight's own stage");
check(battle.battle_avatar_list.size() == 2, "with the knight's team");
check(targetsIn(battle) == 3, "and its three targets");
net::Packet settlePacket;
check(client.await(cmd::ChallengePeakSettleScNotify, settlePacket),
"winning settles the knight");
proto::ChallengePeakSettleScNotify settle;
check(parseBody(settlePacket, settle), "the settle parses");
check(settle.is_win && settle.peak_id == 901, "as a win for that knight");
// Four cycles or fewer, two or fewer, and nobody lost: three cycles meets two.
check(settle.finished_target_list.size() == 2, "three cycles meets two targets of three");
}
client.sendEmpty(cmd::GetCurChallengePeakCsReq);
check(client.await(cmd::GetCurChallengePeakScRsp, packet), "the fight is queryable");
proto::GetCurChallengePeakScRsp cur;
check(parseBody(packet, cur), "the fight parses");
check(cur.peak_id == 901, "and it is the knight");
client.sendEmpty(cmd::ReStartChallengePeakCsReq);
check(client.await(cmd::ReStartChallengePeakScRsp, packet), "retrying answers");
proto::ReStartChallengePeakScRsp restart;
check(parseBody(packet, restart), "the retry parses");
check(restart.retcode == 0, "and was accepted");
arenaOf("the arena went out again", knight, monsters, actors, entryId);
check(monsters == 1, "with the knight standing again");
client.sendEmpty(cmd::LeaveChallengePeakCsReq);
check(client.await(cmd::LeaveChallengePeakScRsp, packet), "leaving answers");
check(client.await(cmd::EnterSceneByServerScNotify, packet), "and puts the player back");
proto::SetChallengePeakBossHardModeCsReq hardMode;
hardMode.peak_group_id = 10;
hardMode.is_hard_mode = true;
client.send(cmd::SetChallengePeakBossHardModeCsReq, hardMode);
check(client.await(cmd::SetChallengePeakBossHardModeScRsp, packet), "hard mode answers");
proto::SetChallengePeakBossHardModeScRsp hardRsp;
check(parseBody(packet, hardRsp), "hard mode parses");
check(hardRsp.is_hard_mode && hardRsp.peak_group_id == 10, "and is echoed");
proto::StartChallengePeakCsReq bossStart;
bossStart.peak_id = 1004;
bossStart.peak_avatar_id_list = {8001, 1001};
client.send(cmd::StartChallengePeakCsReq, bossStart);
check(client.await(cmd::StartChallengePeakScRsp, packet), "starting the boss answers");
check(parseBody(packet, startRsp) && startRsp.retcode == 0, "and was accepted");
uint32_t boss = 0;
arenaOf("the boss arena went out", boss, monsters, actors, entryId);
check(entryId == 3013501, "borrowing season one's arena");
check(monsters == 1, "with the boss alone in it");
if (boss != 0 && fight(boss, 1, battle)) {
check(battle.stage_id == 30510022, "against the boss's hard stage");
check(targetsIn(battle) == 1, "judged by the hard target alone");
net::Packet settlePacket;
check(client.await(cmd::ChallengePeakSettleScNotify, settlePacket),
"winning settles the boss");
proto::ChallengePeakSettleScNotify settle;
check(parseBody(settlePacket, settle), "the settle parses");
check(settle.is_win && settle.hard_mode_has_passed, "as a hard clear");
}
client.sendEmpty(cmd::LeaveChallengePeakCsReq);
check(client.await(cmd::LeaveChallengePeakScRsp, packet), "leaving the boss answers");
check(client.await(cmd::EnterSceneByServerScNotify, packet), "and puts the player back");
client.sendEmpty(cmd::GetCurChallengePeakCsReq);
check(client.await(cmd::GetCurChallengePeakScRsp, packet), "the run is queryable");
proto::GetCurChallengePeakScRsp after;
check(parseBody(packet, after), "the run parses");
check(after.peak_id == 0, "and there is no longer one going");
}
// Every way out of a fight answers. None of these did before, which left the
// client sitting on the defeat screen or on a half-switched party.
client.sendEmpty(cmd::QuitBattleCsReq);
check(client.await(cmd::QuitBattleScRsp, packet), "quitting a battle answers");
client.sendEmpty(cmd::SceneReviveAfterRebattleCsReq);
check(client.await(cmd::SceneReviveAfterRebattleScRsp, packet), "reviving answers");
check(client.sawNotify(cmd::SyncLineupNotify), "and pushes the party back");
client.sendEmpty(cmd::GetLineupAvatarDataCsReq);
check(client.await(cmd::GetLineupAvatarDataScRsp, packet), "lineup avatar data answered");
proto::GetLineupAvatarDataScRsp avatarData;
check(parseBody(packet, avatarData), "lineup avatar data parses");
check(!avatarData.avatar_data_list.empty(), "with every avatar's hp in it");
// Switching to a squad nobody is in must still answer, or the client waits on it
// forever.
proto::SwitchLineupIndexCsReq switchTo;
switchTo.index = game::kSquadCount - 1;
client.send(cmd::SwitchLineupIndexCsReq, switchTo);
check(client.await(cmd::SwitchLineupIndexScRsp, packet), "switching to an empty squad answers");
proto::SwitchLineupIndexScRsp switchRsp;
check(parseBody(packet, switchRsp), "the switch parses");
check(switchRsp.retcode != 0, "and is refused rather than silently ignored");
check(switchRsp.index == switchTo.index, "the index is echoed");
proto::MarkPresetLineupCsReq mark;
mark.index = 0;
mark._is_favourite = true;
client.send(cmd::MarkPresetLineupCsReq, mark);
check(client.await(cmd::SetTeamFavourite, packet), "marking a preset answers on 773");
proto::SetTeamFavourite markRsp;
check(parseBody(packet, markRsp), "the mark parses");
check(markRsp.retcode == 0 && markRsp._is_favourite, "the squad is now a favourite");
// The way out of a black screen. The client has no timeout on a transition it
// started, so pushing it a scene is what makes it let go and render again.
{
http::Server admin;
sdk::registerAdminRoutes(admin, gateway);
if (admin.start("127.0.0.1", kAdminPort)) {
std::string reply = httpGet(kAdminPort, "/unstick?entry=1000002");
check(reply.find("\"message\":\"ok\"") != std::string::npos,
"unstick found the live session");
net::Packet pushed;
check(client.await(cmd::EnterSceneByServerScNotify, pushed),
"and pushed a scene at it");
proto::EnterSceneByServerScNotify rescued;
check(parseBody(pushed, rescued), "the pushed scene parses");
check(rescued.scene.has() && rescued.scene->entry_id == 1000002,
"at the entry the request named");
check(client.sawNotify(cmd::SyncLineupNotify), "with the party alongside it");
admin.stop();
} else {
std::printf("SKIP unstick (tcp %d is busy)\n", kAdminPort);
}
}
// Warp: results only, and every refusal still answers.
std::vector<core::WarpBanner> configuredPools = core::Config::get().gameplay.limitedWarpPools;
core::Config::get().gameplay.limitedWarpPools.clear();
client.sendEmpty(cmd::GetGachaInfoCsReq);
check(client.await(cmd::GetGachaInfoScRsp, packet), "warp pools answered");
proto::GetGachaInfoScRsp standardInfo;
check(parseBody(packet, standardInfo) && standardInfo.gacha_info_list.size() == 1 &&
standardInfo.gacha_info_list[0].gacha_id == 1001,
"only the standard pool by default");
core::Config::get().gameplay.limitedWarpPools = {{2002, 0}, {3002, 0}};
client.sendEmpty(cmd::GetGachaInfoCsReq);
check(client.await(cmd::GetGachaInfoScRsp, packet), "and again with limited pools configured");
proto::GetGachaInfoScRsp gachaInfo;
check(parseBody(packet, gachaInfo), "the pools parse");
check(gachaInfo.gacha_info_list.size() == 3, "the standard pool and the two configured ones");
check(!gachaInfo.gacha_info_list.empty() && gachaInfo.gacha_info_list[0].gacha_ceiling.has() &&
gachaInfo.gacha_info_list[0].gacha_ceiling->avatar_list.size() == 7,
"the standard pool carries its ceiling");
uint32_t limitedId = 0;
if (gachaInfo.gacha_info_list.size() > 1) {
limitedId = gachaInfo.gacha_info_list[1].gacha_id;
check(gachaInfo.gacha_info_list[1].prize_item_list.size() == 1,
"a limited pool names its featured 5*");
}
proto::DoGachaCsReq tenPull;
tenPull.gacha_id = limitedId;
tenPull.gacha_num = 10;
tenPull.gacha_random = gachaInfo.gacha_random;
client.send(cmd::DoGachaCsReq, tenPull);
check(client.await(cmd::DoGachaScRsp, packet), "a ten pull answers");
proto::DoGachaScRsp tenRsp;
check(parseBody(packet, tenRsp) && tenRsp.retcode == 0, "and succeeds");
check(tenRsp.gacha_id == limitedId && tenRsp.gacha_num == 10 &&
tenRsp.gacha_item_list.size() == 10,
"with ten results");
bool cardsWhole = !tenRsp.gacha_item_list.empty();
for (const proto::GachaItem& card : tenRsp.gacha_item_list) {
cardsWhole &= card.gacha_item.has() && card.gacha_item->item_id != 0 &&
card.transfer_item_list.has() && card.token_item.has();
}
check(cardsWhole, "every card is filled in");
proto::DoGachaCsReq oddPull = tenPull;
oddPull.gacha_num = 5;
client.send(cmd::DoGachaCsReq, oddPull);
proto::DoGachaScRsp oddRsp;
check(client.await(cmd::DoGachaScRsp, packet) && parseBody(packet, oddRsp) &&
oddRsp.retcode == static_cast<uint32_t>(proto::Retcode::RET_GACHA_NUM_INVALID) &&
oddRsp.gacha_item_list.empty(),
"a five pull is refused");
proto::DoGachaCsReq oldPool;
oldPool.gacha_id = 2138;
oldPool.gacha_num = 1;
client.send(cmd::DoGachaCsReq, oldPool);
proto::DoGachaScRsp oldRsp;
check(client.await(cmd::DoGachaScRsp, packet) && parseBody(packet, oldRsp) &&
oldRsp.retcode == static_cast<uint32_t>(proto::Retcode::RET_GACHA_ID_NOT_EXIST),
"a pool that is not offered is refused");
proto::GetGachaCeilingCsReq ceilingReq;
ceilingReq.DDMCNOJFGON = 1;
client.send(cmd::GetGachaCeilingCsReq, ceilingReq);
proto::GetGachaCeilingScRsp ceilingRsp;
check(client.await(cmd::GetGachaCeilingScRsp, packet) && parseBody(packet, ceilingRsp) &&
ceilingRsp.gacha_ceiling.has() && ceilingRsp.gacha_ceiling->is_claimed,
"the ceiling answers, claimed");
proto::SetGachaDecideItemCsReq decideReq;
decideReq.gacha_id = limitedId;
client.send(cmd::SetGachaDecideItemCsReq, decideReq);
proto::SetGachaDecideItemScRsp decideRsp;
check(client.await(cmd::SetGachaDecideItemScRsp, packet) && parseBody(packet, decideRsp) &&
decideRsp.retcode != 0 && decideRsp.NBLOJLDLBEB.has(),
"a custom 50/50 pool is refused with a body");
core::Config::get().gameplay.limitedWarpPools = configuredPools;
// Stamina, farming and the bag.
client.sendEmpty(cmd::ExchangeStaminaCsReq);
proto::ExchangeStaminaScRsp exchange;
check(client.await(cmd::ExchangeStaminaScRsp, packet) && parseBody(packet, exchange) &&
exchange.retcode == 0 && exchange.stamina_add == 60 && exchange.item_cost_list.size() == 1,
"stamina can be bought with jade");
proto::CocoonSweepCsReq sweep;
sweep.cocoon_id = 1001;
sweep.world_level = 6;
client.send(cmd::CocoonSweepCsReq, sweep);
proto::CocoonSweepScRsp swept;
check(client.await(cmd::CocoonSweepScRsp, packet) && parseBody(packet, swept) && swept.retcode == 0 &&
swept.NCEFJFOHGBI > 0 && swept.multiple_drop_data.has() &&
!swept.multiple_drop_data->item_list.empty(),
"a calyx sweep spends the stamina and drops items");
check(client.sawNotify(cmd::StaminaInfoScNotify) && client.sawNotify(cmd::PlayerSyncScNotify),
"and tells the client about both");
client.send(cmd::CocoonSweepCsReq, sweep);
proto::CocoonSweepScRsp dry;
check(client.await(cmd::CocoonSweepScRsp, packet) && parseBody(packet, dry) &&
dry.retcode == static_cast<uint32_t>(proto::Retcode::RET_LACK_STAMINA),
"with no stamina left the next sweep is refused");
client.sendEmpty(cmd::GetBagCsReq);
proto::GetBagScRsp bagAfter;
check(client.await(cmd::GetBagScRsp, packet) && parseBody(packet, bagAfter) &&
bagAfter.material_list.size() > 2,
"the drops are in the bag");
proto::TakeChallengeRewardCsReq claim;
claim.group_id = 100;
client.send(cmd::TakeChallengeRewardCsReq, claim);
proto::TakeChallengeRewardScRsp claimed;
check(client.await(cmd::TakeChallengeRewardScRsp, packet) && parseBody(packet, claimed) &&
claimed.retcode == 0 && !claimed.taken_reward_list.empty(),
"season rewards can be claimed");
// Shops, Pom-Pom's outfits, the Jukebox, the Data Bank and phone messages.
proto::GetShopListCsReq shops;
shops.shop_type = 1;
client.send(cmd::GetShopListCsReq, shops);
proto::GetShopListScRsp shopList;
check(client.await(cmd::GetShopListScRsp, packet) && parseBody(packet, shopList) && shopList.retcode == 0 &&
!shopList.shop_list.empty() && !shopList.shop_list[0].goods_list.empty(),
"a shop lists its goods");
client.sendEmpty(cmd::GetPamSkinDataCsReq);
proto::GetPamSkinDataScRsp skins;
check(client.await(cmd::GetPamSkinDataScRsp, packet) && parseBody(packet, skins) &&
skins.unlock_skin_list.size() >= 5 && skins.cur_skin == 252000,
"every Pom-Pom outfit is unlocked");
proto::SelectPamSkinCsReq outfit;
outfit.pam_skin = 252001;
client.send(cmd::SelectPamSkinCsReq, outfit);
proto::SelectPamSkinScRsp worn;
check(client.await(cmd::SelectPamSkinScRsp, packet) && parseBody(packet, worn) && worn.retcode == 0 &&
worn.cur_skin == 252001,
"and one can be worn");
client.sendEmpty(cmd::GetJukeboxDataCsReq);
proto::GetJukeboxDataScRsp jukebox;
check(client.await(cmd::GetJukeboxDataScRsp, packet) && parseBody(packet, jukebox) &&
jukebox.IOKAJIBHLMP.size() > 200 && jukebox.GFFOBALDBPM.has(),
"the Jukebox lists its tracks and what is playing");
if (jukebox.IOKAJIBHLMP.size() > 1) {
proto::PlayBackGroundMusicCsReq play;
play.IKJNIKIGFLF.emplace().JKNLCEEDAHJ.emplace().id = jukebox.IOKAJIBHLMP[1].id;
client.send(cmd::PlayBackGroundMusicCsReq, play);
proto::PlayBackGroundMusicScRsp playing;
check(client.await(cmd::PlayBackGroundMusicScRsp, packet) && parseBody(packet, playing) &&
playing.retcode == 0 && playing.GFFOBALDBPM && playing.GFFOBALDBPM->JKNLCEEDAHJ &&
playing.GFFOBALDBPM->JKNLCEEDAHJ->id == jukebox.IOKAJIBHLMP[1].id,
"and plays a pick");
}
client.sendEmpty(cmd::GetArchiveDataCsReq);
proto::GetArchiveDataScRsp archive;
check(client.await(cmd::GetArchiveDataScRsp, packet) && parseBody(packet, archive) && archive.archive_data &&
!archive.archive_data->kill_monster_list.empty() && !archive.archive_data->relic_list.empty() &&
!archive.archive_data->archive_equipment_id_list.empty(),
"the Data Bank is filled");
proto::GetNpcMessageGroupCsReq contacts;
contacts.BJIPBBFIBPD = {1002};
client.send(cmd::GetNpcMessageGroupCsReq, contacts);
proto::GetNpcMessageGroupScRsp conversations;
check(client.await(cmd::GetNpcMessageGroupScRsp, packet) && parseBody(packet, conversations) &&
!conversations.message_group_list.empty() &&
conversations.message_group_list[0].status ==
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");
// The chat box is hidden with everything else, so the UI has to come back by itself.
// Watermark pushes share the notify and are skipped.
auto awaitLua = [&](const std::string& marker, int timeoutMs) {
uint64_t deadline = util::nowMs() + static_cast<uint64_t>(timeoutMs);
net::Packet pushedPacket;
while (util::nowMs() < deadline) {
proto::ClientDownloadDataScNotify pushed;
if (!client.await(cmd::ClientDownloadDataScNotify, pushedPacket, timeoutMs)) return false;
if (parseBody(pushedPacket, pushed) && pushed.download_data &&
pushed.download_data->data.find(marker) != std::string::npos) {
return true;
}
}
return false;
};
std::vector<std::string> hidden = typed("/hideui 1");
check(hidden.size() == 1 && hidden[0].find("hidden for 1s") != std::string::npos,
"/hideui says for how long");
check(awaitLua("canvas.enabled = false", 2000), "the hide goes out at once");
check(awaitLua("canvas.enabled = true", 3000), "and the UI comes back after the delay");
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);
check(client.await(cmd::GetStarFightDataScRsp, packet), "unhandled requests answer empty");
// A module we deliberately do not answer stays unanswered: a body we guessed at
// makes the client throw inside its own module, and for some of them drop the
// session. Silence is the safer default there.
client.sendEmpty(cmd::ChessRogueQueryCsReq);
check(!client.await(cmd::ChessRogueQueryScRsp, packet, 600),
"a silenced module gets no reply");
// The same account logging in from a new port replaces the old session, instead of
// leaving it to write its stale copy over the new login's save when it times out.
{
TestClient second;
check(second.connect(kTestPort), "a second client connects");
proto::PlayerGetTokenCsReq again;
again.account_uid = "capybara";
second.send(cmd::PlayerGetTokenCsReq, again);
net::Packet secondPacket;
check(second.await(cmd::PlayerGetTokenScRsp, secondPacket), "and logs in");
check(gateway.sessions().size() == 1, "leaving one session for the account");
}
gateway.stop();
}