From 8a6aeaedf07a46ad142e9a275e78bb8a2cdd030b Mon Sep 17 00:00:00 2001 From: shen <> Date: Wed, 30 Sep 2026 17:07:24 -0700 Subject: [PATCH] Add free cloak-style auto-hunt pull for 3 10 or 20 monster groups --- audit/history.jsonl | 1 + .../PythonNetworkStreamCommand.cpp.json | 3 +- docs/PORT-PLAN.md | 3 + src/platform/ScriptLib/AutoHuntScript.inc | 8 +- src/port/CMakeLists.txt | 2 + .../PythonNetworkStreamCommand.cpp | 3 + src/port/UserInterface/PythonPlayer.cpp | 5 +- src/port/UserInterface/PythonPlayer.h | 15 +- .../UserInterface/PythonPlayerAutoHunt.cpp | 145 ++++++++---------- tests/host/autohunt_pull_test.cpp | 120 +++++++++++++++ tests/port/autohunt_pull_checks.inc | 44 ++++++ tests/port/port_login_flow_server.cpp | 11 ++ tests/port/port_login_flow_test.cpp | 1 + tools/server/autohunt-pull/README.md | 33 ++++ tools/server/autohunt-pull/autohunt_pull.h | 59 +++++++ tools/server/autohunt-pull/group_select.h | 51 ++++++ tools/server/autohunt-pull/server.patch | 145 ++++++++++++++++++ tools/server/rebuild.sh | 5 +- 18 files changed, 558 insertions(+), 96 deletions(-) create mode 100644 tests/host/autohunt_pull_test.cpp create mode 100644 tests/port/autohunt_pull_checks.inc create mode 100644 tools/server/autohunt-pull/README.md create mode 100644 tools/server/autohunt-pull/autohunt_pull.h create mode 100644 tools/server/autohunt-pull/group_select.h create mode 100644 tools/server/autohunt-pull/server.patch diff --git a/audit/history.jsonl b/audit/history.jsonl index dae8f743..5636b292 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -646,3 +646,4 @@ {"date": "2026-09-30", "unit": "EffectLib/ParticleSystemInstance.cpp", "action": "fix-platform-adapter", "evidence": "XRGB destination alpha semantics fixed in Vulkan pipeline; focused mapping + GPU pixel regression; original particle logic unchanged; desktop/mobile login regressions and Android build pass."} {"date": "2026-09-30", "unit": "EterLib/GrpFontTexture.cpp", "action": "improve-platform-rendering", "evidence": "Retina UI clarity: dynamic font atlas rebuild preserving cached pointers and logical metrics, 8-bit HiDPI glyph alpha, physical pixel alignment and 1:1 sampling; 2406 bundled HD textures. Full native suite 29 passed, 2 skipped, macOS packaged inventory screenshots and strict signature verification. See audit/evidence/2026-09-30-mac-ui-clarity.md."} {"date": "2026-09-30", "unit": "EterLib/GrpImageInstance.cpp", "action": "improve-platform-rendering", "evidence": "Retina UI clarity: dynamic font atlas rebuild preserving cached pointers and logical metrics, 8-bit HiDPI glyph alpha, physical pixel alignment and 1:1 sampling; 2406 bundled HD textures. Full native suite 29 passed, 2 skipped, macOS packaged inventory screenshots and strict signature verification. See audit/evidence/2026-09-30-mac-ui-clarity.md."} +{"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."} diff --git a/audit/port-map/UserInterface/PythonNetworkStreamCommand.cpp.json b/audit/port-map/UserInterface/PythonNetworkStreamCommand.cpp.json index a8446bc7..fea93f92 100644 --- a/audit/port-map/UserInterface/PythonNetworkStreamCommand.cpp.json +++ b/audit/port-map/UserInterface/PythonNetworkStreamCommand.cpp.json @@ -38,7 +38,8 @@ "status": "PORTED", "impl": [ "src/port/UserInterface/PythonNetworkStreamCommand.cpp:CPythonNetworkStream::ServerCommand" - ] + ], + "note": "User-authorized auto-hunt extension consumes only AutoHuntGather replies before unchanged 40250 command handling. Current request generations reject stale/stopped replies; tests/port/autohunt_pull_checks.inc verifies Python settings and CG_CHAT/GC_CHAT through the real network stream. Original commands remain unchanged." } } } diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index 48d2f694..6fb87614 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -44,6 +44,9 @@ metin2-client 是 40250 Windows 客户端的跨平台版本。**首要目标是 | 2026-09-29 | 移动端专有功能(触控、自动狩猎、账号注册、帧率设置)以 PORT 模块形式注入,不改 40250 脚本 | 40250 脚本字节保持不变,账本 `RUN_AS_IS` 仍成立 | | 2026-09-29 | **背包扩到 5 页**(225 格,40250 为 2 页 90 格):服务端补丁 + DB 迁移在 `tools/server/inventory-5-pages/`,客户端 `c_Inventory_Page_Count = 5`、`TQuickSlot.Position` 改 WORD、`mt_inventory` 模块补 5 个页签和写死 2 页的脚本;装备/龙魂/腰带格整体后移 135,快捷栏封包长度变化,旧客户端不再兼容 | 用户要求的玩法扩展;203 已部署并实测 | +2026-09-30 用户授权扩展:自动狩猎引怪改为服务端披风式追击,免费选择 3/10/20 个实际怪物队伍; +见 `tools/server/autohunt-pull/README.md`,203 已部署并实测 3 组/9 只怪物。原披风物品保持不变。 + ## 3. 三个层和代码位置 | 层 | 40250 单元 | 做法 | 位置 | diff --git a/src/platform/ScriptLib/AutoHuntScript.inc b/src/platform/ScriptLib/AutoHuntScript.inc index c79f8e5a..8434bd26 100644 --- a/src/platform/ScriptLib/AutoHuntScript.inc +++ b/src/platform/ScriptLib/AutoHuntScript.inc @@ -36,8 +36,8 @@ RANGE_TEXTS = ("近", "中", "远") HP_VALUES = (0, 30, 50, 70) SP_VALUES = (0, 20, 40, 60) PICKUP_TEXTS = ("不拾取", "全部", "金币装备") -GATHER_VALUES = (0, 3, 5, 8) -GATHER_TEXTS = ("关", "3只", "5只", "8只") +GATHER_VALUES = (0, 3, 10, 20) +GATHER_TEXTS = ("关", "3组", "10组", "20组") SMALL_BUTTON = "d:/ymir work/ui/public/small_button_%02d.sub" MIDDLE_BUTTON = "d:/ymir work/ui/public/middle_button_%02d.sub" @@ -160,7 +160,7 @@ class SettingDialog(ui.BoardWithTitleBar): y += 28 self.pickupRow = ChoiceRow(self, y, "拾取", PICKUP_TEXTS, MIDDLE_BUTTON, 66, self.__Save) y += 28 - self.gatherRow = ChoiceRow(self, y, "聚怪", GATHER_TEXTS, SMALL_BUTTON, 48, self.__Save) + self.gatherRow = ChoiceRow(self, y, "披风引怪", GATHER_TEXTS, SMALL_BUTTON, 48, self.__Save) y += 28 self.skillLabel = ui.TextLine() @@ -297,7 +297,7 @@ class Ticker(ui.Window): if stateIndex != STATE_OFF: text = L("自动狩猎 · ") + L(STATE_TEXT.get(stateIndex, "")) if gathering: - text = L("自动狩猎 · 聚怪中 %d/%d" % (gathered, player.AutoHuntGetConfig()[7])) + text = L("自动狩猎 · 披风引怪中(最多%d组)" % player.AutoHuntGetConfig()[7]) if noHP: text += L(" · 无红药") if noSP: diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt index a85f285f..1d25d3d9 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -196,6 +196,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) add_executable(port_text_test ${PROJECT_SOURCE_DIR}/tests/port/port_text_test.cpp) add_executable(host_ui_quality_test ${PROJECT_SOURCE_DIR}/tests/host/ui_quality_test.cpp) add_test(NAME host.ui_quality COMMAND host_ui_quality_test) + add_executable(host_autohunt_pull_test ${PROJECT_SOURCE_DIR}/tests/host/autohunt_pull_test.cpp) + add_test(NAME host.autohunt_pull COMMAND host_autohunt_pull_test) target_link_libraries(port_text_test PRIVATE port_platform) add_test(NAME port.text COMMAND $) set_tests_properties(port.text PROPERTIES SKIP_RETURN_CODE 77) diff --git a/src/port/UserInterface/PythonNetworkStreamCommand.cpp b/src/port/UserInterface/PythonNetworkStreamCommand.cpp index 694e070e..b0a7d466 100644 --- a/src/port/UserInterface/PythonNetworkStreamCommand.cpp +++ b/src/port/UserInterface/PythonNetworkStreamCommand.cpp @@ -1,4 +1,5 @@ #include "StdAfx.h" +#include "PythonPlayer.h" // PORT: auto-hunt pull acknowledgement #include "PythonNetworkStream.h" #include "PythonNonPlayer.h" #include "AbstractApplication.h" @@ -131,6 +132,8 @@ bool SplitToken(const char * c_szLine, CTokenVector * pstTokenVector, const char void CPythonNetworkStream::ServerCommand(char * c_szCommand) { + // PORT: cloak pulling belongs to the auto-hunt extension, not the unchanged packed scripts. + if (CPythonPlayer::Instance().AutoHunt_HandleServerCommand(c_szCommand)) return; // #0000811: [M2EU] 콘솔창 기능 차단 if (strcmpi(c_szCommand, "ConsoleEnable") == 0) return; diff --git a/src/port/UserInterface/PythonPlayer.cpp b/src/port/UserInterface/PythonPlayer.cpp index 73f71b16..d51bf3c9 100644 --- a/src/port/UserInterface/PythonPlayer.cpp +++ b/src/port/UserInterface/PythonPlayer.cpp @@ -1755,8 +1755,9 @@ CPythonPlayer::CPythonPlayer(void) m_kAutoHuntConfig.iGatherCount = 0; m_isAutoHuntGathering = false; m_fAutoHuntGatherStartTime = 0.0f; - m_fAutoHuntHitTime = 0.0f; - m_fAutoHuntLastUpdateTime = 0.0f; + m_fAutoHuntNextPullTime = 0.0f; + m_dwAutoHuntPullRequest = 0; + m_iAutoHuntGatheredGroups = 0; m_fAutoHuntResumeTime = 0.0f; m_dwAutoHuntTargetVID = 0; m_fAutoHuntStateTime = 0.0f; diff --git a/src/port/UserInterface/PythonPlayer.h b/src/port/UserInterface/PythonPlayer.h index bd92388c..350f4a3e 100644 --- a/src/port/UserInterface/PythonPlayer.h +++ b/src/port/UserInterface/PythonPlayer.h @@ -718,16 +718,17 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer bool bAttackStone; bool bAttackBoss; DWORD dwSkillSlotMask; // bit i = local quick slot i may be cast - int iGatherCount; // gather mode: hit this many mobs once each, then fight them together; 0 = off + int iGatherCount; // cloak pull: max 3/10/20 server monster groups; 0 = off }; void AutoHunt_SetEnabled(bool isEnable); bool AutoHunt_IsEnabled(); int AutoHunt_GetState(); int AutoHunt_GetStopReason(); - // Gather mode: still pulling (vs. fighting the pack), and the pulled mobs still alive. + // Gather mode: still pulling (vs. fighting the pack), and the number of pulled groups. bool AutoHunt_IsGathering(); int AutoHunt_GetGatheredCount(); + bool AutoHunt_HandleServerCommand(const char* command); // PORT: server-authoritative cloak pull replies bool AutoHunt_HasNoPotion(bool isHP); const SAutoHuntConfig& AutoHunt_GetConfig(); void AutoHunt_SetConfig(const SAutoHuntConfig& c_rkConfig); @@ -740,10 +741,9 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer bool __AutoHunt_UpdateBuff(CInstanceBase& rkInstMain, float fNow); void __AutoHunt_UpdateAttackSkill(CInstanceBase& rkInstMain, CInstanceBase& rkInstVictim, float fNow); bool __AutoHunt_IsSkillReady(DWORD dwSkillSlot, CPythonSkill::TSkillData** ppSkillData); - DWORD __AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow, bool isExceptGathered = false); + DWORD __AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow); DWORD __AutoHunt_FindGatheredTarget(CInstanceBase& rkInstMain); void __AutoHunt_PurgeGathered(); - bool __AutoHunt_IsGatherDone(float fNow); DWORD __AutoHunt_FindItem(CInstanceBase& rkInstMain, float fNow); bool __AutoHunt_IsInventoryFull(); void __AutoHunt_Engage(DWORD dwVID, float fNow); @@ -771,9 +771,10 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer std::map m_kMap_dwAutoHuntSkipItem; // ground item vid -> ignored until bool m_isAutoHuntGathering; float m_fAutoHuntGatherStartTime; - float m_fAutoHuntHitTime; // attacking the current target this long (gather tagging) - float m_fAutoHuntLastUpdateTime; - std::set m_kSet_dwAutoHuntGathered; // mobs hit while gathering, until they die + float m_fAutoHuntNextPullTime; + DWORD m_dwAutoHuntPullRequest; + int m_iAutoHuntGatheredGroups; + std::set m_kSet_dwAutoHuntGathered; // server-confirmed pulled mobs, until they die private: std::map m_kMap_dwAffectIndexToSkillIndex; diff --git a/src/port/UserInterface/PythonPlayerAutoHunt.cpp b/src/port/UserInterface/PythonPlayerAutoHunt.cpp index 5783df1e..f280368e 100644 --- a/src/port/UserInterface/PythonPlayerAutoHunt.cpp +++ b/src/port/UserInterface/PythonPlayerAutoHunt.cpp @@ -3,7 +3,8 @@ // Everything here goes through the paths a player's own input takes: the auto-attack target // (__SetAutoAttackTargetActorID -> __Update_AutoAttack -> MODE_CLICK_ACTOR walks up and attacks), // __UseSkill for the quick-slot skills, the MODE_CLICK_ITEM reserve that walks to a ground item and -// sends the pick-up, and CG_ITEM_USE for potions. No packet is sent that manual play would not send. +// sends the pick-up, and CG_ITEM_USE for potions. Cloak-style pulling is a user-requested server extension +// sent through CG_CHAT; it does not spoof item use or client-side monster movement. #include "StdAfx.h" #include "PythonPlayer.h" #include "PythonItem.h" @@ -43,9 +44,8 @@ const float c_fAutoHuntReturnTimeout = 20.0f; const float c_fAutoHuntSkillDistance = 400.0f; // melee skills are cast once this close const BYTE c_byMobRankBoss = 4; // PAWN, S_PAWN, KNIGHT, S_KNIGHT, BOSS, KING const DWORD c_dwGoldVnum = 1; -const float c_fAutoHuntTagHitTime = 0.35f; // gather: attacking a mob this long has pulled it -const float c_fAutoHuntGatherTimeout = 25.0f; -const float c_fAutoHuntGatherRange = 1500.0f; // gather: the next mob this close to the player +const float c_fAutoHuntPullCooldown = 10.0f; +const float c_fAutoHuntPullReplyTimeout = 3.0f; const int c_iAutoHuntGatherHPPercent = 50; // gather: stop pulling below this // Trace: stderr with MT_AUTOHUNT_TRACE=1, logcat (tag mt_autohunt) on Android. @@ -105,8 +105,7 @@ void CPythonPlayer::AutoHunt_SetEnabled(bool isEnable) m_kMap_dwAutoHuntSkipItem.clear(); m_kSet_dwAutoHuntGathered.clear(); m_isAutoHuntGathering = false; - m_fAutoHuntHitTime = 0.0f; - m_fAutoHuntLastUpdateTime = m_fAutoHuntStateTime; + m_iAutoHuntGatheredGroups = 0; } bool CPythonPlayer::AutoHunt_IsEnabled() @@ -131,14 +130,7 @@ bool CPythonPlayer::AutoHunt_IsGathering() int CPythonPlayer::AutoHunt_GetGatheredCount() { - int iCount = 0; - for (DWORD dwVID : m_kSet_dwAutoHuntGathered) - { - CInstanceBase* pkInst = NEW_FindActorPtr(dwVID); - if (pkInst && !pkInst->IsDead()) - ++iCount; - } - return iCount; + return m_kSet_dwAutoHuntGathered.empty() ? 0 : m_iAutoHuntGatheredGroups; } bool CPythonPlayer::AutoHunt_HasNoPotion(bool isHP) @@ -161,7 +153,8 @@ void CPythonPlayer::AutoHunt_SetConfig(const SAutoHuntConfig& c_rkConfig) m_kAutoHuntConfig.iSPPercent = MINMAX(0, m_kAutoHuntConfig.iSPPercent, 95); m_kAutoHuntConfig.iPickup = MINMAX(0, m_kAutoHuntConfig.iPickup, int(AUTO_HUNT_PICKUP_GOLD_EQUIP)); m_kAutoHuntConfig.dwSkillSlotMask &= (1 << AUTO_HUNT_SKILL_SLOT_NUM) - 1; - m_kAutoHuntConfig.iGatherCount = MINMAX(0, m_kAutoHuntConfig.iGatherCount, 10); + m_kAutoHuntConfig.iGatherCount = m_kAutoHuntConfig.iGatherCount <= 0 ? 0 : + (m_kAutoHuntConfig.iGatherCount <= 3 ? 3 : (m_kAutoHuntConfig.iGatherCount <= 10 ? 10 : 20)); __AutoHunt_SaveConfig(); } @@ -238,6 +231,7 @@ void CPythonPlayer::__AutoHunt_OnManualInput() m_dwAutoHuntTargetVID = 0; m_dwAutoHuntItemID = 0; m_eAutoHuntState = AUTO_HUNT_PAUSED; + m_isAutoHuntGathering = false; } m_fAutoHuntResumeTime = CTimer::Instance().GetCurrentSecond() + c_fAutoHuntResumeDelay; } @@ -250,14 +244,13 @@ void CPythonPlayer::__AutoHunt_Engage(DWORD dwVID, float fNow) m_fAutoHuntProgressTime = fNow; m_fAutoHuntBestDistance = 1e9f; m_fAutoHuntNextActionTime = fNow + 0.5f; - m_fAutoHuntHitTime = 0.0f; SetTarget(dwVID); __ClearReservedAction(); __SetAutoAttackTargetActorID(dwVID); } -DWORD CPythonPlayer::__AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow, bool isExceptGathered) +DWORD CPythonPlayer::__AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow) { CPythonCharacterManager& rkChrMgr = CPythonCharacterManager::Instance(); CPythonNonPlayer& rkNonPlayer = CPythonNonPlayer::Instance(); @@ -287,18 +280,12 @@ DWORD CPythonPlayer::__AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow if (f != m_kMap_dwAutoHuntSkipVID.end() && fNow < f->second) continue; - if (isExceptGathered && m_kSet_dwAutoHuntGathered.count(pkInst->GetVirtualID())) - continue; - TPixelPosition kPPos; pkInst->NEW_GetPixelPosition(&kPPos); if (PixelDistance(kPPos, m_kPPosAutoHuntAnchor) > float(m_kAutoHuntConfig.iRange)) continue; const float fDistance = rkInstMain.GetDistance(pkInst); - // The first pull may be anywhere in range; the rest near the pack that follows the character. - if (isExceptGathered && !m_kSet_dwAutoHuntGathered.empty() && fDistance > c_fAutoHuntGatherRange) - continue; if (fDistance < fBestDistance) { fBestDistance = fDistance; @@ -316,8 +303,11 @@ DWORD CPythonPlayer::__AutoHunt_FindGatheredTarget(CInstanceBase& rkInstMain) for (DWORD dwVID : m_kSet_dwAutoHuntGathered) { CInstanceBase* pkInst = NEW_FindActorPtr(dwVID); - if (!pkInst || pkInst->IsDead()) + if (!pkInst || pkInst->IsDead() || pkInst->IsInSafe() || !rkInstMain.IsAttackableInstance(*pkInst)) continue; + const auto skipped = m_kMap_dwAutoHuntSkipVID.find(dwVID); + if (skipped != m_kMap_dwAutoHuntSkipVID.end() && CTimer::Instance().GetCurrentSecond() < skipped->second) + continue; const float fDistance = rkInstMain.GetDistance(pkInst); if (fDistance < fBestDistance) { @@ -341,19 +331,24 @@ void CPythonPlayer::__AutoHunt_PurgeGathered() } } -bool CPythonPlayer::__AutoHunt_IsGatherDone(float fNow) +// PORT: only the server can establish aggro. Replies carry a generation so stopped, +// paused or previous requests cannot repopulate the current pack. +bool CPythonPlayer::AutoHunt_HandleServerCommand(const char* command) { - if (int(m_kSet_dwAutoHuntGathered.size()) >= m_kAutoHuntConfig.iGatherCount) - return true; - if (fNow - m_fAutoHuntGatherStartTime > c_fAutoHuntGatherTimeout) - return true; - const int iMaxHP = GetStatus(POINT_MAX_HP); - if (iMaxHP > 0 && GetStatus(POINT_HP) * 100 < iMaxHP * c_iAutoHuntGatherHPPercent) - { - AutoHuntTrace("gather stop: hp %d/%d", GetStatus(POINT_HP), iMaxHP); - return true; - } - return false; + if (strncmp(command, "AutoHuntGather ", 15)) return false; + unsigned int request = 0, value = 0; + char kind[8] = {}, extra = 0; + if (sscanf(command + 15, "%u %7s %u %c", &request, kind, &value, &extra) != 3) return true; + if (!AutoHunt_IsEnabled() || !m_isAutoHuntGathering || request != m_dwAutoHuntPullRequest) return true; + if (!strcmp(kind, "mob")) { + if (value && m_kSet_dwAutoHuntGathered.size() < 1024) m_kSet_dwAutoHuntGathered.insert(value); + } else if (!strcmp(kind, "done")) { + m_iAutoHuntGatheredGroups = MIN(value, unsigned(m_kAutoHuntConfig.iGatherCount)); + m_isAutoHuntGathering = false; + m_fAutoHuntNextActionTime = 0.0f; + AutoHuntTrace("cloak pull: %d groups, %u mobs", m_iAutoHuntGatheredGroups, unsigned(m_kSet_dwAutoHuntGathered.size())); + } + return true; } DWORD CPythonPlayer::__AutoHunt_FindItem(CInstanceBase& rkInstMain, float fNow) @@ -626,8 +621,6 @@ void CPythonPlayer::__Update_AutoHunt() } const float fNow = CTimer::Instance().GetCurrentSecond(); - const float fElapsed = fMINMAX(0.0f, fNow - m_fAutoHuntLastUpdateTime, 0.1f); - m_fAutoHuntLastUpdateTime = fNow; __AutoHunt_UpdatePotion(rkInstMain, fNow); if (IsOpenPrivateShop() || IsObserverMode() || rkInstMain.IsFishingMode()) @@ -653,28 +646,41 @@ void CPythonPlayer::__Update_AutoHunt() return; m_fAutoHuntNextActionTime = fNow + 0.25f; - // Gather mode: hit up to N mobs once each so they follow, then fight them where they stand. - __AutoHunt_PurgeGathered(); - if (m_kAutoHuntConfig.iGatherCount > 0 && !m_isAutoHuntGathering && m_kSet_dwAutoHuntGathered.empty()) - { - m_isAutoHuntGathering = true; - m_fAutoHuntGatherStartTime = fNow; - } - if (m_isAutoHuntGathering) - { - DWORD dwVID = __AutoHunt_IsGatherDone(fNow) ? 0 : __AutoHunt_FindTarget(rkInstMain, fNow, true); - if (dwVID) - { - __AutoHunt_Engage(dwVID, fNow); - return; - } - m_isAutoHuntGathering = false; - if (!m_kSet_dwAutoHuntGathered.empty()) - AutoHuntTrace("gather done: %d mobs, %.1fs", int(m_kSet_dwAutoHuntGathered.size()), - fNow - m_fAutoHuntGatherStartTime); - } + // 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"); + } + __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) + return; // let the server-controlled monsters approach the stationary player __AutoHunt_Engage(dwVID, fNow); return; } @@ -759,29 +765,6 @@ void CPythonPlayer::__Update_AutoHunt() if (rkInstMain.IsAttacking() || rkInstMain.IsUsingSkill()) { m_fAutoHuntProgressTime = fNow; - m_fAutoHuntHitTime += fElapsed; - } - - // Gather: this one is hit and follows now; go and hit the next. - if (m_isAutoHuntGathering && m_fAutoHuntHitTime >= c_fAutoHuntTagHitTime && - !m_kSet_dwAutoHuntGathered.count(m_dwAutoHuntTargetVID)) - { - m_kSet_dwAutoHuntGathered.insert(m_dwAutoHuntTargetVID); - __AutoHunt_PurgeGathered(); - AutoHuntTrace("gather tag %u (%d/%d)", (unsigned)m_dwAutoHuntTargetVID, int(m_kSet_dwAutoHuntGathered.size()), - m_kAutoHuntConfig.iGatherCount); - } - if (m_isAutoHuntGathering && m_kSet_dwAutoHuntGathered.count(m_dwAutoHuntTargetVID) && !rkInstMain.IsUsingSkill()) - { - DWORD dwNextVID = __AutoHunt_IsGatherDone(fNow) ? 0 : __AutoHunt_FindTarget(rkInstMain, fNow, true); - if (dwNextVID) - { - __AutoHunt_Engage(dwNextVID, fNow); - return; - } - m_isAutoHuntGathering = false; - AutoHuntTrace("gather done: %d mobs, %.1fs", int(m_kSet_dwAutoHuntGathered.size()), - fNow - m_fAutoHuntGatherStartTime); } // Blocked path, a target out of reach, or a safe zone that refuses the attack. diff --git a/tests/host/autohunt_pull_test.cpp b/tests/host/autohunt_pull_test.cpp new file mode 100644 index 00000000..12824ebf --- /dev/null +++ b/tests/host/autohunt_pull_test.cpp @@ -0,0 +1,120 @@ +// Compile and execute the shipped server command against a small server API fixture. +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +using DWORD = uint32_t; +constexpr int ENTITY_CHARACTER=1, ATTR_BANPK=4, MOB_RANK_BOSS=4, CHAT_TYPE_COMMAND=5; +struct Character; +using LPCHARACTER=Character*; +struct Entity { virtual ~Entity() {} bool IsType(int) { return true; } }; +using LPENTITY=Entity*; +struct Party {}; +struct Sectree { + bool safe=false; + std::vector entities; + DWORD GetAttribute(long,long) { return safe ? ATTR_BANPK : 0; } + template void ForEachAround(F& f) { for(auto* e:entities) f(e); } +}; +struct Character: Entity { + DWORD vid=0; + int x=0,y=0,rank=0,hp=100,maxhp=100; + bool pc=false,dead=false,stun=false,canHandle=true; + Sectree* sec=nullptr; + Party* party=nullptr; + Character* victim=nullptr; + int next=0, beginCount=0; + std::vector replies; + bool IsMonster() { return !pc; } + bool IsPC() { return pc; } + bool IsDead() { return dead; } + bool IsStun() { return stun; } + bool CanHandleItem() { return canHandle; } + bool CanBeginFight() { return !victim && !dead && !stun; } + int GetHP() { return hp; } + int GetMaxHP() { return maxhp; } + int GetX() { return x; } + int GetY() { return y; } + int GetMapIndex() { return 1; } + int GetMobRank() { return rank; } + Sectree* GetSectree() { return sec; } + Party* GetParty() { return party; } + DWORD GetVID() { return vid; } + Character* GetVictim() { return victim; } + int GetQuestFlag(const char*) { return next; } + void SetQuestFlag(const char*,int value) { next=value; } + void BeginFight(Character* owner) { victim=owner; ++beginCount; } + void ChatPacket(int,const char* format,...) { + char buf[256]; va_list ap; va_start(ap,format); vsnprintf(buf,sizeof(buf),format,ap); va_end(ap); + replies.emplace_back(buf); + } +}; +struct CHARACTER_MANAGER { + std::map mobs; + static CHARACTER_MANAGER& instance() { static CHARACTER_MANAGER value; return value; } + Character* Find(DWORD vid) { return mobs[vid]; } +}; +int now=100; +int get_global_time() { return now; } +#define DISTANCE_APPROX(x,y) int(std::hypot(x,y)) +#define ACMD(name) void name(LPCHARACTER ch,const char* argument) +#define MT_AUTOHUNT_SERVER_TEST +#include "../../tools/server/autohunt-pull/autohunt_pull.h" + +int main() { + Sectree sec; + Character owner; owner.pc=true; owner.sec=&sec; + Character other; other.pc=true; + std::vector parties(25); + std::vector mobs(50); + for(size_t i=0;i candidates; + for(int g=24;g>=0;--g) + for(int m=0;mNEW_GetPixelPosition(&savedPosition); + auto* pullMob = CPythonCharacterManager::Instance().GetInstancePtr(FakeLoginServer::kMobVID); + TPixelPosition pullPosition = {}; + pullMob->NEW_GetPixelPosition(&pullPosition); + pullPosition.x += 1000; + 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)")); + TPixelPosition origin = {}; + main_instance->NEW_GetPixelPosition(&origin); + CHECK(pump_until(2, [&] { + return server->Has("game2:chat 0 /autohunt_pull 3 5000 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); + player.AutoHunt_SetEnabled(false); + 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. + player.AutoHunt_SetEnabled(true); + CHECK(player.AutoHunt_HandleServerCommand("AutoHuntGather 1 done 20")); + CHECK(player.AutoHunt_GetGatheredCount() == 0); + player.AutoHunt_SetEnabled(false); + player.AutoHunt_SetConfig(savedConfig); + player.SetStatus(POINT_HP, hp); + player.SetStatus(POINT_MAX_HP, maxHP); + main_instance->NEW_Stop(); + main_instance->NEW_SetPixelPosition(savedPosition); + pump_frames(30); // restore the camera/picking matrices before the existing combat fixture +} diff --git a/tests/port/port_login_flow_server.cpp b/tests/port/port_login_flow_server.cpp index 2a48d5ee..d1f58ce3 100644 --- a/tests/port/port_login_flow_server.cpp +++ b/tests/port/port_login_flow_server.cpp @@ -864,6 +864,17 @@ bool FakeLoginServer::ServeGame(Connection& c, int index) return Fail(name + ": send GC_SAFEBOX_SET"); } } + 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"); + 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}; + if (!c.Send(chat) || !c.Send(cmd.c_str(), cmd.size() + 1)) return Fail(name + ": autohunt pull reply"); + } + } else if (text == "/safebox_close") { Event(name + ":safebox_close"); diff --git a/tests/port/port_login_flow_test.cpp b/tests/port/port_login_flow_test.cpp index 0464d5c0..d2c0669a 100644 --- a/tests/port/port_login_flow_test.cpp +++ b/tests/port/port_login_flow_test.cpp @@ -744,6 +744,7 @@ int main(int argc, char** argv) std::printf("port_login_flow_test: %s\n", e.c_str()); } } + #include "autohunt_pull_checks.inc" // 9. (批次 4-b) Combat: a left click on the stray dog reaches CPythonPlayer::__OnClickActor, which targets it // (CG_TARGET → GC_TARGET, uitarget shows the HP board) and walks into range to attack. Each attack frame's // hit (CPythonPlayerEventHandler::OnHit) sends CG_ATTACK; the fake server answers GC_DAMAGE_INFO + GC_TARGET, diff --git a/tools/server/autohunt-pull/README.md b/tools/server/autohunt-pull/README.md new file mode 100644 index 00000000..251ffb0e --- /dev/null +++ b/tools/server/autohunt-pull/README.md @@ -0,0 +1,33 @@ +# 自动狩猎的披风式引怪 + +用户授权的玩法扩展:自动狩猎自带,不需要或消耗隐月披风;选项为关、3 组、10 组、20 组。 +客户端不移动怪物或伪造命中,服务端使用披风 `AggregateMonster` 中的 `BeginFight` 路径建立追击。 +原披风的道具消耗和随机拉怪行为保持不变;本功能按距离确定性选择最多 N 个队伍。 + +“组”是服务端实际的怪物 `CParty`,没有队伍的单只怪物算一组。同一队伍在范围内的成员一起拉取。 +不足所选组数时拉取实际可用的组数。范围使用自动狩猎的范围设置,上限 5000 cm。 +安全区、死亡/眩晕、低于 50% HP 或不能操作道具时不引怪;默认不拉首领。 +队伍在范围内存在首领或正在攻击其他玩家的成员时,整组跳过。 + +请求 `/autohunt_pull <3|10|20> <500..5000> ` 走现有 CG_CHAT。 +服务端每次发 `AutoHuntGather mob `,最后发 `AutoHuntGather done `, +走现有 CHAT_TYPE_COMMAND。每个消息很短,不受一条聊天封包长度限制。 +客户端只接受当前请求的回包;停用、手动暂停后旧回包不生效。 +双方有 10 秒冷却,服务端冷却以角色任务标记保存,重新登录或开关狩猎不能绕过。 +客户端原地等待追击怪物进入近战距离,最多等待 8 秒;3 秒无回包则继续普通狩猎。 + +## 构建和回滚 + +`server.patch` 以 40250 的 `/usr/metin2/src` 为根,`patch -p1` 应用。 +`autohunt_pull.h` 和 `group_select.h` 是补丁中两份新增头文件的源文件。 +`../rebuild.sh` 已加入本补丁,沿用 build / deploy 流程。 + +2026-09-30 已在 192.168.21.203 编译并部署。原 `cmd.cpp`、`cmd_general.cpp`、旧 game +备份在 `/root/autohunt-pull-20260930/`(旧程序为 `game.before`)。回滚时停 game、恢复旧程序后启动; +不需要改数据库结构。原披风物品逻辑没有改动。 + +验证:`host.autohunt_pull` 直接编译本命令,检查 3/10/20 组、队伍成员数、排序、冷却、安全区、 +低血量和其他玩家目标过滤。`port.login_flow` 通过真实 Python 设置和聊天封包验证请求、回包及原地等待, +桌面和移动模式均通过。真服临时角色实测回包为 **3 组、9 只怪物**,随后进入自动战斗。 +临时角色、账号和关联数据已在刷新对应 DB 缓存后清理;auth/game/db 监听已检查。 +macOS 原生包及 Android debug APK 已重新构建。 diff --git a/tools/server/autohunt-pull/autohunt_pull.h b/tools/server/autohunt-pull/autohunt_pull.h new file mode 100644 index 00000000..d6d551a5 --- /dev/null +++ b/tools/server/autohunt-pull/autohunt_pull.h @@ -0,0 +1,59 @@ +// PORT extension: free cloak-style auto-hunt pull, using the original BeginFight path. +#ifdef MT_AUTOHUNT_SERVER_TEST +#include "group_select.h" +#else +#include "sectree_manager.h" +#include "autohunt_group_select.h" +#endif + +struct AutoHuntPullCollector { + LPCHARACTER owner; + int radius; + bool bosses; + std::vector candidates; + AutoHuntPullCollector(LPCHARACTER ch, int range, bool boss) : owner(ch), radius(range), bosses(boss) {} + void operator()(LPENTITY ent) { + if (!ent->IsType(ENTITY_CHARACTER)) return; + LPCHARACTER mob = (LPCHARACTER)ent; + if (!mob->IsMonster() || mob->IsPC() || mob->IsDead() || mob->GetMapIndex() != owner->GetMapIndex()) return; + const int distance = DISTANCE_APPROX(mob->GetX() - owner->GetX(), mob->GetY() - owner->GetY()); + if (distance > radius) return; + const bool safe = !mob->GetSectree() || + (mob->GetSectree()->GetAttribute(mob->GetX(), mob->GetY()) & ATTR_BANPK); + mt_autohunt::Candidate c; + c.group = mob->GetParty() ? uint64_t(uintptr_t(mob->GetParty())) << 1 : (uint64_t((DWORD)mob->GetVID()) << 1) | 1; + c.vid = (DWORD)mob->GetVID(); + c.distance = distance; + c.eligible = !safe && (bosses || mob->GetMobRank() < MOB_RANK_BOSS) && + (!mob->GetVictim() || mob->GetVictim() == owner) && (mob->GetVictim() == owner || mob->CanBeginFight()); + candidates.push_back(c); + } +}; + +ACMD(do_autohunt_pull) { + int groups = 0, radius = 0, bosses = 0; + 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 || + (bosses != 0 && bosses != 1)) 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) || + ch->GetHP() < ch->GetMaxHP() / 2 || now < ch->GetQuestFlag("autohunt.next_pull")) { + ch->ChatPacket(CHAT_TYPE_COMMAND, "AutoHuntGather %u done 0", request); + return; + } + // 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); + 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->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); +} diff --git a/tools/server/autohunt-pull/group_select.h b/tools/server/autohunt-pull/group_select.h new file mode 100644 index 00000000..2a007f7b --- /dev/null +++ b/tools/server/autohunt-pull/group_select.h @@ -0,0 +1,51 @@ +#ifndef MT_AUTOHUNT_GROUP_SELECT_H +#define MT_AUTOHUNT_GROUP_SELECT_H +#include +#include +#include +#include + +namespace mt_autohunt { +struct Candidate { + uint64_t group; // actual server party, or a unique key for an ungrouped monster + uint32_t vid; + int distance; + bool eligible; +}; +struct Selection { + int groups; + std::vector mobs; + Selection() : groups(0) {} +}; +inline Selection SelectGroups(const std::vector& candidates, int count) { + Selection out; + if (count != 3 && count != 10 && count != 20) return out; + struct Group { + uint64_t key; + int distance; + bool eligible; + std::vector mobs; + Group() : key(0), distance(2147483647), eligible(true) {} + }; + std::map groups; + for (size_t i = 0; i < candidates.size(); ++i) { + const Candidate& c = candidates[i]; + Group& g = groups[c.group]; + g.key = c.group; + g.distance = std::min(g.distance, c.distance); + g.eligible = g.eligible && c.eligible; + g.mobs.push_back(c.vid); + } + std::vector > ordered; + for (std::map::const_iterator i = groups.begin(); i != groups.end(); ++i) + if (i->second.eligible) ordered.push_back(std::make_pair(i->second.distance, i->first)); + std::sort(ordered.begin(), ordered.end()); + for (size_t i = 0; i < ordered.size() && out.groups < count; ++i) { + const Group& g = groups[ordered[i].second]; + out.mobs.insert(out.mobs.end(), g.mobs.begin(), g.mobs.end()); + ++out.groups; + } + return out; +} +} +#endif diff --git a/tools/server/autohunt-pull/server.patch b/tools/server/autohunt-pull/server.patch new file mode 100644 index 00000000..59c7b614 --- /dev/null +++ b/tools/server/autohunt-pull/server.patch @@ -0,0 +1,145 @@ +--- a/server/game/src/cmd.cpp ++++ b/server/game/src/cmd.cpp +@@ -6,6 +6,7 @@ + #include "log.h" + #include "desc.h" + ++ACMD(do_autohunt_pull); + ACMD(do_user_horse_ride); + ACMD(do_user_horse_back); + ACMD(do_user_horse_feed); +@@ -279,6 +280,7 @@ + struct command_info cmd_info[] = + { + { "!RESERVED!", NULL, 0, POS_DEAD, GM_IMPLEMENTOR }, /* ¹ݵå½Ã ÀÌ °ÍÀÌ óÀ½À̾î¾ß ÇѴÙ. */ ++ { "autohunt_pull", do_autohunt_pull, 0, POS_STANDING, 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 }, +--- a/server/game/src/cmd_general.cpp ++++ b/server/game/src/cmd_general.cpp +@@ -37,6 +37,8 @@ + #ifdef __AUCTION__ + #include "auction_manager.h" + #endif ++ ++#include "autohunt_pull.h" + + extern int g_server_id; + +--- /dev/null ++++ b/server/game/src/autohunt_pull.h +@@ -0,0 +1,59 @@ ++// PORT extension: free cloak-style auto-hunt pull, using the original BeginFight path. ++#ifdef MT_AUTOHUNT_SERVER_TEST ++#include "group_select.h" ++#else ++#include "sectree_manager.h" ++#include "autohunt_group_select.h" ++#endif ++ ++struct AutoHuntPullCollector { ++ LPCHARACTER owner; ++ int radius; ++ bool bosses; ++ std::vector candidates; ++ AutoHuntPullCollector(LPCHARACTER ch, int range, bool boss) : owner(ch), radius(range), bosses(boss) {} ++ void operator()(LPENTITY ent) { ++ if (!ent->IsType(ENTITY_CHARACTER)) return; ++ LPCHARACTER mob = (LPCHARACTER)ent; ++ if (!mob->IsMonster() || mob->IsPC() || mob->IsDead() || mob->GetMapIndex() != owner->GetMapIndex()) return; ++ const int distance = DISTANCE_APPROX(mob->GetX() - owner->GetX(), mob->GetY() - owner->GetY()); ++ if (distance > radius) return; ++ const bool safe = !mob->GetSectree() || ++ (mob->GetSectree()->GetAttribute(mob->GetX(), mob->GetY()) & ATTR_BANPK); ++ mt_autohunt::Candidate c; ++ c.group = mob->GetParty() ? uint64_t(uintptr_t(mob->GetParty())) << 1 : (uint64_t((DWORD)mob->GetVID()) << 1) | 1; ++ c.vid = (DWORD)mob->GetVID(); ++ c.distance = distance; ++ c.eligible = !safe && (bosses || mob->GetMobRank() < MOB_RANK_BOSS) && ++ (!mob->GetVictim() || mob->GetVictim() == owner) && (mob->GetVictim() == owner || mob->CanBeginFight()); ++ candidates.push_back(c); ++ } ++}; ++ ++ACMD(do_autohunt_pull) { ++ int groups = 0, radius = 0, bosses = 0; ++ 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 || ++ (bosses != 0 && bosses != 1)) 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) || ++ ch->GetHP() < ch->GetMaxHP() / 2 || now < ch->GetQuestFlag("autohunt.next_pull")) { ++ ch->ChatPacket(CHAT_TYPE_COMMAND, "AutoHuntGather %u done 0", request); ++ return; ++ } ++ // 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); ++ 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->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); ++} +--- /dev/null ++++ b/server/game/src/autohunt_group_select.h +@@ -0,0 +1,51 @@ ++#ifndef MT_AUTOHUNT_GROUP_SELECT_H ++#define MT_AUTOHUNT_GROUP_SELECT_H ++#include ++#include ++#include ++#include ++ ++namespace mt_autohunt { ++struct Candidate { ++ uint64_t group; // actual server party, or a unique key for an ungrouped monster ++ uint32_t vid; ++ int distance; ++ bool eligible; ++}; ++struct Selection { ++ int groups; ++ std::vector mobs; ++ Selection() : groups(0) {} ++}; ++inline Selection SelectGroups(const std::vector& candidates, int count) { ++ Selection out; ++ if (count != 3 && count != 10 && count != 20) return out; ++ struct Group { ++ uint64_t key; ++ int distance; ++ bool eligible; ++ std::vector mobs; ++ Group() : key(0), distance(2147483647), eligible(true) {} ++ }; ++ std::map groups; ++ for (size_t i = 0; i < candidates.size(); ++i) { ++ const Candidate& c = candidates[i]; ++ Group& g = groups[c.group]; ++ g.key = c.group; ++ g.distance = std::min(g.distance, c.distance); ++ g.eligible = g.eligible && c.eligible; ++ g.mobs.push_back(c.vid); ++ } ++ std::vector > ordered; ++ for (std::map::const_iterator i = groups.begin(); i != groups.end(); ++i) ++ if (i->second.eligible) ordered.push_back(std::make_pair(i->second.distance, i->first)); ++ std::sort(ordered.begin(), ordered.end()); ++ for (size_t i = 0; i < ordered.size() && out.groups < count; ++i) { ++ const Group& g = groups[ordered[i].second]; ++ out.mobs.insert(out.mobs.end(), g.mobs.begin(), g.mobs.end()); ++ ++out.groups; ++ } ++ return out; ++} ++} ++#endif diff --git a/tools/server/rebuild.sh b/tools/server/rebuild.sh index ca8e8c2f..cbfa6709 100644 --- a/tools/server/rebuild.sh +++ b/tools/server/rebuild.sh @@ -12,12 +12,15 @@ # inventory-5-pages 5-page inventory; needs its DB migration once (inventory-5-pages/README.md) # hunt-set quest item.set_attribute, free items in extended shops # shop-item-time NPC shop items carry a new item's time sockets (tooltip shows the duration) +# autohunt-pull free cloak-style pull for 3/10/20 actual monster groups +# autohunt-pull free cloak-style pull for 3/10/20 actual monster groups +# autohunt-pull free cloak-style pull for 3/10/20 actual monster groups 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" +PATCHES="inventory-5-pages hunt-set shop-item-time autohunt-pull" TS=$(date +%Y%m%d-%H%M%S) patch_state() {