Files
mtgodot-poc/extension/tests/port_login_flow_test.cpp
T
shenleiandClaude Opus 5.5 f0564a7f12 port: ItemData, ItemManager, PythonNonPlayer + LoadLocaleData (batch 2 P3-a)
Verbatim 40250 GameLib ItemData/ItemManager and UserInterface
PythonNonPlayer(+Module) replace their pending stand-ins and the
nonplayer module stub. LoadLocaleData's body moves into the platform
CPythonApplication stand-in, so the select phase loads item_proto and
mob_proto; CPythonSkill is still pending, so it returns false after the
tables load. login_flow checks item 19 (Sword+9) and mob 101 (Wild Dog),
offline and live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 00:36:10 +09:00

561 lines
26 KiB
C++

// 批次 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> <python27.zip> [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/StdAfx.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 <memory>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <unistd.h>
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<std::string> 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<std::mutex> 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<std::mutex> 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<bool()>& done)
{
const auto deadline = std::chrono::steady_clock::now() + std::chrono::duration<double>(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<FakeLoginServer> 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<FakeLoginServer>();
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<CGrannyMaterialPalette*>(&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<float> 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<float> 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 <func> <arg> <rot> <x> <y>"; x, y are the TPixelPosition
// (y negated back, as SendCharacterStatePacket does) plus the map base the server gave.
auto moves = [&] {
std::vector<std::string> 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));
}
// 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<std::string> 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<std::string> 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<std::string> 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)
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;
}