Files
CapySR/tests/test_flow.cpp
T

998 lines
47 KiB
C++
Raw Normal View History

// 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 <string>
#include <thread>
#include <vector>
#include <ikcp.h>
#include "core/config.h"
#include "core/util.h"
#include "game/handlers.h"
#include "game/lineup.h"
#include "net/cmd_ids.h"
#include "http/http_server.h"
#include "net/gateway.h"
#include "net/packet.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.
core::Config::get().paths.playerFile = "build/test-player.json";
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");
}
}
// 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");
}
}
2026-09-11 20:01:23 -07:00
// 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 petChroma = false;
for (const proto::ActivityScheduleData& data : scheduleRsp.schedule_data) {
if (data.activity_id == 7100501 && data.end_time > data.begin_time) petChroma = true;
}
check(petChroma, "with 7100501 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");
}
2026-09-11 20:01:23 -07:00
// 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() >= 10, "with every season in it");
check(peakData.current_peak_group_id == 10, "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);
}
}
// 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");
gateway.stop();
}