init(server/game): rewrite the server in c++
This commit is contained in:
1 parent
63385b7e87
commit
04b14f5a22
273 files changed
+3450394
No files matched your search
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user