diff --git a/gm_platform.py b/gm_platform.py
new file mode 100644
index 0000000..1a9ff0b
--- /dev/null
+++ b/gm_platform.py
@@ -0,0 +1,573 @@
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import secrets
+import sqlite3
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+from flask import Flask, flash, redirect, render_template_string, request, session, url_for
+
+
+WIN_ENDING_STATES = {"escaped", "sacrifice", "pact", "victory", "success", "win", "custom_end"}
+LEVELS = {"bronze", "silver", "gold", "platinum", "diamond"}
+
+
+@dataclass
+class UserStats:
+ plays: int
+ wins: int
+ total_rounds: int
+ best_streak: int
+
+
+def utc_now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def to_int(value: Any, default: int = 0, minimum: int = 0) -> int:
+ try:
+ out = int(value)
+ except (TypeError, ValueError):
+ out = default
+ return max(minimum, out)
+
+
+def normalize_level(value: Any) -> str:
+ text = str(value or "").strip().lower()
+ alias = {
+ "青铜": "bronze",
+ "白银": "silver",
+ "黄金": "gold",
+ "铂金": "platinum",
+ "钻石": "diamond",
+ }
+ text = alias.get(text, text)
+ if text in LEVELS:
+ return text
+ return "bronze"
+
+
+def ensure_schema(conn: sqlite3.Connection) -> None:
+ conn.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS gm_user_stat_overrides (
+ user_id TEXT PRIMARY KEY,
+ played_games INTEGER NOT NULL DEFAULT 0,
+ wins INTEGER NOT NULL DEFAULT 0,
+ total_rounds INTEGER NOT NULL DEFAULT 0,
+ best_streak INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS gm_user_achievement_overrides (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ title TEXT NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ level TEXT NOT NULL DEFAULT 'bronze',
+ source TEXT NOT NULL DEFAULT 'gm',
+ card_id TEXT NOT NULL DEFAULT '',
+ card_title TEXT NOT NULL DEFAULT '',
+ unlocked_at TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_gm_achievement_user
+ ON gm_user_achievement_overrides(user_id, unlocked_at DESC);
+ """
+ )
+ conn.commit()
+
+
+def create_app(db_path: str, gm_password: str) -> Flask:
+ app = Flask(__name__)
+ app.secret_key = os.environ.get("GM_SECRET_KEY", secrets.token_hex(16))
+ app.config["DB_PATH"] = db_path
+ app.config["GM_PASSWORD"] = gm_password
+
+ def connect() -> sqlite3.Connection:
+ conn = sqlite3.connect(app.config["DB_PATH"], check_same_thread=False)
+ conn.row_factory = sqlite3.Row
+ ensure_schema(conn)
+ return conn
+
+ def is_logged_in() -> bool:
+ return bool(session.get("gm_authed"))
+
+ def require_login() -> bool:
+ if is_logged_in():
+ return True
+ return False
+
+ def load_user(user_id: str) -> dict[str, Any] | None:
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id:
+ return None
+ with connect() as conn:
+ row = conn.execute(
+ """
+ SELECT user_id, username, display_name, role, updated_at
+ FROM users
+ WHERE user_id=?
+ """,
+ (safe_user_id,),
+ ).fetchone()
+ return dict(row) if row else None
+
+ def compute_base_stats(user_id: str) -> UserStats:
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id:
+ return UserStats(0, 0, 0, 0)
+ with connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT r.game_state, r.updated_at
+ FROM rooms r
+ JOIN players p ON p.room_id = r.room_id
+ WHERE p.user_id=? AND r.status='ended'
+ GROUP BY r.room_id
+ ORDER BY r.updated_at ASC
+ """,
+ (safe_user_id,),
+ ).fetchall()
+ plays = 0
+ wins = 0
+ total_rounds = 0
+ current_streak = 0
+ best_streak = 0
+ for row in rows:
+ item = dict(row)
+ state_raw = item.get("game_state")
+ try:
+ state = json.loads(state_raw) if isinstance(state_raw, str) else {}
+ except json.JSONDecodeError:
+ state = {}
+ if not isinstance(state, dict):
+ state = {}
+ plays += 1
+ ending_state = str(state.get("ending_state", "")).strip().lower()
+ is_win = ending_state in WIN_ENDING_STATES
+ if is_win:
+ wins += 1
+ current_streak += 1
+ best_streak = max(best_streak, current_streak)
+ else:
+ current_streak = 0
+ total_rounds += max(0, to_int(state.get("round_no", 0)))
+ return UserStats(plays, wins, total_rounds, best_streak)
+
+ def load_stat_override(user_id: str) -> UserStats:
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id:
+ return UserStats(0, 0, 0, 0)
+ with connect() as conn:
+ row = conn.execute(
+ """
+ SELECT played_games, wins, total_rounds, best_streak
+ FROM gm_user_stat_overrides
+ WHERE user_id=?
+ """,
+ (safe_user_id,),
+ ).fetchone()
+ if not row:
+ return UserStats(0, 0, 0, 0)
+ item = dict(row)
+ return UserStats(
+ plays=max(0, to_int(item.get("played_games", 0))),
+ wins=max(0, to_int(item.get("wins", 0))),
+ total_rounds=max(0, to_int(item.get("total_rounds", 0))),
+ best_streak=max(0, to_int(item.get("best_streak", 0))),
+ )
+
+ def load_achievement_overrides(user_id: str) -> list[dict[str, Any]]:
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id:
+ return []
+ with connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, title, name, description, level, source, card_title, unlocked_at, updated_at
+ FROM gm_user_achievement_overrides
+ WHERE user_id=?
+ ORDER BY unlocked_at DESC, id DESC
+ LIMIT 200
+ """,
+ (safe_user_id,),
+ ).fetchall()
+ return [dict(row) for row in rows]
+
+ @app.get("/login")
+ def login_page() -> str:
+ if is_logged_in():
+ return redirect(url_for("home"))
+ return render_template_string(
+ """
+
+
+ GM 平台登录
+
+
+ GM 平台
+ 输入 GM 密码后可修改战绩和成就。
+
+ {% with messages = get_flashed_messages() %}
+ {% if messages %}{{ messages[0] }}
{% endif %}
+ {% endwith %}
+
+
+ """
+ )
+
+ @app.post("/login")
+ def login_action() -> Any:
+ password = str(request.form.get("password", ""))
+ if password != app.config["GM_PASSWORD"]:
+ flash("密码错误")
+ return redirect(url_for("login_page"))
+ session["gm_authed"] = True
+ return redirect(url_for("home"))
+
+ @app.get("/logout")
+ def logout_action() -> Any:
+ session.pop("gm_authed", None)
+ return redirect(url_for("login_page"))
+
+ @app.get("/")
+ def home() -> Any:
+ if not require_login():
+ return redirect(url_for("login_page"))
+ q = str(request.args.get("q", "")).strip()
+ with connect() as conn:
+ if q:
+ like = f"%{q}%"
+ rows = conn.execute(
+ """
+ SELECT user_id, username, display_name, role, updated_at
+ FROM users
+ WHERE username LIKE ? OR display_name LIKE ?
+ ORDER BY updated_at DESC
+ LIMIT 120
+ """,
+ (like, like),
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ """
+ SELECT user_id, username, display_name, role, updated_at
+ FROM users
+ ORDER BY updated_at DESC
+ LIMIT 80
+ """
+ ).fetchall()
+ users = [dict(row) for row in rows]
+ return render_template_string(
+ """
+
+
+ GM 平台
+
+
+
+
+
+ {% with messages = get_flashed_messages() %}
+ {% if messages %}{{ messages[0] }}
{% endif %}
+ {% endwith %}
+
+ {% for u in users %}
+ -
+ {{ u.username }}
+ · {{ u.display_name }} · {{ u.role }} · {{ u.updated_at }}
+
+ {% endfor %}
+ {% if not users %}- 没有找到用户
{% endif %}
+
+
+
+
+ """,
+ q=q,
+ users=users,
+ )
+
+ @app.get("/user/")
+ def user_detail(user_id: str) -> Any:
+ if not require_login():
+ return redirect(url_for("login_page"))
+ user = load_user(user_id)
+ if not user:
+ flash("用户不存在")
+ return redirect(url_for("home"))
+
+ base = compute_base_stats(user_id)
+ override = load_stat_override(user_id)
+ combined = UserStats(
+ plays=base.plays + override.plays,
+ wins=base.wins + override.wins,
+ total_rounds=base.total_rounds + override.total_rounds,
+ best_streak=max(base.best_streak, override.best_streak),
+ )
+ achievements = load_achievement_overrides(user_id)
+
+ return render_template_string(
+ """
+
+
+ GM 用户操作
+
+
+ GM 操作 · @{{ user.username }}
+
+
+
+
+ 战绩修改(目标值)
+ 保存后会写入覆盖值:目标 - 原始。可直接改胜场、对局数、总回合、最高连胜。
+ 当前原始:对局 {{ base.plays }} / 胜场 {{ base.wins }} / 回合 {{ base.total_rounds }} / 连胜 {{ base.best_streak }}
+ 当前覆盖:对局 +{{ override.plays }} / 胜场 +{{ override.wins }} / 回合 +{{ override.total_rounds }} / 连胜下限 {{ override.best_streak }}
+ 当前合计:对局 {{ combined.plays }} / 胜场 {{ combined.wins }} / 回合 {{ combined.total_rounds }} / 连胜 {{ combined.best_streak }}
+
+
+
+
+
+ 手动成就
+ 可直接给该用户增加/删除成就,立即在个人主页显示。
+
+
+
+
+ 已发放手动成就
+ {% with messages = get_flashed_messages() %}
+ {% if messages %}{{ messages[0] }}
{% endif %}
+ {% endwith %}
+
+
+
+
+ """,
+ user=user,
+ base=base,
+ override=override,
+ combined=combined,
+ achievements=achievements,
+ )
+
+ @app.post("/user//stats")
+ def update_stats(user_id: str) -> Any:
+ if not require_login():
+ return redirect(url_for("login_page"))
+ user = load_user(user_id)
+ if not user:
+ flash("用户不存在")
+ return redirect(url_for("home"))
+
+ base = compute_base_stats(user_id)
+ target_plays = to_int(request.form.get("target_plays", 0), minimum=0)
+ target_wins = to_int(request.form.get("target_wins", 0), minimum=0)
+ target_rounds = to_int(request.form.get("target_rounds", 0), minimum=0)
+ target_best_streak = to_int(request.form.get("target_best_streak", 0), minimum=0)
+
+ if target_wins > target_plays:
+ target_wins = target_plays
+
+ override_plays = max(0, target_plays - base.plays)
+ override_wins = max(0, target_wins - base.wins)
+ override_rounds = max(0, target_rounds - base.total_rounds)
+ override_best_streak = target_best_streak
+
+ with connect() as conn:
+ if override_plays == 0 and override_wins == 0 and override_rounds == 0 and override_best_streak == 0:
+ conn.execute("DELETE FROM gm_user_stat_overrides WHERE user_id=?", (user_id,))
+ else:
+ conn.execute(
+ """
+ INSERT INTO gm_user_stat_overrides(user_id, played_games, wins, total_rounds, best_streak, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ played_games=excluded.played_games,
+ wins=excluded.wins,
+ total_rounds=excluded.total_rounds,
+ best_streak=excluded.best_streak,
+ updated_at=excluded.updated_at
+ """,
+ (user_id, override_plays, override_wins, override_rounds, override_best_streak, utc_now_iso()),
+ )
+ conn.commit()
+
+ flash("战绩覆盖已更新")
+ return redirect(url_for("user_detail", user_id=user_id))
+
+ @app.post("/user//achievement/add")
+ def add_achievement(user_id: str) -> Any:
+ if not require_login():
+ return redirect(url_for("login_page"))
+ user = load_user(user_id)
+ if not user:
+ flash("用户不存在")
+ return redirect(url_for("home"))
+
+ title = str(request.form.get("title", "")).strip()[:48]
+ if not title:
+ flash("成就标题不能为空")
+ return redirect(url_for("user_detail", user_id=user_id))
+ name = str(request.form.get("name", "")).strip()[:48] or title
+ description = str(request.form.get("description", "")).strip()[:180]
+ level = normalize_level(request.form.get("level", "bronze"))
+ card_title = str(request.form.get("card_title", "")).strip()[:84]
+
+ now = utc_now_iso()
+ with connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO gm_user_achievement_overrides(
+ user_id, title, name, description, level, source, card_id, card_title, unlocked_at, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, 'gm', '', ?, ?, ?, ?)
+ """,
+ (user_id, title, name, description, level, card_title, now, now, now),
+ )
+ conn.commit()
+ flash("手动成就已添加")
+ return redirect(url_for("user_detail", user_id=user_id))
+
+ @app.post("/user//achievement//delete")
+ def delete_achievement(user_id: str, achievement_id: int) -> Any:
+ if not require_login():
+ return redirect(url_for("login_page"))
+ with connect() as conn:
+ conn.execute(
+ "DELETE FROM gm_user_achievement_overrides WHERE id=? AND user_id=?",
+ (int(achievement_id), user_id),
+ )
+ conn.commit()
+ flash("手动成就已删除")
+ return redirect(url_for("user_detail", user_id=user_id))
+
+ return app
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Echo Tavern GM 平台(独立可删除)")
+ parser.add_argument("--db", default="echo_tavern.db", help="SQLite 数据库路径")
+ parser.add_argument("--host", default="127.0.0.1", help="监听地址")
+ parser.add_argument("--port", type=int, default=8765, help="监听端口")
+ parser.add_argument(
+ "--password",
+ default=os.environ.get("GM_PASSWORD", "gm-admin-123456"),
+ help="GM 登录密码(也可用环境变量 GM_PASSWORD)",
+ )
+ args = parser.parse_args()
+
+ db_path = str(Path(args.db).expanduser().resolve())
+ app = create_app(db_path=db_path, gm_password=str(args.password))
+ print(f"[GM] DB: {db_path}")
+ print(f"[GM] URL: http://{args.host}:{args.port}")
+ print("[GM] 登录密码来自 --password 或环境变量 GM_PASSWORD")
+ app.run(host=args.host, port=int(args.port), debug=False)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/services/persistence.py b/services/persistence.py
index 6562cbb..2c71fa6 100644
--- a/services/persistence.py
+++ b/services/persistence.py
@@ -183,6 +183,32 @@ class SQLitePersistence:
FOREIGN KEY(to_user_id) REFERENCES users(user_id),
FOREIGN KEY(room_id) REFERENCES rooms(room_id)
);
+
+ CREATE TABLE IF NOT EXISTS gm_user_stat_overrides (
+ user_id TEXT PRIMARY KEY,
+ played_games INTEGER NOT NULL DEFAULT 0,
+ wins INTEGER NOT NULL DEFAULT 0,
+ total_rounds INTEGER NOT NULL DEFAULT 0,
+ best_streak INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY(user_id) REFERENCES users(user_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS gm_user_achievement_overrides (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ title TEXT NOT NULL,
+ name TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ level TEXT NOT NULL DEFAULT 'bronze',
+ source TEXT NOT NULL DEFAULT 'gm',
+ card_id TEXT NOT NULL DEFAULT '',
+ card_title TEXT NOT NULL DEFAULT '',
+ unlocked_at TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ FOREIGN KEY(user_id) REFERENCES users(user_id)
+ );
"""
)
@@ -202,6 +228,9 @@ class SQLitePersistence:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_players_user_id_room ON players(user_id, room_id)"
)
+ conn.execute(
+ "CREATE INDEX IF NOT EXISTS idx_gm_achievement_user ON gm_user_achievement_overrides(user_id, unlocked_at DESC)"
+ )
conn.commit()
self._seed_default_cards(conn)
@@ -1266,6 +1295,37 @@ class SQLitePersistence:
if updated_at and updated_at > entry["last_played_at"]:
entry["last_played_at"] = updated_at
+ for override in self.list_all_user_stat_overrides():
+ user_id = str(override.get("user_id", "")).strip()
+ if not user_id:
+ continue
+ entry = summary.get(user_id)
+ if entry is None:
+ user = self.get_user_by_id(user_id)
+ if user is None:
+ continue
+ entry = {
+ "user_id": user_id,
+ "username": str(user.get("username", "")).strip(),
+ "display_name": str(user.get("display_name", "")).strip(),
+ "avatar_color": str(user.get("avatar_color", "#0070f2")).strip() or "#0070f2",
+ "avatar_url": str(user.get("avatar_url", "")).strip(),
+ "theme": str(user.get("theme", "dopamine")).strip() or "dopamine",
+ "played_games": 0,
+ "wins": 0,
+ "total_rounds": 0,
+ "last_played_at": "",
+ "_seen_rooms": set(),
+ }
+ summary[user_id] = entry
+
+ entry["played_games"] += int(override.get("played_games", 0))
+ entry["wins"] += int(override.get("wins", 0))
+ entry["total_rounds"] += int(override.get("total_rounds", 0))
+ override_time = str(override.get("updated_at", "")).strip()
+ if override_time and override_time > entry["last_played_at"]:
+ entry["last_played_at"] = override_time
+
results: list[dict[str, Any]] = []
for entry in summary.values():
entry.pop("_seen_rooms", None)
@@ -1326,6 +1386,225 @@ class SQLitePersistence:
"last_played_at": str(row.get("last_played_at", "")).strip(),
}
+ def get_user_stat_override(self, user_id: str) -> dict[str, Any]:
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id:
+ return {"played_games": 0, "wins": 0, "total_rounds": 0, "best_streak": 0, "updated_at": ""}
+ with self._lock, self._connect() as conn:
+ row = conn.execute(
+ """
+ SELECT user_id, played_games, wins, total_rounds, best_streak, updated_at
+ FROM gm_user_stat_overrides
+ WHERE user_id=?
+ """,
+ (safe_user_id,),
+ ).fetchone()
+ if not row:
+ return {"played_games": 0, "wins": 0, "total_rounds": 0, "best_streak": 0, "updated_at": ""}
+ item = dict(row)
+ return {
+ "played_games": int(item.get("played_games", 0)),
+ "wins": int(item.get("wins", 0)),
+ "total_rounds": int(item.get("total_rounds", 0)),
+ "best_streak": int(item.get("best_streak", 0)),
+ "updated_at": str(item.get("updated_at", "")).strip(),
+ }
+
+ def list_all_user_stat_overrides(self) -> list[dict[str, Any]]:
+ with self._lock, self._connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT user_id, played_games, wins, total_rounds, best_streak, updated_at
+ FROM gm_user_stat_overrides
+ """
+ ).fetchall()
+ out: list[dict[str, Any]] = []
+ for row in rows:
+ item = dict(row)
+ out.append(
+ {
+ "user_id": str(item.get("user_id", "")).strip(),
+ "played_games": int(item.get("played_games", 0)),
+ "wins": int(item.get("wins", 0)),
+ "total_rounds": int(item.get("total_rounds", 0)),
+ "best_streak": int(item.get("best_streak", 0)),
+ "updated_at": str(item.get("updated_at", "")).strip(),
+ }
+ )
+ return out
+
+ def upsert_user_stat_override(
+ self,
+ user_id: str,
+ *,
+ played_games: int = 0,
+ wins: int = 0,
+ total_rounds: int = 0,
+ best_streak: int = 0,
+ ) -> dict[str, Any]:
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id:
+ raise ValueError("user_not_found")
+ if self.get_user_by_id(safe_user_id) is None:
+ raise ValueError("user_not_found")
+
+ safe_played = max(0, int(played_games))
+ safe_wins = max(0, int(wins))
+ safe_rounds = max(0, int(total_rounds))
+ safe_best_streak = max(0, int(best_streak))
+ now = utc_now_iso()
+
+ with self._lock, self._connect() as conn:
+ if safe_played == 0 and safe_wins == 0 and safe_rounds == 0 and safe_best_streak == 0:
+ conn.execute("DELETE FROM gm_user_stat_overrides WHERE user_id=?", (safe_user_id,))
+ conn.commit()
+ return {"played_games": 0, "wins": 0, "total_rounds": 0, "best_streak": 0, "updated_at": ""}
+ conn.execute(
+ """
+ INSERT INTO gm_user_stat_overrides(user_id, played_games, wins, total_rounds, best_streak, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET
+ played_games=excluded.played_games,
+ wins=excluded.wins,
+ total_rounds=excluded.total_rounds,
+ best_streak=excluded.best_streak,
+ updated_at=excluded.updated_at
+ """,
+ (safe_user_id, safe_played, safe_wins, safe_rounds, safe_best_streak, now),
+ )
+ conn.commit()
+ return self.get_user_stat_override(safe_user_id)
+
+ def list_user_achievement_overrides(self, user_id: str, limit: int = 80) -> list[dict[str, Any]]:
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id:
+ return []
+ try:
+ safe_limit = max(1, min(300, int(limit)))
+ except (TypeError, ValueError):
+ safe_limit = 80
+ with self._lock, self._connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, user_id, title, name, description, level, source, card_id, card_title, unlocked_at, created_at, updated_at
+ FROM gm_user_achievement_overrides
+ WHERE user_id=?
+ ORDER BY unlocked_at DESC, id DESC
+ LIMIT ?
+ """,
+ (safe_user_id, safe_limit),
+ ).fetchall()
+ result: list[dict[str, Any]] = []
+ for row in rows:
+ item = dict(row)
+ level = self._normalize_achievement_level(item.get("level", "bronze"))
+ result.append(
+ {
+ "id": int(item.get("id", 0)),
+ "user_id": safe_user_id,
+ "title": str(item.get("title", "")).strip()[:48],
+ "name": str(item.get("name", "")).strip()[:48],
+ "description": str(item.get("description", "")).strip()[:180],
+ "level": level,
+ "level_rank": int(ACHIEVEMENT_LEVEL_ORDER.get(level, 1)),
+ "source": str(item.get("source", "gm")).strip()[:24] or "gm",
+ "card_id": str(item.get("card_id", "")).strip()[:64],
+ "card_title": str(item.get("card_title", "")).strip()[:84],
+ "unlocked_at": str(item.get("unlocked_at", "")).strip(),
+ "created_at": str(item.get("created_at", "")).strip(),
+ "updated_at": str(item.get("updated_at", "")).strip(),
+ }
+ )
+ return result
+
+ def create_user_achievement_override(
+ self,
+ user_id: str,
+ *,
+ title: str,
+ name: str = "",
+ description: str = "",
+ level: str = "bronze",
+ source: str = "gm",
+ card_id: str = "",
+ card_title: str = "",
+ unlocked_at: str = "",
+ ) -> dict[str, Any]:
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id or self.get_user_by_id(safe_user_id) is None:
+ raise ValueError("user_not_found")
+ safe_title = str(title or "").strip()[:48]
+ if not safe_title:
+ raise ValueError("invalid_card_payload")
+ safe_name = str(name or "").strip()[:48] or safe_title
+ safe_desc = str(description or "").strip()[:180]
+ safe_level = self._normalize_achievement_level(level)
+ safe_source = str(source or "gm").strip()[:24] or "gm"
+ safe_card_id = str(card_id or "").strip()[:64]
+ safe_card_title = str(card_title or "").strip()[:84]
+ safe_unlocked_at = str(unlocked_at or "").strip()[:40] or utc_now_iso()
+ now = utc_now_iso()
+ with self._lock, self._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO gm_user_achievement_overrides(
+ user_id, title, name, description, level, source, card_id, card_title, unlocked_at, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ safe_user_id,
+ safe_title,
+ safe_name,
+ safe_desc,
+ safe_level,
+ safe_source,
+ safe_card_id,
+ safe_card_title,
+ safe_unlocked_at,
+ now,
+ now,
+ ),
+ )
+ row_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
+ conn.commit()
+ row = conn.execute(
+ """
+ SELECT id, user_id, title, name, description, level, source, card_id, card_title, unlocked_at, created_at, updated_at
+ FROM gm_user_achievement_overrides
+ WHERE id=?
+ """,
+ (row_id,),
+ ).fetchone()
+ if not row:
+ raise ValueError("invalid_response")
+ item = dict(row)
+ safe_level = self._normalize_achievement_level(item.get("level", "bronze"))
+ item["level"] = safe_level
+ item["level_rank"] = int(ACHIEVEMENT_LEVEL_ORDER.get(safe_level, 1))
+ return item
+
+ def delete_user_achievement_override(self, user_id: str, achievement_id: int) -> bool:
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id:
+ return False
+ try:
+ safe_id = int(achievement_id)
+ except (TypeError, ValueError):
+ return False
+ with self._lock, self._connect() as conn:
+ row = conn.execute(
+ "SELECT id FROM gm_user_achievement_overrides WHERE id=? AND user_id=?",
+ (safe_id, safe_user_id),
+ ).fetchone()
+ if not row:
+ return False
+ conn.execute(
+ "DELETE FROM gm_user_achievement_overrides WHERE id=? AND user_id=?",
+ (safe_id, safe_user_id),
+ )
+ conn.commit()
+ return True
+
def _normalize_achievement_level(self, raw_level: Any) -> str:
level = str(raw_level or "").strip().lower()
alias = {
@@ -1476,7 +1755,24 @@ class SQLitePersistence:
return games
def get_user_achievement_bundle(self, user_id: str) -> dict[str, Any]:
- games = self.list_user_completed_games(user_id)
+ safe_user_id = str(user_id or "").strip()
+ if not safe_user_id:
+ return {
+ "summary": {
+ "total_unlocked": 0,
+ "system_unlocked": 0,
+ "card_unlocked": 0,
+ "gm_unlocked": 0,
+ "highest_level": "",
+ "completed_games": 0,
+ "wins": 0,
+ "best_streak": 0,
+ },
+ "items": [],
+ }
+
+ games = self.list_user_completed_games(safe_user_id)
+ stat_override = self.get_user_stat_override(safe_user_id)
plays = 0
wins = 0
current_streak = 0
@@ -1530,6 +1826,45 @@ class SQLitePersistence:
}
)
+ final_metrics = {
+ "plays": max(0, plays + int(stat_override.get("played_games", 0))),
+ "wins": max(0, wins + int(stat_override.get("wins", 0))),
+ "current_streak": max(current_streak, int(stat_override.get("best_streak", 0))),
+ "best_streak": max(best_streak, int(stat_override.get("best_streak", 0))),
+ }
+ synthetic_unlock_time = str(stat_override.get("updated_at", "")).strip() or utc_now_iso()
+ for track in SYSTEM_ACHIEVEMENT_TRACKS:
+ track_id = str(track.get("id", "")).strip()
+ metric_key = self._normalize_achievement_metric(track.get("metric", "plays"))
+ metric_value = int(final_metrics.get(metric_key, 0))
+ for tier in track.get("tiers", []):
+ level = self._normalize_achievement_level(tier.get("level", "bronze"))
+ try:
+ threshold = max(1, int(tier.get("threshold", 1)))
+ except (TypeError, ValueError):
+ threshold = 1
+ item_key = f"{track_id}:{level}:{threshold}"
+ if item_key in system_seen:
+ continue
+ if metric_value < threshold:
+ continue
+ system_seen.add(item_key)
+ unlocked_system.append(
+ {
+ "source": "system",
+ "track_id": track_id,
+ "title": str(tier.get("title", "")).strip()[:48] or str(track.get("name", "")).strip(),
+ "name": str(track.get("name", "")).strip()[:48] or "系统成就",
+ "description": str(track.get("description", "")).strip()[:180],
+ "level": level,
+ "level_rank": int(ACHIEVEMENT_LEVEL_ORDER.get(level, 1)),
+ "threshold": threshold,
+ "metric": metric_key,
+ "metric_value": metric_value,
+ "unlocked_at": synthetic_unlock_time,
+ }
+ )
+
card_games: dict[str, list[dict[str, Any]]] = {}
for game in games:
card_id = game["game_card_id"]
@@ -1602,7 +1937,29 @@ class SQLitePersistence:
}
)
- all_items = unlocked_system + unlocked_card
+ unlocked_gm: list[dict[str, Any]] = []
+ for item in self.list_user_achievement_overrides(safe_user_id, limit=200):
+ level = self._normalize_achievement_level(item.get("level", "bronze"))
+ unlocked_gm.append(
+ {
+ "source": str(item.get("source", "gm")).strip()[:24] or "gm",
+ "track_id": str(item.get("id", "")),
+ "title": str(item.get("title", "")).strip()[:48],
+ "name": str(item.get("name", "")).strip()[:48] or str(item.get("title", "")).strip()[:48],
+ "description": str(item.get("description", "")).strip()[:180],
+ "level": level,
+ "level_rank": int(ACHIEVEMENT_LEVEL_ORDER.get(level, 1)),
+ "threshold": 0,
+ "metric": "manual",
+ "metric_value": 0,
+ "unlocked_at": str(item.get("unlocked_at", "")).strip(),
+ "card_id": str(item.get("card_id", "")).strip(),
+ "card_title": str(item.get("card_title", "")).strip(),
+ "gm_id": int(item.get("id", 0)),
+ }
+ )
+
+ all_items = unlocked_system + unlocked_card + unlocked_gm
all_items.sort(key=lambda row: (str(row.get("unlocked_at", "")), int(row.get("level_rank", 0))), reverse=True)
highest = "bronze"
highest_rank = 0
@@ -1617,10 +1974,11 @@ class SQLitePersistence:
"total_unlocked": len(all_items),
"system_unlocked": len(unlocked_system),
"card_unlocked": len(unlocked_card),
+ "gm_unlocked": len(unlocked_gm),
"highest_level": highest if highest_rank > 0 else "",
- "completed_games": plays,
- "wins": wins,
- "best_streak": best_streak,
+ "completed_games": int(final_metrics["plays"]),
+ "wins": int(final_metrics["wins"]),
+ "best_streak": int(final_metrics["best_streak"]),
},
"items": all_items[:120],
}