合并外部维护版本并同步自动每日功能

This commit is contained in:
2026-09-28 13:55:07 +08:00
parent 2d81615f78
commit 9040bcb5e5
59 changed files with 3176 additions and 179 deletions
+797 -131
View File
File diff suppressed because it is too large Load Diff
+92 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import gc
import io
import multiprocessing
import os
@@ -18,6 +19,8 @@ DEFAULT_STARTUP_TIMEOUT_SECONDS = 900.0
# 启动失败后再次拉起 worker 的最小间隔:避免"启动超时→立即重启→再超时"的死循环
# 在系统高负载时持续加载 torch/CUDA,进一步加剧卡顿。
STARTUP_FAILURE_BACKOFF_SECONDS = 60.0
# 每 N 次合成才做一次显存回收(empty_cache 会 stall 共用 GPU 的游戏/编码)
FREE_GPU_EVERY_N_SYNTH = 50
DEFAULT_CPU_THREADS = 4
DEFAULT_CPU_AFFINITY_COUNT = 8
DEFAULT_PROCESS_PRIORITY = "below_normal"
@@ -207,6 +210,68 @@ def _synthesize_wav(runtime: dict[str, Any], text: str) -> bytes:
return output.getvalue()
def _collect_memory_stats(runtime: dict[str, Any]) -> dict[str, float]:
"""采集 worker 进程的内存/显存占用,用于定位长跑性能退化(泄漏 vs 碎片化)。"""
stats: dict[str, float] = {}
torch = runtime.get("torch")
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:
@@ -219,6 +284,12 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
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)
@@ -282,6 +353,7 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
connection.close()
return
synth_count = 0
while True:
try:
message = connection.recv()
@@ -297,17 +369,36 @@ def faster_qwen_worker_main(connection: Connection, settings: dict[str, Any]) ->
request_id = str(message.get("request_id") or "")
started = time.monotonic()
memory_log_path = str(settings.get("memory_log_path") or "")
try:
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({
"type": "result",
"request_id": request_id,
"audio": audio,
"duration_ms": int((time.monotonic() - started) * 1000),
"duration_ms": duration_ms,
"bytes": len(audio),
"max_new_tokens": MAX_NEW_TOKENS,
"mem": mem,
})
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:
connection.send({
"type": "error",
+397
View File
@@ -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
View File
@@ -165,12 +165,6 @@ async def _run_queue(host: str, port: int):
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():
import tts_monitor
@@ -204,8 +198,11 @@ async def _run_all(host: str, port: int):
# Refuse stale/conflicting listeners before creating any helper process.
_assert_port_available(host, port)
queue_task = asyncio.create_task(_run_queue(host, port), name="queue-main")
children.append(_spawn_role("music", port, host, visible=False))
children.append(_spawn_role("tts", port, host, visible=True))
# pythonw 无窗口模式下 TTS 监控窗无法显示内容(stdout 为 None),
# 且其每秒的 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:
job.assign(child)
await queue_task
@@ -222,7 +219,7 @@ async def _run_all(host: str, port: int):
def main():
ensure_runtime_dirs()
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("--host", default="0.0.0.0", help="Web service bind address")
args = parser.parse_args()
@@ -238,9 +235,6 @@ def main():
if args.role == "tts":
_run_tts_monitor()
return 0
if args.role == "music":
asyncio.run(_run_music(args.port))
return 0
if args.role == "queue":
asyncio.run(_run_queue(args.host, args.port))
return 0
+142
View File
@@ -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)
+24
View File
@@ -208,6 +208,30 @@ class RedemptionCodeStore:
if starts_utc >= ends_utc:
raise ValueError("失效时间必须晚于生效时间")
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:
cursor = connection.execute(
"""INSERT INTO redemption_codes (
+3
View File
@@ -20,6 +20,9 @@ from core.runtime_paths import DATA_DIR, ensure_runtime_dirs
def clear():
# 无控制台(pythonw)时执行 cls 会不断弹出新 cmd 窗口,直接跳过。
if sys.stdout is None:
return
os.system("cls" if os.name == "nt" else "clear")