- 装备校验:can_equip 按 CanEquipNow→FindEquipCell→EquipItem 精确重排, 新增 find_equip_cell() 1:1 复刻(RING/UNIQUE 占用翻转、COSTUME/BELT/DS), 帝国 antiflag 降级为 tooltip 提示,LIMIT_PCBANG/REAL_TIME* 语义与倒计时文案 - 快捷栏:死亡全槽禁用+激活守卫、变身(affect 220)技能置灰、 物品槽失配置灰(服务器回收/换武器后刷新) - 公会:GUILD_AUTH_* 权限位门控踢人/公告/技能升级,/gskillup、 被宣战应答弹窗(/war·/nowar)、资金存取行(40250 服务端已禁用仍保留 1:1) - 好友/情侣:messenger 三固定组(好友/公会/情侣),lover_login/logout/ near/far/divorce 命令事件→在线/在身边状态与离婚隐藏 - 交易/仓库:ExchangeState.last_notice(ALREADY/LESS_GOLD)结束浮层提示; 仓库改密 /safebox_change_password 三段输入;关窗补 /safebox_close·/mall_close; 仓库金钱经核实 40250 无 wire 协议,UI 定界只读 - app_flow:重连时 guild_marks_ready 重复连接加 is_connected 守卫 - 相机 look_at 零向量守卫(首帧/传送落点) - 测试:equip/love/guild 断言更新,79 项中 78 绿(game_camera_test 为 HEAD 既有失败);真服冒烟 LIVE_SMOKE PASS(192.168.21.203 全链路) - docs/CLIENT-GAP.md 同步增量 129 Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kUFvQGYusuY3dkfGitmAe
3959 lines
137 KiB
C++
3959 lines
137 KiB
C++
#include "m2_client.h"
|
|
|
|
#include "auth_client.h"
|
|
#include "classic/classic_mark_client.h"
|
|
#include "classic/classic_session.h"
|
|
#include "game_client.h"
|
|
#include "mark_client.h"
|
|
#include "net_bounds.h"
|
|
|
|
#include <godot_cpp/classes/engine.hpp>
|
|
#include <godot_cpp/classes/os.hpp>
|
|
#include <godot_cpp/classes/image.hpp>
|
|
#include <godot_cpp/classes/time.hpp>
|
|
#include <godot_cpp/core/class_db.hpp>
|
|
#include <godot_cpp/variant/array.hpp>
|
|
#include <godot_cpp/variant/dictionary.hpp>
|
|
#include <godot_cpp/variant/utility_functions.hpp>
|
|
#include <godot_cpp/variant/vector2.hpp>
|
|
#include <godot_cpp/variant/vector3.hpp>
|
|
|
|
#include <m2_coord.h>
|
|
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
|
|
using namespace godot;
|
|
|
|
namespace mtgodot {
|
|
|
|
namespace {
|
|
|
|
bool valid_angle(double degrees) {
|
|
return std::isfinite(degrees);
|
|
}
|
|
|
|
bool valid_item_pos(int window, int cell) {
|
|
return mtnet::bounds::u8(window) && mtnet::bounds::u16(cell);
|
|
}
|
|
|
|
bool read_int(const Dictionary &dict, const char *key, int64_t &value, int64_t fallback) {
|
|
const Variant raw = dict.get(key, fallback);
|
|
if (raw.get_type() != Variant::INT) {
|
|
return false;
|
|
}
|
|
value = static_cast<int64_t>(raw);
|
|
return true;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
M2Client::M2Client() {
|
|
set_process(true);
|
|
}
|
|
M2Client::~M2Client() = default;
|
|
|
|
void M2Client::_bind_methods() {
|
|
ClassDB::bind_method(
|
|
D_METHOD("connect_to_server", "auth_host", "auth_port", "game_host", "game_port", "id", "pw"),
|
|
&M2Client::connect_to_server);
|
|
ClassDB::bind_method(D_METHOD("disconnect_from_server"), &M2Client::disconnect_from_server);
|
|
ClassDB::bind_method(D_METHOD("select_character", "index"), &M2Client::select_character);
|
|
ClassDB::bind_method(D_METHOD("enter_game", "index"), &M2Client::enter_game);
|
|
ClassDB::bind_method(
|
|
D_METHOD("create_character", "slot", "name", "job", "shape", "con", "int", "str", "dex"),
|
|
&M2Client::create_character);
|
|
ClassDB::bind_method(D_METHOD("delete_character", "slot", "private_code"),
|
|
&M2Client::delete_character);
|
|
ClassDB::bind_method(D_METHOD("change_name", "slot", "name"), &M2Client::change_name);
|
|
ClassDB::bind_method(D_METHOD("get_empire"), &M2Client::get_empire);
|
|
ClassDB::bind_method(D_METHOD("select_empire", "empire"), &M2Client::select_empire);
|
|
ClassDB::bind_method(D_METHOD("get_slot_count"), &M2Client::get_slot_count);
|
|
ClassDB::bind_method(D_METHOD("move", "func", "arg", "rot_deg", "x", "y"), &M2Client::move);
|
|
ClassDB::bind_method(D_METHOD("character_position", "position"), &M2Client::character_position);
|
|
ClassDB::bind_method(D_METHOD("sync_positions", "positions"), &M2Client::sync_positions);
|
|
ClassDB::bind_method(D_METHOD("request_warp"), &M2Client::request_warp);
|
|
ClassDB::bind_method(D_METHOD("fishing", "rot_deg"), &M2Client::fishing);
|
|
ClassDB::bind_method(D_METHOD("request_dungeon"), &M2Client::request_dungeon);
|
|
ClassDB::bind_method(D_METHOD("attack", "motion", "victim_vid"), &M2Client::attack);
|
|
ClassDB::bind_method(D_METHOD("set_target", "victim_vid"), &M2Client::set_target);
|
|
ClassDB::bind_method(D_METHOD("say", "type", "text"), &M2Client::say);
|
|
ClassDB::bind_method(D_METHOD("send_emoticon", "index"), &M2Client::send_emoticon);
|
|
ClassDB::bind_method(D_METHOD("whisper", "to", "text"), &M2Client::whisper);
|
|
ClassDB::bind_method(D_METHOD("cast_skill", "motion_idx", "rot_deg", "x", "y"), &M2Client::cast_skill);
|
|
ClassDB::bind_method(D_METHOD("use_skill", "skill_id", "target_vid"), &M2Client::use_skill);
|
|
ClassDB::bind_method(D_METHOD("shoot", "skill_id"), &M2Client::shoot);
|
|
ClassDB::bind_method(D_METHOD("add_fly_targeting", "target_vid", "x", "y"), &M2Client::add_fly_targeting);
|
|
ClassDB::bind_method(D_METHOD("skill_up", "skill_id"), &M2Client::skill_up);
|
|
ClassDB::bind_method(D_METHOD("get_skill_group"), &M2Client::get_skill_group);
|
|
ClassDB::bind_method(D_METHOD("get_skills"), &M2Client::get_skills);
|
|
ClassDB::bind_method(D_METHOD("get_quickslots"), &M2Client::get_quickslots);
|
|
ClassDB::bind_method(D_METHOD("quickslot_add", "pos", "type", "ref"), &M2Client::quickslot_add);
|
|
ClassDB::bind_method(D_METHOD("quickslot_del", "pos"), &M2Client::quickslot_del);
|
|
ClassDB::bind_method(D_METHOD("quickslot_swap", "pos", "change_pos"), &M2Client::quickslot_swap);
|
|
ClassDB::bind_method(D_METHOD("click_npc", "vid"), &M2Client::click_npc);
|
|
ClassDB::bind_method(D_METHOD("script_answer", "answer"), &M2Client::script_answer);
|
|
ClassDB::bind_method(D_METHOD("script_button", "idx"), &M2Client::script_button);
|
|
ClassDB::bind_method(D_METHOD("script_select_item", "selection"), &M2Client::script_select_item);
|
|
ClassDB::bind_method(D_METHOD("quest_input", "text"), &M2Client::quest_input);
|
|
ClassDB::bind_method(D_METHOD("quest_confirm", "yes", "request_pid"), &M2Client::quest_confirm);
|
|
ClassDB::bind_method(D_METHOD("quest_cancel"), &M2Client::quest_cancel);
|
|
ClassDB::bind_method(D_METHOD("get_quests"), &M2Client::get_quests);
|
|
ClassDB::bind_method(D_METHOD("get_entity", "vid"), &M2Client::get_entity);
|
|
ClassDB::bind_method(D_METHOD("get_entities"), &M2Client::get_entities);
|
|
ClassDB::bind_method(D_METHOD("get_main_vid"), &M2Client::get_main_vid);
|
|
ClassDB::bind_method(D_METHOD("get_main_pid"), &M2Client::get_main_pid);
|
|
ClassDB::bind_method(D_METHOD("get_points"), &M2Client::get_points);
|
|
ClassDB::bind_method(D_METHOD("get_target"), &M2Client::get_target);
|
|
ClassDB::bind_method(D_METHOD("get_affects"), &M2Client::get_affects);
|
|
ClassDB::bind_method(D_METHOD("move_item", "from_window", "from_cell", "to_window", "to_cell", "count"),
|
|
&M2Client::move_item);
|
|
ClassDB::bind_method(D_METHOD("use_item", "window", "cell"), &M2Client::use_item);
|
|
ClassDB::bind_method(D_METHOD("drop_item", "window", "cell", "gold"), &M2Client::drop_item);
|
|
ClassDB::bind_method(D_METHOD("drop_item_count", "window", "cell", "gold", "count"), &M2Client::drop_item_count);
|
|
ClassDB::bind_method(D_METHOD("use_item_to_item", "source_window", "source_cell", "target_window", "target_cell"),
|
|
&M2Client::use_item_to_item);
|
|
ClassDB::bind_method(D_METHOD("give_item", "target_vid", "window", "cell", "count"), &M2Client::give_item);
|
|
ClassDB::bind_method(D_METHOD("pickup_item", "ground_vid"), &M2Client::pickup_item);
|
|
ClassDB::bind_method(D_METHOD("get_inventory"), &M2Client::get_inventory);
|
|
ClassDB::bind_method(D_METHOD("get_equipment"), &M2Client::get_equipment);
|
|
ClassDB::bind_method(D_METHOD("get_belt_inventory"), &M2Client::get_belt_inventory);
|
|
ClassDB::bind_method(D_METHOD("get_view_equipment", "vid"), &M2Client::get_view_equipment);
|
|
ClassDB::bind_method(D_METHOD("get_item", "window", "cell"), &M2Client::get_item);
|
|
ClassDB::bind_method(D_METHOD("get_ground_items"), &M2Client::get_ground_items);
|
|
ClassDB::bind_method(D_METHOD("get_pvp_relations"), &M2Client::get_pvp_relations);
|
|
ClassDB::bind_method(D_METHOD("get_duel"), &M2Client::get_duel);
|
|
ClassDB::bind_method(D_METHOD("suspend"), &M2Client::suspend);
|
|
ClassDB::bind_method(D_METHOD("resume"), &M2Client::resume);
|
|
ClassDB::bind_method(D_METHOD("reconnect"), &M2Client::reconnect);
|
|
ClassDB::bind_method(D_METHOD("is_suspended"), &M2Client::is_suspended);
|
|
ClassDB::bind_method(D_METHOD("get_stage"), &M2Client::get_stage);
|
|
ClassDB::bind_method(D_METHOD("is_in_game"), &M2Client::is_in_game);
|
|
ClassDB::bind_method(D_METHOD("net_poll"), &M2Client::net_poll);
|
|
|
|
ADD_SIGNAL(MethodInfo("stage_changed", PropertyInfo(Variant::STRING, "stage")));
|
|
ADD_SIGNAL(MethodInfo("phase_changed", PropertyInfo(Variant::STRING, "phase")));
|
|
ADD_SIGNAL(MethodInfo("auth_ok", PropertyInfo(Variant::INT, "login_key")));
|
|
ADD_SIGNAL(MethodInfo("login_failed", PropertyInfo(Variant::STRING, "reason")));
|
|
ADD_SIGNAL(MethodInfo("char_list", PropertyInfo(Variant::ARRAY, "characters")));
|
|
ADD_SIGNAL(MethodInfo("char_created", PropertyInfo(Variant::INT, "slot")));
|
|
ADD_SIGNAL(MethodInfo("char_create_failed", PropertyInfo(Variant::INT, "reason_type")));
|
|
ADD_SIGNAL(MethodInfo("char_deleted", PropertyInfo(Variant::INT, "slot")));
|
|
ADD_SIGNAL(MethodInfo("char_delete_failed"));
|
|
ADD_SIGNAL(MethodInfo("empire_changed", PropertyInfo(Variant::INT, "empire")));
|
|
ADD_SIGNAL(MethodInfo("char_name_changed", PropertyInfo(Variant::INT, "pid"),
|
|
PropertyInfo(Variant::STRING, "name")));
|
|
ADD_SIGNAL(MethodInfo("entered_game"));
|
|
ADD_SIGNAL(MethodInfo("disconnected", PropertyInfo(Variant::STRING, "reason")));
|
|
ADD_SIGNAL(MethodInfo("suspended"));
|
|
ADD_SIGNAL(MethodInfo("resumed"));
|
|
// networked world (fed from EntityStore each pump)
|
|
ADD_SIGNAL(MethodInfo("entity_spawned", PropertyInfo(Variant::DICTIONARY, "entity")));
|
|
ADD_SIGNAL(MethodInfo("entity_despawned", PropertyInfo(Variant::INT, "vid")));
|
|
ADD_SIGNAL(MethodInfo("entity_moved", PropertyInfo(Variant::INT, "vid")));
|
|
ADD_SIGNAL(MethodInfo("entity_main_set", PropertyInfo(Variant::INT, "vid")));
|
|
// name/level/guild/mount arrived (GC_CHAR_ADD_INFO) for an already-spawned entity.
|
|
ADD_SIGNAL(MethodInfo("entity_info", PropertyInfo(Variant::INT, "vid"),
|
|
PropertyInfo(Variant::DICTIONARY, "entity")));
|
|
ADD_SIGNAL(MethodInfo("chat", PropertyInfo(Variant::INT, "type"),
|
|
PropertyInfo(Variant::INT, "vid"), PropertyInfo(Variant::STRING, "text")));
|
|
ADD_SIGNAL(MethodInfo("emoticon_requested", PropertyInfo(Variant::INT, "index")));
|
|
ADD_SIGNAL(MethodInfo("whisper_received", PropertyInfo(Variant::INT, "sub"),
|
|
PropertyInfo(Variant::STRING, "from"), PropertyInfo(Variant::STRING, "text")));
|
|
// combat / status
|
|
ADD_SIGNAL(MethodInfo("vitals_changed", PropertyInfo(Variant::INT, "vid")));
|
|
ADD_SIGNAL(MethodInfo("entity_dead", PropertyInfo(Variant::INT, "vid")));
|
|
ADD_SIGNAL(MethodInfo("damage", PropertyInfo(Variant::INT, "vid"),
|
|
PropertyInfo(Variant::INT, "amount"), PropertyInfo(Variant::INT, "flag")));
|
|
ADD_SIGNAL(MethodInfo("motion", PropertyInfo(Variant::INT, "vid"),
|
|
PropertyInfo(Variant::INT, "victim_vid"), PropertyInfo(Variant::INT, "motion")));
|
|
ADD_SIGNAL(MethodInfo("dig_motion", PropertyInfo(Variant::INT, "vid"),
|
|
PropertyInfo(Variant::INT, "target_vid"), PropertyInfo(Variant::INT, "count")));
|
|
ADD_SIGNAL(MethodInfo("points_changed", PropertyInfo(Variant::DICTIONARY, "points")));
|
|
ADD_SIGNAL(MethodInfo("target_info", PropertyInfo(Variant::INT, "vid"),
|
|
PropertyInfo(Variant::INT, "hp_percent")));
|
|
// items
|
|
ADD_SIGNAL(MethodInfo("inventory_changed", PropertyInfo(Variant::INT, "window"),
|
|
PropertyInfo(Variant::INT, "cell")));
|
|
ADD_SIGNAL(MethodInfo("ground_item_added", PropertyInfo(Variant::DICTIONARY, "item")));
|
|
ADD_SIGNAL(MethodInfo("ground_item_removed", PropertyInfo(Variant::INT, "vid")));
|
|
ADD_SIGNAL(MethodInfo("view_equipment", PropertyInfo(Variant::INT, "vid")));
|
|
ADD_SIGNAL(MethodInfo("pvp_changed", PropertyInfo(Variant::INT, "src_vid"),
|
|
PropertyInfo(Variant::INT, "dst_vid"), PropertyInfo(Variant::INT, "mode")));
|
|
ADD_SIGNAL(MethodInfo("duel_changed", PropertyInfo(Variant::DICTIONARY, "duel")));
|
|
ADD_SIGNAL(MethodInfo("duel_started"));
|
|
ADD_SIGNAL(MethodInfo("item_picked_up", PropertyInfo(Variant::INT, "vnum"),
|
|
PropertyInfo(Variant::INT, "count"), PropertyInfo(Variant::STRING, "from")));
|
|
ADD_SIGNAL(MethodInfo("item_used", PropertyInfo(Variant::INT, "vnum")));
|
|
ADD_SIGNAL(MethodInfo("affect_added", PropertyInfo(Variant::DICTIONARY, "affect")));
|
|
ADD_SIGNAL(MethodInfo("affect_removed", PropertyInfo(Variant::INT, "type")));
|
|
// P5: server-triggered effects. name is a .mse path for GC_SPECIFIC_EFFECT,
|
|
// "" for GC_SPECIAL_EFFECT (use `special` id + a client-side table).
|
|
ADD_SIGNAL(MethodInfo("effect_cue", PropertyInfo(Variant::INT, "vid"),
|
|
PropertyInfo(Variant::STRING, "name"), PropertyInfo(Variant::INT, "special")));
|
|
// P6: skills
|
|
ADD_SIGNAL(MethodInfo("skills_changed"));
|
|
ADD_SIGNAL(MethodInfo("skill_group_changed", PropertyInfo(Variant::INT, "skill_group")));
|
|
ADD_SIGNAL(MethodInfo("quickslots_changed"));
|
|
ADD_SIGNAL(MethodInfo("skill_cooldown_end", PropertyInfo(Variant::INT, "skill")));
|
|
ADD_SIGNAL(MethodInfo("fly_cue", PropertyInfo(Variant::INT, "type"),
|
|
PropertyInfo(Variant::INT, "start_vid"), PropertyInfo(Variant::INT, "end_vid")));
|
|
ADD_SIGNAL(MethodInfo("fly_targeting", PropertyInfo(Variant::INT, "shooter_vid"),
|
|
PropertyInfo(Variant::INT, "target_vid"), PropertyInfo(Variant::VECTOR2, "target_cm"),
|
|
PropertyInfo(Variant::BOOL, "append")));
|
|
// P7: quest / NPC
|
|
ADD_SIGNAL(MethodInfo("script_dialog", PropertyInfo(Variant::INT, "skin"),
|
|
PropertyInfo(Variant::STRING, "text")));
|
|
ADD_SIGNAL(MethodInfo("quest_confirm_ask", PropertyInfo(Variant::STRING, "msg"),
|
|
PropertyInfo(Variant::INT, "timeout"), PropertyInfo(Variant::INT, "request_pid")));
|
|
ADD_SIGNAL(MethodInfo("quest_info", PropertyInfo(Variant::INT, "index")));
|
|
// P8: social / shop / storage
|
|
ClassDB::bind_method(D_METHOD("party_invite", "vid"), &M2Client::party_invite);
|
|
ClassDB::bind_method(D_METHOD("party_answer", "leader_pid", "accept"), &M2Client::party_answer);
|
|
ClassDB::bind_method(D_METHOD("party_leave", "pid"), &M2Client::party_leave);
|
|
ClassDB::bind_method(D_METHOD("party_use_skill", "skill_index", "target_vid"), &M2Client::party_use_skill);
|
|
ClassDB::bind_method(D_METHOD("party_set_distribute", "mode"), &M2Client::party_set_distribute);
|
|
ClassDB::bind_method(D_METHOD("party_set_state", "pid", "role", "on"), &M2Client::party_set_state);
|
|
ClassDB::bind_method(D_METHOD("get_party"), &M2Client::get_party);
|
|
ClassDB::bind_method(D_METHOD("get_party_distribute_mode"), &M2Client::get_party_distribute_mode);
|
|
ClassDB::bind_method(D_METHOD("add_friend", "name"), &M2Client::add_friend);
|
|
ClassDB::bind_method(D_METHOD("remove_friend", "name"), &M2Client::remove_friend);
|
|
ClassDB::bind_method(D_METHOD("friend_answer", "name", "accept"), &M2Client::friend_answer);
|
|
ClassDB::bind_method(D_METHOD("get_friends"), &M2Client::get_friends);
|
|
ClassDB::bind_method(D_METHOD("get_lover"), &M2Client::get_lover);
|
|
ClassDB::bind_method(D_METHOD("shop_buy", "pos", "count"), &M2Client::shop_buy);
|
|
ClassDB::bind_method(D_METHOD("shop_sell", "inv_cell", "count"), &M2Client::shop_sell);
|
|
ClassDB::bind_method(D_METHOD("shop_close"), &M2Client::shop_close);
|
|
ClassDB::bind_method(D_METHOD("is_shop_open"), &M2Client::is_shop_open);
|
|
ClassDB::bind_method(D_METHOD("get_shop_items"), &M2Client::get_shop_items);
|
|
ClassDB::bind_method(D_METHOD("get_shop"), &M2Client::get_shop);
|
|
ClassDB::bind_method(D_METHOD("exchange_start", "vid"), &M2Client::exchange_start);
|
|
ClassDB::bind_method(D_METHOD("exchange_add_item", "inv_window", "inv_cell", "display_pos"),
|
|
&M2Client::exchange_add_item);
|
|
ClassDB::bind_method(D_METHOD("exchange_add_gold", "gold"), &M2Client::exchange_add_gold);
|
|
ClassDB::bind_method(D_METHOD("exchange_accept"), &M2Client::exchange_accept);
|
|
ClassDB::bind_method(D_METHOD("exchange_cancel"), &M2Client::exchange_cancel);
|
|
ClassDB::bind_method(D_METHOD("get_exchange"), &M2Client::get_exchange);
|
|
ClassDB::bind_method(D_METHOD("safebox_checkin", "safe_pos", "inv_window", "inv_cell"),
|
|
&M2Client::safebox_checkin);
|
|
ClassDB::bind_method(D_METHOD("safebox_checkout", "safe_pos", "inv_window", "inv_cell"),
|
|
&M2Client::safebox_checkout);
|
|
ClassDB::bind_method(D_METHOD("safebox_move", "from_cell", "to_cell", "count"),
|
|
&M2Client::safebox_move);
|
|
ClassDB::bind_method(D_METHOD("is_safebox_open"), &M2Client::is_safebox_open);
|
|
ClassDB::bind_method(D_METHOD("get_safebox_size"), &M2Client::get_safebox_size);
|
|
ClassDB::bind_method(D_METHOD("get_safebox_gold"), &M2Client::get_safebox_gold);
|
|
ClassDB::bind_method(D_METHOD("get_safebox_items"), &M2Client::get_safebox_items);
|
|
ClassDB::bind_method(D_METHOD("safebox_password", "password"), &M2Client::safebox_password);
|
|
ClassDB::bind_method(D_METHOD("safebox_change_password", "old_password", "new_password"),
|
|
&M2Client::safebox_change_password);
|
|
ClassDB::bind_method(D_METHOD("safebox_close"), &M2Client::safebox_close);
|
|
// item-mall (창고몰)
|
|
ClassDB::bind_method(D_METHOD("is_mall_open"), &M2Client::is_mall_open);
|
|
ClassDB::bind_method(D_METHOD("get_mall_size"), &M2Client::get_mall_size);
|
|
ClassDB::bind_method(D_METHOD("get_mall_items"), &M2Client::get_mall_items);
|
|
ClassDB::bind_method(D_METHOD("mall_checkout", "mall_pos", "inv_window", "inv_cell"),
|
|
&M2Client::mall_checkout);
|
|
ClassDB::bind_method(D_METHOD("mall_password", "password"), &M2Client::mall_password);
|
|
ClassDB::bind_method(D_METHOD("mall_close"), &M2Client::mall_close);
|
|
// private (PC) shop
|
|
ClassDB::bind_method(D_METHOD("open_private_shop", "sign", "items"),
|
|
&M2Client::open_private_shop);
|
|
ClassDB::bind_method(D_METHOD("close_private_shop"), &M2Client::close_private_shop);
|
|
// cube (제작)
|
|
ClassDB::bind_method(D_METHOD("get_cube"), &M2Client::get_cube);
|
|
ClassDB::bind_method(D_METHOD("cube_make", "result_index"), &M2Client::cube_make);
|
|
ClassDB::bind_method(D_METHOD("cube_request_result_list", "npc_vnum"),
|
|
&M2Client::cube_request_result_list);
|
|
ClassDB::bind_method(D_METHOD("cube_request_materials", "start_index", "count"),
|
|
&M2Client::cube_request_materials);
|
|
ClassDB::bind_method(D_METHOD("cube_open"), &M2Client::cube_open);
|
|
ClassDB::bind_method(D_METHOD("cube_close"), &M2Client::cube_close);
|
|
ClassDB::bind_method(D_METHOD("cube_list"), &M2Client::cube_list);
|
|
ClassDB::bind_method(D_METHOD("cube_add_item", "cube_index", "inventory_index"),
|
|
&M2Client::cube_add_item);
|
|
ClassDB::bind_method(D_METHOD("cube_delete_item", "cube_index"), &M2Client::cube_delete_item);
|
|
|
|
ADD_SIGNAL(MethodInfo("party_changed"));
|
|
ADD_SIGNAL(MethodInfo("party_invite_ask", PropertyInfo(Variant::INT, "leader_pid")));
|
|
ADD_SIGNAL(MethodInfo("party_request_denied"));
|
|
ADD_SIGNAL(MethodInfo("lover_changed", PropertyInfo(Variant::DICTIONARY, "lover")));
|
|
ADD_SIGNAL(MethodInfo("friends_changed"));
|
|
ADD_SIGNAL(MethodInfo("friend_invite_ask", PropertyInfo(Variant::STRING, "name")));
|
|
ADD_SIGNAL(MethodInfo("shop_opened", PropertyInfo(Variant::INT, "vid")));
|
|
ADD_SIGNAL(MethodInfo("shop_closed"));
|
|
ADD_SIGNAL(MethodInfo("shop_error", PropertyInfo(Variant::STRING, "kind")));
|
|
ADD_SIGNAL(MethodInfo("exchange_changed"));
|
|
ADD_SIGNAL(MethodInfo("safebox_changed"));
|
|
ADD_SIGNAL(MethodInfo("safebox_password_required"));
|
|
ADD_SIGNAL(MethodInfo("safebox_wrong_password"));
|
|
ADD_SIGNAL(MethodInfo("mall_opened", PropertyInfo(Variant::INT, "size")));
|
|
ADD_SIGNAL(MethodInfo("mall_changed"));
|
|
ADD_SIGNAL(MethodInfo("mall_password_required"));
|
|
ADD_SIGNAL(MethodInfo("private_shop_open_requested"));
|
|
ADD_SIGNAL(MethodInfo("my_shop_price_list", PropertyInfo(Variant::INT, "vnum"),
|
|
PropertyInfo(Variant::INT, "price")));
|
|
ADD_SIGNAL(MethodInfo("block_mode_changed", PropertyInfo(Variant::INT, "mask")));
|
|
ADD_SIGNAL(MethodInfo("observer_mode_changed", PropertyInfo(Variant::BOOL, "enabled")));
|
|
ADD_SIGNAL(MethodInfo("observer_count_changed", PropertyInfo(Variant::INT, "count")));
|
|
ADD_SIGNAL(MethodInfo("stone_detected", PropertyInfo(Variant::INT, "vid"),
|
|
PropertyInfo(Variant::INT, "distance"), PropertyInfo(Variant::FLOAT, "angle")));
|
|
ADD_SIGNAL(MethodInfo("stamina_changed", PropertyInfo(Variant::INT, "current"),
|
|
PropertyInfo(Variant::INT, "per_sec"), PropertyInfo(Variant::BOOL, "consuming")));
|
|
ADD_SIGNAL(MethodInfo("mobile_flag_changed", PropertyInfo(Variant::BOOL, "enabled")));
|
|
ADD_SIGNAL(MethodInfo("mobile_auth_required"));
|
|
ADD_SIGNAL(MethodInfo("combo_changed", PropertyInfo(Variant::BOOL, "enabled")));
|
|
ADD_SIGNAL(MethodInfo("gift_available"));
|
|
ADD_SIGNAL(MethodInfo("cube_opened", PropertyInfo(Variant::INT, "npc_vnum")));
|
|
ADD_SIGNAL(MethodInfo("cube_closed"));
|
|
ADD_SIGNAL(MethodInfo("cube_changed"));
|
|
ADD_SIGNAL(MethodInfo("cube_result", PropertyInfo(Variant::INT, "vnum"),
|
|
PropertyInfo(Variant::INT, "count"), PropertyInfo(Variant::BOOL, "ok")));
|
|
|
|
// guild / refine
|
|
ClassDB::bind_method(D_METHOD("get_guild"), &M2Client::get_guild);
|
|
ClassDB::bind_method(D_METHOD("get_guild_members"), &M2Client::get_guild_members);
|
|
ClassDB::bind_method(D_METHOD("get_guild_grades"), &M2Client::get_guild_grades);
|
|
ClassDB::bind_method(D_METHOD("guild_add_member", "vid"), &M2Client::guild_add_member);
|
|
ClassDB::bind_method(D_METHOD("guild_remove_member", "pid"), &M2Client::guild_remove_member);
|
|
ClassDB::bind_method(D_METHOD("guild_offer", "amount"), &M2Client::guild_offer);
|
|
ClassDB::bind_method(D_METHOD("guild_charge_gsp", "amount"), &M2Client::guild_charge_gsp);
|
|
ClassDB::bind_method(D_METHOD("guild_deposit_money", "amount"), &M2Client::guild_deposit_money);
|
|
ClassDB::bind_method(D_METHOD("guild_withdraw_money", "amount"), &M2Client::guild_withdraw_money);
|
|
ClassDB::bind_method(D_METHOD("guild_change_grade_name", "grade", "name"),
|
|
&M2Client::guild_change_grade_name);
|
|
ClassDB::bind_method(D_METHOD("guild_change_grade_authority", "grade", "authority"),
|
|
&M2Client::guild_change_grade_authority);
|
|
ClassDB::bind_method(D_METHOD("guild_change_member_grade", "pid", "grade"),
|
|
&M2Client::guild_change_member_grade);
|
|
ClassDB::bind_method(D_METHOD("guild_change_member_general", "pid", "enabled"),
|
|
&M2Client::guild_change_member_general);
|
|
ClassDB::bind_method(D_METHOD("guild_post_comment", "text"), &M2Client::guild_post_comment);
|
|
ClassDB::bind_method(D_METHOD("guild_delete_comment", "comment_id"), &M2Client::guild_delete_comment);
|
|
ClassDB::bind_method(D_METHOD("guild_refresh_comments"), &M2Client::guild_refresh_comments);
|
|
ClassDB::bind_method(D_METHOD("guild_answer_invite", "guild_id", "accept"),
|
|
&M2Client::guild_answer_invite);
|
|
ClassDB::bind_method(D_METHOD("guild_answer_make", "name"), &M2Client::guild_answer_make);
|
|
ClassDB::bind_method(D_METHOD("get_guild_comments"), &M2Client::get_guild_comments);
|
|
ClassDB::bind_method(D_METHOD("get_guild_skill"), &M2Client::get_guild_skill);
|
|
ClassDB::bind_method(D_METHOD("get_guild_wars"), &M2Client::get_guild_wars);
|
|
ClassDB::bind_method(D_METHOD("get_guild_war"), &M2Client::get_guild_war);
|
|
ClassDB::bind_method(D_METHOD("get_guild_name", "guild_id"), &M2Client::get_guild_name);
|
|
ClassDB::bind_method(D_METHOD("use_guild_skill", "skill_vnum", "target_vid"),
|
|
&M2Client::use_guild_skill);
|
|
ClassDB::bind_method(D_METHOD("declare_guild_war", "guild_name"), &M2Client::declare_guild_war);
|
|
ADD_SIGNAL(MethodInfo("guild_skill_changed"));
|
|
ADD_SIGNAL(MethodInfo("guild_war_changed"));
|
|
ADD_SIGNAL(MethodInfo("guild_comments_changed"));
|
|
ADD_SIGNAL(MethodInfo("guild_invite_ask", PropertyInfo(Variant::INT, "guild_id"),
|
|
PropertyInfo(Variant::STRING, "guild_name")));
|
|
ADD_SIGNAL(MethodInfo("guild_war_event", PropertyInfo(Variant::INT, "state"),
|
|
PropertyInfo(Variant::INT, "opp_guild_id"), PropertyInfo(Variant::STRING, "opp_name")));
|
|
ADD_SIGNAL(MethodInfo("guild_war_point", PropertyInfo(Variant::INT, "gain_guild_id"),
|
|
PropertyInfo(Variant::INT, "opp_guild_id"), PropertyInfo(Variant::INT, "point")));
|
|
ClassDB::bind_method(D_METHOD("download_guild_marks", "host", "port"),
|
|
&M2Client::download_guild_marks);
|
|
ClassDB::bind_method(D_METHOD("download_guild_symbol", "host", "port", "guild_id"),
|
|
&M2Client::download_guild_symbol);
|
|
ClassDB::bind_method(D_METHOD("get_guild_symbol"), &M2Client::get_guild_symbol);
|
|
ClassDB::bind_method(D_METHOD("are_guild_marks_ready"), &M2Client::are_guild_marks_ready);
|
|
ClassDB::bind_method(D_METHOD("get_mark_server"), &M2Client::get_mark_server);
|
|
ClassDB::bind_method(D_METHOD("get_guild_mark", "guild_id"), &M2Client::get_guild_mark);
|
|
ClassDB::bind_method(D_METHOD("get_guild_mark_image", "guild_id"), &M2Client::get_guild_mark_image);
|
|
ClassDB::bind_method(D_METHOD("upload_guild_mark", "host", "port", "guild_id", "img"),
|
|
&M2Client::upload_guild_mark);
|
|
ClassDB::bind_method(D_METHOD("upload_guild_symbol", "host", "port", "guild_id", "file_bytes"),
|
|
&M2Client::upload_guild_symbol);
|
|
ADD_SIGNAL(MethodInfo("guild_marks_ready", PropertyInfo(Variant::INT, "mark_count")));
|
|
ADD_SIGNAL(MethodInfo("guild_symbol_ready", PropertyInfo(Variant::INT, "guild_id"),
|
|
PropertyInfo(Variant::PACKED_BYTE_ARRAY, "data")));
|
|
ADD_SIGNAL(MethodInfo("guild_mark_uploaded", PropertyInfo(Variant::BOOL, "ok")));
|
|
ADD_SIGNAL(MethodInfo("guild_mark_updated", PropertyInfo(Variant::INT, "guild_id"),
|
|
PropertyInfo(Variant::INT, "img_idx")));
|
|
ClassDB::bind_method(D_METHOD("refine", "pos", "type"), &M2Client::refine);
|
|
ClassDB::bind_method(D_METHOD("ds_refine", "mode", "cells"), &M2Client::ds_refine);
|
|
ClassDB::bind_method(D_METHOD("get_dragon_souls"), &M2Client::get_dragon_souls);
|
|
ADD_SIGNAL(MethodInfo("guild_changed"));
|
|
ADD_SIGNAL(MethodInfo("guild_make_requested"));
|
|
ADD_SIGNAL(MethodInfo("refine_ask", PropertyInfo(Variant::DICTIONARY, "info")));
|
|
ADD_SIGNAL(MethodInfo("refine_result", PropertyInfo(Variant::BOOL, "ok")));
|
|
ADD_SIGNAL(MethodInfo("ds_window_open"));
|
|
ADD_SIGNAL(MethodInfo("ds_refine_result", PropertyInfo(Variant::BOOL, "ok"),
|
|
PropertyInfo(Variant::INT, "sub_type"), PropertyInfo(Variant::INT, "cell")));
|
|
|
|
// P9: world systems
|
|
ClassDB::bind_method(D_METHOD("get_channel"), &M2Client::get_channel);
|
|
ClassDB::bind_method(D_METHOD("get_server_time"), &M2Client::get_server_time);
|
|
ClassDB::bind_method(D_METHOD("get_npc_marks"), &M2Client::get_npc_marks);
|
|
ClassDB::bind_method(D_METHOD("get_land_areas"), &M2Client::get_land_areas);
|
|
ClassDB::bind_method(D_METHOD("get_observers"), &M2Client::get_observers);
|
|
ClassDB::bind_method(D_METHOD("get_world_markers"), &M2Client::get_world_markers);
|
|
ClassDB::bind_method(D_METHOD("is_observer_mode"), &M2Client::is_observer_mode);
|
|
ClassDB::bind_method(D_METHOD("get_observer_count"), &M2Client::get_observer_count);
|
|
ClassDB::bind_method(D_METHOD("has_mobile_flag"), &M2Client::has_mobile_flag);
|
|
ClassDB::bind_method(D_METHOD("combo_skill_enabled"), &M2Client::combo_skill_enabled);
|
|
ClassDB::bind_method(D_METHOD("get_stamina_state"), &M2Client::get_stamina_state);
|
|
ADD_SIGNAL(MethodInfo("warp", PropertyInfo(Variant::VECTOR3, "pos"),
|
|
PropertyInfo(Variant::BOOL, "same_server")));
|
|
ADD_SIGNAL(MethodInfo("world_reset"));
|
|
ADD_SIGNAL(MethodInfo("time_changed", PropertyInfo(Variant::INT, "server_epoch")));
|
|
ADD_SIGNAL(MethodInfo("channel_changed", PropertyInfo(Variant::INT, "channel")));
|
|
ADD_SIGNAL(MethodInfo("npc_marks_changed"));
|
|
ADD_SIGNAL(MethodInfo("land_areas_changed"));
|
|
ADD_SIGNAL(MethodInfo("observer_event", PropertyInfo(Variant::INT, "kind"),
|
|
PropertyInfo(Variant::INT, "vid"), PropertyInfo(Variant::VECTOR2, "pos")));
|
|
ADD_SIGNAL(MethodInfo("fishing_event", PropertyInfo(Variant::INT, "subheader"),
|
|
PropertyInfo(Variant::INT, "info"), PropertyInfo(Variant::INT, "dir")));
|
|
ADD_SIGNAL(MethodInfo("dungeon_event", PropertyInfo(Variant::INT, "subheader"),
|
|
PropertyInfo(Variant::INT, "x"), PropertyInfo(Variant::INT, "y"),
|
|
PropertyInfo(Variant::BOOL, "has_destination")));
|
|
ADD_SIGNAL(MethodInfo("world_markers_changed"));
|
|
ADD_SIGNAL(MethodInfo("mount_changed", PropertyInfo(Variant::INT, "vid")));
|
|
// §9.1 map background music: name is the resolved track id/path, volume is
|
|
// 0..1 or -1 when the server left it unspecified.
|
|
ADD_SIGNAL(MethodInfo("bgm_changed", PropertyInfo(Variant::STRING, "name"),
|
|
PropertyInfo(Variant::FLOAT, "volume")));
|
|
}
|
|
|
|
static const char *phase_name(int p) {
|
|
switch (p) {
|
|
case mtnet::PHASE_CLOSE: return "close";
|
|
case mtnet::PHASE_HANDSHAKE: return "handshake";
|
|
case mtnet::PHASE_LOGIN: return "login";
|
|
case mtnet::PHASE_SELECT: return "select";
|
|
case mtnet::PHASE_LOADING: return "loading";
|
|
case mtnet::PHASE_GAME: return "game";
|
|
case mtnet::PHASE_DEAD: return "dead";
|
|
case mtnet::PHASE_AUTH: return "auth";
|
|
default: return "?";
|
|
}
|
|
}
|
|
|
|
String M2Client::stage_name() const {
|
|
switch (stage) {
|
|
case Stage::Idle: return "idle";
|
|
case Stage::AuthConnect: return "auth_connect";
|
|
case Stage::AuthWait: return "auth_wait";
|
|
case Stage::GameConnect: return "game_connect";
|
|
case Stage::GameLogin: return "game_login";
|
|
case Stage::InGame: return "in_game";
|
|
case Stage::Failed: return "failed";
|
|
}
|
|
return "?";
|
|
}
|
|
|
|
void M2Client::set_stage(Stage s) {
|
|
if (stage == s) {
|
|
return;
|
|
}
|
|
stage = s;
|
|
emit_signal("stage_changed", stage_name());
|
|
}
|
|
|
|
bool M2Client::is_in_game() const {
|
|
return stage == Stage::InGame;
|
|
}
|
|
|
|
void M2Client::net_poll() {
|
|
if (suspended) {
|
|
return;
|
|
}
|
|
if (auth) {
|
|
auth->process();
|
|
}
|
|
if (game) {
|
|
game->process();
|
|
}
|
|
if (classic_sess) {
|
|
classic_sess->set_now((uint32_t)Time::get_singleton()->get_ticks_msec());
|
|
classic_sess->pump();
|
|
}
|
|
if (mark) {
|
|
mark->process();
|
|
}
|
|
if (classic_mark) {
|
|
classic_mark->process();
|
|
}
|
|
}
|
|
|
|
void M2Client::connect_to_server(const String &auth_host, int auth_port, const String &g_host,
|
|
int g_port, const String &id, const String &pw) {
|
|
disconnect_from_server();
|
|
if (auth_host.is_empty() || g_host.is_empty() || auth_port <= 0 || g_port <= 0 ||
|
|
!mtnet::bounds::u16(auth_port) || !mtnet::bounds::u16(g_port)) {
|
|
emit_signal("login_failed", String("invalid server address"));
|
|
set_stage(Stage::Failed);
|
|
return;
|
|
}
|
|
game_host = g_host;
|
|
game_port = g_port;
|
|
account_id = id;
|
|
|
|
cfg_auth_host = auth_host;
|
|
cfg_auth_port = auth_port;
|
|
cfg_game_host = g_host;
|
|
cfg_game_port = g_port;
|
|
cfg_id = id;
|
|
cfg_pw = pw;
|
|
have_cfg = true;
|
|
suspended = false;
|
|
|
|
// MT_PROTOCOL=classic -> 40250 backend: authenticate on auth_host first,
|
|
// then connect to game_host with the returned login ticket.
|
|
if (OS::get_singleton()->get_environment("MT_PROTOCOL") == "classic") {
|
|
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
|
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
|
|
// §1.5 (W1 G1 wiring): CG_CLIENT_VERSION2 reports the running binary's
|
|
// filename; feed it the real executable name rather than the built-in
|
|
// default so the server-side version gate sees what ClientVS22 sends.
|
|
classic_sess->set_executable_name(
|
|
std::string(OS::get_singleton()->get_executable_path().get_file().utf8().get_data()));
|
|
// §2.2 (W1 G1 wiring): non-PC spawn names come from root/npclist.txt.
|
|
// M2Client has no asset-root handle, so the parser falls back to
|
|
// $MT_ASSETS/$M2_ASSETS (see npc_names.h default_npclist_path()); an
|
|
// explicit set_npclist_path() awaits asset-root plumbing into M2Client.
|
|
if (const String assets = OS::get_singleton()->get_environment("MT_ASSETS"); !assets.is_empty()) {
|
|
classic_sess->parser().set_npclist_path(
|
|
std::string(assets.utf8().get_data()) + "/root/npclist.txt");
|
|
}
|
|
// The 40250 server expects CG_ENTERGAME shortly after the loading phase
|
|
// starts. Keep the live client in the same timing window as m2dev while
|
|
// allowing a deployment-specific override for unusually large bursts.
|
|
const String enter_delay = OS::get_singleton()->get_environment("MT_CLASSIC_ENTER_DELAY");
|
|
classic_sess->set_auto_entergame_delay(enter_delay.is_empty() ? 1500 :
|
|
static_cast<uint32_t>(MAX(0, enter_delay.to_int())));
|
|
last_login_key = 0;
|
|
classic_list_emitted = false;
|
|
classic_last_stage = -1;
|
|
classic_last_empire = -1;
|
|
shop_open_seen = false;
|
|
mall_open_seen = false;
|
|
last_game_phase = -1;
|
|
if (!classic_sess->connect(std::string(auth_host.utf8().get_data()), (uint16_t)auth_port,
|
|
std::string(g_host.utf8().get_data()), (uint16_t)g_port,
|
|
std::string(id.utf8().get_data()), std::string(pw.utf8().get_data()))) {
|
|
emit_signal("login_failed", String(classic_sess->last_error().c_str()));
|
|
set_stage(Stage::Failed);
|
|
classic_sess.reset();
|
|
return;
|
|
}
|
|
set_stage(Stage::GameConnect);
|
|
return;
|
|
}
|
|
|
|
auth = std::make_unique<mtnet::AuthClient>(std::string(id.utf8().get_data()),
|
|
std::string(pw.utf8().get_data()));
|
|
char_list_emitted = false;
|
|
last_auth_state = last_game_state = last_game_phase = last_game_empire = -1;
|
|
|
|
if (!auth->connect(std::string(auth_host.utf8().get_data()), (uint16_t)auth_port)) {
|
|
emit_signal("login_failed", String(auth->last_error().c_str()));
|
|
set_stage(Stage::Failed);
|
|
auth.reset();
|
|
return;
|
|
}
|
|
set_stage(Stage::AuthConnect);
|
|
}
|
|
|
|
void M2Client::disconnect_from_server() {
|
|
if (auth) {
|
|
auth->disconnect();
|
|
auth.reset();
|
|
}
|
|
if (game) {
|
|
game->disconnect();
|
|
game.reset();
|
|
}
|
|
if (classic_sess) {
|
|
classic_sess->disconnect();
|
|
classic_sess.reset();
|
|
}
|
|
if (mark) {
|
|
mark->disconnect();
|
|
mark.reset();
|
|
}
|
|
if (classic_mark) {
|
|
classic_mark->disconnect();
|
|
classic_mark.reset();
|
|
}
|
|
shop_open_seen = false;
|
|
mall_open_seen = false;
|
|
selected_pid = 0;
|
|
if (stage != Stage::Idle) {
|
|
set_stage(Stage::Idle);
|
|
}
|
|
}
|
|
|
|
bool M2Client::select_character(int index) {
|
|
if (classic_sess) {
|
|
const bool ok = classic_sess->select_char(index);
|
|
if (ok && index >= 0 && index < (int)classic_sess->char_slots().size()) {
|
|
selected_pid = classic_sess->char_slots()[index].id;
|
|
}
|
|
return ok;
|
|
}
|
|
if (!game) {
|
|
return false;
|
|
}
|
|
const bool ok = game->select_character(index);
|
|
if (ok) {
|
|
for (const auto &slot : game->chars()) {
|
|
if (slot.index == index) {
|
|
selected_pid = slot.id;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
bool M2Client::enter_game(int index) {
|
|
if (classic_sess) {
|
|
const bool ok = classic_sess->connect_direct_enter(index);
|
|
if (ok && index >= 0 && index < static_cast<int>(classic_sess->char_slots().size())) {
|
|
selected_pid = classic_sess->char_slots()[index].id;
|
|
}
|
|
return ok;
|
|
}
|
|
return select_character(index);
|
|
}
|
|
|
|
bool M2Client::create_character(int slot, const godot::String &name, int job, int shape,
|
|
int con, int intel, int str, int dex) {
|
|
const std::string name_wire = std::string(name.utf8().get_data());
|
|
const size_t name_limit = classic_sess ? mtnet::classic::CHARACTER_NAME_MAX_LEN :
|
|
mtnet::CHARACTER_NAME_MAX_LEN;
|
|
if (slot < 0 || slot >= mtnet::PLAYER_PER_ACCOUNT4 || name_wire.empty() ||
|
|
name_wire.size() > name_limit || !mtnet::bounds::u16(job) || !mtnet::bounds::u8(shape) ||
|
|
!mtnet::bounds::u8(con) || !mtnet::bounds::u8(intel) || !mtnet::bounds::u8(str) ||
|
|
!mtnet::bounds::u8(dex)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->create_character(slot, name_wire, job, shape,
|
|
con, intel, str, dex);
|
|
}
|
|
if (!game) {
|
|
return false;
|
|
}
|
|
return game->create_character(slot, name_wire, job, shape,
|
|
con, intel, str, dex);
|
|
}
|
|
|
|
bool M2Client::delete_character(int slot, const godot::String &private_code) {
|
|
if (classic_sess) {
|
|
return classic_sess->delete_character(slot, std::string(private_code.utf8().get_data()));
|
|
}
|
|
if (!game) {
|
|
return false;
|
|
}
|
|
return game->delete_character(slot, std::string(private_code.utf8().get_data()));
|
|
}
|
|
|
|
bool M2Client::change_name(int slot, const godot::String &name) {
|
|
if (classic_sess) {
|
|
return classic_sess->change_name(slot, std::string(name.utf8().get_data()));
|
|
}
|
|
if (!game) {
|
|
return false;
|
|
}
|
|
return game->change_name(slot, std::string(name.utf8().get_data()));
|
|
}
|
|
|
|
int M2Client::get_empire() const {
|
|
if (classic_sess) {
|
|
return (int)classic_sess->parser().empire();
|
|
}
|
|
return game ? (int)game->empire() : 0;
|
|
}
|
|
|
|
bool M2Client::select_empire(int empire) {
|
|
if (empire < 1 || empire > 3) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_empire((uint8_t)empire);
|
|
}
|
|
return game && game->phase() == mtnet::PHASE_SELECT && game->send_empire((uint8_t)empire);
|
|
}
|
|
|
|
int M2Client::get_slot_count() const {
|
|
if (classic_sess) {
|
|
return classic_sess->parser().slot_count() > 0 ? classic_sess->parser().slot_count()
|
|
: mtnet::classic::PLAYER_PER_ACCOUNT;
|
|
}
|
|
return game ? game->slot_count() : mtnet::PLAYER_PER_ACCOUNT4;
|
|
}
|
|
|
|
godot::Array M2Client::build_char_list() const {
|
|
Array list;
|
|
if (classic_sess) {
|
|
const auto &slots = classic_sess->char_slots();
|
|
for (int i = 0; i < (int)slots.size(); ++i) {
|
|
const auto &c = slots[i];
|
|
Dictionary d;
|
|
d["index"] = i;
|
|
d["id"] = (int64_t)c.id;
|
|
d["name"] = String::utf8(c.name.c_str());
|
|
d["job"] = (int)c.job;
|
|
d["level"] = (int)c.level;
|
|
d["x"] = c.x;
|
|
d["y"] = c.y;
|
|
d["guild_id"] = (int64_t)c.guild_id;
|
|
d["guild_name"] = String::utf8(c.guild_name.c_str());
|
|
d["play_minutes"] = (int)c.play_minutes;
|
|
d["st"] = (int)c.st;
|
|
d["ht"] = (int)c.ht;
|
|
d["dx"] = (int)c.dx;
|
|
d["iq"] = (int)c.iq;
|
|
d["main_part"] = (int)c.main_part;
|
|
d["hair_part"] = (int)c.hair_part;
|
|
d["change_name"] = c.change_name;
|
|
list.push_back(d);
|
|
}
|
|
return list;
|
|
}
|
|
if (!game) return list;
|
|
for (const auto &c : game->chars()) {
|
|
Dictionary d;
|
|
d["index"] = c.index;
|
|
d["id"] = (int64_t)c.id;
|
|
d["name"] = String(c.name.c_str());
|
|
d["job"] = c.job;
|
|
d["level"] = c.level;
|
|
d["x"] = c.x;
|
|
d["y"] = c.y;
|
|
d["guild_id"] = (int64_t)c.guild_id;
|
|
d["guild_name"] = String::utf8(c.guild_name.c_str());
|
|
d["play_minutes"] = (int)c.play_minutes;
|
|
d["st"] = c.st;
|
|
d["ht"] = c.ht;
|
|
d["dx"] = c.dx;
|
|
d["iq"] = c.iq;
|
|
d["main_part"] = c.main_part;
|
|
d["hair_part"] = c.hair_part;
|
|
d["change_name"] = c.change_name;
|
|
list.push_back(d);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
// --- in-game intents ---
|
|
|
|
bool M2Client::move(int func, int arg, double rot_deg, int x, int y) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(func) || !mtnet::bounds::u8(arg) ||
|
|
!valid_angle(rot_deg) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) {
|
|
return false;
|
|
}
|
|
double r = std::fmod(rot_deg, 360.0);
|
|
if (r < 0.0) {
|
|
r += 360.0;
|
|
}
|
|
uint32_t t = (uint32_t)Time::get_singleton()->get_ticks_msec();
|
|
if (classic_sess) {
|
|
return classic_sess->send_move((uint8_t)func, (uint8_t)arg, (float)r, (int32_t)x,
|
|
(int32_t)y, t);
|
|
}
|
|
if (!game) {
|
|
return false;
|
|
}
|
|
uint8_t rot = (uint8_t)(r / 5.0); // client wire form: degrees / 5
|
|
return game->send_move((uint8_t)func, (uint8_t)arg, rot, (int32_t)x, (int32_t)y, t);
|
|
}
|
|
|
|
bool M2Client::character_position(int position) {
|
|
if (!is_in_game() || position < 0 || position > 255) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_character_position((uint8_t)position);
|
|
}
|
|
return game && game->send_character_position((uint8_t)position);
|
|
}
|
|
|
|
bool M2Client::request_warp() {
|
|
if (classic_sess) {
|
|
return is_in_game() && classic_sess->send_request_warp();
|
|
}
|
|
return game && is_in_game() && game->send_warp();
|
|
}
|
|
|
|
bool M2Client::fishing(double rot_deg) {
|
|
if (!is_in_game() || !valid_angle(rot_deg)) {
|
|
return false;
|
|
}
|
|
double r = std::fmod(rot_deg, 360.0);
|
|
if (r < 0.0) {
|
|
r += 360.0;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_fishing((uint8_t)(r / 5.0));
|
|
}
|
|
if (!game) return false;
|
|
return game->send_fishing((uint8_t)(r / 5.0));
|
|
}
|
|
|
|
bool M2Client::request_dungeon() {
|
|
if (classic_sess) {
|
|
return is_in_game() && classic_sess->send_dungeon();
|
|
}
|
|
return game && is_in_game() && game->send_dungeon();
|
|
}
|
|
|
|
bool M2Client::sync_positions(const Array &positions) {
|
|
if (!is_in_game() || positions.is_empty() || positions.size() > 16) {
|
|
return false;
|
|
}
|
|
std::vector<mtnet::classic::SyncPosElement> classic_elements;
|
|
std::vector<mtnet::CGSyncPositionElement> elements;
|
|
if (classic_sess) classic_elements.reserve(positions.size());
|
|
else elements.reserve(positions.size());
|
|
for (int i = 0; i < positions.size(); ++i) {
|
|
Variant value = positions[i];
|
|
if (value.get_type() != Variant::DICTIONARY) {
|
|
return false;
|
|
}
|
|
Dictionary d = value;
|
|
if (!d.has("vid") || !d.has("x") || !d.has("y")) {
|
|
return false;
|
|
}
|
|
const Variant raw_vid = d["vid"];
|
|
const Variant raw_x = d["x"];
|
|
const Variant raw_y = d["y"];
|
|
if (raw_vid.get_type() != Variant::INT || raw_x.get_type() != Variant::INT ||
|
|
raw_y.get_type() != Variant::INT) {
|
|
return false;
|
|
}
|
|
const int64_t vid = (int64_t)raw_vid;
|
|
const int64_t x = (int64_t)raw_x;
|
|
const int64_t y = (int64_t)raw_y;
|
|
if (!mtnet::bounds::u32(vid) || !mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) classic_elements.push_back({(uint32_t)vid, (int32_t)x, (int32_t)y});
|
|
else elements.push_back({(uint32_t)vid, (int32_t)x, (int32_t)y});
|
|
}
|
|
if (classic_sess) return classic_sess->send_sync_positions(classic_elements);
|
|
if (!game) return false;
|
|
return game->send_sync_positions(elements);
|
|
}
|
|
|
|
bool M2Client::attack(int motion, int victim_vid) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(motion) || victim_vid <= 0 ||
|
|
!mtnet::bounds::u32(victim_vid)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_attack((uint8_t)motion, (uint32_t)victim_vid);
|
|
}
|
|
return game && game->send_attack((uint8_t)motion, (uint32_t)victim_vid);
|
|
}
|
|
|
|
bool M2Client::set_target(int victim_vid) {
|
|
// VID 0 is the stock client's explicit "clear target" packet.
|
|
if (!is_in_game() || victim_vid < 0 || !mtnet::bounds::u32(victim_vid)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_target((uint32_t)victim_vid);
|
|
}
|
|
return game && game->send_target((uint32_t)victim_vid);
|
|
}
|
|
|
|
bool M2Client::say(int type, const String &text) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(type)) {
|
|
return false;
|
|
}
|
|
const std::string text_wire = std::string(text.utf8().get_data());
|
|
if ((classic_sess && text_wire.size() >= 512) ||
|
|
(!classic_sess && text_wire.size() > 0xffffu - sizeof(mtnet::CGChat) - 1)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_chat((uint8_t)type, text_wire);
|
|
}
|
|
return game && game->send_chat((uint8_t)type, text_wire);
|
|
}
|
|
|
|
bool M2Client::send_emoticon(int index) {
|
|
// Registration order from playersettingmodule.py. The reference client's
|
|
// SendEmoticon sends this token through SendChatPacket, while the received
|
|
// token is parsed back into the matching visual effect by the chat handler.
|
|
static constexpr const char *kTokens[] = {
|
|
"(sweat)", "(money)", "(happy)", "(like)", "(love)", "(angry)",
|
|
"(aha)", "(sad)", "(sorry)", "(!)", "(?)", "(fish)"
|
|
};
|
|
constexpr int kTokenCount = sizeof(kTokens) / sizeof(kTokens[0]);
|
|
if (index < 0 || index >= kTokenCount || !say(0 /* CHAT_TYPE_TALKING */, String(kTokens[index]))) {
|
|
return false;
|
|
}
|
|
emit_signal("emoticon_requested", index);
|
|
return true;
|
|
}
|
|
|
|
bool M2Client::whisper(const String &to, const String &text) {
|
|
const std::string to_wire = std::string(to.utf8().get_data());
|
|
const std::string text_wire = std::string(text.utf8().get_data());
|
|
if (classic_sess) {
|
|
return is_in_game() && !to_wire.empty() &&
|
|
to_wire.size() <= mtnet::classic::CHARACTER_NAME_MAX_LEN && text_wire.size() < 512 &&
|
|
classic_sess->send_whisper(to_wire, text_wire);
|
|
}
|
|
if (!game || !is_in_game() || to_wire.empty() ||
|
|
to_wire.size() > mtnet::CHARACTER_NAME_MAX_LEN ||
|
|
text_wire.size() > 0xffffu - sizeof(mtnet::CGWhisper) - 1) {
|
|
return false;
|
|
}
|
|
return game->send_whisper(to_wire, text_wire);
|
|
}
|
|
|
|
bool M2Client::cast_skill(int motion_idx, double rot_deg, int x, int y) {
|
|
// FUNC_SKILL | motion_idx; do not silently mask an invalid caller value.
|
|
if (motion_idx < 0 || motion_idx > 0x7f) {
|
|
return false;
|
|
}
|
|
return move(0x80 | motion_idx, 0, rot_deg, x, y);
|
|
}
|
|
|
|
bool M2Client::use_skill(int skill_id, int target_vid) {
|
|
// §3.8 mod 3: signed params, range-checked before the uint32 wire cast.
|
|
// skill_id is a real skill index (0 < id < SKILL_MAX_NUM), not a free u32.
|
|
if (!is_in_game() || !mtnet::bounds::skill_index(skill_id) || target_vid < 0 ||
|
|
!mtnet::bounds::u32(target_vid)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_use_skill((uint32_t)skill_id, (uint32_t)target_vid);
|
|
}
|
|
if (!game) {
|
|
return false;
|
|
}
|
|
if (target_vid > 0) {
|
|
uint32_t fly_target_vid = 0;
|
|
int32_t fly_x = 0, fly_y = 0;
|
|
const mtnet::Entity *target = game->world().get((uint32_t)target_vid);
|
|
if (target) {
|
|
fly_target_vid = target->vid;
|
|
fly_x = (int32_t)target->x;
|
|
fly_y = (int32_t)target->y;
|
|
}
|
|
// The legacy client deliberately falls back to a zero target packet
|
|
// when the VID is no longer in its character manager.
|
|
if (!game->send_fly_targeting(fly_target_vid, fly_x, fly_y)) {
|
|
return false;
|
|
}
|
|
}
|
|
return game->send_use_skill((uint32_t)skill_id, (uint32_t)target_vid);
|
|
}
|
|
|
|
bool M2Client::shoot(int skill_id) {
|
|
if (!is_in_game() || skill_id < 0 || skill_id > 255) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_shoot((uint8_t)skill_id);
|
|
if (!game) return false;
|
|
return game->send_shoot((uint8_t)skill_id);
|
|
}
|
|
|
|
bool M2Client::add_fly_targeting(int target_vid, int x, int y) {
|
|
if (!is_in_game() || target_vid < 0 || !mtnet::bounds::u32(target_vid) ||
|
|
!mtnet::bounds::i32(x) || !mtnet::bounds::i32(y)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_add_fly_targeting((uint32_t)target_vid, (int32_t)x, (int32_t)y);
|
|
if (!game) return false;
|
|
return game->send_add_fly_targeting((uint32_t)target_vid, (int32_t)x, (int32_t)y);
|
|
}
|
|
|
|
bool M2Client::skill_up(int skill_id) {
|
|
// §3.9 / §3.10: /skillup 只发正的、低于 SKILL_MAX_NUM 的技能索引(与 use_skill 同一谓词)。
|
|
if (!is_in_game() || !mtnet::bounds::skill_index(skill_id)) {
|
|
return false;
|
|
}
|
|
return say(0 /*CHAT_TYPE_TALKING*/, String("/skillup ") + String::num_int64(skill_id));
|
|
}
|
|
|
|
int M2Client::get_skill_group() const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
return world ? (int)world->skill_group() : 0;
|
|
}
|
|
|
|
Array M2Client::get_skills() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (int id = 0; id < mtnet::SKILL_MAX_NUM; ++id) {
|
|
int lv = world->skill_level(id);
|
|
if (lv > 0) {
|
|
Dictionary d;
|
|
d["id"] = id;
|
|
d["level"] = lv;
|
|
d["master"] = (int)world->skill_master(id); // 0..3
|
|
out.push_back(d);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Array M2Client::get_quickslots() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (int pos = 0; pos < mtnet::QUICKSLOT_MAX_NUM; ++pos) {
|
|
mtnet::QuickSlot qs = world->quickslot(pos);
|
|
if (qs.type == 0) {
|
|
continue;
|
|
}
|
|
Dictionary d;
|
|
d["pos"] = pos;
|
|
d["type"] = (int)qs.type; // 1 inventory / 2 skill / 3 emotion / 4 shop (not executable)
|
|
d["ref"] = (int)qs.position; // item cell / skill id / ...
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
bool M2Client::quickslot_add(int pos, int type, int ref) {
|
|
if (!is_in_game() || !mtnet::bounds::quick_slot(pos) || !mtnet::bounds::u8(type) ||
|
|
type < 1 || type > 3 || !mtnet::bounds::u8(ref)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_quickslot_add((uint8_t)pos, (uint8_t)type, (uint8_t)ref);
|
|
if (!game) return false;
|
|
return game->send_quickslot_add((uint8_t)pos, (uint8_t)type, (uint8_t)ref);
|
|
}
|
|
|
|
bool M2Client::quickslot_del(int pos) {
|
|
if (!is_in_game() || !mtnet::bounds::quick_slot(pos)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_quickslot_del((uint8_t)pos);
|
|
if (!game) return false;
|
|
return game->send_quickslot_del((uint8_t)pos);
|
|
}
|
|
|
|
bool M2Client::quickslot_swap(int pos, int change_pos) {
|
|
if (!is_in_game() || !mtnet::bounds::quick_slot(pos) ||
|
|
!mtnet::bounds::quick_slot(change_pos)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_quickslot_swap((uint8_t)pos, (uint8_t)change_pos);
|
|
if (!game) return false;
|
|
return game->send_quickslot_swap((uint8_t)pos, (uint8_t)change_pos);
|
|
}
|
|
|
|
bool M2Client::click_npc(int vid) {
|
|
if (!is_in_game() || vid <= 0 || !mtnet::bounds::u32(vid)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_click_npc((uint32_t)vid);
|
|
}
|
|
return game && game->send_on_click((uint32_t)vid);
|
|
}
|
|
bool M2Client::script_answer(int answer) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(answer)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_script_answer((uint8_t)answer);
|
|
}
|
|
return game && game->send_script_answer((uint8_t)answer);
|
|
}
|
|
bool M2Client::script_button(int idx) {
|
|
if (!is_in_game() || idx < 0 || !mtnet::bounds::u32(idx)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_script_button((uint32_t)idx);
|
|
return game && game->send_script_button((uint32_t)idx);
|
|
}
|
|
bool M2Client::script_select_item(int selection) {
|
|
if (!is_in_game() || selection < 0 || !mtnet::bounds::u32(selection)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_script_select_item((uint32_t)selection);
|
|
if (!game) return false;
|
|
return game->send_script_select_item((uint32_t)selection);
|
|
}
|
|
bool M2Client::quest_input(const String &text) {
|
|
if (classic_sess) return is_in_game() && classic_sess->send_quest_input(std::string(text.utf8().get_data()));
|
|
return game && is_in_game() && game->send_quest_input(std::string(text.utf8().get_data()));
|
|
}
|
|
bool M2Client::quest_confirm(bool yes, int request_pid) {
|
|
if (classic_sess) return is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) &&
|
|
classic_sess->send_quest_confirm(yes, (uint32_t)request_pid);
|
|
return game && is_in_game() && request_pid >= 0 && mtnet::bounds::u32(request_pid) &&
|
|
game->send_quest_confirm(yes, (uint32_t)request_pid);
|
|
}
|
|
bool M2Client::quest_cancel() {
|
|
if (classic_sess) return is_in_game() && classic_sess->send_quest_cancel();
|
|
return game && is_in_game() && game->send_quest_cancel();
|
|
}
|
|
|
|
Array M2Client::get_quests() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (uint16_t idx : world->quest_indices()) {
|
|
const mtnet::QuestInfo *q = world->quest(idx);
|
|
if (!q) {
|
|
continue;
|
|
}
|
|
Dictionary d;
|
|
d["index"] = (int)q->index;
|
|
d["begin"] = q->begin;
|
|
d["title"] = String::utf8(q->title.c_str());
|
|
d["clock_name"] = String::utf8(q->clock_name.c_str());
|
|
d["clock_value"] = q->clock_value;
|
|
d["counter_name"] = String::utf8(q->counter_name.c_str());
|
|
d["counter_value"] = q->counter_value;
|
|
d["icon"] = String::utf8(q->icon.c_str());
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// --- P8 social / shop / storage ---
|
|
|
|
bool M2Client::party_invite(int vid) {
|
|
if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
|
|
classic_sess->send_party_invite((uint32_t)vid);
|
|
return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
|
|
game->send_party_invite((uint32_t)vid);
|
|
}
|
|
bool M2Client::party_answer(int leader_pid, bool accept) {
|
|
if (classic_sess) return is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) &&
|
|
classic_sess->send_party_answer((uint32_t)leader_pid, accept);
|
|
return game && is_in_game() && leader_pid > 0 && mtnet::bounds::u32(leader_pid) &&
|
|
game->send_party_invite_answer((uint32_t)leader_pid, accept);
|
|
}
|
|
bool M2Client::party_leave(int pid) {
|
|
if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
|
|
classic_sess->send_party_remove((uint32_t)pid);
|
|
return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
|
|
game->send_party_remove((uint32_t)pid);
|
|
}
|
|
bool M2Client::party_use_skill(int skill_index, int target_vid) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(skill_index) || target_vid < 0 ||
|
|
!mtnet::bounds::u32(target_vid)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid);
|
|
if (!game) return false;
|
|
return game->send_party_use_skill((uint8_t)skill_index, (uint32_t)target_vid);
|
|
}
|
|
bool M2Client::party_set_distribute(int mode) {
|
|
if (classic_sess) return is_in_game() && mtnet::bounds::u8(mode) &&
|
|
classic_sess->send_party_parameter((uint8_t)mode);
|
|
return game && is_in_game() && mtnet::bounds::u8(mode) &&
|
|
game->send_party_parameter((uint8_t)mode);
|
|
}
|
|
bool M2Client::party_set_state(int pid, int role, bool on) {
|
|
if (!is_in_game() || pid <= 0 || !mtnet::bounds::u32(pid) || role < 0 || role >= 8) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_party_set_state((uint32_t)pid, (uint8_t)role, on);
|
|
return game && game->send_party_set_state((uint32_t)pid, (uint8_t)role, on ? 1 : 0);
|
|
}
|
|
Array M2Client::get_party() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (uint32_t pid : world->party_pids()) {
|
|
const mtnet::PartyMember *m = world->party_member(pid);
|
|
if (!m) {
|
|
continue;
|
|
}
|
|
Dictionary d;
|
|
d["pid"] = (int)m->pid;
|
|
d["vid"] = (int)m->vid;
|
|
d["name"] = String::utf8(m->name.c_str());
|
|
d["leader"] = m->leader();
|
|
d["state"] = (int)m->state; // full role byte (bit0 = leader on this fork's server)
|
|
d["hp_pct"] = (int)m->hp_pct;
|
|
Array affects;
|
|
for (int i = 0; i < mtnet::PARTY_AFFECT_SLOT_MAX_NUM; ++i) {
|
|
affects.push_back((int)m->affects[i]);
|
|
}
|
|
d["affects"] = affects;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
int M2Client::get_party_distribute_mode() const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
return world ? (int)world->party_distribute_mode() : 0;
|
|
}
|
|
|
|
bool M2Client::add_friend(const String &name) {
|
|
if (classic_sess) {
|
|
return is_in_game() && classic_sess->send_friend_add(std::string(name.utf8().get_data()));
|
|
}
|
|
return game && is_in_game() && game->send_friend_add(std::string(name.utf8().get_data()));
|
|
}
|
|
bool M2Client::remove_friend(const String &name) {
|
|
if (classic_sess) {
|
|
return is_in_game() && classic_sess->send_friend_remove(std::string(name.utf8().get_data()));
|
|
}
|
|
return game && is_in_game() && game->send_friend_remove(std::string(name.utf8().get_data()));
|
|
}
|
|
bool M2Client::friend_answer(const String &name, bool accept) {
|
|
if (!is_in_game() || name.is_empty()) {
|
|
return false;
|
|
}
|
|
// 40250 handles messenger invitations through the normal slash-command
|
|
// channel: `/messenger_auth y|n <requester>`, not a CG subheader.
|
|
const std::string command = "/messenger_auth " + std::string(accept ? "y " : "n ") +
|
|
std::string(name.utf8().get_data());
|
|
if (classic_sess) {
|
|
return classic_sess->send_chat(mtnet::CHAT_TYPE_TALKING, command);
|
|
}
|
|
return game && game->send_chat(mtnet::CHAT_TYPE_TALKING, command);
|
|
}
|
|
Array M2Client::get_friends() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (const auto &f : world->friends()) {
|
|
Dictionary d;
|
|
d["name"] = String::utf8(f.name.c_str());
|
|
d["online"] = f.online;
|
|
d["mobile"] = f.mobile;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Dictionary M2Client::get_lover() const {
|
|
Dictionary d;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) return d;
|
|
const mtnet::LoverInfo &lover = world->lover();
|
|
d["valid"] = lover.valid;
|
|
d["name"] = String::utf8(lover.name.c_str());
|
|
d["love_point"] = (int)lover.love_point;
|
|
d["online"] = lover.online;
|
|
d["near"] = lover.near;
|
|
return d;
|
|
}
|
|
|
|
bool M2Client::shop_buy(int pos, int count) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(pos) || count < 1 || !mtnet::bounds::u8(count)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_shop_buy((uint8_t)pos, (uint8_t)count);
|
|
return game && game->send_shop_buy((uint8_t)pos, (uint8_t)count);
|
|
}
|
|
bool M2Client::shop_sell(int inv_cell, int count) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(inv_cell) || count < 0 || !mtnet::bounds::u8(count)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_shop_sell((uint8_t)inv_cell, (uint8_t)count);
|
|
return game->send_shop_sell((uint8_t)inv_cell, (uint8_t)count);
|
|
}
|
|
bool M2Client::shop_close() {
|
|
if (classic_sess) return is_in_game() && classic_sess->send_shop_close();
|
|
return game && is_in_game() && game->send_shop_close();
|
|
}
|
|
bool M2Client::is_shop_open() const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
return world && world->shop_open();
|
|
}
|
|
static Dictionary shop_entry_dict(const mtnet::ShopEntry &s) {
|
|
Dictionary d;
|
|
d["pos"] = (int)s.pos;
|
|
d["vnum"] = (int)s.vnum;
|
|
d["price"] = (int)s.price;
|
|
d["count"] = (int)s.count;
|
|
return d;
|
|
}
|
|
|
|
Array M2Client::get_shop_items() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (const auto &s : world->shop_items()) {
|
|
out.push_back(shop_entry_dict(s));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Dictionary M2Client::get_shop() const {
|
|
Dictionary d;
|
|
const mtnet::EntityStore *wp = active_world();
|
|
if (!wp) {
|
|
return d;
|
|
}
|
|
const mtnet::EntityStore &w = *wp;
|
|
d["vid"] = (int)w.shop_vid();
|
|
d["open"] = w.shop_open();
|
|
d["selling_price"] = w.shop_selling_price();
|
|
Array tabs;
|
|
if (!w.shop_tabs().empty()) {
|
|
// SHOP_GC_START_EX — one entry per shelf, each with its name + coin_type.
|
|
for (const auto &t : w.shop_tabs()) {
|
|
Dictionary td;
|
|
td["name"] = String::utf8(t.name.c_str());
|
|
td["coin_type"] = (int)t.coin_type;
|
|
Array items;
|
|
for (const auto &s : t.items) {
|
|
items.push_back(shop_entry_dict(s));
|
|
}
|
|
td["items"] = items;
|
|
tabs.push_back(td);
|
|
}
|
|
} else if (w.shop_open()) {
|
|
// plain SHOP_GC_START — a single unnamed tab
|
|
Dictionary td;
|
|
td["name"] = String();
|
|
td["coin_type"] = 0;
|
|
td["items"] = get_shop_items();
|
|
tabs.push_back(td);
|
|
}
|
|
d["tabs"] = tabs;
|
|
return d;
|
|
}
|
|
|
|
bool M2Client::exchange_start(int vid) {
|
|
if (!is_in_game() || !mtnet::bounds::u32(vid) || vid <= 0) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_exchange_start((uint32_t)vid);
|
|
}
|
|
return game && game->send_exchange_start((uint32_t)vid);
|
|
}
|
|
bool M2Client::exchange_add_item(int inv_window, int inv_cell, int display_pos) {
|
|
if (!is_in_game() || !valid_item_pos(inv_window, inv_cell) ||
|
|
!mtnet::bounds::exchange_slot(display_pos)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell,
|
|
(uint8_t)display_pos);
|
|
}
|
|
return game && game->send_exchange_item_add((uint8_t)inv_window, (uint16_t)inv_cell,
|
|
(uint8_t)display_pos);
|
|
}
|
|
bool M2Client::exchange_add_gold(int gold) {
|
|
if (!is_in_game() || !mtnet::bounds::u32(gold)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_exchange_gold((uint32_t)gold);
|
|
}
|
|
return game && game->send_exchange_gold((uint32_t)gold);
|
|
}
|
|
bool M2Client::exchange_accept() {
|
|
if (classic_sess) {
|
|
return is_in_game() && classic_sess->send_exchange_accept();
|
|
}
|
|
return game && is_in_game() && game->send_exchange_accept();
|
|
}
|
|
bool M2Client::exchange_cancel() {
|
|
if (classic_sess) {
|
|
return is_in_game() && classic_sess->send_exchange_cancel();
|
|
}
|
|
return game && is_in_game() && game->send_exchange_cancel();
|
|
}
|
|
Dictionary M2Client::get_exchange() const {
|
|
Dictionary d;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return d;
|
|
}
|
|
const mtnet::ExchangeState &x = world->exchange();
|
|
d["active"] = x.active;
|
|
d["partner_vid"] = (int)x.partner_vid;
|
|
d["self_gold"] = (int64_t)x.self_gold;
|
|
d["peer_gold"] = (int64_t)x.peer_gold;
|
|
d["self_accept"] = x.self_accept;
|
|
d["peer_accept"] = x.peer_accept;
|
|
// EXCHANGE_GC_ALREADY(6)/LESS_GOLD(7) 的子头值;0 = 无。成功/取消的区分
|
|
// 只看 CHAT_TYPE_INFO 文案(服务器两种结局都发 GC_END)。
|
|
d["notice"] = (int)x.last_notice;
|
|
auto slots = [](const mtnet::ExchangeSlot *arr) {
|
|
Array a;
|
|
for (int i = 0; i < 12; ++i) {
|
|
if (arr[i].vnum == 0) {
|
|
continue;
|
|
}
|
|
Dictionary s;
|
|
s["slot"] = i;
|
|
s["vnum"] = (int)arr[i].vnum;
|
|
s["count"] = (int)arr[i].count;
|
|
Array sockets;
|
|
for (int j = 0; j < mtnet::ITEM_SOCKET_SLOT_MAX_NUM; ++j) {
|
|
sockets.push_back(arr[i].sockets[j]);
|
|
}
|
|
s["sockets"] = sockets;
|
|
Array attrs;
|
|
for (int j = 0; j < mtnet::ITEM_ATTRIBUTE_SLOT_MAX_NUM; ++j) {
|
|
if (arr[i].attrs[j].type == 0) {
|
|
continue;
|
|
}
|
|
Dictionary a;
|
|
a["type"] = (int)arr[i].attrs[j].type;
|
|
a["value"] = (int)arr[i].attrs[j].value;
|
|
attrs.push_back(a);
|
|
}
|
|
s["attrs"] = attrs;
|
|
a.push_back(s);
|
|
}
|
|
return a;
|
|
};
|
|
d["self_items"] = slots(x.self_items);
|
|
d["peer_items"] = slots(x.peer_items);
|
|
return d;
|
|
}
|
|
|
|
bool M2Client::safebox_checkin(int safe_pos, int inv_window, int inv_cell) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window,
|
|
(uint16_t)inv_cell);
|
|
}
|
|
return game && game->send_safebox_checkin((uint8_t)safe_pos, (uint8_t)inv_window,
|
|
(uint16_t)inv_cell);
|
|
}
|
|
bool M2Client::safebox_checkout(int safe_pos, int inv_window, int inv_cell) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(safe_pos) || !valid_item_pos(inv_window, inv_cell)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window,
|
|
(uint16_t)inv_cell);
|
|
}
|
|
return game && game->send_safebox_checkout((uint8_t)safe_pos, (uint8_t)inv_window,
|
|
(uint16_t)inv_cell);
|
|
}
|
|
bool M2Client::safebox_move(int from_cell, int to_cell, int count) {
|
|
if (!is_in_game() || !mtnet::bounds::u16(from_cell) || !mtnet::bounds::u16(to_cell) ||
|
|
!mtnet::bounds::u8(count)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell,
|
|
(uint8_t)count);
|
|
}
|
|
return game && game->send_safebox_move((uint16_t)from_cell, (uint16_t)to_cell, (uint8_t)count);
|
|
}
|
|
bool M2Client::is_safebox_open() const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
return world && world->safebox_open();
|
|
}
|
|
int M2Client::get_safebox_size() const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
return world ? world->safebox_size() : 0;
|
|
}
|
|
int M2Client::get_safebox_gold() const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
return world ? (int)world->safebox_gold() : 0;
|
|
}
|
|
Array M2Client::get_safebox_items() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (int c = 0; c < mtnet::SAFEBOX_MAX_NUM; ++c) {
|
|
const mtnet::Item &it = world->safebox_slot(c);
|
|
if (it.empty()) {
|
|
continue;
|
|
}
|
|
Dictionary d;
|
|
d["cell"] = c;
|
|
d["vnum"] = (int)it.vnum;
|
|
d["count"] = (int)it.count;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// --- item-mall (창고몰) ---
|
|
|
|
bool M2Client::is_mall_open() const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
return world && world->mall_open();
|
|
}
|
|
int M2Client::get_mall_size() const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
return world ? world->mall_size() : 0;
|
|
}
|
|
Array M2Client::get_mall_items() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (int c = 0; c < mtnet::MALL_MAX_NUM; ++c) {
|
|
const mtnet::Item &it = world->mall_slot(c);
|
|
if (it.empty()) {
|
|
continue;
|
|
}
|
|
Dictionary d;
|
|
d["cell"] = c;
|
|
d["vnum"] = (int)it.vnum;
|
|
d["count"] = (int)it.count;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
bool M2Client::mall_checkout(int mall_pos, int inv_window, int inv_cell) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(mall_pos) || !valid_item_pos(inv_window, inv_cell)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window,
|
|
(uint16_t)inv_cell);
|
|
}
|
|
return game && game->send_mall_checkout((uint8_t)mall_pos, (uint8_t)inv_window,
|
|
(uint16_t)inv_cell);
|
|
}
|
|
|
|
bool M2Client::safebox_password(const String &password) {
|
|
if (password.is_empty() || password.length() > 6) {
|
|
return false;
|
|
}
|
|
return say(0, String("/safebox_password ") + password);
|
|
}
|
|
|
|
bool M2Client::mall_password(const String &password) {
|
|
if (password.is_empty() || password.length() > 6) {
|
|
return false;
|
|
}
|
|
return say(0, String("/mall_password ") + password);
|
|
}
|
|
|
|
bool M2Client::safebox_change_password(const String &old_password, const String &new_password) {
|
|
// 服务器 do_safebox_change_password 要求两个参数各 1..6 字符。
|
|
if (old_password.is_empty() || old_password.length() > 6 ||
|
|
new_password.is_empty() || new_password.length() > 6) {
|
|
return false;
|
|
}
|
|
return say(0, String("/safebox_change_password ") + old_password + " " + new_password);
|
|
}
|
|
|
|
bool M2Client::safebox_close() {
|
|
return say(0, "/safebox_close");
|
|
}
|
|
|
|
bool M2Client::mall_close() {
|
|
return say(0, "/mall_close");
|
|
}
|
|
|
|
// --- private (PC) shop ---
|
|
|
|
bool M2Client::open_private_shop(const godot::String &sign, const godot::Array &items) {
|
|
if (!is_in_game() || (!game && !classic_sess) ||
|
|
items.size() > mtnet::bounds::PRIVATE_SHOP_ITEM_COUNT) {
|
|
return false;
|
|
}
|
|
const std::string sign_wire = std::string(sign.utf8().get_data());
|
|
if (sign_wire.size() > mtnet::SHOP_SIGN_MAX_LEN) {
|
|
return false;
|
|
}
|
|
|
|
struct PrivateShopItemInput {
|
|
uint32_t vnum;
|
|
uint8_t count;
|
|
uint8_t inv_window;
|
|
uint16_t inv_cell;
|
|
uint32_t price;
|
|
uint8_t display_pos;
|
|
};
|
|
std::vector<PrivateShopItemInput> parsed;
|
|
parsed.reserve(items.size());
|
|
for (int i = 0; i < items.size(); ++i) {
|
|
const Variant raw = items[i];
|
|
if (raw.get_type() != Variant::DICTIONARY) {
|
|
return false;
|
|
}
|
|
const Dictionary d = raw;
|
|
int64_t vnum = 0;
|
|
int64_t count = 1;
|
|
int64_t inv_window = mtnet::WINDOW_INVENTORY;
|
|
int64_t inv_cell = 0;
|
|
int64_t price = 0;
|
|
int64_t display_pos = i;
|
|
if (!read_int(d, "vnum", vnum, 0) || !read_int(d, "count", count, 1) ||
|
|
!read_int(d, "inv_window", inv_window, mtnet::WINDOW_INVENTORY) ||
|
|
!read_int(d, "inv_cell", inv_cell, 0) || !read_int(d, "price", price, 0) ||
|
|
!read_int(d, "display_pos", display_pos, i) || !mtnet::bounds::u32(vnum) ||
|
|
!mtnet::bounds::u8(count) || count < 1 || !mtnet::bounds::u8(inv_window) ||
|
|
!mtnet::bounds::u16(inv_cell) || !mtnet::bounds::u32(price) ||
|
|
display_pos < 0 || !mtnet::bounds::shop_slot(display_pos)) {
|
|
return false;
|
|
}
|
|
parsed.push_back({(uint32_t)vnum, (uint8_t)count, (uint8_t)inv_window,
|
|
(uint16_t)inv_cell, (uint32_t)price, (uint8_t)display_pos});
|
|
}
|
|
|
|
if (classic_sess) {
|
|
std::vector<mtnet::classic::CGMyShopItem> v;
|
|
v.reserve(parsed.size());
|
|
for (const auto &in : parsed) {
|
|
mtnet::classic::CGMyShopItem e{};
|
|
e.vnum = in.vnum;
|
|
e.count = in.count;
|
|
e.pos = {in.inv_window, in.inv_cell};
|
|
e.price = in.price;
|
|
e.display_pos = in.display_pos;
|
|
v.push_back(e);
|
|
}
|
|
return classic_sess->send_private_shop(sign_wire, v);
|
|
}
|
|
std::vector<mtnet::MyShopItem> v;
|
|
v.reserve(parsed.size());
|
|
for (const auto &in : parsed) {
|
|
mtnet::MyShopItem e{};
|
|
e.vnum = in.vnum;
|
|
e.count = in.count;
|
|
e.pos = {in.inv_window, in.inv_cell};
|
|
e.price = in.price;
|
|
e.display_pos = in.display_pos;
|
|
v.push_back(e);
|
|
}
|
|
return game->send_open_private_shop(sign_wire, v);
|
|
}
|
|
bool M2Client::close_private_shop() {
|
|
if (classic_sess) {
|
|
// 40250 toggles a player's own shop in CInputMain::MyShop: receiving a
|
|
// CG_MYSHOP head while GetMyShop() is set closes it. CG_SHOP/END only
|
|
// stops an NPC-shop visit and leaves a private shop open.
|
|
return is_in_game() && classic_sess->send_private_shop("", {});
|
|
}
|
|
return game && game->send_close_private_shop();
|
|
}
|
|
|
|
// --- cube (제작) ---
|
|
|
|
Dictionary M2Client::get_cube() const {
|
|
Dictionary d;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return d;
|
|
}
|
|
const mtnet::EntityStore::CubeState &c = world->cube();
|
|
d["open"] = c.open;
|
|
d["npc_vnum"] = (int)c.npc_vnum;
|
|
d["need_gold"] = (int64_t)c.need_gold;
|
|
d["need_item_vnum"] = (int)c.need_item_vnum;
|
|
d["need_item_count"] = c.need_item_count;
|
|
Array recipes;
|
|
for (size_t ri = 0; ri < c.recipes.size(); ++ri) {
|
|
const auto &r = c.recipes[ri];
|
|
Dictionary rd;
|
|
rd["index"] = (int)ri;
|
|
rd["result_vnum"] = (int)r.result_vnum;
|
|
rd["result_count"] = r.result_count;
|
|
rd["gold"] = (int64_t)r.gold;
|
|
Array groups;
|
|
for (const auto &g : r.material_groups) {
|
|
Array slots;
|
|
for (const auto &s : g) {
|
|
Dictionary sd;
|
|
sd["vnum"] = (int)s.vnum;
|
|
sd["count"] = s.count;
|
|
slots.push_back(sd);
|
|
}
|
|
groups.push_back(slots);
|
|
}
|
|
rd["material_groups"] = groups;
|
|
recipes.push_back(rd);
|
|
}
|
|
d["recipes"] = recipes;
|
|
Array results;
|
|
for (const auto &e : c.results) {
|
|
Dictionary ed;
|
|
ed["vnum"] = (int)e.vnum;
|
|
ed["count"] = e.count;
|
|
results.push_back(ed);
|
|
}
|
|
d["results"] = results;
|
|
return d;
|
|
}
|
|
bool M2Client::cube_make(int result_index) {
|
|
if (!is_in_game() || result_index < 0 || !mtnet::bounds::u32(result_index)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_cube_make(result_index);
|
|
return game && game->send_cube_make(result_index);
|
|
}
|
|
bool M2Client::cube_request_result_list(int npc_vnum) {
|
|
if (!is_in_game() || !mtnet::bounds::u32(npc_vnum)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_cube_result_list(npc_vnum);
|
|
return game && game->send_cube_result_list((uint32_t)npc_vnum);
|
|
}
|
|
bool M2Client::cube_request_materials(int start_index, int count) {
|
|
if (!is_in_game() || !mtnet::bounds::u8(start_index) || count < 1 ||
|
|
!mtnet::bounds::u8(count)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_cube_materials(start_index, count);
|
|
return game && game->send_cube_material_info(start_index, count);
|
|
}
|
|
bool M2Client::cube_open() {
|
|
if (classic_sess) return is_in_game() && classic_sess->send_cube_open();
|
|
return game && is_in_game() && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube open");
|
|
}
|
|
bool M2Client::cube_close() {
|
|
if (classic_sess) return is_in_game() && classic_sess->send_cube_close();
|
|
return game && is_in_game() && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube close");
|
|
}
|
|
bool M2Client::cube_list() {
|
|
if (classic_sess) return is_in_game() && classic_sess->send_cube_list();
|
|
return game && is_in_game() && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube list");
|
|
}
|
|
bool M2Client::cube_add_item(int cube_index, int inventory_index) {
|
|
if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index) ||
|
|
!mtnet::bounds::u8(inventory_index)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_cube_add_item(cube_index, inventory_index);
|
|
if (!game) return false;
|
|
return game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube add " + std::to_string(cube_index) +
|
|
" " + std::to_string(inventory_index));
|
|
}
|
|
bool M2Client::cube_delete_item(int cube_index) {
|
|
if (!is_in_game() || !mtnet::bounds::cube_slot(cube_index)) return false;
|
|
if (classic_sess) return classic_sess->send_cube_delete_item(cube_index);
|
|
return game && game->send_chat(mtnet::CHAT_TYPE_COMMAND, "/cube delete " + std::to_string(cube_index));
|
|
}
|
|
|
|
// --- guild / refine ---
|
|
|
|
Dictionary M2Client::get_guild() const {
|
|
Dictionary d;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return d;
|
|
}
|
|
const mtnet::GuildState &g = world->guild();
|
|
d["in_guild"] = g.in_guild;
|
|
d["id"] = (int)g.id;
|
|
d["name"] = String::utf8(g.name.c_str());
|
|
d["level"] = (int)g.level;
|
|
d["exp"] = (int64_t)g.exp;
|
|
d["gold"] = (int64_t)g.gold;
|
|
d["member_count"] = (int)g.member_count;
|
|
d["max_member_count"] = (int)g.max_member_count;
|
|
d["master_pid"] = (int)g.master_pid;
|
|
d["has_land"] = g.has_land;
|
|
return d;
|
|
}
|
|
Array M2Client::get_guild_members() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (const auto &m : world->guild_members()) {
|
|
Dictionary d;
|
|
d["pid"] = (int)m.pid;
|
|
d["name"] = String::utf8(m.name.c_str());
|
|
d["grade"] = (int)m.grade;
|
|
d["job"] = (int)m.job;
|
|
d["level"] = (int)m.level;
|
|
d["offer"] = (int64_t)m.offer;
|
|
d["general"] = m.is_general;
|
|
d["online"] = m.online;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
Array M2Client::get_guild_grades() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (int i = 0; i < 16; ++i) {
|
|
const mtnet::GuildGrade &g = world->guild_grade(i);
|
|
Dictionary d;
|
|
d["name"] = String::utf8(g.name.c_str());
|
|
d["auth"] = (int)g.auth;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
bool M2Client::guild_add_member(int vid) {
|
|
if (classic_sess) return is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
|
|
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_ADD_MEMBER, (uint32_t)vid);
|
|
return game && is_in_game() && vid > 0 && mtnet::bounds::u32(vid) &&
|
|
game->send_guild_sub_u32(mtnet::GUILD_CG_ADD_MEMBER, (uint32_t)vid);
|
|
}
|
|
bool M2Client::guild_remove_member(int pid) {
|
|
if (classic_sess) return is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
|
|
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid);
|
|
return game && is_in_game() && pid > 0 && mtnet::bounds::u32(pid) &&
|
|
game->send_guild_sub_u32(mtnet::GUILD_CG_REMOVE_MEMBER, (uint32_t)pid);
|
|
}
|
|
bool M2Client::guild_offer(int amount) {
|
|
if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) &&
|
|
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_OFFER, (uint32_t)amount);
|
|
return game && is_in_game() && mtnet::bounds::u32(amount) &&
|
|
game->send_guild_sub_u32(mtnet::GUILD_CG_OFFER, (uint32_t)amount);
|
|
}
|
|
bool M2Client::guild_deposit_money(int amount) {
|
|
// 40250 服务器禁用了存款处理(input_main.cpp GUILD_CG_DEPOSIT_MONEY 直接
|
|
// return);原版 UI 仍发送该包,这里保持同样的线上行为。
|
|
if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) &&
|
|
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_DEPOSIT_MONEY,
|
|
(uint32_t)amount);
|
|
return game && is_in_game() && mtnet::bounds::u32(amount) &&
|
|
game->send_guild_sub_u32(mtnet::GUILD_CG_DEPOSIT_MONEY, (uint32_t)amount);
|
|
}
|
|
|
|
bool M2Client::guild_withdraw_money(int amount) {
|
|
// 同上:40250 服务器禁用取款(且原逻辑仅会长可取,上限 500000)。
|
|
if (classic_sess) return is_in_game() && mtnet::bounds::u32(amount) &&
|
|
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_WITHDRAW_MONEY,
|
|
(uint32_t)amount);
|
|
return game && is_in_game() && mtnet::bounds::u32(amount) &&
|
|
game->send_guild_sub_u32(mtnet::GUILD_CG_WITHDRAW_MONEY, (uint32_t)amount);
|
|
}
|
|
|
|
bool M2Client::guild_charge_gsp(int amount) {
|
|
if (!mtnet::bounds::i32(amount)) return false;
|
|
if (classic_sess) return is_in_game() &&
|
|
classic_sess->send_guild_i32(mtnet::classic::GUILD_CG_CHARGE_GSP, (int32_t)amount);
|
|
return game && is_in_game() &&
|
|
game->send_guild_sub_i32(mtnet::GUILD_CG_CHARGE_GSP, (int32_t)amount);
|
|
}
|
|
bool M2Client::guild_change_grade_name(int grade, const String &name) {
|
|
const std::string value = name.utf8().get_data();
|
|
if (grade < 1 || grade >= 16 || value.empty() ||
|
|
value.size() > mtnet::classic::GUILD_GRADE_NAME_MAX_LEN) return false;
|
|
if (classic_sess) return is_in_game() &&
|
|
classic_sess->send_guild_grade_name((uint8_t)grade, value);
|
|
return game && is_in_game() && game->send_guild_grade_name((uint8_t)grade, value);
|
|
}
|
|
bool M2Client::guild_change_grade_authority(int grade, int authority) {
|
|
if (grade < 1 || grade >= 16 || !mtnet::bounds::u8(authority)) return false;
|
|
if (classic_sess) return is_in_game() &&
|
|
classic_sess->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority);
|
|
return game && is_in_game() &&
|
|
game->send_guild_grade_authority((uint8_t)grade, (uint8_t)authority);
|
|
}
|
|
bool M2Client::guild_change_member_grade(int pid, int grade) {
|
|
if (pid <= 0 || !mtnet::bounds::u32(pid) || grade < 1 || grade >= 16) return false;
|
|
if (classic_sess) return is_in_game() &&
|
|
classic_sess->send_guild_member_grade((uint32_t)pid, (uint8_t)grade);
|
|
return game && is_in_game() &&
|
|
game->send_guild_member_grade((uint32_t)pid, (uint8_t)grade);
|
|
}
|
|
bool M2Client::guild_change_member_general(int pid, bool enabled) {
|
|
if (pid <= 0 || !mtnet::bounds::u32(pid)) return false;
|
|
if (classic_sess) return is_in_game() &&
|
|
classic_sess->send_guild_member_general((uint32_t)pid, enabled);
|
|
return game && is_in_game() &&
|
|
game->send_guild_member_general((uint32_t)pid, enabled);
|
|
}
|
|
bool M2Client::guild_post_comment(const String &text) {
|
|
const std::string comment = text.utf8().get_data();
|
|
if (comment.empty() || comment.size() + 1 > mtnet::classic::GUILD_COMMENT_MAX_LEN) return false;
|
|
if (classic_sess) return is_in_game() && classic_sess->send_guild_comment(comment);
|
|
return game && is_in_game() && game->send_guild_comment(comment);
|
|
}
|
|
bool M2Client::guild_delete_comment(int comment_id) {
|
|
if (comment_id <= 0 || !mtnet::bounds::u32(comment_id)) return false;
|
|
if (classic_sess) return is_in_game() &&
|
|
classic_sess->send_guild_u32(mtnet::classic::GUILD_CG_DELETE_COMMENT,
|
|
(uint32_t)comment_id);
|
|
return game && is_in_game() &&
|
|
game->send_guild_sub_u32(mtnet::GUILD_CG_DELETE_COMMENT, (uint32_t)comment_id);
|
|
}
|
|
bool M2Client::guild_refresh_comments() {
|
|
if (classic_sess) return is_in_game() &&
|
|
classic_sess->send_guild_sub(mtnet::classic::GUILD_CG_REFRESH_COMMENT);
|
|
return game && is_in_game() && game->send_guild_sub(mtnet::GUILD_CG_REFRESH_COMMENT);
|
|
}
|
|
bool M2Client::guild_answer_invite(int guild_id, bool accept) {
|
|
if (guild_id <= 0 || !mtnet::bounds::u32(guild_id)) return false;
|
|
if (classic_sess) return is_in_game() &&
|
|
classic_sess->send_guild_invite_answer((uint32_t)guild_id, accept);
|
|
return game && is_in_game() && game->send_guild_invite_answer((uint32_t)guild_id, accept);
|
|
}
|
|
bool M2Client::guild_answer_make(const String &name) {
|
|
if (classic_sess) return is_in_game() &&
|
|
classic_sess->send_guild_answer_make(std::string(name.utf8().get_data()));
|
|
return game && is_in_game() &&
|
|
game->send_guild_answer_make(std::string(name.utf8().get_data()));
|
|
}
|
|
Array M2Client::get_guild_comments() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) return out;
|
|
for (const auto &comment : world->guild_comments()) {
|
|
Dictionary d;
|
|
d["id"] = (int64_t)comment.id;
|
|
d["name"] = String::utf8(comment.name.c_str());
|
|
d["content"] = String::utf8(comment.content.c_str());
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// --- guild war / guild skill ---
|
|
|
|
Dictionary M2Client::get_guild_skill() const {
|
|
Dictionary d;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return d;
|
|
}
|
|
const mtnet::GuildSkillState &s = world->guild_skill();
|
|
d["valid"] = s.valid;
|
|
d["skill_point"] = (int)s.skill_point;
|
|
d["guild_point"] = (int)s.guild_point;
|
|
d["max_guild_point"] = (int)s.max_guild_point;
|
|
Array lv;
|
|
for (int i = 0; i < mtnet::GUILD_SKILL_MAX_NUM; ++i) {
|
|
lv.push_back((int)s.levels[i]);
|
|
}
|
|
d["levels"] = lv;
|
|
return d;
|
|
}
|
|
|
|
Array M2Client::get_guild_wars() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
const mtnet::EntityStore &w = *world;
|
|
for (const auto &p : w.guild_wars()) {
|
|
Dictionary d;
|
|
d["src"] = (int64_t)p.src;
|
|
d["dst"] = (int64_t)p.dst;
|
|
d["src_name"] = String::utf8(w.guild_name(p.src).c_str());
|
|
d["dst_name"] = String::utf8(w.guild_name(p.dst).c_str());
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Dictionary M2Client::get_guild_war() const {
|
|
Dictionary d;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return d;
|
|
}
|
|
const mtnet::GuildWarStatus &g = world->guild_war();
|
|
d["opp_guild_id"] = (int64_t)g.opp_guild_id;
|
|
d["opp_name"] = String::utf8(world->guild_name(g.opp_guild_id).c_str());
|
|
d["type"] = (int)g.type;
|
|
d["state"] = (int)g.state;
|
|
return d;
|
|
}
|
|
|
|
String M2Client::get_guild_name(int guild_id) const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
return world ? String::utf8(world->guild_name((uint32_t)guild_id).c_str()) : String();
|
|
}
|
|
|
|
bool M2Client::use_guild_skill(int skill_vnum, int target_vid) {
|
|
if (!is_in_game() || !mtnet::bounds::u32(skill_vnum) || !mtnet::bounds::u32(target_vid)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_guild_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
|
|
return game && game->send_guild_use_skill((uint32_t)skill_vnum, (uint32_t)target_vid);
|
|
}
|
|
|
|
bool M2Client::declare_guild_war(const String &guild_name) {
|
|
return say(0 /*CHAT_TYPE_TALKING*/, String("/war ") + guild_name);
|
|
}
|
|
|
|
// --- guild marks ---
|
|
|
|
bool M2Client::download_guild_marks(const String &host, int port) {
|
|
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty()) {
|
|
return false;
|
|
}
|
|
mark_host = host;
|
|
mark_port = port;
|
|
mark_ready = false;
|
|
if (mark) {
|
|
mark->disconnect();
|
|
mark.reset();
|
|
}
|
|
if (classic_mark) {
|
|
classic_mark->disconnect();
|
|
classic_mark.reset();
|
|
}
|
|
if (classic_sess) {
|
|
classic_mark = std::make_unique<mtnet::classic::ClassicMarkClient>(
|
|
classic_sess->parser().handle(), classic_sess->parser().random_key());
|
|
if (!classic_mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) {
|
|
UtilityFunctions::push_warning("classic guild-mark connect failed: ",
|
|
String(classic_mark->last_error().c_str()));
|
|
classic_mark.reset();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
const uint32_t handle = game->mark_handle();
|
|
const uint32_t random_key = game->mark_random_key();
|
|
mark = std::make_unique<mtnet::MarkClient>(handle, random_key);
|
|
if (!mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) {
|
|
UtilityFunctions::push_warning("guild-mark connect failed: ",
|
|
String(mark->last_error().c_str()));
|
|
mark.reset();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool M2Client::download_guild_symbol(const String &host, int port, int guild_id) {
|
|
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() ||
|
|
guild_id <= 0 || !mtnet::bounds::u32(guild_id)) {
|
|
return false;
|
|
}
|
|
symbol_guild_id = 0;
|
|
symbol_data.clear();
|
|
mark_host = host;
|
|
mark_port = port;
|
|
if (mark) {
|
|
mark->disconnect();
|
|
mark.reset();
|
|
}
|
|
if (classic_mark) {
|
|
classic_mark->disconnect();
|
|
classic_mark.reset();
|
|
}
|
|
if (classic_sess) {
|
|
classic_mark = std::make_unique<mtnet::classic::ClassicMarkClient>(
|
|
classic_sess->parser().handle(), classic_sess->parser().random_key());
|
|
classic_mark->set_download_symbol((uint32_t)guild_id);
|
|
if (!classic_mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) {
|
|
UtilityFunctions::push_warning("classic guild-symbol download connect failed: ",
|
|
String(classic_mark->last_error().c_str()));
|
|
classic_mark.reset();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
const uint32_t handle = game->mark_handle();
|
|
const uint32_t random_key = game->mark_random_key();
|
|
mark = std::make_unique<mtnet::MarkClient>(handle, random_key);
|
|
mark->set_download_symbol((uint32_t)guild_id);
|
|
if (!mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) {
|
|
UtilityFunctions::push_warning("guild-symbol download connect failed: ",
|
|
String(mark->last_error().c_str()));
|
|
mark.reset();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
PackedByteArray M2Client::get_guild_symbol() const {
|
|
PackedByteArray b;
|
|
if (!symbol_data.empty()) {
|
|
b.resize((int64_t)symbol_data.size());
|
|
std::memcpy(b.ptrw(), symbol_data.data(), symbol_data.size());
|
|
}
|
|
return b;
|
|
}
|
|
|
|
bool M2Client::are_guild_marks_ready() const {
|
|
return mark_ready;
|
|
}
|
|
|
|
Dictionary M2Client::get_mark_server() const {
|
|
Dictionary d;
|
|
d["host"] = mark_host;
|
|
d["port"] = mark_port;
|
|
return d;
|
|
}
|
|
|
|
bool M2Client::upload_guild_mark(const String &host, int port, int guild_id, const Ref<Image> &img) {
|
|
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() ||
|
|
guild_id <= 0 || !mtnet::bounds::u32(guild_id) || img.is_null()) {
|
|
return false;
|
|
}
|
|
Ref<Image> rgba = img;
|
|
if (rgba->get_width() != mtnet::GUILD_MARK_WIDTH || rgba->get_height() != mtnet::GUILD_MARK_HEIGHT ||
|
|
rgba->get_format() != Image::FORMAT_RGBA8) {
|
|
rgba = img->duplicate();
|
|
rgba->convert(Image::FORMAT_RGBA8);
|
|
if (rgba->get_width() != mtnet::GUILD_MARK_WIDTH ||
|
|
rgba->get_height() != mtnet::GUILD_MARK_HEIGHT) {
|
|
rgba->resize(mtnet::GUILD_MARK_WIDTH, mtnet::GUILD_MARK_HEIGHT, Image::INTERPOLATE_BILINEAR);
|
|
}
|
|
}
|
|
PackedByteArray b = rgba->get_data();
|
|
const int want = mtnet::GUILD_MARK_WIDTH * mtnet::GUILD_MARK_HEIGHT * 4;
|
|
if (b.size() != want) {
|
|
return false;
|
|
}
|
|
mark_host = host;
|
|
mark_port = port;
|
|
if (mark) {
|
|
mark->disconnect();
|
|
mark.reset();
|
|
}
|
|
if (classic_mark) {
|
|
classic_mark->disconnect();
|
|
classic_mark.reset();
|
|
}
|
|
if (classic_sess) {
|
|
classic_mark = std::make_unique<mtnet::classic::ClassicMarkClient>(
|
|
classic_sess->parser().handle(), classic_sess->parser().random_key());
|
|
classic_mark->set_upload_mark((uint32_t)guild_id, (const uint32_t *)b.ptr());
|
|
if (!classic_mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) {
|
|
UtilityFunctions::push_warning("classic guild-mark upload connect failed: ",
|
|
String(classic_mark->last_error().c_str()));
|
|
classic_mark.reset();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
const uint32_t handle = game->mark_handle();
|
|
const uint32_t random_key = game->mark_random_key();
|
|
mark = std::make_unique<mtnet::MarkClient>(handle, random_key);
|
|
mark->set_upload_mark((uint32_t)guild_id, (const uint32_t *)b.ptr());
|
|
if (!mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) {
|
|
UtilityFunctions::push_warning("guild-mark upload connect failed: ",
|
|
String(mark->last_error().c_str()));
|
|
mark.reset();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool M2Client::upload_guild_symbol(const String &host, int port, int guild_id,
|
|
const PackedByteArray &file_bytes) {
|
|
if ((!game && !classic_sess) || port <= 0 || !mtnet::bounds::u16(port) || host.is_empty() ||
|
|
guild_id <= 0 || !mtnet::bounds::u32(guild_id) || file_bytes.is_empty()) {
|
|
return false;
|
|
}
|
|
std::vector<uint8_t> bytes(file_bytes.ptr(), file_bytes.ptr() + file_bytes.size());
|
|
mark_host = host;
|
|
mark_port = port;
|
|
if (mark) {
|
|
mark->disconnect();
|
|
mark.reset();
|
|
}
|
|
if (classic_mark) {
|
|
classic_mark->disconnect();
|
|
classic_mark.reset();
|
|
}
|
|
if (classic_sess) {
|
|
classic_mark = std::make_unique<mtnet::classic::ClassicMarkClient>(
|
|
classic_sess->parser().handle(), classic_sess->parser().random_key());
|
|
classic_mark->set_upload_symbol((uint32_t)guild_id, std::move(bytes));
|
|
if (!classic_mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) {
|
|
UtilityFunctions::push_warning("classic guild-symbol upload connect failed: ",
|
|
String(classic_mark->last_error().c_str()));
|
|
classic_mark.reset();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
const uint32_t handle = game->mark_handle();
|
|
const uint32_t random_key = game->mark_random_key();
|
|
mark = std::make_unique<mtnet::MarkClient>(handle, random_key);
|
|
mark->set_upload_symbol((uint32_t)guild_id, std::move(bytes));
|
|
if (!mark->connect(std::string(host.utf8().get_data()), (uint16_t)port)) {
|
|
UtilityFunctions::push_warning("guild-symbol upload connect failed: ",
|
|
String(mark->last_error().c_str()));
|
|
mark.reset();
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Dictionary M2Client::get_guild_mark(int guild_id) const {
|
|
Dictionary d;
|
|
d["found"] = false;
|
|
if (!mark_store) {
|
|
return d;
|
|
}
|
|
mtnet::MarkRect rc = mark_store->rect_of((uint32_t)guild_id);
|
|
d["found"] = rc.found;
|
|
d["img_idx"] = rc.img_idx;
|
|
d["x"] = rc.x;
|
|
d["y"] = rc.y;
|
|
d["w"] = rc.w;
|
|
d["h"] = rc.h;
|
|
return d;
|
|
}
|
|
|
|
Ref<Image> M2Client::get_guild_mark_image(int guild_id) const {
|
|
Ref<Image> img;
|
|
if (!mark_store) {
|
|
return img;
|
|
}
|
|
std::vector<uint32_t> px = mark_store->mark_pixels((uint32_t)guild_id);
|
|
if (px.empty()) {
|
|
return img;
|
|
}
|
|
PackedByteArray b;
|
|
b.resize((int64_t)px.size() * 4);
|
|
std::memcpy(b.ptrw(), px.data(), px.size() * 4);
|
|
img = Image::create_from_data(mtnet::GUILD_MARK_WIDTH, mtnet::GUILD_MARK_HEIGHT, false,
|
|
Image::FORMAT_RGBA8, b);
|
|
return img;
|
|
}
|
|
|
|
void M2Client::pump_mark() {
|
|
mark->process();
|
|
const bool offline = mark->state() == mtnet::NetStream::State::Offline;
|
|
|
|
if (mark->mode() == mtnet::MarkClient::Mode::DownloadSymbol) {
|
|
if (mark->complete()) {
|
|
symbol_guild_id = mark->symbol_guild_id();
|
|
symbol_data = mark->symbol_data();
|
|
PackedByteArray b = get_guild_symbol();
|
|
mark.reset();
|
|
emit_signal("guild_symbol_ready", (int64_t)symbol_guild_id, b);
|
|
} else if (offline) {
|
|
mark.reset();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mark->mode() != mtnet::MarkClient::Mode::Download) {
|
|
// upload: done once the packet has actually left our send buffer, or the
|
|
// server closed after we sent it.
|
|
if (mark->upload_done() || (offline && mark->upload_sent())) {
|
|
mark.reset();
|
|
emit_signal("guild_mark_uploaded", true);
|
|
} else if (offline) {
|
|
mark.reset();
|
|
emit_signal("guild_mark_uploaded", false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (mark->complete()) {
|
|
mark_store = std::make_unique<mtnet::MarkImageSet>(mark->marks());
|
|
mark_ready = true;
|
|
size_t n = mark_store->mark_count();
|
|
mark.reset();
|
|
emit_signal("guild_marks_ready", (int)n);
|
|
return;
|
|
}
|
|
if (offline) {
|
|
// server closed before COMPLETE — keep whatever blocks arrived.
|
|
mark_store = std::make_unique<mtnet::MarkImageSet>(mark->marks());
|
|
mark_ready = mark_store->mark_count() > 0;
|
|
mark.reset();
|
|
emit_signal("guild_marks_ready", mark_ready ? (int)mark_store->mark_count() : 0);
|
|
}
|
|
}
|
|
|
|
void M2Client::pump_classic_mark() {
|
|
classic_mark->process();
|
|
const bool offline = classic_mark->state() ==
|
|
mtnet::classic::ClassicStream::State::Offline;
|
|
|
|
if (classic_mark->mode() == mtnet::classic::ClassicMarkClient::Mode::DownloadSymbol) {
|
|
if (classic_mark->complete()) {
|
|
symbol_guild_id = classic_mark->symbol_guild_id();
|
|
symbol_data = classic_mark->symbol_data();
|
|
PackedByteArray b = get_guild_symbol();
|
|
classic_mark.reset();
|
|
emit_signal("guild_symbol_ready", (int64_t)symbol_guild_id, b);
|
|
} else if (offline) {
|
|
classic_mark.reset();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (classic_mark->mode() != mtnet::classic::ClassicMarkClient::Mode::Download) {
|
|
if (classic_mark->upload_done() || (offline && classic_mark->upload_sent())) {
|
|
classic_mark.reset();
|
|
emit_signal("guild_mark_uploaded", true);
|
|
} else if (offline) {
|
|
classic_mark.reset();
|
|
emit_signal("guild_mark_uploaded", false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (classic_mark->complete()) {
|
|
mark_store = std::make_unique<mtnet::MarkImageSet>(classic_mark->marks());
|
|
mark_ready = true;
|
|
size_t n = mark_store->mark_count();
|
|
classic_mark.reset();
|
|
emit_signal("guild_marks_ready", (int)n);
|
|
return;
|
|
}
|
|
if (offline) {
|
|
// Keep successfully received blocks if the stock mark server closes
|
|
// immediately after the final block instead of sending PHASE_CLOSE.
|
|
mark_store = std::make_unique<mtnet::MarkImageSet>(classic_mark->marks());
|
|
mark_ready = mark_store->mark_count() > 0;
|
|
classic_mark.reset();
|
|
emit_signal("guild_marks_ready", mark_ready ? (int)mark_store->mark_count() : 0);
|
|
}
|
|
}
|
|
bool M2Client::refine(int pos, int type) {
|
|
if (!is_in_game() || pos < 0 || pos >= mtnet::INVENTORY_MAX_NUM || !mtnet::bounds::u8(type)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) return classic_sess->send_refine((uint8_t)pos, (uint8_t)type);
|
|
return game && game->send_refine((uint8_t)pos, (uint8_t)type);
|
|
}
|
|
|
|
bool M2Client::ds_refine(int mode, const Array &cells) {
|
|
if (!is_in_game()) {
|
|
return false;
|
|
}
|
|
static const uint8_t kMode[3] = {
|
|
mtnet::DS_SUB_DO_UPGRADE, mtnet::DS_SUB_DO_IMPROVEMENT, mtnet::DS_SUB_DO_REFINE
|
|
};
|
|
if (mode < 0 || mode > 2) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
mtnet::classic::ItemPos grid[mtnet::classic::DS_REFINE_GRID_MAX_NUM] = {};
|
|
int n = (int)cells.size();
|
|
if (n > mtnet::classic::DS_REFINE_GRID_MAX_NUM) return false;
|
|
for (int i = 0; i < n; ++i) {
|
|
int cell = (int)cells[i];
|
|
if (cell < 0 || cell > 0xffff) return false;
|
|
grid[i] = {(uint8_t)mtnet::WINDOW_INVENTORY, (uint16_t)cell};
|
|
}
|
|
return classic_sess->send_dragon_soul_refine(kMode[mode], grid, n);
|
|
}
|
|
if (!game) return false;
|
|
mtnet::ItemPos grid[mtnet::DS_REFINE_WINDOW_MAX_NUM] = {};
|
|
int n = (int)cells.size();
|
|
if (n > mtnet::DS_REFINE_WINDOW_MAX_NUM) return false;
|
|
for (int i = 0; i < n; ++i) {
|
|
int cell = (int)cells[i];
|
|
if (cell < 0 || cell > 0xffff) return false;
|
|
grid[i].window_type = mtnet::WINDOW_INVENTORY;
|
|
grid[i].cell = (uint16_t)cell;
|
|
}
|
|
return game->send_ds_refine(kMode[mode], grid);
|
|
}
|
|
|
|
Array M2Client::get_dragon_souls() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (int c = 0; c < mtnet::DRAGON_SOUL_MAX_NUM; ++c) {
|
|
const mtnet::Item &it = world->dragon_soul_slot(c);
|
|
if (it.vnum == 0) {
|
|
continue;
|
|
}
|
|
Dictionary d;
|
|
d["cell"] = c;
|
|
d["vnum"] = (int)it.vnum;
|
|
d["count"] = (int)it.count;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// --- P9 world systems ---
|
|
|
|
int M2Client::get_channel() const {
|
|
const auto *world = active_world();
|
|
return world ? world->channel() : 0;
|
|
}
|
|
int64_t M2Client::get_server_time() const {
|
|
const auto *world = active_world();
|
|
return world ? world->server_time() : 0;
|
|
}
|
|
Array M2Client::get_npc_marks() const {
|
|
Array out;
|
|
const auto *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (const auto &m : world->npc_marks()) {
|
|
Dictionary d;
|
|
d["type"] = (int)m.type;
|
|
d["vnum"] = (int)m.vnum;
|
|
d["name"] = String::utf8(m.name.c_str());
|
|
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(m.x, m.y, 0);
|
|
d["pos"] = Vector3(g.x, g.y, g.z);
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
Array M2Client::get_land_areas() const {
|
|
Array out;
|
|
const auto *world = active_world();
|
|
if (!world) return out;
|
|
for (const auto &a : world->land_areas()) {
|
|
Dictionary d;
|
|
d["id"] = (int)a.id;
|
|
d["guild_id"] = (int)a.guild_id;
|
|
d["x"] = a.x;
|
|
d["y"] = a.y;
|
|
d["width"] = a.width;
|
|
d["height"] = a.height;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
Array M2Client::get_observers() const {
|
|
Array out;
|
|
const auto *world = active_world();
|
|
if (!world) return out;
|
|
for (const auto &o : world->observers()) {
|
|
Dictionary d;
|
|
d["vid"] = (int)o.vid;
|
|
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(o.x, o.y, 0);
|
|
d["pos"] = Vector3(g.x, g.y, g.z);
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
bool M2Client::is_observer_mode() const {
|
|
const auto *world = active_world();
|
|
return world && world->observer_mode();
|
|
}
|
|
int M2Client::get_observer_count() const {
|
|
const auto *world = active_world();
|
|
return world ? world->observer_count() : 0;
|
|
}
|
|
bool M2Client::has_mobile_flag() const {
|
|
const auto *world = active_world();
|
|
return world && world->mobile_flag();
|
|
}
|
|
bool M2Client::combo_skill_enabled() const {
|
|
const auto *world = active_world();
|
|
return world && world->combo_skill_flag();
|
|
}
|
|
Dictionary M2Client::get_stamina_state() const {
|
|
Dictionary d;
|
|
const auto *world = active_world();
|
|
if (!world) return d;
|
|
d["consuming"] = world->stamina_consuming();
|
|
d["per_sec"] = (int)world->stamina_per_sec();
|
|
d["current"] = (int)world->current_stamina();
|
|
return d;
|
|
}
|
|
Array M2Client::get_world_markers() const {
|
|
Array out;
|
|
const auto *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (const auto &m : world->markers()) {
|
|
Dictionary d;
|
|
d["id"] = (int)m.id;
|
|
d["name"] = String::utf8(m.name.c_str());
|
|
d["vid"] = (int)m.vid;
|
|
d["type"] = (int)m.type;
|
|
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(m.x, m.y, 0);
|
|
d["pos"] = Vector3(g.x, g.y, g.z);
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// --- world snapshot ---
|
|
|
|
static Dictionary entity_dict(const mtnet::Entity &e) {
|
|
Dictionary d;
|
|
d["vid"] = (int)e.vid;
|
|
d["race"] = (int)e.race;
|
|
d["ch_type"] = (int)e.ch_type; // 0 PC, 1 NPC, 2 MONSTER, ...
|
|
d["name"] = String::utf8(e.name.c_str());
|
|
d["shop_sign"] = String::utf8(e.shop_sign.c_str());
|
|
d["is_main"] = e.is_main;
|
|
d["func"] = (int)e.func;
|
|
d["position"] = (int)e.position;
|
|
d["walk_mode"] = (int)e.walk_mode;
|
|
d["moving_speed"] = (int)e.moving_speed;
|
|
d["attack_speed"] = (int)e.attack_speed;
|
|
d["fly_target_vid"] = (int)e.fly_target_vid;
|
|
d["fly_target_set"] = e.fly_target_set;
|
|
d["fly_target_cm"] = Vector3(e.fly_target_x, 0, e.fly_target_y);
|
|
d["moving"] = e.moving;
|
|
d["angle_deg"] = e.angle;
|
|
d["hp"] = e.hp;
|
|
d["max_hp"] = e.max_hp;
|
|
d["sp"] = e.sp;
|
|
d["max_sp"] = e.max_sp;
|
|
d["level"] = e.level;
|
|
d["dead"] = e.dead;
|
|
d["stunned"] = e.stunned;
|
|
d["knock_down"] = e.knock_down;
|
|
d["mount_vnum"] = (int)e.mount_vnum;
|
|
d["guild"] = e.guild;
|
|
d["alignment"] = (int)e.alignment;
|
|
d["pk_mode"] = (int)e.pk_mode;
|
|
d["empire"] = (int)e.empire;
|
|
d["affect_flags"] = (int64_t)e.affect_flags;
|
|
d["owner_vid"] = (int)e.owner_vid;
|
|
d["state_flags"] = (int)e.state_flags;
|
|
Array parts;
|
|
for (int i = 0; i < 4; ++i) {
|
|
parts.push_back((int)e.parts[i]);
|
|
}
|
|
d["parts"] = parts;
|
|
// server cm (Z-up) -> Godot metres (Y-up)
|
|
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(e.x, e.y, e.z);
|
|
d["pos"] = Vector3(g.x, g.y, g.z);
|
|
d["pos_cm"] = Vector3(e.x, e.y, e.z);
|
|
return d;
|
|
}
|
|
|
|
const mtnet::EntityStore *M2Client::active_world() const {
|
|
if (classic_sess) {
|
|
return &classic_sess->world();
|
|
}
|
|
return game ? &game->world() : nullptr;
|
|
}
|
|
|
|
Dictionary M2Client::get_entity(int vid) const {
|
|
const mtnet::EntityStore *w = active_world();
|
|
if (!w) {
|
|
return Dictionary();
|
|
}
|
|
const mtnet::Entity *e = w->get((uint32_t)vid);
|
|
return e ? entity_dict(*e) : Dictionary();
|
|
}
|
|
|
|
Array M2Client::get_entities() const {
|
|
Array out;
|
|
const mtnet::EntityStore *w = active_world();
|
|
if (!w) {
|
|
return out;
|
|
}
|
|
for (uint32_t vid : w->vids()) {
|
|
if (const mtnet::Entity *e = w->get(vid)) {
|
|
out.push_back(entity_dict(*e));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
int M2Client::get_main_vid() const {
|
|
const mtnet::EntityStore *w = active_world();
|
|
return w ? (int)w->main_vid() : 0;
|
|
}
|
|
|
|
int M2Client::get_main_pid() const {
|
|
return (int)selected_pid;
|
|
}
|
|
|
|
Dictionary M2Client::get_points() const {
|
|
Dictionary d;
|
|
const mtnet::EntityStore *w = active_world();
|
|
if (!w) {
|
|
return d;
|
|
}
|
|
const mtnet::PlayerPoints &p = w->points();
|
|
d["hp"] = p.hp();
|
|
d["max_hp"] = p.max_hp();
|
|
d["sp"] = p.sp();
|
|
d["max_sp"] = p.max_sp();
|
|
d["stamina"] = p.stamina();
|
|
d["max_stamina"] = p.max_stamina();
|
|
d["level"] = p.level();
|
|
// exp values overflow int32 at high level on live servers — surface unsigned.
|
|
d["exp"] = (int64_t)(uint32_t)p.exp();
|
|
d["next_exp"] = (int64_t)(uint32_t)p.next_exp();
|
|
d["gold"] = p.gold();
|
|
d["attack_speed"] = p.v[mtnet::POINT_ATT_SPEED];
|
|
d["bow_distance"] = p.v[mtnet::POINT_BOW_DISTANCE]; // __GetBowRange() 距离加成
|
|
// §3.10 加号按钮门控:三个技能点来源分开取(uicharacter.py skillPageStatDict)。
|
|
d["skill_active"] = p.v[mtnet::POINT_SKILL];
|
|
d["skill_support"] = p.v[mtnet::POINT_SUB_SKILL];
|
|
d["skill_horse"] = p.v[mtnet::POINT_HORSE_SKILL];
|
|
d["energy"] = p.energy();
|
|
d["energy_end_time"] = p.energy_end_time();
|
|
Array values;
|
|
for (int i = 0; i < mtnet::POINT_MAX_NUM; ++i) {
|
|
values.push_back(p.v[i]);
|
|
}
|
|
d["points"] = values;
|
|
return d;
|
|
}
|
|
|
|
Dictionary M2Client::get_target() const {
|
|
Dictionary d;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return d;
|
|
}
|
|
d["vid"] = (int)world->target_vid();
|
|
d["hp_percent"] = (int)world->target_hp_pct();
|
|
return d;
|
|
}
|
|
|
|
static Dictionary affect_dict(const mtnet::Affect &a) {
|
|
Dictionary d;
|
|
d["type"] = (int)a.type;
|
|
d["point_idx"] = (int)a.point_idx;
|
|
d["value"] = a.value;
|
|
d["flag"] = (int)a.flag;
|
|
d["duration"] = a.duration;
|
|
return d;
|
|
}
|
|
|
|
Array M2Client::get_affects() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (const auto &a : world->affects()) {
|
|
out.push_back(affect_dict(a));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// --- items ---
|
|
|
|
bool M2Client::move_item(int fw, int fc, int tw, int tc, int count) {
|
|
if (!is_in_game() || !valid_item_pos(fw, fc) || !valid_item_pos(tw, tc) ||
|
|
!mtnet::bounds::u8(count)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_item_move((uint8_t)fw, (uint16_t)fc, (uint8_t)tw, (uint16_t)tc,
|
|
(uint8_t)count);
|
|
}
|
|
return game && game->send_item_move((uint8_t)fw, (uint16_t)fc, (uint8_t)tw, (uint16_t)tc,
|
|
(uint8_t)count);
|
|
}
|
|
bool M2Client::use_item(int window, int cell) {
|
|
if (!is_in_game() || !valid_item_pos(window, cell)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_item_use((uint8_t)window, (uint16_t)cell);
|
|
}
|
|
return game && game->send_item_use((uint8_t)window, (uint16_t)cell);
|
|
}
|
|
bool M2Client::drop_item(int window, int cell, int gold) {
|
|
if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold, 0);
|
|
}
|
|
return game && game->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold);
|
|
}
|
|
bool M2Client::drop_item_count(int window, int cell, int gold, int count) {
|
|
if (!is_in_game() || !valid_item_pos(window, cell) || !mtnet::bounds::u32(gold) ||
|
|
count < 1 || !mtnet::bounds::u8(count)) return false;
|
|
if (classic_sess) {
|
|
return classic_sess->send_item_drop((uint8_t)window, (uint16_t)cell, (uint32_t)gold,
|
|
(uint8_t)count);
|
|
}
|
|
return game && game->send_item_drop2((uint8_t)window, (uint16_t)cell, (uint32_t)gold,
|
|
(uint8_t)count);
|
|
}
|
|
bool M2Client::use_item_to_item(int sw, int sc, int tw, int tc) {
|
|
if (!is_in_game() || !valid_item_pos(sw, sc) || !valid_item_pos(tw, tc)) return false;
|
|
if (classic_sess) {
|
|
return classic_sess->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc);
|
|
}
|
|
if (!game) return false;
|
|
return game->send_item_use_to_item((uint8_t)sw, (uint16_t)sc, (uint8_t)tw, (uint16_t)tc);
|
|
}
|
|
bool M2Client::give_item(int target_vid, int window, int cell, int count) {
|
|
if (!is_in_game() || target_vid <= 0 || !mtnet::bounds::u32(target_vid) ||
|
|
!valid_item_pos(window, cell) || count < 1 || !mtnet::bounds::u8(count)) return false;
|
|
if (classic_sess) {
|
|
return classic_sess->send_item_give((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell,
|
|
(uint8_t)count);
|
|
}
|
|
if (!game) return false;
|
|
return game->send_give_item((uint32_t)target_vid, (uint8_t)window, (uint16_t)cell, (uint8_t)count);
|
|
}
|
|
bool M2Client::pickup_item(int ground_vid) {
|
|
if (!is_in_game() || ground_vid <= 0 || !mtnet::bounds::u32(ground_vid)) {
|
|
return false;
|
|
}
|
|
if (classic_sess) {
|
|
return classic_sess->send_item_pickup((uint32_t)ground_vid);
|
|
}
|
|
return game && game->send_item_pickup((uint32_t)ground_vid);
|
|
}
|
|
|
|
static Dictionary item_dict(const mtnet::Item &it) {
|
|
Dictionary d;
|
|
d["vnum"] = (int)it.vnum;
|
|
d["count"] = (int)it.count;
|
|
d["flags"] = (int)it.flags;
|
|
d["anti_flags"] = (int)it.anti_flags;
|
|
Array sockets;
|
|
for (int i = 0; i < 3; ++i) {
|
|
sockets.push_back(it.sockets[i]);
|
|
}
|
|
d["sockets"] = sockets;
|
|
Array attrs;
|
|
for (int i = 0; i < 7; ++i) {
|
|
if (it.attrs[i].type == 0) {
|
|
continue;
|
|
}
|
|
Dictionary a;
|
|
a["type"] = (int)it.attrs[i].type;
|
|
a["value"] = (int)it.attrs[i].value;
|
|
attrs.push_back(a);
|
|
}
|
|
d["attrs"] = attrs;
|
|
return d;
|
|
}
|
|
|
|
Array M2Client::get_inventory() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (int c = 0; c < mtnet::INVENTORY_MAX_NUM; ++c) {
|
|
const mtnet::Item &it = world->inv_slot(c);
|
|
if (it.empty()) {
|
|
continue;
|
|
}
|
|
Dictionary d = item_dict(it);
|
|
d["cell"] = c;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Array M2Client::get_equipment() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (int w = 0; w < mtnet::WEAR_MAX_NUM; ++w) {
|
|
Dictionary d = item_dict(world->equip_slot(w));
|
|
d["wear"] = w;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Array M2Client::get_belt_inventory() const {
|
|
Array out;
|
|
const auto *world = active_world();
|
|
if (!world) return out;
|
|
for (int c = 0; c < mtnet::BELT_INVENTORY_MAX_NUM; ++c) {
|
|
const mtnet::Item &it = world->belt_slot(c);
|
|
if (it.empty()) continue;
|
|
Dictionary d = item_dict(it);
|
|
d["cell"] = c;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Array M2Client::get_view_equipment(int vid) const {
|
|
Array out;
|
|
const auto *world = active_world();
|
|
if (!world || vid <= 0) return out;
|
|
const mtnet::ViewedEquipment *view = world->viewed_equipment((uint32_t)vid);
|
|
if (!view) return out;
|
|
for (int wear = 0; wear < mtnet::VIEW_EQUIP_WEAR_MAX_NUM; ++wear) {
|
|
Dictionary d = item_dict(view->items[wear]);
|
|
d["wear"] = wear;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Dictionary M2Client::get_item(int window, int cell) const {
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return Dictionary();
|
|
}
|
|
return item_dict(world->item_slot((uint8_t)window, cell));
|
|
}
|
|
|
|
Array M2Client::get_ground_items() const {
|
|
Array out;
|
|
const mtnet::EntityStore *world = active_world();
|
|
if (!world) {
|
|
return out;
|
|
}
|
|
for (uint32_t vid : world->ground_vids()) {
|
|
const mtnet::GroundItem *g = world->ground(vid);
|
|
if (!g) {
|
|
continue;
|
|
}
|
|
Dictionary d;
|
|
d["vid"] = (int)g->vid;
|
|
d["vnum"] = (int)g->vnum;
|
|
d["owner"] = String::utf8(g->owner.c_str());
|
|
fmt::m2coord::Vec3 gp = fmt::m2coord::position_to_godot(g->x, g->y, g->z);
|
|
d["pos"] = Vector3(gp.x, gp.y, gp.z);
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Array M2Client::get_pvp_relations() const {
|
|
Array out;
|
|
const auto *world = active_world();
|
|
if (!world) return out;
|
|
for (const auto &p : world->pvp_relations()) {
|
|
Dictionary d;
|
|
d["src_vid"] = (int)p.src_vid;
|
|
d["dst_vid"] = (int)p.dst_vid;
|
|
d["mode"] = (int)p.mode;
|
|
out.push_back(d);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Dictionary M2Client::get_duel() const {
|
|
Dictionary out;
|
|
const auto *world = active_world();
|
|
if (!world) return out;
|
|
Array opponents;
|
|
for (uint32_t vid : world->duel_opponents()) {
|
|
opponents.push_back((int)vid);
|
|
}
|
|
out["active"] = !world->duel_opponents().empty();
|
|
out["cannot_attack"] = world->duel_cannot_attack();
|
|
out["opponents"] = opponents;
|
|
return out;
|
|
}
|
|
|
|
void M2Client::_process(double) {
|
|
if (Engine::get_singleton()->is_editor_hint()) {
|
|
return;
|
|
}
|
|
if (suspended) {
|
|
return; // backgrounded — don't touch the socket
|
|
}
|
|
if (classic_sess) {
|
|
pump_classic();
|
|
}
|
|
if (auth) {
|
|
pump_auth();
|
|
}
|
|
if (game) {
|
|
pump_game();
|
|
}
|
|
if (mark) {
|
|
pump_mark();
|
|
}
|
|
if (classic_mark) {
|
|
pump_classic_mark();
|
|
}
|
|
}
|
|
|
|
// §9.3: M2Client no longer listens for NOTIFICATION_APPLICATION_PAUSED/RESUMED.
|
|
// AppFlow owns the single AppLifecycle coordinator (equivalent to
|
|
// CPythonApplication); it drives suspend()/resume() below. Keeping a second
|
|
// listener here meant two entry points for the same lifecycle edge.
|
|
|
|
void M2Client::suspend() {
|
|
if (suspended) {
|
|
return;
|
|
}
|
|
suspended = true;
|
|
emit_signal("suspended");
|
|
}
|
|
|
|
void M2Client::resume() {
|
|
if (!suspended) {
|
|
return;
|
|
}
|
|
suspended = false;
|
|
emit_signal("resumed");
|
|
// The next _process() pumps the socket; if the OS dropped the TCP
|
|
// connection while backgrounded, pump_game()/pump_auth() will see it go
|
|
// Offline and emit `disconnected`. GDScript can then call reconnect().
|
|
}
|
|
|
|
bool M2Client::reconnect() {
|
|
if (!have_cfg) {
|
|
return false;
|
|
}
|
|
// Fast path: still holding a valid login_key -> skip the auth server, go
|
|
// straight back to the (last) game server. Falls back to full re-login if
|
|
// the game connect fails.
|
|
if (last_login_key != 0 && !cfg_game_host.is_empty()) {
|
|
warp_to_game_server(cfg_game_host, cfg_game_port);
|
|
if (stage == Stage::GameConnect) {
|
|
return true;
|
|
}
|
|
}
|
|
connect_to_server(cfg_auth_host, cfg_auth_port, cfg_game_host, cfg_game_port, cfg_id, cfg_pw);
|
|
return stage != Stage::Failed && stage != Stage::Idle;
|
|
}
|
|
|
|
// Tear down the current game connection and reconnect to `host:port` reusing the
|
|
// stored login_key (cross-server GC_WARP, or a fast reconnect).
|
|
void M2Client::warp_to_game_server(const String &host, int port) {
|
|
if (classic_sess) {
|
|
classic_sess->disconnect();
|
|
classic_sess.reset();
|
|
if (mark) {
|
|
mark->disconnect();
|
|
mark.reset();
|
|
}
|
|
if (classic_mark) {
|
|
classic_mark->disconnect();
|
|
classic_mark.reset();
|
|
}
|
|
game_host = host;
|
|
game_port = port;
|
|
classic_list_emitted = false;
|
|
classic_last_stage = -1;
|
|
classic_last_empire = -1;
|
|
shop_open_seen = false;
|
|
mall_open_seen = false;
|
|
dead_seen.clear();
|
|
classic_sess = std::make_unique<mtnet::classic::ClassicSession>();
|
|
classic_sess->set_wire_trace(OS::get_singleton()->get_environment("MT_NET_TRACE") == "1");
|
|
bool connected = false;
|
|
if (last_login_key != 0) {
|
|
connected = classic_sess->connect_with_login_key(std::string(host.utf8().get_data()),
|
|
(uint16_t)port, std::string(cfg_id.utf8().get_data()), last_login_key);
|
|
} else {
|
|
connected = classic_sess->connect(std::string(cfg_auth_host.utf8().get_data()),
|
|
(uint16_t)cfg_auth_port, std::string(host.utf8().get_data()), (uint16_t)port,
|
|
std::string(cfg_id.utf8().get_data()), std::string(cfg_pw.utf8().get_data()));
|
|
}
|
|
if (!connected) {
|
|
emit_signal("login_failed", String(classic_sess->last_error().c_str()));
|
|
set_stage(Stage::Failed);
|
|
classic_sess.reset();
|
|
return;
|
|
}
|
|
last_game_phase = -1;
|
|
set_stage(Stage::GameConnect);
|
|
return;
|
|
}
|
|
if (last_login_key == 0) {
|
|
return;
|
|
}
|
|
if (game) {
|
|
game->disconnect();
|
|
game.reset();
|
|
}
|
|
auth.reset();
|
|
game_host = host;
|
|
game_port = port;
|
|
char_list_emitted = false;
|
|
shop_open_seen = false;
|
|
dead_seen.clear();
|
|
game = std::make_unique<mtnet::GameClient>(std::string(account_id.utf8().get_data()),
|
|
last_login_key);
|
|
game->set_auto_enter_game(false);
|
|
loading_since_ms = 0;
|
|
enter_game_sent = false;
|
|
if (!game->connect(std::string(host.utf8().get_data()), (uint16_t)port)) {
|
|
emit_signal("login_failed", String(game->last_error().c_str()));
|
|
set_stage(Stage::Failed);
|
|
game.reset();
|
|
return;
|
|
}
|
|
last_game_phase = -1;
|
|
last_game_state = last_game_empire = -1;
|
|
set_stage(Stage::GameConnect);
|
|
}
|
|
|
|
void M2Client::pump_classic() {
|
|
using St = mtnet::INetSession::Stage;
|
|
|
|
classic_sess->set_now((uint32_t)Time::get_singleton()->get_ticks_msec());
|
|
classic_sess->pump();
|
|
if (classic_sess->login_key() != 0) {
|
|
last_login_key = classic_sess->login_key();
|
|
}
|
|
const int empire = (int)classic_sess->parser().empire();
|
|
if (empire != classic_last_empire) {
|
|
classic_last_empire = empire;
|
|
emit_signal("empire_changed", empire);
|
|
}
|
|
|
|
// stage -> phase_changed / stage_changed / char_list / entered_game
|
|
int st = (int)classic_sess->stage();
|
|
if (st != classic_last_stage) {
|
|
classic_last_stage = st;
|
|
static const char *pn[] = {"close", "handshake", "login", "select",
|
|
"loading", "in_game", "failed"};
|
|
int idx = 0;
|
|
switch (classic_sess->stage()) {
|
|
case St::Offline: idx = 0; break;
|
|
case St::Connecting: idx = 1; break;
|
|
case St::LoggingIn: idx = 2; break;
|
|
case St::CharSelect: idx = 3; break;
|
|
case St::Loading: idx = 4; break;
|
|
case St::InGame: idx = 5; break;
|
|
case St::Failed: idx = 6; break;
|
|
}
|
|
emit_signal("phase_changed", String(pn[idx]));
|
|
if (classic_sess->stage() == St::Failed && stage != Stage::Failed) {
|
|
// ClientVS22 sends every already-established game connection loss back
|
|
// through SetLoginPhase(), regardless of whether SELECT was reached.
|
|
// Authentication failures happen before this flag is set and remain
|
|
// login_failed events.
|
|
if (classic_sess->was_online_lost()) {
|
|
emit_signal("disconnected", String(classic_sess->last_error().c_str()));
|
|
} else {
|
|
emit_signal("login_failed", String(classic_sess->last_error().c_str()));
|
|
}
|
|
set_stage(Stage::Failed);
|
|
} else if (classic_sess->stage() == St::LoggingIn && stage == Stage::GameConnect) {
|
|
set_stage(Stage::GameLogin);
|
|
}
|
|
}
|
|
|
|
if (!classic_list_emitted && classic_sess->parser().char_list_ready() &&
|
|
classic_sess->stage() == St::CharSelect) {
|
|
classic_list_emitted = true;
|
|
emit_signal("char_list", build_char_list());
|
|
}
|
|
for (const auto &ev : classic_sess->parser().drain_char_events()) {
|
|
using CE = mtnet::classic::ClassicParser::CharEvent;
|
|
switch (ev.kind) {
|
|
case CE::CreateOk:
|
|
emit_signal("char_list", build_char_list());
|
|
emit_signal("char_created", ev.slot);
|
|
break;
|
|
case CE::CreateFail:
|
|
emit_signal("char_create_failed", ev.fail_type);
|
|
break;
|
|
case CE::DeleteOk:
|
|
emit_signal("char_list", build_char_list());
|
|
emit_signal("char_deleted", ev.slot);
|
|
break;
|
|
case CE::DeleteFail:
|
|
emit_signal("char_delete_failed");
|
|
break;
|
|
}
|
|
}
|
|
for (const auto &ev : classic_sess->parser().drain_name_events()) {
|
|
emit_signal("char_list", build_char_list());
|
|
emit_signal("char_name_changed", (int64_t)ev.pid, String::utf8(ev.name.c_str()));
|
|
}
|
|
for (int i = 0, n = classic_sess->parser().drain_guild_make_requests(); i < n; ++i) {
|
|
emit_signal("guild_make_requested");
|
|
}
|
|
|
|
if (classic_sess->in_game() && stage != Stage::InGame) {
|
|
set_stage(Stage::InGame);
|
|
emit_signal("entered_game");
|
|
}
|
|
|
|
if (classic_sess->in_game()) {
|
|
mtnet::EntityStore &w = classic_sess->world();
|
|
// set_now()/tick() already run inside pump(); mirror the deltas the
|
|
// classic parser currently produces (grows with classic_parser).
|
|
for (const auto &c : w.drain_changes()) {
|
|
switch (c.kind) {
|
|
case mtnet::EntityStore::ChangeKind::Spawn:
|
|
if (const mtnet::Entity *e = w.get(c.vid)) {
|
|
emit_signal("entity_spawned", entity_dict(*e));
|
|
}
|
|
break;
|
|
case mtnet::EntityStore::ChangeKind::Despawn:
|
|
dead_seen.erase(c.vid);
|
|
emit_signal("entity_despawned", (int)c.vid);
|
|
break;
|
|
case mtnet::EntityStore::ChangeKind::Move:
|
|
emit_signal("entity_moved", (int)c.vid);
|
|
break;
|
|
case mtnet::EntityStore::ChangeKind::MainSet:
|
|
emit_signal("entity_main_set", (int)c.vid);
|
|
break;
|
|
case mtnet::EntityStore::ChangeKind::Info:
|
|
if (const mtnet::Entity *e = w.get(c.vid)) {
|
|
emit_signal("entity_info", (int)c.vid, entity_dict(*e));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
// §2.5: bare field updates (ownership / affect flags) with no Spawn/Move/
|
|
// Info change — re-mirror the node so presentation stays in sync.
|
|
for (uint32_t vid : w.drain_dirty()) {
|
|
if (const mtnet::Entity *e = w.get(vid)) {
|
|
emit_signal("entity_info", (int)vid, entity_dict(*e));
|
|
}
|
|
}
|
|
// §9.1: map background music.
|
|
if (w.take_bgm_dirty()) {
|
|
emit_signal("bgm_changed", String::utf8(w.bgm_name().c_str()), w.bgm_volume());
|
|
}
|
|
for (const auto &m : w.drain_chat()) {
|
|
if (m.type == mtnet::CHAT_TYPE_WHISPER) {
|
|
emit_signal("whisper_received", (int)m.sub, String::utf8(m.from.c_str()),
|
|
String::utf8(m.text.c_str()));
|
|
} else {
|
|
emit_signal("chat", (int)m.type, (int)m.vid, String::utf8(m.text.c_str()));
|
|
}
|
|
}
|
|
for (uint32_t vid : w.drain_vitals()) {
|
|
emit_signal("vitals_changed", (int)vid);
|
|
const mtnet::Entity *e = w.get(vid);
|
|
if (e && e->dead && !dead_seen.count(vid)) {
|
|
dead_seen.insert(vid);
|
|
emit_signal("entity_dead", (int)vid);
|
|
}
|
|
}
|
|
if (w.take_points_dirty()) {
|
|
emit_signal("points_changed", get_points());
|
|
}
|
|
for (const auto &ic : w.drain_inv()) {
|
|
emit_signal("inventory_changed", (int)ic.window, (int)ic.cell);
|
|
}
|
|
for (const auto &gc : w.drain_ground()) {
|
|
if (gc.added) {
|
|
const mtnet::GroundItem *g = w.ground(gc.vid);
|
|
if (g) {
|
|
Dictionary d;
|
|
d["vid"] = (int)g->vid;
|
|
d["vnum"] = (int)g->vnum;
|
|
d["owner"] = String::utf8(g->owner.c_str());
|
|
fmt::m2coord::Vec3 gp = fmt::m2coord::position_to_godot(g->x, g->y, g->z);
|
|
d["pos"] = Vector3(gp.x, gp.y, gp.z);
|
|
emit_signal("ground_item_added", d);
|
|
}
|
|
} else {
|
|
emit_signal("ground_item_removed", (int)gc.vid);
|
|
}
|
|
}
|
|
for (const auto &p : w.drain_pvp_changes()) {
|
|
emit_signal("pvp_changed", (int)p.src_vid, (int)p.dst_vid, (int)p.mode);
|
|
}
|
|
if (w.take_duel_started()) {
|
|
emit_signal("duel_changed", get_duel());
|
|
emit_signal("duel_started");
|
|
}
|
|
for (const auto &mo : w.drain_motions()) {
|
|
emit_signal("motion", (int)mo.vid, (int)mo.victim_vid, (int)mo.motion);
|
|
}
|
|
for (const auto &dm : w.drain_dig_motions()) {
|
|
emit_signal("dig_motion", (int)dm.vid, (int)dm.target_vid, (int)dm.count);
|
|
}
|
|
for (const auto &fe : w.drain_fishing_events()) {
|
|
emit_signal("fishing_event", (int)fe.subheader, (int)fe.info, (int)fe.dir);
|
|
}
|
|
for (const auto &de : w.drain_dungeon_events()) {
|
|
emit_signal("dungeon_event", (int)de.subheader, (int)de.x, (int)de.y,
|
|
de.has_destination);
|
|
}
|
|
for (const auto &oe : w.drain_observer_events()) {
|
|
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(oe.x, oe.y, 0);
|
|
emit_signal("observer_event", (int)oe.kind, (int)oe.vid, Vector2(g.x, g.z));
|
|
}
|
|
for (const auto &ec : w.drain_effect_cues()) {
|
|
emit_signal("effect_cue", (int)ec.vid, String::utf8(ec.file.c_str()),
|
|
(int)ec.special);
|
|
}
|
|
for (const auto &fc : w.drain_fly_cues()) {
|
|
emit_signal("fly_cue", (int)fc.type, (int)fc.start_vid, (int)fc.end_vid);
|
|
}
|
|
for (const auto &tc : w.drain_fly_target_cues()) {
|
|
emit_signal("fly_targeting", (int)tc.shooter_vid, (int)tc.target_vid,
|
|
Vector2((float)tc.x, (float)tc.y), tc.append);
|
|
}
|
|
for (const auto &d : w.drain_damage()) {
|
|
emit_signal("damage", (int)d.vid, (int)d.amount, (int)d.flag);
|
|
}
|
|
for (uint32_t vid : w.drain_mount_changes()) {
|
|
emit_signal("mount_changed", (int)vid);
|
|
}
|
|
if (w.take_target_dirty()) {
|
|
emit_signal("target_info", (int)w.target_vid(), (int)w.target_hp_pct());
|
|
}
|
|
if (w.skills_dirty()) {
|
|
emit_signal("skills_changed");
|
|
}
|
|
if (w.skill_group_dirty()) {
|
|
emit_signal("skill_group_changed", (int)w.skill_group());
|
|
}
|
|
for (int sk : w.drain_cooldown_ends()) {
|
|
emit_signal("skill_cooldown_end", sk);
|
|
}
|
|
if (w.quickslots_dirty()) {
|
|
emit_signal("quickslots_changed");
|
|
}
|
|
for (const auto &ac : w.drain_affects()) {
|
|
if (ac.added) {
|
|
for (const auto &a : w.affects()) {
|
|
if (a.type == ac.type) {
|
|
emit_signal("affect_added", affect_dict(a));
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
emit_signal("affect_removed", (int)ac.type);
|
|
}
|
|
}
|
|
for (const auto &ie : w.drain_item_events()) {
|
|
if (ie.kind == mtnet::ItemEvent::Get) {
|
|
emit_signal("item_picked_up", (int)ie.vnum, (int)ie.count,
|
|
String::utf8(ie.from.c_str()));
|
|
} else {
|
|
emit_signal("item_used", (int)ie.vnum);
|
|
}
|
|
}
|
|
for (uint32_t leader : w.drain_party_invites()) {
|
|
emit_signal("party_invite_ask", (int)leader);
|
|
}
|
|
for (const auto &ev : w.drain_server_commands()) {
|
|
switch (ev.kind) {
|
|
case mtnet::ServerCommandEvent::PartyRequestDenied:
|
|
emit_signal("party_request_denied");
|
|
break;
|
|
case mtnet::ServerCommandEvent::LoverLogin:
|
|
case mtnet::ServerCommandEvent::LoverLogout:
|
|
case mtnet::ServerCommandEvent::LoverNear:
|
|
case mtnet::ServerCommandEvent::LoverFar:
|
|
case mtnet::ServerCommandEvent::LoverDivorce:
|
|
// LoverInfo 由 mutator 同步更新;lover_changed 信号在
|
|
// take_lover_dirty() 处统一发出,这里无需单独 emit。
|
|
break;
|
|
case mtnet::ServerCommandEvent::SafeboxPasswordRequired:
|
|
emit_signal("safebox_password_required");
|
|
break;
|
|
case mtnet::ServerCommandEvent::SafeboxWrongPassword:
|
|
emit_signal("safebox_wrong_password");
|
|
break;
|
|
case mtnet::ServerCommandEvent::MallPasswordRequired:
|
|
emit_signal("mall_password_required");
|
|
break;
|
|
case mtnet::ServerCommandEvent::RefineSucceeded:
|
|
emit_signal("refine_result", true);
|
|
break;
|
|
case mtnet::ServerCommandEvent::RefineFailed:
|
|
emit_signal("refine_result", false);
|
|
break;
|
|
case mtnet::ServerCommandEvent::PrivateShopOpenRequested:
|
|
emit_signal("private_shop_open_requested");
|
|
break;
|
|
case mtnet::ServerCommandEvent::MyShopPrice:
|
|
emit_signal("my_shop_price_list", (int)ev.value, (int)ev.value2);
|
|
break;
|
|
case mtnet::ServerCommandEvent::BlockModeChanged:
|
|
emit_signal("block_mode_changed", (int)ev.value);
|
|
break;
|
|
case mtnet::ServerCommandEvent::ObserverModeChanged:
|
|
emit_signal("observer_mode_changed", ev.value != 0);
|
|
break;
|
|
case mtnet::ServerCommandEvent::ObserverCountChanged:
|
|
emit_signal("observer_count_changed", (int)ev.value);
|
|
break;
|
|
case mtnet::ServerCommandEvent::StoneDetected:
|
|
emit_signal("stone_detected", (int)ev.value, (int)ev.value2, ev.value3);
|
|
break;
|
|
case mtnet::ServerCommandEvent::StaminaStarted:
|
|
emit_signal("stamina_changed", (int)ev.value2, (int)ev.value, true);
|
|
break;
|
|
case mtnet::ServerCommandEvent::StaminaStopped:
|
|
emit_signal("stamina_changed", (int)ev.value2, 0, false);
|
|
break;
|
|
case mtnet::ServerCommandEvent::MobileFlagChanged:
|
|
emit_signal("mobile_flag_changed", ev.value != 0);
|
|
break;
|
|
case mtnet::ServerCommandEvent::MobileAuthRequired:
|
|
emit_signal("mobile_auth_required");
|
|
break;
|
|
case mtnet::ServerCommandEvent::ComboChanged:
|
|
emit_signal("combo_changed", ev.value != 0);
|
|
break;
|
|
case mtnet::ServerCommandEvent::GiftAvailable:
|
|
emit_signal("gift_available");
|
|
break;
|
|
}
|
|
}
|
|
if (w.party_dirty()) {
|
|
emit_signal("party_changed");
|
|
}
|
|
bool shop_was_open = shop_open_seen;
|
|
if (w.shop_dirty()) {
|
|
shop_open_seen = w.shop_open();
|
|
if (shop_open_seen && !shop_was_open) {
|
|
emit_signal("shop_opened", (int)w.shop_vid());
|
|
} else if (!shop_open_seen && shop_was_open) {
|
|
emit_signal("shop_closed");
|
|
}
|
|
}
|
|
for (const auto &err : w.drain_shop_errors()) {
|
|
emit_signal("shop_error", String::utf8(err.c_str()));
|
|
}
|
|
for (const auto &sc : w.drain_scripts()) {
|
|
emit_signal("script_dialog", (int)sc.skin, String::utf8(sc.text.c_str()));
|
|
}
|
|
for (const auto &cc : w.drain_confirms()) {
|
|
emit_signal("quest_confirm_ask", String::utf8(cc.msg.c_str()), (int)cc.timeout,
|
|
(int)cc.request_pid);
|
|
}
|
|
for (uint16_t qi : w.drain_quest_changes()) {
|
|
emit_signal("quest_info", (int)qi);
|
|
}
|
|
if (w.friends_dirty()) {
|
|
emit_signal("friends_changed");
|
|
}
|
|
for (const auto &name : w.drain_friend_invites()) {
|
|
emit_signal("friend_invite_ask", String::utf8(name.c_str()));
|
|
}
|
|
if (w.take_lover_dirty()) {
|
|
emit_signal("lover_changed", get_lover());
|
|
}
|
|
if (w.exchange_dirty()) {
|
|
emit_signal("exchange_changed");
|
|
}
|
|
if (w.safebox_dirty()) {
|
|
emit_signal("safebox_changed");
|
|
}
|
|
if (w.mall_dirty()) {
|
|
bool mall_now = w.mall_open();
|
|
if (mall_now && !mall_open_seen) {
|
|
emit_signal("mall_opened", w.mall_size());
|
|
}
|
|
mall_open_seen = mall_now;
|
|
emit_signal("mall_changed");
|
|
}
|
|
for (const auto &ev : w.drain_cube_events()) {
|
|
using CE = mtnet::EntityStore::CubeEvent;
|
|
switch (ev) {
|
|
case CE::Opened:
|
|
emit_signal("cube_opened", (int)w.cube().npc_vnum);
|
|
break;
|
|
case CE::Closed:
|
|
emit_signal("cube_closed");
|
|
break;
|
|
case CE::InfoChanged:
|
|
emit_signal("cube_changed");
|
|
break;
|
|
case CE::Success: {
|
|
auto result = w.cube_last_success();
|
|
emit_signal("cube_result", (int)result.vnum, result.count, true);
|
|
break;
|
|
}
|
|
case CE::Fail:
|
|
emit_signal("cube_result", 0, 0, false);
|
|
break;
|
|
}
|
|
}
|
|
if (w.guild_dirty()) {
|
|
emit_signal("guild_changed");
|
|
}
|
|
if (w.guild_comments_dirty()) {
|
|
emit_signal("guild_comments_changed");
|
|
}
|
|
for (const auto &invite : w.drain_guild_invites()) {
|
|
emit_signal("guild_invite_ask", (int64_t)invite.guild_id,
|
|
String::utf8(invite.guild_name.c_str()));
|
|
}
|
|
if (w.guild_skill_dirty()) {
|
|
emit_signal("guild_skill_changed");
|
|
}
|
|
if (w.guild_war_dirty()) {
|
|
emit_signal("guild_war_changed");
|
|
}
|
|
for (const auto &ev : w.drain_guild_war_events()) {
|
|
emit_signal("guild_war_event", (int)ev.state, (int64_t)ev.opp_guild_id,
|
|
String::utf8(w.guild_name(ev.opp_guild_id).c_str()));
|
|
}
|
|
for (const auto &score : w.drain_guild_war_scores()) {
|
|
emit_signal("guild_war_point", (int64_t)score.gain_guild_id,
|
|
(int64_t)score.opp_guild_id, score.point);
|
|
}
|
|
for (const auto &rc : w.drain_refine_cues()) {
|
|
Dictionary d;
|
|
d["type"] = (int)rc.type;
|
|
d["pos"] = (int)rc.pos;
|
|
d["src_vnum"] = (int)rc.src_vnum;
|
|
d["result_vnum"] = (int)rc.result_vnum;
|
|
d["cost"] = rc.cost;
|
|
d["prob"] = rc.prob;
|
|
Array mats;
|
|
for (int i = 0; i < rc.material_count && i < 5; ++i) {
|
|
if (rc.materials[i].vnum == 0) {
|
|
continue;
|
|
}
|
|
Dictionary mat;
|
|
mat["vnum"] = (int)rc.materials[i].vnum;
|
|
mat["count"] = rc.materials[i].count;
|
|
mats.push_back(mat);
|
|
}
|
|
d["materials"] = mats;
|
|
emit_signal("refine_ask", d);
|
|
}
|
|
for (const auto &dc : w.drain_ds_cues()) {
|
|
if (dc.sub_type == mtnet::DS_SUB_OPEN) {
|
|
emit_signal("ds_window_open");
|
|
} else if (dc.sub_type == mtnet::DS_SUB_REFINE_SUCCEED) {
|
|
emit_signal("ds_refine_result", true, (int)dc.sub_type, (int)dc.cell);
|
|
} else if (dc.sub_type >= mtnet::DS_SUB_REFINE_FAIL &&
|
|
dc.sub_type <= mtnet::DS_SUB_REFINE_FAIL_TOO_MUCH_MATERIAL) {
|
|
emit_signal("ds_refine_result", false, (int)dc.sub_type, (int)dc.cell);
|
|
}
|
|
}
|
|
for (const auto &warp : w.drain_warps()) {
|
|
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(warp.x, warp.y, 0);
|
|
// ClientVS22's RecvWarpPacket() always calls Connect(lAddr, wPort),
|
|
// even for an address that points back to the current channel. Clear
|
|
// the old loading/world state before the socket is replaced.
|
|
w.reset_for_map_change();
|
|
emit_signal("world_reset");
|
|
emit_signal("warp", Vector3(g.x, g.y, g.z), false);
|
|
|
|
uint32_t addr = static_cast<uint32_t>(warp.addr);
|
|
char host[16];
|
|
std::snprintf(host, sizeof(host), "%u.%u.%u.%u", addr & 0xFF,
|
|
(addr >> 8) & 0xFF, (addr >> 16) & 0xFF, (addr >> 24) & 0xFF);
|
|
if (!classic_sess->connect_warp(std::string(host), warp.port)) {
|
|
// connect_warp() marks the session Failed. Let the normal stage
|
|
// bridge surface it as a post-login disconnect on the next pump.
|
|
return;
|
|
}
|
|
classic_last_stage = -1;
|
|
shop_open_seen = false;
|
|
mall_open_seen = false;
|
|
dead_seen.clear();
|
|
set_stage(Stage::GameConnect);
|
|
return;
|
|
}
|
|
if (w.take_time_dirty()) {
|
|
emit_signal("time_changed", (int64_t)w.server_time());
|
|
}
|
|
if (w.take_channel_dirty()) {
|
|
emit_signal("channel_changed", w.channel());
|
|
}
|
|
if (w.take_npc_marks_dirty()) {
|
|
emit_signal("npc_marks_changed");
|
|
}
|
|
if (w.take_land_dirty()) {
|
|
emit_signal("land_areas_changed");
|
|
}
|
|
if (w.take_markers_dirty()) {
|
|
emit_signal("world_markers_changed");
|
|
}
|
|
}
|
|
}
|
|
|
|
void M2Client::pump_auth() {
|
|
auth->process();
|
|
|
|
if ((int)auth->state() != last_auth_state) {
|
|
last_auth_state = (int)auth->state();
|
|
if (auth->state() == mtnet::NetStream::State::Online && stage == Stage::AuthConnect) {
|
|
set_stage(Stage::AuthWait);
|
|
}
|
|
}
|
|
|
|
if (!auth->done()) {
|
|
return;
|
|
}
|
|
|
|
if (auth->success()) {
|
|
uint32_t key = auth->login_key();
|
|
last_login_key = key;
|
|
emit_signal("auth_ok", (int64_t)key);
|
|
auth->disconnect();
|
|
auth.reset();
|
|
|
|
game = std::make_unique<mtnet::GameClient>(std::string(account_id.utf8().get_data()), key);
|
|
game->set_auto_enter_game(false);
|
|
loading_since_ms = 0;
|
|
enter_game_sent = false;
|
|
if (!game->connect(std::string(game_host.utf8().get_data()), (uint16_t)game_port)) {
|
|
emit_signal("login_failed", String(game->last_error().c_str()));
|
|
set_stage(Stage::Failed);
|
|
game.reset();
|
|
return;
|
|
}
|
|
set_stage(Stage::GameConnect);
|
|
} else {
|
|
emit_signal("login_failed",
|
|
String(auth->fail_reason().empty() ? auth->last_error().c_str()
|
|
: auth->fail_reason().c_str()));
|
|
set_stage(Stage::Failed);
|
|
auth.reset();
|
|
}
|
|
}
|
|
|
|
void M2Client::pump_game() {
|
|
game->process();
|
|
if (game->empire_seen() && (int)game->empire() != last_game_empire) {
|
|
last_game_empire = game->empire();
|
|
emit_signal("empire_changed", (int)game->empire());
|
|
}
|
|
|
|
if ((int)game->phase() != last_game_phase) {
|
|
last_game_phase = (int)game->phase();
|
|
emit_signal("phase_changed", String(phase_name(last_game_phase)));
|
|
if (game->phase() != mtnet::PHASE_LOADING) {
|
|
loading_since_ms = 0;
|
|
}
|
|
if (game->phase() == mtnet::PHASE_LOGIN && stage == Stage::GameConnect) {
|
|
set_stage(Stage::GameLogin);
|
|
}
|
|
}
|
|
|
|
if (game->char_list_ready() && !char_list_emitted) {
|
|
char_list_emitted = true;
|
|
emit_signal("char_list", build_char_list());
|
|
}
|
|
|
|
// create / delete results (server patches the slot list, doesn't re-send it)
|
|
for (const auto &ev : game->drain_char_events()) {
|
|
switch (ev.kind) {
|
|
case mtnet::GameClient::CharEvent::CreateOk:
|
|
emit_signal("char_list", build_char_list());
|
|
emit_signal("char_created", ev.slot);
|
|
break;
|
|
case mtnet::GameClient::CharEvent::CreateFail:
|
|
emit_signal("char_create_failed", ev.fail_type);
|
|
break;
|
|
case mtnet::GameClient::CharEvent::DeleteOk:
|
|
emit_signal("char_list", build_char_list());
|
|
emit_signal("char_deleted", ev.slot);
|
|
break;
|
|
case mtnet::GameClient::CharEvent::DeleteFail:
|
|
emit_signal("char_delete_failed");
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (const auto &ev : game->drain_name_events()) {
|
|
emit_signal("char_list", build_char_list());
|
|
emit_signal("char_name_changed", (int64_t)ev.pid, String::utf8(ev.name.c_str()));
|
|
}
|
|
for (int i = 0, n = game->drain_guild_make_requests(); i < n; ++i) {
|
|
emit_signal("guild_make_requested");
|
|
}
|
|
|
|
if (game->failed()) {
|
|
emit_signal("login_failed", String(game->fail_reason().c_str()));
|
|
set_stage(Stage::Failed);
|
|
game->disconnect();
|
|
game.reset();
|
|
return;
|
|
}
|
|
|
|
if (game->phase() == mtnet::PHASE_LOADING && !enter_game_sent) {
|
|
uint64_t now = godot::Time::get_singleton()->get_ticks_msec();
|
|
if (loading_since_ms == 0) {
|
|
loading_since_ms = now;
|
|
} else if (now - loading_since_ms > 1500) {
|
|
game->send_enter_game();
|
|
enter_game_sent = true;
|
|
}
|
|
}
|
|
|
|
if (game->in_game() && stage != Stage::InGame) {
|
|
set_stage(Stage::InGame);
|
|
emit_signal("entered_game");
|
|
}
|
|
|
|
// Advance the networked-world model and surface its deltas as signals.
|
|
if (game->in_game()) {
|
|
mtnet::EntityStore &w = game->world();
|
|
w.set_now((uint32_t)Time::get_singleton()->get_ticks_msec());
|
|
w.tick();
|
|
for (const auto &c : w.drain_changes()) {
|
|
switch (c.kind) {
|
|
case mtnet::EntityStore::ChangeKind::Spawn:
|
|
if (const mtnet::Entity *e = w.get(c.vid)) {
|
|
emit_signal("entity_spawned", entity_dict(*e));
|
|
}
|
|
break;
|
|
case mtnet::EntityStore::ChangeKind::Despawn:
|
|
dead_seen.erase(c.vid);
|
|
emit_signal("entity_despawned", (int)c.vid);
|
|
break;
|
|
case mtnet::EntityStore::ChangeKind::Move:
|
|
emit_signal("entity_moved", (int)c.vid);
|
|
break;
|
|
case mtnet::EntityStore::ChangeKind::MainSet:
|
|
emit_signal("entity_main_set", (int)c.vid);
|
|
break;
|
|
case mtnet::EntityStore::ChangeKind::Info:
|
|
if (const mtnet::Entity *e = w.get(c.vid)) {
|
|
emit_signal("entity_info", (int)c.vid, entity_dict(*e));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
// §2.5: bare field updates (ownership / affect flags) with no Spawn/Move/
|
|
// Info change — re-mirror the node so presentation stays in sync.
|
|
for (uint32_t vid : w.drain_dirty()) {
|
|
if (const mtnet::Entity *e = w.get(vid)) {
|
|
emit_signal("entity_info", (int)vid, entity_dict(*e));
|
|
}
|
|
}
|
|
// §9.1: map background music.
|
|
if (w.take_bgm_dirty()) {
|
|
emit_signal("bgm_changed", String::utf8(w.bgm_name().c_str()), w.bgm_volume());
|
|
}
|
|
for (const auto &m : w.drain_chat()) {
|
|
if (m.type == mtnet::CHAT_TYPE_WHISPER) {
|
|
emit_signal("whisper_received", (int)m.sub, String::utf8(m.from.c_str()),
|
|
String::utf8(m.text.c_str()));
|
|
} else {
|
|
emit_signal("chat", (int)m.type, (int)m.vid, String::utf8(m.text.c_str()));
|
|
}
|
|
}
|
|
for (uint32_t vid : w.drain_vitals()) {
|
|
emit_signal("vitals_changed", (int)vid);
|
|
const mtnet::Entity *e = w.get(vid);
|
|
if (e && e->dead && !dead_seen.count(vid)) {
|
|
dead_seen.insert(vid);
|
|
emit_signal("entity_dead", (int)vid);
|
|
}
|
|
}
|
|
for (const auto &d : w.drain_damage()) {
|
|
emit_signal("damage", (int)d.vid, (int)d.amount, (int)d.flag);
|
|
}
|
|
for (const auto &mo : w.drain_motions()) {
|
|
emit_signal("motion", (int)mo.vid, (int)mo.victim_vid, (int)mo.motion);
|
|
}
|
|
for (const auto &dm : w.drain_dig_motions()) {
|
|
emit_signal("dig_motion", (int)dm.vid, (int)dm.target_vid, (int)dm.count);
|
|
}
|
|
for (const auto &fe : w.drain_fishing_events()) {
|
|
emit_signal("fishing_event", (int)fe.subheader, (int)fe.info, (int)fe.dir);
|
|
}
|
|
for (const auto &de : w.drain_dungeon_events()) {
|
|
emit_signal("dungeon_event", (int)de.subheader, (int)de.x, (int)de.y,
|
|
de.has_destination);
|
|
}
|
|
for (const auto &oe : w.drain_observer_events()) {
|
|
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(oe.x, oe.y, 0);
|
|
emit_signal("observer_event", (int)oe.kind, (int)oe.vid,
|
|
Vector2(g.x, g.z));
|
|
}
|
|
for (const auto &ec : w.drain_effect_cues()) {
|
|
emit_signal("effect_cue", (int)ec.vid, String::utf8(ec.file.c_str()),
|
|
(int)ec.special);
|
|
}
|
|
for (const auto &fc : w.drain_fly_cues()) {
|
|
emit_signal("fly_cue", (int)fc.type, (int)fc.start_vid, (int)fc.end_vid);
|
|
}
|
|
for (const auto &tc : w.drain_fly_target_cues()) {
|
|
emit_signal("fly_targeting", (int)tc.shooter_vid, (int)tc.target_vid,
|
|
Vector2((float)tc.x, (float)tc.y), tc.append);
|
|
}
|
|
if (w.skills_dirty()) {
|
|
emit_signal("skills_changed");
|
|
}
|
|
if (w.skill_group_dirty()) {
|
|
emit_signal("skill_group_changed", (int)w.skill_group());
|
|
}
|
|
for (int sk : w.drain_cooldown_ends()) {
|
|
emit_signal("skill_cooldown_end", sk);
|
|
}
|
|
if (w.quickslots_dirty()) {
|
|
emit_signal("quickslots_changed");
|
|
}
|
|
for (const auto &sc : w.drain_scripts()) {
|
|
emit_signal("script_dialog", (int)sc.skin, String::utf8(sc.text.c_str()));
|
|
}
|
|
for (const auto &cc : w.drain_confirms()) {
|
|
emit_signal("quest_confirm_ask", String::utf8(cc.msg.c_str()), (int)cc.timeout,
|
|
(int)cc.request_pid);
|
|
}
|
|
for (uint16_t qi : w.drain_quest_changes()) {
|
|
emit_signal("quest_info", (int)qi);
|
|
}
|
|
for (const auto &ac : w.drain_affects()) {
|
|
if (ac.added) {
|
|
for (const auto &a : w.affects()) {
|
|
if (a.type == ac.type) {
|
|
emit_signal("affect_added", affect_dict(a));
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
emit_signal("affect_removed", (int)ac.type);
|
|
}
|
|
}
|
|
if (w.take_points_dirty()) {
|
|
emit_signal("points_changed", get_points());
|
|
}
|
|
if (w.take_target_dirty()) {
|
|
emit_signal("target_info", (int)w.target_vid(), (int)w.target_hp_pct());
|
|
}
|
|
for (const auto &ic : w.drain_inv()) {
|
|
emit_signal("inventory_changed", (int)ic.window, (int)ic.cell);
|
|
}
|
|
for (const auto &gc : w.drain_ground()) {
|
|
if (gc.added) {
|
|
const mtnet::GroundItem *g = w.ground(gc.vid);
|
|
if (g) {
|
|
Dictionary d;
|
|
d["vid"] = (int)g->vid;
|
|
d["vnum"] = (int)g->vnum;
|
|
d["owner"] = String::utf8(g->owner.c_str());
|
|
fmt::m2coord::Vec3 gp = fmt::m2coord::position_to_godot(g->x, g->y, g->z);
|
|
d["pos"] = Vector3(gp.x, gp.y, gp.z);
|
|
emit_signal("ground_item_added", d);
|
|
}
|
|
} else {
|
|
emit_signal("ground_item_removed", (int)gc.vid);
|
|
}
|
|
}
|
|
for (uint32_t vid : w.drain_view_equipment_changes()) {
|
|
emit_signal("view_equipment", (int)vid);
|
|
}
|
|
for (const auto &p : w.drain_pvp_changes()) {
|
|
emit_signal("pvp_changed", (int)p.src_vid, (int)p.dst_vid, (int)p.mode);
|
|
}
|
|
if (w.take_duel_started()) {
|
|
emit_signal("duel_changed", get_duel());
|
|
emit_signal("duel_started");
|
|
}
|
|
for (const auto &ie : w.drain_item_events()) {
|
|
if (ie.kind == mtnet::ItemEvent::Get) {
|
|
emit_signal("item_picked_up", (int)ie.vnum, (int)ie.count,
|
|
String::utf8(ie.from.c_str()));
|
|
} else {
|
|
emit_signal("item_used", (int)ie.vnum);
|
|
}
|
|
}
|
|
// P8: social / shop / storage
|
|
for (uint32_t leader_pid : w.drain_party_invites()) {
|
|
emit_signal("party_invite_ask", (int)leader_pid);
|
|
}
|
|
for (const auto &ev : w.drain_server_commands()) {
|
|
switch (ev.kind) {
|
|
case mtnet::ServerCommandEvent::PartyRequestDenied:
|
|
emit_signal("party_request_denied");
|
|
break;
|
|
case mtnet::ServerCommandEvent::LoverLogin:
|
|
case mtnet::ServerCommandEvent::LoverLogout:
|
|
case mtnet::ServerCommandEvent::LoverNear:
|
|
case mtnet::ServerCommandEvent::LoverFar:
|
|
case mtnet::ServerCommandEvent::LoverDivorce:
|
|
// LoverInfo 由 mutator 同步更新;lover_changed 信号在
|
|
// take_lover_dirty() 处统一发出,这里无需单独 emit。
|
|
break;
|
|
case mtnet::ServerCommandEvent::SafeboxPasswordRequired:
|
|
emit_signal("safebox_password_required");
|
|
break;
|
|
case mtnet::ServerCommandEvent::SafeboxWrongPassword:
|
|
emit_signal("safebox_wrong_password");
|
|
break;
|
|
case mtnet::ServerCommandEvent::MallPasswordRequired:
|
|
emit_signal("mall_password_required");
|
|
break;
|
|
case mtnet::ServerCommandEvent::RefineSucceeded:
|
|
emit_signal("refine_result", true);
|
|
break;
|
|
case mtnet::ServerCommandEvent::RefineFailed:
|
|
emit_signal("refine_result", false);
|
|
break;
|
|
case mtnet::ServerCommandEvent::PrivateShopOpenRequested:
|
|
emit_signal("private_shop_open_requested");
|
|
break;
|
|
case mtnet::ServerCommandEvent::MyShopPrice:
|
|
emit_signal("my_shop_price_list", (int)ev.value, (int)ev.value2);
|
|
break;
|
|
case mtnet::ServerCommandEvent::BlockModeChanged:
|
|
emit_signal("block_mode_changed", (int)ev.value);
|
|
break;
|
|
case mtnet::ServerCommandEvent::ObserverModeChanged:
|
|
emit_signal("observer_mode_changed", ev.value != 0);
|
|
break;
|
|
case mtnet::ServerCommandEvent::ObserverCountChanged:
|
|
emit_signal("observer_count_changed", (int)ev.value);
|
|
break;
|
|
case mtnet::ServerCommandEvent::StoneDetected:
|
|
emit_signal("stone_detected", (int)ev.value, (int)ev.value2, ev.value3);
|
|
break;
|
|
case mtnet::ServerCommandEvent::StaminaStarted:
|
|
emit_signal("stamina_changed", (int)ev.value2, (int)ev.value, true);
|
|
break;
|
|
case mtnet::ServerCommandEvent::StaminaStopped:
|
|
emit_signal("stamina_changed", (int)ev.value2, 0, false);
|
|
break;
|
|
case mtnet::ServerCommandEvent::MobileFlagChanged:
|
|
emit_signal("mobile_flag_changed", ev.value != 0);
|
|
break;
|
|
case mtnet::ServerCommandEvent::MobileAuthRequired:
|
|
emit_signal("mobile_auth_required");
|
|
break;
|
|
case mtnet::ServerCommandEvent::ComboChanged:
|
|
emit_signal("combo_changed", ev.value != 0);
|
|
break;
|
|
case mtnet::ServerCommandEvent::GiftAvailable:
|
|
emit_signal("gift_available");
|
|
break;
|
|
}
|
|
}
|
|
if (w.party_dirty()) {
|
|
emit_signal("party_changed");
|
|
}
|
|
if (w.take_lover_dirty()) {
|
|
emit_signal("lover_changed", get_lover());
|
|
}
|
|
if (w.friends_dirty()) {
|
|
emit_signal("friends_changed");
|
|
}
|
|
for (const auto &name : w.drain_friend_invites()) {
|
|
emit_signal("friend_invite_ask", String::utf8(name.c_str()));
|
|
}
|
|
bool shop_was_open = shop_open_seen;
|
|
if (w.shop_dirty()) {
|
|
shop_open_seen = w.shop_open();
|
|
if (shop_open_seen && !shop_was_open) {
|
|
emit_signal("shop_opened", (int)w.shop_vid());
|
|
} else if (!shop_open_seen && shop_was_open) {
|
|
emit_signal("shop_closed");
|
|
}
|
|
}
|
|
for (const auto &err : w.drain_shop_errors()) {
|
|
emit_signal("shop_error", String::utf8(err.c_str()));
|
|
}
|
|
if (w.exchange_dirty()) {
|
|
emit_signal("exchange_changed");
|
|
}
|
|
if (w.safebox_dirty()) {
|
|
emit_signal("safebox_changed");
|
|
}
|
|
if (w.mall_dirty()) {
|
|
bool mall_now = w.mall_open();
|
|
if (mall_now && !mall_open_seen) {
|
|
emit_signal("mall_opened", w.mall_size());
|
|
}
|
|
mall_open_seen = mall_now;
|
|
emit_signal("mall_changed");
|
|
}
|
|
for (const auto &ev : w.drain_cube_events()) {
|
|
using CE = mtnet::EntityStore::CubeEvent;
|
|
switch (ev) {
|
|
case CE::Opened:
|
|
emit_signal("cube_opened", (int)w.cube().npc_vnum);
|
|
break;
|
|
case CE::Closed:
|
|
emit_signal("cube_closed");
|
|
break;
|
|
case CE::InfoChanged:
|
|
emit_signal("cube_changed");
|
|
break;
|
|
case CE::Success: {
|
|
auto s = w.cube_last_success();
|
|
emit_signal("cube_result", (int)s.vnum, s.count, true);
|
|
break;
|
|
}
|
|
case CE::Fail:
|
|
emit_signal("cube_result", 0, 0, false);
|
|
break;
|
|
}
|
|
}
|
|
if (w.guild_dirty()) {
|
|
emit_signal("guild_changed");
|
|
}
|
|
if (w.guild_comments_dirty()) {
|
|
emit_signal("guild_comments_changed");
|
|
}
|
|
for (const auto &invite : w.drain_guild_invites()) {
|
|
emit_signal("guild_invite_ask", (int64_t)invite.guild_id,
|
|
String::utf8(invite.guild_name.c_str()));
|
|
}
|
|
if (w.guild_skill_dirty()) {
|
|
emit_signal("guild_skill_changed");
|
|
}
|
|
if (w.guild_war_dirty()) {
|
|
emit_signal("guild_war_changed");
|
|
}
|
|
for (const auto &ev : w.drain_guild_war_events()) {
|
|
emit_signal("guild_war_event", (int)ev.state, (int64_t)ev.opp_guild_id,
|
|
String::utf8(w.guild_name(ev.opp_guild_id).c_str()));
|
|
}
|
|
for (const auto &sc : w.drain_guild_war_scores()) {
|
|
emit_signal("guild_war_point", (int64_t)sc.gain_guild_id, (int64_t)sc.opp_guild_id,
|
|
sc.point);
|
|
}
|
|
for (const auto &mu : game->drain_mark_updates()) {
|
|
emit_signal("guild_mark_updated", (int)mu.guild_id, (int)mu.img_idx);
|
|
}
|
|
for (const auto &rc : w.drain_refine_cues()) {
|
|
Dictionary d;
|
|
d["type"] = (int)rc.type;
|
|
d["pos"] = (int)rc.pos;
|
|
d["src_vnum"] = (int)rc.src_vnum;
|
|
d["result_vnum"] = (int)rc.result_vnum;
|
|
d["cost"] = rc.cost;
|
|
d["prob"] = rc.prob;
|
|
Array mats;
|
|
for (int i = 0; i < rc.material_count && i < 5; ++i) {
|
|
if (rc.materials[i].vnum == 0) {
|
|
continue;
|
|
}
|
|
Dictionary m;
|
|
m["vnum"] = (int)rc.materials[i].vnum;
|
|
m["count"] = rc.materials[i].count;
|
|
mats.push_back(m);
|
|
}
|
|
d["materials"] = mats;
|
|
emit_signal("refine_ask", d);
|
|
}
|
|
for (const auto &dc : w.drain_ds_cues()) {
|
|
if (dc.sub_type == mtnet::DS_SUB_OPEN) {
|
|
emit_signal("ds_window_open");
|
|
} else if (dc.sub_type == mtnet::DS_SUB_REFINE_SUCCEED) {
|
|
emit_signal("ds_refine_result", true, (int)dc.sub_type, (int)dc.cell);
|
|
} else if (dc.sub_type >= mtnet::DS_SUB_REFINE_FAIL &&
|
|
dc.sub_type <= mtnet::DS_SUB_REFINE_FAIL_TOO_MUCH_MATERIAL) {
|
|
emit_signal("ds_refine_result", false, (int)dc.sub_type, (int)dc.cell);
|
|
}
|
|
}
|
|
// P9: world systems
|
|
for (const auto &warp : w.drain_warps()) {
|
|
fmt::m2coord::Vec3 g = fmt::m2coord::position_to_godot(warp.x, warp.y, 0);
|
|
emit_signal("warp", Vector3(g.x, g.y, g.z), warp.same_server());
|
|
if (!warp.same_server()) {
|
|
// int32 lAddr is a network-order IPv4; format dotted-quad.
|
|
uint32_t a = (uint32_t)warp.addr;
|
|
char host[16];
|
|
std::snprintf(host, sizeof(host), "%u.%u.%u.%u", a & 0xFF, (a >> 8) & 0xFF,
|
|
(a >> 16) & 0xFF, (a >> 24) & 0xFF);
|
|
warp_to_game_server(String(host), warp.port);
|
|
return; // game client is being torn down; stop touching w
|
|
}
|
|
}
|
|
if (w.take_time_dirty()) {
|
|
emit_signal("time_changed", (int64_t)w.server_time());
|
|
}
|
|
if (w.take_channel_dirty()) {
|
|
emit_signal("channel_changed", w.channel());
|
|
}
|
|
if (w.take_npc_marks_dirty()) {
|
|
emit_signal("npc_marks_changed");
|
|
}
|
|
if (w.take_land_dirty()) {
|
|
emit_signal("land_areas_changed");
|
|
}
|
|
if (w.take_markers_dirty()) {
|
|
emit_signal("world_markers_changed");
|
|
}
|
|
for (uint32_t vid : w.drain_mount_changes()) {
|
|
emit_signal("mount_changed", (int)vid);
|
|
}
|
|
}
|
|
|
|
if (game->state() == mtnet::NetStream::State::Offline && stage != Stage::Failed) {
|
|
emit_signal("disconnected", String(game->last_error().c_str()));
|
|
set_stage(Stage::Failed);
|
|
game.reset();
|
|
}
|
|
}
|
|
|
|
} // namespace mtgodot
|