Add free cloak-style auto-hunt pull for 3 10 or 20 monster groups

This commit is contained in:
shen
2026-09-30 17:07:24 -07:00
parent d1f8d3dddd
commit 8a6aeaedf0
18 changed files with 558 additions and 96 deletions
+1
View File
@@ -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."}
@@ -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."
}
}
}
+3
View File
@@ -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 单元 | 做法 | 位置 |
+4 -4
View File
@@ -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:
+2
View File
@@ -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 $<TARGET_FILE:port_text_test>)
set_tests_properties(port.text PROPERTIES SKIP_RETURN_CODE 77)
@@ -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;
+3 -2
View File
@@ -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;
+8 -7
View File
@@ -718,16 +718,17 @@ class CPythonPlayer : public CSingleton<CPythonPlayer>, 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<CPythonPlayer>, 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<CPythonPlayer>, public IAbstractPlayer
std::map<DWORD, float> 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<DWORD> m_kSet_dwAutoHuntGathered; // mobs hit while gathering, until they die
float m_fAutoHuntNextPullTime;
DWORD m_dwAutoHuntPullRequest;
int m_iAutoHuntGatheredGroups;
std::set<DWORD> m_kSet_dwAutoHuntGathered; // server-confirmed pulled mobs, until they die
private:
std::map<DWORD, DWORD> m_kMap_dwAffectIndexToSkillIndex;
+64 -81
View File
@@ -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.
+120
View File
@@ -0,0 +1,120 @@
// Compile and execute the shipped server command against a small server API fixture.
#ifdef NDEBUG
#undef NDEBUG
#endif
#include <cassert>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <map>
#include <string>
#include <vector>
#include <cmath>
#include <cstdint>
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<Entity*> entities;
DWORD GetAttribute(long,long) { return safe ? ATTR_BANPK : 0; }
template<class F> 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<std::string> 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<DWORD,Character*> 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<Party> parties(25);
std::vector<Character> mobs(50);
for(size_t i=0;i<mobs.size();++i) {
auto& mob=mobs[i]; mob.vid=100+i; mob.x=100+int(i/2)*100; mob.sec=&sec; mob.party=&parties[i/2];
sec.entities.push_back(&mob); CHARACTER_MANAGER::instance().mobs[mob.vid]=&mob;
}
// A group owned by another player and a boss group are both excluded as whole groups.
mobs[0].victim=&other;
mobs[2].rank=MOB_RANK_BOSS;
do_autohunt_pull(&owner,"3 5000 0 7");
assert(owner.replies.back()=="AutoHuntGather 7 done 3");
assert(owner.replies.size()==7); // 3 actual parties × 2 members, then completion
for(int i=4;i<10;++i) assert(mobs[i].victim==&owner && mobs[i].beginCount==1);
assert(!mobs[1].victim && !mobs[3].victim && !mobs[10].victim);
owner.replies.clear();
do_autohunt_pull(&owner,"20 5000 1 8");
assert(owner.replies.size()==1 && owner.replies.back()=="AutoHuntGather 8 done 0"); // cooldown
now+=10;
owner.replies.clear();
do_autohunt_pull(&owner,"20 5000 1 9");
assert(owner.replies.back()=="AutoHuntGather 9 done 20");
assert(owner.replies.size()==41);
assert(mobs[4].beginCount==1); // already on this player; no redundant restart
now+=10; owner.replies.clear(); owner.hp=49;
do_autohunt_pull(&owner,"10 5000 1 10");
assert(owner.replies.back()=="AutoHuntGather 10 done 0");
owner.hp=100; sec.safe=true; owner.replies.clear();
do_autohunt_pull(&owner,"10 5000 1 11");
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 5000 0 12 extra");
assert(owner.replies.empty());
// Unequal group sizes, exact 10-group cap and nearest ordering independent of input order.
std::vector<mt_autohunt::Candidate> candidates;
for(int g=24;g>=0;--g)
for(int m=0;m<g%4+1;++m) candidates.push_back({uint64_t(g),uint32_t(g*10+m),g*100,true});
auto ten=mt_autohunt::SelectGroups(candidates,10);
assert(ten.groups==10 && ten.mobs.front()==0);
for(auto vid:ten.mobs) assert(vid/10 < 10);
auto sparse=mt_autohunt::SelectGroups({{1,1,100,true},{1,2,110,true},{2,3,50,true}},20);
assert(sparse.groups==2 && sparse.mobs.size()==3 && sparse.mobs[0]==3);
puts("autohunt_pull_test: PASS");
}
+44
View File
@@ -0,0 +1,44 @@
// PORT extension test: real Python settings → CG_CHAT → GC_CHAT → player state.
if (server && CPythonCharacterManager::Instance().GetMainInstancePtr()) {
auto* main_instance = CPythonCharacterManager::Instance().GetMainInstancePtr();
CPythonPlayer& player = CPythonPlayer::Instance();
const auto savedConfig = player.AutoHunt_GetConfig();
const int hp = player.GetStatus(POINT_HP), maxHP = player.GetStatus(POINT_MAX_HP);
player.SetStatus(POINT_HP, 1000);
player.SetStatus(POINT_MAX_HP, 1000);
CHECK(py("import player; player.AutoHuntSetConfig(5000, 0, 0, 0, 0, 0, 0, 20)"));
CHECK(player.AutoHunt_GetConfig().iGatherCount == 20);
TPixelPosition savedPosition = {};
main_instance->NEW_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(&current);
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
}
+11
View File
@@ -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<std::uint16_t>(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");
+1
View File
@@ -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,
+33
View File
@@ -0,0 +1,33 @@
# 自动狩猎的披风式引怪
用户授权的玩法扩展:自动狩猎自带,不需要或消耗隐月披风;选项为关、3 组、10 组、20 组。
客户端不移动怪物或伪造命中,服务端使用披风 `AggregateMonster` 中的 `BeginFight` 路径建立追击。
原披风的道具消耗和随机拉怪行为保持不变;本功能按距离确定性选择最多 N 个队伍。
“组”是服务端实际的怪物 `CParty`,没有队伍的单只怪物算一组。同一队伍在范围内的成员一起拉取。
不足所选组数时拉取实际可用的组数。范围使用自动狩猎的范围设置,上限 5000 cm。
安全区、死亡/眩晕、低于 50% HP 或不能操作道具时不引怪;默认不拉首领。
队伍在范围内存在首领或正在攻击其他玩家的成员时,整组跳过。
请求 `/autohunt_pull <3|10|20> <500..5000> <boss:0|1> <request-id>` 走现有 CG_CHAT。
服务端每次发 `AutoHuntGather <id> mob <vid>`,最后发 `AutoHuntGather <id> done <groups>`,
走现有 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 已重新构建。
@@ -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<mt_autohunt::Candidate> 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);
}
+51
View File
@@ -0,0 +1,51 @@
#ifndef MT_AUTOHUNT_GROUP_SELECT_H
#define MT_AUTOHUNT_GROUP_SELECT_H
#include <algorithm>
#include <vector>
#include <map>
#include <stdint.h>
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<uint32_t> mobs;
Selection() : groups(0) {}
};
inline Selection SelectGroups(const std::vector<Candidate>& 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<uint32_t> mobs;
Group() : key(0), distance(2147483647), eligible(true) {}
};
std::map<uint64_t, Group> 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<std::pair<int, uint64_t> > ordered;
for (std::map<uint64_t, Group>::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
+145
View File
@@ -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<mt_autohunt::Candidate> 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 <algorithm>
+#include <vector>
+#include <map>
+#include <stdint.h>
+
+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<uint32_t> mobs;
+ Selection() : groups(0) {}
+};
+inline Selection SelectGroups(const std::vector<Candidate>& 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<uint32_t> mobs;
+ Group() : key(0), distance(2147483647), eligible(true) {}
+ };
+ std::map<uint64_t, Group> 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<std::pair<int, uint64_t> > ordered;
+ for (std::map<uint64_t, Group>::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
+4 -1
View File
@@ -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() {