Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions docs/develop/zzz/application/charge_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- **每日重置**:按游戏刷新日每天首次运行前清零所有计划的已运行次数。
- **恢复电量**:体力不足时按配置使用储蓄电量、以太电池或两者。
- **双倍活动**:开启后在每次运行首次打开快捷手册时检查实战模拟室双倍次数;命中时生成临时计划优先执行,不写入正式计划列表。
- **换队时限**:每条计划单独配置,范围为 0 到 600 秒;0 表示关闭。普通计划和双倍活动计划都可以用“S级时限”按钮填入当前副本的推荐值,也可以继续手工输入。开启后,实战模拟室、区域巡防、专业挑战室和体力模式恶名狩猎在战斗失败或超过时限时按预备编队顺序换队。
- **合成电池**:固定需要 60 电量;进入“传送”节点后按分类分流,点击快捷手册资源栏的以太电池区域,再用全屏 OCR 点击“合成”进入道具处理界面。按一次最大批量合成计一次计划,合成素材不足时遵循“跳过计划”配置。

## 主要流程
Expand Down Expand Up @@ -141,6 +142,19 @@
6. **战斗结束**:战斗完成后更新运行次数
7. **判断下一次**:根据剩余次数决定是否继续战斗

### 5.1 战斗失败或超时换队

- “S级时限”按钮按副本填入推荐值:实战模拟室和区域巡防为 120 秒,专业挑战室为 180 秒,恶名狩猎为 300 秒。按钮只负责填入数值,不会在切换副本时覆盖已有的自定义时限。
- 换队时限从“自动战斗”节点开始计时,不包含选队、加载和暂停时间。
- 自动战斗先检查结算结果,再判断时限。当前画面已经离开战斗态时不会按配置时限退出,避免把正常结算误判成超时。
- 配置时限达到后复用已有的“战斗菜单 → 退出战斗 → 确认 → 退出失败结果”流程。原有 600 秒硬超时仍按旧的战斗超时状态结束,不触发换队。
- 功能开启时,普通战斗失败也进入换队流程。体力模式恶名狩猎会直接撤退,不再选择“倒带”重试同一队。
- 当前使用游戏内配队(`-1`)时切到预备编队 1;当前使用预备编队 N 时切到 N+1。切换后立即保存配置,退出后保留当前副本和卡片数量,复用“下一步”节点进入出战页,再重新选队并出战。该流程不会重新传送、选副本或配置卡片数量。
- 退出后无法识别“下一步”或“预备编队”入口时,按对应操作失败结束,不回退到完整副本流程。
- 双倍活动临时计划只把新的预备编队写回双倍活动配置,不保存临时的运行次数和卡片数量。
- 到达最后一个预备编队后不回绕。开启“跳过计划”时继续下一条计划,关闭时结束本轮。
- 该判断不识别结算页的实际评级。用户配置的换队时限就是“无法达到目标用时”的判断标准。

### 6. 电量不足处理

**电量不足触发条件**:
Expand Down Expand Up @@ -206,8 +220,13 @@ graph TD
H -->|合成电池| EB["点击以太电池 → OCR点击合成 → 合成确认"]
H --> K["识别副本分类"]
K --> L["执行对应副本操作"]
L --> M{"副本执行结果"}
M -->|成功/失败| N["挑战完成"]
L --> T["下一步 → 选队 → 出战"]
T --> R["战斗"]
R -->|"失败/配置时限,有后续编队"| S["退出结果 → 写回下一队"]
S --> T
R -->|"没有后续编队"| Q
R -->|"副本结束"| M{"副本执行结果"}
M -->|成功,或未启用换队的失败| N["挑战完成"]
M -->|电量不足/特殊跳过| Q["跳过或结束计划"]
EB -->|成功| N
EB -->|素材不足| Q
Expand All @@ -225,3 +244,4 @@ graph TD
5. **每日清零**:可按游戏刷新日重置已运行次数
6. **多副本支持**:统一的流程框架适配不同副本类型
7. **状态持久化**:记录执行进度,支持断点续传
8. **顺序换队**:按每条计划的战斗时限和失败结果切换后续预备编队,不循环回绕
18 changes: 15 additions & 3 deletions docs/game/gameplay/combat.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,22 @@ involves_screens: []
# 战斗系统

## 机制
(自由描述:战斗核心机制、角色切换、属性克制等。)

- 实战模拟室、区域巡防、专业挑战室和恶名狩猎等挑战会经过编队、出战、战斗和结算。
- 部分挑战目标包含通关用时要求。自动化可以用用户设置的战斗时限近似判断当前队伍是否无法达到目标用时,但这不等同于识别结算评级。
- 体力计划提供的 S 级时限预设为:实战模拟室 120 秒、区域巡防 120 秒、专业挑战室 180 秒、恶名狩猎 300 秒。预设按各玩法的 S 级通关目标取值,仍允许玩家按队伍情况修改。
- 战斗过程中退出或战斗失败不会算作正常通关,只有进入正常结算流程后才增加体力计划的已运行次数。

## 流程与决策点
(自由描述:进入战斗 → 编队 → 开打 → 结算 的画面流转;关键决策点。)

1. 在副本入口选择预备编队并出战。
2. 战斗画面出现后开始自动战斗;选队和加载阶段不属于实际战斗时间。
3. 敌人被击败后进入正常结算;角色全灭会进入失败结果。
4. 战斗中可以打开暂停菜单,选择退出战斗并确认,随后离开失败结果画面回到当前副本的准备或配置页。
5. 体力计划启用换队时限后,战斗失败或实际战斗时间达到用户设置的阈值会退出。脚本先点击“下一步”进入出战页;如果已经在出战页,则直接选择下一支预备编队。

## 备注
(自由记录。)

- 体力计划的换队时限不读取结算页的 S 评级;S 级时限按钮只是把玩法对应的目标时间填入配置。
- 预备编队按配置中的顺序使用;到最后一队后不从第一队重新开始。
- 原地换队会保留已选副本和卡片数量;若“下一步”或出战页无法识别,按对应操作失败处理。
10 changes: 9 additions & 1 deletion docs/game/screens/战斗画面.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
screen_name: 战斗画面
appears_in: [实战模拟室, 区域巡防, 专业挑战室, 恶名狩猎, 式舆防卫战, 迷失之地, 枯萎之都, 零号空洞]
last_updated: 2026-07-18
last_updated: 2026-08-21
source_image: screens/战斗画面/默认.webp
---

Expand All @@ -23,6 +23,14 @@ source_image: screens/战斗画面/默认.webp
| 限时关卡 | 限时挑战开始 | 同上;右上角多倒计时 |
| 战斗结果 | 敌人全灭 | 点完成/再来一次/退出/撤退/已达成/倒带→下一画面 |

**主动退出流转**:

```text
战斗画面左上角菜单 → 战斗-菜单“退出战斗” → “退出战斗-确认” → 战斗-挑战结果-失败“退出” → 当前副本准备页(部分玩法需再点“下一步”进入出战页)
```

体力计划的配置时限到达时复用这条已有流转,回到当前副本准备页后复用已有“下一步”流程进入出战页,再打开“预备编队”并重新出战。普通失败结果使用“撤退”;体力模式恶名狩猎启用自动换队后也使用“撤退”,不选择“倒带”。这些区域已经存在于 `battle.yml`、`battle_menu.yml` 和 `battle_result_fail.yml`,无需新增画面区域。

## 识别特征(稳定锚点)

- **战斗态稳定锚点**:`按键-普通攻击` 模板(conf ~0.97)。其余 UI 元素(血条/计时/连携条/技能图标/角色头像)随角色、敌人、数值动态变化,**不可做标识**。注意:此锚点只在战斗画面内稳定,不是全局唯一精确标识(详见备注「误匹配隐患」)。
Expand Down
72 changes: 67 additions & 5 deletions src/zzz_od/application/charge_plan/charge_plan_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class ChargePlanApp(ZApplication):
STATUS_NO_PLAN: ClassVar[str] = '没有可运行的计划'
STATUS_ROUND_FINISHED: ClassVar[str] = '已完成一轮计划'
STATUS_FIND_NEXT_PLAN: ClassVar[str] = '继续查找下一个计划'
STATUS_TEAM_EXHAUSTED: ClassVar[str] = charge_plan_const.STATUS_TEAM_EXHAUSTED

def __init__(self, ctx: ZContext):
ZApplication.__init__(
Expand Down Expand Up @@ -292,27 +293,80 @@ def check_mission_type(self) -> OperationRoundResult:
@node_from(from_name='识别副本分类', status='实战模拟室')
@operation_node(name='实战模拟室')
def combat_simulation(self) -> OperationRoundResult:
op = CombatSimulation(self.ctx, self.current_plan)
op = CombatSimulation(self.ctx, self.current_plan, switch_team_callback=self.switch_team)
return self.round_by_op_result(op.execute())

@node_from(from_name='识别副本分类', status='区域巡防')
@operation_node(name='区域巡防')
def area_patrol(self) -> OperationRoundResult:
op = AreaPatrol(self.ctx, self.current_plan)
op = AreaPatrol(self.ctx, self.current_plan, switch_team_callback=self.switch_team)
return self.round_by_op_result(op.execute())

@node_from(from_name='识别副本分类', status='专业挑战室')
@operation_node(name='专业挑战室')
def expert_challenge(self) -> OperationRoundResult:
op = ExpertChallenge(self.ctx, self.current_plan)
op = ExpertChallenge(self.ctx, self.current_plan, switch_team_callback=self.switch_team)
return self.round_by_op_result(op.execute())

@node_from(from_name='识别副本分类', status='恶名狩猎')
@operation_node(name='恶名狩猎')
def notorious_hunt(self) -> OperationRoundResult:
op = NotoriousHunt(self.ctx, self.current_plan, use_charge_power=True)
op = NotoriousHunt(
self.ctx,
self.current_plan,
use_charge_power=True,
switch_team_callback=self.switch_team,
)
return self.round_by_op_result(op.execute())

def switch_team(self, reason: str) -> bool:
"""切换到当前计划的下一支预备编队并保存配置。

Args:
reason: 触发换队的原因,用于日志记录。

Returns:
是否找到并写回了下一支预备编队。
"""
if self.current_plan is None:
log.error('体力计划触发换队时当前计划为空')
return False

team_list = self.ctx.team_config.team_list
old_team_idx = self.current_plan.predefined_team_idx
next_team_idx = 0 if old_team_idx == -1 else old_team_idx + 1

if old_team_idx == -1:
old_team_name = '游戏内配队'
elif 0 <= old_team_idx < len(team_list):
old_team_name = team_list[old_team_idx].name
else:
old_team_name = f'预备编队 {old_team_idx + 1}'

if next_team_idx >= len(team_list):
log.info('体力计划触发换队:%s;%s 后没有可用的后续预备编队', reason, old_team_name)
return False

next_team_name = team_list[next_team_idx].name
self.current_plan.predefined_team_idx = next_team_idx
self.current_plan.skipped = False
self.last_tried_plan = None

if self.current_plan is self.temp_plan:
saved_plan = self.config.combat_simulation_double_reward_config
saved_plan.predefined_team_idx = next_team_idx
self.config.combat_simulation_double_reward_config = saved_plan
else:
self.config.save()

log.info(
'体力计划触发换队:%s;%s -> %s',
reason,
old_team_name,
next_team_name,
)
return True

@node_from(from_name='实战模拟室', success=True)
@node_from(from_name='实战模拟室', success=False)
@node_from(from_name='区域巡防', success=True)
Expand Down Expand Up @@ -345,13 +399,21 @@ def challenge_complete(self) -> OperationRoundResult:
@node_from(from_name='专业挑战室', status=ChooseNextOrFinishAfterBattle.STATUS_AGENT_PLAN_FINISHED)
@node_from(from_name='恶名狩猎', status=ChooseNextOrFinishAfterBattle.STATUS_AGENT_PLAN_FINISHED)
@node_from(from_name='传送', success=False, status='找不到 代理人方案培养')
@node_from(from_name='实战模拟室', success=False, status=STATUS_TEAM_EXHAUSTED)
@node_from(from_name='区域巡防', success=False, status=STATUS_TEAM_EXHAUSTED)
@node_from(from_name='专业挑战室', success=False, status=STATUS_TEAM_EXHAUSTED)
@node_from(from_name='恶名狩猎', success=False, status=STATUS_TEAM_EXHAUSTED)
@operation_node(name='跳过或结束计划')
def skip_plan_or_finish(self) -> OperationRoundResult:
is_agent_plan = self.current_plan.is_agent_plan
is_blocked_by_left_times = (
self.previous_node.status == NotoriousHunt.STATUS_BLOCKED_BY_LEFT_TIMES
)
if self.config.skip_plan or is_agent_plan or is_blocked_by_left_times:
is_team_exhausted = self.previous_node.status == ChargePlanApp.STATUS_TEAM_EXHAUSTED
should_skip = self.config.skip_plan or (
not is_team_exhausted and (is_agent_plan or is_blocked_by_left_times)
)
if should_skip:
# 标记当前计划为跳过,继续尝试下一个
self.current_plan.skipped = True
self.last_tried_plan = self.current_plan
Expand Down
4 changes: 4 additions & 0 deletions src/zzz_od/application/charge_plan/charge_plan_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class ChargePlanItem:
notorious_hunt_buff_num: int = 1 # 恶名狩猎 选择的buff
plan_id: str | None = None # 计划的唯一标识符
skipped: bool = field(default=False, repr=False, metadata={'persist': False}) # 单次运行中是否跳过
battle_timeout_seconds: int = 0 # 换队时限,0为关闭

def __post_init__(self) -> None:
if self.plan_id is None:
Expand Down Expand Up @@ -365,6 +366,9 @@ def validate_item(cls, ctx: 'ZContext', item: 'ChargePlanItem') -> str | None:

合法返 None,非法返原因(含合法值)。供 MCP config 工具写入前校验。
"""
if not 0 <= item.battle_timeout_seconds <= 600:
return f'battle_timeout_seconds 必须在 0..600 之间(当前: {item.battle_timeout_seconds})'

categories = [c.value for c in ctx.compendium_service.get_charge_plan_category_list()]
if item.category_name not in categories:
return f'category {item.category_name} 不合法(合法: {categories})'
Expand Down
10 changes: 10 additions & 0 deletions src/zzz_od/application/charge_plan/charge_plan_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,13 @@
DEFAULT_GROUP = True
PRIORITY = 600
NEED_NOTIFY = True

STATUS_SWITCH_TEAM = '需要切换配队'
STATUS_TEAM_EXHAUSTED = '没有后续预备编队'

S_RANK_BATTLE_TIMEOUT_SECONDS: dict[str, int] = {
'实战模拟室': 120,
'区域巡防': 120,
'专业挑战室': 180,
'恶名狩猎': 300,
}
12 changes: 11 additions & 1 deletion src/zzz_od/backend/config_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def _build_list_fields(
def _validate_hint_for(app_id: str) -> str:
"""各 config 的校验提示。"""
hints = {
'charge_plan': 'category/mission_type/mission_name 必须在 compendium 合法;card_num 仅实战模拟室',
'charge_plan': 'category/mission_type/mission_name 必须在 compendium 合法;card_num 仅实战模拟室;battle_timeout_seconds 范围 0..600',
'notorious_hunt': 'mission_type 必须在 compendium(恶名狩猎域)合法',
'standalone_app': 'app_id 必须已注册(is_app_registered)',
'_group': 'app_id 必须已注册;add 不支持(app 由注册注入)',
Expand Down Expand Up @@ -301,6 +301,16 @@ def _build_routes() -> dict[str, RouterEntry]:
{'name': 'run_times', 'type': 'int', 'required': False, 'default': 0, 'note': '运行次数(可手工修正,如手工跑了一次后调整)'},
{'name': 'auto_battle_config', 'type': 'str', 'required': False, 'default': '全配队通用'},
{'name': 'predefined_team_idx', 'type': 'int', 'required': False, 'default': -1},
{
'name': 'battle_timeout_seconds',
'type': 'int',
'required': False,
'default': 0,
'note': (
'换队时限,0 为关闭,范围 0..600 秒;'
'S级预设:实战模拟室/区域巡防 120,专业挑战室 180,恶名狩猎 300'
),
},
{'name': 'card_num', 'type': 'enum', 'required': False, 'enum_cls': CardNumEnum, 'applicability': '仅实战模拟室'},
{'name': 'level', 'type': 'str', 'required': False, 'default': '默认等级'},
],
Expand Down
Loading