feat: publish sanitized Linux cloud-game MAA adapter

This commit is contained in:
RRRosmontis
2026-09-16 11:11:35 +08:00
commit de064272ec
23 changed files with 2741 additions and 0 deletions
+197
View File
@@ -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 时不限折扣购买其他非黑名单商品;
- 不无视信用商店黑名单;
- 领取每日/每周任务奖励;
- 领取所有邮件;
- 任务结束后退出云游戏。
+82
View File
@@ -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
+256
View File
@@ -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())