feat: publish sanitized Linux cloud-game MAA adapter
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
# MAA Online 用户配置
|
||||
|
||||
`daily.example.toml` 是公开模板。执行 `install.sh` 后会复制为被 Git 忽略的 `daily.toml`,并建立以下链接:
|
||||
|
||||
```text
|
||||
config/tasks/daily.toml
|
||||
-> user_config/daily.toml
|
||||
```
|
||||
|
||||
## 使用前必须确认
|
||||
|
||||
下面这些参数会改变游戏资源或账号行为。首次运行前请逐项确认。
|
||||
|
||||
### 全局参数
|
||||
|
||||
| 参数 | 当前值 | 可填写值/作用 |
|
||||
|---|---:|---|
|
||||
| `client_type` | `Official` | 官服用 `Official`;B服用 `Bilibili` |
|
||||
| `startup` | `true` | 运行任务前自动启动并进入游戏 |
|
||||
| `closedown` | `true` | 全部任务结束后退出网易云游戏实例 |
|
||||
|
||||
### 清理理智 `Fight`
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|---|---:|---|
|
||||
| `stage` | `1-7` | 必填偏好。要刷的关卡,如 `1-7`、`CE-6`、`LS-6`、`AP-5`、活动关卡 |
|
||||
| `medicine` | `0` | 最多使用多少瓶普通理智药;`0` 表示不用 |
|
||||
| `medicine_expire_days` | `0` | 使用多少天内将过期的理智药;`0` 表示不用 |
|
||||
| `stone` | `0` | 最多碎多少颗源石;强烈建议保持 `0` |
|
||||
| `times` | `999` | 最大作战次数;理智不足时仍会停止 |
|
||||
| `series` | `0` | `0` 自动选择最大代理倍率,`1` 单次代理,其他正数为指定倍率 |
|
||||
| `report_to_penguin` | `false` | 是否匿名上报掉落至企鹅物流 |
|
||||
| `report_to_yituliu` | `false` | 是否匿名上报至一图流 |
|
||||
|
||||
修改关卡示例:
|
||||
|
||||
```toml
|
||||
stage = "CE-6"
|
||||
```
|
||||
|
||||
只刷十次:
|
||||
|
||||
```toml
|
||||
times = 10
|
||||
```
|
||||
|
||||
### 自动公招 `Recruit`
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|---|---:|---|
|
||||
| `refresh` | `true` | 是否刷新低价值三星标签,会消耗游戏内免费刷新次数 |
|
||||
| `select` | `[5, 4]` | 允许选择的标签组合等级 |
|
||||
| `confirm` | `[4, 3]` | 允许自动确认的标签等级;高价值组合不自动确认 |
|
||||
| `first_tags` | `高级资深干员` | 三星组合时优先考虑的标签;通常无需修改 |
|
||||
| `extra_tags_mode` | `0` | `0` 默认;`1` 尽量选三个标签;`2` 尽量选择更多高星标签 |
|
||||
| `times` | `4` | 本轮最多处理/发起多少次招募,会消耗招聘许可 |
|
||||
| `set_time` | `true` | 是否自动设置招募时间 |
|
||||
| `expedite` | `false` | 是否使用加急许可;建议保持 `false` |
|
||||
| `preserve_tags` | 高资、资深、支援机械 | 识别到任一标签时保留槽位,留给人工确认 |
|
||||
| `recruitment_time` | 540 分钟 | 三星和四星组合默认设置为 9 小时 |
|
||||
| `server` | `CN` | 国服为 `CN`,国际服可为 `US`、`JP`、`KR` |
|
||||
|
||||
如果只想识别公招、不自动确认和发起招募:
|
||||
|
||||
```toml
|
||||
confirm = []
|
||||
times = 0
|
||||
```
|
||||
|
||||
### 基建换班 `Infrast`
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|---|---:|---|
|
||||
| `mode` | `20000` | 已启用一键轮换;`0` 为自动计算换班,`10000` 为自定义排班 |
|
||||
| `facility` | 七类常用设施 | 要处理的设施及顺序 |
|
||||
| `drones` | `_NotUse` | 不使用无人机;也可填 `Money`、`CombatRecord`、`PureGold` 等 |
|
||||
| `threshold` | `0.3` | 心情低于此比例时参与换班判断 |
|
||||
| `dorm_notstationed_enabled` | `false` | 是否将未进驻干员放入宿舍 |
|
||||
| `dorm_trust_enabled` | `false` | 是否优先安排需要提升信赖的干员进宿舍 |
|
||||
|
||||
设施名称:
|
||||
|
||||
```text
|
||||
Mfg 制造站
|
||||
Trade 贸易站
|
||||
Power 发电站
|
||||
Control 控制中枢
|
||||
Reception 会客室
|
||||
Office 办公室
|
||||
Dorm 宿舍
|
||||
Processing 加工站
|
||||
Training 训练室
|
||||
```
|
||||
|
||||
### 信用商店 `Mall`
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|---|---:|---|
|
||||
| `visit_friends` | `true` | 访问好友基建获取信用 |
|
||||
| `shopping` | `true` | 是否自动购买信用商店商品 |
|
||||
| `buy_first` | 招聘许可、龙门币 | 优先购买列表,按顺序购买 |
|
||||
| `blacklist` | 家具零件、加急许可 | 第二轮购物时不购买的商品 |
|
||||
| `force_shopping_if_credit_full` | `false` | 信用将溢出时是否无视黑名单;建议保持 `false` |
|
||||
| `only_buy_discount` | `false` | 第二轮是否只购买折扣商品 |
|
||||
| `reserve_max_credit` | `true` | 信用高于 300 时可继续购买其他非黑名单商品;降至 300 以下后停止第二轮购物 |
|
||||
| `credit_fight` | `false` | 是否借助战打一局 OF-1 获取次日信用;会产生额外操作 |
|
||||
| `formation_index` | `0` | 信用战使用的编队;`0` 当前编队,`1`~`4` 对应编队栏 |
|
||||
|
||||
完全禁止自动购物,但仍访问好友:
|
||||
|
||||
```toml
|
||||
visit_friends = true
|
||||
shopping = false
|
||||
```
|
||||
|
||||
### 奖励领取 `Award`
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|---|---:|---|
|
||||
| `award` | `true` | 领取每日和每周任务奖励 |
|
||||
| `mail` | `true` | 领取所有邮件奖励 |
|
||||
| `recruit` | `false` | 是否领取限定池每日免费单抽 |
|
||||
| `orundum` | `false` | 是否领取幸运墙等活动合成玉奖励 |
|
||||
|
||||
## 交互式编辑
|
||||
|
||||
运行:
|
||||
|
||||
```bash
|
||||
bin/maa-online-config
|
||||
```
|
||||
|
||||
使用数字选择“全局”或某个任务,再选择具体配置项。字符串可以直接输入;列表既可以输入完整 TOML,也可以用逗号分隔,例如:
|
||||
|
||||
```text
|
||||
1-7
|
||||
招聘许可,龙门币
|
||||
5,4
|
||||
```
|
||||
|
||||
在主菜单输入 `s` 保存。每次保存前会自动备份到 `user_config/backups/`,随后运行 MAA dry-run;若验证失败会自动回滚。输入 `q` 退出,`Ctrl+C` 会取消未保存的修改。
|
||||
|
||||
## 检查配置但不执行
|
||||
|
||||
每次修改后先运行:
|
||||
|
||||
```bash
|
||||
bin/maa-online run daily --dry-run --batch
|
||||
```
|
||||
|
||||
出现任务列表且没有 Error,表示 TOML 格式和参数能够被解析。
|
||||
|
||||
## 正式执行
|
||||
|
||||
终端一:
|
||||
|
||||
```bash
|
||||
bin/maa-online-server
|
||||
```
|
||||
|
||||
终端二:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:22888/start
|
||||
bin/maa-online run daily --batch -v \
|
||||
--log-file=maa-daily.log
|
||||
```
|
||||
|
||||
## 紧急停止
|
||||
|
||||
停止当前 MAA:
|
||||
|
||||
```bash
|
||||
pkill -INT -f '/runtime/bin/maa'
|
||||
```
|
||||
|
||||
退出网易云游戏实例:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:22888/exit
|
||||
```
|
||||
|
||||
## 示例模板设置
|
||||
|
||||
公开示例模板会:
|
||||
|
||||
- 使用基建一键轮换模式;
|
||||
- 最多处理四次公招,但保留高资、资深和支援机械标签;
|
||||
- 刷 `1-7` 直到理智不足;
|
||||
- 不吃理智药;
|
||||
- 绝不碎石;
|
||||
- 不使用加急许可;
|
||||
- 访问好友;优先购买指定商品,信用高于 300 时不限折扣购买其他非黑名单商品;
|
||||
- 不无视信用商店黑名单;
|
||||
- 领取每日/每周任务奖励;
|
||||
- 领取所有邮件;
|
||||
- 任务结束后退出云游戏。
|
||||
@@ -0,0 +1,82 @@
|
||||
# MAA Online 一键日常配置
|
||||
# 修改参数前请阅读同目录 README.md。
|
||||
|
||||
client_type = "Official"
|
||||
startup = true
|
||||
closedown = true
|
||||
|
||||
# 1. 基建一键轮换
|
||||
[[tasks]]
|
||||
name = "基建一键轮换"
|
||||
type = "Infrast"
|
||||
|
||||
[tasks.params]
|
||||
mode = 20000
|
||||
facility = ["Mfg", "Trade", "Power", "Control", "Reception", "Office", "Dorm"]
|
||||
drones = "_NotUse"
|
||||
threshold = 0.3
|
||||
dorm_notstationed_enabled = false
|
||||
dorm_trust_enabled = false
|
||||
|
||||
# 2. 自动公招
|
||||
[[tasks]]
|
||||
name = "自动公招"
|
||||
type = "Recruit"
|
||||
|
||||
[tasks.params]
|
||||
refresh = true
|
||||
select = [5, 4]
|
||||
confirm = [4, 3]
|
||||
first_tags = ["高级资深干员"]
|
||||
extra_tags_mode = 0
|
||||
times = 4
|
||||
set_time = true
|
||||
expedite = false
|
||||
preserve_tags = ["高级资深干员", "资深干员", "支援机械"]
|
||||
recruitment_time = { "3" = 540, "4" = 540 }
|
||||
report_to_penguin = false
|
||||
report_to_yituliu = false
|
||||
server = "CN"
|
||||
|
||||
# 3. 清理理智:默认刷 1-7,不吃药、不碎石
|
||||
[[tasks]]
|
||||
name = "清理理智"
|
||||
type = "Fight"
|
||||
|
||||
[tasks.params]
|
||||
stage = "1-7"
|
||||
medicine = 0
|
||||
medicine_expire_days = 0
|
||||
stone = 0
|
||||
times = 999
|
||||
series = 0
|
||||
report_to_penguin = false
|
||||
report_to_yituliu = false
|
||||
client_type = "Official"
|
||||
|
||||
# 4. 好友信用与信用商店
|
||||
[[tasks]]
|
||||
name = "信用商店"
|
||||
type = "Mall"
|
||||
|
||||
[tasks.params]
|
||||
visit_friends = true
|
||||
shopping = true
|
||||
buy_first = ["招聘许可", "龙门币"]
|
||||
blacklist = ["家具零件", "加急许可"]
|
||||
force_shopping_if_credit_full = false
|
||||
only_buy_discount = false
|
||||
reserve_max_credit = true
|
||||
credit_fight = false
|
||||
formation_index = 0
|
||||
|
||||
# 5. 领取日常和周常任务奖励
|
||||
[[tasks]]
|
||||
name = "领取奖励"
|
||||
type = "Award"
|
||||
|
||||
[tasks.params]
|
||||
award = true
|
||||
mail = true
|
||||
recruit = false
|
||||
orundum = false
|
||||
Executable
+256
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interactive, comment-preserving editor for MAA Online daily.toml."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import tomllib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(os.getenv("MAA_ONLINE_ROOT", Path(__file__).resolve().parents[1]))
|
||||
DEFAULT_FILE = Path(os.getenv("MAA_ONLINE_CONFIG_FILE", Path(__file__).with_name("daily.toml")))
|
||||
MAA_DRY_RUN = [
|
||||
str(ROOT / "bin/maa-online"),
|
||||
"run",
|
||||
"daily",
|
||||
"--dry-run",
|
||||
"--batch",
|
||||
]
|
||||
KEY_RE = re.compile(r"^(?P<indent>\s*)(?P<key>[A-Za-z0-9_]+)(?P<space>\s*=\s*)(?P<value>.*?)(?P<newline>\r?\n)?$")
|
||||
|
||||
|
||||
def toml_literal(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
return repr(value)
|
||||
if isinstance(value, list):
|
||||
return "[" + ", ".join(toml_literal(item) for item in value) + "]"
|
||||
if isinstance(value, dict):
|
||||
pairs = [f"{toml_literal(str(key))} = {toml_literal(item)}" for key, item in value.items()]
|
||||
return "{ " + ", ".join(pairs) + " }"
|
||||
raise TypeError(f"不支持的值类型:{type(value).__name__}")
|
||||
|
||||
|
||||
def parse_value(raw: str, current: Any) -> Any:
|
||||
text = raw.strip()
|
||||
if isinstance(current, bool):
|
||||
aliases = {
|
||||
"true": True, "yes": True, "y": True, "1": True, "是": True, "开": True,
|
||||
"false": False, "no": False, "n": False, "0": False, "否": False, "关": False,
|
||||
}
|
||||
value = aliases.get(text.lower())
|
||||
if value is None:
|
||||
raise ValueError("布尔值请输入 true/false、yes/no、是/否或开/关")
|
||||
return value
|
||||
|
||||
if isinstance(current, str) and not text.startswith(('"', "'")):
|
||||
return text
|
||||
|
||||
if isinstance(current, list) and not text.startswith("["):
|
||||
parts = [part.strip() for part in text.split(",") if part.strip()]
|
||||
if not parts:
|
||||
return []
|
||||
sample = current[0] if current else ""
|
||||
if isinstance(sample, int):
|
||||
return [int(part) for part in parts]
|
||||
return parts
|
||||
|
||||
try:
|
||||
return tomllib.loads("value = " + text)["value"]
|
||||
except (tomllib.TOMLDecodeError, KeyError) as exc:
|
||||
raise ValueError(f"无法解析 TOML 值:{exc}") from exc
|
||||
|
||||
|
||||
def same_kind(current: Any, value: Any) -> bool:
|
||||
if isinstance(current, bool):
|
||||
return isinstance(value, bool)
|
||||
if isinstance(current, int) and not isinstance(current, bool):
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
if isinstance(current, float):
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
return isinstance(value, type(current))
|
||||
|
||||
|
||||
class ConfigEditor:
|
||||
def __init__(self, path: Path, maa_validate: bool = True):
|
||||
self.path = path.resolve()
|
||||
self.maa_validate = maa_validate and self.path == DEFAULT_FILE.resolve()
|
||||
self.original_text = self.path.read_text(encoding="utf-8")
|
||||
self.text = self.original_text
|
||||
|
||||
def data(self) -> dict[str, Any]:
|
||||
return tomllib.loads(self.text)
|
||||
|
||||
def sections(self) -> list[tuple[str, int | None, dict[str, Any]]]:
|
||||
data = self.data()
|
||||
global_values = {key: value for key, value in data.items() if key != "tasks"}
|
||||
result: list[tuple[str, int | None, dict[str, Any]]] = [("全局", None, global_values)]
|
||||
for index, task in enumerate(data.get("tasks", [])):
|
||||
label = f"{task.get('name', task.get('type', '任务'))} [{task.get('type', '?')}]"
|
||||
result.append((label, index, task.get("params", {})))
|
||||
return result
|
||||
|
||||
def replace(self, task_index: int | None, key: str, value: Any) -> None:
|
||||
lines = self.text.splitlines(keepends=True)
|
||||
current_task = -1
|
||||
in_params = False
|
||||
for index, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped == "[[tasks]]":
|
||||
current_task += 1
|
||||
in_params = False
|
||||
continue
|
||||
if stripped == "[tasks.params]":
|
||||
in_params = True
|
||||
continue
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
in_params = False
|
||||
|
||||
matches_section = current_task == -1 if task_index is None else current_task == task_index and in_params
|
||||
if not matches_section:
|
||||
continue
|
||||
match = KEY_RE.match(line)
|
||||
if not match or match.group("key") != key:
|
||||
continue
|
||||
newline = match.group("newline") or ""
|
||||
lines[index] = f"{match.group('indent')}{key}{match.group('space')}{toml_literal(value)}{newline}"
|
||||
self.text = "".join(lines)
|
||||
self.data() # 再次解析,确保生成结果仍是合法 TOML。
|
||||
return
|
||||
raise KeyError(f"找不到配置项:{key}")
|
||||
|
||||
def save(self) -> Path:
|
||||
self.data()
|
||||
backup_dir = self.path.parent / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
backup = backup_dir / f"{self.path.name}.{stamp}.bak"
|
||||
suffix = 1
|
||||
while backup.exists():
|
||||
backup = backup_dir / f"{self.path.name}.{stamp}.{suffix}.bak"
|
||||
suffix += 1
|
||||
shutil.copy2(self.path, backup)
|
||||
|
||||
mode = self.path.stat().st_mode & 0o777
|
||||
fd, temp_name = tempfile.mkstemp(prefix=f".{self.path.name}.", dir=self.path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
||||
handle.write(self.text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temp_name, mode)
|
||||
os.replace(temp_name, self.path)
|
||||
except Exception:
|
||||
Path(temp_name).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
if self.maa_validate:
|
||||
result = subprocess.run(MAA_DRY_RUN, text=True, capture_output=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
shutil.copy2(backup, self.path)
|
||||
detail = (result.stderr or result.stdout).strip()
|
||||
raise RuntimeError(f"MAA dry-run 失败,已自动回滚:\n{detail}")
|
||||
print(result.stdout.strip())
|
||||
|
||||
self.original_text = self.text
|
||||
return backup
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return self.text != self.original_text
|
||||
|
||||
|
||||
def edit_section(editor: ConfigEditor, task_index: int | None, label: str) -> None:
|
||||
while True:
|
||||
section = next(values for section_label, index, values in editor.sections() if section_label == label and index == task_index)
|
||||
keys = list(section)
|
||||
print(f"\n=== {label} ===")
|
||||
for number, key in enumerate(keys, 1):
|
||||
print(f"{number:2}. {key} = {toml_literal(section[key])}")
|
||||
print(" b. 返回")
|
||||
choice = input("选择配置项:").strip().lower()
|
||||
if choice in {"b", "back", "返回", ""}:
|
||||
return
|
||||
try:
|
||||
key = keys[int(choice) - 1]
|
||||
except (ValueError, IndexError):
|
||||
print("无效选择。")
|
||||
continue
|
||||
|
||||
current = section[key]
|
||||
print(f"当前值:{toml_literal(current)}")
|
||||
raw = input("新值(直接回车取消;列表可用逗号分隔):")
|
||||
if not raw.strip():
|
||||
continue
|
||||
try:
|
||||
value = parse_value(raw, current)
|
||||
if not same_kind(current, value):
|
||||
raise ValueError(f"类型不匹配,需要 {type(current).__name__}")
|
||||
editor.replace(task_index, key, value)
|
||||
print(f"已暂存:{key} = {toml_literal(value)}")
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
print(f"修改失败:{exc}")
|
||||
|
||||
|
||||
def interactive(editor: ConfigEditor) -> int:
|
||||
while True:
|
||||
print("\n=== MAA Online TOML 配置编辑器 ===")
|
||||
for number, (label, _, _) in enumerate(editor.sections(), 1):
|
||||
print(f"{number:2}. {label}")
|
||||
print(" s. 保存并验证" + (" *" if editor.changed else ""))
|
||||
print(" q. 退出")
|
||||
choice = input("请选择:").strip().lower()
|
||||
if choice in {"q", "quit", "退出"}:
|
||||
if editor.changed:
|
||||
confirm = input("存在未保存修改,确认丢弃?[y/N] ").strip().lower()
|
||||
if confirm not in {"y", "yes", "是"}:
|
||||
continue
|
||||
return 0
|
||||
if choice in {"s", "save", "保存"}:
|
||||
if not editor.changed:
|
||||
print("没有需要保存的修改。")
|
||||
continue
|
||||
try:
|
||||
backup = editor.save()
|
||||
print(f"保存成功;备份:{backup}")
|
||||
except Exception as exc:
|
||||
print(f"保存失败:{exc}")
|
||||
continue
|
||||
try:
|
||||
label, task_index, _ = editor.sections()[int(choice) - 1]
|
||||
except (ValueError, IndexError):
|
||||
print("无效选择。")
|
||||
continue
|
||||
edit_section(editor, task_index, label)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="交互式编辑 MAA Online TOML 配置")
|
||||
parser.add_argument("--file", type=Path, default=DEFAULT_FILE, help="要编辑的 TOML 文件")
|
||||
parser.add_argument("--no-maa-validate", action="store_true", help="保存时不运行 MAA dry-run")
|
||||
args = parser.parse_args()
|
||||
if not args.file.is_file():
|
||||
parser.error(f"配置文件不存在:{args.file}")
|
||||
editor = ConfigEditor(args.file, maa_validate=not args.no_maa_validate)
|
||||
try:
|
||||
return interactive(editor)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n已取消,未保存的修改不会写入文件。")
|
||||
return 130
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user