1088 lines
38 KiB
C++
1088 lines
38 KiB
C++
// Offline end-to-end test for the net client. An in-process MockServer speaks
|
|
// the real wire protocol (framing + libsodium KX + scripted phase packets) over
|
|
// a localhost socket; AuthClient then GameClient are driven through the full
|
|
// auth -> char list -> select -> game flow with no external server.
|
|
#include "../src/net/auth_client.h"
|
|
#include "../src/net/game_client.h"
|
|
#include "../src/net/mark_client.h"
|
|
#include "../src/net/wire.h"
|
|
|
|
#include <arpa/inet.h>
|
|
#include <cerrno>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <fcntl.h>
|
|
#include <functional>
|
|
#include <netinet/in.h>
|
|
#include <string>
|
|
#include <sys/socket.h>
|
|
#include <unistd.h>
|
|
#include <vector>
|
|
|
|
using namespace mtnet;
|
|
|
|
static int g_fail = 0;
|
|
#define CHECK(c, msg) \
|
|
do { \
|
|
if (!(c)) { \
|
|
std::fprintf(stderr, "FAIL: %s\n", msg); \
|
|
++g_fail; \
|
|
} \
|
|
} while (0)
|
|
|
|
// --- MockServer: minimal server-side NetStream over one accepted socket -------
|
|
class MockServer {
|
|
public:
|
|
enum class Mode { Auth, Game, Mark };
|
|
|
|
explicit MockServer(Mode m) : m_mode(m) {}
|
|
~MockServer() {
|
|
if (m_conn >= 0) {
|
|
::close(m_conn);
|
|
}
|
|
if (m_listen >= 0) {
|
|
::close(m_listen);
|
|
}
|
|
}
|
|
|
|
uint16_t listen_ephemeral() {
|
|
m_listen = ::socket(AF_INET, SOCK_STREAM, 0);
|
|
if (m_listen < 0) {
|
|
return 0;
|
|
}
|
|
int one = 1;
|
|
setsockopt(m_listen, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
|
sockaddr_in a{};
|
|
a.sin_family = AF_INET;
|
|
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
|
a.sin_port = 0;
|
|
if (::bind(m_listen, (sockaddr *)&a, sizeof(a)) != 0 || ::listen(m_listen, 1) != 0) {
|
|
::close(m_listen);
|
|
m_listen = -1;
|
|
return 0;
|
|
}
|
|
socklen_t l = sizeof(a);
|
|
if (::getsockname(m_listen, (sockaddr *)&a, &l) != 0) {
|
|
::close(m_listen);
|
|
m_listen = -1;
|
|
return 0;
|
|
}
|
|
int fl = fcntl(m_listen, F_GETFL, 0);
|
|
fcntl(m_listen, F_SETFL, fl | O_NONBLOCK);
|
|
return ntohs(a.sin_port);
|
|
}
|
|
|
|
// Pump: accept, recv+decrypt, dispatch scripted responses, flush.
|
|
void poll() {
|
|
if (m_conn < 0) {
|
|
int c = ::accept(m_listen, nullptr, nullptr);
|
|
if (c < 0) {
|
|
return;
|
|
}
|
|
m_conn = c;
|
|
int fl = fcntl(m_conn, F_GETFL, 0);
|
|
fcntl(m_conn, F_SETFL, fl | O_NONBLOCK);
|
|
// server opens the conversation
|
|
m_cipher.initialize();
|
|
send_phase(PHASE_HANDSHAKE);
|
|
send_key_challenge();
|
|
}
|
|
|
|
uint8_t buf[4096];
|
|
ssize_t r = ::recv(m_conn, buf, sizeof(buf), 0);
|
|
if (r > 0) {
|
|
if (m_cipher.is_activated()) {
|
|
m_cipher.decrypt_in_place(buf, (size_t)r);
|
|
}
|
|
m_recv.write(buf, (size_t)r);
|
|
dispatch();
|
|
}
|
|
flush();
|
|
}
|
|
|
|
private:
|
|
void send_raw(const void *p, size_t n) {
|
|
uint8_t tmp[1024];
|
|
std::memcpy(tmp, p, n);
|
|
m_cipher.encrypt_in_place(tmp, n); // no-op until activated
|
|
m_send.write(tmp, n);
|
|
}
|
|
void flush() {
|
|
while (m_send.readable() > 0) {
|
|
ssize_t w = ::send(m_conn, m_send.read_ptr(), m_send.readable(), 0);
|
|
if (w > 0) {
|
|
m_send.discard((size_t)w);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void send_phase(uint8_t phase) {
|
|
GCPhase p{GC_PHASE, sizeof(GCPhase), phase};
|
|
send_raw(&p, sizeof(p));
|
|
}
|
|
void send_command(const std::string &text) {
|
|
std::vector<uint8_t> buf(sizeof(GCChat) + text.size() + 1);
|
|
GCChat h{};
|
|
h.header = GC_CHAT;
|
|
h.length = (uint16_t)buf.size();
|
|
h.type = CHAT_TYPE_COMMAND;
|
|
std::memcpy(buf.data(), &h, sizeof(h));
|
|
std::memcpy(buf.data() + sizeof(h), text.c_str(), text.size() + 1);
|
|
send_raw(buf.data(), buf.size());
|
|
}
|
|
void send_key_challenge() {
|
|
GCKeyChallenge kc{};
|
|
kc.header = GC_KEY_CHALLENGE;
|
|
kc.length = sizeof(kc);
|
|
m_cipher.get_public_key(kc.server_pk);
|
|
randombytes_buf(m_challenge, sizeof(m_challenge));
|
|
std::memcpy(kc.challenge, m_challenge, sizeof(m_challenge));
|
|
kc.server_time = 12345;
|
|
send_raw(&kc, sizeof(kc));
|
|
}
|
|
|
|
void dispatch() {
|
|
for (;;) {
|
|
DynHeader dh;
|
|
if (!m_recv.peek(&dh, sizeof(dh))) {
|
|
return;
|
|
}
|
|
if (dh.length < 4 || !m_recv.has(dh.length)) {
|
|
return;
|
|
}
|
|
switch (dh.header) {
|
|
case CG_KEY_RESPONSE: {
|
|
CGKeyResponse kr;
|
|
m_recv.read(&kr, sizeof(kr));
|
|
m_cipher.compute_server_keys(kr.client_pk);
|
|
bool ok = m_cipher.verify_challenge_response(m_challenge, kr.challenge_response);
|
|
if (!ok) {
|
|
std::fprintf(stderr, "MockServer: challenge verify FAILED\n");
|
|
}
|
|
// KeyComplete plaintext, then activate, then encrypted phase
|
|
GCKeyComplete kc{};
|
|
kc.header = GC_KEY_COMPLETE;
|
|
kc.length = sizeof(kc);
|
|
uint8_t token[32];
|
|
randombytes_buf(token, sizeof(token));
|
|
m_cipher.encrypt_token(token, sizeof(token), kc.encrypted_token, kc.nonce);
|
|
send_raw(&kc, sizeof(kc));
|
|
m_cipher.set_activated(true);
|
|
send_phase(m_mode == Mode::Auth ? PHASE_AUTH : PHASE_LOGIN);
|
|
break;
|
|
}
|
|
case CG_LOGIN3: {
|
|
CGLogin3 l;
|
|
m_recv.read(&l, sizeof(l));
|
|
GCAuthSuccess as{GC_AUTH_SUCCESS, sizeof(GCAuthSuccess), 0x0BADF00D, 1};
|
|
send_raw(&as, sizeof(as));
|
|
break;
|
|
}
|
|
case CG_LOGIN2: {
|
|
CGLogin2 l;
|
|
m_recv.read(&l, sizeof(l));
|
|
m_got_login_key = l.login_key;
|
|
GCLoginSuccess3 ok{};
|
|
ok.header = GC_LOGIN_SUCCESS3;
|
|
ok.length = sizeof(ok);
|
|
std::strcpy(ok.players[0].name, "Tester");
|
|
ok.players[0].id = 42;
|
|
ok.players[0].job = 0;
|
|
ok.players[0].level = 7;
|
|
ok.players[0].x = 100;
|
|
ok.players[0].y = 200;
|
|
send_raw(&ok, sizeof(ok));
|
|
send_phase(PHASE_SELECT);
|
|
break;
|
|
}
|
|
case CG_CHARACTER_SELECT: {
|
|
CGCharacterSelect s;
|
|
m_recv.read(&s, sizeof(s));
|
|
m_selected = s.player_index;
|
|
send_phase(PHASE_LOADING);
|
|
GCMainCharacter mc{};
|
|
mc.header = GC_MAIN_CHARACTER;
|
|
mc.length = sizeof(mc);
|
|
mc.vid = 1000;
|
|
mc.race = 0;
|
|
std::strcpy(mc.name, "Tester");
|
|
mc.x = 100;
|
|
mc.y = 200;
|
|
mc.z = 0;
|
|
send_raw(&mc, sizeof(mc));
|
|
// a nearby NPC + a move for it
|
|
GCCharacterAdd add{};
|
|
add.header = GC_CHARACTER_ADD;
|
|
add.length = sizeof(add);
|
|
add.vid = 2000;
|
|
add.race = 101;
|
|
add.x = 300;
|
|
add.y = 400;
|
|
send_raw(&add, sizeof(add));
|
|
GCMove mv{};
|
|
mv.header = GC_MOVE;
|
|
mv.length = sizeof(mv);
|
|
mv.vid = 2000;
|
|
mv.func = FUNC_MOVE;
|
|
mv.x = 800;
|
|
mv.y = 400;
|
|
mv.duration = 1000;
|
|
send_raw(&mv, sizeof(mv));
|
|
GCDigMotion dig{GC_DIG_MOTION, sizeof(GCDigMotion), 2000, 1000, 2};
|
|
send_raw(&dig, sizeof(dig));
|
|
send_phase(PHASE_GAME);
|
|
DynHeader guild_request{GC_REQUEST_MAKE_GUILD, sizeof(DynHeader)};
|
|
send_raw(&guild_request, sizeof(guild_request));
|
|
|
|
// item-mall: open + one item
|
|
{
|
|
GCMallOpen mo{GC_MALL_OPEN, sizeof(GCMallOpen), 45};
|
|
send_raw(&mo, sizeof(mo));
|
|
GCItemSet is{};
|
|
is.header = GC_MALL_SET;
|
|
is.length = sizeof(is);
|
|
is.pos = {WINDOW_MALL, 0};
|
|
is.vnum = 27993;
|
|
is.count = 1;
|
|
send_raw(&is, sizeof(is));
|
|
}
|
|
// cube: open + result list via GC_CHAT/COMMAND
|
|
send_command("cube open 20383");
|
|
send_command("cube r_list 20383 1 72723,1");
|
|
break;
|
|
}
|
|
case CG_MOVE: {
|
|
CGMove m;
|
|
m_recv.read(&m, sizeof(m));
|
|
m_last_move = m;
|
|
++m_move_count;
|
|
break;
|
|
}
|
|
case CG_CHARACTER_POSITION: {
|
|
CGCharacterPosition p;
|
|
m_recv.read(&p, sizeof(p));
|
|
m_last_character_position = p;
|
|
++m_character_position_count;
|
|
break;
|
|
}
|
|
case CG_SYNC_POSITION: {
|
|
std::vector<uint8_t> tmp(dh.length);
|
|
m_recv.read(tmp.data(), tmp.size());
|
|
if (tmp.size() >= sizeof(CGSyncPosition)) {
|
|
CGSyncPosition h{};
|
|
std::memcpy(&h, tmp.data(), sizeof(h));
|
|
const size_t payload = tmp.size() - sizeof(h);
|
|
if (h.length == tmp.size() && payload % sizeof(CGSyncPositionElement) == 0) {
|
|
m_sync_positions.clear();
|
|
for (size_t off = sizeof(h); off < tmp.size(); off += sizeof(CGSyncPositionElement)) {
|
|
CGSyncPositionElement e{};
|
|
std::memcpy(&e, tmp.data() + off, sizeof(e));
|
|
m_sync_positions.push_back(e);
|
|
}
|
|
++m_sync_position_count;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case CG_WARP: {
|
|
CGWarp p;
|
|
m_recv.read(&p, sizeof(p));
|
|
m_warp_seen = true;
|
|
GCWarp w{GC_WARP, sizeof(GCWarp), 4321, 8765, 0, 0};
|
|
send_raw(&w, sizeof(w));
|
|
break;
|
|
}
|
|
case CG_FISHING: {
|
|
CGFishing p;
|
|
m_recv.read(&p, sizeof(p));
|
|
m_fishing_seen = true;
|
|
m_last_fishing_dir = p.dir;
|
|
GCFishing f{GC_FISHING, sizeof(GCFishing), FISHING_START, 1000, p.dir};
|
|
send_raw(&f, sizeof(f));
|
|
break;
|
|
}
|
|
case CG_DUNGEON: {
|
|
CGDungeon p;
|
|
m_recv.read(&p, sizeof(p));
|
|
m_dungeon_seen = true;
|
|
std::vector<uint8_t> out(sizeof(GCDungeon) + 8);
|
|
GCDungeon h{GC_DUNGEON, (uint16_t)out.size(), DUNGEON_DESTINATION_POSITION};
|
|
std::memcpy(out.data(), &h, sizeof(h));
|
|
int32_t x = 3210, y = 6540;
|
|
std::memcpy(out.data() + sizeof(h), &x, 4);
|
|
std::memcpy(out.data() + sizeof(h) + 4, &y, 4);
|
|
send_raw(out.data(), out.size());
|
|
break;
|
|
}
|
|
case CG_ATTACK: {
|
|
CGAttack a;
|
|
m_recv.read(&a, sizeof(a));
|
|
m_last_attack = a;
|
|
++m_attack_count;
|
|
break;
|
|
}
|
|
case CG_USE_SKILL: {
|
|
CGUseSkill s;
|
|
m_recv.read(&s, sizeof(s));
|
|
m_last_use_skill = s;
|
|
++m_use_skill_count;
|
|
break;
|
|
}
|
|
case CG_SHOOT: {
|
|
CGShoot s;
|
|
m_recv.read(&s, sizeof(s));
|
|
m_last_shoot = s;
|
|
++m_shoot_count;
|
|
break;
|
|
}
|
|
case CG_FLY_TARGETING:
|
|
case CG_ADD_FLY_TARGETING: {
|
|
CGFlyTargeting t;
|
|
m_recv.read(&t, sizeof(t));
|
|
if (dh.header == CG_FLY_TARGETING) {
|
|
m_last_primary_fly_targeting = t;
|
|
++m_primary_fly_targeting_count;
|
|
} else {
|
|
m_last_fly_targeting = t;
|
|
m_last_fly_header = dh.header;
|
|
}
|
|
++m_fly_targeting_count;
|
|
break;
|
|
}
|
|
case CG_TARGET: {
|
|
CGTarget t;
|
|
m_recv.read(&t, sizeof(t));
|
|
m_last_target_vid = t.vid;
|
|
break;
|
|
}
|
|
case CG_QUICKSLOT_ADD: {
|
|
CGQuickSlotAdd q;
|
|
m_recv.read(&q, sizeof(q));
|
|
m_last_quickslot_add = q;
|
|
break;
|
|
}
|
|
case CG_QUICKSLOT_DEL: {
|
|
CGQuickSlotDel q;
|
|
m_recv.read(&q, sizeof(q));
|
|
m_last_quickslot_del = q;
|
|
break;
|
|
}
|
|
case CG_QUICKSLOT_SWAP: {
|
|
CGQuickSlotSwap q;
|
|
m_recv.read(&q, sizeof(q));
|
|
m_last_quickslot_swap = q;
|
|
break;
|
|
}
|
|
case CG_SCRIPT_SELECT_ITEM: {
|
|
CGScriptSelectItem p;
|
|
m_recv.read(&p, sizeof(p));
|
|
m_last_script_selection = p.selection;
|
|
++m_script_selection_count;
|
|
break;
|
|
}
|
|
case CG_QUEST_CANCEL: {
|
|
CGQuestCancel p;
|
|
m_recv.read(&p, sizeof(p));
|
|
m_quest_cancel_count++;
|
|
break;
|
|
}
|
|
case CG_CHAT: {
|
|
std::vector<uint8_t> tmp(dh.length);
|
|
m_recv.read(tmp.data(), dh.length);
|
|
// [u16 head][u16 len][u8 type][utf8 text][NUL]
|
|
if (dh.length >= 6) {
|
|
m_last_chat_type = tmp[4];
|
|
m_last_chat.assign(reinterpret_cast<char *>(tmp.data() + 5),
|
|
dh.length - 5 - 1);
|
|
if (m_last_chat_type == CHAT_TYPE_COMMAND) {
|
|
m_last_command = m_last_chat;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case CG_MYSHOP: {
|
|
std::vector<uint8_t> tmp(dh.length);
|
|
m_recv.read(tmp.data(), dh.length);
|
|
CGMyShopHead h{};
|
|
std::memcpy(&h, tmp.data(), sizeof(h));
|
|
m_myshop_sign.assign(h.sign, strnlen(h.sign, sizeof(h.sign)));
|
|
m_myshop_count = h.count;
|
|
m_myshop_items.clear();
|
|
for (int i = 0; i < h.count; ++i) {
|
|
MyShopItem e{};
|
|
std::memcpy(&e, tmp.data() + sizeof(h) + i * sizeof(MyShopItem),
|
|
sizeof(MyShopItem));
|
|
m_myshop_items.push_back(e);
|
|
}
|
|
m_myshop_seen = true;
|
|
break;
|
|
}
|
|
case CG_MALL_CHECKOUT: {
|
|
CGMallCheckout c;
|
|
m_recv.read(&c, sizeof(c));
|
|
m_last_mall_checkout = c;
|
|
m_mall_checkout_seen = true;
|
|
break;
|
|
}
|
|
case CG_ITEM_MOVE: {
|
|
CGItemMove m;
|
|
m_recv.read(&m, sizeof(m));
|
|
m_last_item_move = m;
|
|
++m_item_move_count;
|
|
break;
|
|
}
|
|
case CG_ITEM_USE: {
|
|
CGItemUse u;
|
|
m_recv.read(&u, sizeof(u));
|
|
m_last_item_use = u;
|
|
break;
|
|
}
|
|
case CG_ITEM_DROP2: {
|
|
CGItemDrop2 d;
|
|
m_recv.read(&d, sizeof(d));
|
|
m_last_item_drop2 = d;
|
|
break;
|
|
}
|
|
case CG_ITEM_USE_TO_ITEM: {
|
|
CGItemUseToItem u;
|
|
m_recv.read(&u, sizeof(u));
|
|
m_last_item_use_to_item = u;
|
|
break;
|
|
}
|
|
case CG_ITEM_GIVE: {
|
|
CGGiveItem g;
|
|
m_recv.read(&g, sizeof(g));
|
|
m_last_give_item = g;
|
|
break;
|
|
}
|
|
case CG_ITEM_PICKUP: {
|
|
CGItemPickUp p;
|
|
m_recv.read(&p, sizeof(p));
|
|
m_last_pickup_vid = p.vid;
|
|
break;
|
|
}
|
|
case CG_PARTY_INVITE: {
|
|
CGPartyInvite p;
|
|
m_recv.read(&p, sizeof(p));
|
|
m_last_party_invite_vid = p.vid;
|
|
break;
|
|
}
|
|
case CG_PARTY_USE_SKILL: {
|
|
CGPartyUseSkill p;
|
|
m_recv.read(&p, sizeof(p));
|
|
m_last_party_skill = p;
|
|
++m_party_skill_count;
|
|
break;
|
|
}
|
|
case CG_SHOP: {
|
|
std::vector<uint8_t> tmp(dh.length);
|
|
m_recv.read(tmp.data(), dh.length);
|
|
m_last_shop_sub = tmp[4];
|
|
if (dh.length >= 7) {
|
|
m_last_shop_a = tmp[5];
|
|
m_last_shop_b = tmp[6];
|
|
}
|
|
break;
|
|
}
|
|
case CG_EXCHANGE: {
|
|
CGExchange x;
|
|
m_recv.read(&x, sizeof(x));
|
|
m_last_exchange_sub = x.subheader;
|
|
m_last_exchange_arg1 = x.arg1;
|
|
break;
|
|
}
|
|
case CG_SAFEBOX_CHECKIN: {
|
|
CGSafeboxCheckin s;
|
|
m_recv.read(&s, sizeof(s));
|
|
m_last_safebox_checkin_pos = s.safe_pos;
|
|
m_last_safebox_checkin_cell = s.inv_pos.cell;
|
|
break;
|
|
}
|
|
case CG_CLIENT_VERSION: {
|
|
CGClientVersion v;
|
|
m_recv.read(&v, sizeof(v));
|
|
m_client_version_seen = true;
|
|
break;
|
|
}
|
|
case CG_CHARACTER_CREATE: {
|
|
CGCreateCharacter c;
|
|
m_recv.read(&c, sizeof(c));
|
|
m_last_create = c;
|
|
m_create_seen = true;
|
|
if (std::string(c.name) == "taken") {
|
|
GCCreateFailure f{GC_PLAYER_CREATE_FAILURE, sizeof(GCCreateFailure), 1};
|
|
send_raw(&f, sizeof(f));
|
|
} else {
|
|
GCPlayerCreateSuccess ok{};
|
|
ok.header = GC_PLAYER_CREATE_SUCCESS;
|
|
ok.length = sizeof(ok);
|
|
ok.slot = c.index;
|
|
ok.player.id = 500 + c.index;
|
|
std::strncpy(ok.player.name, c.name, CHARACTER_NAME_MAX_LEN);
|
|
ok.player.job = (uint8_t)c.job;
|
|
ok.player.level = 1;
|
|
ok.player.st = c.str;
|
|
ok.player.ht = c.con;
|
|
ok.player.dx = c.dex;
|
|
ok.player.iq = c.intel;
|
|
send_raw(&ok, sizeof(ok));
|
|
}
|
|
break;
|
|
}
|
|
case CG_CHARACTER_DELETE: {
|
|
CGDeleteCharacter d;
|
|
m_recv.read(&d, sizeof(d));
|
|
m_last_delete = d;
|
|
m_delete_seen = true;
|
|
if (std::string(d.private_code, strnlen(d.private_code, PRIVATE_CODE_LENGTH)) == "1234567") {
|
|
GCDeleteSuccess ok{GC_PLAYER_DELETE_SUCCESS, sizeof(GCDeleteSuccess), d.index};
|
|
send_raw(&ok, sizeof(ok));
|
|
} else {
|
|
DynHeader bad{GC_PLAYER_DELETE_WRONG_SOCIAL_ID, sizeof(DynHeader)};
|
|
send_raw(&bad, sizeof(bad));
|
|
}
|
|
break;
|
|
}
|
|
case CG_CHANGE_NAME: {
|
|
CGChangeName c;
|
|
m_recv.read(&c, sizeof(c));
|
|
m_last_change_name = c;
|
|
m_change_name_seen = true;
|
|
// Character slot 0 in the scripted list has pid 42.
|
|
GCChangeName n{GC_CHANGE_NAME, sizeof(GCChangeName), 42, {}};
|
|
std::strncpy(n.name, c.name, CHARACTER_NAME_MAX_LEN);
|
|
send_raw(&n, sizeof(n));
|
|
break;
|
|
}
|
|
case CG_MARK_LOGIN: {
|
|
CGMarkLogin l;
|
|
m_recv.read(&l, sizeof(l));
|
|
m_mark_login_handle = l.handle;
|
|
m_mark_login_key = l.random_key;
|
|
break;
|
|
}
|
|
case CG_SYMBOL_CRC: {
|
|
CGSymbolCRC c;
|
|
m_recv.read(&c, sizeof(c));
|
|
m_symbol_crc_seen = true;
|
|
m_symbol_crc_gid = c.guild_id;
|
|
std::vector<uint8_t> out(sizeof(GCSymbolData) + 200);
|
|
GCSymbolData h{GC_SYMBOL_DATA, (uint16_t)out.size(), c.guild_id};
|
|
std::memcpy(out.data(), &h, sizeof(h));
|
|
for (size_t i = sizeof(h); i < out.size(); ++i)
|
|
out[i] = (uint8_t)(i * 3);
|
|
send_raw(out.data(), out.size());
|
|
break;
|
|
}
|
|
case CG_MARK_UPLOAD: {
|
|
CGMarkUpload u;
|
|
m_recv.read(&u, sizeof(u));
|
|
m_mark_upload_gid = u.gid;
|
|
m_mark_upload_seen = true;
|
|
m_mark_upload_sum = 0;
|
|
for (size_t k = 0; k < sizeof(u.image); ++k) {
|
|
m_mark_upload_sum += u.image[k];
|
|
}
|
|
break;
|
|
}
|
|
case CG_GUILD_SYMBOL_UPLOAD: {
|
|
std::vector<uint8_t> tmp(dh.length);
|
|
m_recv.read(tmp.data(), dh.length);
|
|
CGSymbolUpload head{};
|
|
std::memcpy(&head, tmp.data(), sizeof(head));
|
|
m_symbol_upload_gid = head.handle;
|
|
m_symbol_upload_bytes = (uint32_t)(dh.length - (uint16_t)sizeof(CGSymbolUpload));
|
|
m_symbol_upload_seen = true;
|
|
break;
|
|
}
|
|
default:
|
|
// consume unknown
|
|
std::vector<uint8_t> tmp(dh.length);
|
|
m_recv.read(tmp.data(), dh.length);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
Mode m_mode;
|
|
int m_listen = -1, m_conn = -1;
|
|
ByteBuffer m_recv, m_send;
|
|
SecureCipher m_cipher;
|
|
uint8_t m_challenge[32]{};
|
|
|
|
public:
|
|
uint32_t m_got_login_key = 0;
|
|
int m_selected = -1;
|
|
CGMove m_last_move{};
|
|
int m_move_count = 0;
|
|
CGCharacterPosition m_last_character_position{};
|
|
int m_character_position_count = 0;
|
|
std::vector<CGSyncPositionElement> m_sync_positions;
|
|
int m_sync_position_count = 0;
|
|
CGAttack m_last_attack{};
|
|
int m_attack_count = 0;
|
|
CGUseSkill m_last_use_skill{};
|
|
int m_use_skill_count = 0;
|
|
CGShoot m_last_shoot{};
|
|
int m_shoot_count = 0;
|
|
CGFlyTargeting m_last_primary_fly_targeting{};
|
|
int m_primary_fly_targeting_count = 0;
|
|
CGFlyTargeting m_last_fly_targeting{};
|
|
uint16_t m_last_fly_header = 0;
|
|
int m_fly_targeting_count = 0;
|
|
uint32_t m_last_target_vid = 0;
|
|
CGQuickSlotAdd m_last_quickslot_add{};
|
|
CGQuickSlotDel m_last_quickslot_del{};
|
|
CGQuickSlotSwap m_last_quickslot_swap{};
|
|
uint32_t m_last_script_selection = 0;
|
|
int m_script_selection_count = 0;
|
|
int m_quest_cancel_count = 0;
|
|
uint8_t m_last_chat_type = 0xFF;
|
|
std::string m_last_chat;
|
|
std::string m_last_command;
|
|
std::string m_myshop_sign;
|
|
int m_myshop_count = -1;
|
|
std::vector<MyShopItem> m_myshop_items;
|
|
bool m_myshop_seen = false;
|
|
CGMallCheckout m_last_mall_checkout{};
|
|
bool m_mall_checkout_seen = false;
|
|
CGItemMove m_last_item_move{};
|
|
int m_item_move_count = 0;
|
|
CGItemUse m_last_item_use{};
|
|
CGItemDrop2 m_last_item_drop2{};
|
|
CGItemUseToItem m_last_item_use_to_item{};
|
|
CGGiveItem m_last_give_item{};
|
|
uint32_t m_last_pickup_vid = 0;
|
|
uint32_t m_last_party_invite_vid = 0;
|
|
CGPartyUseSkill m_last_party_skill{};
|
|
int m_party_skill_count = 0;
|
|
uint8_t m_last_shop_sub = 0xFF, m_last_shop_a = 0, m_last_shop_b = 0;
|
|
uint8_t m_last_exchange_sub = 0xFF;
|
|
uint32_t m_last_exchange_arg1 = 0;
|
|
uint8_t m_last_safebox_checkin_pos = 0xFF;
|
|
uint16_t m_last_safebox_checkin_cell = 0xFFFF;
|
|
bool m_client_version_seen = false;
|
|
CGCreateCharacter m_last_create{};
|
|
bool m_create_seen = false;
|
|
CGDeleteCharacter m_last_delete{};
|
|
bool m_delete_seen = false;
|
|
CGChangeName m_last_change_name{};
|
|
bool m_change_name_seen = false;
|
|
bool m_warp_seen = false;
|
|
bool m_fishing_seen = false;
|
|
uint8_t m_last_fishing_dir = 0;
|
|
bool m_dungeon_seen = false;
|
|
uint32_t m_mark_login_handle = 0, m_mark_login_key = 0;
|
|
bool m_mark_upload_seen = false;
|
|
uint32_t m_mark_upload_gid = 0;
|
|
uint64_t m_mark_upload_sum = 0;
|
|
bool m_symbol_upload_seen = false;
|
|
uint32_t m_symbol_upload_gid = 0, m_symbol_upload_bytes = 0;
|
|
bool m_symbol_crc_seen = false;
|
|
uint32_t m_symbol_crc_gid = 0;
|
|
};
|
|
|
|
// --- driver -----------------------------------------------------------------
|
|
|
|
template <class Client>
|
|
static void run(MockServer &srv, Client &cli, int max_iters,
|
|
const std::function<bool()> &stop) {
|
|
for (int i = 0; i < max_iters && !stop(); ++i) {
|
|
srv.poll();
|
|
cli.process();
|
|
usleep(1000);
|
|
}
|
|
}
|
|
|
|
int main() {
|
|
CHECK(SecureCipher::ensure_sodium_init(), "sodium init");
|
|
|
|
// --- auth server: connect -> KX -> CG_LOGIN3 -> GC_AUTH_SUCCESS ---
|
|
uint32_t login_key = 0;
|
|
{
|
|
MockServer srv(MockServer::Mode::Auth);
|
|
uint16_t port = srv.listen_ephemeral();
|
|
if (port == 0) {
|
|
std::fprintf(stdout, "SKIP: localhost socket bind unavailable in this environment\n");
|
|
return 0;
|
|
}
|
|
AuthClient auth("admin", "123456789");
|
|
CHECK(auth.connect("127.0.0.1", port), "auth connect");
|
|
run(srv, auth, 5000, [&] { return auth.done(); });
|
|
CHECK(auth.done(), "auth reached a result");
|
|
CHECK(auth.handshaked(), "auth: cipher activated");
|
|
CHECK(auth.sent_login(), "auth: CG_LOGIN3 sent at PHASE_AUTH");
|
|
CHECK(auth.success(), "auth: GC_AUTH_SUCCESS");
|
|
CHECK(auth.login_key() == 0x0BADF00D, "auth: login_key");
|
|
login_key = auth.login_key();
|
|
}
|
|
|
|
// --- game server: connect -> KX -> CG_LOGIN2 -> char list -> select -> GAME ---
|
|
{
|
|
MockServer srv(MockServer::Mode::Game);
|
|
uint16_t port = srv.listen_ephemeral();
|
|
GameClient game("admin", login_key);
|
|
CHECK(game.connect("127.0.0.1", port), "game connect");
|
|
|
|
run(srv, game, 5000, [&] { return game.char_list_ready(); });
|
|
CHECK(game.char_list_ready(), "game: got char list");
|
|
CHECK(srv.m_got_login_key == 0x0BADF00D, "game: server received our login_key");
|
|
CHECK(game.chars().size() == 1, "game: 1 character");
|
|
if (game.chars().size() == 1) {
|
|
CHECK(game.chars()[0].name == "Tester", "game: char name");
|
|
CHECK(game.chars()[0].level == 7, "game: char level");
|
|
CHECK(game.chars()[0].x == 100 && game.chars()[0].y == 200, "game: char position");
|
|
}
|
|
|
|
// --- CG_CHANGE_NAME -> GC_CHANGE_NAME updates the local slot list ---
|
|
CHECK(game.change_name(0, "Renamed"), "game: change_name sent");
|
|
run(srv, game, 3000, [] { return false; });
|
|
CHECK(srv.m_change_name_seen && srv.m_last_change_name.index == 0,
|
|
"game: server got CG_CHANGE_NAME idx=0");
|
|
CHECK(std::string(srv.m_last_change_name.name) == "Renamed",
|
|
"game: change name byte-exact");
|
|
CHECK(game.chars().size() == 1 && game.chars()[0].name == "Renamed",
|
|
"game: GC_CHANGE_NAME patched slot name");
|
|
{
|
|
auto evs = game.drain_name_events();
|
|
bool got = false;
|
|
for (const auto &e : evs)
|
|
if (e.pid == 42 && e.name == "Renamed")
|
|
got = true;
|
|
CHECK(got, "game: name change event for pid 42");
|
|
}
|
|
|
|
// --- CG_CHARACTER_CREATE -> GC_PLAYER_CREATE_SUCCESS patches the slot list ---
|
|
CHECK(game.create_character(1, "Newbie", 1, 0, /*con*/3, /*int*/2, /*str*/4, /*dex*/1),
|
|
"game: create_character sent");
|
|
run(srv, game, 3000, [&] { return game.chars().size() == 2; });
|
|
CHECK(srv.m_create_seen && std::string(srv.m_last_create.name) == "Newbie",
|
|
"game: server got CG_CHARACTER_CREATE (name byte-exact)");
|
|
CHECK(srv.m_last_create.job == 1 && srv.m_last_create.str == 4 && srv.m_last_create.con == 3,
|
|
"game: create job/stats byte-exact");
|
|
CHECK(game.chars().size() == 2, "game: slot list grew to 2 after create");
|
|
{
|
|
const GameClient::CharSlot *ns = nullptr;
|
|
for (const auto &c : game.chars())
|
|
if (c.index == 1)
|
|
ns = &c;
|
|
CHECK(ns && ns->name == "Newbie" && ns->job == 1, "game: new slot 1 = Newbie/job1");
|
|
}
|
|
{
|
|
auto evs = game.drain_char_events();
|
|
bool got = false;
|
|
for (const auto &e : evs)
|
|
if (e.kind == GameClient::CharEvent::CreateOk && e.slot == 1)
|
|
got = true;
|
|
CHECK(got, "game: CreateOk event for slot 1");
|
|
}
|
|
|
|
// --- create name clash -> GC_PLAYER_CREATE_FAILURE(type) ---
|
|
CHECK(game.create_character(2, "taken", 0, 0, 1, 1, 1, 1), "game: create (clash) sent");
|
|
run(srv, game, 3000, [&] {
|
|
for (const auto &e : game.peek_char_events())
|
|
if (e.kind == GameClient::CharEvent::CreateFail)
|
|
return true;
|
|
return false;
|
|
});
|
|
{
|
|
auto evs = game.drain_char_events();
|
|
bool fail = false;
|
|
for (const auto &e : evs)
|
|
if (e.kind == GameClient::CharEvent::CreateFail && e.fail_type == 1)
|
|
fail = true;
|
|
CHECK(fail, "game: CreateFail(type=1) event");
|
|
CHECK(game.chars().size() == 2, "game: clash did not add a slot");
|
|
}
|
|
|
|
// --- CG_CHARACTER_DELETE: wrong code rejected, right code removes the slot ---
|
|
CHECK(game.delete_character(1, "0000000"), "game: delete (wrong code) sent");
|
|
run(srv, game, 3000, [&] {
|
|
for (const auto &e : game.peek_char_events())
|
|
if (e.kind == GameClient::CharEvent::DeleteFail)
|
|
return true;
|
|
return false;
|
|
});
|
|
{
|
|
auto evs = game.drain_char_events();
|
|
bool df = false;
|
|
for (const auto &e : evs)
|
|
if (e.kind == GameClient::CharEvent::DeleteFail)
|
|
df = true;
|
|
CHECK(df, "game: DeleteFail (wrong social id)");
|
|
CHECK(game.chars().size() == 2, "game: wrong code kept the slot");
|
|
}
|
|
CHECK(game.delete_character(1, "1234567"), "game: delete (right code) sent");
|
|
run(srv, game, 3000, [&] { return game.chars().size() == 1; });
|
|
CHECK(srv.m_delete_seen && srv.m_last_delete.index == 1, "game: server got CG_CHARACTER_DELETE idx=1");
|
|
CHECK(game.chars().size() == 1, "game: slot 1 removed after delete");
|
|
{
|
|
auto evs = game.drain_char_events();
|
|
bool ok = false;
|
|
for (const auto &e : evs)
|
|
if (e.kind == GameClient::CharEvent::DeleteOk && e.slot == 1)
|
|
ok = true;
|
|
CHECK(ok, "game: DeleteOk event for slot 1");
|
|
}
|
|
|
|
CHECK(game.select_character(0), "game: select_character sent");
|
|
run(srv, game, 5000, [&] { return game.in_game(); });
|
|
CHECK(game.in_game(), "game: reached PHASE_GAME");
|
|
CHECK(srv.m_selected == 0, "game: server saw our selection");
|
|
run(srv, game, 200, [] { return false; });
|
|
CHECK(game.drain_guild_make_requests() == 1,
|
|
"game: GC_REQUEST_MAKE_GUILD surfaced as an event");
|
|
CHECK(game.send_warp(), "game: CG_WARP sent");
|
|
run(srv, game, 200, [] { return false; });
|
|
CHECK(srv.m_warp_seen, "game: server got CG_WARP");
|
|
{
|
|
auto warps = game.world().drain_warps();
|
|
CHECK(!warps.empty() && warps.back().x == 4321 && warps.back().y == 8765,
|
|
"game: GC_WARP destination surfaced");
|
|
}
|
|
CHECK(game.send_fishing(36), "game: CG_FISHING sent");
|
|
run(srv, game, 200, [] { return false; });
|
|
CHECK(srv.m_fishing_seen && srv.m_last_fishing_dir == 36,
|
|
"game: server got CG_FISHING direction");
|
|
{
|
|
auto fishing = game.world().drain_fishing_events();
|
|
CHECK(!fishing.empty() && fishing.back().subheader == FISHING_START &&
|
|
fishing.back().info == 1000 && fishing.back().dir == 36,
|
|
"game: GC_FISHING event surfaced");
|
|
}
|
|
CHECK(game.send_dungeon(), "game: CG_DUNGEON sent");
|
|
run(srv, game, 200, [] { return false; });
|
|
CHECK(srv.m_dungeon_seen, "game: server got CG_DUNGEON");
|
|
{
|
|
auto dungeons = game.world().drain_dungeon_events();
|
|
CHECK(!dungeons.empty() && dungeons.back().subheader == DUNGEON_DESTINATION_POSITION &&
|
|
dungeons.back().has_destination && dungeons.back().x == 3210 && dungeons.back().y == 6540,
|
|
"game: GC_DUNGEON destination surfaced");
|
|
}
|
|
|
|
// game-phase packets flowed into the EntityStore
|
|
run(srv, game, 200, [] { return false; }); // drain a few more polls
|
|
mtnet::EntityStore &wm = game.world();
|
|
const EntityStore &w = wm;
|
|
CHECK(w.main_vid() == 1000, "world: main vid from GC_MAIN_CHARACTER");
|
|
CHECK(w.get(1000) && w.get(1000)->name == "Tester", "world: main entity");
|
|
CHECK(srv.m_client_version_seen,
|
|
"client auto-sent CG_CLIENT_VERSION after GC_MAIN_CHARACTER");
|
|
CHECK(w.get(2000) != nullptr, "world: NPC spawned from GC_CHARACTER_ADD");
|
|
{
|
|
auto digs = wm.drain_dig_motions();
|
|
CHECK(!digs.empty() && digs.back().vid == 2000 && digs.back().target_vid == 1000 && digs.back().count == 2,
|
|
"world: dig motion surfaced");
|
|
}
|
|
CHECK(w.get(2000) && w.get(2000)->moving, "world: NPC moving from GC_MOVE");
|
|
// interpolate it forward
|
|
wm.set_now(500);
|
|
wm.tick();
|
|
CHECK(wm.get(2000) && std::abs(wm.get(2000)->x - 550.0f) < 2.0f,
|
|
"world: NPC interpolated halfway (300->800)");
|
|
|
|
// --- outbound in-game intents reach the server, byte-exact ---
|
|
CHECK(game.send_move(FUNC_MOVE, 0, /*rot=*/18, 12345, 67890, 111), "send_move");
|
|
CHECK(game.send_character_position(3), "send_character_position");
|
|
CHECK(game.send_sync_positions({{2000, 321, 654}, {3000, -12, 99}}), "send_sync_positions");
|
|
CHECK(!game.send_sync_positions({}), "send_sync_positions rejects empty batch");
|
|
CHECK(!game.send_sync_positions(std::vector<CGSyncPositionElement>(17)),
|
|
"send_sync_positions rejects batches over 16");
|
|
CHECK(game.send_attack(/*motion=*/4, 2000), "send_attack");
|
|
CHECK(game.send_fly_targeting(2000, 800, 400), "send_fly_targeting");
|
|
CHECK(game.send_add_fly_targeting(3000, 700, 500), "send_add_fly_targeting");
|
|
CHECK(game.send_shoot(/*skill=*/7), "send_shoot");
|
|
CHECK(game.send_use_skill(/*vnum=*/1, /*target_vid=*/2000), "send_use_skill");
|
|
CHECK(game.send_target(2000), "send_target");
|
|
CHECK(game.send_quickslot_add(/*pos=*/35, /*type=*/2, /*ref=*/16), "send_quickslot_add");
|
|
CHECK(game.send_quickslot_del(/*pos=*/34), "send_quickslot_del");
|
|
CHECK(game.send_quickslot_swap(/*pos=*/32, /*change=*/33), "send_quickslot_swap");
|
|
CHECK(game.send_script_select_item(/*selection=*/91), "send_script_select_item");
|
|
CHECK(game.send_quest_cancel(), "send_quest_cancel");
|
|
CHECK(game.send_chat(1, "hello"), "send_chat");
|
|
run(srv, game, 300, [] { return false; });
|
|
CHECK(srv.m_move_count == 1 && srv.m_last_move.func == FUNC_MOVE &&
|
|
srv.m_last_move.rot == 18 && srv.m_last_move.x == 12345 &&
|
|
srv.m_last_move.y == 67890 && srv.m_last_move.time == 111,
|
|
"server got CG_MOVE with our fields");
|
|
CHECK(srv.m_character_position_count == 1 && srv.m_last_character_position.position == 3 &&
|
|
srv.m_last_character_position.length == sizeof(CGCharacterPosition),
|
|
"server got CG_CHARACTER_POSITION{3}");
|
|
CHECK(srv.m_sync_position_count == 1 && srv.m_sync_positions.size() == 2 &&
|
|
srv.m_sync_positions[0].vid == 2000 && srv.m_sync_positions[0].x == 321 &&
|
|
srv.m_sync_positions[0].y == 654 && srv.m_sync_positions[1].vid == 3000 &&
|
|
srv.m_sync_positions[1].x == -12 && srv.m_sync_positions[1].y == 99,
|
|
"server got CG_SYNC_POSITION batch");
|
|
CHECK(srv.m_attack_count == 1 && srv.m_last_attack.type == 4 &&
|
|
srv.m_last_attack.victim_vid == 2000,
|
|
"server got CG_ATTACK{motion=4, vid=2000}");
|
|
CHECK(srv.m_fly_targeting_count == 2 && srv.m_primary_fly_targeting_count == 1 &&
|
|
srv.m_last_primary_fly_targeting.target_vid == 2000 &&
|
|
srv.m_last_primary_fly_targeting.x == 800 &&
|
|
srv.m_last_primary_fly_targeting.y == 400,
|
|
"server got CG_FLY_TARGETING{vid=2000,x=800,y=400}");
|
|
CHECK(srv.m_last_fly_header == CG_ADD_FLY_TARGETING &&
|
|
srv.m_last_fly_targeting.target_vid == 3000 &&
|
|
srv.m_last_fly_targeting.x == 700 && srv.m_last_fly_targeting.y == 500,
|
|
"server got CG_ADD_FLY_TARGETING{vid=3000,x=700,y=500}");
|
|
CHECK(srv.m_shoot_count == 1 && srv.m_last_shoot.type == 7,
|
|
"server got CG_SHOOT{skill=7}");
|
|
CHECK(srv.m_use_skill_count == 1 && srv.m_last_use_skill.vnum == 1 &&
|
|
srv.m_last_use_skill.target_vid == 2000,
|
|
"server got CG_USE_SKILL{vnum=1, target=2000}");
|
|
CHECK(srv.m_last_target_vid == 2000, "server got CG_TARGET{2000}");
|
|
CHECK(srv.m_last_quickslot_add.pos == 35 && srv.m_last_quickslot_add.slot.type == 2 &&
|
|
srv.m_last_quickslot_add.slot.position == 16,
|
|
"server got CG_QUICKSLOT_ADD{35,skill,16}");
|
|
CHECK(srv.m_last_quickslot_del.pos == 34, "server got CG_QUICKSLOT_DEL{34}");
|
|
CHECK(srv.m_last_quickslot_swap.pos == 32 && srv.m_last_quickslot_swap.change_pos == 33,
|
|
"server got CG_QUICKSLOT_SWAP{32,33}");
|
|
CHECK(srv.m_script_selection_count == 1 && srv.m_last_script_selection == 91,
|
|
"server got CG_SCRIPT_SELECT_ITEM{91}");
|
|
CHECK(srv.m_quest_cancel_count == 1, "server got CG_QUEST_CANCEL");
|
|
CHECK(srv.m_last_chat_type == 1 && srv.m_last_chat == "hello",
|
|
"server got CG_CHAT{type=1,'hello'}");
|
|
|
|
// --- outbound item intents ---
|
|
CHECK(game.send_item_move(WINDOW_INVENTORY, 5, WINDOW_EQUIPMENT, 1, 1), "send_item_move");
|
|
CHECK(game.send_item_use(WINDOW_INVENTORY, 12), "send_item_use");
|
|
CHECK(game.send_item_drop2(WINDOW_INVENTORY, 13, 0, 3), "send_item_drop2");
|
|
CHECK(game.send_item_use_to_item(WINDOW_INVENTORY, 14, WINDOW_EQUIPMENT, 90), "send_item_use_to_item");
|
|
CHECK(game.send_give_item(2000, WINDOW_INVENTORY, 15, 2), "send_give_item");
|
|
CHECK(game.send_item_pickup(5000), "send_item_pickup");
|
|
run(srv, game, 300, [] { return false; });
|
|
CHECK(srv.m_item_move_count == 1 && srv.m_last_item_move.pos.window_type == WINDOW_INVENTORY
|
|
&& srv.m_last_item_move.pos.cell == 5
|
|
&& srv.m_last_item_move.change_pos.window_type == WINDOW_EQUIPMENT
|
|
&& srv.m_last_item_move.change_pos.cell == 1,
|
|
"server got CG_ITEM_MOVE inv5 -> equip1");
|
|
CHECK(srv.m_last_item_use.pos.window_type == WINDOW_INVENTORY
|
|
&& srv.m_last_item_use.pos.cell == 12,
|
|
"server got CG_ITEM_USE inv12");
|
|
CHECK(srv.m_last_item_drop2.pos.cell == 13 && srv.m_last_item_drop2.gold == 0 &&
|
|
srv.m_last_item_drop2.count == 3, "server got CG_ITEM_DROP2 inv13 x3");
|
|
CHECK(srv.m_last_item_use_to_item.source_pos.cell == 14 &&
|
|
srv.m_last_item_use_to_item.target_pos.window_type == WINDOW_EQUIPMENT &&
|
|
srv.m_last_item_use_to_item.target_pos.cell == 90,
|
|
"server got CG_ITEM_USE_TO_ITEM inv14 -> equip90");
|
|
CHECK(srv.m_last_give_item.target_vid == 2000 && srv.m_last_give_item.pos.cell == 15 &&
|
|
srv.m_last_give_item.count == 2, "server got CG_ITEM_GIVE target2000 inv15 x2");
|
|
CHECK(srv.m_last_pickup_vid == 5000, "server got CG_ITEM_PICKUP{5000}");
|
|
|
|
// --- P8 outbound social / shop / storage intents ---
|
|
CHECK(game.send_party_invite(2000), "send_party_invite");
|
|
CHECK(game.send_party_use_skill(/*skill_index=*/4, /*target_vid=*/2000), "send_party_use_skill");
|
|
CHECK(game.send_shop_buy(/*pos=*/3, /*count=*/2), "send_shop_buy");
|
|
CHECK(game.send_exchange_start(2000), "send_exchange_start");
|
|
CHECK(game.send_safebox_checkin(/*safe_pos=*/7, WINDOW_INVENTORY, 9), "send_safebox_checkin");
|
|
run(srv, game, 300, [] { return false; });
|
|
CHECK(srv.m_last_party_invite_vid == 2000, "server got CG_PARTY_INVITE{2000}");
|
|
CHECK(srv.m_party_skill_count == 1 && srv.m_last_party_skill.skill_index == 4 &&
|
|
srv.m_last_party_skill.target_vid == 2000,
|
|
"server got CG_PARTY_USE_SKILL{4,2000}");
|
|
CHECK(srv.m_last_shop_sub == SHOP_CG_BUY && srv.m_last_shop_a == 2 && srv.m_last_shop_b == 3,
|
|
"server got CG_SHOP BUY{count=2,pos=3}");
|
|
CHECK(srv.m_last_exchange_sub == EXCHANGE_SUB_START && srv.m_last_exchange_arg1 == 2000,
|
|
"server got CG_EXCHANGE START{vid=2000}");
|
|
CHECK(srv.m_last_safebox_checkin_pos == 7 && srv.m_last_safebox_checkin_cell == 9,
|
|
"server got CG_SAFEBOX_CHECKIN{safe=7,inv=9}");
|
|
|
|
// --- item-mall: server pushed OPEN + one item on entering game ---
|
|
CHECK(w.mall_open() && w.mall_size() == 45, "mall: client saw GC_MALL_OPEN{45}");
|
|
CHECK(w.mall_slot(0).vnum == 27993, "mall: client saw GC_MALL_SET slot 0");
|
|
CHECK(game.send_mall_checkout(/*mall_pos=*/0, WINDOW_INVENTORY, 11), "send_mall_checkout");
|
|
|
|
// --- cube: server pushed open + result list; client asks for materials/make ---
|
|
CHECK(w.cube().open && w.cube().npc_vnum == 20383, "cube: client saw 'cube open'");
|
|
CHECK(w.cube().results.size() == 1 && w.cube().results[0].vnum == 72723,
|
|
"cube: client saw 'cube r_list'");
|
|
CHECK(game.send_cube_material_info(0, 1), "send_cube_material_info");
|
|
CHECK(game.send_cube_make(0), "send_cube_make");
|
|
|
|
// --- private (PC) shop ---
|
|
std::vector<MyShopItem> shop_items;
|
|
MyShopItem si{};
|
|
si.vnum = 11901;
|
|
si.count = 1;
|
|
si.pos = {WINDOW_INVENTORY, 4};
|
|
si.price = 250000;
|
|
si.display_pos = 0;
|
|
shop_items.push_back(si);
|
|
CHECK(game.send_open_private_shop("Cheap swords", shop_items), "send_open_private_shop");
|
|
|
|
run(srv, game, 300, [] { return false; });
|
|
CHECK(srv.m_mall_checkout_seen && srv.m_last_mall_checkout.mall_pos == 0 &&
|
|
srv.m_last_mall_checkout.inv_pos.cell == 11,
|
|
"server got CG_MALL_CHECKOUT{mall=0,inv=11}");
|
|
CHECK(srv.m_last_command == "/cube make 0", "server got last /cube command");
|
|
CHECK(srv.m_myshop_seen && srv.m_myshop_sign == "Cheap swords" && srv.m_myshop_count == 1 &&
|
|
srv.m_myshop_items.size() == 1 && srv.m_myshop_items[0].vnum == 11901 &&
|
|
srv.m_myshop_items[0].price == 250000 && srv.m_myshop_items[0].pos.cell == 4,
|
|
"server got CG_MYSHOP{sign,1 item byte-exact}");
|
|
}
|
|
|
|
// --- guild-mark side connection: KX -> CG_MARK_LOGIN -> CG_MARK_UPLOAD ---
|
|
{
|
|
MockServer srv(MockServer::Mode::Mark);
|
|
uint16_t port = srv.listen_ephemeral();
|
|
MarkClient mc(/*handle=*/0xABCD1234, /*random_key=*/0x0F0F0F0F);
|
|
uint32_t px[GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT];
|
|
uint64_t want_sum = 0;
|
|
for (int i = 0; i < GUILD_MARK_WIDTH * GUILD_MARK_HEIGHT; ++i) {
|
|
px[i] = 0x11223344u + (uint32_t)i;
|
|
const uint8_t *b = (const uint8_t *)&px[i];
|
|
want_sum += b[0] + b[1] + b[2] + b[3];
|
|
}
|
|
mc.set_upload_mark(/*guild_id=*/77, px);
|
|
CHECK(mc.connect("127.0.0.1", port), "mark: connect");
|
|
run(srv, mc, 5000, [&] { return srv.m_mark_upload_seen && mc.upload_done(); });
|
|
CHECK(mc.logged_in(), "mark: CG_MARK_LOGIN sent after KX");
|
|
CHECK(srv.m_mark_login_handle == 0xABCD1234 && srv.m_mark_login_key == 0x0F0F0F0F,
|
|
"mark: server got handle + random_key");
|
|
CHECK(srv.m_mark_upload_seen, "mark: server got CG_MARK_UPLOAD");
|
|
CHECK(srv.m_mark_upload_gid == 77, "mark: upload guild id");
|
|
CHECK(srv.m_mark_upload_sum == want_sum, "mark: 16x12 image bytes intact");
|
|
CHECK(mc.upload_done(), "mark: upload_done once send buffer drained");
|
|
}
|
|
|
|
// --- guild-symbol upload: CG_GUILD_SYMBOL_UPLOAD head + raw bytes ---
|
|
{
|
|
MockServer srv(MockServer::Mode::Mark);
|
|
uint16_t port = srv.listen_ephemeral();
|
|
MarkClient mc(0xABCD1234, 0x0F0F0F0F);
|
|
std::vector<uint8_t> sym(2000);
|
|
for (size_t i = 0; i < sym.size(); ++i) {
|
|
sym[i] = (uint8_t)(i * 7);
|
|
}
|
|
mc.set_upload_symbol(/*guild_id=*/88, sym);
|
|
CHECK(mc.connect("127.0.0.1", port), "symbol: connect");
|
|
run(srv, mc, 5000, [&] { return srv.m_symbol_upload_seen && mc.upload_done(); });
|
|
CHECK(srv.m_symbol_upload_seen, "symbol: server got CG_GUILD_SYMBOL_UPLOAD");
|
|
CHECK(srv.m_symbol_upload_gid == 88, "symbol: upload guild id (in handle field)");
|
|
CHECK(srv.m_symbol_upload_bytes == 2000, "symbol: raw file bytes appended");
|
|
}
|
|
|
|
// --- guild-symbol download: CG_SYMBOL_CRC -> GC_SYMBOL_DATA ---
|
|
{
|
|
MockServer srv(MockServer::Mode::Mark);
|
|
uint16_t port = srv.listen_ephemeral();
|
|
MarkClient mc(0xABCD1234, 0x0F0F0F0F);
|
|
mc.set_download_symbol(/*guild_id=*/99);
|
|
CHECK(mc.connect("127.0.0.1", port), "symbol download: connect");
|
|
run(srv, mc, 5000, [&] { return mc.complete(); });
|
|
CHECK(srv.m_symbol_crc_seen && srv.m_symbol_crc_gid == 99,
|
|
"symbol download: server got CG_SYMBOL_CRC");
|
|
CHECK(mc.complete() && mc.symbol_guild_id() == 99 && mc.symbol_data().size() == 200,
|
|
"symbol download: GC_SYMBOL_DATA received");
|
|
}
|
|
|
|
if (g_fail) {
|
|
std::fprintf(stderr, "%d check(s) failed\n", g_fail);
|
|
return 1;
|
|
}
|
|
std::printf("all checks passed\n");
|
|
return 0;
|
|
}
|