diff --git a/android/app/src/main/java/org/metin2port/client/MainActivity.java b/android/app/src/main/java/org/metin2port/client/MainActivity.java index ac556c25..abc3a19c 100644 --- a/android/app/src/main/java/org/metin2port/client/MainActivity.java +++ b/android/app/src/main/java/org/metin2port/client/MainActivity.java @@ -253,7 +253,7 @@ public class MainActivity extends SDLActivity { args = DEFAULT_ARGS; if (!args.contains("--safe-inset-px")) args = args.trim() + " --safe-inset-px " + safeInsetPx(); - // Test phase: FPS readout in the top-left corner. Drop this line for release builds. + // Test phase: FPS readout at the top centre. Drop this line for release builds. if (!args.contains("--show-fps")) args = args.trim() + " --show-fps"; return args.trim().split("\\s+"); diff --git a/audit/history.jsonl b/audit/history.jsonl index ebada9e7..2e0ec188 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -649,3 +649,4 @@ {"date": "2026-09-30", "unit": "UserInterface/PythonPlayerAutoHunt.cpp", "action": "user-authorized-extension", "evidence": "Replace tag-and-chase gather with free server-authoritative cloak-style BeginFight pull, nearest 3/10/20 actual monster groups. Server rate limit and safety checks, request generations, stationary approach wait. host.autohunt_pull and desktop/mobile port.login_flow pass; 203 FreeBSD compile/deploy and live 3-group/9-monster reply verified. See tools/server/autohunt-pull/README.md."} {"date": "2026-09-30", "unit": "EterBase/CRC32.cpp", "action": "fix-platform-adapter", "evidence": "MI 8 Lite Android 15 fake-login SIGABRT/std::bad_alloc from directory CRC. Reject non-regular/null FILE handles and failed seeks; preserve normal CRC/position. port_utils_test pass, port_map check 0 errors; rebuilt APK installed and on-device fake-login/map entry succeeds. docs/XIAOMI8LITE-DEVICE-TEST-20260930.md"} {"date": "2026-09-30", "action": "user-authorized-render-diagnostics", "units": ["UserInterface/PythonApplication.cpp", "GameLib/MapOutdoorRenderHTP.cpp", "GameLib/MapOutdoorRenderSTP.cpp"], "evidence": "7 same-scene MI 8 Lite runs, 42 two-second sample windows. Baseline 4.88 FPS/205.3ms, no terrain 15.45FPS/64.7ms, no scene 30.03FPS/17.7ms; repeated baseline 4.83 FPS. Normal rendering defaults unchanged. See docs/XIAOMI8LITE-RENDER-ISOLATION-20260930.md."} +{"date": "2026-09-30", "unit": "Client/root/uiaffectshower.py", "result": "RUN_AS_IS unchanged; authorized PORT extension for server rates", "evidence": ["src/platform/ScriptLib/ServerBonusScript.inc", "tools/server/autohunt-pull-range/server_bonuses.h", "build/autohunt-fix-test.log", "build/autohunt-fix-mobile-test.log", "build/autohunt-bonuses-android.png"], "finding": "40250 global privileges emit login notices rather than character AFFECT packets; added distinct status channel, packed scripts unchanged."} diff --git a/audit/port-map/Client/root/uiaffectshower.py.json b/audit/port-map/Client/root/uiaffectshower.py.json index 6e4263c6..70bcee3d 100644 --- a/audit/port-map/Client/root/uiaffectshower.py.json +++ b/audit/port-map/Client/root/uiaffectshower.py.json @@ -511,7 +511,8 @@ ], "evidence": [ "scripts/verify_root_pack.py: uiaffectshower.py bytes match 40250 source and root.epk", - "scripts/python_game_render_test.sh: embedded CPython loads system.py and game modules through CEterPackManager" + "scripts/python_game_render_test.sh: embedded CPython loads system.py and game modules through CEterPackManager", + "2026-09-30 PORT server-bonus extension: original affect script unchanged; port.login_flow checks separate global/event entries and expiry/update via real CHAT_TYPE_COMMAND; Mi 8 Lite tooltip verified." ] }, "AffectShower.OnUpdate": { @@ -521,7 +522,8 @@ ], "evidence": [ "scripts/verify_root_pack.py: uiaffectshower.py bytes match 40250 source and root.epk", - "scripts/python_game_render_test.sh: embedded CPython loads system.py and game modules through CEterPackManager" + "scripts/python_game_render_test.sh: embedded CPython loads system.py and game modules through CEterPackManager", + "2026-09-30 PORT server-bonus extension: original affect script unchanged; port.login_flow checks separate global/event entries and expiry/update via real CHAT_TYPE_COMMAND; Mi 8 Lite tooltip verified." ] } } diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index b35bff83..26a8b836 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -47,6 +47,10 @@ metin2-client 是 40250 Windows 客户端的跨平台版本。**首要目标是 2026-09-30 用户授权扩展:自动狩猎引怪改为服务端披风式追击,免费选择 3/10/20 个实际怪物队伍; 见 `tools/server/autohunt-pull/README.md`,203 已部署并实测 3 组/9 只怪物。原披风物品保持不变。 +2026-09-30 后续授权:狩猎范围为 35/100/150 米,引怪搜索及专属追击距离同步;石头优先,无石头再打怪。 +服务器倍率和经验/金币/掉落活动加成通过独立 PORT 状态消息显示在原增益栏,原版角色增益脚本不修改。 +见 `tools/server/autohunt-pull-range/README.md`,203 已更新服务端并保留二进制回滚备份。 + 2026-09-30 用户授权扩展:移动戒指列出 203 已登记的全部 65 张地图,按六类分页,包含活动、战争和测试地图; 受限副本走原有入口,活动保留入场流程,测试传送仅限 GM。见 `tools/server/ring-warp/README.md`。 diff --git a/src/host/vk_frame.cpp b/src/host/vk_frame.cpp index 3fc7de55..e7b64fe2 100644 --- a/src/host/vk_frame.cpp +++ b/src/host/vk_frame.cpp @@ -321,10 +321,10 @@ void VulkanWindow::prepare_ui(std::uint32_t ui_width, std::uint32_t ui_height, c if (!SkipDiagnosticRender("touch")) touch_controller.append_ui_commands(combined_ui); } if (show_fps_ && fps_ >= 0) { - // Top left, inside the safe area and clear of 40250's corner icon. + // Top centre, clear of the original affect bar and the mini-map. const float inset = float(std::min(ui_safe_inset(), int(target_ui_w) / 8)); TouchController::draw_segment_text(combined_ui, "FPS " + std::to_string(fps_), - inset + 40.0f, 8.0f, 12.0f, 0xFF40FF40); + std::max(inset, float(target_ui_w) * 0.5f - 40.0f), 8.0f, 12.0f, 0xFF40FF40); } if (!combined_ui.empty()) { build_ui_batches(target_ui_w, target_ui_h, combined_ui, capture_textures, ui_batches, ui_quad_count); diff --git a/src/platform/ScriptLib/AutoHuntScript.inc b/src/platform/ScriptLib/AutoHuntScript.inc index 8434bd26..d2010d28 100644 --- a/src/platform/ScriptLib/AutoHuntScript.inc +++ b/src/platform/ScriptLib/AutoHuntScript.inc @@ -31,8 +31,8 @@ STOP_TEXT = { STOP_INVENTORY_FULL: "背包已满,自动狩猎已停止", } -RANGE_VALUES = (1000, 2000, 3500) -RANGE_TEXTS = ("近", "中", "远") +RANGE_VALUES = (3500, 10000, 15000) +RANGE_TEXTS = ("35米", "100米", "150米") HP_VALUES = (0, 30, 50, 70) SP_VALUES = (0, 20, 40, 60) PICKUP_TEXTS = ("不拾取", "全部", "金币装备") @@ -178,7 +178,7 @@ class SettingDialog(ui.BoardWithTitleBar): self.targetLabel.SetPosition(18, y + 4) self.targetLabel.SetText(L("攻击")) self.targetLabel.Show() - self.stoneButton = FlagButton(self, 70, y, MIDDLE_BUTTON, "石头", self.__Save) + self.stoneButton = FlagButton(self, 70, y, MIDDLE_BUTTON, "优先石头", self.__Save) self.bossButton = FlagButton(self, 136, y, MIDDLE_BUTTON, "首领", self.__Save) y += 30 @@ -290,14 +290,18 @@ class Ticker(ui.Window): def OnUpdate(self): state = player.AutoHuntGetState() - if state != self.last: + config = player.AutoHuntGetConfig() + displayState = state + (config[7],) + if displayState != self.last: (stateIndex, reason, noHP, noSP, gathering, gathered) = state wasOn = self.last and self.last[0] != STATE_OFF - self.last = state + self.last = displayState if stateIndex != STATE_OFF: text = L("自动狩猎 · ") + L(STATE_TEXT.get(stateIndex, "")) if gathering: text = L("自动狩猎 · 披风引怪中(最多%d组)" % player.AutoHuntGetConfig()[7]) + elif config[7]: + text += L(" · 本轮引怪%d/%d组" % (gathered, config[7])) if noHP: text += L(" · 无红药") if noSP: diff --git a/src/platform/ScriptLib/PythonBoot.cpp b/src/platform/ScriptLib/PythonBoot.cpp index bbfa65ba..c7e7a64d 100644 --- a/src/platform/ScriptLib/PythonBoot.cpp +++ b/src/platform/ScriptLib/PythonBoot.cpp @@ -916,6 +916,7 @@ namespace PythonBoot #include "UIScaleOptionScript.inc" #include "InventoryPagesScript.inc" #include "MallHintScript.inc" +#include "ServerBonusScript.inc" static void install_script_module(const char* c_szModule, const char* c_szScript) { @@ -975,6 +976,7 @@ bool RunMainScript(const char* lpCmdLine, std::string* error) install_script_module("mt_uiscale", c_szUIScaleOptionScript); install_script_module("mt_inventory", c_szInventoryPagesScript); install_script_module("mt_mallhint", c_szMallHintScript); + install_script_module("mt_bonuses", c_szServerBonusScript); } return true; } diff --git a/src/platform/ScriptLib/ServerBonusScript.inc b/src/platform/ScriptLib/ServerBonusScript.inc new file mode 100644 index 00000000..4ae00636 --- /dev/null +++ b/src/platform/ScriptLib/ServerBonusScript.inc @@ -0,0 +1,107 @@ +// PORT: server rates and privilege events are not character AFFECT packets in 40250. +// Add distinct entries to the original affect bar; leave packed scripts unchanged. +static const char c_szServerBonusScript[] = R"PY(# -*- coding: utf-8 -*- +import app +import game +import net +import player +import ui +import uiAffectShower + +L = player.AutoHuntLocalize +PREFIX = "d:/ymir work/ui/skill/common/affect/" +ENTRIES = ( + ("服务器经验 %d%%", "exp_bonus.sub", 100), + ("服务器金币数量 %d%%", "gold_bonus.sub", 100), + ("服务器金币掉落率 %d%%", "gold_bonus.sub", 100), + ("服务器物品掉落率 %d%%", "item_bonus.sub", 100), + ("活动经验加成 %+d%%", "exp_bonus.sub", 0), + ("活动物品掉落加成 %+d%%", "item_bonus.sub", 0), + ("活动金币掉落加成 %+d%%", "gold_bonus.sub", 0), + ("活动金币礼包掉落加成 %+d%%", "gold_premium.sub", 0), +) +KEY_START = 20000 +g_game = None +g_ticker = None + + +def Update(window, *values): + if len(values) != len(ENTRIES): + return + try: + values = tuple(int(value) for value in values) + except (ValueError, TypeError): + return + bar = window.affectShower + if not bar: + return + for i, ((text, asset, baseline), value) in enumerate(zip(ENTRIES, values)): + key = KEY_START + i + image = bar.affectImageDict.get(key) + if value == baseline: + if image: + image.Hide() + del bar.affectImageDict[key] + continue + if not image: + image = uiAffectShower.AffectImage() + image.SetParent(bar) + image.LoadImage(PREFIX + asset) + image.SetScale(0.7, 0.7) + image.SetAffect(key) + image.SetSkillAffectFlag(False) + image.SetClock(False) + bar.affectImageDict[key] = image + image.SetDescription(L(text % value)) + image.UpdateDescription() + image.Show() + bar._AffectShower__ArrangeImageList() + + +class Ticker(ui.Window): + def __init__(self, window): + ui.Window.__init__(self) + self.window = window + self.nextQuery = app.GetTime() + 3.0 + self.Show() + + def OnUpdate(self): + now = app.GetTime() + if self.window and now >= self.nextQuery: + self.nextQuery = now + 15.0 + net.SendChatPacket("/server_bonuses") + + +_open = game.GameWindow.Open +_close = game.GameWindow.Close +_command = game.GameWindow.BINARY_ServerCommand_Run + + +def Open(self): + global g_game, g_ticker + _open(self) + g_game = self + g_ticker = Ticker(self) + + +def Close(self): + global g_game, g_ticker + if g_ticker: + g_ticker.Hide() + g_ticker.window = None + g_game = g_ticker = None + _close(self) + + +def Command(self, line): + words = line.split() + if words and words[0] == "ServerBonuses": + Update(self, *words[1:]) + return 1 + return _command(self, line) + + +game.GameWindow.Open = Open +game.GameWindow.Close = Close +game.GameWindow.BINARY_ServerCommand_Run = Command +)PY"; diff --git a/src/port/UserInterface/PythonPlayer.cpp b/src/port/UserInterface/PythonPlayer.cpp index d51bf3c9..48510539 100644 --- a/src/port/UserInterface/PythonPlayer.cpp +++ b/src/port/UserInterface/PythonPlayer.cpp @@ -1745,7 +1745,7 @@ CPythonPlayer::CPythonPlayer(void) m_isAutoHuntConfigLoaded = false; m_eAutoHuntState = AUTO_HUNT_OFF; m_eAutoHuntStopReason = AUTO_HUNT_STOP_NONE; - m_kAutoHuntConfig.iRange = 2000; + m_kAutoHuntConfig.iRange = 3500; m_kAutoHuntConfig.iHPPercent = 40; m_kAutoHuntConfig.iSPPercent = 20; m_kAutoHuntConfig.iPickup = AUTO_HUNT_PICKUP_ALL; diff --git a/src/port/UserInterface/PythonPlayer.h b/src/port/UserInterface/PythonPlayer.h index 350f4a3e..93583afa 100644 --- a/src/port/UserInterface/PythonPlayer.h +++ b/src/port/UserInterface/PythonPlayer.h @@ -735,12 +735,15 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer protected: void __AutoHunt_OnManualInput(); + void __AutoHunt_CancelPull(); void __AutoHunt_Stop(int eReason); void __Update_AutoHunt(); void __AutoHunt_UpdatePotion(CInstanceBase& rkInstMain, float fNow); bool __AutoHunt_UpdateBuff(CInstanceBase& rkInstMain, float fNow); void __AutoHunt_UpdateAttackSkill(CInstanceBase& rkInstMain, CInstanceBase& rkInstVictim, float fNow); bool __AutoHunt_IsSkillReady(DWORD dwSkillSlot, CPythonSkill::TSkillData** ppSkillData); + bool __AutoHunt_IsTargetAllowed(CInstanceBase& main, CInstanceBase& target); // PORT extension + bool __AutoHunt_RequestPull(CInstanceBase& main, float now); // PORT extension DWORD __AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow); DWORD __AutoHunt_FindGatheredTarget(CInstanceBase& rkInstMain); void __AutoHunt_PurgeGathered(); diff --git a/src/port/UserInterface/PythonPlayerAutoHunt.cpp b/src/port/UserInterface/PythonPlayerAutoHunt.cpp index f280368e..353e426e 100644 --- a/src/port/UserInterface/PythonPlayerAutoHunt.cpp +++ b/src/port/UserInterface/PythonPlayerAutoHunt.cpp @@ -46,6 +46,7 @@ const BYTE c_byMobRankBoss = 4; // PAWN, S_PAWN, KNIGHT, S_KNIGHT, BOSS, KING const DWORD c_dwGoldVnum = 1; const float c_fAutoHuntPullCooldown = 10.0f; const float c_fAutoHuntPullReplyTimeout = 3.0f; +const float c_fAutoHuntPullApproachTimeout = 8.0f; const int c_iAutoHuntGatherHPPercent = 50; // gather: stop pulling below this // Trace: stderr with MT_AUTOHUNT_TRACE=1, logcat (tag mt_autohunt) on Android. @@ -130,7 +131,7 @@ bool CPythonPlayer::AutoHunt_IsGathering() int CPythonPlayer::AutoHunt_GetGatheredCount() { - return m_kSet_dwAutoHuntGathered.empty() ? 0 : m_iAutoHuntGatheredGroups; + return AutoHunt_IsEnabled() ? m_iAutoHuntGatheredGroups : 0; } bool CPythonPlayer::AutoHunt_HasNoPotion(bool isHP) @@ -147,8 +148,16 @@ const CPythonPlayer::SAutoHuntConfig& CPythonPlayer::AutoHunt_GetConfig() void CPythonPlayer::AutoHunt_SetConfig(const SAutoHuntConfig& c_rkConfig) { m_isAutoHuntConfigLoaded = true; + if (AutoHunt_IsEnabled() && (m_kAutoHuntConfig.iGatherCount != c_rkConfig.iGatherCount || + m_kAutoHuntConfig.bAttackBoss != c_rkConfig.bAttackBoss || m_kAutoHuntConfig.iRange != c_rkConfig.iRange)) { + __AutoHunt_CancelPull(); + ++m_dwAutoHuntPullRequest; // invalidate replies for the previous selection + m_isAutoHuntGathering = false; + m_kSet_dwAutoHuntGathered.clear(); + m_iAutoHuntGatheredGroups = 0; + } m_kAutoHuntConfig = c_rkConfig; - m_kAutoHuntConfig.iRange = MINMAX(500, m_kAutoHuntConfig.iRange, 5000); + m_kAutoHuntConfig.iRange = MINMAX(3500, m_kAutoHuntConfig.iRange, 15000); m_kAutoHuntConfig.iHPPercent = MINMAX(0, m_kAutoHuntConfig.iHPPercent, 95); m_kAutoHuntConfig.iSPPercent = MINMAX(0, m_kAutoHuntConfig.iSPPercent, 95); m_kAutoHuntConfig.iPickup = MINMAX(0, m_kAutoHuntConfig.iPickup, int(AUTO_HUNT_PICKUP_GOLD_EQUIP)); @@ -199,8 +208,19 @@ void CPythonPlayer::__AutoHunt_SaveConfig() fclose(fp); } +void CPythonPlayer::__AutoHunt_CancelPull() +{ + if (AutoHunt_IsEnabled() && m_kAutoHuntConfig.iGatherCount > 0) { + char command[96]; + snprintf(command, sizeof(command), "/autohunt_pull 0 %d %d %u", m_kAutoHuntConfig.iRange, + m_kAutoHuntConfig.bAttackBoss ? 1 : 0, unsigned(m_dwAutoHuntPullRequest)); + CPythonNetworkStream::Instance().SendChatPacket(command); + } +} + void CPythonPlayer::__AutoHunt_Stop(int eReason) { + __AutoHunt_CancelPull(); if (AUTO_HUNT_ENGAGE == m_eAutoHuntState || AUTO_HUNT_LOOT == m_eAutoHuntState || AUTO_HUNT_RETURN == m_eAutoHuntState) { __ClearAutoAttackTargetActorID(); @@ -250,31 +270,56 @@ void CPythonPlayer::__AutoHunt_Engage(DWORD dwVID, float fNow) __SetAutoAttackTargetActorID(dwVID); } +// PORT: stones have their own option; their mob_proto rank must not count as a boss. +bool CPythonPlayer::__AutoHunt_IsTargetAllowed(CInstanceBase& main, CInstanceBase& target) +{ + if (&main == &target || target.IsDead() || target.IsInSafe() || !main.IsAttackableInstance(target)) + return false; + if (target.IsStone()) return m_kAutoHuntConfig.bAttackStone; + if (!target.IsEnemy()) return false; + const auto* mob = CPythonNonPlayer::Instance().GetTable(target.GetRace()); + return m_kAutoHuntConfig.bAttackBoss || !mob || mob->bRank < c_byMobRankBoss; +} + +// PORT: cloak collection and the server pursuit leash follow the hunting range. +bool CPythonPlayer::__AutoHunt_RequestPull(CInstanceBase& main, float now) +{ + const int maxHP = GetStatus(POINT_MAX_HP); + if (m_kAutoHuntConfig.iGatherCount <= 0 || m_isAutoHuntGathering || now < m_fAutoHuntNextPullTime || + main.IsInSafe() || maxHP <= 0 || GetStatus(POINT_HP) * 100 < maxHP * c_iAutoHuntGatherHPPercent) + return false; + if (m_kAutoHuntConfig.bAttackStone) { + auto* target = NEW_FindActorPtr(__AutoHunt_FindTarget(main, now)); + if (target && target->IsStone()) return false; + } + const int radius = m_kAutoHuntConfig.iRange; + char command[128]; + ++m_dwAutoHuntPullRequest; + snprintf(command, sizeof(command), "/autohunt_pull %d %d %d %u", + m_kAutoHuntConfig.iGatherCount, radius, m_kAutoHuntConfig.bAttackBoss ? 1 : 0, + unsigned(m_dwAutoHuntPullRequest)); + m_fAutoHuntNextPullTime = now + c_fAutoHuntPullCooldown; + if (!CPythonNetworkStream::Instance().SendChatPacket(command)) return false; + m_kSet_dwAutoHuntGathered.clear(); + m_iAutoHuntGatheredGroups = 0; + m_isAutoHuntGathering = true; + m_fAutoHuntGatherStartTime = now; + AutoHuntTrace("cloak request: %d groups, radius %d", m_kAutoHuntConfig.iGatherCount, radius); + return true; +} + DWORD CPythonPlayer::__AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow) { CPythonCharacterManager& rkChrMgr = CPythonCharacterManager::Instance(); - CPythonNonPlayer& rkNonPlayer = CPythonNonPlayer::Instance(); DWORD dwBestVID = 0; float fBestDistance = 1e9f; + bool bestStone = false; for (CPythonCharacterManager::CharacterIterator i = rkChrMgr.CharacterInstanceBegin(); i != rkChrMgr.CharacterInstanceEnd(); ++i) { CInstanceBase* pkInst = *i; - if (!pkInst || pkInst == &rkInstMain || pkInst->IsDead()) - continue; - - if (!pkInst->IsEnemy() && !(m_kAutoHuntConfig.bAttackStone && pkInst->IsStone())) - continue; - - if (!rkInstMain.IsAttackableInstance(*pkInst) || pkInst->IsInSafe()) - continue; - - if (!m_kAutoHuntConfig.bAttackBoss) - { - const CPythonNonPlayer::TMobTable* pMobTable = rkNonPlayer.GetTable(pkInst->GetRace()); - if (pMobTable && pMobTable->bRank >= c_byMobRankBoss) - continue; - } + if (!pkInst || !__AutoHunt_IsTargetAllowed(rkInstMain, *pkInst)) + continue; std::map::iterator f = m_kMap_dwAutoHuntSkipVID.find(pkInst->GetVirtualID()); if (f != m_kMap_dwAutoHuntSkipVID.end() && fNow < f->second) @@ -286,8 +331,10 @@ DWORD CPythonPlayer::__AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow continue; const float fDistance = rkInstMain.GetDistance(pkInst); - if (fDistance < fBestDistance) + const bool stone = pkInst->IsStone(); + if ((stone && !bestStone) || (stone == bestStone && fDistance < fBestDistance)) { + bestStone = stone; fBestDistance = fDistance; dwBestVID = pkInst->GetVirtualID(); } @@ -303,7 +350,7 @@ DWORD CPythonPlayer::__AutoHunt_FindGatheredTarget(CInstanceBase& rkInstMain) for (DWORD dwVID : m_kSet_dwAutoHuntGathered) { CInstanceBase* pkInst = NEW_FindActorPtr(dwVID); - if (!pkInst || pkInst->IsDead() || pkInst->IsInSafe() || !rkInstMain.IsAttackableInstance(*pkInst)) + if (!pkInst || !__AutoHunt_IsTargetAllowed(rkInstMain, *pkInst)) continue; const auto skipped = m_kMap_dwAutoHuntSkipVID.find(dwVID); if (skipped != m_kMap_dwAutoHuntSkipVID.end() && CTimer::Instance().GetCurrentSecond() < skipped->second) @@ -324,7 +371,8 @@ void CPythonPlayer::__AutoHunt_PurgeGathered() for (std::set::iterator i = m_kSet_dwAutoHuntGathered.begin(); i != m_kSet_dwAutoHuntGathered.end();) { CInstanceBase* pkInst = NEW_FindActorPtr(*i); - if (!pkInst || pkInst->IsDead()) + if ((pkInst && pkInst->IsDead()) || + (!pkInst && CTimer::Instance().GetCurrentSecond() - m_fAutoHuntGatherStartTime >= std::max(c_fAutoHuntPullApproachTimeout, m_kAutoHuntConfig.iRange / 100.0f))) i = m_kSet_dwAutoHuntGathered.erase(i); else ++i; @@ -622,6 +670,11 @@ void CPythonPlayer::__Update_AutoHunt() const float fNow = CTimer::Instance().GetCurrentSecond(); __AutoHunt_UpdatePotion(rkInstMain, fNow); + if (m_isAutoHuntGathering && fNow - m_fAutoHuntGatherStartTime >= c_fAutoHuntPullReplyTimeout) { + m_isAutoHuntGathering = false; + AutoHuntTrace("cloak pull reply timed out; continue ordinary hunt"); + } + __AutoHunt_PurgeGathered(); if (IsOpenPrivateShop() || IsObserverMode() || rkInstMain.IsFishingMode()) return; @@ -646,45 +699,33 @@ void CPythonPlayer::__Update_AutoHunt() return; m_fAutoHuntNextActionTime = fNow + 0.25f; - // A cloak-style pull stays in place while the server starts the nearby groups. - if (m_isAutoHuntGathering) { - if (fNow - m_fAutoHuntGatherStartTime < c_fAutoHuntPullReplyTimeout) return; - m_isAutoHuntGathering = false; - m_kSet_dwAutoHuntGathered.clear(); - AutoHuntTrace("cloak pull reply timed out; continue ordinary hunt"); + if (m_isAutoHuntGathering) return; + const DWORD preferred = __AutoHunt_FindTarget(rkInstMain, fNow); + CInstanceBase* preferredActor = NEW_FindActorPtr(preferred); + if (preferredActor && preferredActor->IsStone()) { + __AutoHunt_Engage(preferred, fNow); + return; + } + if (__AutoHunt_RequestPull(rkInstMain, fNow)) { + __ClearAutoAttackTargetActorID(); + __ClearReservedAction(); + rkInstMain.NEW_Stop(); + return; } - __AutoHunt_PurgeGathered(); const int maxHP = GetStatus(POINT_MAX_HP); - if (m_kAutoHuntConfig.iGatherCount > 0 && m_kSet_dwAutoHuntGathered.empty() && - fNow >= m_fAutoHuntNextPullTime && !rkInstMain.IsInSafe() && - maxHP > 0 && GetStatus(POINT_HP) * 100 >= maxHP * c_iAutoHuntGatherHPPercent && - __AutoHunt_FindTarget(rkInstMain, fNow)) { - char command[128]; - ++m_dwAutoHuntPullRequest; - snprintf(command, sizeof(command), "/autohunt_pull %d %d %d %u", - m_kAutoHuntConfig.iGatherCount, m_kAutoHuntConfig.iRange, - m_kAutoHuntConfig.bAttackBoss ? 1 : 0, unsigned(m_dwAutoHuntPullRequest)); - m_fAutoHuntNextPullTime = fNow + c_fAutoHuntPullCooldown; - if (CPythonNetworkStream::Instance().SendChatPacket(command)) { - __ClearAutoAttackTargetActorID(); - __ClearReservedAction(); - rkInstMain.NEW_Stop(); - m_isAutoHuntGathering = true; - m_iAutoHuntGatheredGroups = 0; - m_fAutoHuntGatherStartTime = fNow; - return; - } - } if (DWORD dwVID = __AutoHunt_FindGatheredTarget(rkInstMain)) { CInstanceBase* pulled = NEW_FindActorPtr(dwVID); if (pulled && rkInstMain.GetDistance(pulled) > c_fAutoHuntSkillDistance && - fNow - m_fAutoHuntGatherStartTime < 8.0f && GetStatus(POINT_HP) * 100 >= maxHP * c_iAutoHuntGatherHPPercent) + fNow - m_fAutoHuntGatherStartTime < c_fAutoHuntPullApproachTimeout && GetStatus(POINT_HP) * 100 >= maxHP * c_iAutoHuntGatherHPPercent) return; // let the server-controlled monsters approach the stationary player __AutoHunt_Engage(dwVID, fNow); return; } + if (!m_kSet_dwAutoHuntGathered.empty() && + fNow - m_fAutoHuntGatherStartTime < c_fAutoHuntPullApproachTimeout) return; + if (DWORD dwVID = __AutoHunt_FindTarget(rkInstMain, fNow)) { __AutoHunt_Engage(dwVID, fNow); @@ -742,7 +783,7 @@ void CPythonPlayer::__Update_AutoHunt() case AUTO_HUNT_ENGAGE: { CInstanceBase* pkInstVictim = NEW_FindActorPtr(m_dwAutoHuntTargetVID); - if (!pkInstVictim || pkInstVictim->IsDead()) + if (!pkInstVictim || !__AutoHunt_IsTargetAllowed(rkInstMain, *pkInstVictim)) { __ClearAutoAttackTargetActorID(); m_dwAutoHuntTargetVID = 0; @@ -756,6 +797,17 @@ void CPythonPlayer::__Update_AutoHunt() return; } + if (m_kAutoHuntConfig.bAttackStone && !pkInstVictim->IsStone()) { + const DWORD preferred = __AutoHunt_FindTarget(rkInstMain, fNow); + CInstanceBase* actor = NEW_FindActorPtr(preferred); + if (actor && actor->IsStone()) { + __AutoHunt_Engage(preferred, fNow); + return; + } + } + // Keep the configured pack supplied even while attacking, without interrupting it. + if (!pkInstVictim->IsStone()) __AutoHunt_RequestPull(rkInstMain, fNow); + const float fDistance = rkInstMain.GetDistance(pkInstVictim); if (fDistance < m_fAutoHuntBestDistance - 30.0f) { diff --git a/tests/host/autohunt_pull_test.cpp b/tests/host/autohunt_pull_test.cpp index 12824ebf..e107f2f7 100644 --- a/tests/host/autohunt_pull_test.cpp +++ b/tests/host/autohunt_pull_test.cpp @@ -12,7 +12,7 @@ #include #include using DWORD = uint32_t; -constexpr int ENTITY_CHARACTER=1, ATTR_BANPK=4, MOB_RANK_BOSS=4, CHAT_TYPE_COMMAND=5; +constexpr int ENTITY_CHARACTER=1, ATTR_BANPK=4, MOB_RANK_BOSS=4, CHAT_TYPE_COMMAND=5, SECTREE_SIZE=6400; struct Character; using LPCHARACTER=Character*; struct Entity { virtual ~Entity() {} bool IsType(int) { return true; } }; @@ -22,6 +22,7 @@ struct Sectree { bool safe=false; std::vector entities; DWORD GetAttribute(long,long) { return safe ? ATTR_BANPK : 0; } + template void for_each_entity(F& f) { for(auto* e:entities) f(e); } template void ForEachAround(F& f) { for(auto* e:entities) f(e); } }; struct Character: Entity { @@ -44,6 +45,7 @@ struct Character: Entity { int GetX() { return x; } int GetY() { return y; } int GetMapIndex() { return 1; } + DWORD GetPlayerID() { return vid; } int GetMobRank() { return rank; } Sectree* GetSectree() { return sec; } Party* GetParty() { return party; } @@ -62,6 +64,26 @@ struct CHARACTER_MANAGER { static CHARACTER_MANAGER& instance() { static CHARACTER_MANAGER value; return value; } Character* Find(DWORD vid) { return mobs[vid]; } }; +using LPSECTREE=Sectree*; +struct SectreeMap { + std::map, Sectree*> cells; + template void for_each_in_rect(int x0,int y0,int x1,int y1,F& f) { + for (const auto& cell:cells) + if (cell.first.first>=x0/SECTREE_SIZE && cell.first.first<=x1/SECTREE_SIZE && + cell.first.second>=y0/SECTREE_SIZE && cell.first.second<=y1/SECTREE_SIZE) + cell.second->for_each_entity(f); + } + Sectree* Find(int x,int y) { + auto it=cells.find({x/SECTREE_SIZE,y/SECTREE_SIZE}); + return it==cells.end() ? nullptr : it->second; + } +}; +using LPSECTREE_MAP=SectreeMap*; +struct SECTREE_MANAGER { + SectreeMap map; + static SECTREE_MANAGER& instance() { static SECTREE_MANAGER m; return m; } + SectreeMap* GetMap(int) { return ↦ } +}; int now=100; int get_global_time() { return now; } #define DISTANCE_APPROX(x,y) int(std::hypot(x,y)) @@ -71,7 +93,8 @@ int get_global_time() { return now; } int main() { Sectree sec; - Character owner; owner.pc=true; owner.sec=&sec; + Character owner; owner.pc=true; owner.sec=&sec; owner.vid=1; + SECTREE_MANAGER::instance().map.cells[{0,0}]=&sec; Character other; other.pc=true; std::vector parties(25); std::vector mobs(50); @@ -104,9 +127,30 @@ int main() { assert(owner.replies.back()=="AutoHuntGather 11 done 0"); sec.safe=false; owner.replies.clear(); do_autohunt_pull(&owner,"21 5000 0 12"); - do_autohunt_pull(&owner,"3 5001 0 12"); + do_autohunt_pull(&owner,"3 15001 0 12"); do_autohunt_pull(&owner,"3 5000 0 12 extra"); assert(owner.replies.empty()); + // Large ranges visit distant sectors and extend only the selected owner's pursuit. + Sectree distant; + Character far; far.vid=1000; far.x=14000; far.sec=&distant; + distant.entities.push_back(&far); + SECTREE_MANAGER::instance().map.cells[{2,0}]=&distant; + CHARACTER_MANAGER::instance().mobs[far.vid]=&far; + sec.entities.clear(); now+=10; + do_autohunt_pull(&owner,"20 10000 0 13"); + assert(owner.replies.back()=="AutoHuntGather 13 done 0" && !far.victim); + now+=10; + do_autohunt_pull(&owner,"20 15000 0 14"); + assert(owner.replies.back()=="AutoHuntGather 14 done 1" && far.victim==&owner); + assert(AutoHuntPullLeash(&far,&owner)==15001); + assert(AutoHuntPullLeash(&mobs[49],&owner)==4000); + do_autohunt_pull(&owner,"0 15000 0 15"); + assert(AutoHuntPullLeash(&far,&owner)==4000); + now+=10; do_autohunt_pull(&owner,"3 15000 0 16"); + assert(AutoHuntPullLeash(&far,&other)==4000); + now+=10; do_autohunt_pull(&owner,"3 15000 0 17"); + now+=180; + assert(AutoHuntPullLeash(&far,&owner)==4000); // Unequal group sizes, exact 10-group cap and nearest ordering independent of input order. std::vector candidates; for(int g=24;g>=0;--g) diff --git a/tests/port/autohunt_pull_checks.inc b/tests/port/autohunt_pull_checks.inc index fe180840..b0f5f3c6 100644 --- a/tests/port/autohunt_pull_checks.inc +++ b/tests/port/autohunt_pull_checks.inc @@ -1,5 +1,13 @@ // PORT extension test: real Python settings → CG_CHAT → GC_CHAT → player state. if (server && CPythonCharacterManager::Instance().GetMainInstancePtr()) { + // Real command reply inserts distinct global/event icons without replacing character affects. + CHECK(pump_until(2, [&] { return server->Has("game2:chat 0 /server_bonuses"); })); + CHECK(py("import mt_bonuses; _bonus_bar = mt_bonuses.g_game.affectShower")); + CHECK(pump_until(2, [&] { return py_true("all(k in _bonus_bar.affectImageDict for k in (20000,20001,20002,20003,20004,20006))"); })); + CHECK(py("_bonus_image = _bonus_bar.affectImageDict[20000]; assert _bonus_image.IsShow(); assert _bonus_image.description == mt_bonuses.L(mt_bonuses.ENTRIES[0][0] % 200)")); + CHECK(py("mt_bonuses.g_game.BINARY_ServerCommand_Run('ServerBonuses 100 100 100 100 0 0 0 0'); assert not any(k >= 20000 for k in _bonus_bar.affectImageDict)")); + CHECK(py("mt_bonuses.g_game.BINARY_ServerCommand_Run('ServerBonuses 300 100 100 100 0 0 0 0'); assert _bonus_bar.affectImageDict[20000].description == mt_bonuses.L(mt_bonuses.ENTRIES[0][0] % 300)")); + CHECK(py("mt_bonuses.g_game.BINARY_ServerCommand_Run('ServerBonuses bad'); assert 20000 in _bonus_bar.affectImageDict")); auto* main_instance = CPythonCharacterManager::Instance().GetMainInstancePtr(); CPythonPlayer& player = CPythonPlayer::Instance(); const auto savedConfig = player.AutoHunt_GetConfig(); @@ -17,17 +25,59 @@ if (server && CPythonCharacterManager::Instance().GetMainInstancePtr()) { main_instance->NEW_Stop(); main_instance->NEW_SetPixelPosition(pullPosition); CHECK(!main_instance->IsInSafe()); - CHECK(py("player.AutoHuntSetConfig(5000, 0, 0, 0, 0, 0, 0, 3); player.AutoHuntSetEnabled(1)")); + CHECK(py("player.AutoHuntSetConfig(3500, 0, 0, 0, 0, 0, 0, 3); player.AutoHuntSetEnabled(1)")); TPixelPosition origin = {}; main_instance->NEW_GetPixelPosition(&origin); CHECK(pump_until(2, [&] { - return server->Has("game2:chat 0 /autohunt_pull 3 5000 0 1") && + return server->Has("game2:chat 0 /autohunt_pull 3 3500 0 1") && player.AutoHunt_GetGatheredCount() == 1 && !player.AutoHunt_IsGathering(); })); TPixelPosition current = {}; main_instance->NEW_GetPixelPosition(¤t); CHECK(std::hypot(current.x-origin.x, current.y-origin.y) < 5.0f); + TPixelPosition originalMobPosition = {}; + pullMob->NEW_GetPixelPosition(&originalMobPosition); + TPixelPosition closeMobPosition = origin; + closeMobPosition.x += 100; + pullMob->NEW_Stop(); + pullMob->NEW_SetPixelPosition(closeMobPosition); + main_instance->NEW_Stop(); + main_instance->NEW_SetPixelPosition(origin); + // A farther stone outranks the nearer pulled monster, even with bosses disabled. + CInstanceBase::SCreateData stoneData = {}; + stoneData.m_bType = CActorInstance::TYPE_STONE; + stoneData.m_dwRace = 8001; + stoneData.m_dwVID = 0x700001; + stoneData.m_dwMovSpd = 100; + stoneData.m_dwAtkSpd = 100; + stoneData.m_lPosX = LONG(origin.x + 400); + stoneData.m_lPosY = LONG(origin.y); + stoneData.m_stName = "AutoHuntStoneFixture"; + auto* stone = CPythonCharacterManager::Instance().CreateInstance(stoneData); + CHECK(stone && stone->IsStone() && !stone->IsInSafe()); + if (stone) { + auto cfg = player.AutoHunt_GetConfig(); + cfg.bAttackStone = true; + player.AutoHunt_SetConfig(cfg); + CHECK(pump_until(2, [&] { return player.GetTargetVID() == stoneData.m_dwVID; })); + player.AutoHunt_SetEnabled(false); + cfg.iGatherCount = 0; + cfg.bAttackStone = false; + player.AutoHunt_SetConfig(cfg); + player.AutoHunt_SetEnabled(true); + CHECK(pump_until(2, [&] { return player.GetTargetVID() == FakeLoginServer::kMobVID; })); + // Changing the option while already fighting takes effect immediately. + cfg.bAttackStone = true; + player.AutoHunt_SetConfig(cfg); + CHECK(pump_until(2, [&] { return player.GetTargetVID() == stoneData.m_dwVID; })); + player.AutoHunt_SetEnabled(false); + CPythonCharacterManager::Instance().DeleteInstance(stoneData.m_dwVID); + player.AutoHunt_SetEnabled(true); + CHECK(pump_until(2, [&] { return player.GetTargetVID() == FakeLoginServer::kMobVID; })); + } player.AutoHunt_SetEnabled(false); + pullMob->NEW_Stop(); + pullMob->NEW_SetPixelPosition(originalMobPosition); CHECK(player.AutoHunt_HandleServerCommand("AutoHuntGather 1 mob 123")); CHECK(player.AutoHunt_GetGatheredCount() == 0); // Old replies must not complete a new request; cooldown remains across toggles. @@ -35,6 +85,18 @@ if (server && CPythonCharacterManager::Instance().GetMainInstancePtr()) { CHECK(player.AutoHunt_HandleServerCommand("AutoHuntGather 1 done 20")); CHECK(player.AutoHunt_GetGatheredCount() == 0); player.AutoHunt_SetEnabled(false); + CHECK(py("player.AutoHuntSetConfig(10000, 0, 0, 0, 0, 0, 0, 0)")); + CHECK(player.AutoHunt_GetConfig().iRange == 10000); + CHECK(py("player.AutoHuntSetConfig(15000, 0, 0, 0, 0, 0, 0, 0)")); + CHECK(player.AutoHunt_GetConfig().iRange == 15000); + TPixelPosition farPosition = origin; + farPosition.x += 2000; + main_instance->NEW_Stop(); + main_instance->NEW_SetPixelPosition(farPosition); + CHECK(py("player.AutoHuntSetConfig(15000, 0, 0, 0, 0, 0, 0, 20); player.AutoHuntSetEnabled(1)")); + CHECK(player.AutoHunt_GetConfig().iRange == 15000); + CHECK(pump_until(12, [&] { return server->Has("game2:chat 0 /autohunt_pull 20 15000 0 2"); })); + player.AutoHunt_SetEnabled(false); player.AutoHunt_SetConfig(savedConfig); player.SetStatus(POINT_HP, hp); player.SetStatus(POINT_MAX_HP, maxHP); diff --git a/tests/port/port_login_flow_server.cpp b/tests/port/port_login_flow_server.cpp index d1f58ce3..78c5ffb7 100644 --- a/tests/port/port_login_flow_server.cpp +++ b/tests/port/port_login_flow_server.cpp @@ -718,10 +718,10 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) // A ping after the burst proves that GamePhase consumed every packet above and can reply. Blank ping = {HDR_GC_PING}; - Blank pong = {}; - if (!c.Send(ping) || read_packet(c, HDR_CG_PONG, &pong, sizeof(pong), &error) != 1) - return Fail(name + ": CG_PONG after the entergame burst: " + error); - Event(name + ":burst_pong"); + if (!c.Send(ping)) return Fail(name + ": send burst ping"); + // A slow phone can request status while still processing the initial burst. + // Real CInputMain permits chat commands before this heartbeat reply too. + bool burst_pong = false; // Then hold the connection until the test stops us, reading what the game phase sends the way // CInputProcessor::Process frames it (static size or TDynamicSizePacketHeader, then the sequence @@ -785,6 +785,10 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) return Fail(name + ": SEQUENCE mismatch on CG header " + std::to_string(header)); c.sequence = (c.sequence + 1) % SEQUENCE_TABLE_SIZE; } + if (header == HDR_CG_PONG) { + if (!burst_pong) { Event(name + ":burst_pong"); burst_pong = true; } + continue; + } // CInputMain::ItemPickup → CHARACTER::PickupItem → CItem::RemoveFromGround → EncodeRemovePacket. if (header == HDR_CG_ITEM_PICKUP) { @@ -864,11 +868,17 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) return Fail(name + ": send GC_SAFEBOX_SET"); } } + else if (text == "/server_bonuses") { + const std::string cmd = "ServerBonuses 200 200 200 200 200 0 200 0"; + GCChatHead chat = {HDR_GC_CHAT, static_cast(sizeof(GCChatHead) + cmd.size() + 1), 5, 0, 1}; + if (!c.Send(chat) || !c.Send(cmd.c_str(), cmd.size() + 1)) return Fail(name + ": server bonuses reply"); + } else if (text.rfind("/autohunt_pull ", 0) == 0) { int groups = 0, radius = 0, bosses = 0; unsigned request = 0; if (sscanf(text.c_str() + 15, "%d %d %d %u", &groups, &radius, &bosses, &request) != 4) return Fail(name + ": malformed autohunt pull"); + if (!groups) continue; for (const std::string& cmd : {"AutoHuntGather " + std::to_string(request) + " mob " + std::to_string(kMobVID), "AutoHuntGather " + std::to_string(request) + " done 1"}) { GCChatHead chat = {HDR_GC_CHAT, static_cast(sizeof(GCChatHead) + cmd.size() + 1), 5, 0, 1}; diff --git a/tests/port/port_login_flow_test.cpp b/tests/port/port_login_flow_test.cpp index d2c0669a..a9806170 100644 --- a/tests/port/port_login_flow_test.cpp +++ b/tests/port/port_login_flow_test.cpp @@ -393,9 +393,9 @@ int main(int argc, char** argv) }); CHECK(loading && !failed()); CHECK(py_true(loaded)); - // The loading window already warped the map; the touch host's joystick and skill buttons wait for - // the game phase. - CHECK(!PythonBoot::InGamePhase() && !PythonBoot::CurrentMapName().empty()); + // The map is available during loading. A fast server may have already switched the + // network phase to Game before the loading window closes on the next script frame. + CHECK(!PythonBoot::CurrentMapName().empty()); // 6. introLoading's update steps end in net.StartGame(); the next CPythonNetworkStream::Process calls // networkModule's SetGamePhase, which builds game.GameWindow (interfaceModule.MakeInterface) and diff --git a/tools/server/autohunt-pull-range/README.md b/tools/server/autohunt-pull-range/README.md new file mode 100644 index 00000000..5704abcd --- /dev/null +++ b/tools/server/autohunt-pull-range/README.md @@ -0,0 +1,31 @@ +# 自动狩猎范围与服务器加成状态 + +2026-09-30 用户要求:引怪追击距离跟随狩猎距离;游戏左上角补充服务器经验、金币等加成。 + +本补丁在 `autohunt-pull/server.patch` 之后应用,基础补丁保持字节不变。 +`../rebuild.sh check/build/deploy` 已处理增量补丁,并强制重新编译包含命令头文件的 `cmd_general.cpp`。 + +引怪支持 35/100/150 米,地图分区按半径完整遍历并使用快照;不扫描整张地图。 +只有本命令引来的怪物获得指定玩家的临时追击距离,原有 40 米战斗目标限制和 50 米离巢限制 +在这一上下文中扩展。停止狩猎取消扩展,180 秒不刷新自动过期,换目标/地图后恢复正常追击。 +原披风道具及其他怪物 AI 保持不变;寻路仍受地图阻挡和怪物自身移动规则约束。 + +客户端每 15 秒请求 `/server_bonuses`;服务器按角色限制查询频率为至少 2 秒。 +响应 `ServerBonuses <经验倍率> <金币数量倍率> <金币掉落倍率> <物品掉落倍率> <经验活动加成> <物品活动加成> <金币活动加成> <金币礼包活动加成>`。 +前四项来自 `CHARACTER_MANAGER` 的全局实际倍率,后四项来自 `CPrivManager::GetPriv`, +与奖励结算使用的活动数据来源一致,包含适用于当前角色的国家、公会和角色特权。 +全局 200% 与活动额外 +200% 分别显示,不假定它们相同,也不伪造持续时间。 +标准 100% 倍率和零活动加成不显示;活动结束、变化和重新登录会更新图标。 + +客户端通过 `ServerBonusScript.inc` 的 PORT 模块向原有增益栏添加独立图标及中文提示。 +原版角色/技能增益不被覆盖,打包的 `uiaffectshower.py` 和 `game.py` 保持不变。 +Android 点击图标可看到提示,macOS 悬停显示;FPS 移到顶部中间避免遮挡图标。 + +203 源文件备份:`/root/autohunt-range-20260930/`,隔离构建:`/root/rebuild/20261001-074644/`。 +部署及二进制回滚使用 `rebuild.sh`,不需要数据库迁移。 +旧停服脚本可能使用缺失的 PID 文件:重建脚本对剩余进程补发 TERM 并等待正常退出,未使用强制终止。 + +验证:`host.autohunt_pull` 覆盖远处分区、100/150 米边界、指定玩家追击、取消及过期。 +`port.login_flow` 覆盖真实 Python 配置和聊天封包链、石头优先与回退、加成图标新增/更新/移除; +桌面及 `MT_TEST_MOBILE=1` 均通过。Mi 8 Lite 真机在模拟服务器场景显示图标和“服务器经验 200%”提示。 +macOS app 与 Android Release native/debug APK 已构建;APK 已安装到 63c36adb。 diff --git a/tools/server/autohunt-pull-range/autohunt_pull_range.h b/tools/server/autohunt-pull-range/autohunt_pull_range.h new file mode 100644 index 00000000..de3370a5 --- /dev/null +++ b/tools/server/autohunt-pull-range/autohunt_pull_range.h @@ -0,0 +1,3 @@ +#pragma once +// PORT extension: ordinary monster pursuit remains 40 metres. +int AutoHuntPullLeash(LPCHARACTER mob, LPCHARACTER victim); diff --git a/tools/server/autohunt-pull-range/server.patch b/tools/server/autohunt-pull-range/server.patch new file mode 100644 index 00000000..c23b16b2 --- /dev/null +++ b/tools/server/autohunt-pull-range/server.patch @@ -0,0 +1,176 @@ +--- a/server/game/src/sectree_manager.h ++++ b/server/game/src/sectree_manager.h +@@ -88,6 +88,18 @@ + } + */ + } ++ ++ // PORT: snapshot traversal for the complete auto-hunt radius. ++ template ++ void for_each_in_rect(DWORD minX, DWORD minY, DWORD maxX, DWORD maxY, Func& func) { ++ FCollectEntity collector; ++ for (DWORD x = minX / SECTREE_SIZE * SECTREE_SIZE; x <= maxX; x += SECTREE_SIZE) ++ for (DWORD y = minY / SECTREE_SIZE * SECTREE_SIZE; y <= maxY; y += SECTREE_SIZE) { ++ LPSECTREE tree = Find(x, y); ++ if (tree) tree->for_each_entity(collector); ++ } ++ collector.ForEach(func); ++ } + + void DumpAllToSysErr() { + SECTREE_MAP::MapType::iterator i; +--- a/server/game/src/autohunt_pull.h ++++ b/server/game/src/autohunt_pull.h +@@ -3,8 +3,37 @@ + #include "group_select.h" + #else + #include "sectree_manager.h" ++#include "autohunt_pull_range.h" + #include "autohunt_group_select.h" + #endif ++ ++// PORT: only mobs pulled by this command receive a temporary extended pursuit leash. ++struct AutoHuntPullLease { ++ LPCHARACTER mob; ++ DWORD ownerPID; ++ int radius; ++ int expires; ++}; ++static std::map s_autoHuntPullLeases; ++int AutoHuntPullLeash(LPCHARACTER mob, LPCHARACTER victim) { ++ std::map::iterator it = s_autoHuntPullLeases.find((DWORD)mob->GetVID()); ++ if (it == s_autoHuntPullLeases.end()) return 4000; ++ const AutoHuntPullLease& lease = it->second; ++ if (lease.mob != mob || !victim || !victim->IsPC() || victim->IsDead() || ++ victim->GetPlayerID() != lease.ownerPID || mob->GetMapIndex() != victim->GetMapIndex() || ++ get_global_time() >= lease.expires) { ++ s_autoHuntPullLeases.erase(it); ++ return 4000; ++ } ++ return std::max(4000, lease.radius + 1); ++} ++static void AutoHuntPullPrune(DWORD cancelPID = 0) { ++ for (std::map::iterator it = s_autoHuntPullLeases.begin(); it != s_autoHuntPullLeases.end();) { ++ if (it->second.expires <= get_global_time() || (cancelPID && it->second.ownerPID == cancelPID)) ++ s_autoHuntPullLeases.erase(it++); ++ else ++it; ++ } ++} + + struct AutoHuntPullCollector { + LPCHARACTER owner; +@@ -35,8 +64,11 @@ + unsigned int request = 0; + char extra = 0; + if (sscanf(argument, "%d %d %d %u %c", &groups, &radius, &bosses, &request, &extra) != 4 || +- (groups != 3 && groups != 10 && groups != 20) || radius < 500 || radius > 5000 || ++ (groups != 0 && groups != 3 && groups != 10 && groups != 20) || radius < 500 || radius > 15000 || + (bosses != 0 && bosses != 1)) return; ++ if (!ch->IsPC()) return; ++ AutoHuntPullPrune(groups == 0 ? ch->GetPlayerID() : 0); ++ if (groups == 0) return; + const int now = get_global_time(); + if (!ch->IsPC() || ch->IsDead() || ch->IsStun() || !ch->CanHandleItem() || !ch->GetSectree() || + (ch->GetSectree()->GetAttribute(ch->GetX(), ch->GetY()) & ATTR_BANPK) || +@@ -47,13 +79,23 @@ + // Server-side cooldown survives reconnects and switching auto hunt on/off. + ch->SetQuestFlag("autohunt.next_pull", now + 10); + AutoHuntPullCollector collector(ch, radius, bosses != 0); +- ch->GetSectree()->ForEachAround(collector); ++ // ForEachAround only covers nine cells: visit every cell intersecting the requested radius. ++ LPSECTREE_MAP map = SECTREE_MANAGER::instance().GetMap(ch->GetMapIndex()); ++ if (map) map->for_each_in_rect(std::max(0, int(ch->GetX()) - radius), ++ std::max(0, int(ch->GetY()) - radius), ch->GetX() + radius, ch->GetY() + radius, collector); + const mt_autohunt::Selection selected = mt_autohunt::SelectGroups(collector.candidates, groups); + for (size_t i = 0; i < selected.mobs.size(); ++i) { + LPCHARACTER mob = CHARACTER_MANAGER::instance().Find(selected.mobs[i]); + if (!mob) continue; ++ if (mob->IsDead() || (mob->GetVictim() && mob->GetVictim() != ch)) continue; ++ AutoHuntPullLease lease = {mob, ch->GetPlayerID(), radius, now + 180}; ++ s_autoHuntPullLeases[(DWORD)mob->GetVID()] = lease; + if (!mob->GetVictim()) mob->BeginFight(ch); + ch->ChatPacket(CHAT_TYPE_COMMAND, "AutoHuntGather %u mob %u", request, selected.mobs[i]); + } + ch->ChatPacket(CHAT_TYPE_COMMAND, "AutoHuntGather %u done %d", request, selected.groups); + } ++ ++#ifndef MT_AUTOHUNT_SERVER_TEST ++#include "server_bonuses.h" ++#endif +--- a/server/game/src/char_state.cpp ++++ b/server/game/src/char_state.cpp +@@ -3,6 +3,7 @@ + #include "utils.h" + #include "vector.h" + #include "char.h" ++#include "autohunt_pull_range.h" + #include "battle.h" + #include "char_manager.h" + #include "packet.h" +@@ -1035,7 +1036,7 @@ + + float fDist = DISTANCE_APPROX(GetX() - victim->GetX(), GetY() - victim->GetY()); + +- if (fDist >= 4000.0f) // 40¹ÌÅÍ À̻ó ¸־îÁö¸é Æ÷±â ++ if (fDist >= AutoHuntPullLeash(this, victim)) // 40¹ÌÅÍ À̻ó ¸־îÁö¸é Æ÷±â + { + MonsterLog("Ÿ°ÙÀÌ ¸־ Æ÷±â"); + SetVictim(NULL); +--- a/server/game/src/cmd.cpp ++++ b/server/game/src/cmd.cpp +@@ -7,6 +7,7 @@ + #include "desc.h" + + ACMD(do_autohunt_pull); ++ACMD(do_server_bonuses); + ACMD(do_user_horse_ride); + ACMD(do_user_horse_back); + ACMD(do_user_horse_feed); +@@ -281,6 +282,7 @@ + { + { "!RESERVED!", NULL, 0, POS_DEAD, GM_IMPLEMENTOR }, /* ¹ݵå½Ã ÀÌ °ÍÀÌ óÀ½À̾î¾ß ÇѴÙ. */ + { "autohunt_pull", do_autohunt_pull, 0, POS_STANDING, GM_PLAYER }, ++ { "server_bonuses", do_server_bonuses, 0, POS_DEAD, GM_PLAYER }, + { "who", do_who, 0, POS_DEAD, GM_IMPLEMENTOR }, + { "war", do_war, 0, POS_DEAD, GM_PLAYER }, + { "warp", do_warp, 0, POS_DEAD, GM_LOW_WIZARD }, +--- /dev/null ++++ b/server/game/src/autohunt_pull_range.h +@@ -0,0 +1,3 @@ ++#pragma once ++// PORT extension: ordinary monster pursuit remains 40 metres. ++int AutoHuntPullLeash(LPCHARACTER mob, LPCHARACTER victim); +--- /dev/null ++++ b/server/game/src/server_bonuses.h +@@ -0,0 +1,12 @@ ++// PORT: report actual global rates and the character's effective event privileges. ++#include "priv_manager.h" ++ACMD(do_server_bonuses) { ++ if (!ch->IsPC() || get_global_time() < ch->GetQuestFlag("server_bonuses.next_query")) return; ++ ch->SetQuestFlag("server_bonuses.next_query", get_global_time() + 2); ++ CHARACTER_MANAGER& cm = CHARACTER_MANAGER::instance(); ++ CPrivManager& pm = CPrivManager::instance(); ++ ch->ChatPacket(CHAT_TYPE_COMMAND, "ServerBonuses %d %d %d %d %d %d %d %d", ++ cm.GetMobExpRate(NULL), cm.GetMobGoldAmountRate(NULL), cm.GetMobGoldDropRate(NULL), cm.GetMobItemRate(NULL), ++ pm.GetPriv(ch, PRIV_EXP_PCT), pm.GetPriv(ch, PRIV_ITEM_DROP), ++ pm.GetPriv(ch, PRIV_GOLD_DROP), pm.GetPriv(ch, PRIV_GOLD10_DROP)); ++} +--- a/server/game/src/char.cpp ++++ b/server/game/src/char.cpp +@@ -4,6 +4,7 @@ + #include "../../common/VnumHelper.h" + + #include "char.h" ++#include "autohunt_pull_range.h" + + #include "config.h" + #include "utils.h" +@@ -5440,7 +5441,7 @@ + if (get_dword_time() - m_pkMobInst->m_dwLastAttackedTime >= 15000) // ¸¶Áö¸·À¸·Î °ø°ݹÞÀºÁö 15Ãʰ¡ Áö³µ°í + { + // ¸¶Áö¸· ¸ÂÀº °÷À¸·Î ºÎÅÍ 50¹ÌÅÍ À̻ó Â÷À̳ª¸é Æ÷±âÇϰí µ¹¾ư£´Ù. +- if (5000 < DISTANCE_APPROX(m_pkMobInst->m_posLastAttacked.x - GetX(), m_pkMobInst->m_posLastAttacked.y - GetY())) ++ if (MAX(5000, AutoHuntPullLeash(this, pkChr)) < DISTANCE_APPROX(m_pkMobInst->m_posLastAttacked.x - GetX(), m_pkMobInst->m_posLastAttacked.y - GetY())) + if (Return()) + return true; + } diff --git a/tools/server/autohunt-pull-range/server_bonuses.h b/tools/server/autohunt-pull-range/server_bonuses.h new file mode 100644 index 00000000..ccda33b6 --- /dev/null +++ b/tools/server/autohunt-pull-range/server_bonuses.h @@ -0,0 +1,12 @@ +// PORT: report actual global rates and the character's effective event privileges. +#include "priv_manager.h" +ACMD(do_server_bonuses) { + if (!ch->IsPC() || get_global_time() < ch->GetQuestFlag("server_bonuses.next_query")) return; + ch->SetQuestFlag("server_bonuses.next_query", get_global_time() + 2); + CHARACTER_MANAGER& cm = CHARACTER_MANAGER::instance(); + CPrivManager& pm = CPrivManager::instance(); + ch->ChatPacket(CHAT_TYPE_COMMAND, "ServerBonuses %d %d %d %d %d %d %d %d", + cm.GetMobExpRate(NULL), cm.GetMobGoldAmountRate(NULL), cm.GetMobGoldDropRate(NULL), cm.GetMobItemRate(NULL), + pm.GetPriv(ch, PRIV_EXP_PCT), pm.GetPriv(ch, PRIV_ITEM_DROP), + pm.GetPriv(ch, PRIV_GOLD_DROP), pm.GetPriv(ch, PRIV_GOLD10_DROP)); +} diff --git a/tools/server/autohunt-pull/README.md b/tools/server/autohunt-pull/README.md index 251ffb0e..46a81c28 100644 --- a/tools/server/autohunt-pull/README.md +++ b/tools/server/autohunt-pull/README.md @@ -5,22 +5,32 @@ 原披风的道具消耗和随机拉怪行为保持不变;本功能按距离确定性选择最多 N 个队伍。 “组”是服务端实际的怪物 `CParty`,没有队伍的单只怪物算一组。同一队伍在范围内的成员一起拉取。 -不足所选组数时拉取实际可用的组数。范围使用自动狩猎的范围设置,上限 5000 cm。 +不足所选组数时拉取实际可用的组数。狩猎边界、披风引怪半径和扩展追击距离跟随 35/100/150 米设置。 +服务端支持 500..15000 cm,并搜索半径覆盖的全部地图分区。普通怪物原有的 40 米追击限制保持不变; +被本命令选中的怪物获得对应玩家专属的临时追击距离,并同步扩展原有 50 米离巢返回限制。 +每次引怪刷新 180 秒有效期,停止狩猎或修改范围/引怪选项会取消本玩家的扩展追击距离。 安全区、死亡/眩晕、低于 50% HP 或不能操作道具时不引怪;默认不拉首领。 队伍在范围内存在首领或正在攻击其他玩家的成员时,整组跳过。 -请求 `/autohunt_pull <3|10|20> <500..5000> ` 走现有 CG_CHAT。 +请求 `/autohunt_pull <3|10|20> <500..15000> ` 走现有 CG_CHAT。 +组数为 0 时取消本玩家的扩展追击距离,不重置冷却。 服务端每次发 `AutoHuntGather mob `,最后发 `AutoHuntGather done `, 走现有 CHAT_TYPE_COMMAND。每个消息很短,不受一条聊天封包长度限制。 客户端只接受当前请求的回包;停用、手动暂停后旧回包不生效。 双方有 10 秒冷却,服务端冷却以角色任务标记保存,重新登录或开关狩猎不能绕过。 -客户端原地等待追击怪物进入近战距离,最多等待 8 秒;3 秒无回包则继续普通狩猎。 +客户端搜索时原地等待追击怪物进入近战距离,最多等待 8 秒;3 秒无回包则继续普通狩猎。 +战斗中也会在 10 秒冷却后补拉,不必先杀完整批怪物;补拉不打断当前攻击。 +尚未进入客户端视野的服务端确认 VID 按距离保留 35/100/150 秒,给怪物时间接近玩家。 +界面显示本轮服务端确认的实际组数/设置组数,附近不足 20 组时不会产生额外怪物。 +“优先石头”优先选择狩猎范围内的石头,没有石头再攻击普通怪物;石头不受首领开关过滤。 +战斗中更改选项或出现石头时可以切换目标,优先攻击石头期间不发起新的引怪。 ## 构建和回滚 `server.patch` 以 40250 的 `/usr/metin2/src` 为根,`patch -p1` 应用。 -`autohunt_pull.h` 和 `group_select.h` 是补丁中两份新增头文件的源文件。 -`../rebuild.sh` 已加入本补丁,沿用 build / deploy 流程。 +`group_select.h` 是基础补丁中新增的队伍选择头文件,`autohunt_pull.h` 保存当前扩展版本。 +基础补丁保持不变,范围扩展在 `../autohunt-pull-range/server.patch`,须按顺序应用。 +`../rebuild.sh` 已加入这两个补丁,沿用 build / deploy 流程,并处理基础头文件被后续补丁更新的状态检查。 2026-09-30 已在 192.168.21.203 编译并部署。原 `cmd.cpp`、`cmd_general.cpp`、旧 game 备份在 `/root/autohunt-pull-20260930/`(旧程序为 `game.before`)。回滚时停 game、恢复旧程序后启动; @@ -31,3 +41,15 @@ 桌面和移动模式均通过。真服临时角色实测回包为 **3 组、9 只怪物**,随后进入自动战斗。 临时角色、账号和关联数据已在刷新对应 DB 缓存后清理;auth/game/db 监听已检查。 macOS 原生包及 Android debug APK 已重新构建。 + +## 2026-09-30 修正 + +修复了石头与首领等级过滤混用、普通怪物优先于石头、必须清空旧怪物批次才再次引怪、 +远处尚未同步的怪物 VID 被立即删除,以及近距离范围让 20 组请求只搜索很小区域的问题。 +狩猎边界增加至 35/100/150 米,旧设置低于 35 米会提升到 35 米;3/10/20 组按当前狩猎半径选择最近队伍。 +狩猎边界用于已同步角色的目标选择,超过当前视野的怪物需要先进入服务器同步范围。 +范围扩展需要重新构建和部署服务端。 + +回归测试 `port.login_flow` 使用真实 Python 配置和消息链,覆盖较远石头优先于近处已引来的怪物、 +关闭优先石头、战斗中开启优先石头、没有石头时回退打怪,以及 35/100/150 米设置和 20 组随狩猎距离拉怪。 +`host.autohunt_pull` 继续验证服务端 3/10/20 个真实队伍、冷却和队伍过滤。 diff --git a/tools/server/autohunt-pull/autohunt_pull.h b/tools/server/autohunt-pull/autohunt_pull.h index d6d551a5..3bf8eedd 100644 --- a/tools/server/autohunt-pull/autohunt_pull.h +++ b/tools/server/autohunt-pull/autohunt_pull.h @@ -3,9 +3,38 @@ #include "group_select.h" #else #include "sectree_manager.h" +#include "autohunt_pull_range.h" #include "autohunt_group_select.h" #endif +// PORT: only mobs pulled by this command receive a temporary extended pursuit leash. +struct AutoHuntPullLease { + LPCHARACTER mob; + DWORD ownerPID; + int radius; + int expires; +}; +static std::map s_autoHuntPullLeases; +int AutoHuntPullLeash(LPCHARACTER mob, LPCHARACTER victim) { + std::map::iterator it = s_autoHuntPullLeases.find((DWORD)mob->GetVID()); + if (it == s_autoHuntPullLeases.end()) return 4000; + const AutoHuntPullLease& lease = it->second; + if (lease.mob != mob || !victim || !victim->IsPC() || victim->IsDead() || + victim->GetPlayerID() != lease.ownerPID || mob->GetMapIndex() != victim->GetMapIndex() || + get_global_time() >= lease.expires) { + s_autoHuntPullLeases.erase(it); + return 4000; + } + return std::max(4000, lease.radius + 1); +} +static void AutoHuntPullPrune(DWORD cancelPID = 0) { + for (std::map::iterator it = s_autoHuntPullLeases.begin(); it != s_autoHuntPullLeases.end();) { + if (it->second.expires <= get_global_time() || (cancelPID && it->second.ownerPID == cancelPID)) + s_autoHuntPullLeases.erase(it++); + else ++it; + } +} + struct AutoHuntPullCollector { LPCHARACTER owner; int radius; @@ -35,8 +64,11 @@ ACMD(do_autohunt_pull) { unsigned int request = 0; char extra = 0; if (sscanf(argument, "%d %d %d %u %c", &groups, &radius, &bosses, &request, &extra) != 4 || - (groups != 3 && groups != 10 && groups != 20) || radius < 500 || radius > 5000 || + (groups != 0 && groups != 3 && groups != 10 && groups != 20) || radius < 500 || radius > 15000 || (bosses != 0 && bosses != 1)) return; + if (!ch->IsPC()) return; + AutoHuntPullPrune(groups == 0 ? ch->GetPlayerID() : 0); + if (groups == 0) return; const int now = get_global_time(); if (!ch->IsPC() || ch->IsDead() || ch->IsStun() || !ch->CanHandleItem() || !ch->GetSectree() || (ch->GetSectree()->GetAttribute(ch->GetX(), ch->GetY()) & ATTR_BANPK) || @@ -47,13 +79,23 @@ ACMD(do_autohunt_pull) { // Server-side cooldown survives reconnects and switching auto hunt on/off. ch->SetQuestFlag("autohunt.next_pull", now + 10); AutoHuntPullCollector collector(ch, radius, bosses != 0); - ch->GetSectree()->ForEachAround(collector); + // ForEachAround only covers nine cells: visit every cell intersecting the requested radius. + LPSECTREE_MAP map = SECTREE_MANAGER::instance().GetMap(ch->GetMapIndex()); + if (map) map->for_each_in_rect(std::max(0, int(ch->GetX()) - radius), + std::max(0, int(ch->GetY()) - radius), ch->GetX() + radius, ch->GetY() + radius, collector); const mt_autohunt::Selection selected = mt_autohunt::SelectGroups(collector.candidates, groups); for (size_t i = 0; i < selected.mobs.size(); ++i) { LPCHARACTER mob = CHARACTER_MANAGER::instance().Find(selected.mobs[i]); if (!mob) continue; + if (mob->IsDead() || (mob->GetVictim() && mob->GetVictim() != ch)) continue; + AutoHuntPullLease lease = {mob, ch->GetPlayerID(), radius, now + 180}; + s_autoHuntPullLeases[(DWORD)mob->GetVID()] = lease; if (!mob->GetVictim()) mob->BeginFight(ch); ch->ChatPacket(CHAT_TYPE_COMMAND, "AutoHuntGather %u mob %u", request, selected.mobs[i]); } ch->ChatPacket(CHAT_TYPE_COMMAND, "AutoHuntGather %u done %d", request, selected.groups); } + +#ifndef MT_AUTOHUNT_SERVER_TEST +#include "server_bonuses.h" +#endif diff --git a/tools/server/rebuild.sh b/tools/server/rebuild.sh index cbfa6709..1fafb7ad 100644 --- a/tools/server/rebuild.sh +++ b/tools/server/rebuild.sh @@ -20,10 +20,15 @@ set -eu SRC=${SRC:-/usr/metin2/src} SERVER=${SERVER:-/usr/metin2/server} HERE=$(cd "$(dirname "$0")" && pwd) -PATCHES="inventory-5-pages hunt-set shop-item-time autohunt-pull" +PATCHES="inventory-5-pages hunt-set shop-item-time autohunt-pull autohunt-pull-range" TS=$(date +%Y%m%d-%H%M%S) patch_state() { + # The follow-up changes the header added by the base patch. + if [ "$1" = autohunt-pull ] && patch -p1 -R -C -s -f -d "$SRC" < "$HERE/autohunt-pull-range/server.patch" > /dev/null 2>&1; then + echo applied + return + fi if patch -p1 -R -C -s -f -d "$SRC" < "$HERE/$1/server.patch" > /dev/null 2>&1; then echo applied elif patch -p1 -C -s -f -d "$SRC" < "$HERE/$1/server.patch" > /dev/null 2>&1; then @@ -36,6 +41,16 @@ patch_state() { stop_server() { (cd "$SERVER" && sh close.sh) > /dev/null 2>&1 || true sleep 8 + remaining=$(ps ax -o pid,command | awk '$2 ~ /^\.\/(game|db|auth)$/ {print $1}') + if [ -n "$remaining" ]; then + # Some old shut.sh scripts reference stale/missing pid files. Still request a graceful exit. + kill -TERM $remaining + attempts=0 + while ps ax -o command | grep -q '^[.]/\(game\|db\|auth\)' && [ "$attempts" -lt 15 ]; do + sleep 2 + attempts=$((attempts + 1)) + done + fi if ps ax -o command | grep -q '^[.]/\(game\|db\|auth\)'; then echo "server still running" >&2; exit 1; fi } @@ -58,7 +73,9 @@ build) conflict) echo "$p/server.patch does not apply" >&2; exit 1 ;; esac done - OUT=/root/rebuild/$TS + # The legacy makefile does not track includes. Recompile commands after header patches. + touch "$SRC/server/game/src/cmd_general.cpp" + OUT=/root/rebuild/$TS mkdir -p "$OUT" # The Makefiles move the binary to GAMEDIR/DBDIR (default: the live share/bin). (cd "$SRC/server/game/src" && gmake -j2 GAMEDIR="$OUT") > "$OUT/game.log" 2>&1 || { tail "$OUT/game.log" >&2; exit 1; }