Files
Live-streaming/app/bettergi_daily.py
T

829 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""BetterGI managed configuration helpers for daily and party commands."""
from __future__ import annotations
import copy
import difflib
import json
import os
import re
import tempfile
import uuid
from dataclasses import dataclass, replace
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable
class DailyAutomationError(ValueError):
"""Raised when a managed BetterGI command cannot be prepared safely."""
DAILY_MODE_NONE = "none"
DAILY_MODE_DOMAIN = "domain"
DAILY_MODE_LEY_LINE = "ley_line"
DAILY_MODE_COMMISSION = "commission"
LEY_LINE_COUNTRIES = ("蒙德", "璃月", "稻妻", "须弥", "枫丹", "纳塔", "挪德卡莱")
LEY_LINE_TYPE_ALIASES = {
"经验": "启示之花",
"经验花": "启示之花",
"蓝花": "启示之花",
"启示": "启示之花",
"启示之花": "启示之花",
"摩拉": "藏金之花",
"摩拉花": "藏金之花",
"金币": "藏金之花",
"金币花": "藏金之花",
"黄花": "藏金之花",
"藏金": "藏金之花",
"藏金之花": "藏金之花",
}
DAILY_TASK_NAMES = {
"mail": "领取邮件",
"craft_resin": "合成树脂",
"domain": "自动秘境",
"ley_line": "自动地脉花",
"commission": "每日委托",
"serenitea": "领取尘歌壶奖励",
"daily_reward": "领取每日奖励",
}
_INVALID_BGI_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_NAME_NORMALIZE = re.compile(r"[\s\-_—-·.。,::,、/|]+")
_MEMBER_SEPARATOR = re.compile(r"[\s,,、/|]+")
@dataclass(frozen=True)
class DailyRequest:
mode: str = DAILY_MODE_NONE
domain_name: str = ""
ley_line_type: str = ""
ley_line_country: str = ""
@property
def task_name(self) -> str:
if self.mode == DAILY_MODE_DOMAIN:
return f"自动每日(秘境:{self.domain_name}"
if self.mode == DAILY_MODE_LEY_LINE:
return f"自动每日(地脉:{self.ley_line_type}/{self.ley_line_country}"
if self.mode == DAILY_MODE_COMMISSION:
return "自动每日(委托)"
return "自动每日"
@property
def summary(self) -> str:
if self.mode == DAILY_MODE_DOMAIN:
return f"秘境 {self.domain_name}"
if self.mode == DAILY_MODE_LEY_LINE:
return f"地脉 {self.ley_line_type} {self.ley_line_country}"
if self.mode == DAILY_MODE_COMMISSION:
return "每日委托"
return "跳过其他任务"
@dataclass(frozen=True)
class JsonUpdate:
path: Path
data: dict[str, Any]
reason: str
@dataclass(frozen=True)
class PreparedDailyRun:
request: DailyRequest
config_name: str
updates: tuple[JsonUpdate, ...]
requires_current_party: bool = False
@property
def task_name(self) -> str:
return self.request.task_name
def _normalize_name(value: str) -> str:
return _NAME_NORMALIZE.sub("", str(value or "").strip().casefold())
def _safe_bgi_name(value: str, label: str) -> str:
name = str(value or "").strip()
if not name:
raise DailyAutomationError(f"{label}不能为空")
if name in {".", ".."} or _INVALID_BGI_NAME.search(name):
raise DailyAutomationError(f"{label}包含非法文件名字符: {name}")
return name
def _read_json_object(path: Path, label: str) -> dict[str, Any]:
if not path.exists():
raise DailyAutomationError(f"未找到{label}: {path}")
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
raise DailyAutomationError(f"读取{label}失败: {exc}") from exc
if not isinstance(data, dict):
raise DailyAutomationError(f"{label}必须是 JSON 对象: {path}")
return data
def parse_daily_request(argument: str) -> DailyRequest:
text = str(argument or "").strip()
if not text:
return DailyRequest()
parts = text.split()
mode = parts[0]
if mode == "秘境":
domain_name = " ".join(parts[1:]).strip()
if not domain_name:
raise DailyAutomationError("秘境模式需要指定秘境,例如:自动每日 秘境 风本")
return DailyRequest(mode=DAILY_MODE_DOMAIN, domain_name=domain_name)
if mode in {"地脉", "地脉花"}:
if len(parts) != 3:
raise DailyAutomationError("地脉模式格式:自动每日 地脉 <经验|摩拉> <国家>")
type_name = LEY_LINE_TYPE_ALIASES.get(_normalize_name(parts[1]))
if not type_name:
raise DailyAutomationError("地脉花类型仅支持经验或摩拉")
country = parts[2].strip()
if country not in LEY_LINE_COUNTRIES:
raise DailyAutomationError(
f"不支持的地脉国家'{country}',可用:{'、'.join(LEY_LINE_COUNTRIES)}"
)
return DailyRequest(
mode=DAILY_MODE_LEY_LINE,
ley_line_type=type_name,
ley_line_country=country,
)
if mode in {"委托", "每日委托"}:
if len(parts) != 1:
raise DailyAutomationError("委托模式不接受额外参数,格式:自动每日 委托")
return DailyRequest(mode=DAILY_MODE_COMMISSION)
raise DailyAutomationError(
"每日模式仅支持:秘境、地脉、委托;不指定模式时直接发送“自动每日”"
)
class DomainAliasResolver:
def __init__(self, alias_path: Path, bettergi_work_dir: Path):
self.alias_path = Path(alias_path)
self.bettergi_work_dir = Path(bettergi_work_dir)
def _available_domains(self) -> list[str]:
settings_path = (
self.bettergi_work_dir
/ "User"
/ "JsScript"
/ "AutoDomain"
/ "settings.json"
)
if not settings_path.exists():
raise DailyAutomationError(
f"未找到 BGI 自动秘境设置文件,请安装或更新 AutoDomain 脚本: {settings_path}"
)
try:
settings = json.loads(settings_path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
raise DailyAutomationError(f"读取 BGI 秘境列表失败: {exc}") from exc
if not isinstance(settings, list):
raise DailyAutomationError("BGI AutoDomain/settings.json 格式无效")
for item in settings:
if isinstance(item, dict) and item.get("name") == "domainName":
options = item.get("options")
if isinstance(options, list):
domains = [str(value).strip() for value in options if str(value).strip()]
if domains:
return domains
raise DailyAutomationError("BGI AutoDomain/settings.json 中没有秘境名称列表")
def resolve(self, raw_name: str) -> str:
available = self._available_domains()
available_set = set(available)
alias_data = _read_json_object(self.alias_path, "秘境俗称文件")
lookup: dict[str, str] = {}
display_terms: dict[str, str] = {}
def add(term: str, canonical: str) -> None:
normalized = _normalize_name(term)
if not normalized:
return
previous = lookup.get(normalized)
if previous and previous != canonical:
raise DailyAutomationError(
f"秘境俗称'{term}'同时指向'{previous}'和'{canonical}'"
)
lookup[normalized] = canonical
display_terms[normalized] = str(term).strip()
for canonical in available:
add(canonical, canonical)
for canonical, aliases in alias_data.items():
if str(canonical).startswith("_"):
continue
canonical_name = str(canonical).strip()
if canonical_name not in available_set:
raise DailyAutomationError(
f"秘境俗称文件中的正式名称不受当前 BGI 支持: {canonical_name}"
)
if not isinstance(aliases, list):
raise DailyAutomationError(f"秘境'{canonical_name}'的俗称必须是数组")
add(canonical_name, canonical_name)
for alias in aliases:
add(str(alias), canonical_name)
normalized_input = _normalize_name(raw_name)
resolved = lookup.get(normalized_input)
if resolved:
return resolved
matches = difflib.get_close_matches(normalized_input, list(lookup), n=3, cutoff=0.45)
if matches:
suggestions = []
for match in matches:
canonical = lookup[match]
display = display_terms.get(match, canonical)
suggestion = canonical if display == canonical else f"{display}({canonical})"
if suggestion not in suggestions:
suggestions.append(suggestion)
raise DailyAutomationError(
f"未知秘境'{raw_name}',可能是:{'、'.join(suggestions)}"
)
raise DailyAutomationError(f"未知秘境'{raw_name}',请检查 config/domain_aliases.json")
def _validate_strategy(work_dir: Path, strategy_name: str, label: str) -> None:
strategy = str(strategy_name or "").strip()
if not strategy:
raise DailyAutomationError(f"{label}未配置战斗策略")
auto_fight_dir = work_dir / "User" / "AutoFight"
if strategy == "根据队伍自动选择":
if not auto_fight_dir.is_dir():
raise DailyAutomationError(f"{label}战斗策略目录不存在: {auto_fight_dir}")
return
json_path = auto_fight_dir / f"{strategy}.json"
txt_path = auto_fight_dir / f"{strategy}.txt"
if not json_path.is_file() and not txt_path.is_file():
raise DailyAutomationError(f"{label}战斗策略文件不存在: {strategy}")
def _template_task_id_candidates(template: dict[str, Any]) -> dict[str, list[str]]:
enabled = template.get("TaskEnabledList")
order = template.get("TaskOrder")
definitions = template.get("TaskDefinitions")
if not isinstance(enabled, dict):
raise DailyAutomationError("一条龙模板缺少 TaskEnabledList 对象")
if order is None:
order = []
if not isinstance(order, list):
raise DailyAutomationError("一条龙模板缺少 TaskOrder 数组")
if definitions is None:
definitions = {}
if not isinstance(definitions, dict):
# BetterGI 为每个一条龙配置独立生成任务 ID,只能按任务名复用模板 ID。
raise DailyAutomationError("一条龙模板的 TaskDefinitions 必须是对象")
ordered_ids: list[str] = []
for raw_id in [*order, *definitions.keys(), *enabled.keys()]:
task_id = str(raw_id or "").strip()
if task_id and task_id not in ordered_ids:
ordered_ids.append(task_id)
candidates: dict[str, list[str]] = {}
old_format = not definitions
for task_id in ordered_ids:
raw_name = task_id if old_format else definitions.get(task_id)
task_name = str(raw_name or "").strip()
if not task_name:
continue
candidates.setdefault(task_name, []).append(task_id)
return candidates
def _build_task_entries(
template: dict[str, Any],
task_keys: list[str],
) -> list[tuple[str, str]]:
candidates = _template_task_id_candidates(template)
reserved_ids = {
task_id
for ids in candidates.values()
for task_id in ids
}
used_ids: set[str] = set()
entries: list[tuple[str, str]] = []
for task_key in task_keys:
task_name = DAILY_TASK_NAMES[task_key]
task_id = next(
(candidate for candidate in candidates.get(task_name, []) if candidate not in used_ids),
"",
)
while not task_id:
candidate = str(uuid.uuid4())
if candidate not in reserved_ids and candidate not in used_ids:
task_id = candidate
used_ids.add(task_id)
reserved_ids.add(task_id)
entries.append((task_id, task_name))
return entries
def _build_one_dragon_config(
template: dict[str, Any],
request: DailyRequest,
managed_name: str,
ley_line_craft_resin_before: bool,
) -> dict[str, Any]:
config = copy.deepcopy(template)
task_keys = ["mail"]
if request.mode != DAILY_MODE_LEY_LINE or ley_line_craft_resin_before:
task_keys.append("craft_resin")
if request.mode == DAILY_MODE_DOMAIN:
task_keys.append("domain")
elif request.mode == DAILY_MODE_LEY_LINE:
task_keys.append("ley_line")
elif request.mode == DAILY_MODE_COMMISSION:
task_keys.append("commission")
task_keys.extend(["serenitea", "daily_reward"])
task_entries = _build_task_entries(template, task_keys)
task_order = [task_id for task_id, _ in task_entries]
config["TaskEnabledList"] = {task_id: True for task_id in task_order}
config["TaskOrder"] = task_order
config["TaskDefinitions"] = dict(task_entries)
config["Name"] = managed_name
config["NextTaskId"] = ""
config["CompletionAction"] = "无"
if request.mode == DAILY_MODE_DOMAIN:
config["WeeklyDomainEnabled"] = False
config["DomainName"] = request.domain_name
if request.mode == DAILY_MODE_LEY_LINE:
config["LeyLineOneDragonMode"] = True
config["LeyLineResinExhaustionMode"] = True
config["LeyLineOpenModeCountMin"] = False
config["LeyLineRunCount"] = 1
for day in (
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
):
config[f"LeyLineRun{day}"] = True
config[f"LeyLine{day}Type"] = request.ley_line_type
config[f"LeyLine{day}Country"] = request.ley_line_country
return config
def _validate_commission_group(
work_path: Path,
*,
use_current_party: bool,
) -> bool:
group_path = work_path / "User" / "ScriptGroup" / "每日委托.json"
group = _read_json_object(group_path, "每日委托配置组")
projects = group.get("projects")
if not isinstance(projects, list):
raise DailyAutomationError("BGI 配置组“每日委托”缺少 projects 数组")
enabled_projects = [
project
for project in projects
if isinstance(project, dict)
and str(project.get("status", "Enabled")).casefold() != "disabled"
]
if not enabled_projects:
raise DailyAutomationError("BGI 配置组“每日委托”没有启用的可执行项目")
uses_auto_commission_nova = any(
str(project.get("folderName") or "") == "AutoCommissionNova"
for project in enabled_projects
)
if not uses_auto_commission_nova:
return False
user_config_path = (
work_path
/ "User"
/ "JsScript"
/ "AutoCommissionNova"
/ "Data"
/ "user-config.json"
)
if not user_config_path.is_file():
raise DailyAutomationError(
"AutoCommissionNova 尚未完成首次配置:缺少 Data/user-config.json"
"请先在 BGI 中手动运行脚本并保存用户配置和战斗策略"
)
user_config = _read_json_object(user_config_path, "AutoCommissionNova 用户配置")
party = user_config.get("party")
global_party = party.get("global") if isinstance(party, dict) else None
if not isinstance(global_party, dict):
raise DailyAutomationError("AutoCommissionNova 用户配置缺少 party.global 对象")
missing = []
if not use_current_party:
if not str(global_party.get("battleTeamName") or "").strip():
missing.append("战斗队伍")
if not str(global_party.get("elementTeamName") or "").strip():
missing.append("元素采集队伍")
if missing:
raise DailyAutomationError(
f"AutoCommissionNova 首次配置不完整,缺少:{'、'.join(missing)}"
)
strategy_name = str(
global_party.get("battleStrategy") or "根据队伍自动选择"
).strip()
_validate_strategy(work_path, strategy_name, "每日委托")
return bool(use_current_party)
def prepare_daily_run(
work_dir: str | Path,
alias_path: str | Path,
argument: str,
*,
template_name: str,
managed_name: str,
ley_line_craft_resin_before: bool,
commission_use_current_party: bool = False,
) -> PreparedDailyRun:
work_path = Path(work_dir)
if not work_path.is_dir():
raise DailyAutomationError(f"BetterGI 工作目录不存在: {work_path}")
template = _safe_bgi_name(template_name, "一条龙模板名称")
managed = _safe_bgi_name(managed_name, "托管一条龙名称")
if template == managed:
raise DailyAutomationError("一条龙模板名称不能与托管配置名称相同")
request = parse_daily_request(argument)
updates: list[JsonUpdate] = []
requires_current_party = False
user_config_path = work_path / "User" / "config.json"
if request.mode == DAILY_MODE_DOMAIN:
resolved = DomainAliasResolver(Path(alias_path), work_path).resolve(request.domain_name)
request = replace(request, domain_name=resolved)
user_config = _read_json_object(user_config_path, "BGI User/config.json")
auto_fight = user_config.get("autoFightConfig")
auto_domain = user_config.get("autoDomainConfig")
if not isinstance(auto_fight, dict) or not isinstance(auto_domain, dict):
raise DailyAutomationError("当前 BGI 缺少自动战斗或自动秘境配置,请升级到 0.63.0+")
_validate_strategy(work_path, auto_fight.get("strategyName", ""), "自动秘境")
corrected = copy.deepcopy(user_config)
corrected["autoDomainConfig"]["specifyResinUse"] = False
if corrected != user_config:
updates.append(JsonUpdate(user_config_path, corrected, "关闭自动秘境指定树脂次数"))
elif request.mode == DAILY_MODE_LEY_LINE:
user_config = _read_json_object(user_config_path, "BGI User/config.json")
auto_fight = user_config.get("autoFightConfig")
ley_line = user_config.get("autoLeyLineOutcropConfig")
if not isinstance(auto_fight, dict) or not isinstance(ley_line, dict):
raise DailyAutomationError("当前 BGI 缺少自动战斗或自动地脉花配置,请升级到 0.63.0+")
fight_config = ley_line.get("fightConfig")
strategy_name = ""
if isinstance(fight_config, dict):
strategy_name = str(fight_config.get("strategyName") or "").strip()
if not strategy_name:
strategy_name = str(auto_fight.get("strategyName") or "").strip()
_validate_strategy(work_path, strategy_name, "自动地脉花")
if ley_line.get("friendshipTeam") and not ley_line.get("team"):
raise DailyAutomationError("BGI 自动地脉花配置了好感队,但未配置战斗队伍")
corrected = copy.deepcopy(user_config)
corrected["autoLeyLineOutcropConfig"]["isGoToSynthesizer"] = False
if corrected != user_config:
updates.append(JsonUpdate(user_config_path, corrected, "关闭地脉花内部合成树脂"))
elif request.mode == DAILY_MODE_COMMISSION:
requires_current_party = _validate_commission_group(
work_path,
use_current_party=bool(commission_use_current_party),
)
template_path = work_path / "User" / "OneDragon" / f"{template}.json"
template_config = _read_json_object(template_path, "一条龙模板")
managed_config = _build_one_dragon_config(
template_config,
request,
managed,
bool(ley_line_craft_resin_before),
)
managed_path = work_path / "User" / "OneDragon" / f"{managed}.json"
updates.append(JsonUpdate(managed_path, managed_config, "生成直播自动每日一条龙"))
return PreparedDailyRun(
request=request,
config_name=managed,
updates=tuple(updates),
requires_current_party=requires_current_party,
)
def prepare_commission_current_party_update(
work_dir: str | Path,
party_name: str,
) -> JsonUpdate:
work_path = Path(work_dir)
name = str(party_name or "").strip()
if not name:
raise DailyAutomationError("当前队伍名称为空")
if len(name) > 20 or any(ord(char) < 32 for char in name):
raise DailyAutomationError(f"当前队伍名称格式无效: {name}")
user_config_path = (
work_path
/ "User"
/ "JsScript"
/ "AutoCommissionNova"
/ "Data"
/ "user-config.json"
)
user_config = _read_json_object(user_config_path, "AutoCommissionNova 用户配置")
corrected = copy.deepcopy(user_config)
party = corrected.get("party")
global_party = party.get("global") if isinstance(party, dict) else None
if not isinstance(global_party, dict):
raise DailyAutomationError("AutoCommissionNova 用户配置缺少 party.global 对象")
global_party["battleTeamName"] = name
global_party["elementTeamName"] = name
return JsonUpdate(
user_config_path,
corrected,
f"将 AutoCommissionNova 战斗及元素采集队伍更新为当前队伍“{name}”",
)
def _prepare_script_group_update(
work_dir: str | Path,
group_name: str,
folder_name: str,
settings_patch: dict[str, Any],
) -> JsonUpdate:
work_path = Path(work_dir)
safe_group = _safe_bgi_name(group_name, "配置组名称")
group_path = work_path / "User" / "ScriptGroup" / f"{safe_group}.json"
group = _read_json_object(group_path, f"配置组“{safe_group}”")
projects = group.get("projects")
if not isinstance(projects, list):
raise DailyAutomationError(f"配置组“{safe_group}”缺少 projects 数组")
matches = [
project
for project in projects
if isinstance(project, dict)
and str(project.get("folderName") or "") == folder_name
and str(project.get("status", "Enabled")).casefold() != "disabled"
]
if not matches:
raise DailyAutomationError(
f"配置组“{safe_group}”中没有启用的 {folder_name} JavaScript 项目"
)
if len(matches) > 1:
raise DailyAutomationError(
f"配置组“{safe_group}”包含多个启用的 {folder_name} 项目,请只保留一个"
)
settings = matches[0].get("jsScriptSettingsObject")
if not isinstance(settings, dict):
settings = {}
matches[0]["jsScriptSettingsObject"] = settings
settings.update(settings_patch)
return JsonUpdate(group_path, group, f"更新配置组“{safe_group}”参数")
def prepare_switch_party_update(work_dir: str | Path, party_name: str) -> JsonUpdate:
party = str(party_name or "").strip()
if not party:
raise DailyAutomationError("队伍名称不能为空")
return _prepare_script_group_update(
work_dir,
"切换队伍",
"AcceleratedEditionSwitchParty",
{"partyName": party},
)
def _add_character_lookup(
lookup: dict[str, str],
ambiguous: set[str],
raw_name: str,
canonical: str,
) -> None:
key = _normalize_name(raw_name)
if not key or key in ambiguous:
return
previous = lookup.get(key)
if previous and previous != canonical:
lookup.pop(key, None)
ambiguous.add(key)
return
lookup[key] = canonical
def _character_lookups_from_settings(
data: Any,
) -> tuple[dict[str, str], dict[str, str]] | None:
if not isinstance(data, list):
return None
position_options: dict[str, list[str]] = {}
for item in data:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "")
if name not in {"position1", "position2", "position3", "position4"}:
continue
options = item.get("options")
if isinstance(options, list):
position_options[name] = [
str(option).strip()
for option in options
if str(option).strip()
]
if len(position_options) != 4 or not position_options.get("position1"):
return None
full_lookup: dict[str, str] = {}
simple_lookup: dict[str, str] = {}
ambiguous_full: set[str] = set()
ambiguous_simple: set[str] = set()
for option in position_options["position1"]:
_add_character_lookup(full_lookup, ambiguous_full, option, option)
simple_name = option.rsplit("-", 1)[-1].strip()
_add_character_lookup(simple_lookup, ambiguous_simple, simple_name, option)
if not simple_lookup:
return None
return full_lookup, simple_lookup
def _character_lookups_from_combat_avatar(
data: Any,
) -> tuple[dict[str, str], dict[str, str]] | None:
if not isinstance(data, list):
return None
full_lookup: dict[str, str] = {}
simple_lookup: dict[str, str] = {}
ambiguous_full: set[str] = set()
ambiguous_simple: set[str] = set()
for item in data:
if not isinstance(item, dict):
continue
canonical = str(item.get("name") or "").strip()
if not canonical:
continue
terms = [canonical]
aliases = item.get("alias")
if isinstance(aliases, list):
terms.extend(str(alias).strip() for alias in aliases if str(alias).strip())
for term in terms:
_add_character_lookup(full_lookup, ambiguous_full, term, canonical)
_add_character_lookup(simple_lookup, ambiguous_simple, term, canonical)
if not simple_lookup:
return None
return full_lookup, simple_lookup
def _load_character_options(work_dir: Path) -> tuple[dict[str, str], dict[str, str]]:
script_dir = work_dir / "User" / "JsScript" / "AutoSwitchRoles"
settings_path = script_dir / "settings.json"
avatar_path = script_dir / "combat_avatar.json"
failures: list[str] = []
if settings_path.exists():
try:
settings_data = json.loads(settings_path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
failures.append(f"settings.json 读取失败: {exc}")
else:
lookups = _character_lookups_from_settings(settings_data)
if lookups:
return lookups
failures.append("settings.json 未提供四个队员位置的 options")
else:
failures.append("缺少 settings.json")
if avatar_path.exists():
try:
avatar_data = json.loads(avatar_path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
failures.append(f"combat_avatar.json 读取失败: {exc}")
else:
lookups = _character_lookups_from_combat_avatar(avatar_data)
if lookups:
return lookups
failures.append("combat_avatar.json 中没有可用角色")
else:
failures.append("缺少 combat_avatar.json")
raise DailyAutomationError(
"AutoSwitchRoles 角色数据不可用,请安装或更新“配对界面切换角色”脚本: "
+ "".join(failures)
)
def _resolve_member_token(
token: str,
full_lookup: dict[str, str],
simple_lookup: dict[str, str],
) -> str:
key = _normalize_name(token)
resolved = full_lookup.get(key) or simple_lookup.get(key)
if not resolved:
raise DailyAutomationError(f"未知或有歧义的角色名称: {token}")
return resolved
def _split_contiguous_members(text: str, simple_lookup: dict[str, str]) -> list[str]:
normalized = _normalize_name(text)
candidates = sorted(simple_lookup, key=len, reverse=True)
@lru_cache(maxsize=None)
def walk(offset: int, slots: int) -> tuple[tuple[str, ...], ...]:
if slots == 4:
return ((),) if offset == len(normalized) else ()
if offset >= len(normalized):
return ()
results: list[tuple[str, ...]] = []
for candidate in candidates:
if not normalized.startswith(candidate, offset):
continue
for remainder in walk(offset + len(candidate), slots + 1):
results.append((candidate, *remainder))
if len(results) >= 2:
return tuple(results)
return tuple(results)
segmentations = walk(0, 0)
if not segmentations:
raise DailyAutomationError("队员必须是4人,请使用空格、逗号、顿号或斜杠分隔")
if len(segmentations) > 1:
raise DailyAutomationError("连续角色名存在多种拆分方式,请使用空格分隔四名角色")
return [simple_lookup[key] for key in segmentations[0]]
def resolve_party_members(work_dir: str | Path, argument: str) -> tuple[list[str], list[str]]:
text = str(argument or "").strip()
if not text:
raise DailyAutomationError("队员必须是4人")
full_lookup, simple_lookup = _load_character_options(Path(work_dir))
parts = [part for part in _MEMBER_SEPARATOR.split(text) if part]
if len(parts) == 4:
resolved = [
_resolve_member_token(part, full_lookup, simple_lookup)
for part in parts
]
else:
resolved = _split_contiguous_members(text, simple_lookup)
if len(resolved) != 4:
raise DailyAutomationError("队员必须是4人")
if len(set(resolved)) != 4:
raise DailyAutomationError("四名队员不能重复")
display_names = [value.rsplit("-", 1)[-1] for value in resolved]
return resolved, display_names
def prepare_edit_party_update(
work_dir: str | Path,
argument: str,
) -> tuple[JsonUpdate, tuple[str, ...]]:
resolved, display_names = resolve_party_members(work_dir, argument)
update = _prepare_script_group_update(
work_dir,
"修改队员",
"AutoSwitchRoles",
{f"position{index + 1}": value for index, value in enumerate(resolved)},
)
return update, tuple(display_names)
def _write_json_atomic(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(
prefix=f".{path.name}.",
suffix=".tmp",
dir=str(path.parent),
)
temp_path = Path(temp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, path)
finally:
if temp_path.exists():
temp_path.unlink()
def apply_json_updates(updates: Iterable[JsonUpdate]) -> None:
seen: set[Path] = set()
for update in updates:
path = Path(update.path)
resolved = path.resolve()
if resolved in seen:
raise DailyAutomationError(f"同一配置文件被重复更新: {path}")
seen.add(resolved)
_write_json_atomic(path, update.data)