Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d014c1515b | ||
|
|
9040bcb5e5 | ||
|
|
2d81615f78 | ||
|
|
da8a82bfd5 | ||
|
|
f623959fd4 |
@@ -2,6 +2,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.venv/
|
.venv/
|
||||||
|
.venv-tts/
|
||||||
runtime/
|
runtime/
|
||||||
config/.venv_python_path.txt
|
config/.venv_python_path.txt
|
||||||
|
|
||||||
@@ -21,6 +22,13 @@ data/queue_state.json
|
|||||||
data/song_requests.json
|
data/song_requests.json
|
||||||
data/tts_state.json
|
data/tts_state.json
|
||||||
data/bilibili_credentials.json
|
data/bilibili_credentials.json
|
||||||
|
data/admin_audit.log
|
||||||
|
data/admin_auth.json
|
||||||
|
data/statistics.sqlite3*
|
||||||
|
data/users.json
|
||||||
|
data/kirara_pet.json
|
||||||
|
data/startup_tasks_state.json
|
||||||
|
data/ref_audio.wav
|
||||||
web/music_cover.jpg
|
web/music_cover.jpg
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
@@ -30,3 +38,4 @@ web/music_cover.jpg
|
|||||||
# Large binaries (kept local only)
|
# Large binaries (kept local only)
|
||||||
vendor/mpv/mpv.exe
|
vendor/mpv/mpv.exe
|
||||||
vendor/mpv/mpv.7z
|
vendor/mpv/mpv.7z
|
||||||
|
vendor/tts-model/
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
# BetterGI 直播联动
|
# BetterGI 直播联动
|
||||||
|
|
||||||
监听 B 站直播间弹幕,管理观众排队、积分、扫码上号、BetterGI 配置组执行、点歌和 TTS 播报。
|
监听 B 站直播间弹幕,管理观众排队、积分、扫码上号、BetterGI 配置组与自动每日一条龙、队伍管理、点歌和 TTS 播报。
|
||||||
|
|
||||||
## 环境
|
## 环境
|
||||||
|
|
||||||
- **Python**: conda 环境 `Live-streaming`(`E:\Programs\Anaconda3\envs\Live-streaming\python.exe`)
|
- **Python**: 项目虚拟环境 `.venv` 或 `.venv-tts`(Python 3.11+)
|
||||||
- **Node.js**: 构建前端用
|
- **Node.js**: 构建前端用
|
||||||
- **BetterGI**: 自动化脚本引擎,路径在 `config/config.json` 中配置
|
- **BetterGI**: 自动化脚本引擎,路径在 `config/config.json` 中配置
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 安装 Python 依赖
|
# 安装 Python 依赖
|
||||||
E:\Programs\Anaconda3\envs\Live-streaming\python.exe -m pip install -r requirements.txt
|
.venv\Scripts\python.exe -m pip install -r requirements.txt
|
||||||
|
|
||||||
# 下载 TTS 模型(约 1.2GB,仅需一次)
|
# 下载 TTS 模型(约 1.2GB,仅需一次)
|
||||||
E:\Programs\Anaconda3\envs\Live-streaming\python.exe -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-TTS-12Hz-0.6B-Base')"
|
.venv-tts\Scripts\python.exe -c "from huggingface_hub import snapshot_download; snapshot_download('Qwen/Qwen3-TTS-12Hz-0.6B-Base')"
|
||||||
|
|
||||||
# 安装前端依赖并构建
|
# 安装前端依赖并构建
|
||||||
cd frontend\admin
|
cd frontend\admin
|
||||||
@@ -29,7 +29,7 @@ npm run build
|
|||||||
|
|
||||||
| 参数 | 默认值 | 说明 |
|
| 参数 | 默认值 | 说明 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `--role` | `all` | `queue` 仅排队/Web / `music` 仅音乐监听 / `tts` TTS 状态窗口 / `all` 同时启动全部(推荐,否则音乐页面不会随 SMTC 更新) |
|
| `--role` | `all` | `queue` 仅排队/Web / `tts` TTS 状态窗口 / `all` 启动排队/Web 和内置 mpv 点歌(推荐) |
|
||||||
| `--host` | `0.0.0.0` | 绑定地址 |
|
| `--host` | `0.0.0.0` | 绑定地址 |
|
||||||
| `--port` | `8086` | 端口 |
|
| `--port` | `8086` | 端口 |
|
||||||
|
|
||||||
@@ -69,6 +69,20 @@ npm run build
|
|||||||
| `admin_uids` | 管理员 UID 列表(一级用户,可用重置等特权指令) |
|
| `admin_uids` | 管理员 UID 列表(一级用户,可用重置等特权指令) |
|
||||||
| `log_level` | 日志级别:`DEBUG` / `INFO` / `WARNING` |
|
| `log_level` | 日志级别:`DEBUG` / `INFO` / `WARNING` |
|
||||||
|
|
||||||
|
### daily — 自动每日
|
||||||
|
|
||||||
|
| 字段 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `one_dragon_template` | 默认配置 | BGI 一条龙模板名称 |
|
||||||
|
| `managed_one_dragon_name` | 直播系统自动每日 | 每次覆盖生成并启动的托管配置名称 |
|
||||||
|
| `ley_line_craft_resin_before` | true | 地脉模式执行前是否合成树脂 |
|
||||||
|
| `commission_use_current_party` | true | 委托前读取游戏当前队伍名并写入 AutoCommissionNova 的战斗与元素采集队伍配置 |
|
||||||
|
| `current_party_read_timeout_sec` | 45 | 当前队伍 OCR 读取超时秒数 |
|
||||||
|
|
||||||
|
自动每日流程为“领取邮件 → 合成树脂 → 可选其他任务 → 领取尘歌壶奖励 → 领取每日奖励”。秘境俗称维护在 `config/domain_aliases.json`,修改后无需重启。
|
||||||
|
|
||||||
|
完整的 BGI 模板、战斗策略和三个配置组配置方法见 [自动每日与队伍管理](docs/自动每日.md)。
|
||||||
|
|
||||||
### broadcast — 弹幕播报与 TTS
|
### broadcast — 弹幕播报与 TTS
|
||||||
|
|
||||||
| 字段 | 默认值 | 说明 |
|
| 字段 | 默认值 | 说明 |
|
||||||
@@ -81,7 +95,7 @@ npm run build
|
|||||||
|
|
||||||
### commands — 内置指令别名
|
### commands — 内置指令别名
|
||||||
|
|
||||||
11 个内置指令,每个可单独启用/禁用、自定义别名:
|
14 个内置指令,每个可单独启用/禁用、自定义别名和允许角色:
|
||||||
|
|
||||||
| key | 默认别名 | 功能 |
|
| key | 默认别名 | 功能 |
|
||||||
|-----|----------|------|
|
|-----|----------|------|
|
||||||
@@ -91,6 +105,9 @@ npm run build
|
|||||||
| `confirm_yes` | 是 | 确认账号正确 |
|
| `confirm_yes` | 是 | 确认账号正确 |
|
||||||
| `confirm_no` | 不是 | 确认账号不正确,重新扫码 |
|
| `confirm_no` | 不是 | 确认账号不正确,重新扫码 |
|
||||||
| `run` | 执行, 跑, 开始 | 执行配置组(需带参数) |
|
| `run` | 执行, 跑, 开始 | 执行配置组(需带参数) |
|
||||||
|
| `daily` | 自动每日, 每日, 日常, 做每日, 做日常 | 启动托管的一条龙每日任务,可选秘境、地脉或委托模式 |
|
||||||
|
| `switch_party` | 切换队伍, 更换队伍, 换队伍, 换队 | 修改并执行配置组“切换队伍” |
|
||||||
|
| `edit_party` | 修改队员, 更换队员, 换队员, 换角色 | 校验四名角色后修改并执行配置组“修改队员” |
|
||||||
| `leave` | 退出 | 退出队列 |
|
| `leave` | 退出 | 退出队列 |
|
||||||
| `reset` | 重置 | 一级用户重启原神和 BGI |
|
| `reset` | 重置 | 一级用户重启原神和 BGI |
|
||||||
| `points` | 积分 | 查询积分 |
|
| `points` | 积分 | 查询积分 |
|
||||||
@@ -115,10 +132,34 @@ npm run build
|
|||||||
|
|
||||||
### music_monitor — 音乐监听与点歌
|
### music_monitor — 音乐监听与点歌
|
||||||
|
|
||||||
监听 Windows 系统媒体会话(SMTC),观众可通过弹幕点歌。
|
观众可通过弹幕点歌,内置 mpv 播放器负责播放、续播和状态维护;旧版 `music_monitor.py` 仍保留用于兼容历史配置,但不再作为独立进程启动。
|
||||||
|
|
||||||
`request_player.commands`:点歌触发词,默认 `["点歌", "dg"]`。
|
`request_player.commands`:点歌触发词,默认 `["点歌", "dg"]`。
|
||||||
|
|
||||||
|
### llm_reply — AI 弹幕回复(默认关闭)
|
||||||
|
|
||||||
|
把观众弹幕交给 LLM 自动回复,回复同时走弹幕+TTS 通道(后台「AI回复」页可配置,支持测试)。
|
||||||
|
|
||||||
|
直播前台 `/` 现显示绮良良像素桌宠。`web/uploads/kirara_walk_front.png`、`kirara_walk_back.png`、`kirara_walk_left.png`、`kirara_walk_right.png` 分别提供正面、背面、左侧、右侧四帧无尾巴走路动画;桌宠只沿水平或垂直方向游走,步态帧随移动距离切换,停下时切回正面静止帧。每隔 60–90 秒开始一次移动,持续 10–15 秒,移动速度为 21–35 像素/秒。旁边的黄、蓝、红进度环按体力、清洁、心情从外到内套叠,尺寸为 40×40 像素,以方格绘制阶梯状轮廓,不显示文字。角色与状态环一起沿游戏镂空区外的上、右、下区域游走。体力、清洁和心情均为 0–100,首次为 100;每 15 秒查询一次 B 站直播间状态,只有确认直播中才累计时间,每累计一小时体力和清洁各减 10。查询短暂失败时沿用最近状态最多 60 秒;服务停机期间不补扣。两项同时低于 40 时心情每小时减 5,同时高于 80 时每小时加 3。AI 回复应以 `[开心]`、`[很开心]`、`[平静]`、`[生气]` 或 `[很生气]` 开头,分别令心情 +1、+3、0、-1、-3;标签不会发到弹幕或 TTS。状态保存在 `data/kirara_pet.json`(若 `queue.data_dir` 被修改,则保存在对应数据目录)。
|
||||||
|
|
||||||
|
走路结束后绮良良转为正面静止姿势。每隔 120–150 秒随机触发一次送快递、看书或看手机待机动作,持续 15–20 秒。三种待机和睡觉动作各使用一套由 ImageGen 生成的独立四帧完整角色 PNG 素材(`web/uploads/kirara_idle_*.png`)。心情低于 50 时立即停止行走和普通待机并进入睡眠;进食和洗澡礼物动画优先于睡觉,播放完再回到睡觉姿势,心情恢复后重新安排游走和待机。
|
||||||
|
|
||||||
|
体力或清洁降到 60 及以下时,桌宠每 60 秒显示一次跟随角色的像素气泡。体力从饭团、薯条提示中随机选一句,清洁从人气票、粉丝团灯牌提示中随机选一句;两项都低时轮流提示,数值为 0 的需求优先。气泡在数值 60/40/20 时分别显示 5/10/30 秒,中间线性过渡,数值 0 时常驻,恢复到 60 以上即停止该项提示。
|
||||||
|
|
||||||
|
礼物恢复规则固定为:每个饭团或薯条增加 5 点体力(桌宠的饱食度);每个人气票或粉丝团灯牌增加 5 点清洁。直播事件中的“灯牌”简写也按粉丝团灯牌处理。其他礼物不改变这两项数值。礼物数量乘以每个礼物的 5 点恢复量;数值仍限制在 0–100。
|
||||||
|
|
||||||
|
进食和洗澡动画按礼物的名义恢复量排播,每 1 点播放 5 秒;即使数值已达 100,单个上述礼物仍完整播放 25 秒。进食有饭团和团子两种素材,洗澡有海绵擦洗和花洒冲洗两种素材,播放时各自轮流使用。动画期间桌宠暂停游走,结束后重新安排下一次移动;刷新前台不会重放已有礼物,但打开页面后的首件新礼物会正常播放。四组透明像素素材位于 `web/uploads/kirara_eat_rice.png`、`kirara_eat_dango.png`、`kirara_bath_sponge.png` 和 `kirara_bath_shower.png`。
|
||||||
|
|
||||||
|
- **协议抽象**(`app/llm_client.py`):`provider` 支持
|
||||||
|
- `openai` — OpenAI `/chat/completions` 兼容协议,覆盖 DeepSeek、通义千问(DashScope 兼容模式)、Kimi、智谱 GLM、硅基流动、OpenRouter、vLLM、LM Studio 及 Ollama 的 OpenAI 兼容端点
|
||||||
|
- `ollama` — Ollama 原生 `/api/chat`(本地部署免鉴权)
|
||||||
|
- `anthropic` — Anthropic `/v1/messages`(Claude 系列)
|
||||||
|
- 新增协议:继承 `BaseLLMProvider` 实现 `chat()` 后在 `PROVIDERS` 注册即可
|
||||||
|
- **指令自动忽略**:排队/上号/点歌/自定义规则等会触发指令的弹幕在指令分发层被识别,不会发送给 LLM;`ignore_prefixes`(默认 `//`)开头的弹幕同样忽略
|
||||||
|
- **安全与限流**:`replies_per_minute` 全局每分钟上限、`per_user_cooldown_sec` 单人冷却、`blacklist_uids` 黑名单、`max_reply_chars` 回复截断、并发保护(在途请求未完成时新弹幕直接跳过)
|
||||||
|
|
||||||
|
主要字段:`enabled`(开关)、`base_url`/`api_key`/`model`、`system_prompt`(人设)、`context_turns`(上下文轮数)。
|
||||||
|
|
||||||
### system — 系统定时任务
|
### system — 系统定时任务
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
@@ -162,7 +203,7 @@ npm run build # 构建到 web/admin/
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 编译检查
|
# 编译检查
|
||||||
E:\Programs\Anaconda3\envs\Live-streaming\python.exe -m py_compile app/danmu_queue.py
|
.venv-tts\Scripts\python.exe -m py_compile app/danmu_queue.py
|
||||||
```
|
```
|
||||||
|
|
||||||
核心文件:
|
核心文件:
|
||||||
@@ -170,7 +211,9 @@ E:\Programs\Anaconda3\envs\Live-streaming\python.exe -m py_compile app/danmu_que
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `app/main.py` | 入口,启动 Web 服务和子模块 |
|
| `app/main.py` | 入口,启动 Web 服务和子模块 |
|
||||||
| `app/danmu_queue.py` | 弹幕监听、指令处理、队列管理、BGI 控制、TTS、Web API |
|
| `app/danmu_queue.py` | 弹幕监听、指令处理、队列管理、BGI 控制、TTS、Web API |
|
||||||
| `app/music_monitor.py` | SMTC 音乐监听与点歌调度 |
|
| `app/bettergi_daily.py` | 自动每日配置生成、秘境别名和队伍参数更新 |
|
||||||
|
| `app/bettergi_current_party.py` | 当前队伍读取托管配置组、状态协议和防串读校验 |
|
||||||
|
| `app/music_monitor.py` | 旧版 SMTC 音乐监听兼容模块;当前点歌由 `app/mpv_player.py` 和排队调度负责 |
|
||||||
| `app/core/runtime_paths.py` | 运行时路径解析 |
|
| `app/core/runtime_paths.py` | 运行时路径解析 |
|
||||||
|
|
||||||
## 打包
|
## 打包
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""BetterGI adapter helpers for reading the active party preset name."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .bettergi_daily import DailyAutomationError, JsonUpdate
|
||||||
|
except ImportError:
|
||||||
|
from bettergi_daily import DailyAutomationError, JsonUpdate
|
||||||
|
|
||||||
|
|
||||||
|
CURRENT_PARTY_SCRIPT_NAME = "LiveCurrentParty"
|
||||||
|
CURRENT_PARTY_GROUP_NAME = "直播系统读取当前队伍"
|
||||||
|
CURRENT_PARTY_STATUS_FILE = "status.json"
|
||||||
|
_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||||
|
_INVALID_BGI_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentPartyReadError(DailyAutomationError):
|
||||||
|
"""Raised when the managed current-party reader cannot complete safely."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PreparedCurrentPartyRead:
|
||||||
|
request_id: str
|
||||||
|
group_name: str
|
||||||
|
status_path: Path
|
||||||
|
updates: tuple[JsonUpdate, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CurrentPartyReadResult:
|
||||||
|
party_name: str
|
||||||
|
candidates: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_object(path: Path, label: str) -> dict[str, Any]:
|
||||||
|
if not path.is_file():
|
||||||
|
raise CurrentPartyReadError(f"未找到{label}: {path}")
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise CurrentPartyReadError(f"读取{label}失败: {exc}") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise CurrentPartyReadError(f"{label}必须是 JSON 对象: {path}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_group_name(value: str) -> str:
|
||||||
|
name = str(value or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise CurrentPartyReadError("当前队伍读取配置组名称不能为空")
|
||||||
|
if name in {".", ".."} or _INVALID_BGI_NAME.search(name):
|
||||||
|
raise CurrentPartyReadError(f"当前队伍读取配置组名称包含非法字符: {name}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _next_group_index(group_dir: Path) -> int:
|
||||||
|
indexes: list[int] = []
|
||||||
|
for path in group_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
value = data.get("index") if isinstance(data, dict) else None
|
||||||
|
if isinstance(value, int):
|
||||||
|
indexes.append(value)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
return max(indexes, default=0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _load_group_template(group_dir: Path, managed_path: Path) -> dict[str, Any]:
|
||||||
|
if managed_path.is_file():
|
||||||
|
return _read_json_object(managed_path, "托管当前队伍读取配置组")
|
||||||
|
|
||||||
|
for name in ("切换队伍", "修改队员", "每日委托"):
|
||||||
|
candidate = group_dir / f"{name}.json"
|
||||||
|
if candidate.is_file():
|
||||||
|
template = _read_json_object(candidate, f"配置组“{name}”")
|
||||||
|
template["index"] = _next_group_index(group_dir)
|
||||||
|
return template
|
||||||
|
raise CurrentPartyReadError(
|
||||||
|
"无法生成当前队伍读取配置组:请先在 BGI 创建“切换队伍”“修改队员”或“每日委托”中的任意一个配置组"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_current_party_read(
|
||||||
|
work_dir: str | Path,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
group_name: str = CURRENT_PARTY_GROUP_NAME,
|
||||||
|
) -> PreparedCurrentPartyRead:
|
||||||
|
work_path = Path(work_dir)
|
||||||
|
if not work_path.is_dir():
|
||||||
|
raise CurrentPartyReadError(f"BetterGI 工作目录不存在: {work_path}")
|
||||||
|
request = str(request_id or "").strip()
|
||||||
|
if not _REQUEST_ID_PATTERN.fullmatch(request):
|
||||||
|
raise CurrentPartyReadError("当前队伍读取请求 ID 格式无效")
|
||||||
|
managed_name = _safe_group_name(group_name)
|
||||||
|
|
||||||
|
group_dir = work_path / "User" / "ScriptGroup"
|
||||||
|
if not group_dir.is_dir():
|
||||||
|
raise CurrentPartyReadError(f"BGI 配置组目录不存在: {group_dir}")
|
||||||
|
managed_path = group_dir / f"{managed_name}.json"
|
||||||
|
managed = copy.deepcopy(_load_group_template(group_dir, managed_path))
|
||||||
|
managed["name"] = managed_name
|
||||||
|
managed["projects"] = [
|
||||||
|
{
|
||||||
|
"name": "读取当前队伍名称",
|
||||||
|
"folderName": CURRENT_PARTY_SCRIPT_NAME,
|
||||||
|
"jsScriptSettingsObject": {"requestId": request},
|
||||||
|
"index": 1,
|
||||||
|
"type": "Javascript",
|
||||||
|
"status": "Enabled",
|
||||||
|
"schedule": "Daily",
|
||||||
|
"runNum": 1,
|
||||||
|
"allowJsNotification": True,
|
||||||
|
"allowJsHTTPHash": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
status_path = (
|
||||||
|
work_path
|
||||||
|
/ "User"
|
||||||
|
/ "JsScript"
|
||||||
|
/ CURRENT_PARTY_SCRIPT_NAME
|
||||||
|
/ CURRENT_PARTY_STATUS_FILE
|
||||||
|
)
|
||||||
|
update = JsonUpdate(managed_path, managed, "生成当前队伍读取配置组")
|
||||||
|
return PreparedCurrentPartyRead(
|
||||||
|
request_id=request,
|
||||||
|
group_name=managed_name,
|
||||||
|
status_path=status_path,
|
||||||
|
updates=(update,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_current_party_status(status_path: str | Path) -> None:
|
||||||
|
path = Path(status_path)
|
||||||
|
try:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise CurrentPartyReadError(f"清理当前队伍读取状态失败: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def read_current_party_status(
|
||||||
|
status_path: str | Path,
|
||||||
|
request_id: str,
|
||||||
|
) -> CurrentPartyReadResult | None:
|
||||||
|
path = Path(status_path)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(data, dict) or str(data.get("request_id") or "") != request_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
state = str(data.get("state") or "").strip().casefold()
|
||||||
|
if state in {"", "running"}:
|
||||||
|
return None
|
||||||
|
candidates = tuple(
|
||||||
|
str(value).strip()
|
||||||
|
for value in data.get("candidates", [])
|
||||||
|
if str(value).strip()
|
||||||
|
) if isinstance(data.get("candidates"), list) else ()
|
||||||
|
if state == "success":
|
||||||
|
party_name = str(data.get("party_name") or "").strip()
|
||||||
|
if not party_name:
|
||||||
|
raise CurrentPartyReadError("当前队伍读取脚本返回成功,但队伍名称为空")
|
||||||
|
return CurrentPartyReadResult(party_name=party_name, candidates=candidates)
|
||||||
|
if state == "error":
|
||||||
|
message = str(data.get("message") or "读取当前队伍失败").strip()
|
||||||
|
raise CurrentPartyReadError(message)
|
||||||
|
raise CurrentPartyReadError(f"当前队伍读取脚本返回未知状态: {state}")
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
"""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)
|
||||||
+1596
-213
File diff suppressed because it is too large
Load Diff
+205
-20
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gc
|
||||||
import io
|
import io
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -13,13 +15,16 @@ from typing import Any, Callable
|
|||||||
|
|
||||||
MAX_NEW_TOKENS = 384
|
MAX_NEW_TOKENS = 384
|
||||||
DEFAULT_SYNTHESIS_TIMEOUT_SECONDS = 120.0
|
DEFAULT_SYNTHESIS_TIMEOUT_SECONDS = 120.0
|
||||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 480.0
|
DEFAULT_STARTUP_TIMEOUT_SECONDS = 900.0
|
||||||
# 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环
|
# 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环
|
||||||
# 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。
|
# 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。
|
||||||
STARTUP_FAILURE_BACKOFF_SECONDS = 60.0
|
STARTUP_FAILURE_BACKOFF_SECONDS = 60.0
|
||||||
|
# 每 N 次合成才做一次显存回收(empty_cache 会 stall 共用 GPU 的游戏/编码)
|
||||||
|
FREE_GPU_EVERY_N_SYNTH = 50
|
||||||
DEFAULT_CPU_THREADS = 4
|
DEFAULT_CPU_THREADS = 4
|
||||||
DEFAULT_CPU_AFFINITY_COUNT = 8
|
DEFAULT_CPU_AFFINITY_COUNT = 8
|
||||||
DEFAULT_PROCESS_PRIORITY = "below_normal"
|
DEFAULT_PROCESS_PRIORITY = "below_normal"
|
||||||
|
STARTUP_PROGRESS_POLL_SECONDS = 1.0
|
||||||
|
|
||||||
|
|
||||||
class FasterQwenWorkerError(RuntimeError):
|
class FasterQwenWorkerError(RuntimeError):
|
||||||
@@ -122,15 +127,26 @@ def _apply_worker_process_limits(settings: dict[str, Any]) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]:
|
def _load_runtime(
|
||||||
|
settings: dict[str, Any],
|
||||||
|
progress: Callable[[str], None] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
def report(stage: str) -> None:
|
||||||
|
if progress:
|
||||||
|
progress(stage)
|
||||||
|
|
||||||
device = str(settings.get("device") or "cuda")
|
device = str(settings.get("device") or "cuda")
|
||||||
if device == "cpu" and "CUDA_VISIBLE_DEVICES" not in os.environ:
|
if device == "cpu" and "CUDA_VISIBLE_DEVICES" not in os.environ:
|
||||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||||
|
|
||||||
cpu_threads = _configure_worker_environment(settings)
|
cpu_threads = _configure_worker_environment(settings)
|
||||||
|
report("torch_import_started")
|
||||||
import torch
|
import torch
|
||||||
|
report("torch_import_complete")
|
||||||
_configure_torch_threads(torch, cpu_threads)
|
_configure_torch_threads(torch, cpu_threads)
|
||||||
|
report("tts_library_import_started")
|
||||||
from faster_qwen3_tts import FasterQwen3TTS
|
from faster_qwen3_tts import FasterQwen3TTS
|
||||||
|
report("tts_library_import_complete")
|
||||||
|
|
||||||
if device == "cpu":
|
if device == "cpu":
|
||||||
torch.cuda.is_available = lambda: False
|
torch.cuda.is_available = lambda: False
|
||||||
@@ -138,10 +154,12 @@ def _load_runtime(settings: dict[str, Any]) -> dict[str, Any]:
|
|||||||
load_kwargs: dict[str, Any] = {}
|
load_kwargs: dict[str, Any] = {}
|
||||||
if device == "cpu":
|
if device == "cpu":
|
||||||
load_kwargs["device"] = "cpu"
|
load_kwargs["device"] = "cpu"
|
||||||
|
report("from_pretrained_started")
|
||||||
model = FasterQwen3TTS.from_pretrained(
|
model = FasterQwen3TTS.from_pretrained(
|
||||||
str(settings.get("model_name_or_path") or "Qwen/Qwen3-TTS-12Hz-0.6B-Base"),
|
str(settings.get("model_name_or_path") or "Qwen/Qwen3-TTS-12Hz-0.6B-Base"),
|
||||||
**load_kwargs,
|
**load_kwargs,
|
||||||
)
|
)
|
||||||
|
report("from_pretrained_complete")
|
||||||
|
|
||||||
# voice_clone_prompt 不再预计算:当前 faster_qwen3_tts 的 FasterQwen3TTS 没有
|
# voice_clone_prompt 不再预计算:当前 faster_qwen3_tts 的 FasterQwen3TTS 没有
|
||||||
# create_voice_clone_prompt 方法,预计算只会失败。改为每次合成时在
|
# create_voice_clone_prompt 方法,预计算只会失败。改为每次合成时在
|
||||||
@@ -192,22 +210,132 @@ def _synthesize_wav(runtime: dict[str, Any], text: str) -> bytes:
|
|||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None:
|
def _collect_memory_stats(runtime: dict[str, Any]) -> dict[str, float]:
|
||||||
|
"""采集 worker 进程的内存/显存占用,用于定位长跑性能退化(泄漏 vs 碎片化)。"""
|
||||||
|
stats: dict[str, float] = {}
|
||||||
|
torch = runtime.get("torch")
|
||||||
try:
|
try:
|
||||||
|
if torch is not None and torch.cuda.is_available():
|
||||||
|
stats["vram_alloc_mb"] = round(torch.cuda.memory_allocated() / 1048576.0, 1)
|
||||||
|
stats["vram_reserved_mb"] = round(torch.cuda.memory_reserved() / 1048576.0, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
stats["rss_mb"] = round(psutil.Process().memory_info().rss / 1048576.0, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def _free_gpu_memory(runtime: dict[str, Any]) -> None:
|
||||||
|
"""回收碎片化显存与 Python 内存。
|
||||||
|
|
||||||
|
注意:empty_cache 会把缓存显存还给驱动,下次合成需重新申请,
|
||||||
|
在原神/直播姬共用 GPU 时会引发驱动级页表重整,导致 GPU 瞬时停摆
|
||||||
|
(占用掉到 0、直播画面卡顿)。因此不能在每次合成后调用,
|
||||||
|
只能在低频维护窗口(每 FREE_GPU_EVERY_N_SYNTH 次)执行。
|
||||||
|
"""
|
||||||
|
gc.collect()
|
||||||
|
torch = runtime.get("torch")
|
||||||
|
try:
|
||||||
|
if torch is not None and torch.cuda.is_available():
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _append_memory_sample(
|
||||||
|
log_path: str,
|
||||||
|
duration_ms: int,
|
||||||
|
mem: dict[str, float],
|
||||||
|
status: str,
|
||||||
|
) -> None:
|
||||||
|
"""把每次合成的耗时与内存/显存写入独立日志文件,用于定位长跑退化根因。"""
|
||||||
|
if not log_path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
line = (
|
||||||
|
f"{datetime.now().strftime('%Y-%m-%dT%H:%M:%S')} "
|
||||||
|
f"duration_ms={int(duration_ms)} "
|
||||||
|
f"vram_alloc_mb={mem.get('vram_alloc_mb', '')} "
|
||||||
|
f"vram_reserved_mb={mem.get('vram_reserved_mb', '')} "
|
||||||
|
f"rss_mb={mem.get('rss_mb', '')} "
|
||||||
|
f"status={status}"
|
||||||
|
)
|
||||||
|
with open(log_path, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(line + "\n")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) -> None:
|
||||||
|
def report(stage: str, **details: Any) -> None:
|
||||||
|
try:
|
||||||
|
connection.send({
|
||||||
|
"type": "progress",
|
||||||
|
"stage": stage,
|
||||||
|
"pid": os.getpid(),
|
||||||
|
**details,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# pythonw 无窗口模式下 sys.stdout/sys.stderr 为 None,
|
||||||
|
# 第三方库(transformers/tqdm 等)写入时会抛 NoneType.write,这里兜底到 devnull。
|
||||||
|
if sys.stdout is None:
|
||||||
|
sys.stdout = open(os.devnull, "w", encoding="utf-8")
|
||||||
|
if sys.stderr is None:
|
||||||
|
sys.stderr = open(os.devnull, "w", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
report("process_started", executable=sys.executable, prefix=sys.prefix)
|
||||||
cpu_threads = _configure_worker_environment(settings)
|
cpu_threads = _configure_worker_environment(settings)
|
||||||
process_limits = _apply_worker_process_limits(settings)
|
report("environment_ready", cpu_threads=cpu_threads)
|
||||||
load_started = time.monotonic()
|
load_started = time.monotonic()
|
||||||
runtime = _load_runtime(settings)
|
report("model_loading", model_name=str(settings.get("model_name_or_path") or ""))
|
||||||
|
runtime = _load_runtime(settings, progress=report)
|
||||||
load_ms = int((time.monotonic() - load_started) * 1000)
|
load_ms = int((time.monotonic() - load_started) * 1000)
|
||||||
|
report("model_loaded", load_ms=load_ms)
|
||||||
|
|
||||||
warmup_started = time.monotonic()
|
warmup_started = time.monotonic()
|
||||||
|
graph_warmup_ms = 0
|
||||||
|
report("warmup_started")
|
||||||
|
warmup = getattr(runtime["model"], "warmup", None)
|
||||||
|
if callable(warmup):
|
||||||
|
graph_warmup_started = time.monotonic()
|
||||||
|
warmup()
|
||||||
|
graph_warmup_ms = int((time.monotonic() - graph_warmup_started) * 1000)
|
||||||
|
report("graph_warmup_complete", graph_warmup_ms=graph_warmup_ms)
|
||||||
|
|
||||||
|
# model.warmup() 只捕获模型内部 CUDA Graph,不会初始化参考音频和
|
||||||
|
# voice-clone 生成路径。若把这一步留到首条播报,后台会在近一分钟内
|
||||||
|
# 看起来毫无响应,因此在降低进程优先级之前完成一次真实短句合成。
|
||||||
|
voice_clone_warmup_started = time.monotonic()
|
||||||
|
report("voice_clone_warmup_started")
|
||||||
_synthesize_wav(runtime, "系统启动")
|
_synthesize_wav(runtime, "系统启动")
|
||||||
|
voice_clone_warmup_ms = int((time.monotonic() - voice_clone_warmup_started) * 1000)
|
||||||
|
report("voice_clone_warmup_complete", voice_clone_warmup_ms=voice_clone_warmup_ms)
|
||||||
warmup_ms = int((time.monotonic() - warmup_started) * 1000)
|
warmup_ms = int((time.monotonic() - warmup_started) * 1000)
|
||||||
|
report(
|
||||||
|
"warmup_complete",
|
||||||
|
warmup_ms=warmup_ms,
|
||||||
|
graph_warmup_ms=graph_warmup_ms,
|
||||||
|
voice_clone_warmup_ms=voice_clone_warmup_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 加载和 CUDA Graph 捕获需要完整 CPU 调度能力。仅在预热完成后降低
|
||||||
|
# worker 优先级和亲和性,避免繁忙直播环境下启动时间被放大到超时。
|
||||||
|
process_limits = _apply_worker_process_limits(settings)
|
||||||
connection.send({
|
connection.send({
|
||||||
"type": "ready",
|
"type": "ready",
|
||||||
"pid": os.getpid(),
|
"pid": os.getpid(),
|
||||||
"load_ms": load_ms,
|
"load_ms": load_ms,
|
||||||
"warmup_ms": warmup_ms,
|
"warmup_ms": warmup_ms,
|
||||||
|
"graph_warmup_ms": graph_warmup_ms,
|
||||||
|
"voice_clone_warmup_ms": voice_clone_warmup_ms,
|
||||||
"max_new_tokens": MAX_NEW_TOKENS,
|
"max_new_tokens": MAX_NEW_TOKENS,
|
||||||
"cpu_threads": cpu_threads,
|
"cpu_threads": cpu_threads,
|
||||||
**process_limits,
|
**process_limits,
|
||||||
@@ -225,6 +353,7 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
|
|||||||
connection.close()
|
connection.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
synth_count = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
message = connection.recv()
|
message = connection.recv()
|
||||||
@@ -240,17 +369,36 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
|
|||||||
|
|
||||||
request_id = str(message.get("request_id") or "")
|
request_id = str(message.get("request_id") or "")
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
|
memory_log_path = str(settings.get("memory_log_path") or "")
|
||||||
try:
|
try:
|
||||||
audio = _synthesize_wav(runtime, str(message.get("text") or ""))
|
audio = _synthesize_wav(runtime, str(message.get("text") or ""))
|
||||||
|
duration_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
mem = _collect_memory_stats(runtime)
|
||||||
|
synth_count += 1
|
||||||
|
if synth_count % FREE_GPU_EVERY_N_SYNTH == 0:
|
||||||
|
_free_gpu_memory(runtime)
|
||||||
|
_append_memory_sample(memory_log_path, duration_ms, mem, "ok")
|
||||||
connection.send({
|
connection.send({
|
||||||
"type": "result",
|
"type": "result",
|
||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"audio": audio,
|
"audio": audio,
|
||||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
"duration_ms": duration_ms,
|
||||||
"bytes": len(audio),
|
"bytes": len(audio),
|
||||||
"max_new_tokens": MAX_NEW_TOKENS,
|
"max_new_tokens": MAX_NEW_TOKENS,
|
||||||
|
"mem": mem,
|
||||||
})
|
})
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
|
duration_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
mem = _collect_memory_stats(runtime)
|
||||||
|
synth_count += 1
|
||||||
|
if synth_count % FREE_GPU_EVERY_N_SYNTH == 0:
|
||||||
|
_free_gpu_memory(runtime)
|
||||||
|
_append_memory_sample(
|
||||||
|
memory_log_path,
|
||||||
|
duration_ms,
|
||||||
|
mem,
|
||||||
|
f"error:{type(exc).__name__}",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
connection.send({
|
connection.send({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
@@ -278,7 +426,7 @@ class FasterQwenWorkerClient:
|
|||||||
self.settings = dict(settings)
|
self.settings = dict(settings)
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.synthesis_timeout_seconds = max(1.0, float(synthesis_timeout_seconds))
|
self.synthesis_timeout_seconds = max(1.0, float(synthesis_timeout_seconds))
|
||||||
self.startup_timeout_seconds = max(10.0, float(startup_timeout_seconds))
|
self.startup_timeout_seconds = max(0.1, float(startup_timeout_seconds))
|
||||||
self._context = context or multiprocessing.get_context("spawn")
|
self._context = context or multiprocessing.get_context("spawn")
|
||||||
self._process_target = process_target or faster_qwen_worker_main
|
self._process_target = process_target or faster_qwen_worker_main
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
@@ -330,21 +478,55 @@ class FasterQwenWorkerClient:
|
|||||||
self._worker_pid = int(getattr(process, "pid", 0) or 0)
|
self._worker_pid = int(getattr(process, "pid", 0) or 0)
|
||||||
self._log("info", "[FasterQwenTTS] worker 已启动, pid=%s,正在加载和预热", self.worker_pid)
|
self._log("info", "[FasterQwenTTS] worker 已启动, pid=%s,正在加载和预热", self.worker_pid)
|
||||||
|
|
||||||
if not parent_connection.poll(self.startup_timeout_seconds):
|
started = time.monotonic()
|
||||||
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
deadline = started + self.startup_timeout_seconds
|
||||||
self._terminate_worker_locked("startup_timeout", graceful=False)
|
last_stage = "process_spawned"
|
||||||
raise FasterQwenWorkerError(
|
message: Any = None
|
||||||
f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s"
|
while True:
|
||||||
)
|
remaining = deadline - time.monotonic()
|
||||||
try:
|
if remaining <= 0:
|
||||||
message = parent_connection.recv()
|
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
||||||
except (EOFError, OSError) as exc:
|
self._terminate_worker_locked("startup_timeout", graceful=False)
|
||||||
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
raise FasterQwenWorkerError(
|
||||||
self._terminate_worker_locked("startup_connection_closed", graceful=False)
|
f"Faster-Qwen3-TTS worker startup exceeded {self.startup_timeout_seconds:.0f}s "
|
||||||
raise FasterQwenWorkerError("Faster-Qwen3-TTS worker exited during startup") from exc
|
f"(last_stage={last_stage})"
|
||||||
|
)
|
||||||
|
if not parent_connection.poll(min(STARTUP_PROGRESS_POLL_SECONDS, remaining)):
|
||||||
|
if not process.is_alive():
|
||||||
|
exit_code = getattr(process, "exitcode", None)
|
||||||
|
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
||||||
|
self._terminate_worker_locked("startup_process_exited", graceful=False)
|
||||||
|
raise FasterQwenWorkerError(
|
||||||
|
f"Faster-Qwen3-TTS worker exited during startup "
|
||||||
|
f"(exit_code={exit_code}, last_stage={last_stage})"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
message = parent_connection.recv()
|
||||||
|
except (EOFError, OSError) as exc:
|
||||||
|
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
||||||
|
self._terminate_worker_locked("startup_connection_closed", graceful=False)
|
||||||
|
raise FasterQwenWorkerError(
|
||||||
|
f"Faster-Qwen3-TTS worker connection closed during startup "
|
||||||
|
f"(last_stage={last_stage})"
|
||||||
|
) from exc
|
||||||
|
if isinstance(message, dict) and message.get("type") == "progress":
|
||||||
|
last_stage = str(message.get("stage") or "unknown")
|
||||||
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
self._log(
|
||||||
|
"info",
|
||||||
|
"[FasterQwenTTS] worker 启动进度: %s, elapsed=%sms",
|
||||||
|
last_stage,
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
break
|
||||||
if not isinstance(message, dict) or message.get("type") != "ready":
|
if not isinstance(message, dict) or message.get("type") != "ready":
|
||||||
if isinstance(message, dict):
|
if isinstance(message, dict):
|
||||||
error = str(message.get("error") or message.get("error_type") or "unknown startup error")
|
error = str(message.get("error") or message.get("error_type") or "unknown startup error")
|
||||||
|
worker_traceback = str(message.get("traceback") or "").strip()
|
||||||
|
if worker_traceback:
|
||||||
|
self._log("error", "[FasterQwenTTS] worker 启动异常:\n%s", worker_traceback)
|
||||||
else:
|
else:
|
||||||
error = "invalid startup response"
|
error = "invalid startup response"
|
||||||
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
self._next_start_after = time.monotonic() + STARTUP_FAILURE_BACKOFF_SECONDS
|
||||||
@@ -353,10 +535,13 @@ class FasterQwenWorkerClient:
|
|||||||
self._next_start_after = 0.0
|
self._next_start_after = 0.0
|
||||||
self._log(
|
self._log(
|
||||||
"info",
|
"info",
|
||||||
"[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms max_new_tokens=%s",
|
"[FasterQwenTTS] worker 预热完成, pid=%s load=%sms warmup=%sms "
|
||||||
|
"graph=%sms voice_clone=%sms max_new_tokens=%s",
|
||||||
self.worker_pid,
|
self.worker_pid,
|
||||||
message.get("load_ms"),
|
message.get("load_ms"),
|
||||||
message.get("warmup_ms"),
|
message.get("warmup_ms"),
|
||||||
|
message.get("graph_warmup_ms"),
|
||||||
|
message.get("voice_clone_warmup_ms"),
|
||||||
message.get("max_new_tokens"),
|
message.get("max_new_tokens"),
|
||||||
)
|
)
|
||||||
self._log(
|
self._log(
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""LLM 客户端抽象层。
|
||||||
|
|
||||||
|
目标:为直播间弹幕回复提供统一的对话接口,屏蔽底层各家协议差异。
|
||||||
|
|
||||||
|
结构:
|
||||||
|
- BaseLLMProvider 抽象基类,定义 async chat(messages) -> str
|
||||||
|
- OpenAICompatProvider OpenAI /chat/completions 兼容协议
|
||||||
|
(DeepSeek / 通义千问DashScope兼容模式 / Kimi / 硅基流动 /
|
||||||
|
Ollama / vLLM / LM Studio 等几乎所有主流服务)
|
||||||
|
- OllamaNativeProvider Ollama 原生 /api/chat 协议
|
||||||
|
- AnthropicProvider Anthropic /v1/messages 协议(Claude 系列)
|
||||||
|
- create_provider() 按 config["llm_reply"]["provider"] 工厂构造
|
||||||
|
- LLMReplyEngine 弹幕回复业务引擎:过滤、限流、上下文、重试
|
||||||
|
|
||||||
|
扩展新协议只需:继承 BaseLLMProvider 实现 chat(),并在 PROVIDERS 注册。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
except ImportError: # pragma: no cover - aiohttp 为运行时必需依赖
|
||||||
|
aiohttp = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provider 抽象与实现
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LLMError(Exception):
|
||||||
|
"""LLM 调用失败(网络/协议/内容拒绝等)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class BaseLLMProvider(ABC):
|
||||||
|
"""所有 LLM 协议适配器的公共基类。"""
|
||||||
|
|
||||||
|
name = "base"
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_key: str = "", model: str = "",
|
||||||
|
timeout_sec: float = 20.0, logger: logging.Logger | None = None):
|
||||||
|
self.base_url = (base_url or "").rstrip("/")
|
||||||
|
self.api_key = api_key or ""
|
||||||
|
self.model = model or ""
|
||||||
|
self.timeout_sec = max(3.0, float(timeout_sec or 20.0))
|
||||||
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||||
|
max_tokens: int = 256) -> str:
|
||||||
|
"""发送一轮对话,返回助手回复文本;失败抛 LLMError。"""
|
||||||
|
|
||||||
|
async def _post_json(self, url: str, payload: dict,
|
||||||
|
headers: dict | None = None) -> dict:
|
||||||
|
if aiohttp is None:
|
||||||
|
raise LLMError("缺少 aiohttp 依赖")
|
||||||
|
timeout = aiohttp.ClientTimeout(total=self.timeout_sec)
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(url, json=payload,
|
||||||
|
headers=headers or {}) as resp:
|
||||||
|
body = await resp.text()
|
||||||
|
if resp.status != 200:
|
||||||
|
raise LLMError(
|
||||||
|
f"HTTP {resp.status}: {body[:200]}")
|
||||||
|
import json as _json
|
||||||
|
return _json.loads(body)
|
||||||
|
except asyncio.TimeoutError as e:
|
||||||
|
raise LLMError(f"请求超时({self.timeout_sec}s)") from e
|
||||||
|
except LLMError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise LLMError(f"请求失败: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICompatProvider(BaseLLMProvider):
|
||||||
|
"""OpenAI /v1/chat/completions 兼容协议。
|
||||||
|
|
||||||
|
覆盖:OpenAI、DeepSeek、Kimi(Moonshot)、通义千问(DashScope兼容模式)、
|
||||||
|
智谱GLM、硅基流动、OpenRouter、Ollama/VoidAI/vLLM/LM Studio 本地端点等。
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "openai"
|
||||||
|
|
||||||
|
DEFAULT_PATH = "/chat/completions"
|
||||||
|
|
||||||
|
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||||
|
max_tokens: int = 256) -> str:
|
||||||
|
url = self.base_url + self.DEFAULT_PATH
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": max(0.0, min(2.0, temperature)),
|
||||||
|
"max_tokens": max(16, int(max_tokens)),
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
data = await self._post_json(url, payload, headers)
|
||||||
|
try:
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
except (KeyError, IndexError, TypeError) as e:
|
||||||
|
raise LLMError(f"响应格式异常: {str(data)[:200]}") from e
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise LLMError("响应 content 非文本")
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaNativeProvider(BaseLLMProvider):
|
||||||
|
"""Ollama 原生 /api/chat 协议(无需鉴权)。"""
|
||||||
|
|
||||||
|
name = "ollama"
|
||||||
|
|
||||||
|
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||||
|
max_tokens: int = 256) -> str:
|
||||||
|
url = self.base_url + "/api/chat"
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": max(0.0, min(2.0, temperature)),
|
||||||
|
"num_predict": max(16, int(max_tokens)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data = await self._post_json(url, payload)
|
||||||
|
content = data.get("message", {}).get("content", "")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
raise LLMError("响应 message.content 非文本")
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicProvider(BaseLLMProvider):
|
||||||
|
"""Anthropic /v1/messages 协议(Claude 系列)。"""
|
||||||
|
|
||||||
|
name = "anthropic"
|
||||||
|
|
||||||
|
ANTHROPIC_VERSION = "2023-06-01"
|
||||||
|
|
||||||
|
async def chat(self, messages: list[dict], *, temperature: float = 0.8,
|
||||||
|
max_tokens: int = 256) -> str:
|
||||||
|
url = self.base_url + "/v1/messages"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-api-key": self.api_key,
|
||||||
|
"anthropic-version": self.ANTHROPIC_VERSION,
|
||||||
|
}
|
||||||
|
# Anthropic 协议中 system 是顶层字段,不放在 messages 里。
|
||||||
|
system_text = "\n".join(
|
||||||
|
m["content"] for m in messages if m.get("role") == "system")
|
||||||
|
chat_msgs = [m for m in messages if m.get("role") != "system"]
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"system": system_text or None,
|
||||||
|
"messages": [
|
||||||
|
{"role": m["role"], "content": m["content"]}
|
||||||
|
for m in chat_msgs
|
||||||
|
],
|
||||||
|
"temperature": max(0.0, min(1.0, temperature)),
|
||||||
|
"max_tokens": max(16, int(max_tokens)),
|
||||||
|
}
|
||||||
|
data = await self._post_json(url, payload, headers)
|
||||||
|
try:
|
||||||
|
parts = data["content"]
|
||||||
|
text = "".join(p.get("text", "") for p in parts if isinstance(p, dict))
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
raise LLMError(f"响应格式异常: {str(data)[:200]}") from e
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# 协议注册表:新增协议在此注册即可。
|
||||||
|
PROVIDERS: dict[str, type[BaseLLMProvider]] = {
|
||||||
|
OpenAICompatProvider.name: OpenAICompatProvider,
|
||||||
|
OllamaNativeProvider.name: OllamaNativeProvider,
|
||||||
|
AnthropicProvider.name: AnthropicProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_provider(cfg: dict, logger: logging.Logger | None = None) -> BaseLLMProvider:
|
||||||
|
"""根据 llm_reply 配置节构造 provider。"""
|
||||||
|
cfg = cfg or {}
|
||||||
|
name = str(cfg.get("provider", "openai") or "openai").strip().lower()
|
||||||
|
cls = PROVIDERS.get(name)
|
||||||
|
if cls is None:
|
||||||
|
raise LLMError(
|
||||||
|
f"未知 LLM 协议: {name}(可选: {', '.join(sorted(PROVIDERS))})")
|
||||||
|
base_url = str(cfg.get("base_url", "") or "").strip()
|
||||||
|
if not base_url:
|
||||||
|
raise LLMError("未配置 base_url")
|
||||||
|
return cls(
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=str(cfg.get("api_key", "") or ""),
|
||||||
|
model=str(cfg.get("model", "") or "").strip(),
|
||||||
|
timeout_sec=float(cfg.get("timeout_sec", 20) or 20),
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 弹幕回复业务引擎
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LLMReplyEngine:
|
||||||
|
"""把观众弹幕交给 LLM 生成回复。
|
||||||
|
|
||||||
|
职责边界:本类只负责"生成回复文本",不负责发送——
|
||||||
|
由调用方(CommandHandler)决定走弹幕/TTS 通道。
|
||||||
|
内置:
|
||||||
|
- 开关与触发过滤(长度、忽略前缀、黑名单)
|
||||||
|
- 频率限制(全局每分钟上限 + 单人冷却)
|
||||||
|
- 短期上下文(最近 N 条观众发言与机器人回复)
|
||||||
|
- 失败重试(指数退避,最多 retry_attempts 次)
|
||||||
|
- 并发保护(同一时刻只允许一个在途请求,忙时直接跳过该条)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cfg: dict | None = None, logger: logging.Logger | None = None,
|
||||||
|
config_getter=None):
|
||||||
|
"""config_getter: 可选的可调用对象,返回最新 llm_reply 配置 dict,
|
||||||
|
用于跟随 Config 热更新(reload 后 data 节点可能是新对象)。"""
|
||||||
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
|
self._cfg = cfg or {}
|
||||||
|
self._config_getter = config_getter
|
||||||
|
self._provider: BaseLLMProvider | None = None
|
||||||
|
self._provider_built_at = 0.0
|
||||||
|
self._context: deque[tuple[str, str]] = deque() # (speaker, text)
|
||||||
|
self._reply_times: deque[float] = deque() # 全局滑动窗口
|
||||||
|
self._user_last_reply: dict[int, float] = {}
|
||||||
|
self._inflight = asyncio.Lock()
|
||||||
|
# 配置指纹:热更新后自动重建 provider。
|
||||||
|
self._cfg_fingerprint = ""
|
||||||
|
|
||||||
|
# -- 配置 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _config(self) -> dict:
|
||||||
|
if self._config_getter is not None:
|
||||||
|
try:
|
||||||
|
cfg = self._config_getter()
|
||||||
|
if cfg:
|
||||||
|
self._cfg = cfg
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self._cfg or {}
|
||||||
|
|
||||||
|
def update_config(self, cfg: dict):
|
||||||
|
"""配置热更新入口。"""
|
||||||
|
self._cfg = cfg or {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(self._config().get("enabled", False))
|
||||||
|
|
||||||
|
def _provider_fingerprint(self) -> str:
|
||||||
|
c = self._config()
|
||||||
|
return "|".join(str(c.get(k, "")) for k in
|
||||||
|
("provider", "base_url", "api_key", "model", "timeout_sec"))
|
||||||
|
|
||||||
|
def _get_provider(self) -> BaseLLMProvider:
|
||||||
|
fp = self._provider_fingerprint()
|
||||||
|
if self._provider is None or fp != self._cfg_fingerprint:
|
||||||
|
self._provider = create_provider(self._config(), self.logger)
|
||||||
|
self._cfg_fingerprint = fp
|
||||||
|
self.logger.info(f"[LLM] 使用协议 {self._provider.name} "
|
||||||
|
f"模型 {self._provider.model or '(默认)'}")
|
||||||
|
return self._provider
|
||||||
|
|
||||||
|
# -- 过滤 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def should_reply(self, text: str, uid: int, uname: str) -> str | None:
|
||||||
|
"""返回需要回复的规范化文本;不需要回复返回 None。"""
|
||||||
|
cfg = self._config()
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
text = (text or "").strip()
|
||||||
|
raw = cfg.get("min_text_len", 2)
|
||||||
|
min_len = int(raw) if raw is not None else 2
|
||||||
|
if len(text) < min_len:
|
||||||
|
return None
|
||||||
|
for prefix in cfg.get("ignore_prefixes", []) or []:
|
||||||
|
prefix = str(prefix or "").strip()
|
||||||
|
if prefix and text.startswith(prefix):
|
||||||
|
return None
|
||||||
|
blacklist = {str(u) for u in (cfg.get("blacklist_uids", []) or [])}
|
||||||
|
if str(uid) in blacklist:
|
||||||
|
return None
|
||||||
|
# 单人冷却(0 表示禁用冷却)
|
||||||
|
raw = cfg.get("per_user_cooldown_sec", 30)
|
||||||
|
cooldown = float(raw) if raw is not None else 30.0
|
||||||
|
if cooldown > 0:
|
||||||
|
last = self._user_last_reply.get(int(uid), 0.0)
|
||||||
|
if time.time() - last < cooldown:
|
||||||
|
return None
|
||||||
|
# 全局限流(0 表示不限流)
|
||||||
|
raw = cfg.get("replies_per_minute", 6)
|
||||||
|
rpm = int(raw) if raw is not None else 6
|
||||||
|
if rpm > 0:
|
||||||
|
now = time.time()
|
||||||
|
while self._reply_times and now - self._reply_times[0] > 60:
|
||||||
|
self._reply_times.popleft()
|
||||||
|
if len(self._reply_times) >= rpm:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
# -- 上下文 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_messages(self, text: str, uname: str) -> list[dict]:
|
||||||
|
cfg = self._config()
|
||||||
|
system_prompt = str(cfg.get("system_prompt", "") or "").strip()
|
||||||
|
messages: list[dict] = []
|
||||||
|
emotion_instruction = (
|
||||||
|
"每次回复必须先输出一个情绪标签,只能从[开心]、[很开心]、[平静]、[生气]、[很生气]中选一个,"
|
||||||
|
"紧接着输出给观众的回复正文。标签只用于桌宠状态,不属于口播内容。"
|
||||||
|
)
|
||||||
|
messages.append({"role": "system", "content": "\n".join(
|
||||||
|
part for part in (system_prompt, emotion_instruction) if part
|
||||||
|
)})
|
||||||
|
for speaker, content in list(self._context):
|
||||||
|
role = "assistant" if speaker == "bot" else "user"
|
||||||
|
content = f"{speaker}: {content}" if role == "user" else content
|
||||||
|
messages.append({"role": role, "content": content})
|
||||||
|
messages.append({"role": "user", "content": f"{uname}: {text}"})
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _remember(self, uname: str, text: str, reply: str | None):
|
||||||
|
max_turns = int(self._config().get("context_turns", 6) or 6)
|
||||||
|
if max_turns <= 0:
|
||||||
|
return
|
||||||
|
self._context.append((uname, text))
|
||||||
|
if reply:
|
||||||
|
self._context.append(("bot", reply))
|
||||||
|
while len(self._context) > max_turns * 2:
|
||||||
|
self._context.popleft()
|
||||||
|
|
||||||
|
# -- 主入口 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def generate_reply(self, text: str, uid: int, uname: str) -> str | None:
|
||||||
|
"""生成回复;被过滤/限流/失败返回 None。非阻塞——忙时立即放弃。"""
|
||||||
|
text = self.should_reply(text, uid, uname)
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
if self._inflight.locked():
|
||||||
|
# 已有请求在途:丢弃本条,避免弹幕高峰打爆 LLM。
|
||||||
|
return None
|
||||||
|
async with self._inflight:
|
||||||
|
cfg = self._config()
|
||||||
|
provider = self._get_provider()
|
||||||
|
messages = self._build_messages(text, uname or "观众")
|
||||||
|
temperature = float(cfg.get("temperature", 0.8) or 0.8)
|
||||||
|
max_tokens = int(cfg.get("max_tokens", 128) or 128)
|
||||||
|
attempts = max(1, int(cfg.get("retry_attempts", 2) or 1))
|
||||||
|
last_err: Exception | None = None
|
||||||
|
for i in range(attempts):
|
||||||
|
try:
|
||||||
|
raw = await provider.chat(
|
||||||
|
messages, temperature=temperature, max_tokens=max_tokens)
|
||||||
|
reply = self._sanitize(raw)
|
||||||
|
if reply:
|
||||||
|
now = time.time()
|
||||||
|
self._reply_times.append(now)
|
||||||
|
self._user_last_reply[int(uid)] = now
|
||||||
|
self._remember(uname or "观众", text, reply)
|
||||||
|
return reply
|
||||||
|
return None # 模型返回空内容,不重试
|
||||||
|
except LLMError as e:
|
||||||
|
last_err = e
|
||||||
|
if i < attempts - 1:
|
||||||
|
await asyncio.sleep(0.8 * (2 ** i) + random.random() * 0.4)
|
||||||
|
self.logger.warning(f"[LLM] 回复失败({last_err})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _sanitize(self, raw: str) -> str:
|
||||||
|
"""清洗模型输出:去空白、截断到 max_reply_chars、去 markdown 粗体。
|
||||||
|
|
||||||
|
开头的情绪标签(如 [开心])豁免截断长度,保证不会被截坏;
|
||||||
|
标签本身由调用方(danmu_queue)在弹幕/语音输出前剥离。
|
||||||
|
"""
|
||||||
|
cfg = self._config()
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
# 直播口播场景不需要 markdown 标记
|
||||||
|
for token in ("**", "__", "```"):
|
||||||
|
text = text.replace(token, "")
|
||||||
|
text = " ".join(text.split())
|
||||||
|
m = re.match(r"^(\[[^\[\]]{1,8}\])\s*", text)
|
||||||
|
tag = m.group(1) if m else ""
|
||||||
|
body = text[m.end():] if m else text
|
||||||
|
raw = cfg.get("max_reply_chars", 60)
|
||||||
|
max_chars = int(raw) if raw is not None else 60
|
||||||
|
if max_chars > 0 and len(tag) + len(body) > max_chars:
|
||||||
|
body = body[:max(1, max_chars - len(tag))].rstrip() + "…"
|
||||||
|
return tag + body
|
||||||
+6
-12
@@ -165,12 +165,6 @@ async def _run_queue(host: str, port: int):
|
|||||||
await danmu_queue.main(host=host, port=port)
|
await danmu_queue.main(host=host, port=port)
|
||||||
|
|
||||||
|
|
||||||
async def _run_music(port: int):
|
|
||||||
import music_monitor
|
|
||||||
|
|
||||||
await music_monitor.run_monitor(port)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_tts_monitor():
|
def _run_tts_monitor():
|
||||||
import tts_monitor
|
import tts_monitor
|
||||||
|
|
||||||
@@ -204,8 +198,11 @@ async def _run_all(host: str, port: int):
|
|||||||
# Refuse stale/conflicting listeners before creating any helper process.
|
# Refuse stale/conflicting listeners before creating any helper process.
|
||||||
_assert_port_available(host, port)
|
_assert_port_available(host, port)
|
||||||
queue_task = asyncio.create_task(_run_queue(host, port), name="queue-main")
|
queue_task = asyncio.create_task(_run_queue(host, port), name="queue-main")
|
||||||
children.append(_spawn_role("music", port, host, visible=False))
|
# pythonw 无窗口模式下 TTS 监控窗无法显示内容(stdout 为 None),
|
||||||
children.append(_spawn_role("tts", port, host, visible=True))
|
# 且其每秒的 cls 清屏会不断弹出新控制台窗口,直接跳过。
|
||||||
|
running_windowless = sys.stdout is None or sys.executable.lower().endswith("pythonw.exe")
|
||||||
|
if not running_windowless:
|
||||||
|
children.append(_spawn_role("tts", port, host, visible=True))
|
||||||
for child in children:
|
for child in children:
|
||||||
job.assign(child)
|
job.assign(child)
|
||||||
await queue_task
|
await queue_task
|
||||||
@@ -222,7 +219,7 @@ async def _run_all(host: str, port: int):
|
|||||||
def main():
|
def main():
|
||||||
ensure_runtime_dirs()
|
ensure_runtime_dirs()
|
||||||
parser = argparse.ArgumentParser(description="BetterGI 直播联动统一入口")
|
parser = argparse.ArgumentParser(description="BetterGI 直播联动统一入口")
|
||||||
parser.add_argument("--role", choices=["all", "queue", "music", "tts"], default="all")
|
parser.add_argument("--role", choices=["all", "queue", "tts"], default="all")
|
||||||
parser.add_argument("--port", type=int, default=8086)
|
parser.add_argument("--port", type=int, default=8086)
|
||||||
parser.add_argument("--host", default="0.0.0.0", help="Web service bind address")
|
parser.add_argument("--host", default="0.0.0.0", help="Web service bind address")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -238,9 +235,6 @@ def main():
|
|||||||
if args.role == "tts":
|
if args.role == "tts":
|
||||||
_run_tts_monitor()
|
_run_tts_monitor()
|
||||||
return 0
|
return 0
|
||||||
if args.role == "music":
|
|
||||||
asyncio.run(_run_music(args.port))
|
|
||||||
return 0
|
|
||||||
if args.role == "queue":
|
if args.role == "queue":
|
||||||
asyncio.run(_run_queue(args.host, args.port))
|
asyncio.run(_run_queue(args.host, args.port))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Persistent Kirara state that decays only during an active live session."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
EMOTION_DELTAS = {
|
||||||
|
"开心": 1,
|
||||||
|
"很开心": 3,
|
||||||
|
"平静": 0,
|
||||||
|
"生气": -1,
|
||||||
|
"很生气": -3,
|
||||||
|
}
|
||||||
|
EMOTION_PREFIX = re.compile(r"^\s*\[(很开心|很生气|开心|平静|生气)\]\s*")
|
||||||
|
GIFT_RECOVERY = {
|
||||||
|
"饭团": (5, 0),
|
||||||
|
"薯条": (5, 0),
|
||||||
|
"人气票": (0, 5),
|
||||||
|
"粉丝团灯牌": (0, 5),
|
||||||
|
"灯牌": (0, 5), # 直播事件中常用的简写
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value: int | float) -> int:
|
||||||
|
return max(0, min(100, int(value)))
|
||||||
|
|
||||||
|
|
||||||
|
def gift_recovery(gift_name: str, quantity: int = 1) -> tuple[int, int]:
|
||||||
|
"""Return nominal stamina/cleanliness points for a gift batch."""
|
||||||
|
stamina, cleanliness = GIFT_RECOVERY.get(str(gift_name).strip(), (0, 0))
|
||||||
|
count = max(0, int(quantity))
|
||||||
|
return stamina * count, cleanliness * count
|
||||||
|
|
||||||
|
|
||||||
|
def split_emotion(reply: str) -> tuple[str, str | None]:
|
||||||
|
"""Return the spoken reply and its recognized leading emotion tag."""
|
||||||
|
match = EMOTION_PREFIX.match(reply)
|
||||||
|
if not match:
|
||||||
|
return reply, None
|
||||||
|
return reply[match.end():].strip(), match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
class PetState:
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.values = {"stamina": 100, "cleanliness": 100, "mood": 100}
|
||||||
|
# Short-lived events let open frontends play each AI mood reaction once.
|
||||||
|
self.recent_emotions: list[dict] = []
|
||||||
|
self.live_seconds = 0.0
|
||||||
|
# A restart must never count the offline gap as live time.
|
||||||
|
self.live_active = False
|
||||||
|
self.updated_at = time.time()
|
||||||
|
try:
|
||||||
|
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
self.values = {key: clamp(data.get(key, 100)) for key in self.values}
|
||||||
|
self.live_seconds = max(0.0, min(3599.999, float(data.get("live_seconds", 0))))
|
||||||
|
self.updated_at = min(self.updated_at, float(data.get("updated_at", self.updated_at)))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
self._saved_at = self.updated_at
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
"""持久化。保存失败只降级为日志,绝不影响礼物/直播主流程。"""
|
||||||
|
try:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = json.dumps({**self.values, "live_seconds": self.live_seconds,
|
||||||
|
"updated_at": self.updated_at}, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
pending = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||||
|
pending.write_text(payload, encoding="utf-8")
|
||||||
|
pending.replace(self.path)
|
||||||
|
except OSError:
|
||||||
|
# 原子替换被拒(杀软锁定/权限)时退回直接覆写
|
||||||
|
self.path.write_text(payload, encoding="utf-8")
|
||||||
|
Path(str(self.path) + ".tmp").unlink(missing_ok=True)
|
||||||
|
self._saved_at = self.updated_at
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger("pet_state").warning(
|
||||||
|
"[桌宠] 状态保存失败(不影响礼物处理): %s", self.path, exc_info=True)
|
||||||
|
|
||||||
|
def _record_emotion(self, delta: int, now: float) -> None:
|
||||||
|
if not delta:
|
||||||
|
return
|
||||||
|
self.recent_emotions.append({
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"ts": now,
|
||||||
|
"delta": delta,
|
||||||
|
})
|
||||||
|
del self.recent_emotions[:-32]
|
||||||
|
|
||||||
|
def sync(self, now: float | None = None) -> dict[str, int]:
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
if now > self.updated_at:
|
||||||
|
added = now - self.updated_at if self.live_active else 0.0
|
||||||
|
self.live_seconds += added
|
||||||
|
hours = int(self.live_seconds // 3600)
|
||||||
|
self.live_seconds %= 3600
|
||||||
|
for _ in range(hours):
|
||||||
|
self.values["stamina"] = clamp(self.values["stamina"] - 10)
|
||||||
|
self.values["cleanliness"] = clamp(self.values["cleanliness"] - 10)
|
||||||
|
if self.values["stamina"] < 40 and self.values["cleanliness"] < 40:
|
||||||
|
self.values["mood"] = clamp(self.values["mood"] - 5)
|
||||||
|
elif self.values["stamina"] > 80 and self.values["cleanliness"] > 80:
|
||||||
|
self.values["mood"] = clamp(self.values["mood"] + 3)
|
||||||
|
self._record_emotion(3, now)
|
||||||
|
self.updated_at = now
|
||||||
|
if hours or (added and now - self._saved_at >= 60):
|
||||||
|
self._save()
|
||||||
|
return dict(self.values)
|
||||||
|
|
||||||
|
def set_live_active(self, active: bool, now: float | None = None) -> dict[str, int]:
|
||||||
|
"""Settle the previous session state before switching at this instant."""
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
self.sync(now)
|
||||||
|
if self.live_active != active:
|
||||||
|
self.live_active = active
|
||||||
|
self._save()
|
||||||
|
return dict(self.values)
|
||||||
|
|
||||||
|
def apply_emotion(self, emotion: str) -> dict[str, int]:
|
||||||
|
self.sync()
|
||||||
|
if emotion in EMOTION_DELTAS:
|
||||||
|
delta = EMOTION_DELTAS[emotion]
|
||||||
|
self.values["mood"] = clamp(self.values["mood"] + delta)
|
||||||
|
self._record_emotion(delta, time.time())
|
||||||
|
self._save()
|
||||||
|
return dict(self.values)
|
||||||
|
|
||||||
|
def restore_gift(self, stamina: int = 0, cleanliness: int = 0) -> dict[str, int]:
|
||||||
|
"""Apply gift recovery while keeping stored values within 0–100."""
|
||||||
|
self.sync()
|
||||||
|
if stamina or cleanliness:
|
||||||
|
self.values["stamina"] = clamp(self.values["stamina"] + stamina)
|
||||||
|
self.values["cleanliness"] = clamp(self.values["cleanliness"] + cleanliness)
|
||||||
|
self._save()
|
||||||
|
return dict(self.values)
|
||||||
@@ -208,6 +208,30 @@ class RedemptionCodeStore:
|
|||||||
if starts_utc >= ends_utc:
|
if starts_utc >= ends_utc:
|
||||||
raise ValueError("失效时间必须晚于生效时间")
|
raise ValueError("失效时间必须晚于生效时间")
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
|
# 同名的"已停用/已删除且从未兑换"旧码会让 UNIQUE 约束挡住重建,
|
||||||
|
# 直接在旧记录上复用(更新内容并重新启用),历史 id 保持连续。
|
||||||
|
stale = connection.execute(
|
||||||
|
"""SELECT id FROM redemption_codes
|
||||||
|
WHERE code_normalized = ? AND redeemed_count = 0
|
||||||
|
AND (deleted_at_utc IS NOT NULL OR enabled = 0)""",
|
||||||
|
(normalized,),
|
||||||
|
).fetchone()
|
||||||
|
if stale is not None:
|
||||||
|
connection.execute(
|
||||||
|
"""UPDATE redemption_codes SET
|
||||||
|
code_display = ?, points = ?, starts_at_utc = ?, ends_at_utc = ?,
|
||||||
|
max_redemptions = ?, enabled = ?, deleted_at_utc = NULL,
|
||||||
|
updated_at_utc = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(display, points, starts_utc, ends_utc, max_value,
|
||||||
|
int(bool(enabled)), now, stale["id"]),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT * FROM redemption_codes WHERE id=?", (stale["id"],)
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
return self._serialize_code(row)
|
||||||
try:
|
try:
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""INSERT INTO redemption_codes (
|
"""INSERT INTO redemption_codes (
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ from core.runtime_paths import DATA_DIR, ensure_runtime_dirs
|
|||||||
|
|
||||||
|
|
||||||
def clear():
|
def clear():
|
||||||
|
# 无控制台(pythonw)时执行 cls 会不断弹出新 cmd 窗口,直接跳过。
|
||||||
|
if sys.stdout is None:
|
||||||
|
return
|
||||||
os.system("cls" if os.name == "nt" else "clear")
|
os.system("cls" if os.name == "nt" else "clear")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+58
-12
@@ -2,7 +2,7 @@
|
|||||||
"_说明": "BetterGI弹幕联动配置。改完保存即可,程序支持热说明请重启生效。",
|
"_说明": "BetterGI弹幕联动配置。改完保存即可,程序支持热说明请重启生效。",
|
||||||
"bilibili": {
|
"bilibili": {
|
||||||
"_说明": "room_id 可填短号。请在浏览器登录B站后,把 SESSDATA、bili_jct、buvid3 填入 cookie 字段(或分别填入 sessdata/bili_jct/buvid3),才能监听弹幕和回复。",
|
"_说明": "room_id 可填短号。请在浏览器登录B站后,把 SESSDATA、bili_jct、buvid3 填入 cookie 字段(或分别填入 sessdata/bili_jct/buvid3),才能监听弹幕和回复。",
|
||||||
"room_id": 1871977513,
|
"room_id": 0,
|
||||||
"cookie": "",
|
"cookie": "",
|
||||||
"sessdata": "",
|
"sessdata": "",
|
||||||
"bili_jct": "",
|
"bili_jct": "",
|
||||||
@@ -12,15 +12,20 @@
|
|||||||
},
|
},
|
||||||
"bettergi": {
|
"bettergi": {
|
||||||
"_说明": "BetterGI.exe 的完整路径。work_dir 留空则自动取 exe 所在目录。",
|
"_说明": "BetterGI.exe 的完整路径。work_dir 留空则自动取 exe 所在目录。",
|
||||||
"exe_path": "C:\\Program Files\\BetterGI\\BetterGI.exe",
|
"exe_path": "",
|
||||||
"work_dir": ""
|
"work_dir": ""
|
||||||
},
|
},
|
||||||
|
"daily": {
|
||||||
|
"one_dragon_template": "默认配置",
|
||||||
|
"managed_one_dragon_name": "直播系统自动每日",
|
||||||
|
"ley_line_craft_resin_before": true,
|
||||||
|
"commission_use_current_party": true,
|
||||||
|
"current_party_read_timeout_sec": 45
|
||||||
|
},
|
||||||
"global": {
|
"global": {
|
||||||
"_说明": "default_cooldown: 冷却秒数防刷屏。admin_uids: 管理员UID绕过冷却。log_level: DEBUG/INFO/WARNING。restart_mode: gentle温和(执行中跳过)/aggressive激进(先杀BetterGI再重启,立即响应,适合直播)。",
|
"_说明": "default_cooldown: 冷却秒数防刷屏。admin_uids: 管理员UID绕过冷却。log_level: DEBUG/INFO/WARNING。restart_mode: gentle温和(执行中跳过)/aggressive激进(先杀BetterGI再重启,立即响应,适合直播)。",
|
||||||
"default_cooldown": 30,
|
"default_cooldown": 30,
|
||||||
"admin_uids": [
|
"admin_uids": [],
|
||||||
1123997326
|
|
||||||
],
|
|
||||||
"log_level": "INFO",
|
"log_level": "INFO",
|
||||||
"restart_mode": "aggressive",
|
"restart_mode": "aggressive",
|
||||||
"log_file": "danmu_bettergi.log"
|
"log_file": "danmu_bettergi.log"
|
||||||
@@ -44,7 +49,7 @@
|
|||||||
"model_name_or_path": "vendor/tts-model",
|
"model_name_or_path": "vendor/tts-model",
|
||||||
"language": "Chinese",
|
"language": "Chinese",
|
||||||
"ref_audio": "data/ref_audio.wav",
|
"ref_audio": "data/ref_audio.wav",
|
||||||
"ref_text": "凯茨莱茵家族的迪奥娜小姐,货物我确实收下了,再次感谢您选择狛荷屋",
|
"ref_text": "",
|
||||||
"xvec_only": true,
|
"xvec_only": true,
|
||||||
"non_streaming_mode": true,
|
"non_streaming_mode": true,
|
||||||
"chunk_size": 8,
|
"chunk_size": 8,
|
||||||
@@ -52,14 +57,15 @@
|
|||||||
"streaming": false,
|
"streaming": false,
|
||||||
"cpu_threads": 3,
|
"cpu_threads": 3,
|
||||||
"cpu_affinity_count": 6,
|
"cpu_affinity_count": 6,
|
||||||
"process_priority": "below_normal"
|
"process_priority": "below_normal",
|
||||||
|
"startup_timeout_seconds": 900
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"enable_danmu_reply": true,
|
"enable_danmu_reply": true,
|
||||||
"enable_system_danmu": true,
|
"enable_system_danmu": true,
|
||||||
"enable_tts": true,
|
"enable_tts": false,
|
||||||
"danmu_interval_sec": 1,
|
"danmu_interval_sec": 1,
|
||||||
"tts_provider": "faster-qwen3-tts",
|
"tts_provider": "none",
|
||||||
"tts_categories": {
|
"tts_categories": {
|
||||||
"signin": false,
|
"signin": false,
|
||||||
"queue": true,
|
"queue": true,
|
||||||
@@ -92,7 +98,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"background_image": "/uploads/background.jpg?t=1783666624",
|
"background_image": "",
|
||||||
"background_opacity": 0.65,
|
"background_opacity": 0.65,
|
||||||
"background_blur": 0,
|
"background_blur": 0,
|
||||||
"background_fit": "cover",
|
"background_fit": "cover",
|
||||||
@@ -167,10 +173,10 @@
|
|||||||
"reboot_after_stop_delay_sec": 60,
|
"reboot_after_stop_delay_sec": 60,
|
||||||
"launch_bilibili_live_enabled": true,
|
"launch_bilibili_live_enabled": true,
|
||||||
"launch_bilibili_live_time": "08:50",
|
"launch_bilibili_live_time": "08:50",
|
||||||
"bilibili_live_exe": "C:\\Program Files\\bililive\\livehime\\livehime.exe",
|
"bilibili_live_exe": "",
|
||||||
"launch_genshin_enabled": true,
|
"launch_genshin_enabled": true,
|
||||||
"launch_genshin_time": "08:50",
|
"launch_genshin_time": "08:50",
|
||||||
"genshin_exe": "C:\\Program Files\\miHoYo Launcher\\games\\Genshin Impact Game\\YuanShen.exe",
|
"genshin_exe": "",
|
||||||
"bilibili_push_enabled": true,
|
"bilibili_push_enabled": true,
|
||||||
"bilibili_push_time": "09:00",
|
"bilibili_push_time": "09:00",
|
||||||
"bilibili_push_window_keyword": "直播姬",
|
"bilibili_push_window_keyword": "直播姬",
|
||||||
@@ -249,6 +255,46 @@
|
|||||||
"active_operator"
|
"active_operator"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"daily": {
|
||||||
|
"enabled": true,
|
||||||
|
"aliases": [
|
||||||
|
"自动每日",
|
||||||
|
"每日",
|
||||||
|
"日常",
|
||||||
|
"做每日",
|
||||||
|
"做日常"
|
||||||
|
],
|
||||||
|
"allowed_roles": [
|
||||||
|
"super_admin",
|
||||||
|
"active_operator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"switch_party": {
|
||||||
|
"enabled": true,
|
||||||
|
"aliases": [
|
||||||
|
"切换队伍",
|
||||||
|
"更换队伍",
|
||||||
|
"换队伍",
|
||||||
|
"换队"
|
||||||
|
],
|
||||||
|
"allowed_roles": [
|
||||||
|
"super_admin",
|
||||||
|
"active_operator"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"edit_party": {
|
||||||
|
"enabled": true,
|
||||||
|
"aliases": [
|
||||||
|
"修改队员",
|
||||||
|
"更换队员",
|
||||||
|
"换队员",
|
||||||
|
"换角色"
|
||||||
|
],
|
||||||
|
"allowed_roles": [
|
||||||
|
"super_admin",
|
||||||
|
"active_operator"
|
||||||
|
]
|
||||||
|
},
|
||||||
"leave": {
|
"leave": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"aliases": [
|
"aliases": [
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"_说明": "键为 BetterGI 正式秘境名,值为可维护的俗称列表;修改后无需重启。",
|
||||||
|
"铭记之谷": [
|
||||||
|
"风本",
|
||||||
|
"少女套"
|
||||||
|
]
|
||||||
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
# 自动每日与队伍管理
|
||||||
|
|
||||||
|
本功能要求 BetterGI `0.63.0` 或更高版本,系统会通过以下命令行入口启动托管的一条龙配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BetterGI.exe startOneDragon <配置名称>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 弹幕指令
|
||||||
|
|
||||||
|
```text
|
||||||
|
自动每日
|
||||||
|
每日
|
||||||
|
日常
|
||||||
|
做每日
|
||||||
|
做日常
|
||||||
|
自动每日 秘境 <秘境正式名称或俗称>
|
||||||
|
自动每日 地脉 <经验|摩拉> <国家>
|
||||||
|
自动每日 委托
|
||||||
|
|
||||||
|
切换队伍 <队伍名称>
|
||||||
|
更换队伍 <队伍名称>
|
||||||
|
换队伍 <队伍名称>
|
||||||
|
换队 <队伍名称>
|
||||||
|
修改队员 <队员1> <队员2> <队员3> <队员4>
|
||||||
|
更换队员 <队员1> <队员2> <队员3> <队员4>
|
||||||
|
换队员 <队员1> <队员2> <队员3> <队员4>
|
||||||
|
换角色 <队员1> <队员2> <队员3> <队员4>
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
自动每日
|
||||||
|
每日
|
||||||
|
日常 秘境 风本
|
||||||
|
自动每日 秘境 风本
|
||||||
|
自动每日 秘境 少女套
|
||||||
|
自动每日 地脉 经验 蒙德
|
||||||
|
自动每日 地脉 摩拉 枫丹
|
||||||
|
自动每日 委托
|
||||||
|
切换队伍 永冻队
|
||||||
|
换队 永冻队
|
||||||
|
修改队员 神里绫华 申鹤 枫原万叶 珊瑚宫心海
|
||||||
|
换角色 神里绫华 申鹤 枫原万叶 珊瑚宫心海
|
||||||
|
修改队员 神里绫华、申鹤、枫原万叶、珊瑚宫心海
|
||||||
|
```
|
||||||
|
|
||||||
|
队员支持空格、英文或中文逗号、顿号、斜杠分隔。连续输入四个角色名时,只有能够唯一拆分为四名已知角色才会执行。角色必须存在于 BGI 的 `AutoSwitchRoles` 角色数据中,且四人不能重复。新版脚本同时支持 `combat_avatar.json` 中维护的角色别名。
|
||||||
|
|
||||||
|
执行类指令可以省略指令后的空格,例如 `每日秘境 风本`、`换队永冻队`、`换角色芙宁娜纳西妲钟离雷电将军`。系统按最长别名优先解析,因此“换队员”不会被误识别为“换队”。
|
||||||
|
|
||||||
|
## 每日流程
|
||||||
|
|
||||||
|
每日流程固定为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
领取邮件 > 合成树脂 > 可选其他任务 > 领取尘歌壶奖励 > 领取每日奖励
|
||||||
|
```
|
||||||
|
|
||||||
|
- 直接发送 `自动每日` 时不插入其他任务。
|
||||||
|
- 秘境模式一直执行到树脂耗尽或 BGI 报错。
|
||||||
|
- 地脉模式默认在执行前合成树脂,然后一直执行到树脂耗尽或 BGI 报错。
|
||||||
|
- 委托模式执行配置组“每日委托”。
|
||||||
|
- 委托启用“使用当前队伍”时,正式任务启动前会先读取游戏当前队伍预设名,并写入 AutoCommissionNova 的战斗与元素采集队伍配置;该准备步骤不进入正式计费任务。
|
||||||
|
- 其他任务报错时,由 BetterGI 一条龙继续执行后面的尘歌壶和每日奖励任务。
|
||||||
|
- 一条龙完成后复用现有配置组的计费、三分钟保留队列和任务结算规则。
|
||||||
|
|
||||||
|
当 `daily.ley_line_craft_resin_before` 设为 `false` 时,只有地脉模式会跳过“合成树脂”;其他每日模式仍会执行该步骤。
|
||||||
|
|
||||||
|
## 项目配置
|
||||||
|
|
||||||
|
`config/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"daily": {
|
||||||
|
"one_dragon_template": "默认配置",
|
||||||
|
"managed_one_dragon_name": "直播系统自动每日",
|
||||||
|
"ley_line_craft_resin_before": true,
|
||||||
|
"commission_use_current_party": true,
|
||||||
|
"current_party_read_timeout_sec": 45
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `one_dragon_template` | BGI `User/OneDragon` 中作为来源的一条龙配置名,不含 `.json` |
|
||||||
|
| `managed_one_dragon_name` | 系统每次覆盖生成并通过命令行启动的配置名 |
|
||||||
|
| `ley_line_craft_resin_before` | 地脉模式执行前是否插入“合成树脂” |
|
||||||
|
| `commission_use_current_party` | 委托模式是否读取游戏当前队伍名并覆盖 AutoCommissionNova 的战斗与元素采集队伍 |
|
||||||
|
| `current_party_read_timeout_sec` | 读取当前队伍名称的超时时间,限制为 10 至 120 秒 |
|
||||||
|
|
||||||
|
托管配置每次执行都会从模板重新生成。模板中的合成地区、冒险家协会地区、战斗队伍、好感队和尘歌壶设置会保留,系统会强制把完成动作设为“无”,防止任务结束后退出游戏或关机。
|
||||||
|
|
||||||
|
秘境俗称维护在 `config/domain_aliases.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"铭记之谷": ["风本", "少女套"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
键必须是当前 BGI `AutoDomain/settings.json` 中存在的正式秘境名。值是俗称列表。同一俗称不能指向多个秘境;文件在每次指令执行时读取,修改后无需重启本项目。
|
||||||
|
|
||||||
|
## BetterGI 配置
|
||||||
|
|
||||||
|
### 1. 一条龙模板
|
||||||
|
|
||||||
|
在 BetterGI 中创建一条龙配置,名称与 `daily.one_dragon_template` 相同,默认是“默认配置”。在模板中配置:
|
||||||
|
|
||||||
|
- 合成浓缩树脂使用的地区。
|
||||||
|
- 领取每日奖励使用的冒险家协会地区。
|
||||||
|
- 自动秘境和自动地脉需要的战斗队伍、好感队及其他树脂选项。
|
||||||
|
- 尘歌壶进入方式和奖励领取设置。
|
||||||
|
|
||||||
|
模板的任务勾选和顺序不会直接采用,系统会按固定流程重新生成托管配置。
|
||||||
|
|
||||||
|
### 2. 自动秘境
|
||||||
|
|
||||||
|
- 安装并配置 BGI 的 `AutoDomain` 脚本。
|
||||||
|
- 在 BGI 自动战斗配置中选择可用战斗策略,并确保策略文件存在于 `User/AutoFight`。
|
||||||
|
- 系统会关闭一条龙周计划覆盖并写入解析后的正式秘境名。
|
||||||
|
- 系统会永久将 `User/config.json` 中 `autoDomainConfig.specifyResinUse` 修正为 `false`,以树脂耗尽为停止条件。
|
||||||
|
|
||||||
|
### 3. 自动地脉花
|
||||||
|
|
||||||
|
- 在 BGI 中配置自动地脉花使用的战斗策略和队伍。
|
||||||
|
- 系统会把地脉类型和国家写入星期一至星期日的全部字段。
|
||||||
|
- 系统会开启树脂耗尽模式,并关闭“运行次数取小值”。
|
||||||
|
- 系统会永久将 `autoLeyLineOutcropConfig.isGoToSynthesizer` 修正为 `false`。是否在地脉前合成树脂只由本项目的 `ley_line_craft_resin_before` 控制。
|
||||||
|
|
||||||
|
支持国家:蒙德、璃月、稻妻、须弥、枫丹、纳塔、挪德卡莱。
|
||||||
|
|
||||||
|
### 4. 每日委托配置组
|
||||||
|
|
||||||
|
在 BetterGI 的配置组页面创建“每日委托”,加入并启用实际完成每日委托所需的项目。系统不会自动创建此配置组,缺失或没有可执行项目时会拒绝启动自动每日,且不会停止当前任务。
|
||||||
|
|
||||||
|
使用 `AutoCommissionNova` 时,首次必须在 BetterGI 中手动运行并完成配置面板,至少保存用户配置和战斗策略。启用“使用当前队伍”后,战斗队伍与元素采集队伍都可以留空;未启用时,这两个队伍名称都必须保存。确认生成以下文件后,再关闭“启动时显示配置面板”:
|
||||||
|
|
||||||
|
```text
|
||||||
|
User/JsScript/AutoCommissionNova/Data/user-config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
系统会在启动“自动每日 委托”前校验该文件。启用当前队伍后,项目会自动同步自维护脚本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
integrations/LiveCurrentParty
|
||||||
|
```
|
||||||
|
|
||||||
|
系统自动生成并覆盖托管配置组“直播系统读取当前队伍”,通过请求 ID 对应的 `status.json` 获取游戏当前队伍预设名,然后只修改:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AutoCommissionNova/Data/user-config.json
|
||||||
|
party.global.battleTeamName
|
||||||
|
party.global.elementTeamName
|
||||||
|
```
|
||||||
|
|
||||||
|
不会修改 `AutoCommissionNova` 的任何源码,因此第三方脚本更新不会覆盖本项目逻辑。两个字段使用同一次 OCR 得到的当前队伍预设名。
|
||||||
|
|
||||||
|
### 5. 切换队伍配置组
|
||||||
|
|
||||||
|
在 BetterGI 创建配置组“切换队伍”,加入并只启用一个文件夹名为 `AcceleratedEditionSwitchParty` 的 JavaScript 项目。系统会在执行前更新该项目的 `jsScriptSettingsObject.partyName`。
|
||||||
|
|
||||||
|
### 6. 修改队员配置组
|
||||||
|
|
||||||
|
在 BetterGI 创建配置组“修改队员”,加入并只启用一个文件夹名为 `AutoSwitchRoles` 的 JavaScript 项目。系统会按顺序读取:
|
||||||
|
|
||||||
|
```text
|
||||||
|
User/JsScript/AutoSwitchRoles/settings.json
|
||||||
|
User/JsScript/AutoSwitchRoles/combat_avatar.json
|
||||||
|
```
|
||||||
|
|
||||||
|
旧版脚本会从 `settings.json` 的下拉选项读取角色;`AutoSwitchRoles 6.7.0+` 的位置参数是文本框,系统会改为读取 `combat_avatar.json` 中的正式名称和别名。校验后更新配置组中的 `position1` 到 `position4`。
|
||||||
|
|
||||||
|
## 权限与计费
|
||||||
|
|
||||||
|
`daily`、`switch_party`、`edit_party` 默认权限与 `run` 相同:一级超管和已完成上号的二级队首。可以在后台“规则”页修改启停状态、别名和允许角色。
|
||||||
|
|
||||||
|
三个新功能都复用现有排队和计费规则。切换队伍与修改队员分别执行配置组“切换队伍”和“修改队员”,并按普通配置组的三分钟完成规则结算。
|
||||||
|
|
||||||
|
## 排障
|
||||||
|
|
||||||
|
- 提示找不到一条龙模板:检查 `User/OneDragon/<模板名>.json` 是否存在,且模板名与配置完全一致。
|
||||||
|
- 提示秘境未知:检查 BGI `AutoDomain/settings.json` 和 `config/domain_aliases.json` 的正式名称。
|
||||||
|
- 提示战斗策略不存在:在 BetterGI 中重新选择策略,并确认对应文件位于 `User/AutoFight`。
|
||||||
|
- 提示配置组缺少 JavaScript 项目:检查配置组名称、项目的 `folderName` 和启用状态。
|
||||||
|
- 提示 AutoCommissionNova 未完成首次配置:在 BGI 中打开配置面板并保存用户配置与战斗策略;关闭“使用当前队伍”时还需保存战斗队伍和元素采集队伍。
|
||||||
|
- 提示读取当前队伍失败:确认当前场景允许打开配队界面;若 OCR 返回多个候选,查看 BetterGI 日志中的“当前队伍名称候选”记录。
|
||||||
|
- 提示角色未知或有歧义:检查 `AutoSwitchRoles/settings.json` 或 `combat_avatar.json`,使用其中唯一对应的名称或别名,并用空格明确分隔四人。
|
||||||
|
- BGI 启动失败:确认版本为 `0.63.0+`、`bettergi.exe_path` 和 `bettergi.work_dir` 正确,并查看 BetterGI 日志。
|
||||||
|
- 一条龙执行中子任务报错:查看 BetterGI 日志确认后续“领取尘歌壶奖励”和“领取每日奖励”是否继续;本项目只在整条一条龙完成标记出现后结算。
|
||||||
|
- 自动秘境异常后扫码上号卡在“退出秘境”:确认项目维护的 `integrations/扫码上号` 已同步。脚本会 OCR 识别“退出秘境”并点击右侧确认,OCR 按钮定位失败时使用 1920×1080 坐标 `(1164, 757)` 兜底。
|
||||||
+138
-1
@@ -15,6 +15,9 @@ const COMMAND_ROLE_DEFAULTS = {
|
|||||||
confirm_yes: ["super_admin", "pending_operator"],
|
confirm_yes: ["super_admin", "pending_operator"],
|
||||||
confirm_no: ["super_admin", "pending_operator"],
|
confirm_no: ["super_admin", "pending_operator"],
|
||||||
run: ["super_admin", "active_operator"],
|
run: ["super_admin", "active_operator"],
|
||||||
|
daily: ["super_admin", "active_operator"],
|
||||||
|
switch_party: ["super_admin", "active_operator"],
|
||||||
|
edit_party: ["super_admin", "active_operator"],
|
||||||
leave: ["super_admin", "active_operator", "viewer"],
|
leave: ["super_admin", "active_operator", "viewer"],
|
||||||
reset: ["super_admin"],
|
reset: ["super_admin"],
|
||||||
points: ROLE_OPTIONS.map(([id]) => id),
|
points: ROLE_OPTIONS.map(([id]) => id),
|
||||||
@@ -29,6 +32,9 @@ const CMD_LABELS = {
|
|||||||
confirm_yes: "确认是",
|
confirm_yes: "确认是",
|
||||||
confirm_no: "确认不是",
|
confirm_no: "确认不是",
|
||||||
run: "执行配置组",
|
run: "执行配置组",
|
||||||
|
daily: "自动每日",
|
||||||
|
switch_party: "切换队伍",
|
||||||
|
edit_party: "修改队员",
|
||||||
leave: "退出",
|
leave: "退出",
|
||||||
reset: "重置",
|
reset: "重置",
|
||||||
points: "积分",
|
points: "积分",
|
||||||
@@ -36,7 +42,7 @@ const CMD_LABELS = {
|
|||||||
help: "帮助"
|
help: "帮助"
|
||||||
};
|
};
|
||||||
|
|
||||||
const CMD_HAS_ARG = { run: true };
|
const CMD_HAS_ARG = { run: true, daily: true, switch_party: true, edit_party: true };
|
||||||
|
|
||||||
const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
|
const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
|
||||||
const splitList = (value) => String(value || "").replaceAll(",", ",").split(",").map((item) => item.trim()).filter(Boolean);
|
const splitList = (value) => String(value || "").replaceAll(",", ",").split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
@@ -82,6 +88,12 @@ const ensureDraftShape = (draft) => {
|
|||||||
draft.bilibili.cookie_auto_refresh_enabled ??= true;
|
draft.bilibili.cookie_auto_refresh_enabled ??= true;
|
||||||
draft.bilibili.cookie_check_interval_hours ??= 6;
|
draft.bilibili.cookie_check_interval_hours ??= 6;
|
||||||
draft.bettergi ||= {};
|
draft.bettergi ||= {};
|
||||||
|
draft.daily ||= {};
|
||||||
|
draft.daily.one_dragon_template ||= "默认配置";
|
||||||
|
draft.daily.managed_one_dragon_name ||= "直播系统自动每日";
|
||||||
|
draft.daily.ley_line_craft_resin_before ??= true;
|
||||||
|
draft.daily.commission_use_current_party ??= true;
|
||||||
|
draft.daily.current_party_read_timeout_sec ??= 45;
|
||||||
draft.global ||= {};
|
draft.global ||= {};
|
||||||
draft.queue ||= {};
|
draft.queue ||= {};
|
||||||
draft.frontend ||= {};
|
draft.frontend ||= {};
|
||||||
@@ -126,6 +138,31 @@ const ensureDraftShape = (draft) => {
|
|||||||
draft.rules ||= [];
|
draft.rules ||= [];
|
||||||
draft.commands ||= {};
|
draft.commands ||= {};
|
||||||
draft.music_monitor.targets ||= [];
|
draft.music_monitor.targets ||= [];
|
||||||
|
draft.llm_reply ||= {};
|
||||||
|
const llmDefaults = {
|
||||||
|
enabled: false,
|
||||||
|
provider: "openai",
|
||||||
|
base_url: "",
|
||||||
|
api_key: "",
|
||||||
|
model: "",
|
||||||
|
system_prompt: "你是B站直播间里的互动助手,直播间主播在玩原神。观众会发弹幕和你聊天,你用简短口语化的中文回复(不超过50字),语气轻松友好,可以玩梗,但不要讨论政治敏感内容,不要承诺任何游戏代练或交易。",
|
||||||
|
temperature: 0.8,
|
||||||
|
max_tokens: 128,
|
||||||
|
timeout_sec: 20,
|
||||||
|
retry_attempts: 2,
|
||||||
|
max_reply_chars: 60,
|
||||||
|
replies_per_minute: 6,
|
||||||
|
per_user_cooldown_sec: 30,
|
||||||
|
context_turns: 6,
|
||||||
|
min_text_len: 2,
|
||||||
|
blacklist_uids: [],
|
||||||
|
ignore_prefixes: ["//"],
|
||||||
|
reply_via_tts: true,
|
||||||
|
reply_via_danmu: true
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(llmDefaults)) {
|
||||||
|
draft.llm_reply[key] ??= value;
|
||||||
|
}
|
||||||
draft.global.admin_uids ||= [];
|
draft.global.admin_uids ||= [];
|
||||||
draft.global.admin_uidsText = draft.global.admin_uids.join(",");
|
draft.global.admin_uidsText = draft.global.admin_uids.join(",");
|
||||||
draft.music_monitor.targetsText = draft.music_monitor.targets.join(",");
|
draft.music_monitor.targetsText = draft.music_monitor.targets.join(",");
|
||||||
@@ -166,6 +203,7 @@ createApp({
|
|||||||
["redemption", "兑换码"],
|
["redemption", "兑换码"],
|
||||||
["songs", "点歌"],
|
["songs", "点歌"],
|
||||||
["media", "音乐/TTS"],
|
["media", "音乐/TTS"],
|
||||||
|
["llm", "AI回复"],
|
||||||
["system", "系统定时"],
|
["system", "系统定时"],
|
||||||
["logs", "日志"],
|
["logs", "日志"],
|
||||||
["raw", "JSON"]
|
["raw", "JSON"]
|
||||||
@@ -207,6 +245,8 @@ createApp({
|
|||||||
giftTestNum: 1,
|
giftTestNum: 1,
|
||||||
giftTestValue: 0,
|
giftTestValue: 0,
|
||||||
giftTestResult: "",
|
giftTestResult: "",
|
||||||
|
llmTestText: "主播在玩什么游戏呀?",
|
||||||
|
llmTestResult: "",
|
||||||
lastLoadedAt: "",
|
lastLoadedAt: "",
|
||||||
songKeyword: "",
|
songKeyword: "",
|
||||||
songResults: [],
|
songResults: [],
|
||||||
@@ -276,6 +316,22 @@ createApp({
|
|||||||
lanAdminUrls() {
|
lanAdminUrls() {
|
||||||
return this.state.access_urls?.lan_admin || [];
|
return this.state.access_urls?.lan_admin || [];
|
||||||
},
|
},
|
||||||
|
llmIgnorePrefixesText: {
|
||||||
|
get() {
|
||||||
|
return (this.draft?.llm_reply?.ignore_prefixes || []).join(",");
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.draft.llm_reply.ignore_prefixes = String(value || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
llmBlacklistText: {
|
||||||
|
get() {
|
||||||
|
return (this.draft?.llm_reply?.blacklist_uids || []).join(",");
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.draft.llm_reply.blacklist_uids = String(value || "").split(",").map((s) => s.trim()).filter(Boolean).map(Number).filter(Number.isFinite);
|
||||||
|
}
|
||||||
|
},
|
||||||
songQueue() {
|
songQueue() {
|
||||||
return this.music.song_requests?.queue || this.music.requests || [];
|
return this.music.song_requests?.queue || this.music.requests || [];
|
||||||
},
|
},
|
||||||
@@ -1013,6 +1069,41 @@ createApp({
|
|||||||
this.giftTestResult = err.message || String(err);
|
this.giftTestResult = err.message || String(err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async saveLlmReply() {
|
||||||
|
if (!this.draft?.llm_reply) return;
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
await adminApi("/api/admin/action/save_llm_reply_config", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(clone(this.draft.llm_reply))
|
||||||
|
});
|
||||||
|
this.notify("AI 回复配置已保存");
|
||||||
|
} catch (err) {
|
||||||
|
this.handleAuthLoss(err);
|
||||||
|
this.notify(err.message || String(err), "error");
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async testLlmReply() {
|
||||||
|
if (!this.draft?.llm_reply) return;
|
||||||
|
this.llmTestResult = "请求中...";
|
||||||
|
try {
|
||||||
|
// 先保存当前配置再测试,保证测的是面板上的最新值
|
||||||
|
await adminApi("/api/admin/action/save_llm_reply_config", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(clone(this.draft.llm_reply))
|
||||||
|
});
|
||||||
|
const data = await adminApi("/api/admin/action/llm_reply_test", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ text: this.llmTestText, uname: "后台测试", broadcast: false })
|
||||||
|
});
|
||||||
|
this.llmTestResult = `${data.reply}(${data.provider}/${data.model},${data.duration_ms}ms)`;
|
||||||
|
} catch (err) {
|
||||||
|
this.handleAuthLoss(err);
|
||||||
|
this.llmTestResult = err.message || String(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
async saveSystemSchedule() {
|
async saveSystemSchedule() {
|
||||||
if (!this.draft?.system) return;
|
if (!this.draft?.system) return;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
@@ -1261,6 +1352,11 @@ createApp({
|
|||||||
<h2>BetterGI</h2>
|
<h2>BetterGI</h2>
|
||||||
<label>BetterGI.exe<input v-model="draft.bettergi.exe_path"></label>
|
<label>BetterGI.exe<input v-model="draft.bettergi.exe_path"></label>
|
||||||
<label>工作目录<input v-model="draft.bettergi.work_dir"></label>
|
<label>工作目录<input v-model="draft.bettergi.work_dir"></label>
|
||||||
|
<label>每日一条龙模板<input v-model="draft.daily.one_dragon_template"></label>
|
||||||
|
<label>托管一条龙名称<input v-model="draft.daily.managed_one_dragon_name"></label>
|
||||||
|
<label class="inline"><input v-model="draft.daily.ley_line_craft_resin_before" type="checkbox"> 地脉前合成树脂</label>
|
||||||
|
<label class="inline"><input v-model="draft.daily.commission_use_current_party" type="checkbox"> 委托战斗及采集均使用游戏当前队伍</label>
|
||||||
|
<label>读取当前队伍超时秒数<input v-model.number="draft.daily.current_party_read_timeout_sec" type="number" min="10" max="120"></label>
|
||||||
<label>超管 UID<input v-model="draft.global.admin_uidsText" @blur="draft.global.admin_uids = splitList(draft.global.admin_uidsText).map((item) => Number(item)).filter((item) => Number.isFinite(item))" placeholder="逗号分隔 UID"></label>
|
<label>超管 UID<input v-model="draft.global.admin_uidsText" @blur="draft.global.admin_uids = splitList(draft.global.admin_uidsText).map((item) => Number(item)).filter((item) => Number.isFinite(item))" placeholder="逗号分隔 UID"></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -1648,6 +1744,47 @@ createApp({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeTab==='llm' && draft" class="form-grid">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>AI 弹幕回复</h2>
|
||||||
|
<label class="inline"><input v-model="draft.llm_reply.enabled" type="checkbox"> 启用 LLM 弹幕回复(触发指令的弹幕自动忽略,不会发给 AI)</label>
|
||||||
|
<label>协议适配
|
||||||
|
<select v-model="draft.llm_reply.provider">
|
||||||
|
<option value="openai">OpenAI 兼容(DeepSeek/通义/Kimi/GLM/硅基流动等)</option>
|
||||||
|
<option value="ollama">Ollama 原生</option>
|
||||||
|
<option value="anthropic">Anthropic(Claude)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>接口地址 base_url<input v-model="draft.llm_reply.base_url" placeholder="如 https://api.deepseek.com/v1 或 http://localhost:11434"></label>
|
||||||
|
<label>API Key<input v-model="draft.llm_reply.api_key" type="password" placeholder="Ollama 本地可留空"></label>
|
||||||
|
<label>模型<input v-model="draft.llm_reply.model" placeholder="如 deepseek-chat / qwen3 / claude-sonnet-4-5"></label>
|
||||||
|
<label>人设(系统提示词)<textarea v-model="draft.llm_reply.system_prompt" rows="4"></textarea></label>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>回复与限流</h2>
|
||||||
|
<label>回复最大字数<input v-model.number="draft.llm_reply.max_reply_chars" type="number" min="10" max="200"></label>
|
||||||
|
<label>每分钟最多回复条数<input v-model.number="draft.llm_reply.replies_per_minute" type="number" min="0" max="60"></label>
|
||||||
|
<label>同一观众冷却秒数(0=不限)<input v-model.number="draft.llm_reply.per_user_cooldown_sec" type="number" min="0" max="3600"></label>
|
||||||
|
<label>上下文轮数<input v-model.number="draft.llm_reply.context_turns" type="number" min="0" max="20"></label>
|
||||||
|
<label>最短触发字数<input v-model.number="draft.llm_reply.min_text_len" type="number" min="0" max="20"></label>
|
||||||
|
<label>请求超时秒数<input v-model.number="draft.llm_reply.timeout_sec" type="number" min="3" max="120"></label>
|
||||||
|
<label>温度<input v-model.number="draft.llm_reply.temperature" type="number" min="0" max="2" step="0.1"></label>
|
||||||
|
<label class="inline"><input v-model="draft.llm_reply.reply_via_tts" type="checkbox"> 语音播报 AI 回复</label>
|
||||||
|
<label class="inline"><input v-model="draft.llm_reply.reply_via_danmu" type="checkbox"> 弹幕发送 AI 回复</label>
|
||||||
|
<label>忽略前缀(逗号分隔,这些开头的弹幕不回复)<input v-model="llmIgnorePrefixesText" placeholder="//"></label>
|
||||||
|
<label>黑名单 UID(逗号分隔)<input v-model="llmBlacklistText" placeholder="123456,654321"></label>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>测试</h2>
|
||||||
|
<label>模拟弹幕<input v-model="llmTestText" placeholder="输入一条观众弹幕"></label>
|
||||||
|
<div class="actions-row">
|
||||||
|
<button class="primary" :disabled="saving" @click="saveLlmReply">保存配置</button>
|
||||||
|
<button class="secondary" :disabled="!draft.llm_reply.enabled" @click="testLlmReply">发送测试弹幕并预览回复</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="llmTestResult" class="llm-test-result">{{ llmTestResult }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="activeTab==='system' && draft" class="form-grid">
|
<section v-if="activeTab==='system' && draft" class="form-grid">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>直播时间</h2>
|
<h2>直播时间</h2>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
(async function () {
|
||||||
|
setGameMetrics(1920, 1080, 1);
|
||||||
|
|
||||||
|
const STATUS_FILE = "status.json";
|
||||||
|
const requestId = String(settings.requestId || "").trim();
|
||||||
|
let lastCandidates = [];
|
||||||
|
|
||||||
|
function writeStatus(state, data) {
|
||||||
|
const payload = Object.assign({
|
||||||
|
state: state,
|
||||||
|
request_id: requestId,
|
||||||
|
party_name: "",
|
||||||
|
candidates: lastCandidates,
|
||||||
|
message: "",
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}, data || {});
|
||||||
|
file.writeTextSync(STATUS_FILE, JSON.stringify(payload, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || "").replace(/\s+/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPartyNameCandidate(text) {
|
||||||
|
if (!text || text.length > 20) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ignored = [
|
||||||
|
"队伍配置",
|
||||||
|
"快速编队",
|
||||||
|
"元素共鸣",
|
||||||
|
"调整队伍",
|
||||||
|
"当前队伍",
|
||||||
|
"部署",
|
||||||
|
"出战",
|
||||||
|
"详情"
|
||||||
|
];
|
||||||
|
if (ignored.some((label) => text === label || text.includes(label))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !/^(esc|enter|space|l|f\d{1,2})$/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPartyPageOpen() {
|
||||||
|
const capture = captureGameRegion();
|
||||||
|
try {
|
||||||
|
const results = capture.findMulti(RecognitionObject.ocr(0, 0, 720, 180));
|
||||||
|
for (let index = 0; index < results.count; index++) {
|
||||||
|
const text = normalizeText(results[index].text);
|
||||||
|
if (text.includes("队伍配置")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
capture.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recognizePartyNameCandidates() {
|
||||||
|
const capture = captureGameRegion();
|
||||||
|
try {
|
||||||
|
const results = capture.findMulti(RecognitionObject.ocr(0, 940, 520, 140));
|
||||||
|
const candidates = [];
|
||||||
|
for (let index = 0; index < results.count; index++) {
|
||||||
|
const result = results[index];
|
||||||
|
const text = normalizeText(result.text);
|
||||||
|
log.info(
|
||||||
|
"当前队伍名称候选位置:({x},{y},{w},{h}), 识别结果:{text}",
|
||||||
|
result.x,
|
||||||
|
result.y,
|
||||||
|
result.Width,
|
||||||
|
result.Height,
|
||||||
|
text
|
||||||
|
);
|
||||||
|
if (isPartyNameCandidate(text)) {
|
||||||
|
candidates.push({
|
||||||
|
text: text,
|
||||||
|
x: Number(result.x || 0),
|
||||||
|
y: Number(result.y || 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort((left, right) => {
|
||||||
|
if (left.x !== right.x) {
|
||||||
|
return left.x - right.x;
|
||||||
|
}
|
||||||
|
return right.y - left.y;
|
||||||
|
});
|
||||||
|
return candidates.filter(
|
||||||
|
(candidate, index, values) =>
|
||||||
|
values.findIndex((value) => value.text === candidate.text) === index
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
capture.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPartyPageAndRead() {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
keyPress("VK_L");
|
||||||
|
await sleep(2200);
|
||||||
|
for (let scan = 0; scan < 4; scan++) {
|
||||||
|
if (isPartyPageOpen()) {
|
||||||
|
const candidates = recognizePartyNameCandidates();
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sleep(600);
|
||||||
|
}
|
||||||
|
keyPress("VK_ESCAPE");
|
||||||
|
await sleep(800);
|
||||||
|
await genshin.returnMainUi();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!requestId) {
|
||||||
|
throw new Error("读取请求标识为空");
|
||||||
|
}
|
||||||
|
writeStatus("running");
|
||||||
|
await genshin.returnMainUi();
|
||||||
|
const candidates = await openPartyPageAndRead();
|
||||||
|
lastCandidates = candidates.map((candidate) => candidate.text);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error("未识别到当前队伍名称,请确认游戏处于可打开配队界面的状态");
|
||||||
|
}
|
||||||
|
if (candidates.length > 1) {
|
||||||
|
throw new Error("识别到多个队伍名称候选:" + lastCandidates.join("、"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const partyName = candidates[0].text;
|
||||||
|
await genshin.returnMainUi();
|
||||||
|
await sleep(500);
|
||||||
|
writeStatus("success", {
|
||||||
|
party_name: partyName,
|
||||||
|
candidates: lastCandidates
|
||||||
|
});
|
||||||
|
log.info("当前队伍名称读取成功: {name}", partyName);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await genshin.returnMainUi();
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
const message = String(error && error.message ? error.message : error);
|
||||||
|
writeStatus("error", {
|
||||||
|
candidates: lastCandidates,
|
||||||
|
message: message
|
||||||
|
});
|
||||||
|
log.error("当前队伍名称读取失败: {message}", message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 1,
|
||||||
|
"name": "直播系统读取当前队伍",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"bgi_version": "0.63.0",
|
||||||
|
"description": "读取游戏当前队伍预设名称并通过状态文件返回给直播联动系统",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "BGI直播项目"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings_ui": "settings.json",
|
||||||
|
"main": "main.js",
|
||||||
|
"saved_files": [
|
||||||
|
"status.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "requestId",
|
||||||
|
"type": "input-text",
|
||||||
|
"label": "读取请求标识",
|
||||||
|
"default": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
(async function () {
|
(async function () {
|
||||||
// ========================================
|
// ========================================
|
||||||
// 扫码上号 v3
|
// 扫码上号 v3.1
|
||||||
// 流程: 状态判定 → (已登录则先退出) → A0检测tap → A1选号 → A2等登录+点中心 → A3完成
|
// 流程: 状态判定 → (已登录则先退出) → A0检测tap → A1选号 → A2等登录+点中心 → A3完成
|
||||||
// 状态文件: status.txt (登录中 / 已登录)
|
// 状态文件: status.txt (登录中 / 已登录)
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -20,6 +20,25 @@
|
|||||||
// 脚本启动 → 写入"登录中"
|
// 脚本启动 → 写入"登录中"
|
||||||
writeStatus("登录中");
|
writeStatus("登录中");
|
||||||
|
|
||||||
|
function settingNumber(name, fallback) {
|
||||||
|
const value = typeof settings !== "undefined" && settings ? Number(settings[name]) : NaN;
|
||||||
|
return Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function settingBoolean(name, fallback) {
|
||||||
|
if (typeof settings === "undefined" || !settings || settings[name] === undefined) return fallback;
|
||||||
|
return settings[name] !== false && String(settings[name]).toLowerCase() !== "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
const useExitDomainOcr = settingBoolean("useOcr", true);
|
||||||
|
let confirmExitX = settingNumber("confirmExitX", 1164);
|
||||||
|
let confirmExitY = settingNumber("confirmExitY", 757);
|
||||||
|
if (confirmExitX === 830 && confirmExitY === 600) {
|
||||||
|
confirmExitX = 1164;
|
||||||
|
confirmExitY = 757;
|
||||||
|
log.info("检测到旧版退出秘境坐标,已自动修正为 (1164, 757)");
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 加载图像资源 ----------
|
// ---------- 加载图像资源 ----------
|
||||||
const tapMat = file.readImageMatSync("assets/tap.png");
|
const tapMat = file.readImageMatSync("assets/tap.png");
|
||||||
const a0PhoneMat = file.readImageMatSync("assets/a0_phone.png");
|
const a0PhoneMat = file.readImageMatSync("assets/a0_phone.png");
|
||||||
@@ -115,6 +134,54 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOcrText(text) {
|
||||||
|
return String(text || "").replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readOcrResults(x, y, w, h) {
|
||||||
|
const cap = captureGameRegion();
|
||||||
|
try {
|
||||||
|
const results = cap.findMulti(RecognitionObject.ocr(x, y, w, h));
|
||||||
|
return Array.from(results).map((result) => ({
|
||||||
|
text: normalizeOcrText(result.text),
|
||||||
|
x: Number(result.x || 0),
|
||||||
|
y: Number(result.y || 0),
|
||||||
|
w: Number(result.width || result.Width || 0),
|
||||||
|
h: Number(result.height || result.Height || 0)
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
log.warn("退出秘境弹窗 OCR 失败: " + e);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
cap.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExitDomainDialog() {
|
||||||
|
const results = readOcrResults(480, 220, 960, 620);
|
||||||
|
const combinedText = results.map((result) => result.text).join("");
|
||||||
|
if (!combinedText.includes("退出秘境")) return false;
|
||||||
|
|
||||||
|
log.info("检测到退出秘境确认弹窗");
|
||||||
|
const confirmResult = results
|
||||||
|
.filter((result) => result.text.includes("确认") && result.x >= 900)
|
||||||
|
.sort((left, right) => right.x - left.x)[0];
|
||||||
|
if (useExitDomainOcr && confirmResult) {
|
||||||
|
const x = Math.round(confirmResult.x + confirmResult.w / 2);
|
||||||
|
const y = Math.round(confirmResult.y + confirmResult.h / 2);
|
||||||
|
log.info(`OCR 定位确认按钮,点击 (${x}, ${y})`);
|
||||||
|
click(x, y);
|
||||||
|
} else {
|
||||||
|
const reason = useExitDomainOcr ? "OCR 未定位到确认按钮" : "已关闭 OCR 按钮定位";
|
||||||
|
log.warn(`${reason},使用固定坐标 (${confirmExitX}, ${confirmExitY})`);
|
||||||
|
click(confirmExitX, confirmExitY);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("已点击退出秘境确认,等待返回主界面...");
|
||||||
|
await sleep(6000);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 前置处理:检测登录前的提示图标
|
// 前置处理:检测登录前的提示图标
|
||||||
// 检测到则点击 (1830, 985),1秒后点击 (1100, 675),再1秒后进入初始判定;
|
// 检测到则点击 (1830, 985),1秒后点击 (1100, 675),再1秒后进入初始判定;
|
||||||
@@ -155,11 +222,20 @@
|
|||||||
log.info("未同时检测到 tap 图标和手机图标 → 开始检测派蒙头像");
|
log.info("未同时检测到 tap 图标和手机图标 → 开始检测派蒙头像");
|
||||||
let paimonFound = findImageMatchRobust(paimonMat);
|
let paimonFound = findImageMatchRobust(paimonMat);
|
||||||
|
|
||||||
|
if (!paimonFound && await handleExitDomainDialog()) {
|
||||||
|
paimonFound = findImageMatchRobust(paimonMat);
|
||||||
|
if (paimonFound) log.info("退出秘境后已检测到派蒙头像");
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; !paimonFound && i < 8; i++) {
|
for (let i = 0; !paimonFound && i < 8; i++) {
|
||||||
log.info(`第 ${i + 1}/8 次未检测到派蒙头像,按 ESC 后重试...`);
|
log.info(`第 ${i + 1}/8 次未检测到派蒙头像,按 ESC 后重试...`);
|
||||||
keyPress("Escape");
|
keyPress("Escape");
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
if (await handleExitDomainDialog()) {
|
||||||
|
log.info("退出秘境弹窗已处理,重新检测主界面");
|
||||||
|
}
|
||||||
paimonFound = findImageMatchRobust(paimonMat);
|
paimonFound = findImageMatchRobust(paimonMat);
|
||||||
|
if (paimonFound) log.info("已检测到派蒙头像,继续退出账号流程");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!paimonFound) {
|
if (!paimonFound) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 1,
|
"manifest_version": 1,
|
||||||
"name": "扫码上号",
|
"name": "扫码上号",
|
||||||
"version": "1.0",
|
"version": "1.1",
|
||||||
"bgi_version": "0.48.0",
|
"bgi_version": "0.48.0",
|
||||||
"description": "直播用扫码上号脚本:判断在大世界则退出到开门页面→点击扫码登录→等待扫码→检测验证码",
|
"description": "直播用扫码上号脚本:判断在大世界则退出到开门页面→点击扫码登录→等待扫码→检测验证码",
|
||||||
"authors": [
|
"authors": [
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
{
|
{
|
||||||
"name": "useOcr",
|
"name": "useOcr",
|
||||||
"type": "checkbox",
|
"type": "checkbox",
|
||||||
"label": "使用OCR识别文本(否则用固定坐标)",
|
"label": "退出秘境确认按钮使用 OCR 定位(关闭后使用固定坐标)",
|
||||||
"default": true
|
"default": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -53,14 +53,14 @@
|
|||||||
{
|
{
|
||||||
"name": "confirmExitX",
|
"name": "confirmExitX",
|
||||||
"type": "input-text",
|
"type": "input-text",
|
||||||
"label": "确认退出按钮 X(固定坐标模式)",
|
"label": "退出秘境确认按钮 X(1920×1080 固定坐标)",
|
||||||
"default": "830"
|
"default": "1164"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "confirmExitY",
|
"name": "confirmExitY",
|
||||||
"type": "input-text",
|
"type": "input-text",
|
||||||
"label": "确认退出按钮 Y(固定坐标模式)",
|
"label": "退出秘境确认按钮 Y(1920×1080 固定坐标)",
|
||||||
"default": "600"
|
"default": "757"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "qrLoginX",
|
"name": "qrLoginX",
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ cd /d "%~dp0"
|
|||||||
set PYTHON=%~dp0.venv-tts\Scripts\python.exe
|
set PYTHON=%~dp0.venv-tts\Scripts\python.exe
|
||||||
|
|
||||||
if not exist "%PYTHON%" (
|
if not exist "%PYTHON%" (
|
||||||
echo Python virtual environment not found: %PYTHON%
|
set PYTHON=%~dp0.venv\Scripts\python.exe
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%PYTHON%" (
|
||||||
|
echo Python virtual environment not found.
|
||||||
|
echo Checked .venv-tts and .venv under: %~dp0
|
||||||
echo Please run setup first or ask WorkBuddy to repair dependencies.
|
echo Please run setup first or ask WorkBuddy to repair dependencies.
|
||||||
pause
|
pause
|
||||||
exit /b 1
|
exit /b 1
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import datetime
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
targets = []
|
||||||
|
for p in psutil.process_iter(["name", "cmdline"]):
|
||||||
|
try:
|
||||||
|
cmd = " ".join(p.info["cmdline"] or [])
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
continue
|
||||||
|
if "main.py" in cmd and "--role" in cmd:
|
||||||
|
targets.append(p)
|
||||||
|
|
||||||
|
for p in targets:
|
||||||
|
try:
|
||||||
|
parent = p.parent()
|
||||||
|
print(
|
||||||
|
p.pid,
|
||||||
|
p.name(),
|
||||||
|
"started",
|
||||||
|
datetime.datetime.fromtimestamp(p.create_time()).strftime("%m-%d %H:%M:%S"),
|
||||||
|
"parent",
|
||||||
|
parent.pid if parent else None,
|
||||||
|
parent.name() if parent else "",
|
||||||
|
)
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
print(p.pid, "exited")
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
print("NO_SERVICE_PROCESS")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
TARGET = "perf_sampler.py"
|
||||||
|
mode = sys.argv[1] if len(sys.argv) > 1 else "kill"
|
||||||
|
me = os.getpid()
|
||||||
|
matched = []
|
||||||
|
for p in psutil.process_iter(["name", "cmdline"]):
|
||||||
|
if p.pid == me:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parts = [x for x in (p.info["cmdline"] or [])]
|
||||||
|
# only match when the script is the program argument, not substring of -c code
|
||||||
|
if any(part.endswith(TARGET) for part in parts):
|
||||||
|
matched.append(p)
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if mode == "kill":
|
||||||
|
for p in matched:
|
||||||
|
try:
|
||||||
|
p.kill()
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
print("killed:", [p.pid for p in matched])
|
||||||
|
else:
|
||||||
|
import datetime
|
||||||
|
for p in matched:
|
||||||
|
print(p.pid, p.name(), datetime.datetime.fromtimestamp(p.create_time()).strftime("%H:%M:%S"))
|
||||||
+5
-2
@@ -32,7 +32,9 @@ function Invoke-Checked([string]$File, [string[]]$Arguments) {
|
|||||||
function Get-ProjectPython {
|
function Get-ProjectPython {
|
||||||
$candidates = @(
|
$candidates = @(
|
||||||
$env:LIVE_STREAMING_PYTHON,
|
$env:LIVE_STREAMING_PYTHON,
|
||||||
"E:\Programs\Anaconda3\envs\Live-streaming\python.exe",
|
(Join-Path $Root ".venv-tts\Scripts\python.exe"),
|
||||||
|
(Join-Path $Root ".venv\Scripts\python.exe"),
|
||||||
|
$(if ($env:CONDA_PREFIX) { Join-Path $env:CONDA_PREFIX "python.exe" }),
|
||||||
"python"
|
"python"
|
||||||
) | Where-Object { $_ }
|
) | Where-Object { $_ }
|
||||||
foreach ($candidate in $candidates) {
|
foreach ($candidate in $candidates) {
|
||||||
@@ -45,7 +47,7 @@ function Get-ProjectPython {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw "No usable Python found. Install Python 3.10+, set LIVE_STREAMING_PYTHON, or use the Live-streaming conda env."
|
throw "No usable Python found. Create .venv, install Python 3.10+, or set LIVE_STREAMING_PYTHON."
|
||||||
}
|
}
|
||||||
|
|
||||||
$Python = Get-ProjectPython
|
$Python = Get-ProjectPython
|
||||||
@@ -197,6 +199,7 @@ New-Item -ItemType Directory -Force -Path (Join-Path $AppDist "logs") | Out-Null
|
|||||||
Copy-Item -LiteralPath (Join-Path $Root "web") -Destination (Join-Path $AppDist "web") -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $Root "web") -Destination (Join-Path $AppDist "web") -Recurse -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $Root "config") -Destination (Join-Path $AppDist "config") -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $Root "config") -Destination (Join-Path $AppDist "config") -Recurse -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $Root "integrations") -Destination (Join-Path $AppDist "integrations") -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $Root "integrations") -Destination (Join-Path $AppDist "integrations") -Recurse -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $Root "docs") -Destination (Join-Path $AppDist "docs") -Recurse -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $Root "vendor") -Destination (Join-Path $AppDist "vendor") -Recurse -Force
|
Copy-Item -LiteralPath (Join-Path $Root "vendor") -Destination (Join-Path $AppDist "vendor") -Recurse -Force
|
||||||
Copy-Item -LiteralPath (Join-Path $Root "README.md") -Destination (Join-Path $AppDist "README.md") -Force
|
Copy-Item -LiteralPath (Join-Path $Root "README.md") -Destination (Join-Path $AppDist "README.md") -Force
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Update the Kirara sprite rectangles in web/index.html from transparent PNGs.
|
||||||
|
|
||||||
|
Usage: .venv/Scripts/python.exe scripts/build_kirara_sprite_catalog.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ASSETS = ROOT / "web" / "uploads"
|
||||||
|
PAGE = ROOT / "web" / "index.html"
|
||||||
|
START = " // Source rectangles come from the four generated transparent 2×2 walk sheets."
|
||||||
|
END = " let kiraraDirection = \"front\";"
|
||||||
|
|
||||||
|
|
||||||
|
def frame_boxes(path: Path) -> list[list[int]]:
|
||||||
|
image = Image.open(path).convert("RGBA")
|
||||||
|
width, height = image.size
|
||||||
|
if width < 100 or height < 100:
|
||||||
|
raise ValueError(f"Sprite is unexpectedly small: {path}")
|
||||||
|
boxes = []
|
||||||
|
for frame in range(4):
|
||||||
|
start = round(frame * width / 4)
|
||||||
|
stop = round((frame + 1) * width / 4)
|
||||||
|
alpha = image.crop((start, 0, stop, height)).getchannel("A")
|
||||||
|
content = alpha.point(lambda value: 255 if value >= 16 else 0).getbbox()
|
||||||
|
if content is None:
|
||||||
|
raise ValueError(f"Empty frame {frame}: {path}")
|
||||||
|
left = max(0, content[0] - 3)
|
||||||
|
top = max(0, content[1] - 3)
|
||||||
|
right = min(stop - start, content[2] + 3)
|
||||||
|
bottom = min(height, content[3] + 3)
|
||||||
|
boxes.append([start + left, top, right - left, bottom - top])
|
||||||
|
return boxes
|
||||||
|
|
||||||
|
|
||||||
|
names = [
|
||||||
|
*(f"kirara_walk_{direction}" for direction in ("front", "back", "left", "right")),
|
||||||
|
"kirara_eat_rice", "kirara_eat_dango", "kirara_bath_sponge", "kirara_bath_shower",
|
||||||
|
*(f"kirara_idle_{name}" for name in (
|
||||||
|
"delivery", "reading", "phone", "sleep", "stretch", "groom",
|
||||||
|
"butterfly", "tea", "yarn", "humming")),
|
||||||
|
"kirara_rest_breathe",
|
||||||
|
*(f"kirara_emotion_{name}_{variant}" for name in (
|
||||||
|
"plus3", "plus1", "minus1", "minus3") for variant in ("a", "b")),
|
||||||
|
]
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
" // Crops follow each generated sprite's transparent silhouette.",
|
||||||
|
" const kiraraSpriteBoxes = {",
|
||||||
|
]
|
||||||
|
for name in names:
|
||||||
|
boxes = frame_boxes(ASSETS / f"{name}.png")
|
||||||
|
formatted = ", ".join(str(box).replace(" ", "") for box in boxes)
|
||||||
|
lines.append(f' "{name}": [{formatted}],')
|
||||||
|
lines += [
|
||||||
|
" };",
|
||||||
|
" const kiraraSpriteAsset = name => ({",
|
||||||
|
" src: `/uploads/${name}.png`, boxes: kiraraSpriteBoxes[name]",
|
||||||
|
" });",
|
||||||
|
" const kiraraWalkAssets = Object.fromEntries(",
|
||||||
|
" [\"front\", \"back\", \"left\", \"right\"].map(direction =>",
|
||||||
|
" [direction, kiraraSpriteAsset(`kirara_walk_${direction}`)])",
|
||||||
|
" );",
|
||||||
|
" const kiraraActionAssets = {",
|
||||||
|
" feed: [kiraraSpriteAsset(\"kirara_eat_rice\"), kiraraSpriteAsset(\"kirara_eat_dango\")],",
|
||||||
|
" bath: [kiraraSpriteAsset(\"kirara_bath_sponge\"), kiraraSpriteAsset(\"kirara_bath_shower\")]",
|
||||||
|
" };",
|
||||||
|
" const kiraraIdleAssets = Object.fromEntries(",
|
||||||
|
" [\"delivery\", \"reading\", \"phone\", \"sleep\", \"stretch\",",
|
||||||
|
" \"groom\", \"butterfly\", \"tea\", \"yarn\", \"humming\"].map(type =>",
|
||||||
|
" [type, kiraraSpriteAsset(`kirara_idle_${type}`)])",
|
||||||
|
" );",
|
||||||
|
" const kiraraRestAsset = kiraraSpriteAsset(\"kirara_rest_breathe\");",
|
||||||
|
" const kiraraEmotionAssets = {",
|
||||||
|
]
|
||||||
|
for delta, word in ((3, "plus3"), (1, "plus1"), (-1, "minus1"), (-3, "minus3")):
|
||||||
|
lines.append(
|
||||||
|
f' "{delta}": [kiraraSpriteAsset("kirara_emotion_{word}_a"), '
|
||||||
|
f'kiraraSpriteAsset("kirara_emotion_{word}_b")],'
|
||||||
|
)
|
||||||
|
lines += [" };", ""]
|
||||||
|
|
||||||
|
text = PAGE.read_text(encoding="utf-8")
|
||||||
|
start = text.find(START)
|
||||||
|
if start < 0:
|
||||||
|
start = text.find(" // Crops follow each generated sprite's transparent silhouette.")
|
||||||
|
end = text.find(END, start)
|
||||||
|
if start < 0 or end < 0:
|
||||||
|
raise ValueError("Kirara asset block was not found")
|
||||||
|
PAGE.write_text(text[:start] + "\n".join(lines) + text[end:], encoding="utf-8")
|
||||||
|
print(f"Updated {len(names)} Kirara sprite sheets in {PAGE}")
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Lightweight perf sampler: GPU (nvidia-smi) + system/per-process CPU every 30s.
|
||||||
|
|
||||||
|
Writes CSV rows to logs/perf_samples.csv. Designed to run all day in background
|
||||||
|
so TTS synthesis duration (logs/tts_memory.log) can be correlated against
|
||||||
|
GPU temperature/clocks and CPU pressure afterwards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
OUT = ROOT / "logs" / "perf_samples.csv"
|
||||||
|
INTERVAL_SEC = 30
|
||||||
|
NVIDIA_SMI = shutil.which("nvidia-smi") or r"C:\Windows\System32\nvidia-smi.exe"
|
||||||
|
|
||||||
|
# process name substrings (lowercase) to track individually
|
||||||
|
TRACKED = {
|
||||||
|
"livehime": "livehime.exe",
|
||||||
|
"yuanshen": "yuanshen.exe",
|
||||||
|
"bettergi": "bettergi.exe",
|
||||||
|
"mpv": "mpv.exe",
|
||||||
|
"audiodg": "audiodg.exe",
|
||||||
|
"python": None, # aggregated below (main + TTS worker)
|
||||||
|
}
|
||||||
|
|
||||||
|
FIELDNAMES = [
|
||||||
|
"ts",
|
||||||
|
"gpu_temp_c", "gpu_clock_mhz", "gpu_mem_clock_mhz", "gpu_util_pct",
|
||||||
|
"gpu_power_w", "gpu_throttle",
|
||||||
|
"cpu_total_pct",
|
||||||
|
"cpu_livehime", "cpu_yuanshen", "cpu_bettergi", "cpu_mpv", "cpu_audiodg",
|
||||||
|
"cpu_python_total", "python_proc_count",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def query_gpu() -> dict:
|
||||||
|
keys = (
|
||||||
|
"temperature.gpu", "clocks.current.graphics", "clocks.current.memory",
|
||||||
|
"utilization.gpu", "power.draw", "clocks_event_reasons.active",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
[NVIDIA_SMI, f"--query-gpu={','.join(keys)}", "--format=csv,noheader,nounits"],
|
||||||
|
text=True, timeout=5,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
).strip()
|
||||||
|
temp, clk, memclk, util, power, throttle = [x.strip() for x in out.split(",")]
|
||||||
|
return {
|
||||||
|
"gpu_temp_c": temp,
|
||||||
|
"gpu_clock_mhz": clk,
|
||||||
|
"gpu_mem_clock_mhz": memclk,
|
||||||
|
"gpu_util_pct": util,
|
||||||
|
"gpu_power_w": power,
|
||||||
|
"gpu_throttle": throttle,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {k: "" for k in FIELDNAMES if k.startswith("gpu_")}
|
||||||
|
|
||||||
|
|
||||||
|
def sample_cpu(procs: dict[str, list[psutil.Process]]) -> dict:
|
||||||
|
row = {"cpu_total_pct": psutil.cpu_percent(interval=None)}
|
||||||
|
py_total = 0.0
|
||||||
|
py_count = 0
|
||||||
|
for name, plist in procs.items():
|
||||||
|
total = 0.0
|
||||||
|
alive = []
|
||||||
|
for p in plist:
|
||||||
|
try:
|
||||||
|
total += p.cpu_percent(interval=None)
|
||||||
|
alive.append(p)
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
procs[name] = alive
|
||||||
|
if name == "python":
|
||||||
|
py_total, py_count = total, len(alive)
|
||||||
|
else:
|
||||||
|
row[f"cpu_{name}"] = round(total, 1)
|
||||||
|
row["cpu_python_total"] = round(py_total, 1)
|
||||||
|
row["python_proc_count"] = py_count
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def find_tracked() -> dict[str, list[psutil.Process]]:
|
||||||
|
found: dict[str, list[psutil.Process]] = {k: [] for k in TRACKED}
|
||||||
|
for p in psutil.process_iter(["name"]):
|
||||||
|
try:
|
||||||
|
pname = (p.info["name"] or "").lower()
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
continue
|
||||||
|
for key in TRACKED:
|
||||||
|
if key in pname:
|
||||||
|
found[key].append(p)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
new_file = not OUT.exists()
|
||||||
|
procs = find_tracked()
|
||||||
|
# prime cpu_percent baselines
|
||||||
|
psutil.cpu_percent(interval=None)
|
||||||
|
for plist in procs.values():
|
||||||
|
for p in plist:
|
||||||
|
try:
|
||||||
|
p.cpu_percent(interval=None)
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
with OUT.open("a", newline="", encoding="utf-8") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
|
||||||
|
if new_file:
|
||||||
|
writer.writeheader()
|
||||||
|
while True:
|
||||||
|
time.sleep(INTERVAL_SEC)
|
||||||
|
if time.monotonic() % 300 < INTERVAL_SEC: # re-discover processes ~every 5 min
|
||||||
|
procs = find_tracked()
|
||||||
|
row = {"ts": datetime.now().isoformat(timespec="seconds")}
|
||||||
|
row.update(query_gpu())
|
||||||
|
row.update(sample_cpu(procs))
|
||||||
|
writer.writerow(row)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.bettergi_current_party import (
|
||||||
|
CURRENT_PARTY_SCRIPT_NAME,
|
||||||
|
CurrentPartyReadError,
|
||||||
|
clear_current_party_status,
|
||||||
|
prepare_current_party_read,
|
||||||
|
read_current_party_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BetterGICurrentPartyTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
self.group_dir = self.root / "User" / "ScriptGroup"
|
||||||
|
self._write_json(
|
||||||
|
self.group_dir / "切换队伍.json",
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"name": "切换队伍",
|
||||||
|
"config": {"marker": "preserved"},
|
||||||
|
"projects": [{"name": "旧项目"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
self.group_dir / "其他.json",
|
||||||
|
{"index": 5, "name": "其他", "config": {}, "projects": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_json(path: Path, data):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
def test_prepare_reader_group_clones_config_and_uses_request_id(self):
|
||||||
|
prepared = prepare_current_party_read(self.root, "request-123")
|
||||||
|
|
||||||
|
update = prepared.updates[0]
|
||||||
|
self.assertEqual(update.data["index"], 6)
|
||||||
|
self.assertEqual(update.data["config"]["marker"], "preserved")
|
||||||
|
self.assertEqual(update.data["name"], "直播系统读取当前队伍")
|
||||||
|
project = update.data["projects"][0]
|
||||||
|
self.assertEqual(project["folderName"], CURRENT_PARTY_SCRIPT_NAME)
|
||||||
|
self.assertEqual(project["jsScriptSettingsObject"]["requestId"], "request-123")
|
||||||
|
self.assertEqual(
|
||||||
|
prepared.status_path,
|
||||||
|
self.root / "User" / "JsScript" / CURRENT_PARTY_SCRIPT_NAME / "status.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_reader_ignores_stale_and_running_results(self):
|
||||||
|
prepared = prepare_current_party_read(self.root, "request-123")
|
||||||
|
self._write_json(
|
||||||
|
prepared.status_path,
|
||||||
|
{"state": "success", "request_id": "old", "party_name": "旧队伍"},
|
||||||
|
)
|
||||||
|
self.assertIsNone(read_current_party_status(prepared.status_path, "request-123"))
|
||||||
|
|
||||||
|
self._write_json(
|
||||||
|
prepared.status_path,
|
||||||
|
{"state": "running", "request_id": "request-123"},
|
||||||
|
)
|
||||||
|
self.assertIsNone(read_current_party_status(prepared.status_path, "request-123"))
|
||||||
|
|
||||||
|
self._write_json(
|
||||||
|
prepared.status_path,
|
||||||
|
{
|
||||||
|
"state": "success",
|
||||||
|
"request_id": "request-123",
|
||||||
|
"party_name": "好感队",
|
||||||
|
"candidates": ["好感队"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = read_current_party_status(prepared.status_path, "request-123")
|
||||||
|
self.assertEqual(result.party_name, "好感队")
|
||||||
|
self.assertEqual(result.candidates, ("好感队",))
|
||||||
|
|
||||||
|
def test_status_reader_reports_script_error_and_can_be_cleared(self):
|
||||||
|
prepared = prepare_current_party_read(self.root, "request-123")
|
||||||
|
self._write_json(
|
||||||
|
prepared.status_path,
|
||||||
|
{
|
||||||
|
"state": "error",
|
||||||
|
"request_id": "request-123",
|
||||||
|
"message": "识别到多个候选",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CurrentPartyReadError, "多个候选"):
|
||||||
|
read_current_party_status(prepared.status_path, "request-123")
|
||||||
|
|
||||||
|
clear_current_party_status(prepared.status_path)
|
||||||
|
self.assertFalse(prepared.status_path.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.bettergi_daily import (
|
||||||
|
DAILY_MODE_COMMISSION,
|
||||||
|
DAILY_MODE_DOMAIN,
|
||||||
|
DAILY_MODE_LEY_LINE,
|
||||||
|
DAILY_MODE_NONE,
|
||||||
|
DailyAutomationError,
|
||||||
|
DomainAliasResolver,
|
||||||
|
apply_json_updates,
|
||||||
|
parse_daily_request,
|
||||||
|
prepare_commission_current_party_update,
|
||||||
|
prepare_daily_run,
|
||||||
|
prepare_edit_party_update,
|
||||||
|
prepare_switch_party_update,
|
||||||
|
resolve_party_members,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BetterGIDailyTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
self.alias_path = self.root / "domain_aliases.json"
|
||||||
|
self._write_json(
|
||||||
|
self.alias_path,
|
||||||
|
{
|
||||||
|
"_说明": "test",
|
||||||
|
"铭记之谷": ["风本", "少女套"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "OneDragon" / "默认配置.json",
|
||||||
|
{
|
||||||
|
"Name": "默认配置",
|
||||||
|
"TaskEnabledList": {
|
||||||
|
"mail-template-id": True,
|
||||||
|
"serenitea-template-id": True,
|
||||||
|
"craft-template-id": True,
|
||||||
|
"domain-template-id": False,
|
||||||
|
"ley-line-template-id": False,
|
||||||
|
"daily-reward-template-id": True,
|
||||||
|
},
|
||||||
|
"TaskOrder": [
|
||||||
|
"mail-template-id",
|
||||||
|
"serenitea-template-id",
|
||||||
|
"craft-template-id",
|
||||||
|
"domain-template-id",
|
||||||
|
"ley-line-template-id",
|
||||||
|
"daily-reward-template-id",
|
||||||
|
],
|
||||||
|
"TaskDefinitions": {
|
||||||
|
"mail-template-id": "领取邮件",
|
||||||
|
"serenitea-template-id": "领取尘歌壶奖励",
|
||||||
|
"craft-template-id": "合成树脂",
|
||||||
|
"domain-template-id": "自动秘境",
|
||||||
|
"ley-line-template-id": "自动地脉花",
|
||||||
|
"daily-reward-template-id": "领取每日奖励",
|
||||||
|
},
|
||||||
|
"CraftingBenchCountry": "枫丹",
|
||||||
|
"AdventurersGuildCountry": "璃月",
|
||||||
|
"PartyName": "战斗队",
|
||||||
|
"DailyRewardPartyName": "好感队",
|
||||||
|
"SecretTreasureObjects": ["须臾树脂"],
|
||||||
|
"WeeklyDomainEnabled": True,
|
||||||
|
"DomainName": "旧秘境",
|
||||||
|
"CompletionAction": "关机",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "config.json",
|
||||||
|
{
|
||||||
|
"marker": "preserved",
|
||||||
|
"autoFightConfig": {"strategyName": "策略A"},
|
||||||
|
"autoDomainConfig": {
|
||||||
|
"specifyResinUse": True,
|
||||||
|
"other": 1,
|
||||||
|
},
|
||||||
|
"autoLeyLineOutcropConfig": {
|
||||||
|
"isGoToSynthesizer": True,
|
||||||
|
"team": "战斗队",
|
||||||
|
"friendshipTeam": "",
|
||||||
|
"fightConfig": {"strategyName": "策略A"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
strategy = self.root / "User" / "AutoFight" / "策略A.txt"
|
||||||
|
strategy.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
strategy.write_text("战斗策略", encoding="utf-8")
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "JsScript" / "AutoDomain" / "settings.json",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "domainName",
|
||||||
|
"type": "select",
|
||||||
|
"options": ["铭记之谷", "仲夏庭园"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "ScriptGroup" / "每日委托.json",
|
||||||
|
{"name": "每日委托", "projects": [{"name": "委托", "status": "Enabled"}]},
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "ScriptGroup" / "切换队伍.json",
|
||||||
|
{
|
||||||
|
"name": "切换队伍",
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"folderName": "AcceleratedEditionSwitchParty",
|
||||||
|
"status": "Enabled",
|
||||||
|
"jsScriptSettingsObject": {"partyName": "旧队伍", "debug": True},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "ScriptGroup" / "修改队员.json",
|
||||||
|
{
|
||||||
|
"name": "修改队员",
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"folderName": "AutoSwitchRoles",
|
||||||
|
"status": "Enabled",
|
||||||
|
"jsScriptSettingsObject": {"position1": ""},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
options = [
|
||||||
|
"水-单手剑-芙宁娜",
|
||||||
|
"冰-长枪-爱可菲",
|
||||||
|
"草-法器-纳西妲",
|
||||||
|
"雷-长枪-雷电将军",
|
||||||
|
"岩-长枪-钟离",
|
||||||
|
]
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
|
||||||
|
[
|
||||||
|
{"name": f"position{index}", "type": "select", "options": ["", *options]}
|
||||||
|
for index in range(1, 5)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_json(path: Path, data):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
def _prepare(
|
||||||
|
self,
|
||||||
|
argument: str,
|
||||||
|
*,
|
||||||
|
craft_before: bool = True,
|
||||||
|
commission_use_current_party: bool = False,
|
||||||
|
):
|
||||||
|
return prepare_daily_run(
|
||||||
|
self.root,
|
||||||
|
self.alias_path,
|
||||||
|
argument,
|
||||||
|
template_name="默认配置",
|
||||||
|
managed_name="直播系统自动每日",
|
||||||
|
ley_line_craft_resin_before=craft_before,
|
||||||
|
commission_use_current_party=commission_use_current_party,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_daily_modes(self):
|
||||||
|
self.assertEqual(parse_daily_request("").mode, DAILY_MODE_NONE)
|
||||||
|
domain = parse_daily_request("秘境 风本")
|
||||||
|
self.assertEqual((domain.mode, domain.domain_name), (DAILY_MODE_DOMAIN, "风本"))
|
||||||
|
ley_line = parse_daily_request("地脉 经验 蒙德")
|
||||||
|
self.assertEqual(
|
||||||
|
(ley_line.mode, ley_line.ley_line_type, ley_line.ley_line_country),
|
||||||
|
(DAILY_MODE_LEY_LINE, "启示之花", "蒙德"),
|
||||||
|
)
|
||||||
|
self.assertEqual(parse_daily_request("委托").mode, DAILY_MODE_COMMISSION)
|
||||||
|
|
||||||
|
def test_parse_rejects_missing_or_unknown_arguments(self):
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "需要指定秘境"):
|
||||||
|
parse_daily_request("秘境")
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "地脉模式格式"):
|
||||||
|
parse_daily_request("地脉 经验")
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "不支持的地脉国家"):
|
||||||
|
parse_daily_request("地脉 摩拉 天空岛")
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "仅支持"):
|
||||||
|
parse_daily_request("探索")
|
||||||
|
|
||||||
|
def test_domain_alias_resolves_canonical_and_common_names(self):
|
||||||
|
resolver = DomainAliasResolver(self.alias_path, self.root)
|
||||||
|
self.assertEqual(resolver.resolve("风本"), "铭记之谷")
|
||||||
|
self.assertEqual(resolver.resolve("少女套"), "铭记之谷")
|
||||||
|
self.assertEqual(resolver.resolve("仲夏庭园"), "仲夏庭园")
|
||||||
|
|
||||||
|
def test_domain_alias_collision_is_rejected(self):
|
||||||
|
self._write_json(
|
||||||
|
self.alias_path,
|
||||||
|
{
|
||||||
|
"铭记之谷": ["风本"],
|
||||||
|
"仲夏庭园": ["风 本"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "同时指向"):
|
||||||
|
DomainAliasResolver(self.alias_path, self.root).resolve("风本")
|
||||||
|
|
||||||
|
def test_domain_run_generates_exact_flow_and_corrects_bgi_config(self):
|
||||||
|
prepared = self._prepare("秘境 风本")
|
||||||
|
self.assertEqual(prepared.request.domain_name, "铭记之谷")
|
||||||
|
self.assertEqual(prepared.task_name, "自动每日(秘境:铭记之谷)")
|
||||||
|
updates = {update.path.name: update for update in prepared.updates}
|
||||||
|
corrected = updates["config.json"].data
|
||||||
|
self.assertFalse(corrected["autoDomainConfig"]["specifyResinUse"])
|
||||||
|
self.assertEqual(corrected["autoDomainConfig"]["other"], 1)
|
||||||
|
self.assertEqual(corrected["marker"], "preserved")
|
||||||
|
|
||||||
|
managed = updates["直播系统自动每日.json"].data
|
||||||
|
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
|
||||||
|
self.assertEqual(
|
||||||
|
names,
|
||||||
|
["领取邮件", "合成树脂", "自动秘境", "领取尘歌壶奖励", "领取每日奖励"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
managed["TaskOrder"],
|
||||||
|
[
|
||||||
|
"mail-template-id",
|
||||||
|
"craft-template-id",
|
||||||
|
"domain-template-id",
|
||||||
|
"serenitea-template-id",
|
||||||
|
"daily-reward-template-id",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertFalse(managed["WeeklyDomainEnabled"])
|
||||||
|
self.assertEqual(managed["DomainName"], "铭记之谷")
|
||||||
|
self.assertEqual(managed["CompletionAction"], "无")
|
||||||
|
self.assertEqual(managed["PartyName"], "战斗队")
|
||||||
|
|
||||||
|
def test_no_mode_skips_only_the_other_task(self):
|
||||||
|
prepared = self._prepare("")
|
||||||
|
managed = prepared.updates[-1].data
|
||||||
|
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
|
||||||
|
self.assertEqual(
|
||||||
|
names,
|
||||||
|
["领取邮件", "合成树脂", "领取尘歌壶奖励", "领取每日奖励"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ley_line_run_sets_all_days_and_respects_craft_toggle(self):
|
||||||
|
prepared = self._prepare("地脉 摩拉 枫丹", craft_before=False)
|
||||||
|
updates = {update.path.name: update for update in prepared.updates}
|
||||||
|
corrected = updates["config.json"].data
|
||||||
|
self.assertFalse(corrected["autoLeyLineOutcropConfig"]["isGoToSynthesizer"])
|
||||||
|
managed = updates["直播系统自动每日.json"].data
|
||||||
|
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
|
||||||
|
self.assertEqual(
|
||||||
|
names,
|
||||||
|
["领取邮件", "自动地脉花", "领取尘歌壶奖励", "领取每日奖励"],
|
||||||
|
)
|
||||||
|
self.assertTrue(managed["LeyLineResinExhaustionMode"])
|
||||||
|
self.assertFalse(managed["LeyLineOpenModeCountMin"])
|
||||||
|
for day in ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"):
|
||||||
|
self.assertTrue(managed[f"LeyLineRun{day}"])
|
||||||
|
self.assertEqual(managed[f"LeyLine{day}Type"], "藏金之花")
|
||||||
|
self.assertEqual(managed[f"LeyLine{day}Country"], "枫丹")
|
||||||
|
|
||||||
|
def test_commission_is_added_as_a_configuration_group_task(self):
|
||||||
|
prepared = self._prepare("委托")
|
||||||
|
managed = prepared.updates[-1].data
|
||||||
|
names = [managed["TaskDefinitions"][task_id] for task_id in managed["TaskOrder"]]
|
||||||
|
self.assertEqual(
|
||||||
|
names,
|
||||||
|
["领取邮件", "合成树脂", "每日委托", "领取尘歌壶奖励", "领取每日奖励"],
|
||||||
|
)
|
||||||
|
commission_id = next(
|
||||||
|
task_id
|
||||||
|
for task_id, name in managed["TaskDefinitions"].items()
|
||||||
|
if name == "每日委托"
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
commission_id,
|
||||||
|
{
|
||||||
|
"mail-template-id",
|
||||||
|
"craft-template-id",
|
||||||
|
"serenitea-template-id",
|
||||||
|
"daily-reward-template-id",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def test_commission_requires_nonempty_group(self):
|
||||||
|
(self.root / "User" / "ScriptGroup" / "每日委托.json").unlink()
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "每日委托配置组"):
|
||||||
|
self._prepare("委托")
|
||||||
|
|
||||||
|
def test_auto_commission_nova_requires_complete_first_run_config(self):
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "ScriptGroup" / "每日委托.json",
|
||||||
|
{
|
||||||
|
"name": "每日委托",
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"folderName": "AutoCommissionNova",
|
||||||
|
"status": "Enabled",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "缺少 Data/user-config.json"):
|
||||||
|
self._prepare("委托")
|
||||||
|
|
||||||
|
user_config_path = (
|
||||||
|
self.root
|
||||||
|
/ "User"
|
||||||
|
/ "JsScript"
|
||||||
|
/ "AutoCommissionNova"
|
||||||
|
/ "Data"
|
||||||
|
/ "user-config.json"
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
user_config_path,
|
||||||
|
{
|
||||||
|
"party": {
|
||||||
|
"global": {
|
||||||
|
"battleTeamName": "战斗队",
|
||||||
|
"elementTeamName": "",
|
||||||
|
"battleStrategy": "策略A",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "元素采集队伍"):
|
||||||
|
self._prepare("委托")
|
||||||
|
|
||||||
|
self._write_json(
|
||||||
|
user_config_path,
|
||||||
|
{
|
||||||
|
"party": {
|
||||||
|
"global": {
|
||||||
|
"battleTeamName": "战斗队",
|
||||||
|
"elementTeamName": "采集队",
|
||||||
|
"battleStrategy": "策略A",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(self._prepare("委托").request.mode, DAILY_MODE_COMMISSION)
|
||||||
|
|
||||||
|
def test_auto_commission_nova_can_inject_current_party_for_battle_and_collection(self):
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "ScriptGroup" / "每日委托.json",
|
||||||
|
{
|
||||||
|
"name": "每日委托",
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"folderName": "AutoCommissionNova",
|
||||||
|
"status": "Enabled",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
user_config_path = (
|
||||||
|
self.root
|
||||||
|
/ "User"
|
||||||
|
/ "JsScript"
|
||||||
|
/ "AutoCommissionNova"
|
||||||
|
/ "Data"
|
||||||
|
/ "user-config.json"
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
user_config_path,
|
||||||
|
{
|
||||||
|
"marker": "preserved",
|
||||||
|
"party": {
|
||||||
|
"global": {
|
||||||
|
"battleTeamName": "",
|
||||||
|
"elementTeamName": "",
|
||||||
|
"battleStrategy": "策略A",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
prepared = self._prepare("委托", commission_use_current_party=True)
|
||||||
|
self.assertTrue(prepared.requires_current_party)
|
||||||
|
|
||||||
|
update = prepare_commission_current_party_update(self.root, "当前队伍")
|
||||||
|
self.assertEqual(
|
||||||
|
update.data["party"]["global"]["battleTeamName"],
|
||||||
|
"当前队伍",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
update.data["party"]["global"]["elementTeamName"],
|
||||||
|
"当前队伍",
|
||||||
|
)
|
||||||
|
self.assertEqual(update.data["marker"], "preserved")
|
||||||
|
|
||||||
|
def test_switch_party_updates_only_target_setting(self):
|
||||||
|
update = prepare_switch_party_update(self.root, "深渊队")
|
||||||
|
project = update.data["projects"][0]
|
||||||
|
self.assertEqual(project["jsScriptSettingsObject"]["partyName"], "深渊队")
|
||||||
|
self.assertTrue(project["jsScriptSettingsObject"]["debug"])
|
||||||
|
|
||||||
|
def test_edit_party_accepts_separators_and_contiguous_names(self):
|
||||||
|
separated, display = resolve_party_members(
|
||||||
|
self.root,
|
||||||
|
"芙宁娜 爱可菲 纳西妲 雷电将军",
|
||||||
|
)
|
||||||
|
self.assertEqual(display, ["芙宁娜", "爱可菲", "纳西妲", "雷电将军"])
|
||||||
|
contiguous, contiguous_display = resolve_party_members(
|
||||||
|
self.root,
|
||||||
|
"芙宁娜爱可菲纳西妲雷电将军",
|
||||||
|
)
|
||||||
|
self.assertEqual(contiguous, separated)
|
||||||
|
self.assertEqual(contiguous_display, display)
|
||||||
|
|
||||||
|
update, names = prepare_edit_party_update(
|
||||||
|
self.root,
|
||||||
|
"芙宁娜、爱可菲、纳西妲、雷电将军",
|
||||||
|
)
|
||||||
|
settings = update.data["projects"][0]["jsScriptSettingsObject"]
|
||||||
|
self.assertEqual(names, ("芙宁娜", "爱可菲", "纳西妲", "雷电将军"))
|
||||||
|
self.assertEqual(settings["position4"], "雷-长枪-雷电将军")
|
||||||
|
|
||||||
|
def test_edit_party_supports_auto_switch_roles_67_character_data(self):
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
|
||||||
|
[
|
||||||
|
{"name": f"position{index}", "type": "input-text", "default": ""}
|
||||||
|
for index in range(1, 5)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "combat_avatar.json",
|
||||||
|
[
|
||||||
|
{"name": "神里绫华", "alias": ["绫华"]},
|
||||||
|
{"name": "申鹤", "alias": []},
|
||||||
|
{"name": "枫原万叶", "alias": ["万叶"]},
|
||||||
|
{"name": "珊瑚宫心海", "alias": ["心海"]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
update, names = prepare_edit_party_update(
|
||||||
|
self.root,
|
||||||
|
"绫华 申鹤 万叶 心海",
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = update.data["projects"][0]["jsScriptSettingsObject"]
|
||||||
|
self.assertEqual(names, ("神里绫华", "申鹤", "枫原万叶", "珊瑚宫心海"))
|
||||||
|
self.assertEqual(
|
||||||
|
[settings[f"position{index}"] for index in range(1, 5)],
|
||||||
|
["神里绫华", "申鹤", "枫原万叶", "珊瑚宫心海"],
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved, display = resolve_party_members(
|
||||||
|
self.root,
|
||||||
|
"神里绫华申鹤枫原万叶珊瑚宫心海",
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved, list(display))
|
||||||
|
|
||||||
|
def test_edit_party_rejects_ambiguous_combat_avatar_alias(self):
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "settings.json",
|
||||||
|
[
|
||||||
|
{"name": f"position{index}", "type": "input-text", "default": ""}
|
||||||
|
for index in range(1, 5)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self._write_json(
|
||||||
|
self.root / "User" / "JsScript" / "AutoSwitchRoles" / "combat_avatar.json",
|
||||||
|
[
|
||||||
|
{"name": "角色甲", "alias": ["同名"]},
|
||||||
|
{"name": "角色乙", "alias": ["同名"]},
|
||||||
|
{"name": "角色丙", "alias": []},
|
||||||
|
{"name": "角色丁", "alias": []},
|
||||||
|
{"name": "角色戊", "alias": []},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "未知或有歧义"):
|
||||||
|
resolve_party_members(self.root, "同名 角色丙 角色丁 角色戊")
|
||||||
|
|
||||||
|
def test_edit_party_requires_four_distinct_known_members(self):
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "必须是4人"):
|
||||||
|
resolve_party_members(self.root, "芙宁娜 爱可菲 纳西妲")
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "不能重复"):
|
||||||
|
resolve_party_members(self.root, "芙宁娜 芙宁娜 纳西妲 雷电将军")
|
||||||
|
with self.assertRaisesRegex(DailyAutomationError, "未知"):
|
||||||
|
resolve_party_members(self.root, "芙宁娜 爱可菲 纳西妲 不存在")
|
||||||
|
|
||||||
|
def test_apply_json_updates_writes_managed_files(self):
|
||||||
|
prepared = self._prepare("秘境 风本")
|
||||||
|
apply_json_updates(prepared.updates)
|
||||||
|
bgi_config = json.loads((self.root / "User" / "config.json").read_text(encoding="utf-8"))
|
||||||
|
managed = json.loads(
|
||||||
|
(self.root / "User" / "OneDragon" / "直播系统自动每日.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
self.assertFalse(bgi_config["autoDomainConfig"]["specifyResinUse"])
|
||||||
|
self.assertEqual(managed["DomainName"], "铭记之谷")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from app.danmu_queue import BgiLogMonitor, WebServer
|
from app.danmu_queue import BgiLogMonitor, WebServer
|
||||||
|
|
||||||
@@ -69,5 +71,88 @@ class BgiLogDisplayFormatTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BgiLogCompletionTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_configuration_group_marker_still_finishes_current_group(self):
|
||||||
|
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-group-log"))
|
||||||
|
callback = AsyncMock()
|
||||||
|
monitor.set_finish_callback(callback)
|
||||||
|
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||||
|
monitor.set_current_group("薄荷", "group-run")
|
||||||
|
|
||||||
|
monitor._process_line('配置组 "薄荷" 执行结束')
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
callback.assert_awaited_once_with("薄荷", "group-run")
|
||||||
|
self.assertIsNone(monitor._current_group)
|
||||||
|
|
||||||
|
async def test_one_dragon_marker_finishes_managed_daily(self):
|
||||||
|
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-one-dragon-log"))
|
||||||
|
callback = AsyncMock()
|
||||||
|
monitor.set_finish_callback(callback)
|
||||||
|
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||||
|
monitor.set_current_one_dragon("自动每日(委托)", "daily-run")
|
||||||
|
|
||||||
|
monitor._process_line("一条龙和配置组任务结束")
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
callback.assert_awaited_once_with("自动每日(委托)", "daily-run")
|
||||||
|
self.assertIsNone(monitor._current_group)
|
||||||
|
|
||||||
|
async def test_child_group_completion_does_not_finish_one_dragon_early(self):
|
||||||
|
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-child-group-log"))
|
||||||
|
callback = AsyncMock()
|
||||||
|
monitor.set_finish_callback(callback)
|
||||||
|
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||||
|
monitor.set_current_one_dragon("自动每日", "daily-run")
|
||||||
|
|
||||||
|
monitor._process_line('配置组 "每日委托" 执行结束')
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
callback.assert_not_awaited()
|
||||||
|
self.assertEqual(monitor._current_group, "自动每日")
|
||||||
|
|
||||||
|
|
||||||
|
class BgiLogInterruptTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_task_interrupt_marker_triggers_interrupt_callback(self):
|
||||||
|
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-interrupt-log"))
|
||||||
|
finish = AsyncMock()
|
||||||
|
interrupt = AsyncMock()
|
||||||
|
monitor.set_finish_callback(finish)
|
||||||
|
monitor.set_interrupt_callback(interrupt)
|
||||||
|
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||||
|
monitor.set_current_group("霜仙花", "group-run")
|
||||||
|
|
||||||
|
# BetterGI 被外部取消时只写“任务中断”,不写“配置组 X 执行结束”。
|
||||||
|
monitor._process_line('任务中断:"取消自动任务"')
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
interrupt.assert_awaited_once_with("霜仙花", "group-run")
|
||||||
|
finish.assert_not_awaited()
|
||||||
|
self.assertIsNone(monitor._current_group)
|
||||||
|
|
||||||
|
async def test_cancel_auto_task_marker_triggers_interrupt_callback(self):
|
||||||
|
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-cancel-log"))
|
||||||
|
interrupt = AsyncMock()
|
||||||
|
monitor.set_interrupt_callback(interrupt)
|
||||||
|
with patch.object(monitor, "_get_current_log_size", return_value=0):
|
||||||
|
monitor.set_current_group("霜仙花", "group-run")
|
||||||
|
|
||||||
|
monitor._process_line("取消自动任务")
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
interrupt.assert_awaited_once_with("霜仙花", "group-run")
|
||||||
|
self.assertIsNone(monitor._current_group)
|
||||||
|
|
||||||
|
async def test_interrupt_does_not_fire_when_no_current_group(self):
|
||||||
|
monitor = BgiLogMonitor(".", logging.getLogger("test-bgi-no-group-log"))
|
||||||
|
interrupt = AsyncMock()
|
||||||
|
monitor.set_interrupt_callback(interrupt)
|
||||||
|
|
||||||
|
monitor._process_line('任务中断:"取消自动任务"')
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
interrupt.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
|
from app.bettergi_current_party import (
|
||||||
|
CurrentPartyReadError,
|
||||||
|
CurrentPartyReadResult,
|
||||||
|
PreparedCurrentPartyRead,
|
||||||
|
)
|
||||||
|
from app.bettergi_daily import (
|
||||||
|
DailyAutomationError,
|
||||||
|
DailyRequest,
|
||||||
|
JsonUpdate,
|
||||||
|
PreparedDailyRun,
|
||||||
|
)
|
||||||
|
from app.danmu_queue import (
|
||||||
|
COMMAND_ALLOWED_ROLE_DEFAULTS,
|
||||||
|
BetterGIRunner,
|
||||||
|
CommandHandler,
|
||||||
|
Config,
|
||||||
|
build_public_command_hints,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DailyCommandIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.temp_dir.name)
|
||||||
|
self.config = SimpleNamespace(
|
||||||
|
data={
|
||||||
|
"commands": {
|
||||||
|
"run": {"enabled": True, "aliases": ["执行", "跑"]},
|
||||||
|
"daily": {
|
||||||
|
"enabled": True,
|
||||||
|
"aliases": ["自动每日", "每日", "日常", "做每日", "做日常"],
|
||||||
|
},
|
||||||
|
"switch_party": {
|
||||||
|
"enabled": True,
|
||||||
|
"aliases": ["切换队伍", "更换队伍", "换队伍", "换队"],
|
||||||
|
},
|
||||||
|
"edit_party": {
|
||||||
|
"enabled": True,
|
||||||
|
"aliases": ["修改队员", "更换队员", "换队员", "换角色"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
admin_uids=set(),
|
||||||
|
bettergi_work_dir=str(self.root),
|
||||||
|
daily_cfg={
|
||||||
|
"one_dragon_template": "默认配置",
|
||||||
|
"managed_one_dragon_name": "直播系统自动每日",
|
||||||
|
"ley_line_craft_resin_before": True,
|
||||||
|
"commission_use_current_party": True,
|
||||||
|
"current_party_read_timeout_sec": 45,
|
||||||
|
},
|
||||||
|
broadcast_cfg={"tts_categories": {"execution": True}},
|
||||||
|
music_monitor_cfg={"request_player": {}},
|
||||||
|
)
|
||||||
|
self.queue_mgr = MagicMock()
|
||||||
|
self.queue_mgr.state = {
|
||||||
|
"current_admin_uid": 123,
|
||||||
|
"login_status": "logged_in",
|
||||||
|
"current_group": None,
|
||||||
|
"default_running": False,
|
||||||
|
}
|
||||||
|
self.queue_mgr.is_admin.return_value = True
|
||||||
|
self.queue_mgr.start_group.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"msg": "ok",
|
||||||
|
"run_id": "daily-run",
|
||||||
|
}
|
||||||
|
self.runner = MagicMock()
|
||||||
|
self.runner.kill_bgi = AsyncMock()
|
||||||
|
self.runner.start_groups = AsyncMock(return_value=True)
|
||||||
|
self.runner.start_one_dragon = AsyncMock(return_value=True)
|
||||||
|
self.runner.sync_js_script.return_value = True
|
||||||
|
self.log_monitor = MagicMock()
|
||||||
|
self.broadcaster = SimpleNamespace(broadcast=AsyncMock())
|
||||||
|
self.handler = CommandHandler(
|
||||||
|
self.config,
|
||||||
|
MagicMock(),
|
||||||
|
self.queue_mgr,
|
||||||
|
self.runner,
|
||||||
|
self.log_monitor,
|
||||||
|
MagicMock(),
|
||||||
|
logging.getLogger("test-daily-command"),
|
||||||
|
broadcaster=self.broadcaster,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def _prepared_daily(self, *, requires_current_party: bool = False) -> PreparedDailyRun:
|
||||||
|
return PreparedDailyRun(
|
||||||
|
request=DailyRequest(mode="commission" if requires_current_party else "none"),
|
||||||
|
config_name="直播系统自动每日",
|
||||||
|
updates=(
|
||||||
|
JsonUpdate(
|
||||||
|
self.root / "User" / "OneDragon" / "直播系统自动每日.json",
|
||||||
|
{"Name": "直播系统自动每日"},
|
||||||
|
"test",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
requires_current_party=requires_current_party,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_daily_replaces_old_task_writes_config_and_starts_one_dragon(self):
|
||||||
|
self.queue_mgr.state["current_group"] = "旧任务"
|
||||||
|
prepared = self._prepared_daily()
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.prepare_daily_run", return_value=prepared) as prepare,
|
||||||
|
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||||
|
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
|
||||||
|
):
|
||||||
|
await self.handler._cmd_daily(123, "测试用户", "")
|
||||||
|
|
||||||
|
prepare.assert_called_once()
|
||||||
|
self.queue_mgr.interrupt_group.assert_called_once_with(
|
||||||
|
"daily_replaced",
|
||||||
|
status="cancelled",
|
||||||
|
)
|
||||||
|
self.runner.kill_bgi.assert_awaited_once_with(reason="自动每日配置更新")
|
||||||
|
apply_updates.assert_called_once_with(prepared.updates)
|
||||||
|
self.queue_mgr.start_group.assert_called_once_with(
|
||||||
|
123,
|
||||||
|
"自动每日",
|
||||||
|
run_id="daily-run",
|
||||||
|
)
|
||||||
|
self.log_monitor.set_current_one_dragon.assert_called_once_with(
|
||||||
|
"自动每日",
|
||||||
|
"daily-run",
|
||||||
|
)
|
||||||
|
self.runner.start_one_dragon.assert_awaited_once_with("直播系统自动每日")
|
||||||
|
|
||||||
|
async def test_daily_validation_failure_does_not_stop_current_task(self):
|
||||||
|
self.queue_mgr.state["current_group"] = "旧任务"
|
||||||
|
with patch(
|
||||||
|
"app.danmu_queue.prepare_daily_run",
|
||||||
|
side_effect=DailyAutomationError("未找到一条龙模板"),
|
||||||
|
):
|
||||||
|
await self.handler._cmd_daily(123, "测试用户", "秘境 风本")
|
||||||
|
|
||||||
|
self.queue_mgr.interrupt_group.assert_not_called()
|
||||||
|
self.runner.kill_bgi.assert_not_awaited()
|
||||||
|
self.runner.start_one_dragon.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_daily_commission_reads_current_party_before_starting(self):
|
||||||
|
prepared = self._prepared_daily(requires_current_party=True)
|
||||||
|
reader_update = JsonUpdate(
|
||||||
|
self.root / "User" / "ScriptGroup" / "直播系统读取当前队伍.json",
|
||||||
|
{"projects": []},
|
||||||
|
"reader",
|
||||||
|
)
|
||||||
|
reader = PreparedCurrentPartyRead(
|
||||||
|
request_id="daily-run",
|
||||||
|
group_name="直播系统读取当前队伍",
|
||||||
|
status_path=self.root / "status.json",
|
||||||
|
updates=(reader_update,),
|
||||||
|
)
|
||||||
|
party_update = JsonUpdate(
|
||||||
|
self.root / "User" / "JsScript" / "AutoCommissionNova" / "Data" / "user-config.json",
|
||||||
|
{
|
||||||
|
"party": {
|
||||||
|
"global": {
|
||||||
|
"battleTeamName": "好感队",
|
||||||
|
"elementTeamName": "好感队",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"party",
|
||||||
|
)
|
||||||
|
capture = AsyncMock(return_value="好感队")
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.prepare_daily_run", return_value=prepared) as prepare,
|
||||||
|
patch("app.danmu_queue.prepare_current_party_read", return_value=reader),
|
||||||
|
patch(
|
||||||
|
"app.danmu_queue.prepare_commission_current_party_update",
|
||||||
|
return_value=party_update,
|
||||||
|
) as prepare_party,
|
||||||
|
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||||
|
patch.object(self.handler, "_capture_current_party_name", capture),
|
||||||
|
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
|
||||||
|
):
|
||||||
|
await self.handler._cmd_daily(123, "测试用户", "委托")
|
||||||
|
|
||||||
|
self.assertTrue(prepare.call_args.kwargs["commission_use_current_party"])
|
||||||
|
capture.assert_awaited_once_with(reader, 45)
|
||||||
|
prepare_party.assert_called_once_with(str(self.root), "好感队")
|
||||||
|
apply_updates.assert_called_once_with((party_update, *prepared.updates))
|
||||||
|
self.runner.start_one_dragon.assert_awaited_once_with("直播系统自动每日")
|
||||||
|
|
||||||
|
async def test_daily_commission_reader_failure_does_not_start_billed_task(self):
|
||||||
|
prepared = self._prepared_daily(requires_current_party=True)
|
||||||
|
reader = PreparedCurrentPartyRead(
|
||||||
|
request_id="daily-run",
|
||||||
|
group_name="直播系统读取当前队伍",
|
||||||
|
status_path=self.root / "status.json",
|
||||||
|
updates=(),
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.prepare_daily_run", return_value=prepared),
|
||||||
|
patch("app.danmu_queue.prepare_current_party_read", return_value=reader),
|
||||||
|
patch.object(
|
||||||
|
self.handler,
|
||||||
|
"_capture_current_party_name",
|
||||||
|
new=AsyncMock(side_effect=CurrentPartyReadError("未识别到队伍名称")),
|
||||||
|
),
|
||||||
|
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||||
|
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
|
||||||
|
):
|
||||||
|
await self.handler._cmd_daily(123, "测试用户", "委托")
|
||||||
|
|
||||||
|
apply_updates.assert_not_called()
|
||||||
|
self.queue_mgr.start_group.assert_not_called()
|
||||||
|
self.runner.start_one_dragon.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_current_party_capture_syncs_group_and_waits_for_matching_status(self):
|
||||||
|
reader_update = JsonUpdate(
|
||||||
|
self.root / "User" / "ScriptGroup" / "直播系统读取当前队伍.json",
|
||||||
|
{"projects": []},
|
||||||
|
"reader",
|
||||||
|
)
|
||||||
|
reader = PreparedCurrentPartyRead(
|
||||||
|
request_id="request-123",
|
||||||
|
group_name="直播系统读取当前队伍",
|
||||||
|
status_path=self.root / "status.json",
|
||||||
|
updates=(reader_update,),
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||||
|
patch(
|
||||||
|
"app.danmu_queue.read_current_party_status",
|
||||||
|
return_value=CurrentPartyReadResult("好感队", ("好感队",)),
|
||||||
|
),
|
||||||
|
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock()),
|
||||||
|
):
|
||||||
|
party_name = await self.handler._capture_current_party_name(reader, 45)
|
||||||
|
|
||||||
|
self.assertEqual(party_name, "好感队")
|
||||||
|
self.runner.sync_js_script.assert_called_once_with("LiveCurrentParty")
|
||||||
|
apply_updates.assert_called_once_with(reader.updates)
|
||||||
|
self.runner.start_groups.assert_awaited_once_with(["直播系统读取当前队伍"])
|
||||||
|
|
||||||
|
async def test_daily_requires_confirmed_login_before_preparing_files(self):
|
||||||
|
self.queue_mgr.state["login_status"] = "confirming"
|
||||||
|
with patch("app.danmu_queue.prepare_daily_run") as prepare:
|
||||||
|
await self.handler._cmd_daily(123, "测试用户", "")
|
||||||
|
|
||||||
|
prepare.assert_not_called()
|
||||||
|
self.runner.kill_bgi.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_daily_start_failure_clears_monitor_and_enters_reset_flow(self):
|
||||||
|
prepared = self._prepared_daily()
|
||||||
|
self.runner.start_one_dragon.return_value = False
|
||||||
|
reset = AsyncMock()
|
||||||
|
self.handler.system = SimpleNamespace(_reset_wait_and_retry_login=reset)
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.prepare_daily_run", return_value=prepared),
|
||||||
|
patch("app.danmu_queue.apply_json_updates"),
|
||||||
|
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="daily-run")),
|
||||||
|
):
|
||||||
|
await self.handler._cmd_daily(123, "测试用户", "")
|
||||||
|
|
||||||
|
self.log_monitor.set_current_one_dragon.assert_has_calls(
|
||||||
|
[call("自动每日", "daily-run"), call(None)]
|
||||||
|
)
|
||||||
|
reset.assert_awaited_once_with(
|
||||||
|
123,
|
||||||
|
"测试用户",
|
||||||
|
"自动每日启动失败,正在关闭原神并启动扫码上号",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_switch_party_updates_group_then_reuses_group_execution(self):
|
||||||
|
update = JsonUpdate(
|
||||||
|
self.root / "User" / "ScriptGroup" / "切换队伍.json",
|
||||||
|
{"projects": []},
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.prepare_switch_party_update", return_value=update),
|
||||||
|
patch("app.danmu_queue.apply_json_updates") as apply_updates,
|
||||||
|
patch("app.danmu_queue.uuid.uuid4", return_value=SimpleNamespace(hex="party-run")),
|
||||||
|
patch.object(
|
||||||
|
self.handler,
|
||||||
|
"_resolve_group_name",
|
||||||
|
return_value={
|
||||||
|
"success": True,
|
||||||
|
"name": "切换队伍",
|
||||||
|
"matched": False,
|
||||||
|
"suggestions": [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
patch.object(self.handler, "_group_uses_nahida_collect", return_value=False),
|
||||||
|
):
|
||||||
|
await self.handler._cmd_switch_party(123, "测试用户", "永冻队")
|
||||||
|
|
||||||
|
apply_updates.assert_called_once_with((update,))
|
||||||
|
self.queue_mgr.start_group.assert_called_once_with(
|
||||||
|
123,
|
||||||
|
"切换队伍",
|
||||||
|
run_id="party-run",
|
||||||
|
)
|
||||||
|
self.runner.start_groups.assert_awaited_once_with(["切换队伍"])
|
||||||
|
|
||||||
|
def test_execution_command_aliases_support_no_space_arguments(self):
|
||||||
|
self.assertEqual(self.handler._split_command_text("执行薄荷"), ("执行", "薄荷"))
|
||||||
|
self.assertEqual(
|
||||||
|
self.handler._split_command_text("自动每日秘境 风本"),
|
||||||
|
("自动每日", "秘境 风本"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.handler._split_command_text("更换队伍永冻队"),
|
||||||
|
("更换队伍", "永冻队"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.handler._split_command_text("修改队员芙宁娜纳西妲钟离雷电将军"),
|
||||||
|
("修改队员", "芙宁娜纳西妲钟离雷电将军"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.handler._split_command_text("日常秘境 风本"),
|
||||||
|
("日常", "秘境 风本"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.handler._split_command_text("换队永冻队"),
|
||||||
|
("换队", "永冻队"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.handler._split_command_text("换角色芙宁娜纳西妲钟离雷电将军"),
|
||||||
|
("换角色", "芙宁娜纳西妲钟离雷电将军"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_longest_new_alias_wins_for_contiguous_arguments(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.handler._split_command_text("换队员芙宁娜纳西妲钟离雷电将军"),
|
||||||
|
("换队员", "芙宁娜纳西妲钟离雷电将军"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_confirmed_login_announces_daily_and_party_usage(self):
|
||||||
|
self.queue_mgr.state["login_status"] = "confirming"
|
||||||
|
|
||||||
|
await self.handler._cmd_confirm_login(123, "测试用户", "是")
|
||||||
|
|
||||||
|
self.assertEqual(self.broadcaster.broadcast.await_count, 2)
|
||||||
|
first, second = self.broadcaster.broadcast.await_args_list
|
||||||
|
self.assertIn("发送“每日”完成基础每日", first.args[0])
|
||||||
|
self.assertTrue(first.kwargs["tts"])
|
||||||
|
self.assertIn("每日 秘境 风本", second.args[0])
|
||||||
|
self.assertIn("换队 永冻队", second.args[0])
|
||||||
|
self.assertIn("换角色 四名角色", second.args[0])
|
||||||
|
self.assertIn("执行 组名", second.args[0])
|
||||||
|
self.assertFalse(second.kwargs["tts"])
|
||||||
|
self.assertTrue(second.kwargs["danmu"])
|
||||||
|
|
||||||
|
def test_public_command_hints_are_sanitized_and_use_shortest_alias(self):
|
||||||
|
config_data = {
|
||||||
|
"commands": self.config.data["commands"],
|
||||||
|
"bilibili": {"cookie": "secret"},
|
||||||
|
}
|
||||||
|
|
||||||
|
hints = build_public_command_hints(config_data)
|
||||||
|
|
||||||
|
self.assertEqual(set(hints), {"daily", "switch_party", "edit_party"})
|
||||||
|
self.assertEqual(hints["daily"]["preferred_alias"], "每日")
|
||||||
|
self.assertEqual(hints["switch_party"]["preferred_alias"], "换队")
|
||||||
|
self.assertEqual(hints["edit_party"]["preferred_alias"], "换角色")
|
||||||
|
self.assertNotIn("bilibili", hints)
|
||||||
|
|
||||||
|
def test_new_command_alias_defaults_are_complete(self):
|
||||||
|
config_path = self.root / "config.json"
|
||||||
|
config_path.write_text(json.dumps({}), encoding="utf-8")
|
||||||
|
|
||||||
|
config = Config(str(config_path))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
config.data["commands"]["daily"]["aliases"],
|
||||||
|
["自动每日", "每日", "日常", "做每日", "做日常"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.data["commands"]["switch_party"]["aliases"],
|
||||||
|
["切换队伍", "更换队伍", "换队伍", "换队"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
config.data["commands"]["edit_party"]["aliases"],
|
||||||
|
["修改队员", "更换队员", "换队员", "换角色"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_disabled_command_hint_has_no_public_alias(self):
|
||||||
|
config_data = {
|
||||||
|
"commands": {
|
||||||
|
"daily": {"enabled": False, "aliases": ["每日"]},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hints = build_public_command_hints(config_data)
|
||||||
|
|
||||||
|
self.assertFalse(hints["daily"]["enabled"])
|
||||||
|
self.assertEqual(hints["daily"]["aliases"], [])
|
||||||
|
self.assertEqual(hints["daily"]["preferred_alias"], "")
|
||||||
|
|
||||||
|
def test_new_command_permissions_match_run(self):
|
||||||
|
for key in ("daily", "switch_party", "edit_party"):
|
||||||
|
self.assertEqual(
|
||||||
|
COMMAND_ALLOWED_ROLE_DEFAULTS[key],
|
||||||
|
COMMAND_ALLOWED_ROLE_DEFAULTS["run"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BetterGIRunnerOneDragonTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_start_one_dragon_uses_supported_cli_shape(self):
|
||||||
|
runner = BetterGIRunner(
|
||||||
|
r"C:\BetterGI\BetterGI.exe",
|
||||||
|
r"C:\BetterGI",
|
||||||
|
logging.getLogger("test-one-dragon-runner"),
|
||||||
|
)
|
||||||
|
create_process = AsyncMock(return_value=SimpleNamespace())
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.asyncio.create_subprocess_exec", create_process),
|
||||||
|
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock()),
|
||||||
|
):
|
||||||
|
result = await runner.start_one_dragon("直播系统自动每日")
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
create_process.assert_awaited_once_with(
|
||||||
|
r"C:\BetterGI\BetterGI.exe",
|
||||||
|
"startOneDragon",
|
||||||
|
"直播系统自动每日",
|
||||||
|
cwd=r"C:\BetterGI",
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from app.faster_qwen_worker import (
|
from app.faster_qwen_worker import (
|
||||||
DEFAULT_CPU_AFFINITY_COUNT,
|
DEFAULT_CPU_AFFINITY_COUNT,
|
||||||
@@ -9,6 +10,7 @@ from app.faster_qwen_worker import (
|
|||||||
MAX_NEW_TOKENS,
|
MAX_NEW_TOKENS,
|
||||||
FasterQwenWorkerClient,
|
FasterQwenWorkerClient,
|
||||||
FasterQwenWorkerTimeout,
|
FasterQwenWorkerTimeout,
|
||||||
|
faster_qwen_worker_main,
|
||||||
_generation_kwargs,
|
_generation_kwargs,
|
||||||
_configure_worker_environment,
|
_configure_worker_environment,
|
||||||
)
|
)
|
||||||
@@ -91,6 +93,14 @@ def _ready(pid):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _progress(stage):
|
||||||
|
return {
|
||||||
|
"type": "progress",
|
||||||
|
"stage": stage,
|
||||||
|
"pid": 4000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _probe_worker_main(connection, _settings):
|
def _probe_worker_main(connection, _settings):
|
||||||
connection.send({
|
connection.send({
|
||||||
"type": "ready",
|
"type": "ready",
|
||||||
@@ -153,6 +163,8 @@ class FasterQwenWorkerTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_client_returns_worker_audio(self):
|
def test_client_returns_worker_audio(self):
|
||||||
connection = _FakeConnection([
|
connection = _FakeConnection([
|
||||||
|
_progress("model_loading"),
|
||||||
|
_progress("warmup_started"),
|
||||||
_ready(4100),
|
_ready(4100),
|
||||||
{
|
{
|
||||||
"type": "result",
|
"type": "result",
|
||||||
@@ -181,6 +193,81 @@ class FasterQwenWorkerTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(context.processes), 1)
|
self.assertEqual(len(context.processes), 1)
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
def test_client_default_startup_timeout_allows_slow_model_load(self):
|
||||||
|
client = FasterQwenWorkerClient({})
|
||||||
|
|
||||||
|
self.assertEqual(client.startup_timeout_seconds, 900.0)
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
def test_worker_applies_process_limits_after_model_warmup(self):
|
||||||
|
order = []
|
||||||
|
|
||||||
|
class FakeModel:
|
||||||
|
def warmup(self):
|
||||||
|
order.append("warmup")
|
||||||
|
|
||||||
|
class WorkerConnection:
|
||||||
|
def __init__(self):
|
||||||
|
self.sent = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def send(self, message):
|
||||||
|
self.sent.append(message)
|
||||||
|
|
||||||
|
def recv(self):
|
||||||
|
return {"command": "stop"}
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
connection = WorkerConnection()
|
||||||
|
with (
|
||||||
|
patch("app.faster_qwen_worker._configure_worker_environment", return_value=3),
|
||||||
|
patch(
|
||||||
|
"app.faster_qwen_worker._load_runtime",
|
||||||
|
side_effect=lambda _settings, progress=None: (
|
||||||
|
progress("from_pretrained_started") if progress else None,
|
||||||
|
order.append("load"),
|
||||||
|
{"model": FakeModel()},
|
||||||
|
)[-1],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.faster_qwen_worker._apply_worker_process_limits",
|
||||||
|
side_effect=lambda _settings: order.append("limits") or {
|
||||||
|
"cpu_affinity_count": 6,
|
||||||
|
"process_priority": "below_normal",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.faster_qwen_worker._synthesize_wav",
|
||||||
|
side_effect=lambda _runtime, _text: order.append("voice_clone") or b"wav",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
faster_qwen_worker_main(connection, {})
|
||||||
|
|
||||||
|
self.assertEqual(order, ["load", "warmup", "voice_clone", "limits"])
|
||||||
|
progress_stages = [
|
||||||
|
item.get("stage")
|
||||||
|
for item in connection.sent
|
||||||
|
if item.get("type") == "progress"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
progress_stages,
|
||||||
|
[
|
||||||
|
"process_started",
|
||||||
|
"environment_ready",
|
||||||
|
"model_loading",
|
||||||
|
"from_pretrained_started",
|
||||||
|
"model_loaded",
|
||||||
|
"warmup_started",
|
||||||
|
"graph_warmup_complete",
|
||||||
|
"voice_clone_warmup_started",
|
||||||
|
"voice_clone_warmup_complete",
|
||||||
|
"warmup_complete",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(connection.sent[-1]["type"], "ready")
|
||||||
|
|
||||||
def test_timeout_terminates_worker_and_starts_a_prewarmed_replacement(self):
|
def test_timeout_terminates_worker_and_starts_a_prewarmed_replacement(self):
|
||||||
first_connection = _FakeConnection([_ready(4200)])
|
first_connection = _FakeConnection([_ready(4200)])
|
||||||
replacement_connection = _FakeConnection([_ready(4300)])
|
replacement_connection = _FakeConnection([_ready(4300)])
|
||||||
@@ -252,5 +339,56 @@ class FasterQwenWorkerTests(unittest.TestCase):
|
|||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
class DegradeRebuildTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
"""合成耗时超阈值 → 后台自动重建 worker 的退化自愈逻辑。"""
|
||||||
|
|
||||||
|
def _make_engine(self):
|
||||||
|
from app.danmu_queue import FasterQwen3TTSEngine
|
||||||
|
|
||||||
|
engine = FasterQwen3TTSEngine.__new__(FasterQwen3TTSEngine)
|
||||||
|
engine.logger = logging.getLogger("test-degrade-rebuild")
|
||||||
|
engine._degrade_rebuild_threshold_ms = 25000
|
||||||
|
engine._degrade_rebuild_cooldown_sec = 1800
|
||||||
|
engine._last_degrade_rebuild_at = 0.0
|
||||||
|
engine._degrade_rebuild_task = None
|
||||||
|
engine.restart_worker = AsyncMock(return_value=True)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
async def test_slow_synthesis_triggers_rebuild(self):
|
||||||
|
engine = self._make_engine()
|
||||||
|
engine._maybe_schedule_degrade_rebuild(40000)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
engine.restart_worker.assert_awaited_once_with(reason="合成退化自愈")
|
||||||
|
|
||||||
|
async def test_fast_synthesis_does_not_rebuild(self):
|
||||||
|
engine = self._make_engine()
|
||||||
|
engine._maybe_schedule_degrade_rebuild(8000)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
engine.restart_worker.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_cooldown_prevents_repeat_rebuild(self):
|
||||||
|
engine = self._make_engine()
|
||||||
|
engine._maybe_schedule_degrade_rebuild(40000)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
engine.restart_worker.reset_mock()
|
||||||
|
# 冷却期内的再次超阈值不重复重建
|
||||||
|
engine._maybe_schedule_degrade_rebuild(50000)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
engine.restart_worker.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_inflight_task_not_duplicated(self):
|
||||||
|
engine = self._make_engine()
|
||||||
|
|
||||||
|
async def slow_rebuild(reason=""):
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
engine.restart_worker = slow_rebuild
|
||||||
|
engine._maybe_schedule_degrade_rebuild(40000)
|
||||||
|
first_task = engine._degrade_rebuild_task
|
||||||
|
engine._maybe_schedule_degrade_rebuild(40000)
|
||||||
|
self.assertIs(engine._degrade_rebuild_task, first_task)
|
||||||
|
await first_task
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""LLM 弹幕回复单元测试:协议适配、过滤、限流、上下文、重试。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from app.llm_client import (
|
||||||
|
LLMError,
|
||||||
|
LLMReplyEngine,
|
||||||
|
OpenAICompatProvider,
|
||||||
|
OllamaNativeProvider,
|
||||||
|
AnthropicProvider,
|
||||||
|
create_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine(**overrides) -> LLMReplyEngine:
|
||||||
|
cfg = {
|
||||||
|
"enabled": True,
|
||||||
|
"provider": "openai",
|
||||||
|
"base_url": "https://api.example.com/v1",
|
||||||
|
"api_key": "sk-test",
|
||||||
|
"model": "test-model",
|
||||||
|
"system_prompt": "你是直播间助手",
|
||||||
|
"max_reply_chars": 60,
|
||||||
|
"replies_per_minute": 6,
|
||||||
|
"per_user_cooldown_sec": 30,
|
||||||
|
"context_turns": 4,
|
||||||
|
"min_text_len": 2,
|
||||||
|
"blacklist_uids": [666],
|
||||||
|
"ignore_prefixes": ["//"],
|
||||||
|
}
|
||||||
|
cfg.update(overrides)
|
||||||
|
return LLMReplyEngine(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProvider:
|
||||||
|
"""可注入的假 provider:返回固定回复,可记录调用。"""
|
||||||
|
|
||||||
|
name = "fake"
|
||||||
|
model = "fake-model"
|
||||||
|
|
||||||
|
def __init__(self, reply: str = "你好呀~", delay: float = 0.0, fail_times: int = 0):
|
||||||
|
self.reply = reply
|
||||||
|
self.delay = delay
|
||||||
|
self.fail_times = fail_times
|
||||||
|
self.calls: list[list[dict]] = []
|
||||||
|
|
||||||
|
async def chat(self, messages, *, temperature=0.8, max_tokens=256):
|
||||||
|
self.calls.append(messages)
|
||||||
|
if self.delay:
|
||||||
|
await asyncio.sleep(self.delay)
|
||||||
|
if self.fail_times > 0:
|
||||||
|
self.fail_times -= 1
|
||||||
|
raise LLMError("模拟失败")
|
||||||
|
return self.reply
|
||||||
|
|
||||||
|
|
||||||
|
def _install(engine: LLMReplyEngine, provider: _FakeProvider):
|
||||||
|
engine._provider = provider
|
||||||
|
engine._cfg_fingerprint = engine._provider_fingerprint()
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviders(unittest.TestCase):
|
||||||
|
def test_create_openai(self):
|
||||||
|
p = create_provider({"provider": "openai", "base_url": "https://x/v1",
|
||||||
|
"api_key": "k", "model": "m"})
|
||||||
|
self.assertIsInstance(p, OpenAICompatProvider)
|
||||||
|
self.assertEqual(p.base_url, "https://x/v1")
|
||||||
|
|
||||||
|
def test_create_ollama(self):
|
||||||
|
p = create_provider({"provider": "ollama", "base_url": "http://localhost:11434",
|
||||||
|
"model": "qwen3"})
|
||||||
|
self.assertIsInstance(p, OllamaNativeProvider)
|
||||||
|
|
||||||
|
def test_create_anthropic(self):
|
||||||
|
p = create_provider({"provider": "anthropic", "base_url": "https://x",
|
||||||
|
"api_key": "k", "model": "claude"})
|
||||||
|
self.assertIsInstance(p, AnthropicProvider)
|
||||||
|
|
||||||
|
def test_unknown_provider(self):
|
||||||
|
with self.assertRaises(LLMError):
|
||||||
|
create_provider({"provider": "nope", "base_url": "https://x"})
|
||||||
|
|
||||||
|
def test_missing_base_url(self):
|
||||||
|
with self.assertRaises(LLMError):
|
||||||
|
create_provider({"provider": "openai"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestFiltering(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_disabled_engine(self):
|
||||||
|
engine = _make_engine(enabled=False)
|
||||||
|
self.assertIsNone(await engine.generate_reply("你好", 1, "路人"))
|
||||||
|
|
||||||
|
async def test_too_short(self):
|
||||||
|
engine = _make_engine()
|
||||||
|
self.assertIsNone(engine.should_reply("哈", 1, "路人"))
|
||||||
|
|
||||||
|
async def test_ignore_prefix(self):
|
||||||
|
engine = _make_engine()
|
||||||
|
self.assertIsNone(engine.should_reply("//悄悄话", 1, "路人"))
|
||||||
|
|
||||||
|
async def test_blacklist(self):
|
||||||
|
engine = _make_engine()
|
||||||
|
self.assertIsNone(engine.should_reply("你好呀", 666, "黑名单"))
|
||||||
|
|
||||||
|
async def test_command_like_text_passes_through(self):
|
||||||
|
"""指令拦截在 CommandHandler 层完成;引擎本身不拦截普通文本。"""
|
||||||
|
engine = _make_engine()
|
||||||
|
self.assertIsNotNone(engine.should_reply("上号", 1, "路人"))
|
||||||
|
|
||||||
|
async def test_per_user_cooldown(self):
|
||||||
|
engine = _make_engine()
|
||||||
|
p = _install(engine, _FakeProvider())
|
||||||
|
first = await engine.generate_reply("在吗", 1, "小明")
|
||||||
|
second = await engine.generate_reply("还在吗", 1, "小明")
|
||||||
|
self.assertTrue(first)
|
||||||
|
self.assertIsNone(second) # 冷却期内被过滤
|
||||||
|
self.assertEqual(len(p.calls), 1)
|
||||||
|
|
||||||
|
async def test_zero_cooldown_disabled(self):
|
||||||
|
"""per_user_cooldown_sec=0 表示禁用冷却,不应被吞成默认 30。"""
|
||||||
|
engine = _make_engine(per_user_cooldown_sec=0)
|
||||||
|
_install(engine, _FakeProvider())
|
||||||
|
self.assertTrue(await engine.generate_reply("第一条", 1, "甲"))
|
||||||
|
self.assertTrue(await engine.generate_reply("第二条", 1, "甲"))
|
||||||
|
|
||||||
|
async def test_global_rate_limit(self):
|
||||||
|
engine = _make_engine(replies_per_minute=1, per_user_cooldown_sec=0)
|
||||||
|
_install(engine, _FakeProvider())
|
||||||
|
self.assertTrue(await engine.generate_reply("第一条", 1, "甲"))
|
||||||
|
self.assertIsNone(engine.should_reply("第二条", 2, "乙"))
|
||||||
|
|
||||||
|
async def test_busy_skip(self):
|
||||||
|
"""并发保护:在途请求未完成时新请求直接放弃。"""
|
||||||
|
engine = _make_engine(per_user_cooldown_sec=0)
|
||||||
|
_install(engine, _FakeProvider(delay=0.2))
|
||||||
|
t1 = asyncio.create_task(engine.generate_reply("第一条", 1, "甲"))
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
t2 = asyncio.create_task(engine.generate_reply("第二条", 2, "乙"))
|
||||||
|
self.assertTrue(await t1)
|
||||||
|
self.assertIsNone(await t2)
|
||||||
|
|
||||||
|
async def test_retry_then_success(self):
|
||||||
|
engine = _make_engine(retry_attempts=2, per_user_cooldown_sec=0)
|
||||||
|
_install(engine, _FakeProvider(fail_times=1))
|
||||||
|
with patch("app.llm_client.asyncio.sleep", new=AsyncMock()):
|
||||||
|
reply = await engine.generate_reply("在吗", 1, "小明")
|
||||||
|
self.assertEqual(reply, "你好呀~")
|
||||||
|
|
||||||
|
async def test_sanitize_truncates_and_strips_markdown(self):
|
||||||
|
engine = _make_engine(max_reply_chars=10)
|
||||||
|
# 去掉 ** 后共 14 字,截断到 10 字
|
||||||
|
self.assertEqual(engine._sanitize("**很长**的一段话超过十个字了哦朋友"),
|
||||||
|
"很长的一段话超过十个…")
|
||||||
|
|
||||||
|
|
||||||
|
class TestContext(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_context_included_and_capped(self):
|
||||||
|
engine = _make_engine(context_turns=2, per_user_cooldown_sec=0)
|
||||||
|
p = _install(engine, _FakeProvider())
|
||||||
|
for i in range(5):
|
||||||
|
await engine.generate_reply(f"消息{i}", 1, "小明")
|
||||||
|
# 最后一次调用:system(1) + 最多 2*2 条上下文 + 当前 1 条
|
||||||
|
msgs = p.calls[-1]
|
||||||
|
user_msgs = [m for m in msgs if m["role"] == "user"]
|
||||||
|
self.assertLessEqual(len(msgs), 1 + 4 + 1)
|
||||||
|
self.assertEqual(user_msgs[-1]["content"], "小明: 消息4")
|
||||||
|
|
||||||
|
async def test_system_prompt_present(self):
|
||||||
|
engine = _make_engine()
|
||||||
|
p = _install(engine, _FakeProvider())
|
||||||
|
await engine.generate_reply("在吗", 1, "小明")
|
||||||
|
first = p.calls[0][0]
|
||||||
|
self.assertEqual(first["role"], "system")
|
||||||
|
# 人设在前,情绪标签指令在后(桌宠联动需要)
|
||||||
|
self.assertIn("你是直播间助手", first["content"])
|
||||||
|
self.assertIn("情绪标签", first["content"])
|
||||||
|
self.assertIn("[开心]", first["content"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class LoginScriptDomainExitTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.script_dir = Path(__file__).resolve().parents[1] / "integrations" / "扫码上号"
|
||||||
|
cls.source = (cls.script_dir / "main.js").read_text(encoding="utf-8")
|
||||||
|
cls.settings = json.loads((cls.script_dir / "settings.json").read_text(encoding="utf-8"))
|
||||||
|
cls.manifest = json.loads((cls.script_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_domain_exit_requires_specific_ocr_text_before_click(self):
|
||||||
|
self.assertIn('combinedText.includes("退出秘境")', self.source)
|
||||||
|
self.assertIn('result.text.includes("确认") && result.x >= 900', self.source)
|
||||||
|
|
||||||
|
def test_legacy_and_default_confirm_coordinates_are_corrected(self):
|
||||||
|
defaults = {
|
||||||
|
item.get("name"): item.get("default")
|
||||||
|
for item in self.settings
|
||||||
|
if isinstance(item, dict) and item.get("name")
|
||||||
|
}
|
||||||
|
self.assertEqual(defaults["confirmExitX"], "1164")
|
||||||
|
self.assertEqual(defaults["confirmExitY"], "757")
|
||||||
|
self.assertIn("confirmExitX === 830 && confirmExitY === 600", self.source)
|
||||||
|
|
||||||
|
def test_private_login_script_version_is_updated(self):
|
||||||
|
self.assertEqual(self.manifest["version"], "1.1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "app"))
|
||||||
|
from pet_state import PetState, gift_recovery, split_emotion
|
||||||
|
from app.danmu_queue import get_room_live_status, monitor_pet_live_status
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp(iso: str) -> float:
|
||||||
|
return datetime.fromisoformat(iso).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
class PetStateTests(unittest.TestCase):
|
||||||
|
def test_only_active_sessions_count_and_partial_hours_carry_over(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
pet = PetState(Path(directory) / "pet.json")
|
||||||
|
start = timestamp("2026-09-25T22:00:00")
|
||||||
|
pet.updated_at = start
|
||||||
|
self.assertEqual(pet.sync(start + 3600)["stamina"], 100)
|
||||||
|
pet.set_live_active(True, start + 3600)
|
||||||
|
pet.set_live_active(False, start + 5400)
|
||||||
|
self.assertEqual(pet.sync(start + 9000)["stamina"], 100)
|
||||||
|
pet.set_live_active(True, start + 9000)
|
||||||
|
self.assertEqual(pet.sync(start + 10800)["stamina"], 90)
|
||||||
|
pet.set_live_active(False, start + 10800)
|
||||||
|
self.assertEqual(pet.sync(start + 86400)["stamina"], 90)
|
||||||
|
|
||||||
|
def test_hourly_decay_mood_and_persistence(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "pet.json"
|
||||||
|
pet = PetState(path)
|
||||||
|
pet.updated_at = timestamp("2026-09-25T09:00:00")
|
||||||
|
pet.set_live_active(True, timestamp("2026-09-25T09:00:00"))
|
||||||
|
state = pet.sync(timestamp("2026-09-25T16:00:00"))
|
||||||
|
self.assertEqual(state, {"stamina": 30, "cleanliness": 30, "mood": 95})
|
||||||
|
self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["stamina"], 30)
|
||||||
|
loaded = PetState(path)
|
||||||
|
self.assertEqual(loaded.values, state)
|
||||||
|
self.assertFalse(loaded.live_active)
|
||||||
|
self.assertEqual(loaded.sync(timestamp("2026-09-25T17:00:00")), state)
|
||||||
|
|
||||||
|
def test_emotion_and_gift_clamp(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
pet = PetState(Path(directory) / "pet.json")
|
||||||
|
pet.values = {"stamina": 95, "cleanliness": 99, "mood": 1}
|
||||||
|
self.assertEqual(pet.apply_emotion("很生气")["mood"], 0)
|
||||||
|
self.assertEqual(pet.restore_gift(20, 20),
|
||||||
|
{"stamina": 100, "cleanliness": 100, "mood": 0})
|
||||||
|
self.assertEqual(split_emotion("[很开心] 今天真好"), ("今天真好", "很开心"))
|
||||||
|
self.assertEqual(split_emotion("[未知] 今天真好"), ("[未知] 今天真好", None))
|
||||||
|
|
||||||
|
def test_mood_reaction_events_cover_four_deltas_and_hourly_gain(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
pet = PetState(Path(directory) / "pet.json")
|
||||||
|
pet.values["mood"] = 50
|
||||||
|
for emotion in ("很开心", "开心", "生气", "很生气", "平静"):
|
||||||
|
pet.apply_emotion(emotion)
|
||||||
|
self.assertEqual([event["delta"] for event in pet.recent_emotions],
|
||||||
|
[3, 1, -1, -3])
|
||||||
|
self.assertEqual(len({event["id"] for event in pet.recent_emotions}), 4)
|
||||||
|
start = timestamp("2026-09-25T09:00:00")
|
||||||
|
pet.values.update(stamina=100, cleanliness=100)
|
||||||
|
pet.updated_at = start
|
||||||
|
pet.live_active = True
|
||||||
|
pet.sync(start + 3600)
|
||||||
|
self.assertEqual(pet.recent_emotions[-1]["delta"], 3)
|
||||||
|
|
||||||
|
def test_named_gifts_and_full_stat_keep_nominal_animation_points(self):
|
||||||
|
self.assertEqual(gift_recovery("饭团"), (5, 0))
|
||||||
|
self.assertEqual(gift_recovery("薯条", 2), (10, 0))
|
||||||
|
self.assertEqual(gift_recovery("人气票"), (0, 5))
|
||||||
|
self.assertEqual(gift_recovery("粉丝团灯牌", 2), (0, 10))
|
||||||
|
self.assertEqual(gift_recovery("灯牌"), (0, 5))
|
||||||
|
self.assertEqual(gift_recovery("其他礼物"), (0, 0))
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
pet = PetState(Path(directory) / "pet.json")
|
||||||
|
nominal_stamina, nominal_cleanliness = gift_recovery("饭团")
|
||||||
|
self.assertEqual((nominal_stamina, nominal_cleanliness), (5, 0))
|
||||||
|
self.assertEqual(pet.restore_gift(nominal_stamina, nominal_cleanliness)["stamina"], 100)
|
||||||
|
|
||||||
|
def test_room_state_only_counts_confirmed_live(self):
|
||||||
|
with patch("app.danmu_queue._request_bilibili_json") as request:
|
||||||
|
for status, expected in ((0, False), (1, True), (2, False)):
|
||||||
|
request.return_value = {"code": 0, "data": {"live_status": status}}
|
||||||
|
self.assertIs(get_room_live_status(76), expected)
|
||||||
|
request.return_value = {"code": -1, "data": {"live_status": 1}}
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
get_room_live_status(76)
|
||||||
|
|
||||||
|
def test_room_monitor_switches_pet_session_on_confirmed_state(self):
|
||||||
|
pet = Mock()
|
||||||
|
system = SimpleNamespace(pet_state=pet)
|
||||||
|
config = SimpleNamespace(room_id=76, bilibili_cookie="")
|
||||||
|
with patch("app.danmu_queue.asyncio.to_thread", new=AsyncMock(side_effect=[True, False])), \
|
||||||
|
patch("app.danmu_queue.asyncio.sleep", new=AsyncMock(
|
||||||
|
side_effect=[None, asyncio.CancelledError()])):
|
||||||
|
with self.assertRaises(asyncio.CancelledError):
|
||||||
|
asyncio.run(monitor_pet_live_status(system, config, Mock()))
|
||||||
|
self.assertEqual([call.args[0] for call in pet.set_live_active.call_args_list],
|
||||||
|
[True, False, False])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import ctypes
|
||||||
import logging
|
import logging
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
from app.danmu_queue import SystemScheduler
|
from app.danmu_queue import CommandHandler, QueueSystem, SystemScheduler
|
||||||
|
|
||||||
|
|
||||||
class _FakeConfig:
|
class _FakeConfig:
|
||||||
@@ -155,5 +159,267 @@ class StartupLiveCompensationTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LivehimePushVerificationTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = logging.getLogger("test-livehime-push-verification")
|
||||||
|
self.scheduler = SystemScheduler(
|
||||||
|
_FakeConfig({
|
||||||
|
"bilibili_push_window_keyword": "直播姬",
|
||||||
|
"bilibili_push_click_x_ratio": 0.787,
|
||||||
|
"bilibili_push_click_y_ratio": 0.927,
|
||||||
|
"bilibili_stop_push_click_x_ratio": 0.787,
|
||||||
|
"bilibili_stop_push_click_y_ratio": 0.927,
|
||||||
|
}),
|
||||||
|
self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_push_skips_click_when_bilibili_is_already_live(self):
|
||||||
|
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=1)
|
||||||
|
self.scheduler._click_bilibili_live = AsyncMock()
|
||||||
|
|
||||||
|
self.assertTrue(await self.scheduler.push_bilibili_live())
|
||||||
|
|
||||||
|
self.scheduler._click_bilibili_live.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_push_clicks_then_waits_for_confirmed_live_status(self):
|
||||||
|
self.scheduler._get_bilibili_live_status = AsyncMock(side_effect=[0, 0, 1])
|
||||||
|
self.scheduler._click_bilibili_live = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await self.scheduler.push_bilibili_live()
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.scheduler._click_bilibili_live.assert_awaited_once()
|
||||||
|
|
||||||
|
async def test_stop_skips_click_when_bilibili_is_already_offline(self):
|
||||||
|
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0)
|
||||||
|
self.scheduler._click_bilibili_live = AsyncMock()
|
||||||
|
|
||||||
|
self.assertTrue(await self.scheduler.stop_bilibili_live())
|
||||||
|
|
||||||
|
self.scheduler._click_bilibili_live.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_live_status_timeout_returns_specific_error(self):
|
||||||
|
self.scheduler._get_bilibili_live_status = AsyncMock(return_value=0)
|
||||||
|
|
||||||
|
with patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
result = await self.scheduler._wait_for_bilibili_live_status(
|
||||||
|
1,
|
||||||
|
timeout_seconds=0.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
self.assertIn("未确认开播", self.scheduler.last_live_action_error)
|
||||||
|
|
||||||
|
@patch("app.danmu_queue.os.name", "nt")
|
||||||
|
async def test_click_restores_and_focuses_before_reading_physical_window_rect(self):
|
||||||
|
hwnd = 12345
|
||||||
|
user32 = MagicMock()
|
||||||
|
call_order = []
|
||||||
|
user32.IsIconic.return_value = True
|
||||||
|
user32.GetForegroundWindow.return_value = hwnd
|
||||||
|
user32.GetSystemMetrics.side_effect = lambda index: {
|
||||||
|
76: 0,
|
||||||
|
77: 0,
|
||||||
|
78: 2560,
|
||||||
|
79: 1440,
|
||||||
|
}[index]
|
||||||
|
user32.SetCursorPos.return_value = True
|
||||||
|
user32.GetCursorPos.return_value = False
|
||||||
|
user32.SetThreadDpiAwarenessContext.return_value = None
|
||||||
|
|
||||||
|
def show_window(_hwnd, _command):
|
||||||
|
call_order.append("restore")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_window_rect(_hwnd, pointer):
|
||||||
|
call_order.append("rect")
|
||||||
|
rect = ctypes.cast(pointer, ctypes.POINTER(ctypes.wintypes.RECT)).contents
|
||||||
|
rect.left = -11
|
||||||
|
rect.top = -11
|
||||||
|
rect.right = 2571
|
||||||
|
rect.bottom = 1379
|
||||||
|
return True
|
||||||
|
|
||||||
|
user32.ShowWindow.side_effect = show_window
|
||||||
|
user32.GetWindowRect.side_effect = get_window_rect
|
||||||
|
self.scheduler._find_window_by_keyword = MagicMock(return_value=hwnd)
|
||||||
|
self.scheduler._force_foreground_window = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.ctypes.windll.user32", user32),
|
||||||
|
patch("app.danmu_queue.asyncio.sleep", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
result = await self.scheduler._click_bilibili_live(
|
||||||
|
x_ratio_key="bilibili_push_click_x_ratio",
|
||||||
|
y_ratio_key="bilibili_push_click_y_ratio",
|
||||||
|
action_name="开启推流按钮",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertLess(call_order.index("restore"), call_order.index("rect"))
|
||||||
|
user32.SetCursorPos.assert_called_once_with(2021, 1277)
|
||||||
|
self.assertEqual(user32.mouse_event.call_count, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class ManualLiveOverrideTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.system = QueueSystem.__new__(QueueSystem)
|
||||||
|
self.system.logger = logging.getLogger("test-manual-live-override")
|
||||||
|
self.system._manual_live_override_until_monotonic = 0.0
|
||||||
|
self.system._manual_live_override_until_wall = 0.0
|
||||||
|
self.system._bilibili_live_status = None
|
||||||
|
self.system._bilibili_live_status_updated_monotonic = 0.0
|
||||||
|
self.system._bilibili_live_status_updated_wall = 0.0
|
||||||
|
|
||||||
|
def test_manual_live_override_allows_commands_outside_schedule(self):
|
||||||
|
handler = CommandHandler.__new__(CommandHandler)
|
||||||
|
handler.system = self.system
|
||||||
|
handler.config = SimpleNamespace(
|
||||||
|
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.danmu_queue.time.monotonic", return_value=100.0):
|
||||||
|
self.system.enable_manual_live_override(300)
|
||||||
|
self.assertTrue(handler._is_live_time())
|
||||||
|
snapshot = self.system.manual_live_override_snapshot()
|
||||||
|
|
||||||
|
self.assertTrue(snapshot["active"])
|
||||||
|
self.assertGreater(snapshot["until"], 0)
|
||||||
|
|
||||||
|
def test_clear_manual_live_override_restores_schedule_gate(self):
|
||||||
|
handler = CommandHandler.__new__(CommandHandler)
|
||||||
|
handler.system = self.system
|
||||||
|
handler.config = SimpleNamespace(
|
||||||
|
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.time.monotonic", return_value=100.0),
|
||||||
|
patch("app.danmu_queue.datetime") as mocked_datetime,
|
||||||
|
):
|
||||||
|
mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30)
|
||||||
|
mocked_datetime.strptime = datetime.strptime
|
||||||
|
self.system.enable_manual_live_override(300)
|
||||||
|
self.system.clear_manual_live_override()
|
||||||
|
self.assertFalse(handler._is_live_time())
|
||||||
|
|
||||||
|
def test_confirmed_bilibili_live_status_allows_commands_outside_schedule(self):
|
||||||
|
handler = CommandHandler.__new__(CommandHandler)
|
||||||
|
handler.system = self.system
|
||||||
|
handler.config = SimpleNamespace(
|
||||||
|
system_cfg={"live_start_time": "08:30", "live_end_time": "23:30"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.danmu_queue.time.monotonic", return_value=100.0),
|
||||||
|
patch("app.danmu_queue.datetime") as mocked_datetime,
|
||||||
|
):
|
||||||
|
mocked_datetime.now.return_value = datetime(2026, 8, 16, 3, 30)
|
||||||
|
mocked_datetime.strptime = datetime.strptime
|
||||||
|
self.system.set_bilibili_live_status(1)
|
||||||
|
self.assertTrue(handler._is_live_time())
|
||||||
|
|
||||||
|
|
||||||
|
class StartupTaskSelfHealTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = logging.getLogger("test-startup-self-heal")
|
||||||
|
self.scheduler = SystemScheduler(
|
||||||
|
_FakeConfig({
|
||||||
|
"enable_startup_shortcut": True,
|
||||||
|
"startup_bat": "run.bat",
|
||||||
|
}),
|
||||||
|
self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("app.danmu_queue.os.name", "nt")
|
||||||
|
@patch("app.danmu_queue.project_path")
|
||||||
|
def test_rebuilds_when_task_missing(self, project_path_mock):
|
||||||
|
with tempfile.TemporaryDirectory() as root:
|
||||||
|
root_path = Path(root)
|
||||||
|
(root_path / "run.bat").touch()
|
||||||
|
(root_path / ".venv-tts" / "Scripts").mkdir(parents=True)
|
||||||
|
(root_path / ".venv-tts" / "Scripts" / "pythonw.exe").touch()
|
||||||
|
(root_path / "app").mkdir()
|
||||||
|
(root_path / "app" / "main.py").touch()
|
||||||
|
project_path_mock.side_effect = lambda relative: root_path / relative
|
||||||
|
self.scheduler._startup_task_exists = MagicMock(return_value=False)
|
||||||
|
self.scheduler._create_startup_task = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
self.scheduler.ensure_startup_task_self_heal()
|
||||||
|
|
||||||
|
self.scheduler._create_startup_task.assert_called_once()
|
||||||
|
args = self.scheduler._create_startup_task.call_args.args
|
||||||
|
self.assertEqual(args[0], "BetterGI弹幕排队系统")
|
||||||
|
# 无窗口模式:pythonw 直接运行 main.py,不再弹 cmd 窗口
|
||||||
|
self.assertIn(str(root_path / "app" / "main.py"), args[1])
|
||||||
|
self.assertIn("--role all", args[1])
|
||||||
|
self.assertIn(f"--port {self.scheduler.port}", args[1])
|
||||||
|
|
||||||
|
@patch("app.danmu_queue.os.name", "nt")
|
||||||
|
@patch("app.danmu_queue.project_path")
|
||||||
|
def test_skips_when_task_present(self, project_path_mock):
|
||||||
|
project_path_mock.return_value = MagicMock(
|
||||||
|
exists=MagicMock(return_value=True),
|
||||||
|
__str__=lambda _s: r"C:\proj\run.bat",
|
||||||
|
)
|
||||||
|
self.scheduler._startup_task_exists = MagicMock(return_value=True)
|
||||||
|
self.scheduler._create_startup_task = MagicMock()
|
||||||
|
|
||||||
|
self.scheduler.ensure_startup_task_self_heal()
|
||||||
|
|
||||||
|
self.scheduler._create_startup_task.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.danmu_queue.os.name", "nt")
|
||||||
|
def test_skips_when_disabled(self):
|
||||||
|
self.scheduler.config.system_cfg["enable_startup_shortcut"] = False
|
||||||
|
self.scheduler._startup_task_exists = MagicMock()
|
||||||
|
self.scheduler._create_startup_task = MagicMock()
|
||||||
|
|
||||||
|
self.scheduler.ensure_startup_task_self_heal()
|
||||||
|
|
||||||
|
self.scheduler._startup_task_exists.assert_not_called()
|
||||||
|
self.scheduler._create_startup_task.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptGroupSyncTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.logger = logging.getLogger("test-scriptgroup-sync")
|
||||||
|
self.scheduler = SystemScheduler(
|
||||||
|
_FakeConfig({}),
|
||||||
|
self.logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_copies_and_overwrites_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as src_dir, tempfile.TemporaryDirectory() as bgi_dir:
|
||||||
|
src = Path(src_dir)
|
||||||
|
(src / "薄荷.json").write_text('{"v": 2}', encoding="utf-8")
|
||||||
|
(src / "晶蝶.json").write_text("{}", encoding="utf-8")
|
||||||
|
dst = Path(bgi_dir) / "User" / "ScriptGroup"
|
||||||
|
dst.mkdir(parents=True)
|
||||||
|
(dst / "薄荷.json").write_text('{"v": 1, "corrupt": true}', encoding="utf-8")
|
||||||
|
(dst / "本地多余.json").write_text("{}", encoding="utf-8")
|
||||||
|
self.scheduler.config.bettergi_work_dir = bgi_dir
|
||||||
|
self.scheduler.config.system_cfg["scriptgroup_backup_dir"] = src_dir
|
||||||
|
|
||||||
|
self.scheduler._sync_script_groups()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
(dst / "薄荷.json").read_text(encoding="utf-8"), '{"v": 2}'
|
||||||
|
)
|
||||||
|
self.assertTrue((dst / "晶蝶.json").exists())
|
||||||
|
# 目标目录里多出的文件不删除
|
||||||
|
self.assertTrue((dst / "本地多余.json").exists())
|
||||||
|
|
||||||
|
def test_missing_source_dir_is_skipped(self):
|
||||||
|
with tempfile.TemporaryDirectory() as bgi_dir:
|
||||||
|
self.scheduler.config.bettergi_work_dir = bgi_dir
|
||||||
|
self.scheduler.config.system_cfg["scriptgroup_backup_dir"] = (
|
||||||
|
str(Path(bgi_dir) / "不存在")
|
||||||
|
)
|
||||||
|
# 不抛异常即通过
|
||||||
|
self.scheduler._sync_script_groups()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -144,6 +145,49 @@ class PipelineOverlapTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
await asyncio.gather(synth_worker, play_worker, return_exceptions=True)
|
await asyncio.gather(synth_worker, play_worker, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundWarmupTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_start_returns_before_tts_warmup_finishes(self):
|
||||||
|
warmup_started = asyncio.Event()
|
||||||
|
release_warmup = asyncio.Event()
|
||||||
|
|
||||||
|
class FakeTTS:
|
||||||
|
enabled = True
|
||||||
|
|
||||||
|
async def warmup(self):
|
||||||
|
warmup_started.set()
|
||||||
|
await release_warmup.wait()
|
||||||
|
|
||||||
|
async def idle_worker():
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
broadcaster = Broadcaster.__new__(Broadcaster)
|
||||||
|
broadcaster._tts_started = False
|
||||||
|
broadcaster._tts_warmup_done = asyncio.Event()
|
||||||
|
broadcaster._tts_warmup_task = None
|
||||||
|
broadcaster._tts_worker_tasks = set()
|
||||||
|
broadcaster._tts_queue_cfg = {"warmup_on_start": True}
|
||||||
|
broadcaster._tts_pending = SimpleNamespace(maxsize=8)
|
||||||
|
broadcaster._tts_playback_queue = SimpleNamespace(maxsize=2)
|
||||||
|
broadcaster._tts_synthesis_loop = idle_worker
|
||||||
|
broadcaster._tts_playback_loop = idle_worker
|
||||||
|
broadcaster.tts = FakeTTS()
|
||||||
|
broadcaster.logger = logging.getLogger("tts-background-warmup-test")
|
||||||
|
|
||||||
|
await asyncio.wait_for(broadcaster.start(), timeout=0.2)
|
||||||
|
await asyncio.wait_for(warmup_started.wait(), timeout=0.2)
|
||||||
|
|
||||||
|
self.assertFalse(broadcaster._tts_warmup_done.is_set())
|
||||||
|
self.assertIsNotNone(broadcaster._tts_warmup_task)
|
||||||
|
|
||||||
|
release_warmup.set()
|
||||||
|
await asyncio.wait_for(broadcaster._tts_warmup_done.wait(), timeout=0.2)
|
||||||
|
|
||||||
|
tasks = list(broadcaster._tts_worker_tasks)
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
class TTSExpiryPolicyTests(unittest.TestCase):
|
class TTSExpiryPolicyTests(unittest.TestCase):
|
||||||
def test_default_expiry_windows_match_configured_policy(self):
|
def test_default_expiry_windows_match_configured_policy(self):
|
||||||
broadcaster = Broadcaster.__new__(Broadcaster)
|
broadcaster = Broadcaster.__new__(Broadcaster)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,10 +4,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>直播联动后台</title>
|
<title>直播联动后台</title>
|
||||||
<script type="module" crossorigin src="/admin/assets/index-CIcFYlv1.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-DzEqvM4m.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-BB8VhdaG.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-BB8VhdaG.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+838
-28
@@ -46,10 +46,11 @@
|
|||||||
#viewport {
|
#viewport {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: flex-start; background: transparent;
|
||||||
background: transparent;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.preview #viewport {
|
body.preview #viewport {
|
||||||
@@ -61,9 +62,13 @@
|
|||||||
#canvas {
|
#canvas {
|
||||||
width: 1920px;
|
width: 1920px;
|
||||||
height: 1080px;
|
height: 1080px;
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
margin-left: -960px;
|
||||||
|
margin-top: -540px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transform-origin: top left;
|
transform-origin: center center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#frame {
|
#frame {
|
||||||
@@ -1315,6 +1320,34 @@
|
|||||||
border: 1px solid rgba(255, 242, 201, 0.13);
|
border: 1px solid rgba(255, 242, 201, 0.13);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.command-quick-reference {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 2px 12px;
|
||||||
|
margin: 0 12px 2px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-top: 1px solid rgba(255, 242, 201, 0.15);
|
||||||
|
border-bottom: 1px solid rgba(255, 242, 201, 0.15);
|
||||||
|
color: rgba(244, 255, 247, 0.86);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 720;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-quick-reference span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-quick-reference strong {
|
||||||
|
color: #fff0b5;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
.tutorial-stage {
|
.tutorial-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -1322,19 +1355,20 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 14px 3px;
|
min-height: 32px;
|
||||||
|
padding: 0 10px 1px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tutorial-text {
|
.tutorial-text {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 26px;
|
font-size: 20px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1.35;
|
line-height: 1.25;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
letter-spacing: 0.7px;
|
letter-spacing: 0;
|
||||||
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.48), 0 0 14px rgba(255, 240, 181, 0.18);
|
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.48), 0 0 14px rgba(255, 240, 181, 0.18);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -1342,12 +1376,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tutorial-text.long {
|
.tutorial-text.long {
|
||||||
font-size: 23px;
|
font-size: 18px;
|
||||||
letter-spacing: 0.35px;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tutorial-text.extra-long {
|
.tutorial-text.extra-long {
|
||||||
font-size: 21px;
|
font-size: 16px;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1698,6 +1732,85 @@
|
|||||||
body.theme-aurora .tutorial-text {
|
body.theme-aurora .tutorial-text {
|
||||||
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 16px rgba(150, 180, 255, 0.22);
|
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 16px rgba(150, 180, 255, 0.22);
|
||||||
}
|
}
|
||||||
|
/* 桌宠与状态环作为一个整体,只在游戏镂空区外游走。 */
|
||||||
|
.kirara-wanderer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 12;
|
||||||
|
width: 172px;
|
||||||
|
height: 140px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.kirara-sprite {
|
||||||
|
width: 126px;
|
||||||
|
height: 140px;
|
||||||
|
flex: none;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
filter: drop-shadow(0 5px 1px rgba(5, 22, 18, .8));
|
||||||
|
}
|
||||||
|
.kirara-status {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: none;
|
||||||
|
overflow: visible;
|
||||||
|
filter: drop-shadow(0 2px 1px rgba(7, 22, 21, .8));
|
||||||
|
}
|
||||||
|
.kirara-ring-track, .kirara-ring-progress { shape-rendering: crispEdges; }
|
||||||
|
.kirara-ring-track.stamina { fill: #65562e; }
|
||||||
|
.kirara-ring-track.cleanliness { fill: #28566a; }
|
||||||
|
.kirara-ring-track.mood { fill: #70413d; }
|
||||||
|
.kirara-ring-progress.stamina { fill: #f8d76c; }
|
||||||
|
.kirara-ring-progress.cleanliness { fill: #7ccdf5; }
|
||||||
|
.kirara-ring-progress.mood { fill: #f58382; }
|
||||||
|
.kirara-need-bubble {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border: 3px solid #3d3034;
|
||||||
|
background: #fff0c9;
|
||||||
|
color: #49372f;
|
||||||
|
box-shadow: 4px 4px 0 rgba(18, 33, 29, .65), inset 0 0 0 2px #fff9e5;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 22px;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
.kirara-need-bubble[hidden] { display: none; }
|
||||||
|
.kirara-need-bubble::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
background: #fff0c9;
|
||||||
|
border: 3px solid #3d3034;
|
||||||
|
}
|
||||||
|
.kirara-need-bubble[data-place="side"]::after {
|
||||||
|
left: -12px;
|
||||||
|
top: 17px;
|
||||||
|
border-right: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.kirara-need-bubble[data-place="above"]::after {
|
||||||
|
left: 39px;
|
||||||
|
bottom: -12px;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.kirara-need-bubble[data-place="below"]::after {
|
||||||
|
left: 39px;
|
||||||
|
top: -12px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1719,6 +1832,20 @@
|
|||||||
<!-- game-cutout 放在 #frame 外面, 不受 clip-path 裁剪, 保证镂空区域完全透明(OBS 穿透) -->
|
<!-- game-cutout 放在 #frame 外面, 不受 clip-path 裁剪, 保证镂空区域完全透明(OBS 穿透) -->
|
||||||
<div id="game-cutout"></div>
|
<div id="game-cutout"></div>
|
||||||
|
|
||||||
|
<!-- 镂空区外游走的桌宠;三层状态环跟随角色,不显示文字。 -->
|
||||||
|
<div id="kirara-wanderer" class="kirara-wanderer" aria-label="绮良良桌宠状态">
|
||||||
|
<canvas id="kirara-sprite" class="kirara-sprite" width="126" height="140" role="img" aria-label="像素绮良良桌宠"></canvas>
|
||||||
|
<svg class="kirara-status" viewBox="0 0 80 80" role="img" aria-label="外圈黄色体力、中圈蓝色清洁、内圈红色心情">
|
||||||
|
<path id="kirara-stamina-track" class="kirara-ring-track stamina"/>
|
||||||
|
<path id="kirara-stamina-ring" class="kirara-ring-progress stamina"/>
|
||||||
|
<path id="kirara-cleanliness-track" class="kirara-ring-track cleanliness"/>
|
||||||
|
<path id="kirara-cleanliness-ring" class="kirara-ring-progress cleanliness"/>
|
||||||
|
<path id="kirara-mood-track" class="kirara-ring-track mood"/>
|
||||||
|
<path id="kirara-mood-ring" class="kirara-ring-progress mood"/>
|
||||||
|
</svg>
|
||||||
|
<div id="kirara-need-bubble" class="kirara-need-bubble" role="status" aria-live="polite" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 游戏画面左侧:可用配置组 -->
|
<!-- 游戏画面左侧:可用配置组 -->
|
||||||
<section class="panel available-groups-panel">
|
<section class="panel available-groups-panel">
|
||||||
<div class="panel-title">可用配置组</div>
|
<div class="panel-title">可用配置组</div>
|
||||||
@@ -1852,6 +1979,7 @@
|
|||||||
<!-- 右侧中部:教学轮播 -->
|
<!-- 右侧中部:教学轮播 -->
|
||||||
<section class="panel tutorial-panel">
|
<section class="panel tutorial-panel">
|
||||||
<div class="panel-title tutorial-title">直播间使用说明</div>
|
<div class="panel-title tutorial-title">直播间使用说明</div>
|
||||||
|
<div id="command-quick-reference" class="command-quick-reference"></div>
|
||||||
<div class="tutorial-stage">
|
<div class="tutorial-stage">
|
||||||
<div id="tutorial-text" class="tutorial-text">发送“签到”领取积分,发送“排队”加入队伍</div>
|
<div id="tutorial-text" class="tutorial-text">发送“签到”领取积分,发送“排队”加入队伍</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1898,7 +2026,7 @@
|
|||||||
return m + ":" + String(sec % 60).padStart(2, "0");
|
return m + ":" + String(sec % 60).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
const tutorialSlides = [
|
const baseTutorialSlides = [
|
||||||
"发送“签到”领取积分,发送“排队”加入队伍",
|
"发送“签到”领取积分,发送“排队”加入队伍",
|
||||||
"成为队首后,请在90秒内发送“上号”",
|
"成为队首后,请在90秒内发送“上号”",
|
||||||
"发送“上号”后,请在4分钟内完成扫码登录",
|
"发送“上号”后,请在4分钟内完成扫码登录",
|
||||||
@@ -1907,8 +2035,85 @@
|
|||||||
"发送“积分”查询积分,发送“队列”查看排队情况",
|
"发送“积分”查询积分,发送“队列”查看排队情况",
|
||||||
"发送“退出”离开队伍,发送“点歌 歌名”点歌"
|
"发送“退出”离开队伍,发送“点歌 歌名”点歌"
|
||||||
];
|
];
|
||||||
|
const fallbackCommandHints = {
|
||||||
|
daily: { enabled: true, aliases: ["自动每日", "每日", "日常"], preferred_alias: "每日" },
|
||||||
|
switch_party: { enabled: true, aliases: ["切换队伍", "换队"], preferred_alias: "换队" },
|
||||||
|
edit_party: { enabled: true, aliases: ["修改队员", "换角色"], preferred_alias: "换角色" }
|
||||||
|
};
|
||||||
|
let commandHints = fallbackCommandHints;
|
||||||
|
let tutorialSlides = baseTutorialSlides.slice();
|
||||||
let tutorialIndex = 0;
|
let tutorialIndex = 0;
|
||||||
|
|
||||||
|
function normalizeCommandHint(value, fallback) {
|
||||||
|
const source = value && typeof value === "object" ? value : fallback;
|
||||||
|
const aliases = Array.isArray(source.aliases)
|
||||||
|
? source.aliases.map((item) => String(item || "").trim()).filter(Boolean)
|
||||||
|
: fallback.aliases.slice();
|
||||||
|
const preferred = String(source.preferred_alias || "").trim()
|
||||||
|
|| aliases.reduce((best, item) => !best || item.length < best.length ? item : best, "")
|
||||||
|
|| fallback.preferred_alias;
|
||||||
|
return {
|
||||||
|
enabled: source.enabled !== false,
|
||||||
|
aliases,
|
||||||
|
preferred_alias: preferred
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommandQuickReference() {
|
||||||
|
const el = document.getElementById("command-quick-reference");
|
||||||
|
if (!el) return;
|
||||||
|
const daily = commandHints.daily;
|
||||||
|
if (!daily || !daily.enabled) {
|
||||||
|
el.hidden = true;
|
||||||
|
el.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cmd = esc(daily.preferred_alias || "每日");
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = [
|
||||||
|
`<span><strong>${cmd}</strong>:基础流程</span>`,
|
||||||
|
`<span><strong>${cmd} 秘境</strong> <名称></span>`,
|
||||||
|
`<span><strong>${cmd} 地脉</strong> <类型> <国家></span>`,
|
||||||
|
`<span><strong>${cmd} 委托</strong>:每日委托</span>`
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildTutorialSlides() {
|
||||||
|
const slides = baseTutorialSlides.slice();
|
||||||
|
const daily = commandHints.daily;
|
||||||
|
const switchParty = commandHints.switch_party;
|
||||||
|
const editParty = commandHints.edit_party;
|
||||||
|
if (daily && daily.enabled) {
|
||||||
|
const cmd = daily.preferred_alias || "每日";
|
||||||
|
slides.push(`发送“${cmd}”完成邮件、树脂、尘歌壶和每日奖励`);
|
||||||
|
slides.push(`发送“${cmd} 秘境 风本”刷到树脂耗尽`);
|
||||||
|
slides.push(`发送“${cmd} 地脉 经验 蒙德”刷经验地脉`);
|
||||||
|
slides.push(`发送“${cmd} 委托”执行每日委托配置`);
|
||||||
|
}
|
||||||
|
const partyParts = [];
|
||||||
|
if (switchParty && switchParty.enabled) {
|
||||||
|
partyParts.push(`“${switchParty.preferred_alias || "换队"} 永冻队”切换队伍`);
|
||||||
|
}
|
||||||
|
if (editParty && editParty.enabled) {
|
||||||
|
partyParts.push(`“${editParty.preferred_alias || "换角色"} 四名角色”修改队员`);
|
||||||
|
}
|
||||||
|
if (partyParts.length) slides.push(`发送${partyParts.join(",发送")}`);
|
||||||
|
tutorialSlides = slides;
|
||||||
|
tutorialIndex %= tutorialSlides.length;
|
||||||
|
renderCommandQuickReference();
|
||||||
|
showTutorialSlide(tutorialIndex, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCommandHints(value) {
|
||||||
|
const source = value && typeof value === "object" ? value : {};
|
||||||
|
commandHints = {
|
||||||
|
daily: normalizeCommandHint(source.daily, fallbackCommandHints.daily),
|
||||||
|
switch_party: normalizeCommandHint(source.switch_party, fallbackCommandHints.switch_party),
|
||||||
|
edit_party: normalizeCommandHint(source.edit_party, fallbackCommandHints.edit_party)
|
||||||
|
};
|
||||||
|
rebuildTutorialSlides();
|
||||||
|
}
|
||||||
|
|
||||||
function renderTutorialDots() {
|
function renderTutorialDots() {
|
||||||
const dots = document.getElementById("tutorial-dots");
|
const dots = document.getElementById("tutorial-dots");
|
||||||
if (!dots) return;
|
if (!dots) return;
|
||||||
@@ -2012,14 +2217,603 @@
|
|||||||
let performanceState = {cpu: null, memory: null, gpu: null, gpu_available: false};
|
let performanceState = {cpu: null, memory: null, gpu: null, gpu_available: false};
|
||||||
let lastCoverKey = "";
|
let lastCoverKey = "";
|
||||||
|
|
||||||
|
// 在 2px 网格上逐格绘制,轮廓保持像素阶梯,进度仍可随数值变化。
|
||||||
|
const kiraraPixelRings = Object.fromEntries(
|
||||||
|
[["stamina", 32], ["cleanliness", 23], ["mood", 14]].map(([key, radius]) => {
|
||||||
|
const tiles = [];
|
||||||
|
for (let y = 4; y < 76; y += 2) {
|
||||||
|
for (let x = 4; x < 76; x += 2) {
|
||||||
|
const dx = x + 1 - 40;
|
||||||
|
const dy = y + 1 - 40;
|
||||||
|
if (Math.abs(Math.hypot(dx, dy) - radius) > 3.5) continue;
|
||||||
|
const angle = (Math.atan2(dy, dx) + Math.PI / 2 + Math.PI * 2) % (Math.PI * 2);
|
||||||
|
tiles.push({angle, path: `M${x} ${y}h2v2h-2z`});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById("kirara-" + key + "-track")
|
||||||
|
.setAttribute("d", tiles.map(tile => tile.path).join(""));
|
||||||
|
return [key, tiles];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let kiraraSleeping = false;
|
||||||
|
let kiraraSleepStartedAt = 0;
|
||||||
|
const kiraraNeedMessages = {
|
||||||
|
stamina: ["饿饿,投喂一个饭团叭~", "饿饿,投喂一个薯条叭~"],
|
||||||
|
cleanliness: ["脏脏,投喂一个人气票叭~", "脏脏,投喂一个粉丝团灯牌叭~"]
|
||||||
|
};
|
||||||
|
const kiraraNeedValues = {stamina: 100, cleanliness: 100};
|
||||||
|
let kiraraNeedNextAt = null;
|
||||||
|
let kiraraNeedCurrent = null;
|
||||||
|
let kiraraNeedLastType = null;
|
||||||
|
|
||||||
|
function kiraraNeedDuration(value) {
|
||||||
|
if (value > 60) return 0;
|
||||||
|
if (value <= 0) return Infinity;
|
||||||
|
if (value >= 40) return (5 + (60 - value) / 4) * 1000;
|
||||||
|
if (value >= 20) return (10 + (40 - value)) * 1000;
|
||||||
|
return (30 + (20 - value) * 1.5) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKiraraNeedBubble(now) {
|
||||||
|
const bubble = document.getElementById("kirara-need-bubble");
|
||||||
|
const eligible = ["stamina", "cleanliness"]
|
||||||
|
.filter(type => kiraraNeedDuration(kiraraNeedValues[type]) > 0);
|
||||||
|
if (!eligible.length) {
|
||||||
|
kiraraNeedCurrent = null;
|
||||||
|
kiraraNeedNextAt = null;
|
||||||
|
bubble.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kiraraNeedCurrent && !eligible.includes(kiraraNeedCurrent.type)) {
|
||||||
|
kiraraNeedCurrent = null;
|
||||||
|
kiraraNeedNextAt = now;
|
||||||
|
}
|
||||||
|
if (kiraraNeedNextAt === null) kiraraNeedNextAt = now;
|
||||||
|
if (now >= kiraraNeedNextAt) {
|
||||||
|
const empty = eligible.filter(type => kiraraNeedValues[type] === 0);
|
||||||
|
const choices = empty.length ? empty : eligible;
|
||||||
|
const type = choices.length === 1 ? choices[0]
|
||||||
|
: (kiraraNeedLastType && choices.includes(kiraraNeedLastType)
|
||||||
|
? choices.find(candidate => candidate !== kiraraNeedLastType)
|
||||||
|
: choices.reduce((a, b) => kiraraNeedValues[a] <= kiraraNeedValues[b] ? a : b));
|
||||||
|
const messages = kiraraNeedMessages[type];
|
||||||
|
kiraraNeedCurrent = {type, startedAt: now};
|
||||||
|
kiraraNeedLastType = type;
|
||||||
|
kiraraNeedNextAt = now + 60000;
|
||||||
|
bubble.textContent = messages[Math.floor(Math.random() * messages.length)];
|
||||||
|
}
|
||||||
|
const active = kiraraNeedCurrent &&
|
||||||
|
now - kiraraNeedCurrent.startedAt < kiraraNeedDuration(
|
||||||
|
kiraraNeedValues[kiraraNeedCurrent.type]);
|
||||||
|
bubble.hidden = !active;
|
||||||
|
if (!active) return;
|
||||||
|
if (kiraraPosition.x < 1306) {
|
||||||
|
bubble.dataset.place = "side";
|
||||||
|
bubble.style.left = "178px";
|
||||||
|
bubble.style.top = "19px";
|
||||||
|
} else {
|
||||||
|
const below = kiraraPosition.y < 85;
|
||||||
|
bubble.dataset.place = below ? "below" : "above";
|
||||||
|
bubble.style.left = `${Math.round(Math.min(kiraraPosition.x + 20, 1630) - kiraraPosition.x)}px`;
|
||||||
|
bubble.style.top = below ? "144px" : "-61px";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKirara(pet) {
|
||||||
|
if (!pet) return;
|
||||||
|
for (const [key, tiles] of Object.entries(kiraraPixelRings)) {
|
||||||
|
const value = Math.max(0, Math.min(100, Number(pet[key]) || 0));
|
||||||
|
if (key in kiraraNeedValues) kiraraNeedValues[key] = value;
|
||||||
|
const ring = document.getElementById("kirara-" + key + "-ring");
|
||||||
|
ring.setAttribute("d", tiles
|
||||||
|
.filter(tile => tile.angle < value / 100 * Math.PI * 2)
|
||||||
|
.map(tile => tile.path).join(""));
|
||||||
|
}
|
||||||
|
const mood = Number(pet.mood);
|
||||||
|
if (!Number.isFinite(mood) || (mood < 50) === kiraraSleeping) return;
|
||||||
|
kiraraSleeping = mood < 50;
|
||||||
|
if (kiraraSleeping) {
|
||||||
|
kiraraSleepStartedAt = performance.now();
|
||||||
|
kiraraWalkEndsAt = 0;
|
||||||
|
kiraraWaypoints.length = 0;
|
||||||
|
kiraraIdle = null;
|
||||||
|
kiraraDirection = "front";
|
||||||
|
kiraraDrawnFrameKey = ""; // 强制立即重绘第一帧睡觉姿势
|
||||||
|
} else {
|
||||||
|
kiraraNextWalkAt = null;
|
||||||
|
kiraraNextIdleAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crops follow each generated sprite's transparent silhouette.
|
||||||
|
const kiraraSpriteBoxes = {
|
||||||
|
"kirara_walk_front": [[133,15,407,670], [617,15,392,665], [1126,15,503,672], [1629,15,388,664]],
|
||||||
|
"kirara_walk_back": [[68,19,420,691], [563,27,416,685], [1064,19,416,692], [1561,19,420,692]],
|
||||||
|
"kirara_walk_left": [[136,28,407,679], [543,28,537,679], [1151,28,446,679], [1673,28,448,679]],
|
||||||
|
"kirara_walk_right": [[33,136,397,691], [456,133,399,694], [894,137,419,690], [1330,137,408,690]],
|
||||||
|
"kirara_eat_rice": [[14,42,425,681], [510,42,424,681], [1005,42,425,681], [1502,42,424,681]],
|
||||||
|
"kirara_eat_dango": [[49,13,443,690], [592,13,443,690], [1135,13,443,690], [1677,13,444,690]],
|
||||||
|
"kirara_bath_sponge": [[62,21,437,677], [592,21,429,677], [1143,21,450,677], [1686,21,432,677]],
|
||||||
|
"kirara_bath_shower": [[62,24,405,676], [618,24,389,676], [1133,24,437,676], [1639,102,492,599]],
|
||||||
|
"kirara_idle_delivery": [[92,23,436,676], [580,21,460,678], [1118,23,444,676], [1647,23,433,676]],
|
||||||
|
"kirara_idle_reading": [[59,14,425,692], [601,14,428,692], [1144,44,427,662], [1687,13,424,693]],
|
||||||
|
"kirara_idle_phone": [[138,19,405,682], [543,19,484,681], [1118,20,511,682], [1629,18,405,683]],
|
||||||
|
"kirara_idle_sleep": [[72,81,471,595], [605,128,467,548], [1122,99,469,577], [1647,69,465,607]],
|
||||||
|
"kirara_idle_stretch": [[65,133,365,587], [527,133,430,587], [1022,154,450,566], [1553,134,365,586]],
|
||||||
|
"kirara_idle_groom": [[84,38,413,650], [612,38,409,650], [1153,38,421,650], [1696,38,416,650]],
|
||||||
|
"kirara_idle_butterfly": [[46,138,418,592], [537,138,430,592], [1061,123,381,607], [1515,111,424,619]],
|
||||||
|
"kirara_idle_tea": [[66,13,438,684], [609,13,437,684], [1153,13,436,684], [1695,13,436,684]],
|
||||||
|
"kirara_idle_yarn": [[108,26,419,663], [606,26,418,663], [1149,26,418,663], [1646,26,419,663]],
|
||||||
|
"kirara_idle_humming": [[76,71,420,671], [561,72,417,670], [1005,72,416,670], [1487,71,420,671]],
|
||||||
|
"kirara_rest_breathe": [[23,16,426,679], [571,15,420,680], [1104,20,433,675], [1664,16,424,679]],
|
||||||
|
"kirara_emotion_plus3_a": [[69,59,429,627], [622,33,411,594], [1139,10,426,583], [1676,71,428,615]],
|
||||||
|
"kirara_emotion_plus3_b": [[56,35,459,654], [573,35,504,654], [1135,32,458,657], [1653,35,466,654]],
|
||||||
|
"kirara_emotion_plus1_a": [[65,14,415,678], [611,14,412,678], [1133,14,431,678], [1695,14,413,678]],
|
||||||
|
"kirara_emotion_plus1_b": [[63,16,435,683], [608,37,447,664], [1136,28,430,673], [1679,16,432,683]],
|
||||||
|
"kirara_emotion_minus1_a": [[73,30,440,662], [606,30,433,662], [1153,30,415,662], [1664,30,438,662]],
|
||||||
|
"kirara_emotion_minus1_b": [[146,49,397,638], [543,47,501,640], [1151,44,408,643], [1637,49,418,638]],
|
||||||
|
"kirara_emotion_minus3_a": [[52,61,427,644], [534,71,495,634], [1081,61,474,644], [1555,67,476,638]],
|
||||||
|
"kirara_emotion_minus3_b": [[52,27,450,665], [598,23,466,669], [1139,15,472,679], [1676,29,444,663]],
|
||||||
|
};
|
||||||
|
const kiraraSpriteAsset = name => ({
|
||||||
|
src: `/uploads/${name}.png`, boxes: kiraraSpriteBoxes[name]
|
||||||
|
});
|
||||||
|
const kiraraWalkAssets = Object.fromEntries(
|
||||||
|
["front", "back", "left", "right"].map(direction =>
|
||||||
|
[direction, kiraraSpriteAsset(`kirara_walk_${direction}`)])
|
||||||
|
);
|
||||||
|
const kiraraActionAssets = {
|
||||||
|
feed: [kiraraSpriteAsset("kirara_eat_rice"), kiraraSpriteAsset("kirara_eat_dango")],
|
||||||
|
bath: [kiraraSpriteAsset("kirara_bath_sponge"), kiraraSpriteAsset("kirara_bath_shower")]
|
||||||
|
};
|
||||||
|
const kiraraIdleAssets = Object.fromEntries(
|
||||||
|
["delivery", "reading", "phone", "sleep", "stretch",
|
||||||
|
"groom", "butterfly", "tea", "yarn", "humming"].map(type =>
|
||||||
|
[type, kiraraSpriteAsset(`kirara_idle_${type}`)])
|
||||||
|
);
|
||||||
|
const kiraraRestAsset = kiraraSpriteAsset("kirara_rest_breathe");
|
||||||
|
const kiraraEmotionAssets = {
|
||||||
|
"3": [kiraraSpriteAsset("kirara_emotion_plus3_a"), kiraraSpriteAsset("kirara_emotion_plus3_b")],
|
||||||
|
"1": [kiraraSpriteAsset("kirara_emotion_plus1_a"), kiraraSpriteAsset("kirara_emotion_plus1_b")],
|
||||||
|
"-1": [kiraraSpriteAsset("kirara_emotion_minus1_a"), kiraraSpriteAsset("kirara_emotion_minus1_b")],
|
||||||
|
"-3": [kiraraSpriteAsset("kirara_emotion_minus3_a"), kiraraSpriteAsset("kirara_emotion_minus3_b")],
|
||||||
|
};
|
||||||
|
let kiraraDirection = "front";
|
||||||
|
let kiraraDrawnFrameKey = "";
|
||||||
|
const kiraraActionQueue = [];
|
||||||
|
const kiraraActionVariant = {feed: 0, bath: 0};
|
||||||
|
let kiraraAction = null;
|
||||||
|
const kiraraIdleTypes = ["delivery", "reading", "phone", "stretch", "groom",
|
||||||
|
"butterfly", "tea", "yarn", "humming"];
|
||||||
|
let kiraraIdle = null;
|
||||||
|
let kiraraNextIdleAt = null;
|
||||||
|
let kiraraRestActive = false;
|
||||||
|
let kiraraRestStartedAt = 0;
|
||||||
|
const kiraraEmotionQueue = [];
|
||||||
|
const kiraraEmotionVariant = {"3": 0, "1": 0, "-1": 0, "-3": 0};
|
||||||
|
const kiraraEmotionSeen = new Set();
|
||||||
|
const kiraraEmotionSeenOrder = [];
|
||||||
|
const kiraraEmotionOpenedAtClientTs = Date.now() / 1000;
|
||||||
|
let kiraraEmotionBaseline = null;
|
||||||
|
let kiraraEmotionAction = null;
|
||||||
|
|
||||||
|
function drawKiraraSpriteFrame(asset, frame, key, action) {
|
||||||
|
if (!asset.image?.complete || !asset.image.naturalWidth || key === kiraraDrawnFrameKey) return;
|
||||||
|
const box = asset.boxes[frame];
|
||||||
|
if (!box) return;
|
||||||
|
// 源矩形越界夹紧:部分浏览器对越界 drawImage 会整帧不绘制,
|
||||||
|
// 配合 clearRect 会表现为"桌宠消失"。
|
||||||
|
let [sx, sy, sw, sh] = box;
|
||||||
|
const maxX = asset.image.naturalWidth, maxY = asset.image.naturalHeight;
|
||||||
|
if (sx < 0) { sw += sx; sx = 0; }
|
||||||
|
if (sy < 0) { sh += sy; sy = 0; }
|
||||||
|
if (sx + sw > maxX) sw = maxX - sx;
|
||||||
|
if (sy + sh > maxY) sh = maxY - sy;
|
||||||
|
if (sw <= 0 || sh <= 0) return;
|
||||||
|
try {
|
||||||
|
const canvas = document.getElementById("kirara-sprite");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, 126, 140);
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
const scale = Math.min(
|
||||||
|
130 / Math.max(...asset.boxes.map(box => box[3])),
|
||||||
|
120 / Math.max(...asset.boxes.map(box => box[2]))
|
||||||
|
);
|
||||||
|
const width = sw * scale;
|
||||||
|
const height = sh * scale;
|
||||||
|
ctx.drawImage(asset.image, sx, sy, sw, sh,
|
||||||
|
(126 - width) / 2, 136 - height, width, height);
|
||||||
|
canvas.dataset.action = action;
|
||||||
|
canvas.dataset.direction = action ? "front" : kiraraDirection;
|
||||||
|
canvas.dataset.frame = String(frame);
|
||||||
|
kiraraDrawnFrameKey = key;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("kirara draw", e);
|
||||||
|
window.__kiraraError = String(e && e.stack || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawKiraraWalkFrame(direction, frame) {
|
||||||
|
drawKiraraSpriteFrame(kiraraWalkAssets[direction], frame,
|
||||||
|
"walk:" + direction + ":" + frame, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawKiraraActionFrame(action, frame) {
|
||||||
|
drawKiraraSpriteFrame(kiraraActionAssets[action.type][action.variant], frame,
|
||||||
|
"action:" + action.type + ":" + action.variant + ":" + frame, action.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawKiraraIdleFrame(idle, phase) {
|
||||||
|
drawKiraraSpriteFrame(kiraraIdleAssets[idle.type], phase,
|
||||||
|
"idle:" + idle.type + ":" + phase, idle.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawKiraraSleepFrame(phase) {
|
||||||
|
drawKiraraSpriteFrame(kiraraIdleAssets.sleep, phase,
|
||||||
|
"sleep:" + phase, "sleep");
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawKiraraRestFrame(phase) {
|
||||||
|
drawKiraraSpriteFrame(kiraraRestAsset, phase,
|
||||||
|
"rest:" + phase, "rest");
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawKiraraEmotionFrame(action, frame) {
|
||||||
|
drawKiraraSpriteFrame(kiraraEmotionAssets[String(action.delta)][action.variant], frame,
|
||||||
|
"emotion:" + action.delta + ":" + action.variant + ":" + frame,
|
||||||
|
"emotion");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [direction, asset] of Object.entries(kiraraWalkAssets)) {
|
||||||
|
asset.image = new Image();
|
||||||
|
asset.image.onload = () => {
|
||||||
|
if (!kiraraSleeping && !kiraraIdle && !kiraraAction && !kiraraEmotionAction && kiraraDirection === direction) {
|
||||||
|
kiraraDrawnFrameKey = "";
|
||||||
|
drawKiraraWalkFrame(direction, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
asset.image.src = asset.src;
|
||||||
|
}
|
||||||
|
for (const [type, variants] of Object.entries(kiraraActionAssets)) {
|
||||||
|
for (const [variant, asset] of variants.entries()) {
|
||||||
|
asset.image = new Image();
|
||||||
|
asset.image.onload = () => {
|
||||||
|
if (!kiraraSleeping && kiraraAction?.type === type && kiraraAction.variant === variant) {
|
||||||
|
kiraraDrawnFrameKey = "";
|
||||||
|
drawKiraraActionFrame(kiraraAction, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
asset.image.src = asset.src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [type, asset] of Object.entries(kiraraIdleAssets)) {
|
||||||
|
asset.image = new Image();
|
||||||
|
asset.image.onload = () => {
|
||||||
|
if (!kiraraAction && !kiraraEmotionAction && ((type === "sleep" && kiraraSleeping) ||
|
||||||
|
(!kiraraSleeping && kiraraIdle?.type === type))) {
|
||||||
|
kiraraDrawnFrameKey = "";
|
||||||
|
if (type === "sleep") drawKiraraSleepFrame(0);
|
||||||
|
else drawKiraraIdleFrame(kiraraIdle, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
asset.image.src = asset.src;
|
||||||
|
}
|
||||||
|
kiraraRestAsset.image = new Image();
|
||||||
|
kiraraRestAsset.image.onload = () => {
|
||||||
|
if (kiraraRestActive && !kiraraSleeping && !kiraraIdle &&
|
||||||
|
!kiraraAction && !kiraraEmotionAction) {
|
||||||
|
kiraraDrawnFrameKey = "";
|
||||||
|
drawKiraraRestFrame(Math.floor((performance.now() - kiraraRestStartedAt) / 900) % 4);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
kiraraRestAsset.image.src = kiraraRestAsset.src;
|
||||||
|
for (const [delta, variants] of Object.entries(kiraraEmotionAssets)) {
|
||||||
|
for (const [variant, asset] of variants.entries()) {
|
||||||
|
asset.image = new Image();
|
||||||
|
asset.image.onload = () => {
|
||||||
|
if (!kiraraAction && kiraraEmotionAction?.delta === Number(delta) &&
|
||||||
|
kiraraEmotionAction.variant === variant) {
|
||||||
|
kiraraDrawnFrameKey = "";
|
||||||
|
drawKiraraEmotionFrame(kiraraEmotionAction, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
asset.image.src = asset.src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以完整桌宠(含状态环)的外接矩形规划路径。上、右、下三条通道
|
||||||
|
// 均与游戏镂空矩形保持至少 16px 间距,转弯经过通道交叠处。
|
||||||
|
const kiraraPosition = {x: 1450, y: 590};
|
||||||
|
const kiraraWaypoints = [];
|
||||||
|
let kiraraSpeed = 25;
|
||||||
|
let kiraraNextWalkAt = null;
|
||||||
|
let kiraraWalkStartedAt = 0;
|
||||||
|
let kiraraWalkEndsAt = 0;
|
||||||
|
let kiraraLastFrame = 0;
|
||||||
|
let kiraraStepDistance = 0;
|
||||||
|
|
||||||
|
function kiraraRandom(min, max) {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kiraraPoint(zone) {
|
||||||
|
if (zone === "top") return {x: kiraraRandom(16, 1682), y: kiraraRandom(16, 54)};
|
||||||
|
if (zone === "bottom") return {x: kiraraRandom(16, 1682), y: kiraraRandom(866, 924)};
|
||||||
|
return {x: kiraraRandom(1306, 1682), y: kiraraRandom(16, 924)};
|
||||||
|
}
|
||||||
|
|
||||||
|
function kiraraJunction(edge) {
|
||||||
|
return {x: kiraraRandom(1306, 1682),
|
||||||
|
y: edge === "top" ? kiraraRandom(16, 54) : kiraraRandom(866, 924)};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendKiraraOrthogonalPoint(point) {
|
||||||
|
const from = kiraraWaypoints[kiraraWaypoints.length - 1] || kiraraPosition;
|
||||||
|
if (Math.abs(point.x - from.x) > 0.01 && Math.abs(point.y - from.y) > 0.01) {
|
||||||
|
kiraraWaypoints.push(Math.random() < 0.5
|
||||||
|
? {x: point.x, y: from.y}
|
||||||
|
: {x: from.x, y: point.y});
|
||||||
|
}
|
||||||
|
kiraraWaypoints.push(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseKiraraRoute() {
|
||||||
|
const kiraraZone = kiraraPosition.x >= 1306 ? "right"
|
||||||
|
: kiraraPosition.y <= 54 ? "top" : "bottom";
|
||||||
|
const zones = ["top", "right", "bottom"];
|
||||||
|
const destination = zones[Math.floor(Math.random() * zones.length)];
|
||||||
|
if (destination !== kiraraZone) {
|
||||||
|
if (kiraraZone !== "right" && destination !== "right") {
|
||||||
|
appendKiraraOrthogonalPoint(kiraraJunction(kiraraZone));
|
||||||
|
appendKiraraOrthogonalPoint(kiraraJunction(destination));
|
||||||
|
} else {
|
||||||
|
appendKiraraOrthogonalPoint(kiraraJunction(
|
||||||
|
kiraraZone === "right" ? destination : kiraraZone));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendKiraraOrthogonalPoint(kiraraPoint(destination));
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceKirara(distanceBudget) {
|
||||||
|
// 一帧只走一条水平或垂直路段,转角处不出现斜向位移。
|
||||||
|
for (let i = 0; distanceBudget > 0.01 && i < 64; i++) {
|
||||||
|
if (kiraraWaypoints.length === 0) chooseKiraraRoute();
|
||||||
|
const next = kiraraWaypoints[0];
|
||||||
|
const dx = next.x - kiraraPosition.x;
|
||||||
|
const dy = next.y - kiraraPosition.y;
|
||||||
|
const distance = Math.abs(dx) + Math.abs(dy);
|
||||||
|
if (distance <= 0.01) {
|
||||||
|
kiraraWaypoints.shift();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kiraraDirection = Math.abs(dx) > 0.01
|
||||||
|
? (dx > 0 ? "right" : "left")
|
||||||
|
: (dy > 0 ? "front" : "back");
|
||||||
|
if (distance <= distanceBudget) {
|
||||||
|
kiraraPosition.x = next.x;
|
||||||
|
kiraraPosition.y = next.y;
|
||||||
|
kiraraStepDistance += distance;
|
||||||
|
kiraraWaypoints.shift();
|
||||||
|
} else {
|
||||||
|
if (Math.abs(dx) > 0.01) kiraraPosition.x += Math.sign(dx) * distanceBudget;
|
||||||
|
else kiraraPosition.y += Math.sign(dy) * distanceBudget;
|
||||||
|
kiraraStepDistance += distanceBudget;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueKiraraGiftAnimations(gift) {
|
||||||
|
// 动画按礼物名义恢复量计算;数值满 100 时照样播放。
|
||||||
|
for (const [type, field] of [["feed", "pet_animation_stamina"], ["bath", "pet_animation_cleanliness"]]) {
|
||||||
|
const raw = Number(gift[field]);
|
||||||
|
const points = Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0;
|
||||||
|
if (points > 0) kiraraActionQueue.push({type, remaining: points});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncKiraraEmotionEvents(events, serverTs) {
|
||||||
|
if (!Array.isArray(events)) return;
|
||||||
|
if (kiraraEmotionBaseline === null) {
|
||||||
|
const serverNow = Number(serverTs);
|
||||||
|
const clientNow = Date.now() / 1000;
|
||||||
|
kiraraEmotionBaseline = Number.isFinite(serverNow) && serverNow > 0
|
||||||
|
? serverNow - (clientNow - kiraraEmotionOpenedAtClientTs)
|
||||||
|
: kiraraEmotionOpenedAtClientTs;
|
||||||
|
}
|
||||||
|
for (const event of events) {
|
||||||
|
const id = String(event.id || "");
|
||||||
|
const delta = Number(event.delta);
|
||||||
|
if (!id || !kiraraEmotionAssets[String(delta)] ||
|
||||||
|
Number(event.ts) <= kiraraEmotionBaseline || kiraraEmotionSeen.has(id)) continue;
|
||||||
|
kiraraEmotionSeen.add(id);
|
||||||
|
kiraraEmotionSeenOrder.push(id);
|
||||||
|
if (kiraraEmotionSeenOrder.length > 128) {
|
||||||
|
kiraraEmotionSeen.delete(kiraraEmotionSeenOrder.shift());
|
||||||
|
}
|
||||||
|
kiraraEmotionQueue.push({delta});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKiraraEmotionAction(now) {
|
||||||
|
if (!kiraraEmotionAction && kiraraEmotionQueue.length) {
|
||||||
|
const job = kiraraEmotionQueue.shift();
|
||||||
|
kiraraWalkEndsAt = 0;
|
||||||
|
kiraraWaypoints.length = 0;
|
||||||
|
kiraraIdle = null;
|
||||||
|
kiraraDirection = "front";
|
||||||
|
kiraraEmotionAction = {
|
||||||
|
delta: job.delta,
|
||||||
|
startedAt: now,
|
||||||
|
endsAt: now + 3000
|
||||||
|
};
|
||||||
|
const key = String(kiraraEmotionAction.delta);
|
||||||
|
kiraraEmotionAction.variant = job.variant ?? kiraraEmotionVariant[key]++ % 2;
|
||||||
|
}
|
||||||
|
if (kiraraEmotionAction && now >= kiraraEmotionAction.endsAt) {
|
||||||
|
kiraraEmotionAction = null;
|
||||||
|
kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
|
||||||
|
kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
|
||||||
|
return updateKiraraEmotionAction(now);
|
||||||
|
}
|
||||||
|
return kiraraEmotionAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextKiraraAction(startedAt) {
|
||||||
|
const job = kiraraActionQueue[0];
|
||||||
|
if (!job) return null;
|
||||||
|
job.remaining--;
|
||||||
|
if (job.remaining === 0) kiraraActionQueue.shift();
|
||||||
|
const variant = kiraraActionVariant[job.type]++ % 2;
|
||||||
|
return {type: job.type, variant, startedAt, endsAt: startedAt + 5000};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKiraraAction(now) {
|
||||||
|
if (!kiraraAction && kiraraActionQueue.length) {
|
||||||
|
if (kiraraEmotionAction) {
|
||||||
|
kiraraEmotionQueue.unshift({
|
||||||
|
delta: kiraraEmotionAction.delta,
|
||||||
|
variant: kiraraEmotionAction.variant
|
||||||
|
});
|
||||||
|
kiraraEmotionAction = null;
|
||||||
|
}
|
||||||
|
kiraraWalkEndsAt = 0;
|
||||||
|
kiraraWaypoints.length = 0;
|
||||||
|
kiraraIdle = null;
|
||||||
|
kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
|
||||||
|
kiraraDirection = "front";
|
||||||
|
kiraraAction = nextKiraraAction(now);
|
||||||
|
}
|
||||||
|
while (kiraraAction && now >= kiraraAction.endsAt) {
|
||||||
|
if (kiraraActionQueue.length) {
|
||||||
|
kiraraAction = nextKiraraAction(kiraraAction.endsAt);
|
||||||
|
} else {
|
||||||
|
kiraraAction = null;
|
||||||
|
kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kiraraAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateKirara(now) {
|
||||||
|
// 任何一帧异常都不允许杀死渲染循环(否则桌宠永久消失)。
|
||||||
|
try {
|
||||||
|
updateKiraraNeedBubble(now);
|
||||||
|
const action = updateKiraraAction(now);
|
||||||
|
if (action) {
|
||||||
|
kiraraRestActive = false;
|
||||||
|
kiraraLastFrame = now;
|
||||||
|
document.getElementById("kirara-wanderer").style.transform =
|
||||||
|
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
|
||||||
|
const bob = -Math.abs(Math.sin((now - action.startedAt) / 500 * Math.PI)) * 2;
|
||||||
|
document.getElementById("kirara-sprite").style.transform =
|
||||||
|
`translateY(${bob.toFixed(1)}px)`;
|
||||||
|
drawKiraraActionFrame(action, Math.floor((now - action.startedAt) / 260) % 4);
|
||||||
|
requestAnimationFrame(animateKirara);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const emotionAction = updateKiraraEmotionAction(now);
|
||||||
|
if (emotionAction) {
|
||||||
|
kiraraRestActive = false;
|
||||||
|
kiraraLastFrame = now;
|
||||||
|
document.getElementById("kirara-wanderer").style.transform =
|
||||||
|
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
|
||||||
|
document.getElementById("kirara-sprite").style.transform = "none";
|
||||||
|
drawKiraraEmotionFrame(emotionAction,
|
||||||
|
Math.min(3, Math.floor((now - emotionAction.startedAt) / 650)));
|
||||||
|
requestAnimationFrame(animateKirara);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kiraraSleeping) {
|
||||||
|
kiraraRestActive = false;
|
||||||
|
kiraraLastFrame = now;
|
||||||
|
document.getElementById("kirara-wanderer").style.transform =
|
||||||
|
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
|
||||||
|
document.getElementById("kirara-sprite").style.transform = "none";
|
||||||
|
drawKiraraSleepFrame(Math.floor((now - kiraraSleepStartedAt) / 650) % 4);
|
||||||
|
requestAnimationFrame(animateKirara);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kiraraNextIdleAt === null) kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
|
||||||
|
if (kiraraIdle && now >= kiraraIdle.endsAt) {
|
||||||
|
kiraraIdle = null;
|
||||||
|
kiraraDirection = "front";
|
||||||
|
}
|
||||||
|
if (!kiraraIdle && now >= kiraraNextIdleAt) {
|
||||||
|
kiraraIdle = {
|
||||||
|
type: kiraraIdleTypes[Math.floor(Math.random() * kiraraIdleTypes.length)],
|
||||||
|
startedAt: now,
|
||||||
|
endsAt: now + kiraraRandom(15000, 20000)
|
||||||
|
};
|
||||||
|
kiraraNextIdleAt = now + kiraraRandom(120000, 150000);
|
||||||
|
kiraraWalkEndsAt = 0;
|
||||||
|
kiraraWaypoints.length = 0;
|
||||||
|
kiraraDirection = "front";
|
||||||
|
}
|
||||||
|
if (kiraraIdle) {
|
||||||
|
kiraraRestActive = false;
|
||||||
|
kiraraLastFrame = now;
|
||||||
|
document.getElementById("kirara-wanderer").style.transform =
|
||||||
|
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
|
||||||
|
document.getElementById("kirara-sprite").style.transform = "none";
|
||||||
|
drawKiraraIdleFrame(kiraraIdle,
|
||||||
|
Math.floor((now - kiraraIdle.startedAt) / 360) % 4);
|
||||||
|
requestAnimationFrame(animateKirara);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (kiraraNextWalkAt === null) kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
|
||||||
|
if (now >= kiraraNextWalkAt) {
|
||||||
|
kiraraWalkStartedAt = now;
|
||||||
|
kiraraWalkEndsAt = now + kiraraRandom(10000, 15000);
|
||||||
|
kiraraNextWalkAt = now + kiraraRandom(60000, 90000);
|
||||||
|
kiraraSpeed = kiraraRandom(21, 35); // 原先 42–70 px/s 的一半
|
||||||
|
}
|
||||||
|
if (kiraraWalkEndsAt) {
|
||||||
|
const activeUntil = Math.min(now, kiraraWalkEndsAt);
|
||||||
|
const elapsed = Math.max(0, activeUntil - Math.max(kiraraLastFrame, kiraraWalkStartedAt)) / 1000;
|
||||||
|
advanceKirara(kiraraSpeed * elapsed);
|
||||||
|
}
|
||||||
|
const moving = now < kiraraWalkEndsAt;
|
||||||
|
if (!moving && kiraraWalkEndsAt) {
|
||||||
|
kiraraWalkEndsAt = 0;
|
||||||
|
kiraraWaypoints.length = 0;
|
||||||
|
}
|
||||||
|
if (!moving) kiraraDirection = "front";
|
||||||
|
if (moving) kiraraRestActive = false;
|
||||||
|
else if (!kiraraRestActive) {
|
||||||
|
kiraraRestActive = true;
|
||||||
|
kiraraRestStartedAt = now;
|
||||||
|
}
|
||||||
|
kiraraLastFrame = now;
|
||||||
|
document.getElementById("kirara-wanderer").style.transform =
|
||||||
|
`translate3d(${Math.round(kiraraPosition.x)}px, ${Math.round(kiraraPosition.y)}px, 0)`;
|
||||||
|
const bob = moving ? -Math.abs(Math.sin(kiraraStepDistance / 10 * Math.PI)) * 3 : 0;
|
||||||
|
document.getElementById("kirara-sprite").style.transform =
|
||||||
|
`translateY(${bob.toFixed(1)}px)`;
|
||||||
|
if (moving) {
|
||||||
|
drawKiraraWalkFrame(kiraraDirection, Math.floor(kiraraStepDistance / 5) % 4);
|
||||||
|
} else if (kiraraRestAsset.image.complete && kiraraRestAsset.image.naturalWidth) {
|
||||||
|
drawKiraraRestFrame(Math.floor((now - kiraraRestStartedAt) / 900) % 4);
|
||||||
|
} else {
|
||||||
|
drawKiraraWalkFrame("front", 1);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("kirara anim", e);
|
||||||
|
window.__kiraraError = String(e && e.stack || e);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(animateKirara);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchState() {
|
async function fetchState() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/state");
|
const r = await fetch("/api/state");
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
renderTask(d.current_group, d.group_start_time, d.current_admin, d.users, d.default_running, d.bgi_running, d.current_group_uses_nahida_collect);
|
renderTask(d.current_group, d.group_start_time, d.current_admin, d.users, d.default_running, d.bgi_running, d.current_group_uses_nahida_collect);
|
||||||
renderQueue(d.queue, d.users, d.current_admin);
|
renderQueue(d.queue, d.users, d.current_admin);
|
||||||
initGiftFxBaseline(d.recent_gifts);
|
initGiftFxBaseline(d.server_ts);
|
||||||
giftFx.sync(d.recent_gifts);
|
giftFx.sync(d.recent_gifts);
|
||||||
|
renderKirara(d.pet);
|
||||||
|
syncKiraraEmotionEvents(d.pet_emotion_events, d.server_ts);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("state", e);
|
console.error("state", e);
|
||||||
}
|
}
|
||||||
@@ -2344,8 +3138,10 @@
|
|||||||
themeClasses.forEach((c) => document.body.classList.remove(c));
|
themeClasses.forEach((c) => document.body.classList.remove(c));
|
||||||
const theme = cfg.theme || "classic";
|
const theme = cfg.theme || "classic";
|
||||||
if (theme && theme !== "classic") document.body.classList.add("theme-" + theme);
|
if (theme && theme !== "classic") document.body.classList.add("theme-" + theme);
|
||||||
|
applyCommandHints(cfg.command_hints);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("frontend-config", e);
|
console.error("frontend-config", e);
|
||||||
|
applyCommandHints(fallbackCommandHints);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2406,17 +3202,16 @@
|
|||||||
const noteEl = document.getElementById("music-note");
|
const noteEl = document.getElementById("music-note");
|
||||||
const listEl = document.getElementById("playlist");
|
const listEl = document.getElementById("playlist");
|
||||||
|
|
||||||
const monitor = musicState.monitor || {};
|
// 以 mpv 播放器真实状态为准(已移除 SMTC 系统媒体监听):
|
||||||
const updatedAtMs = Number(monitor.updated_at || 0) * 1000;
|
// idle 或没有曲目时为“未播放”,不再显示“音乐源离线 / SMTC”。
|
||||||
const monitorStale = !updatedAtMs || Date.now() - updatedAtMs > 10000;
|
const isIdle = !!musicState.idle || !cur.title || cur.title === "暂无歌曲";
|
||||||
const monitorOffline = monitor.online === false || monitorStale;
|
|
||||||
|
|
||||||
if (monitorOffline) {
|
if (isIdle) {
|
||||||
titleEl.textContent = "音乐源离线";
|
titleEl.textContent = "未播放";
|
||||||
artistEl.textContent = "等待网易云 SMTC 状态";
|
artistEl.textContent = "等待点歌或背景歌单";
|
||||||
coverEl.innerHTML = "♪";
|
coverEl.innerHTML = "♪";
|
||||||
lastCoverKey = "";
|
lastCoverKey = "";
|
||||||
noteEl.textContent = "监听未运行或状态已过期";
|
noteEl.textContent = "点歌或开启背景歌单后将自动播放";
|
||||||
} else if (!cur.title && !cur.artist) {
|
} else if (!cur.title && !cur.artist) {
|
||||||
titleEl.textContent = "未配置";
|
titleEl.textContent = "未配置";
|
||||||
artistEl.textContent = "等待数据源";
|
artistEl.textContent = "等待数据源";
|
||||||
@@ -2468,6 +3263,8 @@
|
|||||||
particles: [],
|
particles: [],
|
||||||
rafId: null,
|
rafId: null,
|
||||||
lastTs: 0,
|
lastTs: 0,
|
||||||
|
baselineInitialized: false,
|
||||||
|
openedAtClientTs: Date.now() / 1000,
|
||||||
bannerTimer: null,
|
bannerTimer: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
playing: false,
|
playing: false,
|
||||||
@@ -2633,24 +3430,37 @@
|
|||||||
const fresh = gifts.filter(g => Number(g.ts || 0) > this.lastTs);
|
const fresh = gifts.filter(g => Number(g.ts || 0) > this.lastTs);
|
||||||
if (fresh.length === 0) return;
|
if (fresh.length === 0) return;
|
||||||
this.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
|
this.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
|
||||||
for (const g of fresh) this.enqueue(g);
|
for (const g of fresh) {
|
||||||
|
this.enqueue(g);
|
||||||
|
queueKiraraGiftAnimations(g);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 首次加载时把已有礼物的最大 ts 设为基线,避免刷新页面重放旧礼物
|
// 用页面打开时刻作为一次性基线:跳过刷新前的礼物,同时保留打开后的首件礼物。
|
||||||
function initGiftFxBaseline(gifts) {
|
function initGiftFxBaseline(serverTs) {
|
||||||
if (giftFx.lastTs > 0 || !Array.isArray(gifts) || gifts.length === 0) return;
|
if (giftFx.baselineInitialized) return;
|
||||||
giftFx.lastTs = Math.max(...gifts.map(g => Number(g.ts || 0)));
|
const serverNow = Number(serverTs);
|
||||||
|
const clientNow = Date.now() / 1000;
|
||||||
|
giftFx.lastTs = Number.isFinite(serverNow) && serverNow > 0
|
||||||
|
? serverNow - (clientNow - giftFx.openedAtClientTs)
|
||||||
|
: giftFx.openedAtClientTs;
|
||||||
|
giftFx.baselineInitialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function scaleCanvas() {
|
function scaleCanvas() {
|
||||||
const canvas = document.getElementById("canvas");
|
const canvas = document.getElementById("canvas");
|
||||||
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
|
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080);
|
||||||
canvas.style.transform = "scale(" + scale + ")";
|
const offsetX = Math.max(0, (window.innerWidth - 1920 * scale) / 2);
|
||||||
|
const offsetY = Math.max(0, (window.innerHeight - 1080 * scale) / 2);
|
||||||
|
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyCommandHints(fallbackCommandHints);
|
||||||
startTutorialCarousel();
|
startTutorialCarousel();
|
||||||
|
renderKirara({stamina: 100, cleanliness: 100, mood: 100});
|
||||||
|
requestAnimationFrame(animateKirara);
|
||||||
fetchFrontendConfig();
|
fetchFrontendConfig();
|
||||||
fetchState();
|
fetchState();
|
||||||
fetchLog();
|
fetchLog();
|
||||||
|
|||||||
@@ -386,5 +386,17 @@
|
|||||||
{
|
{
|
||||||
"name": "霜仙花",
|
"name": "霜仙花",
|
||||||
"image": "/uploads/group_霜仙花.webp"
|
"image": "/uploads/group_霜仙花.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "植绒草",
|
||||||
|
"image": "/uploads/group_植绒草.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "金蕨",
|
||||||
|
"image": "/uploads/group_金蕨.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "冰醋栗",
|
||||||
|
"image": "/uploads/group_冰醋栗.webp"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user