// 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 #include #include #include #include #include #include #include #include #include #include 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); 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; bind(m_listen, (sockaddr *)&a, sizeof(a)); ::listen(m_listen, 1); socklen_t l = sizeof(a); getsockname(m_listen, (sockaddr *)&a, &l); 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 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 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 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 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(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 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 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 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 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 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 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 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 static void run(MockServer &srv, Client &cli, int max_iters, const std::function &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(); 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(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 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 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; }