diff --git a/audit/history.jsonl b/audit/history.jsonl index 2e0ec188..c7afab28 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -650,3 +650,4 @@ {"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."} +{"date": "2026-10-01", "unit": "UserInterface/PythonPlayerAutoHunt.cpp", "action": "user-authorized-extension", "evidence": ["docs/AUTO-HUNT-ENHANCEMENTS.md", "build/autohunt-enhance-test.log", "build/autohunt-enhance-mobile-test.log", "build/autohunt-enhance-mac-build.log", "build/autohunt-enhance-android-build.log", "build/qa/autohunt-enhance-ui/settings.png"], "finding": "Added bounded terrain/scene-collision A* and reachability for combat/loot/return; independent learned skill-ID selection with legacy config migration; optional server restart_here and 5-second/70%-HP recovery; acknowledged dismount/cast/remount batching. Original manual movement and packed Python scripts unchanged. Desktop/mobile fake-server integration and macOS/Android builds pass; real-server soak not claimed."} diff --git a/audit/port-map/UserInterface/PythonPlayer.cpp.json b/audit/port-map/UserInterface/PythonPlayer.cpp.json index e832ccf0..fd84505a 100644 --- a/audit/port-map/UserInterface/PythonPlayer.cpp.json +++ b/audit/port-map/UserInterface/PythonPlayer.cpp.json @@ -242,7 +242,17 @@ "status": "PORTED", "impl": [ "src/port/UserInterface/PythonPlayer.cpp:CPythonPlayer::NotifyDeadMainCharacter" - ] + ], + "note": "Original auto-attack cleanup retained; user-authorized PORT auto-hunt extension optionally waits for server restart_here and recovery. Default remains stopping on death. See docs/AUTO-HUNT-ENHANCEMENTS.md.", + "evidence": { + "tests": [ + "tests/port/autohunt_recovery_checks.inc" + ], + "commands": [ + "ctest --test-dir build/native -R ^port.login_flow$ --output-on-failure", + "MT_TEST_MOBILE=1 ctest --test-dir build/native -R ^port.login_flow$ --output-on-failure" + ] + } }, "CPythonPlayer::NotifyChangePKMode": { "status": "PORTED", diff --git a/docs/AUTO-HUNT-ENHANCEMENTS.md b/docs/AUTO-HUNT-ENHANCEMENTS.md new file mode 100644 index 00000000..39bde921 --- /dev/null +++ b/docs/AUTO-HUNT-ENHANCEMENTS.md @@ -0,0 +1,43 @@ +# 自动狩猎:寻路、复活和独立技能配置 + +2026-10-01 用户授权的 PORT 玩法扩展。自动狩猎继续使用客户端状态机,攻击、施法、拾取、复活和骑乘通过原有接口与封包完成。 + +## 使用 + +F8 或移动端自动狩猎设置打开面板,选择已学会的技能。选择记录的是技能 ID,移动、删除、换页快捷栏不会改变自动施法选择。 + +- **自动复活**:默认关闭。开启后死亡进入等待状态,10 秒后请求 `/restart_here`,未复活则每 10 秒重试;服务端保留复活限制。只有真实角色存活后才进入恢复,至少等待 5 秒且 HP 达到 70% 后继续。启用了自动红药时,恢复阶段临时将喝药阈值提高到 70%,不修改保存的正常战斗阈值;关闭红药则等待自然恢复。 +- **下马施法**:默认开启。普通技能在马上就绪时,请求 `/ride` 下马,等待角色骑乘状态确实变为下马,施放选中的可用技能,等待技能动画结束后请求 `/ride` 骑乘。一次下马尽量施放多项就绪技能;马战技能保持在马上使用。 +- 下马/骑乘确认最多等待 8 秒,不反复发送切换命令。下马失败后暂缓 30 秒再尝试;完成一次施法骑乘后至少间隔 10 秒再开启下一轮下马。 +- 手动移动、世界点击或攻击会取消正在进行的自动骑乘流程并暂停狩猎。复活/恢复期间的这些手动操作会停止自动狩猎。停止、换地图和退出游戏清理自动动作。 + +骑乘依赖服务端原有 `/ride` 逻辑:普通马需要仍可骑乘;其他坐骑需要相应道具仍可用。原有命令可能优先选择已召唤的马或背包中第一件可用坐骑道具,不能保证恢复不同种类坐骑中的指定一种。 + +## 路径与目标 + +`AutoHuntNavigation.h` 提供自动狩猎专用、100 cm 网格的有界 A*,8 个方向移动,并对边、转角和简化后的路径逐段采样,禁止穿墙和切过封闭墙角。 + +运行时查询已经加载的 `attr.atr` 阻挡属性和场景对象碰撞体,使用 25 cm 的通行余量;未加载的格子视为不可通行。先尝试直达,不能直达时寻找绕障路径。普通目标和引怪目标均按优先级检查可达性,每轮最多检查 4 个候选,每个搜索最多扩展 3500 个节点。没有找到路径的候选短暂跳过 5 秒;实际移动无进展仍使用原有超时和较长黑名单。 + +追怪、拾取和返回挂机点都沿路径节点移动,最终攻击和拾取仍走原有流程。绕障时实际位置变化会更新进展时间,避免因为暂时远离目标而误判卡住;攻击技能施放前还检查当前直达路径,防止目标移动到墙后仍施法。 + +这是有界、基于当前加载数据的规划:很长的绕行、过窄通道、临时角色阻挡或场景数据不完整仍可能找不到路线。不会因找不到路径而退回穿墙直走。原有手动移动没有修改。 + +## 配置和代码 + +`autohunt.cfg` 升级为 `AUTOHUNT2` 格式,保存复活开关、下马施法开关和独立技能 ID。旧 7/8 字段格式仍可读取;进入游戏后将旧勾选快捷栏的技能迁移一次。技能界面只列出已学会且可主动使用的职业/马战技能,不列被动技能。 + +- `src/port/UserInterface/PythonPlayerAutoHunt.cpp`:状态机、地图查询、复活和骑乘流程。 +- `src/port/UserInterface/AutoHuntNavigation.h`:路径规划。 +- `src/platform/ScriptLib/AutoHuntScript.inc`:设置面板、按技能名称勾选和状态显示。 +- `src/port/UserInterface/PythonPlayerModule.cpp`:独立技能选择接口。 + +## 验证 + +- `host.autohunt_navigation`:直达、绕墙、封闭墙、近距离隔墙、墙角、搜索预算、未加载数据、异常坐标。 +- `port.login_flow` / `MT_TEST_MOBILE=1 port.login_flow`:真实 Python 面板和接口;技能选择不受快捷栏变化影响;关闭下马施法不发送骑乘命令;手动操作取消待确认的流程;模拟服务端角色重建确认下马后施法、施法后重新骑乘;默认死亡停止、可选原地复活请求和 HP 恢复门槛。 +- macOS `mt_native_render` 和 Android arm64 `main` 构建。 + +集成测试使用假服并模拟骑乘/存活确认;真实服务器拒绝条件、复杂地图上的长期寻路和真机长时间挂机尚需实测。 + +中文面板已用原生 Vulkan 渲染器检查手机布局,截图位于 `build/qa/autohunt-enhance-ui/settings.png`;截图使用模拟的可选技能列表,实际已学技能过滤由上述集成测试覆盖。 diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index 26a8b836..d6b29a64 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -54,6 +54,10 @@ metin2-client 是 40250 Windows 客户端的跨平台版本。**首要目标是 2026-09-30 用户授权扩展:移动戒指列出 203 已登记的全部 65 张地图,按六类分页,包含活动、战争和测试地图; 受限副本走原有入口,活动保留入场流程,测试传送仅限 GM。见 `tools/server/ring-warp/README.md`。 +2026-10-01 用户授权扩展:自动狩猎增加基于已加载地图阻挡与场景碰撞的有界 A*,追怪/拾取/返回检查可达性; +可选原地复活并恢复后继续;技能按独立技能 ID 选择,支持确认下马后施法及施法后重新骑乘。 +见 `docs/AUTO-HUNT-ENHANCEMENTS.md`,不修改原版打包 Python 脚本及手动移动。 + ## 3. 三个层和代码位置 | 层 | 40250 单元 | 做法 | 位置 | diff --git a/src/platform/ScriptLib/AutoHuntScript.inc b/src/platform/ScriptLib/AutoHuntScript.inc index d2010d28..1fc582ee 100644 --- a/src/platform/ScriptLib/AutoHuntScript.inc +++ b/src/platform/ScriptLib/AutoHuntScript.inc @@ -8,14 +8,15 @@ import app import chat import game import player +import skill import ui import wndMgr L = player.AutoHuntLocalize -STATE_OFF, STATE_SEARCH, STATE_ENGAGE, STATE_LOOT, STATE_RETURN, STATE_PAUSED = range(6) +STATE_OFF, STATE_SEARCH, STATE_ENGAGE, STATE_LOOT, STATE_RETURN, STATE_PAUSED, STATE_REVIVE, STATE_RECOVER = range(8) STOP_NONE, STOP_USER, STOP_DEAD, STOP_MAP, STOP_INVENTORY_FULL = range(5) -SKILL_SLOT_NUM = 8 +SKILL_PAGE_SIZE = 12 STATE_TEXT = { STATE_SEARCH: "寻找目标", @@ -23,6 +24,8 @@ STATE_TEXT = { STATE_LOOT: "拾取中", STATE_RETURN: "返回挂机点", STATE_PAUSED: "手动操作,3秒后继续", + STATE_REVIVE: "等待原地复活", + STATE_RECOVER: "恢复中(血量达到70%后继续)", } STOP_TEXT = { STOP_USER: "自动狩猎已停止", @@ -163,15 +166,35 @@ class SettingDialog(ui.BoardWithTitleBar): self.gatherRow = ChoiceRow(self, y, "披风引怪", GATHER_TEXTS, SMALL_BUTTON, 48, self.__Save) y += 28 + self.reviveButton = FlagButton(self, 18, y, MIDDLE_BUTTON, "自动复活", self.__Save) + self.mountButton = FlagButton(self, 90, y, MIDDLE_BUTTON, "下马施法", self.__Save) + y += 28 self.skillLabel = ui.TextLine() self.skillLabel.SetParent(self) self.skillLabel.SetPosition(18, y + 4) - self.skillLabel.SetText(L("技能栏")) + self.skillLabel.SetText(L("选择自动使用的技能")) self.skillLabel.Show() + self.skillPage = 0 + self.selectedSkills = set() + self.availableSkills = () self.skillButtons = [] - for i in xrange(SKILL_SLOT_NUM): - self.skillButtons.append(FlagButton(self, 70 + i * 31, y, XSMALL_BUTTON, str(i + 1), self.__Save)) - y += 28 + self.skillIDs = [] + self.skillPrev = ui.Button() + self.skillNext = ui.Button() + for button, x, text, step in ((self.skillPrev, 240, "<", -1), (self.skillNext, 273, ">", 1)): + button.SetParent(self) + button.SetPosition(x, y) + button.SetUpVisual(XSMALL_BUTTON % 1) + button.SetOverVisual(XSMALL_BUTTON % 2) + button.SetDownVisual(XSMALL_BUTTON % 3) + button.SetText(text) + button.SetEvent(lambda step=step: self.__ChangeSkillPage(step)) + y += 24 + for i in xrange(SKILL_PAGE_SIZE): + button = FlagButton(self, 25 + (i % 2) * 150, y + (i // 2) * 24, + "d:/ymir work/ui/public/large_button_%02d.sub", "", lambda i=i: self.__SelectSkill(i)) + self.skillButtons.append(button) + y += 24 * (SKILL_PAGE_SIZE // 2) self.targetLabel = ui.TextLine() self.targetLabel.SetParent(self) @@ -209,21 +232,28 @@ class SettingDialog(ui.BoardWithTitleBar): def Destroy(self): for row in (self.rangeRow, self.hpRow, self.spRow, self.pickupRow, self.gatherRow): row.Destroy() - for button in self.skillButtons + [self.stoneButton, self.bossButton]: + for button in self.skillButtons + [self.stoneButton, self.bossButton, self.reviveButton, self.mountButton]: button.Destroy() self.skillButtons = [] + self.skillPrev.SetEvent(None) + self.skillNext.SetEvent(None) + self.startButton.SetEvent(None) self.startButton = None self.Hide() def Open(self): - (range_, hp, sp, pickup, stone, boss, mask, gather) = player.AutoHuntGetConfig() + (range_, hp, sp, pickup, stone, boss, mask, gather, revive, dismount) = player.AutoHuntGetConfig() self.rangeRow.Select(_Nearest(RANGE_VALUES, range_), False) self.hpRow.Select(_Nearest(HP_VALUES, hp), False) self.spRow.Select(_Nearest(SP_VALUES, sp), False) self.pickupRow.Select(pickup, False) self.gatherRow.Select(_Nearest(GATHER_VALUES, gather), False) - for i in xrange(SKILL_SLOT_NUM): - self.skillButtons[i].SetFlag(bool(mask & (1 << i))) + selected, self.availableSkills = player.AutoHuntGetSkills() + self.selectedSkills = set(selected) + self.skillPage = 0 + self.__RefreshSkills() + self.reviveButton.SetFlag(bool(revive)) + self.mountButton.SetFlag(bool(dismount)) self.stoneButton.SetFlag(bool(stone)) self.bossButton.SetFlag(bool(boss)) self.isStarted = None @@ -243,14 +273,43 @@ class SettingDialog(ui.BoardWithTitleBar): self.isStarted = isStarted self.startButton.SetText(L(isStarted and "停止狩猎" or "开始狩猎")) + def __RefreshSkills(self): + start = self.skillPage * SKILL_PAGE_SIZE + self.skillIDs = self.availableSkills[start:start + SKILL_PAGE_SIZE] + for i, button in enumerate(self.skillButtons): + if i < len(self.skillIDs): + index = self.skillIDs[i] + button.SetText(skill.GetSkillName(index)) + button.SetFlag(index in self.selectedSkills) + button.Show() + else: + button.Hide() + self.skillLabel.SetText(L(self.availableSkills and "选择自动使用的技能" or "尚未学会可用技能")) + for button in (self.skillPrev, self.skillNext): + if len(self.availableSkills) > SKILL_PAGE_SIZE: + button.Show() + else: + button.Hide() + + def __ChangeSkillPage(self, step): + pages = max(1, (len(self.availableSkills) + SKILL_PAGE_SIZE - 1) // SKILL_PAGE_SIZE) + self.skillPage = (self.skillPage + step) % pages + self.__RefreshSkills() + + def __SelectSkill(self, slot): + if slot >= len(self.skillIDs): + return + index = self.skillIDs[slot] + if self.skillButtons[slot].flag: + self.selectedSkills.add(index) + else: + self.selectedSkills.discard(index) + player.AutoHuntSetSkills(sorted(self.selectedSkills)) + def __Save(self): - mask = 0 - for i in xrange(SKILL_SLOT_NUM): - if self.skillButtons[i].flag: - mask |= 1 << i player.AutoHuntSetConfig(RANGE_VALUES[self.rangeRow.index], HP_VALUES[self.hpRow.index], - SP_VALUES[self.spRow.index], self.pickupRow.index, int(self.stoneButton.flag), int(self.bossButton.flag), mask, - GATHER_VALUES[self.gatherRow.index]) + SP_VALUES[self.spRow.index], self.pickupRow.index, int(self.stoneButton.flag), int(self.bossButton.flag), 0, + GATHER_VALUES[self.gatherRow.index], int(self.reviveButton.flag), int(self.mountButton.flag)) def __OnClickStart(self): Toggle() @@ -293,12 +352,14 @@ class Ticker(ui.Window): config = player.AutoHuntGetConfig() displayState = state + (config[7],) if displayState != self.last: - (stateIndex, reason, noHP, noSP, gathering, gathered) = state + (stateIndex, reason, noHP, noSP, gathering, gathered, mountState) = state wasOn = self.last and self.last[0] != STATE_OFF self.last = displayState if stateIndex != STATE_OFF: text = L("自动狩猎 · ") + L(STATE_TEXT.get(stateIndex, "")) - if gathering: + if mountState: + text = L("自动狩猎 · ") + L({1: "等待下马", 2: "下马施法中", 3: "等待重新骑乘"}.get(mountState, "")) + elif gathering: text = L("自动狩猎 · 披风引怪中(最多%d组)" % player.AutoHuntGetConfig()[7]) elif config[7]: text += L(" · 本轮引怪%d/%d组" % (gathered, config[7])) diff --git a/src/port/CMakeLists.txt b/src/port/CMakeLists.txt index 1d25d3d9..6d94c23d 100644 --- a/src/port/CMakeLists.txt +++ b/src/port/CMakeLists.txt @@ -198,6 +198,8 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) 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) + add_executable(host_autohunt_navigation_test ${PROJECT_SOURCE_DIR}/tests/host/autohunt_navigation_test.cpp) + add_test(NAME host.autohunt_navigation COMMAND host_autohunt_navigation_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/AutoHuntNavigation.h b/src/port/UserInterface/AutoHuntNavigation.h new file mode 100644 index 00000000..10b05786 --- /dev/null +++ b/src/port/UserInterface/AutoHuntNavigation.h @@ -0,0 +1,96 @@ +#pragma once +// PORT: bounded A* for auto hunt only. Queries loaded terrain and scene collision through a callback. +#include +#include +#include +#include +#include +#include +#include + +namespace AutoHuntNavigation { +struct Point { float x, y; }; +inline float Distance(Point a, Point b) { return std::hypot(a.x-b.x, a.y-b.y); } +using Walkable = std::function; +inline bool Segment(Point a, Point b, const Walkable& walkable) { + const int samples = std::max(1, int(std::ceil(Distance(a,b) / 40.f))); + Point previous=a; + for (int i=1; i<=samples; ++i) { + const float t = float(i)/samples; + const Point next{a.x+(b.x-a.x)*t, a.y+(b.y-a.y)*t}; + if (!walkable(next)) return false; + if (next.x!=previous.x && next.y!=previous.y && + (!walkable({next.x,previous.y}) || !walkable({previous.x,next.y}))) return false; + previous=next; + } + return true; +} +// Empty means unreachable within the loaded data/search budget, never permission to walk straight. +inline std::vector FindPath(Point start, Point goal, float arrival, const Walkable& walkable, + int budget=3500) { + if (!std::isfinite(start.x) || !std::isfinite(start.y) || !std::isfinite(goal.x) || + !std::isfinite(goal.y) || arrival < 0 || budget <= 0 || Distance(start,goal)>40000.f) return {}; + if (Distance(start,goal) <= arrival && Segment(start,goal,walkable)) return {start}; + if (Segment(start,goal,walkable)) return {goal}; + constexpr float cell=100.f; + // Grid is relative to the exact starting position, so the first step cannot cross an unchecked edge. + const int extent = int(std::ceil(Distance(start,goal)/cell))+25; + const int width = extent*2+1; + auto key = [=](int x,int y) { return (y+extent)*width+x+extent; }; + auto point = [=](int x,int y) -> Point { return {start.x+x*cell,start.y+y*cell}; }; + struct Node { float cost=std::numeric_limits::infinity(); int parent=-1; bool closed=false; }; + struct Entry { float score; int x,y; bool operator<(const Entry& b) const { return score>b.score; } }; + std::unordered_map nodes; + std::unordered_map passable; + auto canWalk = [&](int x,int y) { + const int id=key(x,y); + auto i=passable.find(id); + if (i!=passable.end()) return i->second; + return passable[id]=walkable(point(x,y)); + }; + std::priority_queue open; + const int first=key(0,0); + nodes[first].cost=0; + open.push({Distance(start,goal),0,0}); + int finish=-1; + while (!open.empty() && budget>0) { + const auto e=open.top(); open.pop(); + const int id=key(e.x,e.y); + if (nodes[id].closed) continue; + nodes[id].closed=true; + --budget; + const float cost=nodes[id].cost; // inserts below may rehash the map + const Point p=point(e.x,e.y); + if (Distance(p,goal)<=std::max(arrival,cell) && + Segment(p,goal,walkable)) { finish=id; break; } + for (int dy=-1;dy<=1;++dy) for (int dx=-1;dx<=1;++dx) { + if (!dx && !dy) continue; + const int x=e.x+dx,y=e.y+dy; + if (std::abs(x)>extent || std::abs(y)>extent || !canWalk(x,y)) continue; + if (dx && dy && (!canWalk(e.x+dx,e.y) || !canWalk(e.x,e.y+dy))) continue; + const int next=key(x,y); + const float nextCost=cost+((dx && dy)?141.42136f:100.f); + if (nodes[next].closed || nextCost>=nodes[next].cost || !Segment(p,point(x,y),walkable)) continue; + nodes[next].cost=nextCost; + nodes[next].parent=id; + open.push({nextCost+std::max(0.f,Distance(point(x,y),goal)-arrival),x,y}); + } + } + if (finish<0) return {}; + std::vector reverse; + for (int id=finish;id!=first;id=nodes[id].parent) + reverse.push_back(point(id%width-extent,id/width-extent)); + std::reverse(reverse.begin(),reverse.end()); + if (reverse.empty()) reverse.push_back(start); + if (Distance(reverse.back(),goal)>arrival) reverse.push_back(goal); + // Greedy line-of-sight smoothing, retaining every necessary turn. + std::vector result; + Point from=start; + for (size_t i=0;i, public IAbstractPlayer AUTO_HUNT_LOOT, AUTO_HUNT_RETURN, AUTO_HUNT_PAUSED, + AUTO_HUNT_REVIVE, + AUTO_HUNT_RECOVER, }; enum EAutoHuntStopReason @@ -706,7 +708,7 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer enum { - AUTO_HUNT_SKILL_SLOT_NUM = 8, // the local quick slots on the taskbar's first page + AUTO_HUNT_SKILL_SLOT_NUM = 8, // legacy config migration only }; struct SAutoHuntConfig @@ -717,8 +719,11 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer int iPickup; // EAutoHuntPickup bool bAttackStone; bool bAttackBoss; - DWORD dwSkillSlotMask; // bit i = local quick slot i may be cast + DWORD dwSkillSlotMask; // legacy config migration only int iGatherCount; // cloak pull: max 3/10/20 server monster groups; 0 = off + bool bAutoRevive = false; + bool bDismountSkill = true; + std::vector vecSkillIndices; // independent of the quick bar }; void AutoHunt_SetEnabled(bool isEnable); @@ -730,6 +735,10 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer int AutoHunt_GetGatheredCount(); bool AutoHunt_HandleServerCommand(const char* command); // PORT: server-authoritative cloak pull replies bool AutoHunt_HasNoPotion(bool isHP); + std::vector AutoHunt_GetAvailableSkills(); + void AutoHunt_SetSkills(const std::vector& indices); + void AutoHunt_OnDeath(); + int AutoHunt_GetMountState() const { return m_iAutoHuntMountState; } const SAutoHuntConfig& AutoHunt_GetConfig(); void AutoHunt_SetConfig(const SAutoHuntConfig& c_rkConfig); @@ -742,6 +751,13 @@ 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); + bool __AutoHunt_CastSkill(DWORD slot, float now); + bool __AutoHunt_UpdateMount(CInstanceBase& main, float now); + bool __AutoHunt_UpdateRecovery(CInstanceBase& main, float now); + bool __AutoHunt_BuildPath(const TPixelPosition& goal, float arrival, std::vector& path); + bool __AutoHunt_MoveTo(const TPixelPosition& goal, float arrival, float now); + void __AutoHunt_ResetMovement(); + void __AutoHunt_MigrateSkills(); 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); @@ -753,6 +769,17 @@ class CPythonPlayer : public CSingleton, public IAbstractPlayer void __AutoHunt_LoadConfig(); void __AutoHunt_SaveConfig(); + bool m_isAutoHuntLegacySkills = false; + int m_iAutoHuntMountState = 0; // 0 normal, 1 wait dismount, 2 cast, 3 wait remount + DWORD m_dwAutoHuntPendingSkill = 0; + float m_fAutoHuntMountDeadline = 0, m_fAutoHuntMountNextTime = 0; + float m_fAutoHuntMountCastEnd = 0; + float m_fAutoHuntReviveNextTime = 0; + std::vector m_vecAutoHuntPath; + TPixelPosition m_kAutoHuntPathGoal = {}, m_kAutoHuntPathLastPosition = {}; + DWORD m_dwAutoHuntScanTarget = 0; + float m_fAutoHuntTargetScanTime = 0; + float m_fAutoHuntPathTime = 0, m_fAutoHuntPathMoveTime = 0; SAutoHuntConfig m_kAutoHuntConfig; bool m_isAutoHuntConfigLoaded; int m_eAutoHuntState; diff --git a/src/port/UserInterface/PythonPlayerAutoHunt.cpp b/src/port/UserInterface/PythonPlayerAutoHunt.cpp index 353e426e..8718f139 100644 --- a/src/port/UserInterface/PythonPlayerAutoHunt.cpp +++ b/src/port/UserInterface/PythonPlayerAutoHunt.cpp @@ -2,7 +2,7 @@ // // 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 +// __UseSkill for the independently selected skills, the MODE_CLICK_ITEM reserve that walks to a ground item and // 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" @@ -21,6 +21,9 @@ #include #include #include +#include "AutoHuntNavigation.h" +#include "../EterLib/CullingManager.h" +#include "../EterLib/GrpObjectInstance.h" #ifdef __ANDROID__ #include @@ -90,6 +93,9 @@ void CPythonPlayer::AutoHunt_SetEnabled(bool isEnable) return; __AutoHunt_LoadConfig(); + __AutoHunt_MigrateSkills(); + __AutoHunt_ResetMovement(); + m_iAutoHuntMountState = 0; pkInstMain->NEW_GetPixelPosition(&m_kPPosAutoHuntAnchor); m_stAutoHuntMapName = CPythonBackground::Instance().GetWarpMapName(); m_eAutoHuntState = AUTO_HUNT_SEARCH; @@ -157,6 +163,7 @@ void CPythonPlayer::AutoHunt_SetConfig(const SAutoHuntConfig& c_rkConfig) m_iAutoHuntGatheredGroups = 0; } m_kAutoHuntConfig = c_rkConfig; + __AutoHunt_ResetMovement(); 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); @@ -174,38 +181,92 @@ void CPythonPlayer::__AutoHunt_LoadConfig() m_isAutoHuntConfigLoaded = true; FILE* fp = fopen(c_szAutoHuntConfigFileName, "rt"); - if (!fp) - return; - - SAutoHuntConfig kConfig = m_kAutoHuntConfig; - int iStone = 0, iBoss = 0, iGather = 0; - unsigned int uMask = 0; - // 7 fields before gather mode. - const int iRead = fscanf(fp, "%d %d %d %d %d %d %u %d", &kConfig.iRange, &kConfig.iHPPercent, &kConfig.iSPPercent, - &kConfig.iPickup, &iStone, &iBoss, &uMask, &iGather); - if (iRead >= 7) - { - kConfig.iGatherCount = 8 == iRead ? iGather : 0; - kConfig.bAttackStone = iStone != 0; - kConfig.bAttackBoss = iBoss != 0; - kConfig.dwSkillSlotMask = uMask; - fclose(fp); - AutoHunt_SetConfig(kConfig); - return; + if (!fp) return; + SAutoHuntConfig config = m_kAutoHuntConfig; + char header[32] = {}; + int stone=0, boss=0, revive=0, dismount=1, count=0; + unsigned mask=0; + if (fscanf(fp, "%31s", header)==1 && !strcmp(header,"AUTOHUNT2")) { + if (fscanf(fp,"%d %d %d %d %d %d %d %d %d %d", &config.iRange,&config.iHPPercent, + &config.iSPPercent,&config.iPickup,&stone,&boss,&config.iGatherCount,&revive,&dismount,&count)==10 && + count>=0 && count<=32) { + config.vecSkillIndices.clear(); + for (int i=0;i=7) { + if (read==7) config.iGatherCount=0; + config.bAttackStone=stone!=0; config.bAttackBoss=boss!=0; config.dwSkillSlotMask=mask; + m_kAutoHuntConfig=config; m_isAutoHuntLegacySkills=true; + } } fclose(fp); } void CPythonPlayer::__AutoHunt_SaveConfig() { + // Defer legacy migration until the game's skill/quickslot packets have arrived. + if (m_isAutoHuntLegacySkills) return; FILE* fp = fopen(c_szAutoHuntConfigFileName, "wt"); - if (!fp) - return; - fprintf(fp, "%d %d %d %d %d %d %u %d\n", m_kAutoHuntConfig.iRange, m_kAutoHuntConfig.iHPPercent, - m_kAutoHuntConfig.iSPPercent, m_kAutoHuntConfig.iPickup, m_kAutoHuntConfig.bAttackStone ? 1 : 0, - m_kAutoHuntConfig.bAttackBoss ? 1 : 0, (unsigned int)m_kAutoHuntConfig.dwSkillSlotMask, - m_kAutoHuntConfig.iGatherCount); - fclose(fp); + if (!fp) return; + fprintf(fp,"AUTOHUNT2\n%d %d %d %d %d %d %d %d %d %u\n", m_kAutoHuntConfig.iRange, + m_kAutoHuntConfig.iHPPercent,m_kAutoHuntConfig.iSPPercent,m_kAutoHuntConfig.iPickup, + m_kAutoHuntConfig.bAttackStone,m_kAutoHuntConfig.bAttackBoss,m_kAutoHuntConfig.iGatherCount, + m_kAutoHuntConfig.bAutoRevive,m_kAutoHuntConfig.bDismountSkill,unsigned(m_kAutoHuntConfig.vecSkillIndices.size())); + for (DWORD index:m_kAutoHuntConfig.vecSkillIndices) fprintf(fp,"%u ",unsigned(index)); + fprintf(fp,"\n"); fclose(fp); +} + +std::vector CPythonPlayer::AutoHunt_GetAvailableSkills() +{ + __AutoHunt_LoadConfig(); + __AutoHunt_MigrateSkills(); + std::vector indices; + for (DWORD slot=0;slotbyType==CPythonSkill::SKILL_TYPE_ACTIVE || data->byType==CPythonSkill::SKILL_TYPE_HORSE) && + data->IsCanUseSkill() && std::find(indices.begin(),indices.end(),instance.dwIndex)==indices.end()) + indices.push_back(instance.dwIndex); + } + return indices; +} + +void CPythonPlayer::__AutoHunt_MigrateSkills() +{ + if (!m_isAutoHuntLegacySkills) return; + m_kAutoHuntConfig.vecSkillIndices.clear(); + for (DWORD i=0;i& indices) +{ + __AutoHunt_LoadConfig(); + m_isAutoHuntLegacySkills=false; + std::vector clean; + for (DWORD index:indices) { + CPythonSkill::TSkillData* data=nullptr; + if (clean.size()>=32) break; + if (index && CPythonSkill::Instance().GetSkillData(index,&data) && data->IsCanUseSkill() && + (data->byType==CPythonSkill::SKILL_TYPE_ACTIVE || data->byType==CPythonSkill::SKILL_TYPE_HORSE) && + std::find(clean.begin(),clean.end(),index)==clean.end()) clean.push_back(index); + } + m_kAutoHuntConfig.vecSkillIndices=clean; + __AutoHunt_SaveConfig(); } void CPythonPlayer::__AutoHunt_CancelPull() @@ -221,14 +282,11 @@ void CPythonPlayer::__AutoHunt_CancelPull() 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(); - __ClearReservedAction(); - if (AUTO_HUNT_RETURN == m_eAutoHuntState) - if (CInstanceBase* pkInstMain = NEW_GetMainActorPtr()) - pkInstMain->NEW_Stop(); - } + __ClearAutoAttackTargetActorID(); + __ClearReservedAction(); + __AutoHunt_ResetMovement(); + m_iAutoHuntMountState = 0; + if (auto* main=NEW_GetMainActorPtr()) main->NEW_Stop(); m_eAutoHuntState = AUTO_HUNT_OFF; m_eAutoHuntStopReason = eReason; m_dwAutoHuntTargetVID = 0; @@ -244,6 +302,11 @@ void CPythonPlayer::__AutoHunt_OnManualInput() if (!AutoHunt_IsEnabled()) return; + __AutoHunt_ResetMovement(); + m_iAutoHuntMountState = 0; // manual control also cancels pending mount/skill commands + if (AUTO_HUNT_REVIVE == m_eAutoHuntState || AUTO_HUNT_RECOVER == m_eAutoHuntState) { + __AutoHunt_Stop(AUTO_HUNT_STOP_USER); return; + } if (AUTO_HUNT_PAUSED != m_eAutoHuntState) { __ClearAutoAttackTargetActorID(); @@ -267,7 +330,8 @@ void CPythonPlayer::__AutoHunt_Engage(DWORD dwVID, float fNow) SetTarget(dwVID); __ClearReservedAction(); - __SetAutoAttackTargetActorID(dwVID); + __ClearAutoAttackTargetActorID(); + __AutoHunt_ResetMovement(); } // PORT: stones have their own option; their mob_proto rank must not count as a boss. @@ -311,58 +375,59 @@ bool CPythonPlayer::__AutoHunt_RequestPull(CInstanceBase& main, float now) DWORD CPythonPlayer::__AutoHunt_FindTarget(CInstanceBase& rkInstMain, float fNow) { CPythonCharacterManager& rkChrMgr = CPythonCharacterManager::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 || !__AutoHunt_IsTargetAllowed(rkInstMain, *pkInst)) - continue; - - std::map::iterator f = m_kMap_dwAutoHuntSkipVID.find(pkInst->GetVirtualID()); - if (f != m_kMap_dwAutoHuntSkipVID.end() && fNow < f->second) - continue; - - TPixelPosition kPPos; - pkInst->NEW_GetPixelPosition(&kPPos); - if (PixelDistance(kPPos, m_kPPosAutoHuntAnchor) > float(m_kAutoHuntConfig.iRange)) - continue; - - const float fDistance = rkInstMain.GetDistance(pkInst); - const bool stone = pkInst->IsStone(); - if ((stone && !bestStone) || (stone == bestStone && fDistance < fBestDistance)) - { - bestStone = stone; - fBestDistance = fDistance; - dwBestVID = pkInst->GetVirtualID(); - } + if (fNow=skip->second)) return m_dwAutoHuntScanTarget; } - return dwBestVID; + m_fAutoHuntTargetScanTime=fNow+0.5f; m_dwAutoHuntScanTarget=0; + std::vector candidates; + for (auto i=rkChrMgr.CharacterInstanceBegin();i!=rkChrMgr.CharacterInstanceEnd();++i) { + CInstanceBase* target=*i; + if (!target || !__AutoHunt_IsTargetAllowed(rkInstMain,*target)) continue; + const auto skip=m_kMap_dwAutoHuntSkipVID.find(target->GetVirtualID()); + if (skip!=m_kMap_dwAutoHuntSkipVID.end() && fNowsecond) continue; + TPixelPosition position; target->NEW_GetPixelPosition(&position); + if (PixelDistance(position,m_kPPosAutoHuntAnchor)<=m_kAutoHuntConfig.iRange) candidates.push_back(target); + } + std::sort(candidates.begin(),candidates.end(),[&](CInstanceBase* a,CInstanceBase* b) { + if (a->IsStone()!=b->IsStone()) return bool(a->IsStone()); + return rkInstMain.GetDistance(a)4) break; + TPixelPosition goal; target->NEW_GetPixelPosition(&goal); + std::vector path; + if (__AutoHunt_BuildPath(goal,100.f,path)) return m_dwAutoHuntScanTarget=target->GetVirtualID(); + m_kMap_dwAutoHuntSkipVID[target->GetVirtualID()]=fNow+5.f; + } + return 0; } // Gather mode: the nearest pulled mob still alive. DWORD CPythonPlayer::__AutoHunt_FindGatheredTarget(CInstanceBase& rkInstMain) { - DWORD dwBestVID = 0; - float fBestDistance = 1e9f; - for (DWORD dwVID : m_kSet_dwAutoHuntGathered) - { - CInstanceBase* pkInst = NEW_FindActorPtr(dwVID); - 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) - continue; - const float fDistance = rkInstMain.GetDistance(pkInst); - if (fDistance < fBestDistance) - { - fBestDistance = fDistance; - dwBestVID = dwVID; - } + std::vector targets; + const float now=CTimer::Instance().GetCurrentSecond(); + for (DWORD vid:m_kSet_dwAutoHuntGathered) { + auto* target=NEW_FindActorPtr(vid); + const auto skip=m_kMap_dwAutoHuntSkipVID.find(vid); + if (target && __AutoHunt_IsTargetAllowed(rkInstMain,*target) && + (skip==m_kMap_dwAutoHuntSkipVID.end() || now>=skip->second)) targets.push_back(target); } - return dwBestVID; + std::sort(targets.begin(),targets.end(),[&](CInstanceBase* a,CInstanceBase* b) {return rkInstMain.GetDistance(a)4) break; + TPixelPosition goal; target->NEW_GetPixelPosition(&goal); + std::vector path; + if (__AutoHunt_BuildPath(goal,100.f,path)) return target->GetVirtualID(); + m_kMap_dwAutoHuntSkipVID[target->GetVirtualID()]=now+5.f; + } + return 0; } // Dead or out of sight. @@ -495,8 +560,9 @@ bool CPythonPlayer::__AutoHunt_IsSkillReady(DWORD dwSkillSlot, CPythonSkill::TSk return false; if (!pSkillData->IsCanUseSkill()) return false; - if ((pkInstMain->IsMountingHorse() ? true : false) != (pSkillData->IsHorseSkill() ? true : false)) - return false; + if (pSkillData->IsHorseSkill() && !pkInstMain->IsMountingHorse()) return false; + if (!pSkillData->IsHorseSkill() && pkInstMain->IsMountingHorse() && + (!m_kAutoHuntConfig.bDismountSkill || CTimer::Instance().GetCurrentSecond()CanUseWeaponType(pkInstMain->GetWeaponType())) return false; if (pSkillData->IsNeedBow() && !__HasEnoughArrow()) @@ -519,21 +585,16 @@ bool CPythonPlayer::__AutoHunt_IsSkillReady(DWORD dwSkillSlot, CPythonSkill::TSk return true; } -// Self buffs from the enabled quick slots whose effect is not on the character. +// Selected self buffs whose effect is not on the character. bool CPythonPlayer::__AutoHunt_UpdateBuff(CInstanceBase& rkInstMain, float fNow) { if (fNow < m_fAutoHuntNextSkillTime || rkInstMain.IsUsingSkill() || rkInstMain.IsInSafe()) return false; - for (DWORD i = 0; i < AUTO_HUNT_SKILL_SLOT_NUM; ++i) + for (DWORD index : m_kAutoHuntConfig.vecSkillIndices) { - if (!(m_kAutoHuntConfig.dwSkillSlotMask & (1 << i))) - continue; - - DWORD dwType = SLOT_TYPE_NONE, dwSkillSlot = 0; - GetLocalQuickSlotData(i, &dwType, &dwSkillSlot); - if (SLOT_TYPE_SKILL != dwType) - continue; + DWORD dwSkillSlot = 0; + if (!GetSkillSlotIndex(index, &dwSkillSlot)) continue; CPythonSkill::TSkillData* pSkillData; if (!__AutoHunt_IsSkillReady(dwSkillSlot, &pSkillData)) @@ -560,7 +621,7 @@ bool CPythonPlayer::__AutoHunt_UpdateBuff(CInstanceBase& rkInstMain, float fNow) } m_fAutoHuntNextSkillTime = fNow + 0.5f; - if (__UseSkill(dwSkillSlot)) + if (__AutoHunt_CastSkill(dwSkillSlot, fNow)) return true; } return false; @@ -573,15 +634,10 @@ void CPythonPlayer::__AutoHunt_UpdateAttackSkill(CInstanceBase& rkInstMain, CIns m_fAutoHuntNextSkillTime = fNow + 0.3f; const float fDistance = rkInstMain.GetDistance(&rkInstVictim); - for (DWORD i = 0; i < AUTO_HUNT_SKILL_SLOT_NUM; ++i) + for (DWORD index : m_kAutoHuntConfig.vecSkillIndices) { - if (!(m_kAutoHuntConfig.dwSkillSlotMask & (1 << i))) - continue; - - DWORD dwType = SLOT_TYPE_NONE, dwSkillSlot = 0; - GetLocalQuickSlotData(i, &dwType, &dwSkillSlot); - if (SLOT_TYPE_SKILL != dwType) - continue; + DWORD dwSkillSlot = 0; + if (!GetSkillSlotIndex(index, &dwSkillSlot)) continue; CPythonSkill::TSkillData* pSkillData; if (!__AutoHunt_IsSkillReady(dwSkillSlot, &pSkillData) || !pSkillData->IsAttackSkill()) @@ -593,7 +649,7 @@ void CPythonPlayer::__AutoHunt_UpdateAttackSkill(CInstanceBase& rkInstMain, CIns continue; SetTarget(rkInstVictim.GetVirtualID()); - if (__UseSkill(dwSkillSlot)) + if (__AutoHunt_CastSkill(dwSkillSlot, fNow)) { m_fAutoHuntNextSkillTime = fNow + 0.5f; return; @@ -611,7 +667,8 @@ void CPythonPlayer::__AutoHunt_UpdatePotion(CInstanceBase& rkInstMain, float fNo for (int iValue = 0; iValue < 2; ++iValue) { const bool isHP = 0 == iValue; - const int iPercent = isHP ? m_kAutoHuntConfig.iHPPercent : m_kAutoHuntConfig.iSPPercent; + int iPercent = isHP ? m_kAutoHuntConfig.iHPPercent : m_kAutoHuntConfig.iSPPercent; + if (isHP && iPercent>0 && m_eAutoHuntState==AUTO_HUNT_RECOVER) iPercent=MAX(iPercent,70); const int iMax = GetStatus(isHP ? POINT_MAX_HP : POINT_MAX_SP); const int iCur = GetStatus(isHP ? POINT_HP : POINT_SP); bool& isNoPotion = isHP ? m_isAutoHuntNoHPPotion : m_isAutoHuntNoSPPotion; @@ -657,18 +714,19 @@ void CPythonPlayer::__Update_AutoHunt() return; CInstanceBase& rkInstMain = *pkInstMain; - if (rkInstMain.IsDead()) - { - __AutoHunt_Stop(AUTO_HUNT_STOP_DEAD); - return; - } + const float fNow = CTimer::Instance().GetCurrentSecond(); if (m_stAutoHuntMapName != CPythonBackground::Instance().GetWarpMapName()) { __AutoHunt_Stop(AUTO_HUNT_STOP_MAP); return; } - const float fNow = CTimer::Instance().GetCurrentSecond(); + if (rkInstMain.IsDead()) { + AutoHunt_OnDeath(); + if (AutoHunt_IsEnabled()) __AutoHunt_UpdateRecovery(rkInstMain,fNow); + return; + } + if (__AutoHunt_UpdateRecovery(rkInstMain,fNow)) return; __AutoHunt_UpdatePotion(rkInstMain, fNow); if (m_isAutoHuntGathering && fNow - m_fAutoHuntGatherStartTime >= c_fAutoHuntPullReplyTimeout) { m_isAutoHuntGathering = false; @@ -678,6 +736,7 @@ void CPythonPlayer::__Update_AutoHunt() if (IsOpenPrivateShop() || IsObserverMode() || rkInstMain.IsFishingMode()) return; + if (__AutoHunt_UpdateMount(rkInstMain,fNow)) return; switch (m_eAutoHuntState) { @@ -746,6 +805,7 @@ void CPythonPlayer::__Update_AutoHunt() rkInstMain.NEW_GetPixelPosition(&kPPosMain); if (PixelDistance(kPPosMain, m_kPPosAutoHuntAnchor) > c_fAutoHuntReturnDistance) { + __AutoHunt_ResetMovement(); m_eAutoHuntState = AUTO_HUNT_RETURN; m_fAutoHuntStateTime = fNow; m_fAutoHuntNextActionTime = 0.0f; @@ -775,8 +835,11 @@ void CPythonPlayer::__Update_AutoHunt() m_fAutoHuntStateTime = fNow; return; } - if (!rkInstMain.IsWalking()) - rkInstMain.NEW_MoveToDestPixelPositionDirection(m_kPPosAutoHuntAnchor); + if (!__AutoHunt_MoveTo(m_kPPosAutoHuntAnchor,100.f,fNow)) { + rkInstMain.NEW_Stop(); + m_eAutoHuntState=AUTO_HUNT_SEARCH; + m_fAutoHuntNextActionTime=fNow+2.f; + } break; } @@ -785,7 +848,8 @@ void CPythonPlayer::__Update_AutoHunt() CInstanceBase* pkInstVictim = NEW_FindActorPtr(m_dwAutoHuntTargetVID); if (!pkInstVictim || !__AutoHunt_IsTargetAllowed(rkInstMain, *pkInstVictim)) { - __ClearAutoAttackTargetActorID(); + __ClearAutoAttackTargetActorID(); __ClearReservedAction(); + __AutoHunt_ResetMovement(); rkInstMain.NEW_Stop(); m_dwAutoHuntTargetVID = 0; m_dwAutoHuntItemID = 0; // The rest of the pack is still on the character: loot once it is down. @@ -826,17 +890,27 @@ void CPythonPlayer::__Update_AutoHunt() { m_kMap_dwAutoHuntSkipVID[m_dwAutoHuntTargetVID] = fNow + c_fAutoHuntSkipTime; __ClearAutoAttackTargetActorID(); - __ClearReservedAction(); + __ClearReservedAction(); __AutoHunt_ResetMovement(); rkInstMain.NEW_Stop(); m_dwAutoHuntTargetVID = 0; m_eAutoHuntState = AUTO_HUNT_SEARCH; m_fAutoHuntStateTime = fNow; return; } + TPixelPosition goal; pkInstVictim->NEW_GetPixelPosition(&goal); + // Use the planner even in attack range: never swing or cast through a wall. + if (!__AutoHunt_MoveTo(goal,100.f,fNow)) { + m_kMap_dwAutoHuntSkipVID[m_dwAutoHuntTargetVID]=fNow+c_fAutoHuntSkipTime; + __ClearAutoAttackTargetActorID(); __ClearReservedAction(); rkInstMain.NEW_Stop(); + m_dwAutoHuntTargetVID=0; m_eAutoHuntState=AUTO_HUNT_SEARCH; return; + } + if (!m_vecAutoHuntPath.empty()) return; __AutoHunt_UpdateBuff(rkInstMain, fNow); + if (m_iAutoHuntMountState) return; if (!m_isAutoHuntGathering) __AutoHunt_UpdateAttackSkill(rkInstMain, *pkInstVictim, fNow); + if (m_iAutoHuntMountState) return; // A cast or an interrupted approach drops the auto-attack target; put it back. if (m_dwAutoAttackTargetVID != m_dwAutoHuntTargetVID && fNow >= m_fAutoHuntNextActionTime && !rkInstMain.IsUsingSkill() && MODE_USE_SKILL != m_eReservedMode) @@ -870,7 +944,10 @@ void CPythonPlayer::__Update_AutoHunt() } else { - if (MODE_CLICK_ITEM != m_eReservedMode && !rkInstMain.IsWalking()) + if (!__AutoHunt_MoveTo(kPPosItem,100.f,fNow)) { + m_kMap_dwAutoHuntSkipItem[m_dwAutoHuntItemID]=fNow+c_fAutoHuntItemSkipTime; + m_dwAutoHuntItemID=0; __AutoHunt_ResetMovement(); + } else if (m_vecAutoHuntPath.empty() && MODE_CLICK_ITEM != m_eReservedMode) __ReserveClickItem(m_dwAutoHuntItemID); return; } @@ -902,9 +979,205 @@ void CPythonPlayer::__Update_AutoHunt() } else { - __ReserveClickItem(dwItemID); + __AutoHunt_ResetMovement(); + if (!__AutoHunt_MoveTo(kPPosItem,100.f,fNow)) { + m_kMap_dwAutoHuntSkipItem[dwItemID]=fNow+c_fAutoHuntItemSkipTime; + m_dwAutoHuntItemID=0; + } } break; } } } + +void CPythonPlayer::__AutoHunt_ResetMovement() +{ + m_vecAutoHuntPath.clear(); + m_fAutoHuntPathTime=0; + m_fAutoHuntPathMoveTime=0; + m_fAutoHuntTargetScanTime=0; +} + +bool CPythonPlayer::__AutoHunt_BuildPath(const TPixelPosition& goal, float arrival, std::vector& path) +{ + auto* main=NEW_GetMainActorPtr(); + auto& background=CPythonBackground::Instance(); + if (!main || !background.IsMapReady()) return false; + TPixelPosition start; main->NEW_GetPixelPosition(&start); + const float bound=std::max(float(m_kAutoHuntConfig.iRange),PixelDistance(start,m_kPPosAutoHuntAnchor))+200.f; + auto walkable=[&](AutoHuntNavigation::Point p) { + if (std::hypot(p.x-m_kPPosAutoHuntAnchor.x,p.y-m_kPPosAutoHuntAnchor.y)>bound) return false; + // Missing/unloaded terrain is not a traversable cell. A small footprint keeps turns off wall edges. + for (const auto offset : {AutoHuntNavigation::Point{0,0}, {25,0}, {-25,0}, {0,25}, {0,-25}}) { + BYTE attr=0; + if (!background.GetAttr(p.x+offset.x,p.y+offset.y,&attr) || (attr&CTerrainImpl::ATTRIBUTE_BLOCK)) return false; + } + struct CollisionProbe { + CDynamicSphereInstance sphere; + bool blocked=false; + void operator()(CGraphicObjectInstance* object) { + if (!blocked && object && object->CollisionDynamicSphere(sphere)) blocked=true; + } + } probe; + const float height=background.GetHeight(p.x,p.y); + probe.sphere.v3Position=D3DXVECTOR3(p.x,-p.y,height+50.f); + probe.sphere.v3LastPosition=probe.sphere.v3Position; + probe.sphere.fRadius=25.f; + CCullingManager::Instance().ForInRange(Vector3d(p.x,-p.y,height+50.f),100.f,&probe); + return !probe.blocked; + }; + const auto points=AutoHuntNavigation::FindPath({start.x,start.y},{goal.x,goal.y},arrival,walkable); + path.clear(); + for (const auto& point:points) { + TPixelPosition position=goal; position.x=point.x; position.y=point.y; + path.push_back(position); + } + return !path.empty(); +} + +bool CPythonPlayer::__AutoHunt_MoveTo(const TPixelPosition& goal, float arrival, float now) +{ + auto* main=NEW_GetMainActorPtr(); + if (!main) return false; + TPixelPosition here; main->NEW_GetPixelPosition(&here); + if (m_fAutoHuntPathTime<=0 || (now-m_fAutoHuntPathTime>=1.f && + (PixelDistance(goal,m_kAutoHuntPathGoal)>100.f || !main->IsWalking()))) { + if (!__AutoHunt_BuildPath(goal,arrival,m_vecAutoHuntPath)) return false; + m_kAutoHuntPathGoal=goal; m_fAutoHuntPathTime=now; + m_fAutoHuntPathMoveTime=0; + } + while (!m_vecAutoHuntPath.empty() && PixelDistance(here,m_vecAutoHuntPath.front())<=50.f) + m_vecAutoHuntPath.erase(m_vecAutoHuntPath.begin()); + // The final planned point may be an accessible attack/pickup position beside a blocked target cell. + if (m_vecAutoHuntPath.size()==1 && PixelDistance(here,m_vecAutoHuntPath.front())<=arrival) + m_vecAutoHuntPath.clear(); + if (m_vecAutoHuntPath.empty()) return true; + __ClearAutoAttackTargetActorID(); + __ClearReservedAction(); + if (!main->IsUsingSkill() && now>=m_fAutoHuntPathMoveTime) { + m_fAutoHuntPathMoveTime=now+0.5f; + main->NEW_MoveToDestPixelPositionDirection(m_vecAutoHuntPath.front()); + } + // Waypoint progress can temporarily increase distance to the enemy while going around a wall. + if (main->IsWalking() && PixelDistance(here,m_kAutoHuntPathLastPosition)>30.f) { + m_fAutoHuntProgressTime=now; + m_kAutoHuntPathLastPosition=here; + } + return true; +} + +void CPythonPlayer::AutoHunt_OnDeath() +{ + if (!AutoHunt_IsEnabled()) return; + if (!m_kAutoHuntConfig.bAutoRevive) { __AutoHunt_Stop(AUTO_HUNT_STOP_DEAD); return; } + if (m_eAutoHuntState==AUTO_HUNT_REVIVE) return; + __AutoHunt_CancelPull(); ++m_dwAutoHuntPullRequest; + __ClearAutoAttackTargetActorID(); __ClearReservedAction(); __AutoHunt_ResetMovement(); + m_dwAutoHuntTargetVID=0; m_dwAutoHuntItemID=0; + m_iAutoHuntMountState=0; + m_isAutoHuntGathering=false; m_kSet_dwAutoHuntGathered.clear(); m_iAutoHuntGatheredGroups=0; + m_eAutoHuntState=AUTO_HUNT_REVIVE; + m_fAutoHuntStateTime=CTimer::Instance().GetCurrentSecond(); + m_fAutoHuntReviveNextTime=m_fAutoHuntStateTime+10.f; +} + +bool CPythonPlayer::__AutoHunt_UpdateRecovery(CInstanceBase& main, float now) +{ + if (m_eAutoHuntState!=AUTO_HUNT_REVIVE && m_eAutoHuntState!=AUTO_HUNT_RECOVER) return false; + if (!m_kAutoHuntConfig.bAutoRevive) { __AutoHunt_Stop(AUTO_HUNT_STOP_DEAD); return true; } + if (main.IsDead()) { + if (now>=m_fAutoHuntReviveNextTime) { + CPythonNetworkStream::Instance().SendChatPacket("/restart_here"); + m_fAutoHuntReviveNextTime=now+10.f; // server decides cooldown and whether revival is legal + } + return true; + } + if (m_eAutoHuntState==AUTO_HUNT_REVIVE) { + m_eAutoHuntState=AUTO_HUNT_RECOVER; m_fAutoHuntStateTime=now; + __ClearAutoAttackTargetActorID(); __ClearReservedAction(); main.NEW_Stop(); + m_fAutoHuntNextPotionTime=0; + } + __AutoHunt_UpdatePotion(main,now); + const int maxHP=GetStatus(POINT_MAX_HP); + if (now-m_fAutoHuntStateTime<5.f || maxHP<=0 || int64_t(GetStatus(POINT_HP))*100=SKILL_MAX_NUM) return false; + CPythonSkill::TSkillData* data=nullptr; + if (!__AutoHunt_IsSkillReady(slot,&data)) return false; + if (data->IsAttackSkill()) { + auto* target=NEW_FindActorPtr(m_dwAutoHuntTargetVID); + if (!target || !__AutoHunt_IsTargetAllowed(*main,*target)) return false; + TPixelPosition goal; target->NEW_GetPixelPosition(&goal); + std::vector line; + if (!__AutoHunt_BuildPath(goal,0.f,line) || line.size()!=1 || PixelDistance(line.back(),goal)>1.f) return false; + } + if (main->IsMountingHorse() && !data->IsHorseSkill()) { + if (m_iAutoHuntMountState || !m_kAutoHuntConfig.bDismountSkill || nowNEW_Stop(); __AutoHunt_ResetMovement(); + if (!CPythonNetworkStream::Instance().SendChatPacket("/ride")) return false; + m_dwAutoHuntPendingSkill=m_playerStatus.aSkill[slot].dwIndex; + m_iAutoHuntMountState=1; m_fAutoHuntMountDeadline=now+8.f; + return true; + } + const bool cast=__UseSkill(slot); + if (cast && m_iAutoHuntMountState==2) m_fAutoHuntMountCastEnd=now+1.f; + return cast; +} + +bool CPythonPlayer::__AutoHunt_UpdateMount(CInstanceBase& main, float now) +{ + if (!m_iAutoHuntMountState) return false; + __ClearAutoAttackTargetActorID(); + if (m_iAutoHuntMountState==1) { + if (!main.IsMountingHorse()) { + m_iAutoHuntMountState=2; m_fAutoHuntMountDeadline=now+12.f; + m_fAutoHuntMountCastEnd=now+1.f; + } else if (now>=m_fAutoHuntMountDeadline) { + m_iAutoHuntMountState=0; m_fAutoHuntMountNextTime=now+30.f; + } + return true; + } + if (m_iAutoHuntMountState==3) { + if (main.IsMountingHorse() || now>=m_fAutoHuntMountDeadline) { + m_iAutoHuntMountState=0; m_fAutoHuntMountNextTime=now+10.f; + __AutoHunt_ResetMovement(); m_fAutoHuntProgressTime=now; m_fAutoHuntStateTime=now; + } + return true; + } + if (main.IsMountingHorse()) { m_iAutoHuntMountState=0; return true; } + if (now>=m_fAutoHuntMountDeadline && MODE_USE_SKILL==m_eReservedMode) __ClearReservedAction(); + if (main.IsUsingSkill() || MODE_USE_SKILL==m_eReservedMode) return true; + if (m_dwAutoHuntPendingSkill) { + const DWORD index=m_dwAutoHuntPendingSkill; m_dwAutoHuntPendingSkill=0; + DWORD slot=0; + if (std::find(m_kAutoHuntConfig.vecSkillIndices.begin(),m_kAutoHuntConfig.vecSkillIndices.end(),index)!=m_kAutoHuntConfig.vecSkillIndices.end() && + GetSkillSlotIndex(index,&slot)) { + CPythonSkill::TSkillData* data=nullptr; + auto* target=NEW_FindActorPtr(m_dwAutoHuntTargetVID); + if (__AutoHunt_IsSkillReady(slot,&data) && (!data->IsAttackSkill() || + (target && __AutoHunt_IsTargetAllowed(main,*target) && main.GetDistance(target)<=std::max(float(data->GetTargetRange()),c_fAutoHuntSkillDistance)))) { + if (__AutoHunt_CastSkill(slot,now)) { m_fAutoHuntNextSkillTime=now+0.5f; return true; } + } + } + } + // Finish other selected, ready skills while already on foot, then restore riding once animations end. + if (now8) { + int revive=0,dismount=0; + if (PyTuple_Size(poArgs)!=10 || !PyTuple_GetInteger(poArgs,8,&revive) || !PyTuple_GetInteger(poArgs,9,&dismount)) return Py_BuildException(); + kConfig.bAutoRevive=revive!=0; kConfig.bDismountSkill=dismount!=0; + } CPythonPlayer::Instance().AutoHunt_SetConfig(kConfig); return Py_BuildNone(); } +// PORT: skill indices persist independently from shortcut positions. +PyObject* playerAutoHuntGetSkills(PyObject*,PyObject*) +{ + auto& player=CPythonPlayer::Instance(); + const auto available=player.AutoHunt_GetAvailableSkills(); + const auto selected=player.AutoHunt_GetConfig().vecSkillIndices; + PyObject* choices=PyTuple_New(available.size()); + PyObject* selection=PyTuple_New(selected.size()); + for (size_t i=0;i32) { Py_DECREF(sequence); PyErr_SetString(PyExc_ValueError,"at most 32 skills"); return nullptr; } + std::vector indices; + for (Py_ssize_t i=0;i0 && index<=65535) indices.push_back(DWORD(index)); + } + Py_DECREF(sequence); CPythonPlayer::Instance().AutoHunt_SetSkills(indices); + return Py_BuildNone(); +} + // The auto hunt window's UTF-8 source strings in the locale's code page (locale.cfg), as the packed // locale_game.txt strings are. PyObject * playerAutoHuntLocalize(PyObject* poSelf, PyObject* poArgs) @@ -2417,6 +2453,8 @@ void initPlayer() { "AutoHuntGetState", playerAutoHuntGetState, METH_VARARGS }, { "AutoHuntGetConfig", playerAutoHuntGetConfig, METH_VARARGS }, { "AutoHuntSetConfig", playerAutoHuntSetConfig, METH_VARARGS }, + { "AutoHuntGetSkills", playerAutoHuntGetSkills, METH_VARARGS }, + { "AutoHuntSetSkills", playerAutoHuntSetSkills, METH_VARARGS }, { "AutoHuntLocalize", playerAutoHuntLocalize, METH_VARARGS }, { NULL, NULL, NULL }, diff --git a/tests/host/autohunt_navigation_test.cpp b/tests/host/autohunt_navigation_test.cpp new file mode 100644 index 00000000..5145a0dd --- /dev/null +++ b/tests/host/autohunt_navigation_test.cpp @@ -0,0 +1,36 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif +#include "../../src/port/UserInterface/AutoHuntNavigation.h" +#include +#include +using namespace AutoHuntNavigation; +int main() { + auto free=[](Point) { return true; }; + auto direct=FindPath({0,0},{1000,0},100,free); + assert(direct.size()==1 && direct.back().x==1000); + // A solid wall must be bypassed through its opening, with every returned segment traversable. + auto wall=[](Point p) { return !(p.x>=400 && p.x<=600 && p.y>-500 && p.y<500); }; + auto detour=FindPath({0,0},{1000,0},100,wall); + assert(detour.size()>1); + Point prev{0,0}; bool turned=false; + for (auto p:detour) { assert(Segment(prev,p,wall)); turned |= std::abs(p.y)>=500; prev=p; } + assert(turned && Distance(prev,{1000,0})<=100); + auto sealed=[](Point p) { return p.x<400 || p.x>600; }; + assert(FindPath({0,0},{1000,0},100,sealed).empty()); + // A nearby target across a thin wall is not reachable merely because it is in attack range. + auto thin=[](Point p) { return p.x<30 || p.x>70; }; + assert(FindPath({0,0},{100,0},150,thin).empty()); + // Corner cutting cannot pass between touching wall cells. + auto diagonal=[](Point p) { + return (p.x<50 && p.y<50) || (p.x>=50 && p.y>=50); + }; + assert(FindPath({0,0},{200,200},0,diagonal).empty()); + assert(FindPath({0,0},{1000,0},100,wall,1).empty()); + assert(FindPath({0,0},{1000,0},100,[](Point){return false;}).empty()); + assert(FindPath({0,0},{std::numeric_limits::quiet_NaN(),0},100,free).empty()); + // Start/finish and negative map-relative coordinates retain their exact positions. + auto negative=FindPath({-23,-47},{-1023,-47},0,free); + assert(negative.size()==1 && negative.back().x==-1023 && negative.back().y==-47); + std::puts("autohunt navigation: direct, detour, sealed, thin wall, corners, budget, unloaded and coordinates passed"); +} diff --git a/tests/port/autohunt_recovery_checks.inc b/tests/port/autohunt_recovery_checks.inc new file mode 100644 index 00000000..0b56f1d0 --- /dev/null +++ b/tests/port/autohunt_recovery_checks.inc @@ -0,0 +1,112 @@ +// PORT: actual Python UI/bindings and player frame loop, with actor changes simulating server acknowledgments. +if (server && CPythonPlayer::Instance().NEW_GetMainActorPtr()) { + auto& player=CPythonPlayer::Instance(); + auto* actor=player.NEW_GetMainActorPtr(); + const auto saved=player.AutoHunt_GetConfig(); + const int savedHP=player.GetStatus(POINT_HP), savedMaxHP=player.GetStatus(POINT_MAX_HP); + const int savedSP=player.GetStatus(POINT_SP), savedMaxSP=player.GetStatus(POINT_MAX_SP); + TPixelPosition position; actor->NEW_GetPixelPosition(&position); + auto config=saved; config.iGatherCount=0; config.iPickup=0; config.iHPPercent=0; config.iSPPercent=0; + config.bAttackStone=false; config.bAttackBoss=false; config.bAutoRevive=false; config.bDismountSkill=true; + config.vecSkillIndices.clear(); + player.AutoHunt_SetConfig(config); + player.SetStatus(POINT_HP,1000); player.SetStatus(POINT_MAX_HP,1000); + player.SetStatus(POINT_SP,1000); player.SetStatus(POINT_MAX_SP,1000); + const int previousIndex=player.GetSkillIndex(9), previousLevel=player.GetSkillLevel(9), previousGrade=player.GetSkillGrade(9); + player.SetSkill(9,19); player.SetSkillLevel_(19,0,1); + CHECK(py("import player, mt_autohunt\n" + "player.AutoHuntSetSkills([19,19,0,9999])\n" + "assert player.AutoHuntGetSkills()[0] == (19,)\n" + "assert 19 in player.AutoHuntGetSkills()[1]\n" + "mt_autohunt.g_dialog.Open()\n" + "assert 19 in mt_autohunt.g_dialog.skillIDs\n" + "assert mt_autohunt.g_dialog.selectedSkills == set([19])\n" + "assert mt_autohunt.g_dialog.GetHeight() <= 565\n" + "player.AutoHuntSetConfig(3500,0,0,0,0,0,0,0,0,1)\n" + "assert player.AutoHuntGetSkills()[0] == (19,)\n" + "mt_autohunt.g_dialog.Close()")); + // Saving unrelated settings/toggling a shortcut cannot change selected skill IDs. + DWORD shortcutType=0,shortcutPosition=0; + const DWORD globalShortcut=player.LocalQuickSlotIndexToGlobalQuickSlotIndex(0); + player.GetGlobalQuickSlotData(globalShortcut,&shortcutType,&shortcutPosition); + player.AddQuickSlot(globalShortcut,SLOT_TYPE_SKILL,9); + player.DeleteQuickSlot(globalShortcut); + CHECK(player.AutoHunt_GetConfig().vecSkillIndices==std::vector{19}); + player.AddQuickSlot(globalShortcut,char(shortcutType),WORD(shortcutPosition)); + CHECK(player.AutoHunt_GetConfig().vecSkillIndices==std::vector{19}); + // Recreate just the main actor, as the real server's mount status insert does. No game phase reset. + auto acknowledgeMount=[&](DWORD mount) { + auto* old=player.NEW_GetMainActorPtr(); TPixelPosition here; old->NEW_GetPixelPosition(&here); + CInstanceBase::SCreateData data={}; + data.m_bType=6; data.m_dwRace=0; data.m_dwVID=FakeLoginServer::kMainVID; + data.m_dwMovSpd=100; data.m_dwAtkSpd=100; data.m_dwWeapon=19; + data.m_lPosX=LONG(here.x); data.m_lPosY=LONG(here.y); data.m_stName=FakeLoginServer::kCharacterName; + data.m_isMain=true; data.m_dwMountVnum=mount; + CPythonCharacterManager::Instance().DeleteInstance(data.m_dwVID); + auto* fresh=CPythonCharacterManager::Instance().CreateInstance(data); + player.SetMainCharacterIndex(data.m_dwVID); + return fresh; + }; + auto rideCount=[&] { + int count=0; for (const auto& event:server->Events()) if (event=="game2:chat 0 /ride") ++count; + return count; + }; + TPixelPosition combatPosition; + CPythonCharacterManager::Instance().GetInstancePtr(FakeLoginServer::kMobVID)->NEW_GetPixelPosition(&combatPosition); + combatPosition.x+=1000; + actor->NEW_Stop(); actor->NEW_SetPixelPosition(combatPosition); + actor=acknowledgeMount(20103); + CHECK(actor && actor->IsMountingHorse() && !actor->IsInSafe()); + config=player.AutoHunt_GetConfig(); config.bDismountSkill=false; player.AutoHunt_SetConfig(config); + const int disabledRideCount=rideCount(); + player.AutoHunt_SetEnabled(true); pump_frames(12); + CHECK(player.AutoHunt_GetMountState()==0 && rideCount()==disabledRideCount && actor->IsMountingHorse()); + player.AutoHunt_SetEnabled(false); + config.bDismountSkill=true; player.AutoHunt_SetConfig(config); + player.AutoHunt_SetEnabled(true); + CHECK(pump_until(3,[&] { return player.AutoHunt_GetMountState()==1 && rideCount()==disabledRideCount+1; })); + player.SetAttackKeyState(true); player.SetAttackKeyState(false); + CHECK(player.AutoHunt_GetState()==CPythonPlayer::AUTO_HUNT_PAUSED && player.AutoHunt_GetMountState()==0); + player.AutoHunt_SetEnabled(false); pump_frames(12); + CHECK(rideCount()==disabledRideCount+1); // canceled cycle cannot send its eventual remount + const int beforeRide=rideCount(); + player.AutoHunt_SetEnabled(true); + CHECK(pump_until(3,[&] { return rideCount()==beforeRide+1 && player.AutoHunt_GetMountState()==1; })); + CHECK(!player.IsSkillCoolTime(9)); // no cast before the dismount acknowledgment + actor=acknowledgeMount(0); + CHECK(actor && !actor->IsMountingHorse()); + CHECK(pump_until(4,[&] { return player.IsSkillCoolTime(9); })); + CHECK(pump_until(5,[&] { return rideCount()==beforeRide+2 && player.AutoHunt_GetMountState()==3; })); + actor=acknowledgeMount(20103); + CHECK(pump_until(2,[&] { return player.AutoHunt_GetMountState()==0; })); + CHECK(actor && actor->IsMountingHorse()); + player.AutoHunt_SetEnabled(false); + actor=acknowledgeMount(0); + player.AutoHunt_SetSkills({}); + // Default remains stop-on-death. Opt-in waits for a real alive actor and recovery HP. + player.AutoHunt_SetEnabled(true); actor->Die(); player.NotifyDeadMainCharacter(); + CHECK(!player.AutoHunt_IsEnabled() && player.AutoHunt_GetStopReason()==CPythonPlayer::AUTO_HUNT_STOP_DEAD); + actor->Revive(); actor->NEW_Stop(); + config=player.AutoHunt_GetConfig(); config.bAutoRevive=true; player.AutoHunt_SetConfig(config); + player.AutoHunt_SetEnabled(true); actor->Die(); player.SetStatus(POINT_HP,0); player.NotifyDeadMainCharacter(); + CHECK(player.AutoHunt_IsEnabled() && player.AutoHunt_GetState()==CPythonPlayer::AUTO_HUNT_REVIVE); + CHECK(pump_until(12,[&] { return server->Has("game2:chat 0 /restart_here"); })); + CHECK(player.AutoHunt_GetState()==CPythonPlayer::AUTO_HUNT_REVIVE); // command alone cannot revive locally + actor->Revive(); player.SetStatus(POINT_HP,100); + CHECK(pump_until(2,[&] { return player.AutoHunt_GetState()==CPythonPlayer::AUTO_HUNT_RECOVER; })); + pump_frames(330); // > 5 seconds even at custom 60 Hz; low HP must still prevent combat + CHECK(player.AutoHunt_GetState()==CPythonPlayer::AUTO_HUNT_RECOVER); + player.SetStatus(POINT_HP,700); + CHECK(pump_until(7,[&] { return player.AutoHunt_GetState()!=CPythonPlayer::AUTO_HUNT_RECOVER; })); + CHECK(player.AutoHunt_IsEnabled()); + player.AutoHunt_SetEnabled(false); + // A stopped recovery cannot emit later revival or riding commands. + CHECK(player.AutoHunt_GetMountState()==0); + player.SetSkillLevel_(19,0,0); player.SetSkill(9,previousIndex); + if (previousIndex) player.SetSkillLevel_(previousIndex,previousGrade,previousLevel); + player.AutoHunt_SetConfig(saved); + player.SetStatus(POINT_HP,savedHP); player.SetStatus(POINT_MAX_HP,savedMaxHP); + player.SetStatus(POINT_SP,savedSP); player.SetStatus(POINT_MAX_SP,savedMaxSP); + actor->NEW_Stop(); actor->NEW_SetPixelPosition(position); + pump_frames(30); +} diff --git a/tests/port/port_login_flow_test.cpp b/tests/port/port_login_flow_test.cpp index a9806170..47af07d0 100644 --- a/tests/port/port_login_flow_test.cpp +++ b/tests/port/port_login_flow_test.cpp @@ -393,9 +393,10 @@ int main(int argc, char** argv) }); CHECK(loading && !failed()); CHECK(py_true(loaded)); - // 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()); + // CG_CLIENT_VERSION is sent before introLoading's background-load steps. Wait for those + // script frames rather than racing the server thread's receipt of the version packet. + CHECK(pump_until(live ? 10 : 5, [&] { return failed() || !PythonBoot::CurrentMapName().empty(); }) && + !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 @@ -745,6 +746,7 @@ int main(int argc, char** argv) } } #include "autohunt_pull_checks.inc" + #include "autohunt_recovery_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,