init(server/game): rewrite the server in c++

This commit is contained in:
capyb2222 committed 2026-09-10 20:47:29 -07:00
1 parent 63385b7e87
commit 04b14f5a22
273 files changed
+3450394

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
#pragma once
// Shared by the test translation units; test_main.cpp owns the counters and main().
namespace testing {
void check(bool ok, const char* what);
int failures();
int checks();
} // namespace testing
// One entry point per file, called in order from main().
void runGameTests();
void runHttpTests();
void runFlowTests();
+693
View File
@@ -0,0 +1,693 @@
// 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");
}
}
// 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");
}
// 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();
}
+695
View File
@@ -0,0 +1,695 @@
// Exercises the game layer against the real tables and scene dump when they are
// present; the data-driven checks are skipped (and reported) when they are not.
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "core/config.h"
#include "core/util.h"
#include "data/excel.h"
#include "data/scene_res.h"
#include "game/battle.h"
#include "game/challenge.h"
#include "game/lineup.h"
#include "game/player.h"
#include "game/roster.h"
#include "game/scene.h"
#include "game/srtools.h"
#include "net/packet.h"
#include "tests/harness.h"
namespace {
using testing::check;
void testLineupPacking() {
game::LineupBook book;
check(book.curIndex() == 0, "lineup starts on squad 1");
check(book.maxMp() == game::kBaseMazeMp, "empty squad has the base mp cap");
check(book.join(0, 0, 1001), "join slot 0");
check(book.join(0, 1, 1002), "join slot 1");
check(book.join(0, 2, 1003), "join slot 2");
check(book.join(0, 3, 1004), "join slot 3");
check(!book.join(0, 3, 1005), "a full squad refuses a fifth member");
check(book.members(0).size() == 4, "four members");
check(book.leaderSlot(0) == 0, "the first to join leads");
// A join that names an occupied slot moves the member, never duplicates it.
check(book.join(0, 0, 1004), "move 1004 to the front");
std::vector<uint32_t> moved = book.members(0);
check(moved.size() == 4, "moving keeps the squad size");
check(moved[0] == 1004 && moved[1] == 1001 && moved[2] == 1002 && moved[3] == 1003,
"moving shifts the others right");
check(book.quit(0, 1002), "quit 1002");
std::vector<uint32_t> after = book.members(0);
check(after.size() == 3, "three left");
check(after[0] == 1004 && after[1] == 1001 && after[2] == 1003, "members stay packed");
check(book.swap(0, 0, 2), "swap the ends");
check(book.members(0)[0] == 1003 && book.members(0)[2] == 1004, "swap took effect");
check(!book.swap(0, 0, 3), "cannot swap with an empty slot");
// The leader follows the avatar, not the slot.
book.setLeaderSlot(0, 2);
check(book.squad(0).leaderAvatarId == 1004, "leader set by slot");
book.swap(0, 0, 2);
check(book.leaderSlot(0) == 0, "leader follows its avatar across a swap");
book.quit(0, 1004);
check(book.squad(0).leaderAvatarId != 0, "leader moves on when it leaves");
book.replace(0, {1001, 1002, 1001, 1003}, 1);
check(book.members(0).size() == 3, "replace drops the duplicate");
check(book.squad(0).leaderAvatarId == 1002, "replace honours leader_slot");
// The last member cannot leave: the client refuses an empty party.
book.replace(0, {8001}, 0);
check(!book.quit(0, 8001), "the last member stays");
check(book.members(0).size() == 1, "squad still has its member");
}
void testSquadSwitching() {
game::LineupBook book;
book.join(0, 0, 1001);
book.join(2, 0, 1002);
book.join(2, 1, 1003);
book.setCurIndex(2);
check(book.curIndex() == 2, "switched to squad 3");
check(book.curMembers().size() == 2, "and it is that squad's members that walk around");
// Out of range is ignored rather than wrapping; the handler answers it as an error.
book.setCurIndex(game::kSquadCount);
check(book.curIndex() == 2, "an out of range index changes nothing");
check(book.members(1).empty(), "squad 2 was never filled");
check(!book.squad(1).favourite, "and is not marked a favourite");
book.squad(1).favourite = true;
check(book.squad(1).favourite, "the mark sticks");
}
void testMazeMpCap() {
game::LineupBook book;
book.join(0, 0, 1001);
book.setMp(99);
check(book.mp() == game::kBaseMazeMp, "mp is clamped to the cap");
book.join(0, 1, 1408); // Phainon raises the cap by three
check(book.maxMp() == game::kBaseMazeMp + 3, "cap raiser widens the pool");
book.setMp(8);
check(book.mp() == 8, "the wider pool can be filled");
book.quit(0, 1408);
check(book.maxMp() == game::kBaseMazeMp, "cap drops again");
check(book.mp() == game::kBaseMazeMp, "mp follows the cap down");
}
void testItemUniqueIds() {
// srtools numbers from zero and unique id 0 means "nothing", so nothing maps to 0
// and the two ranges never meet.
check(game::relicUniqueId(0) == 1, "first relic is 1");
check(game::equipmentUniqueId(0) == 3001, "first lightcone is 3001");
check(game::relicUniqueId(1274) < game::equipmentUniqueId(0), "ranges do not overlap");
check(game::relicInternalUid(game::relicUniqueId(42)) == 42, "relic id round trips");
check(game::equipmentInternalUid(game::equipmentUniqueId(42)) == 42, "lightcone round trips");
check(game::equipmentInternalUid(0) == 0, "unset lightcone id stays 0");
}
void testElementBuffs() {
check(data::attackBuffForElement("Physical") == 1000111, "physical entry buff");
check(data::attackBuffForElement("Fire") == 1000112, "fire entry buff");
check(data::attackBuffForElement("Ice") == 1000113, "ice entry buff");
check(data::attackBuffForElement("Thunder") == 1000114, "lightning entry buff");
check(data::attackBuffForElement("Wind") == 1000115, "wind entry buff");
check(data::attackBuffForElement("Quantum") == 1000116, "quantum entry buff");
check(data::attackBuffForElement("Imaginary") == 1000117, "imaginary entry buff");
check(data::attackBuffForElement("Nonsense") == 0, "unknown element has no buff");
}
void testTables() {
const data::Tables& tables = data::Tables::get();
if (!tables.loaded()) {
std::printf("SKIP game tables (no data source configured)\n");
return;
}
check(tables.avatar(1001) != nullptr, "March 7th is in the tables");
const data::AvatarInfo* march = tables.avatar(1001);
check(march != nullptr && march->damageType == "Ice", "March is an ice character");
check(march != nullptr && march->attackBuffId == 1000113, "her entry buff follows her element");
check(march != nullptr && march->spNeed == 12000, "SPNeed is scaled to hundredths");
check(!march->techniqueBuffs.empty() && march->techniqueBuffs[0] == 100101,
"her technique buff comes from AvatarDefaultMazeBuff");
check(tables.baseAvatarId(8008) == 8001, "a Trailblazer path folds into 8001");
check(tables.baseAvatarId(1224) == 1001, "March the Hunt folds into 1001");
check(tables.baseAvatarId(1310) == 1310, "everyone else is their own base");
check(tables.multiPathVariants(8001) != nullptr, "8001 has paths");
check(tables.multiPathVariants(1310) == nullptr, "1310 has none");
const std::vector<uint32_t>& globals = tables.globalMazeBuffs();
check(globals.size() == 2, "two global maze buffs");
check(std::find(globals.begin(), globals.end(), 140703u) != globals.end(), "Castorice global");
check(std::find(globals.begin(), globals.end(), 150602u) != globals.end(), "Silver Wolf global");
// PlaneEvent 10301299 at world level 6 is the Express corridor fight.
check(tables.stageForEvent(10301299, 6) == 103012996, "plane event resolves its stage");
check(tables.stageForEvent(10301299, 0) != 0, "an unlisted world level falls back");
check(tables.stageForEvent(999999999, 6) == 0, "an unknown event resolves to nothing");
const data::StageInfo* stage = tables.stage(103012996);
check(stage != nullptr && !stage->waves.empty(), "the stage has monster waves");
// CocoonConfig is keyed by id * 100 + world level.
const data::CocoonInfo* cocoon = tables.cocoon(1001, 6);
check(cocoon != nullptr, "calyx 1001 exists at world level 6");
check(cocoon != nullptr && cocoon->worldLevel == 6, "and it is the world level 6 row");
check(cocoon != nullptr && !cocoon->stageIds.empty(), "with stages to draw from");
// FarmElementConfig: a shadow comes as its stage id, or as its id at a world level.
check(tables.farmElementStage(1012011, 6) == 1012011, "a shadow's stage id is its own stage");
check(tables.farmElementStage(1101, 1) == 1012011, "a shadow id resolves at its world level");
check(tables.farmElementStage(999999999, 0) == 0, "an unknown shadow resolves to nothing");
const data::StandardGacha& gacha = tables.standardGacha();
check(gacha.gachaId == 1001, "the standard warp is pool 1001");
check(gacha.ceilingNum == 300 && gacha.ceilingAvatars.size() == 7,
"with its seven picks at 300 pulls");
check(tables.entrance(1000001) != nullptr, "map entrance 1000001 is loaded");
check(tables.plane(10000) != nullptr, "plane 10000 is loaded");
}
void testChallengeHistory() {
const data::Tables& tables = data::Tables::get();
if (!tables.loaded()) return;
check(tables.challenges().size() > 700, "the three challenge modes loaded their floors");
check(tables.challengeGroups().size() > 90, "and their seasons");
// Memory of Chaos floor 1 of the first season, and the season it belongs to.
const data::ChallengeInfo* floor = nullptr;
for (const data::ChallengeInfo& c : tables.challenges()) {
if (c.id == 1) floor = &c;
}
check(floor != nullptr && floor->groupId == 100, "challenge 1 is in group 100");
check(floor != nullptr && floor->targetIds.size() == 3, "a floor has three star targets");
// Reward line 1 pays out every three stars from 3 to 36.
uint64_t mask = tables.challengeRewardStars(1);
check((mask & (uint64_t{1} << 3)) != 0, "reward line 1 pays out at 3 stars");
check((mask & (uint64_t{1} << 36)) != 0, "and at 36");
check((mask & (uint64_t{1} << 4)) == 0, "but not at 4");
check(tables.challengeRewardStars(999999) == 0, "an unknown reward line pays nothing");
proto::GetChallengeScRsp rsp = game::challenge::history();
check(rsp.challenge_list.size() == tables.challenges().size(), "every floor is reported");
check(rsp.challenge_list[0].star == 7, "with all three of its stars");
check(rsp.max_level_list.size() == 3, "one max level per mode");
check(rsp.serialize().size() + net::kPacketOverhead < net::kMaxKcpMessage,
"the challenge history fits in one kcp message");
}
void testSceneRes() {
const data::SceneRes& res = data::SceneRes::get();
if (!res.loaded()) {
std::printf("SKIP scene dump (res.json not configured)\n");
return;
}
const data::ResFloor* floor = res.byEntry(2041101);
check(floor != nullptr, "the Express parlour entry is in the dump");
if (floor == nullptr) return;
check(floor->planeId == 20411, "plane id parsed out of the key");
check(floor->floorId == 20411001, "floor id parsed out of the key");
check(!floor->groups.empty(), "the floor has groups");
check(res.byFloor(20411001) != nullptr, "floor lookup goes through the entrance map");
check(res.byEntry(1) == nullptr, "an unknown entry is not invented");
// Half the entries have no teleport pad, so the entrance anchors are what put
// the player somewhere sane when they arrive.
const data::ResTeleport* express = res.anchor(1000002);
check(express != nullptr, "the Express entrance has an anchor");
check(express == nullptr || express->pos.z != 0, "and it is a real position");
check(res.anchor(1) == nullptr, "an entry with no anchor gets none");
}
// A player wired up the way a session would have it, without any networking.
game::Player makeTestPlayer() {
game::Player player(1337);
player.setMainCharacter(8008);
player.setMarchType(1224);
game::LineupBook& book = player.lineups();
book.replace(0, {8001, 1001}, 0);
return player;
}
void testRosterProtos() {
if (!data::Tables::get().loaded()) return;
game::Player player = makeTestPlayer();
game::Roster roster = player.roster();
check(roster.resolvePath(8001) == 8008, "8001 resolves to the chosen path");
check(roster.resolvePath(1001) == 1224, "1001 resolves to the chosen March");
check(roster.resolvePath(1310) == 1310, "everyone else resolves to themselves");
const std::vector<uint32_t>& ids = game::Roster::baseAvatarIds();
check(!ids.empty(), "the roster has base ids");
// Derived from AvatarConfig; the 4.5 + 4.6-beta merge comes to exactly 88 base ids.
check(ids.size() == 88, "the derived roster has all 88 base ids");
check(std::find(ids.begin(), ids.end(), 6022u) == ids.end(), "story-only units are excluded");
check(std::find(ids.begin(), ids.end(), 1503u) != ids.end(), "beta-only avatars are included");
check(ids.size() >= 2 && ids[0] == 8001 && ids[1] == 1001, "Trailblazer and March lead");
check(std::find(ids.begin(), ids.end(), 8008u) == ids.end(), "paths are not listed as bases");
check(std::find(ids.begin(), ids.end(), 1224u) == ids.end(), "March the Hunt is not a base");
proto::Avatar avatar = roster.toAvatar(8001);
check(avatar.base_avatar_id == 8001, "avatar keeps its base id");
check(avatar.cur_multi_path_avatar_type == 8008, "and reports the active path");
check(avatar.has_taken_promotion_reward_list.size() == avatar.promotion,
"promotion rewards are all claimed");
proto::Avatar other = roster.toAvatar(1310);
check(other.cur_multi_path_avatar_type == 1310, "a single-path avatar reports itself");
std::vector<proto::BattleBuff> buffs;
proto::BattleAvatar battle = roster.toBattleAvatar(8001, 0, buffs);
check(battle.id == 8008, "the battle avatar is the resolved path");
check(battle.index == 0, "index carried through");
check(battle.hp == 10000, "hp is a percentage in hundredths");
check(battle.sp_bar && battle.sp_bar->max_sp >= 10000, "sp is scaled to hundredths");
check(!battle.skilltree_list.empty(), "traces are filled in");
check(!buffs.empty(), "a technique buff was produced");
check(buffs[0].wave_flag == 0xFFFFFFFFu, "technique buffs apply to every wave");
check(buffs[0].owner_index == 0, "owned by the avatar that brought it");
}
void testSceneBuild() {
if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return;
game::Player player = makeTestPlayer();
proto::SceneInfo scene;
check(!game::scene::load(player, 1, 0, false, scene), "an unknown entry fails cleanly");
check(game::scene::load(player, 2041101, 0, true, scene), "the Express parlour loads");
check(scene.entry_id == 2041101, "entry id");
check(scene.plane_id == 20411, "plane id");
check(scene.floor_id == 20411001, "floor id");
check(!scene.entity_group_list.empty(), "the scene has entity groups");
check(player.location().floorId == 20411001, "committing moved the player");
// The party is group 0 and its entity ids are slot + 1.
const proto::SceneEntityGroupInfo& actors = scene.entity_group_list.back();
check(actors.group_id == 0, "the party is the last group");
check(actors.entity_list.size() == 2, "both party members are in the world");
check(actors.entity_list[0].entity_id == 1, "first actor is entity 1");
check(actors.entity_list[0].actor && actors.entity_list[0].actor->base_avatar_id == 8001,
"the actor carries the base id, not the path");
check(scene.leader_entity_id == 1, "leader entity id points at the leader");
// Entity id bands let a kind be told from the id alone.
bool propsBanded = true;
bool monstersBanded = true;
size_t monsters = 0;
for (const game::SceneEntity& entity : player.sceneState().entities()) {
if (entity.kind == game::EntityKind::Prop && entity.entityId <= game::kPropEntityIdBase) {
propsBanded = false;
}
if (entity.kind == game::EntityKind::Monster) {
++monsters;
if (entity.entityId <= game::kMonsterEntityIdBase) monstersBanded = false;
}
}
check(propsBanded, "props sit in their id band");
check(monstersBanded, "monsters sit in their id band");
check(player.sceneState().find(1) != nullptr, "the registry knows the actors");
proto::SceneMapInfo map = game::scene::mapInfo(player, 20411001);
check(map.floor_id == 20411001, "map info is for the right floor");
check(map.chest_list.size() == 3, "the map legend has its chest counters");
proto::LineupInfo lineup = game::scene::lineupInfo(player);
check(lineup.avatar_list.size() == 2, "the lineup has both members");
check(lineup.avatar_list[0].slot == 0 && lineup.avatar_list[1].slot == 1,
"lineup slots are the packed indices");
check(lineup.max_mp == game::kBaseMazeMp, "lineup carries the technique cap");
(void)monsters;
}
// Every scene in the dump has to fit in one KCP message, or entering it silently
// drops the packet and the client is left staring at a loading screen.
void testChallengeRun() {
if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return;
const data::Tables& tables = data::Tables::get();
// Memory of Chaos floor 1: one node, in scene group 2 of map entrance 3000101.
const data::ChallengeInfo* config = tables.challenge(1);
check(config != nullptr, "challenge 1 is loaded");
if (config == nullptr) return;
check(config->kind == data::ChallengeKind::Memory, "and it is a Memory of Chaos floor");
check(config->mapEntranceId == 3000101, "with its own map entrance");
check(config->roundLimit == 20, "and a cycle limit");
check(config->mazeBuffId != 0, "and a maze buff");
check(config->stages[0].mazeGroupId == 2, "its first node is scene group 2");
check(config->stages[0].monsters.size() == 1, "holding one planted monster");
// Pure Fiction and Apocalyptic Shadow both fight two nodes.
const data::ChallengeInfo* fiction = tables.challenge(20011);
check(fiction != nullptr && fiction->kind == data::ChallengeKind::Story, "20011 is Pure Fiction");
check(fiction != nullptr && fiction->stageNum == 2, "and has two nodes");
check(fiction != nullptr && !fiction->battleTargetIds.empty(),
"with the score targets from its extra table");
const data::ChallengeInfo* shadow = tables.challenge(30011);
check(shadow != nullptr && shadow->kind == data::ChallengeKind::Boss,
"30011 is Apocalyptic Shadow");
check(shadow != nullptr && shadow->mapEntranceId2 != shadow->mapEntranceId,
"whose second node has its own entrance");
game::Player player = makeTestPlayer();
game::ChallengeRun& run = player.challenge();
run.active = true;
run.challengeId = config->id;
run.stage = 1;
run.roundsLeft = config->roundLimit;
run.party[0] = {8001, 1001};
run.party[1] = {1002, 1003};
proto::SceneInfo scene;
check(game::challenge::enterArena(player, scene), "the arena loads");
check(player.location().entryId == config->mapEntranceId, "and the player is standing in it");
// Only the node's own monster is there, and it is the one the maze config names --
// not the placeholder the scene dump carries.
size_t monsters = 0;
size_t npcs = 0;
for (const proto::SceneEntityGroupInfo& group : scene.entity_group_list) {
for (const proto::SceneEntityInfo& entity : group.entity_list) {
if (entity.npc_monster) {
++monsters;
check(entity.npc_monster->monster_id == config->stages[0].monsters[0].npcMonsterId,
"the planted monster replaces the dump's placeholder");
check(entity.group_id == config->stages[0].mazeGroupId,
"and stands in the node's own group");
}
if (entity.npc) ++npcs;
}
}
check(monsters == 1, "exactly one monster is in the arena");
check(npcs == 0, "and nobody is standing around");
// A challenge event is its own stage id -- PlaneEvent maps it to itself, so the
// entity can carry it directly and skip the world-level lookup entirely.
uint32_t eventId = config->stages[0].monsters[0].eventId;
check(tables.stageForEvent(eventId, player.worldLevel()) == eventId,
"a challenge event maps to itself");
const game::SceneEntity* planted = nullptr;
for (const game::SceneEntity& entity : player.sceneState().entities()) {
if (entity.kind == game::EntityKind::Monster) planted = &entity;
}
check(planted != nullptr && planted->stageId == eventId, "so the entity carries the stage id");
check(tables.stage(eventId) != nullptr, "and that stage is in the tables");
// Stars: cycles left and nobody dead, both met on a fresh run.
check(game::challenge::stars(player) == 7, "an untouched run has all three stars");
run.roundsLeft = 1;
run.deadAvatars = 2;
check(game::challenge::stars(player) == 0, "a slow run with deaths has none");
// Score targets are Pure Fiction's, so a Memory floor ignores them entirely.
run.challengeId = fiction->id;
run.roundsLeft = 0;
run.score[0] = 30000;
run.score[1] = 30000;
check(game::challenge::stars(player) == 7, "60000 points clears all three score targets");
run.score[1] = 0;
check(game::challenge::stars(player) == 0, "30000 clears none of them");
}
void testEveryScenePacketFits() {
if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return;
game::Player player = makeTestPlayer();
size_t worst = 0;
uint32_t worstEntry = 0;
size_t failures = 0;
size_t built = 0;
for (const data::ResFloor& floor : data::SceneRes::get().floors()) {
proto::SceneInfo scene;
if (!game::scene::load(player, floor.entryId, 0, false, scene)) continue;
++built;
proto::EnterSceneByServerScNotify notify;
notify.scene = std::move(scene);
notify.lineup = game::scene::lineupInfo(player);
size_t size = notify.serialize().size() + net::kPacketOverhead;
if (size > worst) {
worst = size;
worstEntry = floor.entryId;
}
if (size > net::kMaxKcpMessage) ++failures;
}
check(built > 100, "the dump produced scenes to measure");
check(failures == 0, "every scene fits in one kcp message");
if (failures != 0) {
std::printf(" largest scene is entry %u at %zu bytes (limit %zu)\n", worstEntry, worst,
net::kMaxKcpMessage);
}
// The bag is the other packet that grows with the player's data.
game::Roster roster = player.roster();
proto::GetBagScRsp bag;
for (const game::Lightcone& lightcone : roster.data().lightcones) {
bag.equipment_list.push_back(roster.toEquipment(lightcone));
}
for (const game::Relic& relic : roster.data().relics) {
bag.relic_list.push_back(roster.toRelic(relic));
}
check(bag.serialize().size() + net::kPacketOverhead < net::kMaxKcpMessage,
"the bag fits in one kcp message");
}
void testBattleBuild() {
if (!data::SceneRes::get().loaded() || !data::Tables::get().loaded()) return;
game::Player player = makeTestPlayer();
proto::SceneInfo scene;
if (!game::scene::load(player, 1000002, 0, true, scene)) {
std::printf("SKIP battle build (entry 1000002 missing from the dump)\n");
return;
}
// Find a monster the dump placed in this scene and swing at it.
uint32_t monsterEntity = 0;
for (const game::SceneEntity& entity : player.sceneState().entities()) {
if (entity.kind == game::EntityKind::Monster && entity.eventId != 0) {
monsterEntity = entity.entityId;
break;
}
}
check(monsterEntity != 0, "the scene has a monster to fight");
if (monsterEntity == 0) return;
game::BattleRequest request;
request.casterEntityId = 1;
request.skillIndex = 0;
request.monsterEntityIds = {monsterEntity};
request.allowSrToolsOverride = false;
proto::SceneBattleInfo info = game::battle::create(player, request);
check(info.battle_id != 0, "the fight got an id");
check(info.stage_id != 0, "the monster's event resolved to a stage");
check(info.battle_avatar_list.size() == 2, "the whole party is in the fight");
check(!info.monster_wave_list.empty(), "the fight has monsters");
check(info.monster_wave_list[0].battle_wave_id == 1, "waves are numbered from one");
check(info.world_level == player.worldLevel(), "world level carried through");
check(player.battle().active, "the fight is recorded on the player");
check(player.battle().battleId == info.battle_id, "and by its id");
// A basic attack gives the leader the element entry buff at SkillIndex 1.
bool entryBuff = false;
for (const proto::BattleBuff& buff : info.buff_list) {
if (buff.id < 1000111 || buff.id > 1000117) continue;
entryBuff = true;
check(buff.owner_index == 0, "the entry buff belongs to whoever swung");
auto it = buff.dynamic_values.find("SkillIndex");
check(it != buff.dynamic_values.end() && it->second == 1.0f,
"a basic attack is SkillIndex 1");
}
check(entryBuff, "the swing produced an element entry buff");
bool ambush = false;
for (const proto::BattleBuff& buff : info.buff_list) {
if (buff.id == 1000102) ambush = true;
}
check(!ambush, "no ambush buff when the player struck first");
// Being attacked adds the ambush buff, and only to the first wave.
request.attackedByEntityId = monsterEntity;
request.skillIndex = 1;
proto::SceneBattleInfo ambushed = game::battle::create(player, request);
const proto::BattleBuff* ambushBuff = nullptr;
for (const proto::BattleBuff& buff : ambushed.buff_list) {
if (buff.id == 1000102) ambushBuff = &buff;
}
check(ambushBuff != nullptr, "an ambush adds its buff");
check(ambushBuff == nullptr || ambushBuff->wave_flag == 1, "ambush only hits wave one");
check(ambushed.battle_id != info.battle_id, "each fight gets a fresh id");
for (const proto::BattleBuff& buff : ambushed.buff_list) {
if (buff.id < 1000111 || buff.id > 1000117) continue;
auto it = buff.dynamic_values.find("SkillIndex");
check(it != buff.dynamic_values.end() && it->second == 2.0f,
"a technique is SkillIndex 2");
}
// Global maze buffs ride along unless they are switched off.
bool castorice = false;
for (const proto::BattleBuff& buff : ambushed.buff_list) {
if (buff.id == 140703) castorice = true;
}
check(castorice, "the global maze buffs are added");
player.setGlobalBuffs(false);
proto::SceneBattleInfo plain = game::battle::create(player, request);
bool stillThere = false;
for (const proto::BattleBuff& buff : plain.buff_list) {
if (buff.id == 140703) stillThere = true;
}
check(!stillThere, "and can be switched off");
}
// Writing an equip change back must not drop the parts of the build we do not
// model -- the loadout presets are the user's, not ours.
void testSrToolsRoundTrip() {
const std::string original = core::Config::get().paths.srtoolsFile;
bool ok = false;
std::string source = util::readFile(original, &ok);
if (!ok) {
std::printf("SKIP srtools round trip (no build on disk)\n");
return;
}
const std::string scratch = "build/test-srtools.json";
check(util::writeFile(scratch, source), "copied the build to a scratch file");
core::Config::get().paths.srtoolsFile = scratch;
check(game::SrTools::instance().loadFromDisk(), "scratch build loads");
uint32_t movedItemId = 0;
game::SrTools::instance().mutate([&](game::SrToolsData& data) {
if (!data.lightcones.empty()) {
data.lightcones.front().equipAvatar = 1310;
movedItemId = data.lightcones.front().itemId;
}
});
std::string written = util::readFile(scratch, &ok);
check(ok, "the build was written back");
if (ok) {
nlohmann::json before = nlohmann::json::parse(source);
nlohmann::json after = nlohmann::json::parse(written);
for (const auto& entry : before.items()) {
if (entry.key() == "avatars" || entry.key() == "lightcones" ||
entry.key() == "relics") {
continue;
}
check(after.contains(entry.key()), "unmodelled fields survive a write");
if (after.contains(entry.key())) {
check(after[entry.key()] == entry.value(), "and survive unchanged");
}
}
check(after["lightcones"].size() == before["lightcones"].size(), "no lightcones lost");
check(after["relics"].size() == before["relics"].size(), "no relics lost");
check(after["avatars"].size() == before["avatars"].size(), "no avatars lost");
check(after["lightcones"][0]["equip_avatar"] == 1310, "the equip change was written");
check(after["lightcones"][0]["item_id"] == movedItemId, "on the right lightcone");
}
// A reload of the written file has to produce the same thing we just wrote.
check(game::SrTools::instance().loadFromDisk(), "the written build reloads");
auto reloaded = game::SrTools::instance().data();
check(!reloaded->lightcones.empty() && reloaded->lightcones.front().equipAvatar == 1310,
"and the change survived the round trip");
check(!reloaded->avatars.empty(), "avatars survived the round trip");
if (auto march = reloaded->avatars.find(1001); march != reloaded->avatars.end()) {
check(!march->second.skills.empty(), "traces survived the round trip");
check(!march->second.skillsByAnchor.empty(), "trace anchors survived too");
}
core::Config::get().paths.srtoolsFile = original;
game::SrTools::instance().loadFromDisk();
}
// Launching build\bin\capysr.exe directly used to start a server with no config, no
// tables and no scenes, and every symptom looked like a broken server.
void testRootDirLookup() {
check(!util::executableDir().empty(), "the executable knows where it lives");
std::string root = util::findRootDir("config/config.json");
check(!root.empty(), "the repo root is found from the test binary");
check(util::fileExists(root + "/config/config.json"), "and it really holds the config");
check(util::findRootDir("no/such/marker.json").empty(), "a bogus marker finds nothing");
// The working directory wins, so a deliberate cd still decides.
check(util::findRootDir("config/config.json") == root, "lookup is stable");
}
void testPropInteraction() {
if (!data::Tables::get().loaded()) return;
game::Player player = makeTestPlayer();
game::SceneEntity prop;
prop.entityId = 1001;
prop.kind = game::EntityKind::Prop;
prop.state = 12; // ChestClosed
player.sceneState().add(prop);
game::SceneEntity* stored = player.sceneState().find(1001u);
check(stored != nullptr, "the prop is in the registry");
// InteractConfig 1010 opens a prop that is Closed; a chest is not, so it stays.
const data::InteractInfo* open = data::Tables::get().interact(1010);
check(open != nullptr, "InteractConfig 1010 is loaded");
check(open == nullptr || open->targetState == 1, "and it opens things");
check(data::propStateFromName("ChestUsed") == 13, "prop state names map to numbers");
check(data::propStateFromName("Closed") == 0, "Closed is zero");
check(data::propStateFromName("Nonsense") == 0, "an unknown state reads as Closed");
}
} // namespace
void runGameTests() {
// The tests read the same config the server does, so they exercise the real paths.
// Find the root the same way main() does, so the suite runs from anywhere.
testRootDirLookup();
std::string root = util::findRootDir("config/config.json");
if (!root.empty()) util::setCurrentDir(root);
core::Config::get().load("config/config.json");
data::Tables::get().load(core::Config::get().paths.dataSources);
data::SceneRes::get().load(core::Config::get().paths.sceneRes);
data::SceneRes::get().loadAnchors(core::Config::get().paths.anchors);
game::SrTools::instance().loadFromDisk();
testLineupPacking();
testSquadSwitching();
testMazeMpCap();
testItemUniqueIds();
testElementBuffs();
testTables();
testChallengeHistory();
testSceneRes();
testRosterProtos();
testSceneBuild();
testChallengeRun();
testEveryScenePacketFits();
testBattleBuild();
testPropInteraction();
testSrToolsRoundTrip();
}
+138
View File
@@ -0,0 +1,138 @@
// Drives the dispatch/SDK server over a real socket with the exact requests a client
// makes. The SDK is not verb-consistent, and a 405 here throws inside the client's
// SDK and bounces it back to the login screen with an empty dialog.
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include "core/util.h"
#include "http/http_server.h"
#include "net/socket.h"
#include "proto/gen/protos.h"
#include "sdk/routes.h"
#include "tests/harness.h"
namespace {
using testing::check;
constexpr uint16_t kTestPort = 21099;
// Minimal HTTP/1.0 client: sends one request, returns the whole raw reply.
std::string request(uint16_t port, const std::string& method, const std::string& target,
const std::string& body = {}) {
net::initSockets();
uintptr_t sock = net::tcpConnect("127.0.0.1", port);
if (sock == net::kInvalidSocket) return {};
std::string req = method + " " + target + " HTTP/1.1\r\nHost: 127.0.0.1\r\n";
req += "Content-Length: " + std::to_string(body.size()) + "\r\n";
req += "Connection: close\r\n\r\n" + body;
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;
}
int statusOf(const std::string& reply) {
// "HTTP/1.1 200 OK"
size_t space = reply.find(' ');
if (space == std::string::npos) return 0;
return std::atoi(reply.c_str() + space + 1);
}
bool succeeded(const std::string& reply) {
return statusOf(reply) == 200 && reply.find("\"retcode\":0") != std::string::npos;
}
} // namespace
void runHttpTests() {
http::Server server;
sdk::registerRoutes(server);
if (!server.start("127.0.0.1", kTestPort)) {
std::printf("SKIP http tests (tcp %d is busy)\n", kTestPort);
return;
}
// The three the client actually asked for, with the query strings from its log.
// It GETs all of them, which used to collide with POST-only routes and answer 405.
// Each is checked for a field only its real handler produces, so falling through
// to the catch-all stub does not pass: the client needs the actual config back.
struct Endpoint {
const char* target;
const char* marker;
};
const Endpoint kClientGets[] = {
{"/device-fp/api/getExtList?platform=3", "ext_list"},
{"/hkrpg_cn/combo/granter/api/getConfig?app_id=8&channel_id=1&client_type=3", "app_name"},
{"/hkrpg_cn/mdk/shield/api/loadConfig?client=3&game_key=hkrpg_cn", "game_key"},
};
for (const Endpoint& e : kClientGets) {
for (const char* method : {"GET", "POST"}) {
std::string reply = request(kTestPort, method, e.target, "{}");
bool ok = succeeded(reply) && reply.find(e.marker) != std::string::npos;
check(ok, "the client's sdk call gets its real answer on either verb");
if (!ok) {
std::printf(" %s %s -> %d (wanted \"%s\")\n", method, e.target,
statusOf(reply), e.marker);
}
}
}
// No SDK path may ever answer 405: the client's SDK throws on it rather than
// handling it, which shows an empty dialog and loops back to login.
const char* kProbes[] = {"/hkrpg_cn/mdk/shield/api/login",
"/hkrpg_cn/mdk/shield/api/verify",
"/hkrpg_cn/combo/granter/login/v2/login",
"/account/risky/api/check",
"/account/ma-cn-passport/app/loginByPassword",
"/account/ma-cn-session/app/verify",
"/device-fp/api/getFp",
"/some/endpoint/we/never/heard/of"};
for (const char* target : kProbes) {
for (const char* method : {"GET", "POST"}) {
std::string reply = request(kTestPort, method, target, "{}");
bool ok = succeeded(reply);
check(ok, "every sdk path answers success on any verb");
if (!ok) std::printf(" %s %s -> %d\n", method, target, statusOf(reply));
}
}
// Dispatch itself: base64 protobuf, and the gateway carries the hotfix urls.
std::string dispatch = request(kTestPort, "GET", "/query_dispatch");
check(statusOf(dispatch) == 200, "query_dispatch answers");
check(dispatch.find("\r\n\r\n") != std::string::npos && dispatch.size() > 40,
"and returns a body");
std::string gateway = request(
kTestPort, "GET", "/query_gateway?version=CNBETAWin4.5.53&language_type=3&platform_type=3");
check(statusOf(gateway) == 200, "query_gateway answers");
size_t split = gateway.find("\r\n\r\n");
std::string encoded = split == std::string::npos ? "" : gateway.substr(split + 4);
std::string decoded = util::base64Decode(encoded);
check(!decoded.empty(), "the gateway reply is base64");
// An empty ex_resource_url stalls the client at ~99% before it opens a session.
check(decoded.find("autopatch") != std::string::npos,
"and carries hotfix urls for the client version");
check(decoded.find("127.0.0.1") != std::string::npos, "and the game server address");
proto::GateServer gate;
check(gate.parse(reinterpret_cast<const uint8_t*>(decoded.data()), decoded.size()),
"the gateway reply parses");
// Without the lua revision the client keeps its base-package Lua.
check(gate.lua_version == "16399471", "and names the lua revision, from the lua url");
check(gate.i_fix_patch_revision == "0", "and the ifix revision");
check(!gate.asset_bundle_url_android.empty() &&
gate.asset_bundle_url_android == gate.asset_bundle_url,
"and the asset url again in 1335, as the official gateway does");
server.stop();
}
+292
View File
@@ -0,0 +1,292 @@
// Small assert-based tests; the wire codec is hand written so it gets the most cover.
#include <cstdio>
#include <string>
#include "core/util.h"
#include "net/cmd_ids.h"
#include "net/packet.h"
#include "net/session.h"
#include "proto/gen/protos.h"
#include "tests/harness.h"
namespace {
int g_failures = 0;
int g_checks = 0;
} // namespace
namespace testing {
void check(bool ok, const char* what) {
++g_checks;
if (!ok) {
++g_failures;
std::printf("FAIL %s\n", what);
}
}
int failures() { return g_failures; }
int checks() { return g_checks; }
} // namespace testing
namespace {
using testing::check;
void testVarint() {
pb::Writer w;
w.writeVarint(0);
w.writeVarint(1);
w.writeVarint(127);
w.writeVarint(128);
w.writeVarint(300);
w.writeVarint(0xFFFFFFFFull);
w.writeVarint(0xFFFFFFFFFFFFFFFFull);
pb::Reader r(w.data());
uint64_t v = 0;
check(r.readVarint(v) && v == 0, "varint 0");
check(r.readVarint(v) && v == 1, "varint 1");
check(r.readVarint(v) && v == 127, "varint 127");
check(r.readVarint(v) && v == 128, "varint 128");
check(r.readVarint(v) && v == 300, "varint 300");
check(r.readVarint(v) && v == 0xFFFFFFFFull, "varint u32 max");
check(r.readVarint(v) && v == 0xFFFFFFFFFFFFFFFFull, "varint u64 max");
check(r.eof(), "varint stream drained");
}
void testFixedAndZigzag() {
pb::Writer w;
w.writeFixed32(0xDEADBEEF);
w.writeFixed64(0x0123456789ABCDEFull);
w.writeFloat(1.5f);
w.writeDouble(-2.25);
w.writeSInt32(-1);
w.writeSInt64(-123456789);
pb::Reader r(w.data());
uint32_t f32 = 0;
uint64_t f64 = 0;
float f = 0;
double d = 0;
int32_t s32 = 0;
int64_t s64 = 0;
check(r.readFixed32(f32) && f32 == 0xDEADBEEF, "fixed32");
check(r.readFixed64(f64) && f64 == 0x0123456789ABCDEFull, "fixed64");
check(r.readFloat(f) && f == 1.5f, "float");
check(r.readDouble(d) && d == -2.25, "double");
check(r.readSInt32(s32) && s32 == -1, "sint32");
check(r.readSInt64(s64) && s64 == -123456789, "sint64");
}
void testMessageRoundTrip() {
proto::PlayerLoginScRsp rsp;
rsp.retcode = 0;
rsp.login_random = 0x1122334455667788ull;
rsp.server_timestamp_ms = 1750000000000ull;
rsp.stamina = 240;
auto& info = rsp.basic_info.emplace();
info.nickname = "Capybara";
info.level = 70;
info.world_level = 6;
info.hcoin = 999999;
std::string bytes = rsp.serialize();
proto::PlayerLoginScRsp back;
check(back.parse(reinterpret_cast<const uint8_t*>(bytes.data()), bytes.size()), "login parse");
check(back.login_random == rsp.login_random, "login_random survives");
check(back.server_timestamp_ms == rsp.server_timestamp_ms, "timestamp survives");
check(back.stamina == 240, "stamina survives");
check(static_cast<bool>(back.basic_info), "basic_info present");
check(back.basic_info->nickname == "Capybara", "nickname survives");
check(back.basic_info->level == 70, "level survives");
check(back.basic_info->hcoin == 999999, "hcoin survives");
// proto3 skips default values, so an all-default message is zero bytes.
proto::PlayerLoginFinishScRsp empty;
check(empty.serialize().empty(), "defaults are not encoded");
}
void testRepeatedAndNested() {
proto::GetBagScRsp bag;
for (uint32_t i = 0; i < 5; ++i) {
auto& item = bag.relic_list.emplace_back();
item.tid = 61000 + i;
item.level = 15;
item.unique_id = 100 + i;
}
std::string bytes = bag.serialize();
proto::GetBagScRsp back;
check(back.parse(reinterpret_cast<const uint8_t*>(bytes.data()), bytes.size()), "bag parse");
check(back.relic_list.size() == 5, "repeated message count");
check(back.relic_list[4].tid == 61004, "repeated message content");
}
void testPacketFraming() {
std::string body = "hello";
std::string a = net::encodePacket(cmd::PlayerLoginScRsp, {}, body);
std::string b = net::encodePacket(cmd::PlayerHeartBeatScRsp, {}, "");
std::string joined = a + b;
std::vector<net::Packet> packets;
std::string error;
check(net::decodePackets(joined, packets, &error), "framing decode");
check(packets.size() == 2, "two packets in one message");
check(packets[0].cmdId == cmd::PlayerLoginScRsp, "first cmd id");
check(packets[0].body == body, "first body");
check(packets[1].cmdId == cmd::PlayerHeartBeatScRsp, "second cmd id");
check(packets[1].body.empty(), "second body empty");
std::string broken = a;
broken[0] = 0;
packets.clear();
check(!net::decodePackets(broken, packets, &error), "bad magic rejected");
}
void testBase64() {
check(util::base64Encode("") == "", "b64 empty");
check(util::base64Encode("f") == "Zg==", "b64 1 byte");
check(util::base64Encode("fo") == "Zm8=", "b64 2 bytes");
check(util::base64Encode("foo") == "Zm9v", "b64 3 bytes");
check(util::base64Encode("foobar") == "Zm9vYmFy", "b64 6 bytes");
check(util::base64Decode("Zm9vYmFy") == "foobar", "b64 decode");
std::string binary("\x00\x01\xFF\xFE", 4);
check(util::base64Decode(util::base64Encode(binary)) == binary, "b64 binary round trip");
}
void testCmdIds() {
check(cmd::name(cmd::PlayerLoginCsReq) == "PlayerLoginCsReq", "cmd name lookup");
check(cmd::id("PlayerLoginScRsp") == cmd::PlayerLoginScRsp, "cmd id lookup");
check(cmd::id("NotAPacket") == 0, "unknown cmd id");
}
void testLineupCmdIds() {
// 716 is JoinLineupScRsp and 719 ReplaceLineupScRsp; the dump's own table had them
// mislabelled. Answering a join on 719 leaves the party UI waiting forever, so the
// names are corrected in proto/cmdids.txt.
check(cmd::JoinLineupScRsp == 716, "JoinLineupScRsp is 716");
check(cmd::ReplaceLineupScRsp == 719, "ReplaceLineupScRsp is 719");
check(cmd::name(716) == "JoinLineupScRsp", "716 names itself");
check(cmd::name(719) == "ReplaceLineupScRsp", "719 names itself");
// The generic fallback derives the response name from the request name, so
// ReplaceLineupCsReq now resolves even without a handler.
check(cmd::id("ReplaceLineupScRsp") == 719, "the reverse lookup agrees");
check(cmd::id("JoinLineupScRsp") == 716, "and for the join");
}
void testIdleTimeout() {
constexpr uint64_t kTimeout = 60000;
check(!net::isIdle(1000, 1000, kTimeout), "no time passed is not idle");
check(!net::isIdle(61000, 1000, kTimeout), "exactly the timeout is not idle yet");
check(net::isIdle(61001, 1000, kTimeout), "past the timeout is idle");
// The updater thread captures `now` and then walks the session list, so the receive
// thread can move `last` past it. Subtracting first underflows to ~1.8e19 and drops
// every live session mid-login, which is what "disconnected (idle timeout)" 156 ms
// into a session turned out to be.
check(!net::isIdle(1000, 1001, kTimeout), "activity newer than now is not idle");
check(!net::isIdle(0, 1, kTimeout), "and it cannot underflow at zero");
check(!net::isIdle(1000, 0xFFFFFFFFFFFFFFFFull, kTimeout), "nor at the top of the range");
}
void testFillPresence() {
// An absent sub-message and a present-but-empty one are different on the wire, and
// the client throws on the former. fill() is what makes the difference.
proto::GetCurBattleInfoScRsp bare;
check(bare.serialize().empty(), "an unfilled response is zero bytes");
check(!bare.battle_info, "and its sub-message is absent");
proto::GetCurBattleInfoScRsp filled;
filled.fill(1);
check(static_cast<bool>(filled.battle_info), "fill emplaces the sub-message");
std::string bytes = filled.serialize();
check(!bytes.empty(), "so the body is no longer empty");
proto::GetCurBattleInfoScRsp back;
check(back.parse(reinterpret_cast<const uint8_t*>(bytes.data()), bytes.size()),
"a filled response parses");
check(static_cast<bool>(back.battle_info), "and the sub-message survives as present");
check(back.battle_info->battle_id == 0, "still carrying nothing");
// Depth is honoured, so filling cannot walk a recursive type forever.
proto::GetCurBattleInfoScRsp zero;
zero.fill(0);
check(!zero.battle_info, "depth 0 fills nothing");
// One level down: SceneBattleInfo's own sub-messages appear only at depth 2.
proto::GetCurBattleInfoScRsp deep;
deep.fill(2);
check(deep.battle_info && static_cast<bool>(deep.battle_info->battle_action_event_id),
"depth 2 reaches the second level");
proto::GetCurBattleInfoScRsp shallow;
shallow.fill(1);
check(shallow.battle_info && !shallow.battle_info->battle_action_event_id,
"depth 1 stops at the first");
// The one that was disconnecting the session.
proto::GetRogueHandbookDataScRsp handbook;
handbook.fill(2);
check(!handbook.serialize().empty(), "the rogue handbook reply is not empty");
}
void testLargeMessage() {
// Exercises the length back-patch for payloads over 127 bytes.
proto::PlayerLoginScRsp rsp;
rsp.basic_info.emplace().nickname = std::string(5000, 'x');
std::string bytes = rsp.serialize();
proto::PlayerLoginScRsp back;
check(back.parse(reinterpret_cast<const uint8_t*>(bytes.data()), bytes.size()), "large parse");
check(back.basic_info && back.basic_info->nickname.size() == 5000, "large string survives");
}
void testAgainstProtoc() {
// Bytes produced by google protobuf for BattleBuff; covers packed repeated and a
// string->float map, the two shapes the generator is most likely to get wrong.
static const char kBytes[] =
"\x08\xb0\x85\x3d\x10\x01\x18\xff\xff\xff\xff\x0f\x20\xff\xff\xff\xff\x0f\x2a\x05\x00"
"\x01\x02\xac\x02\x32\x0c\x0a\x05\x52\x61\x74\x69\x6f\x15\x00\x00\x00\x3f\x32\x11\x0a"
"\x0a\x53\x6b\x69\x6c\x6c\x49\x6e\x64\x65\x78\x15\x00\x00\x80\x3f";
const size_t size = sizeof(kBytes) - 1;
proto::BattleBuff buff;
check(buff.parse(reinterpret_cast<const uint8_t*>(kBytes), size), "protoc bytes parse");
check(buff.id == 1000112, "protoc id");
check(buff.level == 1, "protoc level");
check(buff.owner_index == 0xFFFFFFFFu, "protoc owner_index");
check(buff.wave_flag == 0xFFFFFFFFu, "protoc wave_flag");
check(buff.target_index_list.size() == 4, "protoc packed count");
check(buff.target_index_list[3] == 300, "protoc packed multibyte value");
check(buff.dynamic_values.size() == 2, "protoc map count");
check(buff.dynamic_values["SkillIndex"] == 1.0f, "protoc map value");
check(buff.dynamic_values["Ratio"] == 0.5f, "protoc map second value");
// Re-encoding must produce the same wire bytes (map order is sorted either way).
std::string again = buff.serialize();
check(again.size() == size, "re-encoded size matches protoc");
check(again == std::string(kBytes, size), "re-encoded bytes match protoc");
}
} // namespace
int main() {
testVarint();
testFixedAndZigzag();
testMessageRoundTrip();
testRepeatedAndNested();
testPacketFraming();
testBase64();
testCmdIds();
testLineupCmdIds();
testIdleTimeout();
testFillPresence();
testLargeMessage();
testAgainstProtoc();
runGameTests();
runHttpTests();
runFlowTests();
std::printf("%d checks, %d failures\n", testing::checks(), testing::failures());
return testing::failures() == 0 ? 0 : 1;
}