From 36bb2d1730c819aed58e4814fd73dbfb7e5b46a5 Mon Sep 17 00:00:00 2001
From: LEEE
Date: Sun, 8 Mar 2026 18:28:43 +0800
Subject: [PATCH] feat: add achievements center, match summary, and
configurable multiplayer runtime
---
app.py | 340 ++++++++++++++++++++++++++++++++++++
game/prompts.py | 9 +
game/room_manager.py | 61 +++++++
game/state_manager.py | 14 ++
game/story_engine.py | 244 +++++++++++++++++++++++++-
services/persistence.py | 187 +++++++++++++++++++-
static/css/style.css | 90 ++++++++++
static/js/achievements.js | 112 ++++++++++++
static/js/card_editor.js | 40 ++++-
static/js/room.js | 30 ++++
static/js/room_summary.js | 142 +++++++++++++++
static/js/user_profile.js | 4 +
templates/achievements.html | 47 +++++
templates/card_editor.html | 24 +++
templates/index.html | 2 +
templates/leaderboard.html | 4 +
templates/plaza.html | 4 +
templates/room.html | 5 +
templates/room_summary.html | 48 +++++
templates/rooms.html | 4 +
templates/user_profile.html | 5 +
21 files changed, 1407 insertions(+), 9 deletions(-)
create mode 100644 static/js/achievements.js
create mode 100644 static/js/room_summary.js
create mode 100644 templates/achievements.html
create mode 100644 templates/room_summary.html
diff --git a/app.py b/app.py
index 71f43f7..d7e82f8 100644
--- a/app.py
+++ b/app.py
@@ -62,6 +62,7 @@ ERROR_TEXTS = {
"only_lobby_can_kick": "游戏开始后不能踢人。",
"cannot_kick_host": "不能踢出房主。",
"spectator_readonly": "观战模式下不能执行该操作。",
+ "player_is_dead": "你当前处于阵亡/失败状态,暂时不能提交行动。",
"ai_invalid_json": "AI 返回了无效 JSON,本轮未结算,请稍后重试。",
"invite_invalid": "邀请参数无效。",
"invite_self_forbidden": "不能邀请自己。",
@@ -334,6 +335,36 @@ def normalize_assist_current(payload: Any) -> dict[str, Any]:
systems = raw.get("systems", raw.get("systems_json", {}))
if not isinstance(systems, dict):
systems = parse_json_object(systems) or {}
+ runtime_options = systems.get("runtime_options", {}) if isinstance(systems, dict) else {}
+ if not isinstance(runtime_options, dict):
+ runtime_options = {}
+
+ interaction_mode = str(raw.get("interaction_mode", runtime_options.get("interaction_mode", "hybrid"))).strip().lower()
+ if interaction_mode not in {"cooperative", "split", "hybrid"}:
+ interaction_mode = "hybrid"
+
+ def _to_bool(value: Any, default: bool) -> bool:
+ if isinstance(value, bool):
+ return value
+ text = str(value or "").strip().lower()
+ if text in {"1", "true", "yes", "on", "y"}:
+ return True
+ if text in {"0", "false", "no", "off", "n"}:
+ return False
+ return default
+
+ sync_all_player_ai_replies = _to_bool(
+ raw.get("sync_all_player_ai_replies", runtime_options.get("sync_all_player_ai_replies", False)),
+ False,
+ )
+ allow_player_death = _to_bool(
+ raw.get("allow_player_death", runtime_options.get("allow_player_death", False)),
+ False,
+ )
+ allow_revive = _to_bool(
+ raw.get("allow_revive", runtime_options.get("allow_revive", False)),
+ False,
+ )
metrics = raw.get("metrics", [])
if not isinstance(metrics, list):
@@ -383,6 +414,10 @@ def normalize_assist_current(payload: Any) -> dict[str, Any]:
"hero_status_color": str(raw.get("hero_status_color", "")).strip()[:7],
"cover_url": str(raw.get("cover_url", "")).strip()[:720],
"room_background_url": str(raw.get("room_background_url", "")).strip()[:720],
+ "interaction_mode": interaction_mode,
+ "sync_all_player_ai_replies": sync_all_player_ai_replies,
+ "allow_player_death": allow_player_death,
+ "allow_revive": allow_revive,
"design_notes": parse_text_list(raw.get("design_notes", []), limit=8),
}
@@ -457,6 +492,7 @@ def normalize_card_patch(raw_patch: Any) -> dict[str, Any]:
"hero_status_color": 7,
"cover_url": 720,
"room_background_url": 720,
+ "interaction_mode": 16,
}
for key, limit in scalar_limits.items():
if key in patch:
@@ -497,6 +533,18 @@ def normalize_card_patch(raw_patch: Any) -> dict[str, Any]:
if isinstance(patch.get("end_conditions"), list):
normalized["end_conditions"] = [row for row in patch.get("end_conditions", []) if isinstance(row, dict)][:30]
+ for key in ("sync_all_player_ai_replies", "allow_player_death", "allow_revive"):
+ if key in patch:
+ value = patch.get(key)
+ if isinstance(value, bool):
+ normalized[key] = value
+ else:
+ text = str(value or "").strip().lower()
+ if text in {"1", "true", "yes", "on", "y"}:
+ normalized[key] = True
+ elif text in {"0", "false", "no", "off", "n"}:
+ normalized[key] = False
+
return normalized
@@ -518,6 +566,7 @@ def apply_card_patch(current: dict[str, Any], patch: dict[str, Any]) -> dict[str
"hero_status_color",
"cover_url",
"room_background_url",
+ "interaction_mode",
}
for key in replace_keys:
if key in patch and str(patch.get(key, "")).strip():
@@ -564,6 +613,9 @@ def apply_card_patch(current: dict[str, Any], patch: dict[str, Any]) -> dict[str
merged["custom_achievements"] = parse_custom_achievements(
patch.get("custom_achievements", []), limit=20
)
+ for key in ("sync_all_player_ai_replies", "allow_player_death", "allow_revive"):
+ if key in patch and isinstance(patch.get(key), bool):
+ merged[key] = patch[key]
return merged
@@ -723,6 +775,205 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
"recent_rooms": recent_rooms,
}
+ def build_room_summary_payload(room_id: str) -> dict[str, Any]:
+ def _to_int(value: Any, default: int = 0) -> int:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return int(default)
+
+ safe_room_id = str(room_id or "").strip().upper()
+ room = persistence.get_room(safe_room_id)
+ if room is None:
+ raise ValueError("room_not_found")
+
+ state = room.get("game_state", {})
+ if not isinstance(state, dict):
+ state = {}
+ global_flags = state.get("global_flags", {})
+ if not isinstance(global_flags, dict):
+ global_flags = {}
+ runtime_options = global_flags.get("runtime_options", {})
+ if not isinstance(runtime_options, dict):
+ runtime_options = {}
+
+ players = persistence.list_players(safe_room_id)
+ actions = persistence.list_actions(safe_room_id, limit=1200)
+ messages = persistence.list_messages(safe_room_id, limit=600)
+ card = persistence.get_game_card(str(room.get("game_card_id", "")))
+ user_cache: dict[str, dict[str, Any]] = {}
+ for player in players:
+ user_id = str(player.get("user_id") or "").strip()
+ if not user_id or user_id in user_cache:
+ continue
+ user_cache[user_id] = persistence.get_user_by_id(user_id) or {}
+
+ contributions: dict[str, dict[str, Any]] = {}
+ for player in players:
+ player_id = str(player.get("player_id", "")).strip()
+ if not player_id:
+ continue
+ contributions[player_id] = {
+ "actions": 0,
+ "primary": 0,
+ "support": 0,
+ "blocked": 0,
+ "chats": 0,
+ "score": 0.0,
+ }
+
+ for row in actions:
+ player_id = str(row.get("player_id", "")).strip()
+ if player_id in contributions:
+ contributions[player_id]["actions"] += 1
+
+ for row in messages:
+ if str(row.get("kind", "")).strip() != "chat":
+ continue
+ player_id = str(row.get("player_id", "")).strip()
+ if player_id in contributions:
+ contributions[player_id]["chats"] += 1
+
+ action_history = state.get("action_history", [])
+ if isinstance(action_history, list):
+ for history_row in action_history:
+ if not isinstance(history_row, dict):
+ continue
+ merged = history_row.get("merged_actions", {})
+ if not isinstance(merged, dict):
+ continue
+ ordered = merged.get("ordered_actions", [])
+ if not isinstance(ordered, list):
+ continue
+ for action_row in ordered:
+ if not isinstance(action_row, dict):
+ continue
+ player_id = str(action_row.get("player_id", "")).strip()
+ if player_id not in contributions:
+ continue
+ resolution = str(action_row.get("resolution", "primary")).strip().lower()
+ if resolution == "primary":
+ contributions[player_id]["primary"] += 1
+ elif resolution == "support":
+ contributions[player_id]["support"] += 1
+ elif resolution == "blocked":
+ contributions[player_id]["blocked"] += 1
+
+ for score_row in contributions.values():
+ score_row["score"] = round(
+ score_row["actions"] * 1.2
+ + score_row["primary"] * 3.0
+ + score_row["support"] * 1.8
+ + score_row["blocked"] * 0.6
+ + score_row["chats"] * 0.45,
+ 2,
+ )
+
+ mvp_player_id = ""
+ mvp_score = -1.0
+ for player_id, score_row in contributions.items():
+ key = (
+ float(score_row.get("score", 0.0)),
+ int(score_row.get("primary", 0)),
+ int(score_row.get("actions", 0)),
+ )
+ best = (
+ float(mvp_score),
+ int(contributions.get(mvp_player_id, {}).get("primary", 0)),
+ int(contributions.get(mvp_player_id, {}).get("actions", 0)),
+ )
+ if key > best:
+ mvp_player_id = player_id
+ mvp_score = float(score_row.get("score", 0.0))
+
+ player_states = global_flags.get("player_states", {})
+ if not isinstance(player_states, dict):
+ player_states = {}
+
+ status_labels = {
+ "alive": "存活",
+ "down": "倒地",
+ "dead": "阵亡",
+ "failed": "失败",
+ "revived": "复活",
+ }
+
+ player_rows: list[dict[str, Any]] = []
+ alive_count = 0
+ for player in players:
+ player_id = str(player.get("player_id", "")).strip()
+ state_row = player_states.get(player_id, {})
+ if not isinstance(state_row, dict):
+ state_row = {}
+ raw_status = str(state_row.get("status", "")).strip().lower()
+ is_alive = bool(state_row.get("is_alive", True))
+ if raw_status in {"dead", "failed"}:
+ is_alive = False
+ if raw_status in {"alive", "revived"}:
+ is_alive = True
+ if not raw_status:
+ raw_status = "alive" if is_alive else "dead"
+ if raw_status == "down":
+ is_alive = False
+ if is_alive:
+ alive_count += 1
+
+ user_id = str(player.get("user_id") or "").strip()
+ user_row = user_cache.get(user_id, {})
+ score_row = contributions.get(player_id, {})
+ player_rows.append(
+ {
+ "player_id": player_id,
+ "name": player.get("name", player_id),
+ "username": user_row.get("username", ""),
+ "display_name": user_row.get("display_name", ""),
+ "avatar_color": user_row.get("avatar_color", "#0070f2"),
+ "avatar_url": user_row.get("avatar_url", ""),
+ "status": raw_status,
+ "status_label": status_labels.get(raw_status, raw_status or "未知"),
+ "is_alive": is_alive,
+ "deaths": max(0, _to_int(state_row.get("deaths", 0), 0)),
+ "revives": max(0, _to_int(state_row.get("revives", 0), 0)),
+ "contribution": score_row,
+ "is_mvp": player_id == mvp_player_id,
+ }
+ )
+
+ mvp = next((row for row in player_rows if row.get("player_id") == mvp_player_id), None)
+ ending_summary = str(global_flags.get("ending_summary", "")).strip()
+ if not ending_summary:
+ history = state.get("action_history", [])
+ if isinstance(history, list) and history:
+ tail = history[-1] if isinstance(history[-1], dict) else {}
+ ending_summary = str(tail.get("narration", "")).strip()
+ if not ending_summary:
+ ending_summary = str(state.get("scene_description", "")).strip()
+
+ interaction_mode = str(runtime_options.get("interaction_mode", "hybrid")).strip().lower()
+ if interaction_mode not in {"cooperative", "split", "hybrid"}:
+ interaction_mode = "hybrid"
+
+ return {
+ "room_id": safe_room_id,
+ "status": room.get("status", ""),
+ "card_id": str(room.get("game_card_id", "")).strip(),
+ "card_title": card.get("title", "未知剧本") if card else "未知剧本",
+ "ending_state": str(state.get("ending_state", "")).strip(),
+ "ending_summary": ending_summary,
+ "rounds": max(0, _to_int(state.get("round_no", 0), 0)),
+ "chapter": max(0, _to_int(state.get("chapter", 0), 0)),
+ "total_actions": len(actions),
+ "total_players": len(player_rows),
+ "alive_count": alive_count,
+ "mvp": mvp,
+ "players": player_rows,
+ "interaction_mode": interaction_mode,
+ "sync_all_player_ai_replies": bool(runtime_options.get("sync_all_player_ai_replies", False)),
+ "allow_player_death": bool(runtime_options.get("allow_player_death", False)),
+ "allow_revive": bool(runtime_options.get("allow_revive", False)),
+ "updated_at": str(room.get("updated_at", "")).strip(),
+ }
+
def pick_unique_username(base_username: str, provider_user_id: str) -> str:
base = normalize_account_username(base_username) or "linuxdo_user"
if persistence.get_user_by_username(base) is None:
@@ -767,6 +1018,15 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
profile_username=safe_username or "",
)
+ @app.get("/users//achievements")
+ def user_achievements_page(username: str) -> str:
+ safe_username = normalize_account_username(username)
+ return render_template(
+ "achievements.html",
+ user=serialize_user(current_user()),
+ profile_username=safe_username or "",
+ )
+
@app.get("/plaza")
def plaza_page() -> str:
return render_template("plaza.html", user=serialize_user(current_user()))
@@ -1211,6 +1471,34 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
if not isinstance(systems_payload, dict):
systems_payload = {}
+ runtime_options_raw = systems_payload.get("runtime_options", {}) if isinstance(systems_payload, dict) else {}
+ if not isinstance(runtime_options_raw, dict):
+ runtime_options_raw = {}
+ interaction_mode = str(
+ payload.get("interaction_mode", runtime_options_raw.get("interaction_mode", "hybrid"))
+ ).strip().lower()
+ if interaction_mode not in {"cooperative", "split", "hybrid"}:
+ interaction_mode = "hybrid"
+ runtime_options = {
+ "interaction_mode": interaction_mode,
+ "sync_all_player_ai_replies": parse_bool(
+ payload.get(
+ "sync_all_player_ai_replies",
+ runtime_options_raw.get("sync_all_player_ai_replies", False),
+ ),
+ False,
+ ),
+ "allow_player_death": parse_bool(
+ payload.get("allow_player_death", runtime_options_raw.get("allow_player_death", False)),
+ False,
+ ),
+ "allow_revive": parse_bool(
+ payload.get("allow_revive", runtime_options_raw.get("allow_revive", False)),
+ False,
+ ),
+ }
+ systems_payload["runtime_options"] = runtime_options
+
content_pack = {
"genre_tags": parse_text_list(payload.get("genre_tags", []), limit=10),
"feature_badges": parse_text_list(payload.get("feature_badges", []), limit=12),
@@ -1447,6 +1735,10 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
"state_fields": ["string", "string", "string"],
"end_conditions": [{"id": "string", "metric": "string", "op": ">=|<=|>|<|==|!=", "value": 0, "ending_state": "string", "ending_summary": "string"}],
"systems": {"combat": {"enabled": True}, "dice": {"enabled": True}},
+ "interaction_mode": "cooperative|split|hybrid",
+ "sync_all_player_ai_replies": "boolean",
+ "allow_player_death": "boolean",
+ "allow_revive": "boolean",
"genre_tags": ["string"],
"feature_badges": ["string"],
"world_lore": "string",
@@ -1677,6 +1969,10 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
"status_tracks": [{"key": "string", "label": "string"}],
"end_conditions": [{"id": "string", "metric": "string"}],
"systems": {"any_system": {"enabled": True}},
+ "interaction_mode": "cooperative|split|hybrid(optional)",
+ "sync_all_player_ai_replies": "boolean(optional)",
+ "allow_player_death": "boolean(optional)",
+ "allow_revive": "boolean(optional)",
"feature_badges": ["string(optional)"],
"system_rules": ["string(optional)"],
"player_roles": ["string(optional)"],
@@ -1910,6 +2206,24 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
return jsonify({"ok": False, "error": readable_error("user_not_found")}), 404
return jsonify({"ok": True, "profile": build_user_profile_payload(target)})
+ @app.get("/api/users//achievements")
+ def user_achievements_api(username: str) -> Any:
+ safe_username = normalize_account_username(username)
+ if not safe_username:
+ return jsonify({"ok": False, "error": readable_error("user_not_found")}), 404
+ target = persistence.get_user_by_username(safe_username)
+ if target is None:
+ return jsonify({"ok": False, "error": readable_error("user_not_found")}), 404
+ bundle = persistence.get_user_achievement_bundle(target["user_id"])
+ return jsonify(
+ {
+ "ok": True,
+ "username": target.get("username", ""),
+ "display_name": target.get("display_name", target.get("username", "")),
+ "achievements": bundle,
+ }
+ )
+
@app.get("/api/invites")
def list_invites_api() -> Any:
user = current_user()
@@ -2199,6 +2513,21 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
}
)
+ @app.get("/room//summary")
+ def room_summary_page(room_id: str) -> Any:
+ safe_room_id = room_id.strip().upper()
+ try:
+ summary = build_room_summary_payload(safe_room_id)
+ except ValueError:
+ return redirect(url_for("index"))
+ if summary.get("status") != "ended":
+ return redirect(url_for("room_page", room_id=safe_room_id))
+ return render_template(
+ "room_summary.html",
+ room_id=safe_room_id,
+ user=serialize_user(current_user()),
+ )
+
@app.get("/room/")
def room_page(room_id: str) -> Any:
room_id = room_id.strip().upper()
@@ -2254,6 +2583,15 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
except ValueError:
return jsonify({"ok": False, "error": "room_not_found"}), 404
+ @app.get("/api/rooms//summary")
+ def room_summary_api(room_id: str) -> Any:
+ safe_room_id = room_id.strip().upper()
+ try:
+ summary = build_room_summary_payload(safe_room_id)
+ except ValueError:
+ return jsonify({"ok": False, "error": readable_error("room_not_found")}), 404
+ return jsonify({"ok": True, "summary": summary})
+
@socketio.on("join_room_socket")
def on_join_room_socket(data: dict[str, Any]) -> None:
room_id = str(data.get("room_id", "")).strip().upper()
@@ -2410,6 +2748,7 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
"room_id": room_id,
"ending_state": game_state.get("ending_state"),
"ending_summary": game_state.get("global_flags", {}).get("ending_summary"),
+ "summary_url": url_for("room_summary_page", room_id=room_id),
},
to=room_id,
)
@@ -2450,6 +2789,7 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]:
"room_id": room_id,
"ending_state": game_state.get("ending_state"),
"ending_summary": game_state.get("global_flags", {}).get("ending_summary"),
+ "summary_url": url_for("room_summary_page", room_id=room_id),
},
to=room_id,
)
diff --git a/game/prompts.py b/game/prompts.py
index 0d89897..6d595a5 100644
--- a/game/prompts.py
+++ b/game/prompts.py
@@ -40,6 +40,11 @@ def build_system_prompt() -> str:
def build_user_prompt(context: dict[str, Any]) -> str:
story_context = context.get("story_context", {})
allowed_clues = story_context.get("allowed_clues", [])
+ runtime_options_raw = story_context.get("runtime_options", {})
+ runtime_options = runtime_options_raw if isinstance(runtime_options_raw, dict) else {}
+ interaction_mode = str(runtime_options.get("interaction_mode", "hybrid")).strip().lower()
+ if interaction_mode not in {"cooperative", "split", "hybrid"}:
+ interaction_mode = "hybrid"
players = context.get("players", [])
if not isinstance(players, list):
players = []
@@ -60,6 +65,7 @@ def build_user_prompt(context: dict[str, Any]) -> str:
"players": players,
"player_count": len(players),
"player_usernames": player_names,
+ "runtime_options": runtime_options,
"merged_actions": context.get("merged_actions", {}),
"schema": SCHEMA_DESCRIPTION,
"constraints": [
@@ -74,6 +80,9 @@ def build_user_prompt(context: dict[str, Any]) -> str:
"choices 最多 4 条",
"private_player_updates 需要覆盖所有玩家,每个 player_id 至少 1 条 message",
"private_player_updates 中的 player_id 必须来自 players 列表",
+ f"互动模式 interaction_mode={interaction_mode}:cooperative 强调协同,split 允许分头行动,hybrid 两者可混合",
+ "可在 set_flags.player_status 中设置玩家状态(alive/down/dead/failed/revived)",
+ "可在 set_flags.player_dead/player_death/player_revive/player_survival 中按玩家控制生存状态",
"如果故事未满足结局条件,应设置 should_end_game=false",
],
}
diff --git a/game/room_manager.py b/game/room_manager.py
index e088879..0f3692c 100644
--- a/game/room_manager.py
+++ b/game/room_manager.py
@@ -79,6 +79,31 @@ class RoomManager:
return f"{text[:417]}..."
return text
+ def _runtime_options_from_state(self, state: dict[str, Any]) -> dict[str, Any]:
+ global_flags = state.get("global_flags", {}) if isinstance(state, dict) else {}
+ raw = global_flags.get("runtime_options", {}) if isinstance(global_flags, dict) else {}
+ options = raw if isinstance(raw, dict) else {}
+
+ def _to_bool(value: Any, default: bool) -> bool:
+ if isinstance(value, bool):
+ return value
+ text = str(value or "").strip().lower()
+ if text in {"1", "true", "yes", "on", "y"}:
+ return True
+ if text in {"0", "false", "no", "off", "n"}:
+ return False
+ return default
+
+ interaction_mode = str(options.get("interaction_mode", "hybrid")).strip().lower()
+ if interaction_mode not in {"cooperative", "split", "hybrid"}:
+ interaction_mode = "hybrid"
+ return {
+ "interaction_mode": interaction_mode,
+ "sync_all_player_ai_replies": _to_bool(options.get("sync_all_player_ai_replies", False), False),
+ "allow_player_death": _to_bool(options.get("allow_player_death", False), False),
+ "allow_revive": _to_bool(options.get("allow_revive", False), False),
+ }
+
def _ensure_private_updates_for_all_players(
self,
ai_payload: dict[str, Any],
@@ -121,6 +146,32 @@ class RoomManager:
for player_id, message in by_player.items()
]
+ def _sync_private_updates_for_all_players(self, ai_payload: dict[str, Any], players: list[dict[str, Any]]) -> None:
+ updates_raw = ai_payload.get("private_player_updates", [])
+ updates = updates_raw if isinstance(updates_raw, list) else []
+ base_message = ""
+ for row in updates:
+ if not isinstance(row, dict):
+ continue
+ message = " ".join(str(row.get("message", "")).strip().split())
+ if message:
+ base_message = message
+ break
+ if not base_message:
+ base_message = self._normalize_ai_narration(
+ ai_payload.get("narration", ""),
+ "全队同步简报:本轮信息已同步,继续协作推进剧情。",
+ )
+ if len(base_message) > 220:
+ base_message = f"{base_message[:220]}..."
+ synced = []
+ for player in players:
+ player_id = str(player.get("player_id", "")).strip()
+ if not player_id:
+ continue
+ synced.append({"player_id": player_id, "message": base_message})
+ ai_payload["private_player_updates"] = synced
+
def _ensure_unique_name(self, name: str, existing_names: set[str]) -> str:
if name not in existing_names:
return name
@@ -449,10 +500,17 @@ class RoomManager:
state = room["game_state"]
if room["status"] != "running" or state.get("game_phase") != "running":
raise ValueError("game_not_running")
+ runtime_options = self._runtime_options_from_state(state)
player = self.persistence.get_player(room_id, player_id)
if not player:
raise ValueError("player_not_found")
+ if runtime_options.get("allow_player_death"):
+ player_states = state.get("global_flags", {}).get("player_states", {})
+ if isinstance(player_states, dict):
+ row = player_states.get(player_id, {})
+ if isinstance(row, dict) and not bool(row.get("is_alive", True)):
+ raise ValueError("player_is_dead")
text = self._sanitize(content)
if not text:
@@ -566,7 +624,10 @@ class RoomManager:
if ai_payload is None:
raise ValueError("ai_call_failed:empty_ai_payload")
+ runtime_options = self._runtime_options_from_state(state)
self._ensure_private_updates_for_all_players(ai_payload, players, actions)
+ if runtime_options.get("sync_all_player_ai_replies"):
+ self._sync_private_updates_for_all_players(ai_payload, players)
next_state, round_result = self.story_engine.apply_turn(state, ai_payload, merge_result, players)
next_status = "ended" if next_state.get("game_phase") == "ended" else "running"
self.state_manager.set_room_state(room_id, next_status, next_state)
diff --git a/game/state_manager.py b/game/state_manager.py
index 4390e46..cc29d1f 100644
--- a/game/state_manager.py
+++ b/game/state_manager.py
@@ -54,6 +54,20 @@ class StateManager:
game_state = room["game_state"]
game_state.setdefault("inventory_by_player", {})
game_state["inventory_by_player"].setdefault(player_id, [])
+ global_flags = game_state.get("global_flags", {})
+ if isinstance(global_flags, dict):
+ player_states = global_flags.get("player_states", {})
+ if not isinstance(player_states, dict):
+ player_states = {}
+ player_states[player_id] = {
+ "status": "alive",
+ "is_alive": True,
+ "deaths": 0,
+ "revives": 0,
+ "last_event": "join",
+ "updated_at": game_state.get("last_updated_at") or "",
+ }
+ global_flags["player_states"] = player_states
self.persistence.add_or_update_player(
room_id,
diff --git a/game/story_engine.py b/game/story_engine.py
index 9713700..187f21b 100644
--- a/game/story_engine.py
+++ b/game/story_engine.py
@@ -66,6 +66,7 @@ ENDING_SUMMARIES = {
"sacrifice": "真相被揭开,但有人留下挡住了灾厄,你们带着代价离开。",
"trapped": "危险失控,旅店的规则吞没了所有出口,你们被永困其中。",
"pact": "你们选择与隐藏势力交易,离开旅店,却成为新的规则执行者。",
+ "team_wipe": "队伍全员倒下,冒险在黑暗中终止。",
}
@@ -86,6 +87,10 @@ class StoryEngine:
content_pack = dict(card.get("content_pack", {}))
data_scope = self._normalize_data_scope(state_schema.get("data_scope", {}))
visibility = self._normalize_visibility(state_schema.get("visibility", {}))
+ systems_payload = content_pack.get("systems", {}) if isinstance(content_pack, dict) else {}
+ runtime_options = self._normalize_runtime_options(
+ systems_payload.get("runtime_options", {}) if isinstance(systems_payload, dict) else {}
+ )
card_mode = "fogharbor" if game_card_id == "fogharbor" else "custom"
initial_scene = "lobby" if card_mode == "fogharbor" else "opening"
initial_choices = deepcopy(SCENES["lobby"]["choices"]) if card_mode == "fogharbor" else (
@@ -106,6 +111,17 @@ class StoryEngine:
shared_inventory: list[str] = [] if data_scope["inventory"] == "shared" else []
clues_by_player: dict[str, list[str]] = {player_id: [] for player_id in player_ids} if data_scope["clues"] == "per_player" else {}
+ player_states = {
+ player_id: {
+ "status": "alive",
+ "is_alive": True,
+ "deaths": 0,
+ "revives": 0,
+ "last_event": "spawn",
+ "updated_at": utc_now_iso(),
+ }
+ for player_id in player_ids
+ }
return GameState(
room_id=room_id,
@@ -129,10 +145,12 @@ class StoryEngine:
"card_content_pack": content_pack,
"data_scope": data_scope,
"visibility": visibility,
+ "runtime_options": runtime_options,
"metric_values": metric_values,
"metric_values_by_player": metric_values_by_player,
"shared_inventory": shared_inventory,
"clues_by_player": clues_by_player,
+ "player_states": player_states,
},
discovered_clues=[],
inventory_by_player=inventory,
@@ -150,11 +168,13 @@ class StoryEngine:
def story_context(self, scene_id: str, state: dict[str, Any] | None = None) -> dict[str, Any]:
scene = SCENES.get(scene_id, SCENES["lobby"])
- card_mode = (state or {}).get("global_flags", {}).get("card_mode", "fogharbor")
+ global_flags = (state or {}).get("global_flags", {})
+ card_mode = global_flags.get("card_mode", "fogharbor")
+ runtime_options = self._normalize_runtime_options(global_flags.get("runtime_options", {}))
if card_mode != "fogharbor":
- content_pack = (state or {}).get("global_flags", {}).get("card_content_pack", {})
+ content_pack = global_flags.get("card_content_pack", {})
return {
- "setting": (state or {}).get("global_flags", {}).get("game_card_title", "自定义剧本"),
+ "setting": global_flags.get("game_card_title", "自定义剧本"),
"scene": {
"id": scene_id,
"title": scene_id,
@@ -162,6 +182,7 @@ class StoryEngine:
"default_choices": list((state or {}).get("pending_choices", [])),
},
"content_pack": content_pack,
+ "runtime_options": runtime_options,
"allowed_clues": "ANY_STRING",
"npcs": "ANY_NPC",
"rules": [
@@ -183,6 +204,7 @@ class StoryEngine:
"allowed_clues": sorted(ALLOWED_CLUES),
"npcs": sorted(NPC_IDS),
"endings": list(ENDING_SUMMARIES.keys()),
+ "runtime_options": runtime_options,
"rules": [
"关键真相与结局由服务端状态机控制",
"危险值达到 10 会触发被困结局",
@@ -218,6 +240,8 @@ class StoryEngine:
updates = ai_payload.get("state_updates", {})
card_mode = state.global_flags.get("card_mode", "fogharbor")
data_scope = self._normalize_data_scope(state.global_flags.get("data_scope", {}))
+ runtime_options = self._normalize_runtime_options(state.global_flags.get("runtime_options", {}))
+ state.global_flags["runtime_options"] = runtime_options
player_ids_list = [player["player_id"] for player in players]
danger_delta = int(updates.get("danger_delta", 0))
if card_mode == "fogharbor":
@@ -334,6 +358,8 @@ class StoryEngine:
if card_mode != "fogharbor":
self._apply_custom_metrics(state, updates, flags, players, merge_result)
+ self._apply_player_survival_updates(state, flags, players, runtime_options)
+
state.scene_description = ai_payload.get("narration", state.scene_description)
state.pending_choices = ai_payload.get("choices", state.pending_choices)[:4]
state.round_no += 1
@@ -358,12 +384,24 @@ class StoryEngine:
else:
transitioned = self._custom_scene_progress(state, ai_payload)
ending_state = self._check_custom_endings(state, ai_payload)
+ if (
+ runtime_options.get("allow_player_death")
+ and not ending_state
+ and self._alive_player_count(state) <= 0
+ ):
+ ending_state = "team_wipe"
+ if not str(state.global_flags.get("ending_summary", "")).strip():
+ state.global_flags["ending_summary"] = ENDING_SUMMARIES["team_wipe"]
if ending_state:
state.game_phase = "ended"
state.ending_state = ending_state
ending_summary = ai_payload.get("ending_summary")
if isinstance(ending_summary, str) and ending_summary.strip():
state.global_flags["ending_summary"] = ending_summary.strip()
+ elif not str(state.global_flags.get("ending_summary", "")).strip():
+ fallback_summary = ENDING_SUMMARIES.get(ending_state, "")
+ if fallback_summary:
+ state.global_flags["ending_summary"] = fallback_summary
round_result = {
"narration": ai_payload.get("narration", ""),
@@ -375,7 +413,9 @@ class StoryEngine:
"triggered_players": merge_result.get("triggered_players", []),
"scene_transition": transitioned,
"ending_state": state.ending_state,
- "ending_summary": ENDING_SUMMARIES.get(state.ending_state),
+ "ending_summary": str(state.global_flags.get("ending_summary", "")).strip() or ENDING_SUMMARIES.get(state.ending_state),
+ "player_states": state.global_flags.get("player_states", {}),
+ "runtime_options": runtime_options,
"guidance": self.scene_guidance(state.scene_id, state.round_no, state.pending_choices),
}
return state.to_dict(), round_result
@@ -491,6 +531,202 @@ class StoryEngine:
state.global_flags["metric_values_by_player"] = map_by_player
+ def _alive_player_count(self, state: GameState) -> int:
+ player_states = state.global_flags.get("player_states", {})
+ if not isinstance(player_states, dict):
+ return 0
+ alive = 0
+ for row in player_states.values():
+ if not isinstance(row, dict):
+ continue
+ if bool(row.get("is_alive", True)):
+ alive += 1
+ return alive
+
+ def _safe_int(self, value: Any, default: int = 0) -> int:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return int(default)
+
+ def _normalize_runtime_options(self, raw_options: Any) -> dict[str, Any]:
+ options = raw_options if isinstance(raw_options, dict) else {}
+
+ def _to_bool(value: Any, default: bool) -> bool:
+ if isinstance(value, bool):
+ return value
+ text = str(value or "").strip().lower()
+ if text in {"1", "true", "yes", "on", "y"}:
+ return True
+ if text in {"0", "false", "no", "off", "n"}:
+ return False
+ return default
+
+ interaction_mode = str(options.get("interaction_mode", "hybrid")).strip().lower()
+ if interaction_mode not in {"cooperative", "split", "hybrid"}:
+ interaction_mode = "hybrid"
+ return {
+ "interaction_mode": interaction_mode,
+ "sync_all_player_ai_replies": _to_bool(options.get("sync_all_player_ai_replies", False), False),
+ "allow_player_death": _to_bool(options.get("allow_player_death", False), False),
+ "allow_revive": _to_bool(options.get("allow_revive", False), False),
+ }
+
+ def _ensure_player_states(self, state: GameState, players: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
+ raw_states = state.global_flags.get("player_states", {})
+ if not isinstance(raw_states, dict):
+ raw_states = {}
+ now = utc_now_iso()
+ out: dict[str, dict[str, Any]] = {}
+ for player in players:
+ player_id = str(player.get("player_id", "")).strip()
+ if not player_id:
+ continue
+ row = raw_states.get(player_id, {})
+ if not isinstance(row, dict):
+ row = {}
+ status = str(row.get("status", "")).strip().lower()
+ if status not in {"alive", "down", "dead", "failed"}:
+ status = "alive"
+ is_alive_raw = row.get("is_alive")
+ if isinstance(is_alive_raw, bool):
+ is_alive = is_alive_raw
+ else:
+ is_alive = status not in {"dead", "failed"}
+ out[player_id] = {
+ "status": status,
+ "is_alive": is_alive,
+ "deaths": max(0, self._safe_int(row.get("deaths", 0), 0)),
+ "revives": max(0, self._safe_int(row.get("revives", 0), 0)),
+ "last_event": str(row.get("last_event", "")).strip()[:24],
+ "updated_at": str(row.get("updated_at", "")).strip() or now,
+ }
+ state.global_flags["player_states"] = out
+ return out
+
+ def _iter_flagged_players(self, value: Any) -> list[str]:
+ out: list[str] = []
+ if isinstance(value, dict):
+ for key, enabled in value.items():
+ if isinstance(enabled, bool):
+ if enabled:
+ out.append(str(key).strip())
+ elif isinstance(enabled, (int, float)):
+ if int(enabled) != 0:
+ out.append(str(key).strip())
+ elif str(enabled or "").strip().lower() in {"1", "true", "yes", "on", "y"}:
+ out.append(str(key).strip())
+ elif isinstance(value, list):
+ for row in value:
+ player_id = str(row or "").strip()
+ if player_id:
+ out.append(player_id)
+ elif isinstance(value, str):
+ for row in value.split(","):
+ player_id = row.strip()
+ if player_id:
+ out.append(player_id)
+ return out
+
+ def _apply_player_survival_updates(
+ self,
+ state: GameState,
+ flags: dict[str, Any],
+ players: list[dict[str, Any]],
+ runtime_options: dict[str, Any],
+ ) -> None:
+ player_states = self._ensure_player_states(state, players)
+ allow_death = bool(runtime_options.get("allow_player_death"))
+ allow_revive = bool(runtime_options.get("allow_revive"))
+
+ if not allow_death:
+ now = utc_now_iso()
+ for row in player_states.values():
+ row["status"] = "alive"
+ row["is_alive"] = True
+ row["updated_at"] = now
+ state.global_flags["player_states"] = player_states
+ return
+
+ def mark_dead(player_id: str, *, event: str = "dead") -> None:
+ player = player_states.get(player_id)
+ if not isinstance(player, dict):
+ return
+ if bool(player.get("is_alive", True)):
+ player["deaths"] = max(0, self._safe_int(player.get("deaths", 0), 0)) + 1
+ player["status"] = "dead"
+ player["is_alive"] = False
+ player["last_event"] = event[:24]
+ player["updated_at"] = utc_now_iso()
+
+ def mark_alive(player_id: str, *, event: str = "revived") -> None:
+ player = player_states.get(player_id)
+ if not isinstance(player, dict):
+ return
+ if not bool(player.get("is_alive", True)):
+ player["revives"] = max(0, self._safe_int(player.get("revives", 0), 0)) + 1
+ player["status"] = "alive"
+ player["is_alive"] = True
+ player["last_event"] = event[:24]
+ player["updated_at"] = utc_now_iso()
+
+ status_patch = flags.get("player_status", {})
+ if isinstance(status_patch, dict):
+ for raw_player_id, raw_status in status_patch.items():
+ player_id = str(raw_player_id or "").strip()
+ status = str(raw_status or "").strip().lower()
+ if player_id not in player_states or not status:
+ continue
+ if status in {"dead", "failed"}:
+ mark_dead(player_id, event=status)
+ elif status in {"alive", "revived"} and allow_revive:
+ mark_alive(player_id, event=status)
+ elif status == "down":
+ player = player_states[player_id]
+ player["status"] = "down"
+ player["is_alive"] = False
+ player["last_event"] = "down"
+ player["updated_at"] = utc_now_iso()
+
+ for player_id in self._iter_flagged_players(flags.get("player_dead", {})):
+ if player_id in player_states:
+ mark_dead(player_id, event="dead")
+ for player_id in self._iter_flagged_players(flags.get("player_death", {})):
+ if player_id in player_states:
+ mark_dead(player_id, event="dead")
+
+ survival_patch = flags.get("player_survival", {})
+ if isinstance(survival_patch, dict):
+ for raw_player_id, raw_alive in survival_patch.items():
+ player_id = str(raw_player_id or "").strip()
+ if player_id not in player_states:
+ continue
+ alive = None
+ if isinstance(raw_alive, bool):
+ alive = raw_alive
+ elif isinstance(raw_alive, (int, float)):
+ alive = int(raw_alive) != 0
+ else:
+ text = str(raw_alive or "").strip().lower()
+ if text in {"1", "true", "yes", "on", "y"}:
+ alive = True
+ elif text in {"0", "false", "no", "off", "n"}:
+ alive = False
+ if alive is None:
+ continue
+ if alive:
+ if allow_revive:
+ mark_alive(player_id, event="revived")
+ else:
+ mark_dead(player_id, event="failed")
+
+ if allow_revive:
+ for player_id in self._iter_flagged_players(flags.get("player_revive", {})):
+ if player_id in player_states:
+ mark_alive(player_id, event="revived")
+
+ state.global_flags["player_states"] = player_states
+
def _normalize_data_scope(self, raw_scope: Any) -> dict[str, str]:
scope = raw_scope if isinstance(raw_scope, dict) else {}
out = {
diff --git a/services/persistence.py b/services/persistence.py
index 2c71fa6..97b7f24 100644
--- a/services/persistence.py
+++ b/services/persistence.py
@@ -830,6 +830,20 @@ class SQLitePersistence:
).fetchall()
return [dict(row) for row in reversed(rows)]
+ def list_actions(self, room_id: str, limit: int = 600) -> list[dict[str, Any]]:
+ with self._lock, self._connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, room_id, round_no, player_id, content, created_at
+ FROM actions
+ WHERE room_id=?
+ ORDER BY id ASC
+ LIMIT ?
+ """,
+ (room_id, max(1, int(limit))),
+ ).fetchall()
+ return [dict(row) for row in rows]
+
def append_action(self, room_id: str, round_no: int, player_id: str, content: str) -> None:
with self._lock, self._connect() as conn:
conn.execute(
@@ -1756,6 +1770,13 @@ class SQLitePersistence:
def get_user_achievement_bundle(self, user_id: str) -> dict[str, Any]:
safe_user_id = str(user_id or "").strip()
+ empty_section = {
+ "unlocked": [],
+ "locked": [],
+ "all": [],
+ "unlocked_count": 0,
+ "total_count": 0,
+ }
if not safe_user_id:
return {
"summary": {
@@ -1769,6 +1790,8 @@ class SQLitePersistence:
"best_streak": 0,
},
"items": [],
+ "official": dict(empty_section),
+ "card": dict(empty_section),
}
games = self.list_user_completed_games(safe_user_id)
@@ -1860,10 +1883,15 @@ class SQLitePersistence:
"level_rank": int(ACHIEVEMENT_LEVEL_ORDER.get(level, 1)),
"threshold": threshold,
"metric": metric_key,
- "metric_value": metric_value,
- "unlocked_at": synthetic_unlock_time,
- }
- )
+ "metric_value": metric_value,
+ "unlocked_at": synthetic_unlock_time,
+ }
+ )
+
+ system_unlock_index = {
+ f"{row.get('track_id')}:{row.get('level')}:{int(row.get('threshold', 0))}": row
+ for row in unlocked_system
+ }
card_games: dict[str, list[dict[str, Any]]] = {}
for game in games:
@@ -1937,6 +1965,11 @@ class SQLitePersistence:
}
)
+ card_unlock_index = {
+ f"{row.get('card_id')}:{row.get('track_id')}:{row.get('level')}:{int(row.get('threshold', 0))}": row
+ for row in 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"))
@@ -1952,6 +1985,9 @@ class SQLitePersistence:
"threshold": 0,
"metric": "manual",
"metric_value": 0,
+ "progress_value": 1,
+ "progress_percent": 100,
+ "unlocked": True,
"unlocked_at": str(item.get("unlocked_at", "")).strip(),
"card_id": str(item.get("card_id", "")).strip(),
"card_title": str(item.get("card_title", "")).strip(),
@@ -1959,6 +1995,135 @@ class SQLitePersistence:
}
)
+ official_rows: list[dict[str, Any]] = []
+ for track in SYSTEM_ACHIEVEMENT_TRACKS:
+ track_id = str(track.get("id", "")).strip()
+ track_name = str(track.get("name", "")).strip()[:48] or "系统成就"
+ track_desc = str(track.get("description", "")).strip()[:180]
+ 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
+ idx_key = f"{track_id}:{level}:{threshold}"
+ unlocked = system_unlock_index.get(idx_key)
+ progress_value = min(metric_value, threshold)
+ progress_percent = min(100, int(round((progress_value / threshold) * 100))) if threshold > 0 else 0
+ official_rows.append(
+ {
+ "source": "system",
+ "track_id": track_id,
+ "track_name": track_name,
+ "title": str(tier.get("title", "")).strip()[:48] or track_name,
+ "name": track_name,
+ "description": track_desc,
+ "level": level,
+ "level_rank": int(ACHIEVEMENT_LEVEL_ORDER.get(level, 1)),
+ "threshold": threshold,
+ "metric": metric_key,
+ "metric_value": metric_value,
+ "progress_value": progress_value,
+ "progress_percent": progress_percent,
+ "unlocked": bool(unlocked),
+ "unlocked_at": str((unlocked or {}).get("unlocked_at", "")).strip(),
+ }
+ )
+
+ official_rows.sort(
+ key=lambda row: (
+ not bool(row.get("unlocked")),
+ int(row.get("level_rank", 0)),
+ int(row.get("threshold", 0)),
+ str(row.get("track_id", "")),
+ )
+ )
+
+ all_cards = self.list_game_cards()
+ card_rows: list[dict[str, Any]] = []
+ for card in all_cards:
+ card_id = str(card.get("card_id", "")).strip()
+ if not card_id:
+ continue
+ card_title = str(card.get("title", "未知剧本")).strip() or "未知剧本"
+ rows = card_games.get(card_id, [])
+ content_pack = card.get("content_pack", {})
+ custom_defs = self._normalize_custom_achievements(
+ content_pack.get("custom_achievements", []) if isinstance(content_pack, dict) else [],
+ limit=20,
+ )
+ if not custom_defs:
+ continue
+
+ plays_count = 0
+ wins_count = 0
+ streak_count = 0
+ best_streak_count = 0
+ for row in rows:
+ plays_count += 1
+ if row.get("is_win"):
+ wins_count += 1
+ streak_count += 1
+ best_streak_count = max(best_streak_count, streak_count)
+ else:
+ streak_count = 0
+ metric_map = {
+ "plays": plays_count,
+ "wins": wins_count,
+ "current_streak": streak_count,
+ "best_streak": best_streak_count,
+ }
+
+ for ach in custom_defs:
+ ach_id = str(ach.get("id", "")).strip() or "card_achievement"
+ ach_name = str(ach.get("name", "")).strip()[:48] or "卡片成就"
+ ach_desc = str(ach.get("description", "")).strip()[:180]
+ metric_key = self._normalize_achievement_metric(ach.get("metric", "plays"))
+ metric_value = int(metric_map.get(metric_key, 0))
+ for tier in ach.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
+ idx_key = f"{card_id}:{ach_id}:{level}:{threshold}"
+ unlocked = card_unlock_index.get(idx_key)
+ progress_value = min(metric_value, threshold)
+ progress_percent = min(100, int(round((progress_value / threshold) * 100))) if threshold > 0 else 0
+ card_rows.append(
+ {
+ "source": "card",
+ "track_id": ach_id,
+ "track_name": ach_name,
+ "title": str(tier.get("title", "")).strip()[:48] or ach_name,
+ "name": ach_name,
+ "description": ach_desc,
+ "level": level,
+ "level_rank": int(ACHIEVEMENT_LEVEL_ORDER.get(level, 1)),
+ "threshold": threshold,
+ "metric": metric_key,
+ "metric_value": metric_value,
+ "progress_value": progress_value,
+ "progress_percent": progress_percent,
+ "unlocked": bool(unlocked),
+ "unlocked_at": str((unlocked or {}).get("unlocked_at", "")).strip(),
+ "card_id": card_id,
+ "card_title": card_title,
+ }
+ )
+
+ card_rows.sort(
+ key=lambda row: (
+ not bool(row.get("unlocked")),
+ str(row.get("card_title", "")),
+ int(row.get("level_rank", 0)),
+ int(row.get("threshold", 0)),
+ str(row.get("track_id", "")),
+ )
+ )
+
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"
@@ -1981,6 +2146,20 @@ class SQLitePersistence:
"best_streak": int(final_metrics["best_streak"]),
},
"items": all_items[:120],
+ "official": {
+ "unlocked": [row for row in official_rows if row.get("unlocked")] + unlocked_gm[:120],
+ "locked": [row for row in official_rows if not row.get("unlocked")],
+ "all": official_rows + unlocked_gm[:120],
+ "unlocked_count": len([row for row in official_rows if row.get("unlocked")]) + len(unlocked_gm),
+ "total_count": len(official_rows) + len(unlocked_gm),
+ },
+ "card": {
+ "unlocked": [row for row in card_rows if row.get("unlocked")],
+ "locked": [row for row in card_rows if not row.get("unlocked")],
+ "all": card_rows,
+ "unlocked_count": len([row for row in card_rows if row.get("unlocked")]),
+ "total_count": len(card_rows),
+ },
}
def get_oauth_identity(self, provider: str, provider_user_id: str) -> dict[str, Any] | None:
diff --git a/static/css/style.css b/static/css/style.css
index 5309049..64dc0aa 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -1600,6 +1600,96 @@ body.room-page .meta-panel li {
gap: 0.3rem;
}
+.toggle-row {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ margin-top: 0.2rem;
+ margin-bottom: 0.2rem;
+}
+
+.toggle-row input[type="checkbox"] {
+ width: auto;
+ min-width: 18px;
+ height: 18px;
+ margin: 0;
+}
+
+.toggle-row span {
+ font-size: 0.92rem;
+ color: var(--text);
+}
+
+.achievements-grid,
+.room-summary-grid {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+}
+
+.achievement-rows {
+ display: grid;
+ gap: 0.55rem;
+}
+
+.achievement-row {
+ border-radius: 12px;
+ border: 1px solid rgba(163, 188, 238, 0.58);
+ background: rgba(246, 250, 255, 0.82);
+ padding: 0.58rem 0.62rem;
+ display: grid;
+ gap: 0.3rem;
+}
+
+.achievement-row.unlocked {
+ border-color: rgba(98, 173, 118, 0.6);
+ background: rgba(238, 255, 244, 0.86);
+}
+
+.achievement-row.locked {
+ opacity: 0.96;
+}
+
+.achievement-row h3 {
+ margin: 0;
+ font-size: 0.98rem;
+}
+
+.achievement-row p {
+ margin: 0;
+ font-size: 0.84rem;
+ color: var(--muted);
+}
+
+.achievement-progress {
+ height: 9px;
+ border-radius: 999px;
+ background: rgba(111, 138, 188, 0.24);
+ overflow: hidden;
+}
+
+.achievement-progress i {
+ display: block;
+ height: 100%;
+ width: 0;
+ background: linear-gradient(90deg, #37a7ff, #4f66ff);
+}
+
+body[data-theme="midnight"] .achievement-row,
+body[data-theme="graphite"] .achievement-row {
+ background: rgba(18, 29, 58, 0.78);
+ border-color: rgba(92, 127, 214, 0.56);
+}
+
+body[data-theme="midnight"] .achievement-row.unlocked,
+body[data-theme="graphite"] .achievement-row.unlocked {
+ background: rgba(18, 58, 44, 0.56);
+ border-color: rgba(93, 182, 131, 0.56);
+}
+
+body[data-theme="midnight"] .achievement-row p,
+body[data-theme="graphite"] .achievement-row p {
+ color: #cddcff;
+}
+
.invite-toast-stack {
position: fixed;
top: 0.9rem;
diff --git a/static/js/achievements.js b/static/js/achievements.js
new file mode 100644
index 0000000..c1703df
--- /dev/null
+++ b/static/js/achievements.js
@@ -0,0 +1,112 @@
+(function () {
+ const body = document.body;
+ const profileUsername = String(body.dataset.profileUsername || '').trim().toLowerCase();
+ const subtitleEl = document.getElementById('achievements-subtitle');
+ const officialSummaryEl = document.getElementById('official-summary');
+ const cardSummaryEl = document.getElementById('card-summary');
+ const officialWrap = document.getElementById('official-achievements');
+ const cardWrap = document.getElementById('card-achievements');
+ const statusEl = document.getElementById('status');
+
+ const setStatus = (text) => {
+ if (statusEl) statusEl.textContent = text || '';
+ };
+
+ const getJson = async (url) => {
+ const res = await fetch(url);
+ return res.json();
+ };
+
+ const levelLabel = (level) => {
+ const map = {
+ bronze: '青铜',
+ silver: '白银',
+ gold: '黄金',
+ platinum: '铂金',
+ diamond: '钻石',
+ };
+ return map[String(level || '').toLowerCase()] || '青铜';
+ };
+
+ const renderRows = (target, rows, type) => {
+ if (!target) return;
+ target.textContent = '';
+ if (!Array.isArray(rows) || !rows.length) {
+ const empty = document.createElement('p');
+ empty.className = 'helper-text';
+ empty.textContent = '暂无成就条目';
+ target.appendChild(empty);
+ return;
+ }
+
+ rows.forEach((row) => {
+ const card = document.createElement('article');
+ card.className = `achievement-row ${row.unlocked ? 'unlocked' : 'locked'}`;
+
+ const title = document.createElement('h3');
+ title.textContent = row.title || row.name || '未命名成就';
+ card.appendChild(title);
+
+ const meta = document.createElement('p');
+ const scope = type === 'card'
+ ? `故事卡 · ${row.card_title || '未知剧本'}`
+ : (row.source === 'gm' ? 'GM授予' : '官方');
+ meta.textContent = `${scope} · ${levelLabel(row.level)} · 指标 ${row.metric || 'manual'}`;
+ card.appendChild(meta);
+
+ const progressValue = Number(row.progress_value ?? row.metric_value ?? 0);
+ const threshold = Math.max(0, Number(row.threshold || 0));
+ const percent = threshold > 0
+ ? Math.max(0, Math.min(100, Math.round((Math.min(progressValue, threshold) / threshold) * 100)))
+ : (row.unlocked ? 100 : 0);
+
+ const progressText = document.createElement('p');
+ if (row.unlocked) {
+ progressText.textContent = `已获得 · ${row.unlocked_at || '时间未知'}`;
+ } else if (threshold > 0) {
+ progressText.textContent = `进度 ${Math.min(progressValue, threshold)}/${threshold} (${percent}%)`;
+ } else {
+ progressText.textContent = '未获得';
+ }
+ card.appendChild(progressText);
+
+ const bar = document.createElement('div');
+ bar.className = 'achievement-progress';
+ const fill = document.createElement('i');
+ fill.style.width = `${percent}%`;
+ bar.appendChild(fill);
+ card.appendChild(bar);
+
+ target.appendChild(card);
+ });
+ };
+
+ const load = async () => {
+ if (!profileUsername) {
+ setStatus('用户不存在');
+ return;
+ }
+ const data = await getJson(`/api/users/${encodeURIComponent(profileUsername)}/achievements`);
+ if (!data.ok) {
+ setStatus(data.error || '加载成就失败');
+ return;
+ }
+ const bundle = data.achievements || {};
+ const official = bundle.official || {};
+ const card = bundle.card || {};
+ const displayName = data.display_name || data.username || profileUsername;
+ if (subtitleEl) {
+ subtitleEl.textContent = `@${data.username || profileUsername} · ${displayName}`;
+ }
+ if (officialSummaryEl) {
+ officialSummaryEl.textContent = `已获得 ${official.unlocked_count || 0} / ${official.total_count || 0}`;
+ }
+ if (cardSummaryEl) {
+ cardSummaryEl.textContent = `已获得 ${card.unlocked_count || 0} / ${card.total_count || 0}`;
+ }
+ renderRows(officialWrap, official.all || [], 'official');
+ renderRows(cardWrap, card.all || [], 'card');
+ };
+
+ load().catch((error) => setStatus(`网络错误: ${error}`));
+})();
diff --git a/static/js/card_editor.js b/static/js/card_editor.js
index 1de0a16..c6cff46 100644
--- a/static/js/card_editor.js
+++ b/static/js/card_editor.js
@@ -46,6 +46,10 @@
statusTracksJson: document.getElementById('card-status-tracks-json'),
endConditionsJson: document.getElementById('card-end-conditions-json'),
systemsJson: document.getElementById('card-systems-json'),
+ interactionMode: document.getElementById('card-interaction-mode'),
+ syncAllReplies: document.getElementById('card-sync-all-replies'),
+ allowPlayerDeath: document.getElementById('card-allow-player-death'),
+ allowRevive: document.getElementById('card-allow-revive'),
genreTags: document.getElementById('card-genre-tags'),
featureBadges: document.getElementById('card-feature-badges'),
worldLore: document.getElementById('card-world-lore'),
@@ -162,6 +166,9 @@
if (!fields.customAchievementsJson.value.trim()) {
fields.customAchievementsJson.value = '[]';
}
+ if (!fields.interactionMode.value) {
+ fields.interactionMode.value = 'hybrid';
+ }
};
const fillForm = (card) => {
@@ -172,6 +179,10 @@
const ui = schema.ui || {};
const hero = ui.hero_status || {};
const pack = card?.content_pack || {};
+ const systems = pack.systems && typeof pack.systems === 'object' ? pack.systems : {};
+ const runtimeOptions = systems.runtime_options && typeof systems.runtime_options === 'object'
+ ? systems.runtime_options
+ : {};
fields.title.value = card?.title || '';
fields.subtitle.value = card?.subtitle || '';
@@ -195,10 +206,17 @@
fields.metricsJson.value = prettyJson(Array.isArray(schema.metrics) ? schema.metrics : []);
fields.statusTracksJson.value = prettyJson(Array.isArray(ui.status_tracks) ? ui.status_tracks : []);
fields.endConditionsJson.value = prettyJson(Array.isArray(pack.end_conditions) ? pack.end_conditions : []);
- fields.systemsJson.value = prettyJson(pack.systems && typeof pack.systems === 'object' ? pack.systems : {});
+ fields.systemsJson.value = prettyJson(systems);
fields.customAchievementsJson.value = prettyJson(
Array.isArray(pack.custom_achievements) ? pack.custom_achievements : [],
);
+ const interactionMode = String(runtimeOptions.interaction_mode || 'hybrid').trim().toLowerCase();
+ fields.interactionMode.value = ['cooperative', 'split', 'hybrid'].includes(interactionMode)
+ ? interactionMode
+ : 'hybrid';
+ fields.syncAllReplies.checked = runtimeOptions.sync_all_player_ai_replies === true;
+ fields.allowPlayerDeath.checked = runtimeOptions.allow_player_death === true;
+ fields.allowRevive.checked = runtimeOptions.allow_revive === true;
fields.genreTags.value = (Array.isArray(pack.genre_tags) ? pack.genre_tags : []).join(', ');
fields.featureBadges.value = (Array.isArray(pack.feature_badges) ? pack.feature_badges : []).join(', ');
@@ -230,6 +248,19 @@
if (Array.isArray(draft.status_tracks)) fields.statusTracksJson.value = prettyJson(draft.status_tracks);
if (Array.isArray(draft.end_conditions)) fields.endConditionsJson.value = prettyJson(draft.end_conditions);
if (draft.systems && typeof draft.systems === 'object') fields.systemsJson.value = prettyJson(draft.systems);
+ if (typeof draft.interaction_mode === 'string') {
+ const mode = draft.interaction_mode.trim().toLowerCase();
+ fields.interactionMode.value = ['cooperative', 'split', 'hybrid'].includes(mode) ? mode : 'hybrid';
+ }
+ if (typeof draft.sync_all_player_ai_replies === 'boolean') {
+ fields.syncAllReplies.checked = draft.sync_all_player_ai_replies;
+ }
+ if (typeof draft.allow_player_death === 'boolean') {
+ fields.allowPlayerDeath.checked = draft.allow_player_death;
+ }
+ if (typeof draft.allow_revive === 'boolean') {
+ fields.allowRevive.checked = draft.allow_revive;
+ }
if (Array.isArray(draft.custom_achievements)) {
fields.customAchievementsJson.value = prettyJson(draft.custom_achievements);
}
@@ -304,6 +335,10 @@
metrics,
status_tracks: statusTracks,
systems_json: systems,
+ interaction_mode: fields.interactionMode.value,
+ sync_all_player_ai_replies: fields.syncAllReplies.checked,
+ allow_player_death: fields.allowPlayerDeath.checked,
+ allow_revive: fields.allowRevive.checked,
genre_tags: fields.genreTags.value.trim(),
feature_badges: fields.featureBadges.value.trim(),
@@ -333,6 +368,9 @@
saveBtn.textContent = '发布游戏卡';
cardMetaNote.textContent = '保存后会自动跳转到该卡的编辑页。';
ensureDefaults();
+ if (fields.syncAllReplies) fields.syncAllReplies.checked = false;
+ if (fields.allowPlayerDeath) fields.allowPlayerDeath.checked = false;
+ if (fields.allowRevive) fields.allowRevive.checked = false;
}
};
diff --git a/static/js/room.js b/static/js/room.js
index a89aa55..6c24b62 100644
--- a/static/js/room.js
+++ b/static/js/room.js
@@ -30,6 +30,7 @@
const endingOverlay = document.getElementById('ending-overlay');
const endingTitle = document.getElementById('ending-title');
const endingSummary = document.getElementById('ending-summary');
+ const endingSummaryLink = document.getElementById('ending-summary-link');
const readyBtn = document.getElementById('ready-btn');
const startBtn = document.getElementById('start-btn');
@@ -64,6 +65,7 @@
inviteLoading: false,
forcedEnding: null,
cluesCollapsed: true,
+ summaryUrl: `/room/${encodeURIComponent(roomId)}/summary`,
};
const PHASE_LABELS = {
@@ -457,6 +459,7 @@
const renderPlayers = () => {
playersList.textContent = '';
const players = localState.snapshot?.players || [];
+ const playerStates = localState.snapshot?.game_state?.global_flags?.player_states || {};
const me = players.find((p) => p.player_id === playerId);
const canManage = Boolean(me?.is_host && localState.snapshot?.status === 'lobby');
players.forEach((p) => {
@@ -507,6 +510,26 @@
badge.textContent = '离线';
li.appendChild(badge);
}
+ const ps = playerStates && typeof playerStates === 'object' ? playerStates[p.player_id] : null;
+ if (ps && typeof ps === 'object') {
+ const status = String(ps.status || '').trim().toLowerCase();
+ if (status === 'dead' || status === 'failed') {
+ const badge = document.createElement('span');
+ badge.className = 'badge danger';
+ badge.textContent = '阵亡';
+ li.appendChild(badge);
+ } else if (status === 'down') {
+ const badge = document.createElement('span');
+ badge.className = 'badge';
+ badge.textContent = '倒地';
+ li.appendChild(badge);
+ } else if (status === 'revived') {
+ const badge = document.createElement('span');
+ badge.className = 'badge ready';
+ badge.textContent = '复活';
+ li.appendChild(badge);
+ }
+ }
if (canManage && !p.is_host) {
const ops = document.createElement('div');
@@ -864,6 +887,9 @@
|| snapshot.game_state?.scene_description
|| '本局已结束。';
endingSummary.textContent = localizeStoryText(endingText);
+ if (endingSummaryLink) {
+ endingSummaryLink.href = localState.summaryUrl || `/room/${encodeURIComponent(roomId)}/summary`;
+ }
endingOverlay.classList.remove('hidden');
};
@@ -1138,6 +1164,9 @@
socket.on('room_snapshot', (snapshot) => {
localState.snapshot = snapshot;
+ if (snapshot?.status === 'ended' || snapshot?.game_state?.game_phase === 'ended') {
+ localState.summaryUrl = `/room/${encodeURIComponent(roomId)}/summary`;
+ }
const endedNow = snapshot?.status === 'ended' || snapshot?.game_state?.game_phase === 'ended';
if (!endedNow) {
localState.forcedEnding = null;
@@ -1199,6 +1228,7 @@
ending_state: payload?.ending_state || null,
ending_summary: payload?.ending_summary || '',
};
+ localState.summaryUrl = String(payload?.summary_url || '').trim() || `/room/${encodeURIComponent(roomId)}/summary`;
appendSystem('本局已结束。');
renderAll();
});
diff --git a/static/js/room_summary.js b/static/js/room_summary.js
new file mode 100644
index 0000000..6772ee3
--- /dev/null
+++ b/static/js/room_summary.js
@@ -0,0 +1,142 @@
+(function () {
+ const body = document.body;
+ const roomId = String(body.dataset.roomId || '').trim().toUpperCase();
+ const subtitleEl = document.getElementById('summary-subtitle');
+ const overviewEl = document.getElementById('summary-overview');
+ const mvpEl = document.getElementById('summary-mvp');
+ const playersEl = document.getElementById('summary-players');
+ const statusEl = document.getElementById('status');
+
+ const setStatus = (text) => {
+ if (statusEl) statusEl.textContent = text || '';
+ };
+
+ const getJson = async (url) => {
+ const res = await fetch(url);
+ return res.json();
+ };
+
+ const interactionLabel = (mode) => {
+ const map = {
+ cooperative: '强协作',
+ split: '分头行动',
+ hybrid: '混合模式',
+ };
+ return map[String(mode || '').toLowerCase()] || '混合模式';
+ };
+
+ const renderOverview = (summary) => {
+ if (!overviewEl) return;
+ overviewEl.textContent = '';
+ const rows = [
+ `房间:${summary.room_id || '-'}`,
+ `剧本:${summary.card_title || '未知剧本'}`,
+ `结局:${summary.ending_state || '未命名结局'}`,
+ `存活:${summary.alive_count || 0}/${summary.total_players || 0}`,
+ `总回合:${summary.rounds || 0}`,
+ `总行动:${summary.total_actions || 0}`,
+ `互动模式:${interactionLabel(summary.interaction_mode)}`,
+ `全员同步回复:${summary.sync_all_player_ai_replies ? '开启' : '关闭'}`,
+ `允许死亡:${summary.allow_player_death ? '开启' : '关闭'}`,
+ `允许复活:${summary.allow_revive ? '开启' : '关闭'}`,
+ `结束时间:${summary.updated_at || '-'}`,
+ ];
+ rows.forEach((text) => {
+ const div = document.createElement('div');
+ div.textContent = text;
+ overviewEl.appendChild(div);
+ });
+ };
+
+ const renderMvp = (summary) => {
+ if (!mvpEl) return;
+ mvpEl.textContent = '';
+ const mvp = summary.mvp || null;
+ if (!mvp) {
+ const p = document.createElement('p');
+ p.className = 'helper-text';
+ p.textContent = '本局未产生 MVP。';
+ mvpEl.appendChild(p);
+ return;
+ }
+ const badge = document.createElement('div');
+ badge.className = `user-badge ${mvp.avatar_url ? 'has-avatar' : ''}`;
+ badge.style.setProperty('--avatar-color', mvp.avatar_color || '#0070f2');
+ if (mvp.avatar_url) {
+ badge.style.setProperty('--avatar-url', `url('${String(mvp.avatar_url).replace(/'/g, "\\'")}')`);
+ }
+ const icon = document.createElement('i');
+ badge.appendChild(icon);
+ const text = document.createElement('span');
+ text.textContent = mvp.username ? `@${mvp.username}` : (mvp.name || '匿名');
+ badge.appendChild(text);
+ mvpEl.appendChild(badge);
+
+ const desc = document.createElement('p');
+ desc.className = 'helper-text';
+ const score = Number(mvp.contribution?.score || 0).toFixed(2);
+ desc.textContent = `贡献评分 ${score} · 状态 ${mvp.status_label || '-'}`;
+ mvpEl.appendChild(desc);
+
+ if (mvp.username) {
+ const link = document.createElement('a');
+ link.className = 'link-btn';
+ link.href = `/users/${encodeURIComponent(mvp.username)}`;
+ link.textContent = '查看玩家主页';
+ mvpEl.appendChild(link);
+ }
+ };
+
+ const renderPlayers = (summary) => {
+ if (!playersEl) return;
+ playersEl.textContent = '';
+ const rows = Array.isArray(summary.players) ? summary.players : [];
+ if (!rows.length) {
+ const li = document.createElement('li');
+ li.textContent = '暂无玩家数据';
+ playersEl.appendChild(li);
+ return;
+ }
+ rows.forEach((row) => {
+ const li = document.createElement('li');
+ const score = Number(row.contribution?.score || 0).toFixed(2);
+ const actionCount = Number(row.contribution?.actions || 0);
+ const primary = Number(row.contribution?.primary || 0);
+ const support = Number(row.contribution?.support || 0);
+ const blocked = Number(row.contribution?.blocked || 0);
+ const badge = row.is_mvp ? ' · MVP' : '';
+ const profileName = row.username ? `@${row.username}` : (row.name || '匿名');
+ li.textContent = `${profileName}${badge} · ${row.status_label || '-'} · 贡献 ${score} · 行动 ${actionCount} (触发 ${primary}/协同 ${support}/受阻 ${blocked})`;
+ if (row.username) {
+ const link = document.createElement('a');
+ link.className = 'link-btn';
+ link.style.marginLeft = '0.45rem';
+ link.href = `/users/${encodeURIComponent(row.username)}`;
+ link.textContent = '主页';
+ li.appendChild(link);
+ }
+ playersEl.appendChild(li);
+ });
+ };
+
+ const load = async () => {
+ if (!roomId) {
+ setStatus('房间不存在');
+ return;
+ }
+ const data = await getJson(`/api/rooms/${encodeURIComponent(roomId)}/summary`);
+ if (!data.ok) {
+ setStatus(data.error || '加载总结失败');
+ return;
+ }
+ const summary = data.summary || {};
+ if (subtitleEl) {
+ subtitleEl.textContent = summary.ending_summary || '本局已结束。';
+ }
+ renderOverview(summary);
+ renderMvp(summary);
+ renderPlayers(summary);
+ };
+
+ load().catch((error) => setStatus(`网络错误: ${error}`));
+})();
diff --git a/static/js/user_profile.js b/static/js/user_profile.js
index 93ae968..b5d787d 100644
--- a/static/js/user_profile.js
+++ b/static/js/user_profile.js
@@ -13,6 +13,7 @@
const heroEl = document.getElementById('profile-hero');
const metaEl = document.getElementById('profile-meta');
const achievementSummaryEl = document.getElementById('achievement-summary');
+ const achievementDetailLinkEl = document.getElementById('achievement-detail-link');
const achievementsListEl = document.getElementById('achievements-list');
const recentRoomsEl = document.getElementById('recent-rooms');
const invitePanelEl = document.getElementById('invite-panel');
@@ -221,6 +222,9 @@
});
renderAchievements(profile.achievements || {});
+ if (achievementDetailLinkEl && user.username) {
+ achievementDetailLinkEl.href = `/users/${encodeURIComponent(user.username)}/achievements`;
+ }
renderRecentRooms(profile.recent_rooms || []);
await renderInvitePanel(profile);
};
diff --git a/templates/achievements.html b/templates/achievements.html
new file mode 100644
index 0000000..89714fe
--- /dev/null
+++ b/templates/achievements.html
@@ -0,0 +1,47 @@
+
+
+
+
+
+ 成就中心 - Echo Tavern
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/card_editor.html b/templates/card_editor.html
index f216b52..4f6fda9 100644
--- a/templates/card_editor.html
+++ b/templates/card_editor.html
@@ -157,6 +157,30 @@
这是“高自由扩展区”。可写战斗、技能、资源、法术、阵营等结构,AI 会读取并在叙述中遵循。
+ 玩法控制
+
+
+ 会传给 AI 作为主持约束,决定对玩家行动关系的叙述倾向。
+
+
+
+
+
+
+
内容包
diff --git a/templates/index.html b/templates/index.html
index 8335ee7..02d2807 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -20,6 +20,8 @@
{% if user.role == 'admin' %}
管理后台
{% endif %}
+ 我的主页
+ 我的成就
联机大厅
玩家排行
游戏卡广场
diff --git a/templates/leaderboard.html b/templates/leaderboard.html
index e4f0bc6..a8b5bf6 100644
--- a/templates/leaderboard.html
+++ b/templates/leaderboard.html
@@ -13,6 +13,10 @@
按胜场、参与局数和总回合综合排序,点击用户名可查看个人主页。
diff --git a/templates/plaza.html b/templates/plaza.html
index a42fed9..9fd7fa8 100644
--- a/templates/plaza.html
+++ b/templates/plaza.html
@@ -16,6 +16,10 @@
{% if user and user.role == 'admin' %}
管理后台
{% endif %}
+ {% if user %}
+ 我的主页
+ 我的成就
+ {% endif %}
玩家排行
联机大厅
{% if user %}
diff --git a/templates/room.html b/templates/room.html
index 93e7586..7f3d40a 100644
--- a/templates/room.html
+++ b/templates/room.html
@@ -19,6 +19,10 @@
房间码 {{ room_id }} · 玩家 {{ player_name }}
+ {% if user %}
+
我的主页
+
我的成就
+ {% endif %}
玩家排行
游戏卡广场
返回主页
diff --git a/templates/user_profile.html b/templates/user_profile.html
index ddf6f35..ab92351 100644
--- a/templates/user_profile.html
+++ b/templates/user_profile.html
@@ -16,6 +16,10 @@
加载中...