// 批次 2V1-d/2V2: the real 40250 login → select → loading → game path, offline. // // The 40250 root scripts (intrologin.py, introselect.py, introloading.py, networkmodule.py) drive the // ported CPythonNetworkStream and CAccountConnector against FakeLoginServer (port_login_flow_server.h), // which answers the way the 40250 game server does. The test only plays the player: // // 1. frames advance the logo phase into introLogin.LoginWindow; // 2. LoginWindow.Connect(id, pwd) → net.ConnectToAccountServer: handshake + key agreement with the // auth server, CG_LOGIN3, GC_AUTH_SUCCESS; the account connector hands the login key to the stream, // which connects to the game server and sends CG_LOGIN2; // 3. GC_EMPIRE, PHASE_SELECT, GC_LOGIN_SUCCESS_NEWSLOT open introSelect.SelectCharacterWindow; // 4. SelectCharacterWindow.StartGame() → (3 s later) net.DirectEnter(0): the stream reconnects to the // slot's addr/port, logs in again, and in direct-enter mode the select phase goes straight to // introLoading.LoadingWindow, which sends CG_CHARACTER_SELECT; // 5. PHASE_LOADING, GC_MAIN_CHARACTER, GC_CHARACTER_POINTS: the loading phase answers the main // character with CG_CLIENT_VERSION(2), the server's proof the client reached Loading; // 6. (批次 2V2) the loading steps end in net.StartGame(): networkModule.SetGamePhase opens // game.GameWindow, whose Open sends CG_ENTERGAME; // 7. the server's Entergame answer (the actor's insert packets, PHASE_GAME, the Entergame burst) is // read by the ported GamePhase without an unknown packet, and a final ping gets its pong. // // The server checks every sequence byte against the 40250 table, so a skipped or extra sequenced // packet fails the run. // // port_login_flow_test <40250 Client dir> [live] // // With `live` (批次 2V1-e, ctest port.login_live) the same path runs against a real 40250 server and // the client's own state is the proof: the character list arrived, the loading window got // LoadData from GC_MAIN_CHARACTER, which CPythonNetworkStream::RecvMainCharacter answers with // CG_CLIENT_VERSION, game.GameWindow is open, which sent CG_ENTERGAME, and 10 s of the real game // phase later it is still open and connected with no unknown packet. Everything about the // server comes from the environment and nothing of it is printed: // // MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD required; without them the test is skipped // MT_LIVE_AUTH_PORT (11000), MT_LIVE_GAME_PORT (13000, channel 1), MT_LIVE_SLOT (0) // // Exit 77 (ctest SKIP) when the client is missing, unless MT_ASSETS_STRICT=1, which fails instead. #include "ScriptLib/StdAfx.h" #include "../src/platform/ScriptLib/PythonBoot.h" #include "../src/platform/ScriptLib/PythonHost.h" #include "../src/platform/UserInterface/UserInterface.h" #include "../src/platform/EterBase/TraceErrorObserver.h" #include "../src/platform/EterLib/RenderCommands3D.h" #include "GameLib/StdAfx.h" #include "GameLib/RaceManager.h" #include "GameLib/RaceData.h" #include "GameLib/RaceMotionData.h" #include "GameLib/FlyingData.h" #include "GameLib/FlyingObjectManager.h" #include "GameLib/ItemManager.h" #include "UserInterface/PythonItem.h" #include "UserInterface/StdAfx.h" #include "UserInterface/PythonExchange.h" #include "UserInterface/PythonTextTail.h" #include "UserInterface/PythonCharacterManager.h" #include "UserInterface/InstanceBase.h" #include "UserInterface/PythonBackground.h" #include "EterGrnLib/StdAfx.h" #include "EterGrnLib/LODController.h" #include "EterGrnLib/ModelInstance.h" #include "EterGrnLib/Model.h" #include "port_login_flow_server.h" #include #include #include #include #include #include #include #include #include #include #include static int g_failures = 0; #define CHECK(cond) \ do { \ if (!(cond)) { \ std::fprintf(stderr, "%s:%d: CHECK(%s)\n", __FILE__, __LINE__, #cond); \ ++g_failures; \ } \ } while (0) // TraceError lines; the script thread writes them while the host thread waits for its baton. static std::mutex g_syserr_mutex; static std::vector g_syserr; // The two headers CheckPacket saw last (UserInterface/PythonNetworkStream.cpp). extern int g_iLastPacket[2]; // CPythonNetworkStream::RecvDefaultPacket's CP949 "unhandled packet header %d, state %s": a phase // dispatcher dropped the whole recv buffer. static const char kUnhandledPacket[] = "\xc3\xb3\xb8\xae\xb5\xc7\xc1\xf6 \xbe\xca\xc0\xba \xc6\xd0\xc5\xb6 \xc7\xec\xb4\xf5"; static void on_trace_error(const char* line) { std::lock_guard lock(g_syserr_mutex); g_syserr.emplace_back(line); // RecvDefaultPacket does not print the preceding headers; the packet before a stray header is // the one whose size disagrees with the server. if (std::string(line).find(kUnhandledPacket) != std::string::npos) std::fprintf(stderr, "port_login_flow_test: unhandled packet after headers %d, %d\n", g_iLastPacket[0], g_iLastPacket[1]); } static int count_syserr(const char* needle) { std::lock_guard lock(g_syserr_mutex); int n = 0; for (const auto& line : g_syserr) n += line.find(needle) != std::string::npos; return n; } static bool py(const std::string& source) { std::string error; if (PythonBoot::RunLine(source.c_str(), &error)) return true; std::fprintf(stderr, "python: %s\n%s\n", source.c_str(), error.c_str()); return false; } // Evaluates a Python expression on the host thread without raising. static bool py_true(const char* expression) { PyObject* globals = PyModule_GetDict(PyImport_AddModule((char*) "__main__")); PyObject* result = PyRun_String(expression, Py_eval_input, globals, globals); if (!result) { PyErr_Clear(); return false; } const bool truth = PyObject_IsTrue(result) == 1; Py_DECREF(result); return truth; } // Runs frames (~60 per second of wall time) until `done` or the deadline. static bool pump_until(double seconds, const std::function& done) { const auto deadline = std::chrono::steady_clock::now() + std::chrono::duration(seconds); while (std::chrono::steady_clock::now() < deadline && PythonBoot::IsAppLooping()) { PythonBoot::UIUpdate(); if (done()) return true; std::this_thread::sleep_for(std::chrono::milliseconds(16)); } return done(); } static void dump(FakeLoginServer& server) { std::fprintf(stderr, "server events:"); for (const auto& e : server.Events()) std::fprintf(stderr, " %s", e.c_str()); std::fprintf(stderr, "\nserver error: %s\n", server.Error().c_str()); } static std::string env(const char* name, const char* fallback = "") { const char* value = std::getenv(name); return value && *value ? value : fallback; } // Hands a C string to __main__ as a Python object, so account data never goes through source text. static void py_set(const char* name, const std::string& value) { PyObject* object = PyString_FromString(value.c_str()); PyDict_SetItemString(PyModule_GetDict(PyImport_AddModule((char*) "__main__")), name, object); Py_DECREF(object); } int main(int argc, char** argv) { const char* strict = std::getenv("MT_ASSETS_STRICT"); const bool is_strict = strict && std::string(strict) == "1"; const std::string client = argc > 1 ? argv[1] : ""; const std::string stdlib = argc > 2 ? argv[2] : PythonHost::DefaultStdLibPath(); const bool live = argc > 3 && std::string(argv[3]) == "live"; if (client.empty() || chdir(client.c_str()) != 0 || access("pack/Index", 0) != 0) { std::fprintf(stderr, "port_login_flow_test: no 40250 Client/pack at '%s'\n", client.c_str()); return is_strict ? 1 : 77; } std::string host = "127.0.0.1", login = FakeLoginServer::kLogin, password = FakeLoginServer::kPassword; int auth_port = 0, game_port = 0, slot = 0; std::unique_ptr server; if (live) { host = env("MT_LIVE_HOST"); login = env("MT_LIVE_LOGIN"); password = env("MT_LIVE_PASSWORD"); if (host.empty() || login.empty() || password.empty()) { std::fprintf(stderr, "port_login_flow_test: live mode needs MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD\n"); return 77; } auth_port = std::atoi(env("MT_LIVE_AUTH_PORT", "11000").c_str()); game_port = std::atoi(env("MT_LIVE_GAME_PORT", "13000").c_str()); slot = std::atoi(env("MT_LIVE_SLOT", "0").c_str()); } else { server = std::make_unique(); if (!server->Start()) { std::fprintf(stderr, "port_login_flow_test: cannot listen on loopback\n"); return 1; } auth_port = server->AuthPort(); game_port = server->GamePort(); } auto failed = [&] { return server && server->Failed(); }; SetTraceErrorObserver(on_trace_error); PackSingletons(); CHECK(PackInitialize("pack")); std::string error; CHECK(PythonBoot::Start(stdlib.c_str(), &error)); PythonBoot::SetUISize(800, 600); const bool ran = PythonBoot::RunMainScript("", &error); if (!ran) std::fprintf(stderr, "RunMainScript: %s\n", error.c_str()); CHECK(ran && PythonBoot::IsAppLooping()); CHECK(py("import __main__, networkModule, introLogin, introSelect, introLoading\n" "__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]")); CHECK(pump_until(20, [] { return py_true("isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"); })); // 2. The login button's path (LoginWindow.Connect), pointed at the fake or the live server. py_set("_host", host); py_set("_login", login); py_set("_password", password); char ports[128]; std::snprintf(ports, sizeof(ports), "_auth_port, _game_port, _slot = %d, %d, %d", auth_port, game_port, slot); CHECK(py(ports)); CHECK(py("_stream.SetConnectInfo(_host, _game_port, _host, _auth_port)\n" "_stream.curPhaseWindow.Connect(_login, _password)\n" "del __main__._login, __main__._password")); // 3. The character list. const bool selecting = pump_until(live ? 30 : 20, [&] { return failed() || ((!server || server->Has("game1:login2")) && py_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)")); }); CHECK(selecting && !failed()); if (!selecting) { // The login window reports failures in its popup (LOGIN_FAILURE, connect errors). py("import sys\n" "_w = _stream.curPhaseWindow\n" "_msg = _stream.popupWindow and _stream.popupWindow.IsShow() and _stream.popupWindow.GetChild('message').GetText()\n" "sys.stderr.write('phase window %s, popup %r\\n' % (_w.__class__.__name__, _msg))"); } if (server) { CHECK(server->Has("auth:login3") && server->Has("auth:closed")); CHECK(py("import net\n" "_name = net.GetAccountCharacterSlotDataString(0, net.ACCOUNT_CHARACTER_SLOT_NAME)\n" "assert _name == '" + std::string(FakeLoginServer::kCharacterName) + "', _name\n" "assert net.GetEmpireID() == 1")); } else if (selecting) { CHECK(py("import net\n" "assert net.GetAccountCharacterSlotDataString(_slot, net.ACCOUNT_CHARACTER_SLOT_NAME), 'slot is empty'\n" "assert net.GetEmpireID() in (1, 2, 3), net.GetEmpireID()")); } // 3b. intrologin's channel status list (__RequestServerStateList / NotifyChannelState): ServerStateChecker // connects to the first channel, sends CG_STATE_CHECKER and reports each channel the answer lists // by port. TChannelStatus::nPort is a short, so the fake server's ephemeral state port (> 32767) // never matches its own channel; offline the proof is the second channel (13002) it also lists. // Live the game port answers for the channels the db knows, the game port among them. { const int state_port = server ? server->StatePort() : game_port; const int key = server ? 2 : 1; char channels[256]; std::snprintf(channels, sizeof(channels), "ServerStateChecker.AddChannel(1, _host, %d)\nServerStateChecker.AddChannel(2, _host, %d)\n" "_channel_key = %d\n", state_port, FakeLoginServer::kOtherChannelPort, key); CHECK(py(std::string("import ServerStateChecker\n" "class _ChannelStates:\n" " def __init__(self): self.states = {}\n" " def NotifyChannelState(self, key, state): self.states[key] = state\n" "_channel_states = _ChannelStates()\n" "ServerStateChecker.Create(_channel_states)\n" "ServerStateChecker.Initialize()\n") + channels + "ServerStateChecker.Request()")); CHECK(pump_until(10, [] { return py("ServerStateChecker.Update()") && py_true("_channel_key in _channel_states.states"); })); py("import sys\nsys.stderr.write('port_login_flow_test: channel states %r\\n' % _channel_states.states)"); CHECK(py_true("_channel_states.states.get(_channel_key, 0) > 0")); if (server) CHECK(server->Has("state:checker")); } // 3c. SetSelectCharacterPhase ran localeInfo.LoadLocaleData() → app.LoadLocaleData(app.GetLocalePath()): // CItemManager read item_list.txt/item_proto/itemdesc.txt and CPythonNonPlayer read mob_proto. { CItemData* sword = nullptr; CHECK(CItemManager::Instance().GetItemDataPointer(19, &sword) && sword); if (sword) { std::printf("port_login_flow_test: item 19 %s type %u refine %u\n", sword->GetName(), sword->GetType(), sword->GetRefine()); CHECK(sword->GetType() == CItemData::ITEM_TYPE_WEAPON && sword->GetRefine() == 9); CHECK(sword->GetModelThing() != nullptr); } CHECK(py("import nonplayer\n" "_mob = nonplayer.GetMonsterName(101)\n" "assert _mob, 'mob 101 has no name'\n" "import sys\nsys.stderr.write('port_login_flow_test: mob 101 %s\\n' % _mob)")); } // 4-5. Start the game on the slot: DirectEnter, the second game login and the loading phase. if (selecting) { CHECK(py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()")); // GC_MAIN_CHARACTER → LoadingWindow.LoadData(x, y), then CG_CLIENT_VERSION in the same call. const char* loaded = "isinstance(_stream.curPhaseWindow, introLoading.LoadingWindow) and " "getattr(_stream.curPhaseWindow, 'playerX', None) is not None"; const bool loading = pump_until(live ? 40 : 30, [&] { return failed() || (server ? server->Has("game2:client_version") : py_true(loaded)); }); CHECK(loading && !failed()); CHECK(py_true(loaded)); // 6. introLoading's update steps end in net.StartGame(); the next CPythonNetworkStream::Process calls // networkModule's SetGamePhase, which builds game.GameWindow (interfaceModule.MakeInterface) and // opens it, and GameWindow.Open sends CG_ENTERGAME. CHECK(py("import game")); const char* gaming = "isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()"; const bool entered = pump_until(30, [&] { return failed() || (py_true(gaming) && (!server || server->Has("game2:entergame"))); }); CHECK(entered && !failed()); // 7. (批次 2V2-b) The server answers CG_ENTERGAME with the character's own insert packets and // PHASE_GAME, then the Entergame burst (PVP/land list, time, channel, greet chat, ...). GamePhase // has to read all of it: an unknown or mis-sized packet makes CheckPacket log "Unknown packet // header" and drop the receive buffer. Offline the fake server ends with a ping the client // must answer; live the stream has to stay in the game window and connected for 10 s. const bool burst = pump_until(live ? 10 : 30, [&] { return failed() || (server && server->Has("game2:burst_pong")); }); CHECK(live || (burst && !failed())); CHECK(py_true(gaming)); CHECK(py("import net\nassert net.IsConnect(), 'disconnected in the game phase'")); CHECK(count_syserr("Unknown packet header") == 0); CHECK(count_syserr(kUnhandledPacket) == 0); CHECK(count_syserr("does not handle this header") == 0); // The fake greet notice went through RecvChatPacket into the ported CPythonChat, where // uichat's chat set (created by the game interface) counts it. CHECK(live || py("import chat\nassert any(chat.GetLineCount(i) > 0 for i in range(4)), 'greet notice not in chat'")); // playerSettingModule ran its race registration through the real chrmgr module: the warrior // (race 0) has a general-mode wait motion in its CRaceData. CHECK(py("import chr, chrmgr\nassert not hasattr(chr, '__port_stub__') and not hasattr(chrmgr, '__port_stub__')")); CRaceData* race = nullptr; const CRaceData::TMotionVector* waits = nullptr; CHECK(CRaceManager::Instance().GetRaceDataPointer(0, &race) && race && race->GetMotionVectorPointer(CRaceMotionData::MODE_GENERAL, CRaceMotionData::NAME_WAIT, &waits) && waits && !waits->empty()); // (批次 2V2-d) GC_CHARACTER_ADD for the player's own VID went through RecvCharacterAppendPacket and // CNetworkActorManager into CPythonCharacterManager: the main instance exists and SetRace ran on it // (offline the fake server sends a warrior, race 0), and CHAR_ADDITIONAL_INFO found the actor. CInstanceBase* main_instance = CPythonCharacterManager::Instance().GetMainInstancePtr(); CHECK(main_instance != nullptr); CHECK(live || (main_instance && main_instance->GetRace() == 0)); CHECK(count_syserr("TPacketGCCharacterAdditionalInfo") == 0); // (批次 2V2-d.2) SetRace/SetShape loaded the body .gr2 through the Granny runtime into PART_MAIN, and // game.GameWindow's OnUpdate/OnRender (app.UpdateGame/RenderGame) play the WAIT motion: the // sampled bone matrices change between frames. CGrannyLODController* lod = main_instance ? main_instance->GetGraphicThingInstanceRef().GetLODControllerPointer(CRaceData::PART_MAIN) : nullptr; CGrannyModelInstance* model_instance = lod ? lod->GetModelInstance() : nullptr; CGrannyModel* model = model_instance ? model_instance->GetModel() : nullptr; CHECK(model && model->GetMeshCount() > 0); // The material's diffuse map resolved through GrannyGetMaterialTextureByType to a loaded image. CGrannyMaterialPalette* palette = model ? const_cast(&model->GetMaterialPalette()) : nullptr; CHECK(palette && palette->GetMaterialCount() > 0 && palette->GetMaterialRef(0).GetImagePointer(0)); const int bone_count = model ? model->GetGrannyModelPointer()->Skeleton->BoneCount : 0; CHECK(bone_count > 1); auto bones = [&] { std::vector out; for (int i = 0; i < bone_count; ++i) { const float* m = model_instance->GetBoneMatrixPointer(i); out.insert(out.end(), m, m + 16); } return out; }; if (bone_count > 1) { const std::vector before = bones(); CHECK(pump_until(3, [&] { return bones() != before; })); } // (批次 2V2-e.1) RenderGame drew the character through CStateManager into the recording device: // a frame holds a textured, lit triangle draw (the PNT vertex format of RenderWithOneTexture) // under CPythonGraphic::SetPerspective's right-handed projection (_34 = -1), and the camera that // __UpdateCamera centred on the main actor puts every vertex of it on screen. auto on_screen = [](const Render3DDraw& d) { auto mul = [](const float* v, const float* m, float* o) { for (int c = 0; c < 4; ++c) o[c] = v[0] * m[c] + v[1] * m[4 + c] + v[2] * m[8 + c] + v[3] * m[12 + c]; }; for (size_t i = 0; i + 2 < d.positions.size(); i += 3) { const float local[4] = {d.positions[i], d.positions[i + 1], d.positions[i + 2], 1.0f}; float world[4], view[4], clip[4]; mul(local, d.world, world); mul(world, d.view, view); mul(view, d.proj, clip); if (clip[3] <= 0.0f || std::fabs(clip[0]) > clip[3] || std::fabs(clip[1]) > clip[3]) return false; } return true; }; auto character_draw = [&] { for (const Render3DDraw& d : Render3DDraws()) if (!d.pretransformed && !d.lines && !d.indices.empty() && !d.texture0.empty() && !d.normals.empty() && !d.uv0.empty() && d.proj[11] == -1.0f && on_screen(d)) return true; return false; }; CHECK(pump_until(3, character_draw)); // 7. (批次 2V3) Keyboard movement through the original path: the key reaches game.GameWindow's // onPressKeyDict, MoveUp calls player.SetSingleDIKKeyState(DIK_UP, True), CPythonPlayer turns // it into NEW_MoveToDirection on the main instance, and CPythonPlayerEventHandler::OnMove sends // CG_MOVE FUNC_MOVE. Releasing the key stops the actor (OnStop: FUNC_WAIT at the new position). TPixelPosition start = {}; if (main_instance) main_instance->NEW_GetPixelPosition(&start); auto walked = [&] { TPixelPosition now = {}; main_instance->NEW_GetPixelPosition(&now); return std::hypot(now.x - start.x, now.y - start.y); }; PythonBoot::UIKey(DIK_UP, true); CHECK(main_instance && pump_until(3, [&] { return walked() > 100.0f; })); PythonBoot::UIKey(DIK_UP, false); CHECK(main_instance && pump_until(2, [&] { return !main_instance->IsWalking(); })); const float distance = main_instance ? walked() : 0.0f; std::printf("port_login_flow_test: DIK_UP walked %.0f cm\n", distance); if (server) { // The fake server records "game2:move "; x, y are the TPixelPosition // (y negated back, as SendCharacterStatePacket does) plus the map base the server gave. auto moves = [&] { std::vector out; for (const auto& e : server->Events()) if (e.rfind("game2:move ", 0) == 0) out.push_back(e); return out; }; CHECK(pump_until(2, [&] { auto m = moves(); return m.size() >= 2 && m.back().rfind("game2:move 0 ", 0) == 0; })); const auto m = moves(); CHECK(!m.empty() && m.front().rfind("game2:move 1 0 ", 0) == 0); for (const auto& e : m) std::printf("port_login_flow_test: %s\n", e.c_str()); } // The terrain attribute map (attr.atr) under the actor: CInstanceBase's block/water/ban-PK checks read it // through CPythonBackground::isAttrOn. The actor stands on a free cell; its 256x256 tile has blocked cells. if (main_instance) { TPixelPosition here = {}; main_instance->NEW_GetPixelPosition(&here); CPythonBackground& bg = CPythonBackground::Instance(); CHECK(!bg.isAttrOn(here.x, here.y, CTerrainImpl::ATTRIBUTE_BLOCK)); const int tx = int(here.x) / CTerrainImpl::TERRAIN_XSIZE * CTerrainImpl::TERRAIN_XSIZE; const int ty = int(here.y) / CTerrainImpl::TERRAIN_YSIZE * CTerrainImpl::TERRAIN_YSIZE; int blocked = 0; for (int y = 0; y < CTerrainImpl::TERRAIN_YSIZE; y += CTerrainImpl::HALF_CELLSCALE) for (int x = 0; x < CTerrainImpl::TERRAIN_XSIZE; x += CTerrainImpl::HALF_CELLSCALE) blocked += bg.isAttrOn(tx + x, ty + y, CTerrainImpl::ATTRIBUTE_BLOCK) ? 1 : 0; std::printf("port_login_flow_test: %d blocked attribute cells in the actor's terrain\n", blocked); CHECK(blocked > 0); } // playersettingmodule.py registered the indexed fly data (effect.RegisterIndexedFlyData → CFlyingManager → // CFlyingData::LoadScriptFile): the .msf parses, and registering the name again finds it already present. { const char* msf = "d:/ymir work/effect/etc/gathering/ga_piece_yellow_small2.msf"; CFlyingData* data = CFlyingData::New(); CHECK(data->LoadScriptFile(msf)); CFlyingData::Delete(data); CHECK(!CFlyingManager::Instance().RegisterFlyingData(msf)); } // item module (PythonItemModule.cpp) over the loaded item_proto: SelectItem + the selected item's fields. CHECK(py("import item\n" "item.SelectItem(19)\n" "assert item.GetItemName() == 'Sword+9', item.GetItemName()\n" "assert item.GetItemType() == item.ITEM_TYPE_WEAPON and item.GetItemSubType() == item.WEAPON_SWORD\n" "assert item.GetValue(3) > 0, 'min attack'")); // miniMap module (PythonMiniMapModule.cpp): uiminimap.MiniMap created and showed it. CMapOutdoor is not // ported, so CMapManager::IsMapOutdoor() is false and the atlas reports "not loaded" as 40250 does off an // outdoor map. CHECK(py("import miniMap\n" "assert miniMap.isShow()\n" "(bGet, x, y) = miniMap.GetAtlasSize()\n" "assert not bGet, (bGet, x, y)")); // Ground items (CPythonItem): the fake server put vnum 19 next to the actor at entergame (GC_ITEM_GROUND_ADD // + GC_ITEM_OWNERSHIP). player.PickCloseItem finds it within __GetPickableDistance, sends CG_ITEM_PICKUP, // and the server's GC_ITEM_GROUND_DEL removes it. if (server) { CPythonItem& ground = CPythonItem::Instance(); TPixelPosition item_pos = {}; const char* owner = nullptr; CHECK(ground.GetGroundItemPosition(FakeLoginServer::kGroundItemVID, &item_pos)); CHECK(ground.GetOwnership(FakeLoginServer::kGroundItemVID, &owner) && owner && std::string(owner) == FakeLoginServer::kCharacterName); CHECK(ground.GetVirtualNumberOfGroundItem(FakeLoginServer::kGroundItemVID) == FakeLoginServer::kGroundItemVnum); std::printf("port_login_flow_test: ground item at %.0f %.0f\n", item_pos.x, item_pos.y); // CPythonItem::CreateItem registered the item's name tail (CPythonTextTail::RegisterItemTextTail) next to // the actor's character tail. Shown and projected, the name is pickable on screen: textTail.Pick is how // game.GameWindow.OnRender finds the item under the cursor while names are shown. auto tail_info = [] { std::string info; CPythonTextTail::Instance().GetInfo(&info); return info; }; std::printf("port_login_flow_test: %s\n", tail_info().c_str()); CHECK(tail_info().find("ItemTail (Map 1,") != std::string::npos); CHECK(tail_info().find("ChrTail (Map 1,") != std::string::npos); // Holding ALT (game.GameWindow.ShowName) makes OnRender show every tail in range and project it after // app.RenderGame; the item tail keeps that screen rect, which is what textTail.Pick tests. PythonBoot::UIKey(DIK_LMENU, true); pump_until(0.1, [] { return false; }); PythonBoot::UIKey(DIK_LMENU, false); int picked = -1, pick_x = 0, pick_y = 0; for (int y = 0; y < 1200 && picked < 0; y += 2) for (int x = 0; x < 1600 && picked < 0; x += 2) if ((picked = CPythonTextTail::Instance().Pick(x, y)) >= 0) pick_x = x, pick_y = y; std::printf("port_login_flow_test: item name tail picked at %d %d\n", pick_x, pick_y); CHECK(picked == (int) FakeLoginServer::kGroundItemVID); CHECK(py("import player\nplayer.PickCloseItem()")); const std::string pickup = "game2:pickup " + std::to_string(FakeLoginServer::kGroundItemVID); CHECK(pump_until(2, [&] { return server->Has(pickup); })); CHECK(pump_until(2, [&] { return !ground.GetGroundItemPosition(FakeLoginServer::kGroundItemVID, &item_pos); })); CHECK(tail_info().find("ItemTail (Map 0,") != std::string::npos); } // Exchange (CPythonExchange + exchange module): net.SendExchangeStartPacket → the fake partner's GC_EXCHANGE // START/ITEM_ADD/GOLD_ADD fill the target side and open uiexchange; net.SendExchangeExitPacket → END. if (server) { CHECK(py("import net\nnet.SendExchangeStartPacket(" + std::to_string(FakeLoginServer::kMainVID) + ")")); CHECK(pump_until(2, [&] { return CPythonExchange::Instance().GetElkFromTarget() == FakeLoginServer::kExchangeGold; })); CHECK(py("import exchange\n" "assert exchange.isTrading() == 1\n" "assert exchange.GetItemVnumFromTarget(0) == " + std::to_string(FakeLoginServer::kExchangeItemVnum) + "\n" "assert exchange.GetItemCountFromTarget(0) == 1\n" "assert exchange.GetElkFromSelf() == 0")); CHECK(py("import net\nnet.SendExchangeExitPacket()")); CHECK(pump_until(2, [&] { return !CPythonExchange::Instance().isTrading(); })); CHECK(server->Has("game2:exchange 0 " + std::to_string(FakeLoginServer::kMainVID))); CHECK(server->Has("game2:exchange 5")); // uiexchange.CloseDialog armed wndMgr.OnceIgnoreMouseLeftButtonUpEvent (in the client that release belongs // to the click that closed the dialog); spend it so step 8's click-release is not swallowed. PythonBoot::UIMouseButton(1, false, 400, 520); } // 8. (批次 2V3) Click-to-move: a left click on the ground below the actor reaches game.GameWindow's // OnMouseLeftButtonDown → player.SetMouseState(MBT_LEFT, MBS_PRESS); CPythonPlayer picks the ground // through the cursor ray (CScreen::SetCursorPosition → CPythonBackground::GetPickingPoint) and the // actor walks to it, sending CG_MOVE FUNC_MOVE, then FUNC_WAIT on arrival. const size_t moves_before = server ? server->Events().size() : 0; if (main_instance) main_instance->NEW_GetPixelPosition(&start); PythonBoot::UIMouseButton(1, true, 400, 520); pump_until(0.1, [] { return false; }); PythonBoot::UIMouseButton(1, false, 400, 520); CHECK(main_instance && pump_until(3, [&] { return walked() > 100.0f; })); CHECK(main_instance && pump_until(5, [&] { return !main_instance->IsWalking(); })); std::printf("port_login_flow_test: click walked %.0f cm\n", main_instance ? walked() : 0.0f); if (server) { auto click_moves = [&] { std::vector out; const auto events = server->Events(); for (size_t i = moves_before; i < events.size(); ++i) if (events[i].rfind("game2:move ", 0) == 0) out.push_back(events[i]); return out; }; CHECK(pump_until(2, [&] { auto m = click_moves(); return m.size() >= 2 && m.back().rfind("game2:move 0 ", 0) == 0; })); const auto m = click_moves(); CHECK(!m.empty() && m.front().rfind("game2:move 1 0 ", 0) == 0); for (const auto& e : m) std::printf("port_login_flow_test: %s\n", e.c_str()); } } if (server) { const std::vector expected = { "auth:handshake", "auth:login3", "game1:handshake", "game1:login2", "auth:closed", "game1:closed", "game2:handshake", "game2:login2", "game2:select", "game2:client_version", "game2:entergame", "game2:burst_sent", "game2:burst_pong", }; auto events = server->Events(); // auth:closed races game1's handshake; compare the order within each connection. for (const char* prefix : {"auth:", "game1:", "game2:"}) { std::vector want, got; for (const auto& e : expected) if (e.rfind(prefix, 0) == 0) want.push_back(e); for (const auto& e : events) if (e.rfind(prefix, 0) == 0 && e.find(":move ") == std::string::npos && e.find(":pickup ") == std::string::npos && e.find(":exchange ") == std::string::npos) got.push_back(e); CHECK(want == got); } if (g_failures) dump(*server); } CHECK(py("import app\napp.Exit()")); pump_until(2, [] { return !PythonBoot::IsAppLooping(); }); CHECK(!PythonBoot::IsAppLooping()); PythonBoot::Stop(); if (server) server->Stop(); if (g_failures) std::fprintf(stderr, "%d check(s) failed\n", g_failures); else std::printf("port_login_flow_test: ok%s\n", live ? " (live)" : ""); return g_failures ? 1 : 0; }