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 + + + +
+
+

成就中心

+

加载中...

+
+
+ {% if user %} + 我的主页 + 我的成就 + {% endif %} + 联机大厅 + 玩家排行 + 返回大厅 +
+
+ +
+
+

官方成就

+

加载中...

+
+
+
+

故事卡成就

+

加载中...

+
+
+
+ +
+ + + + + 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 @@

按胜场、参与局数和总回合综合排序,点击用户名可查看个人主页。

+ {% if user %} + 我的主页 + 我的成就 + {% endif %} 联机大厅 返回大厅
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 %} 返回主页 @@ -118,6 +122,7 @@

本局结束

结局已触发。

+ 查看总结页 返回大厅
diff --git a/templates/room_summary.html b/templates/room_summary.html new file mode 100644 index 0000000..bad00dc --- /dev/null +++ b/templates/room_summary.html @@ -0,0 +1,48 @@ + + + + + + 对局总结 - Echo Tavern + + + +
+
+

对局总结

+

加载中...

+
+
+ {% if user %} + 我的主页 + 我的成就 + {% endif %} + 返回房间 + 联机大厅 + 返回大厅 +
+
+ +
+
+

本局概况

+
+

MVP

+
+
+ +
+

玩家状态

+
    +
    +
    + +
    + + + + + diff --git a/templates/rooms.html b/templates/rooms.html index 4d1bcac..ea28b86 100644 --- a/templates/rooms.html +++ b/templates/rooms.html @@ -13,6 +13,10 @@

    查看公开房间、输入房间码加入、快速重连你参与过的对局。

    + {% 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 @@

    加载中...

    + {% if user %} + 我的主页 + 我的成就 + {% endif %} 联机大厅 返回排行 返回大厅 @@ -29,6 +33,7 @@

    成就展柜

    加载中...

    + 查看完整成就页