commit fda3eb683cbc39a960d952fca0dd4cb32efa939e Author: LEEE Date: Sun Mar 8 09:16:44 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dce55c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +.pytest_cache/ +*.pyc +*.pyo +*.db +.env +.venv/ +.DS_Store +.claude/ +gitea.config diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2fb5a31 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +地产运维经营管理中台 - Claude 开发指南 +0. Global Protocols +所有操作必须严格遵循以下系统约束: + +交互语言:用户输出强制使用 中文。 + +代码主权:最终交付代码确保无冗余、企业级标准。 + +风格定义:整体代码风格始终定位为,精简高效、毫无冗余。该要求同样适用于注释与文档,且对于这两者,严格遵循非必要不形成的核心原则。 + +仅对需求做针对性改动:严禁影响用户现有的其他功能。 + +上下文检索:必须减少search/find/grep的次数。 + +判断依据:始终以项目代码、搜索结果作为判断依据,严禁使用一般知识进行猜测,允许向用户表明自己的不确定性。在调用编程语言的非内置库时,必须启用搜索,以文档作为判断依据进行编码。例如,在调用fastapi库对api接口进行封装时,必须使用联网搜索的最新结果作为依据、阅读官方文档说明编写代码,严禁使用已知的一般知识进行直接编码,这样会直接造成用户项目的崩坏。 + +MUST ultrathink in English. + +以下为记忆部分 + +记忆 当完成一次对话时,以精简的语言写入项目下文件夹memory,规则为 +| 层级 | 文件 | 用途 | +|------|------|------| +| 索引层 | MEMORY.md | 关于用户、能力概览、记忆索引。保持精简(<40行) | +| 项目层 | memory/projects.md | 各项目当前状态与待办 | +| 基础设施层 | memory/infra.md | 服务器、API、部署等配置速查 | +| 教训层 | memory/lessons.md | 踩过的坑,按严重程度分级 | +| 日志层 | memory/YYYY-MM-DD.md | 每日原始记录 | + +日志:当天发生的事写入 memory/YYYY-MM-DD.md,格式: +[PROJECT:名称] 标题 +结论: 一句话总结 +文件变更: 涉及的文件 +教训: 踩坑点(如有) +标签: #tag1 #tag2 + +项目状态:项目有进展时同步更新 memory/projects.md + +教训:踩坑后写入 memory/lessons.md + +MEMORY.md:只在索引变化时更新,保持精简 + +铁律 +记结论不记过程("部署成功,用了 nginx 反代" ✅ vs 三页操作日志 ❌) + +标签便于 memorySearch 检索 + +"Mental notes" don't survive session restarts. Files do. 想记住就写文件。 +以上为记忆部分 + +梳理:每次开始时先查找项目memory文件夹下的memory.md,确认目前应该干什么 + +项目概述 +地产运维经营管理中台系统,服务于80-150家地产客户、20名顾问。系统覆盖运维服务管理线、客户经营分析线、AI智能辅助线三大主线,采用FastAPI + PostgreSQL + Redis + Vue3 + 微信小程序的技术架构,私有化Docker部署。 + +技术栈 +层级 技术 版本/说明 +后端框架 FastAPI Python 3.11 +ORM SQLAlchemy 2.0+ +数据库 PostgreSQL 15+,带PGVector扩展 +缓存/状态 Redis 7+ +异步任务 Celery 配合Redis Broker +实时通信 WebSocket FastAPI原生支持 +小程序 微信原生/Uniapp JavaScript +PC后台 Vue3 + Element Plus TypeScript +部署 Docker + Compose 私有化部署 +网关 Nginx 反向代理 +项目阶段规划(6阶段18周) +text +阶段1 (1-2周): 基础架构 + 登录系统 + 小程序骨架 +阶段2 (3-6周): 客户管理 + 工单系统核心闭环 +阶段3 (7-9周): 在线客服系统 +阶段4 (10-12周): AI知识库系统 +阶段5 (13-15周): 巡检 + 领导交办 + 年度规划 +阶段6 (16-18周): KPI + 报表 + 经营分析 +开发规范 +代码结构规范 +text +backend/ +├── app/ +│ ├── api/ # 路由接口层 +│ ├── core/ # 核心配置、中间件 +│ ├── models/ # SQLAlchemy模型 +│ ├── schemas/ # Pydantic模型 +│ ├── services/ # 业务逻辑层 +│ └── utils/ # 工具函数 +├── migrations/ # Alembic迁移 +├── tasks/ # Celery任务 +├── requirements.txt +└── main.py +API设计规范 +RESTful风格 + +路径: /api/v1/{resource} + +方法: GET/POST/PUT/PATCH/DELETE + +响应格式统一: {"code": 200, "data": {}, "message": "success"} + +数据库命名规范 +表名: 小写复数,如 users, tickets + +字段: 小写蛇形,如 created_at, is_active + +主键: id (UUID自增混合) + +外键: {table}_id,如 customer_id \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d57e5a --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Echo-Tavern diff --git a/app.py b/app.py new file mode 100644 index 0000000..f147f53 --- /dev/null +++ b/app.py @@ -0,0 +1,1845 @@ +from __future__ import annotations + +import json +import re +from typing import Any + +from flask import Flask, jsonify, redirect, render_template, request, session, url_for +from flask_socketio import SocketIO, emit, join_room as sio_join_room +from werkzeug.security import check_password_hash, generate_password_hash + +from config import Config +from game.action_resolver import ActionResolver +from game.room_manager import RoomManager +from game.state_manager import StateManager +from game.story_engine import StoryEngine +from services.deepseek_client import DeepSeekClient +from services.persistence import SQLitePersistence + + +ERROR_TEXTS = { + "missing_room_or_player": "缺少房间信息,请重新进入房间。", + "room_not_found": "房间不存在,请返回大厅重新加入。", + "player_not_in_room": "你不在该房间中,请从大厅重新加入。", + "only_host_can_start": "只有房主可以开始游戏。", + "game_not_running": "游戏尚未开始,请先点击“房主开始”。", + "player_not_found": "玩家信息不存在,请重新进入房间。", + "empty_message": "聊天内容不能为空。", + "empty_action": "行动内容不能为空。", + "room_full": "房间已满,无法加入。", + "no_pending_actions": "当前没有待处理行动。请先提交行动。", + "round_resolving": "本轮正在结算,请稍候。", + "action_already_submitted": "你已提交本轮行动,请等待 AI 结算后再提交下一次。", + "game_in_progress": "本局已开始,不能中途加入。请等待下一局或使用原身份重连。", + "game_already_started": "游戏已经开始,无需重复开始。", + "players_not_ready": "仍有玩家未准备,无法开始。请等待所有玩家准备后再开始。", + "game_ended": "本局已结束,请返回大厅创建新房间。", + "username_taken": "登录账号已被占用。", + "invalid_username": "登录账号格式无效,仅支持字母、数字、下划线,长度 3-20。", + "weak_password": "密码至少需要 6 位字符。", + "bad_credentials": "用户名或密码错误。", + "auth_required": "请先登录账号。", + "invalid_card_id": "游戏卡不存在。", + "only_host_can_delete": "只有房主可以删除房间。", + "card_delete_forbidden": "你只能删除自己创建的非官方游戏卡。", + "card_in_use": "该游戏卡仍被房间使用中,不能删除。", + "invalid_assist_prompt": "请先输入游戏卡创意描述。", + "invalid_assist_modify_prompt": "请先输入修改指令。", + "card_edit_forbidden": "你只能修改自己创建的游戏卡。", + "invalid_card_payload": "游戏卡配置格式无效,请检查后重试。", + "admin_required": "需要管理员权限。", + "invalid_role": "权限组无效。", + "cannot_remove_last_admin": "系统必须保留至少一名管理员。", + "user_not_found": "用户不存在。", + "only_host_can_transfer": "只有房主可以转交房主权限。", + "cannot_transfer_to_self": "不能把房主权限转交给自己。", + "target_player_not_found": "目标玩家不存在。", + "only_host_can_kick": "只有房主可以踢出玩家。", + "only_host_can_manage_public": "只有房主可以设置房间公开状态。", + "only_lobby_can_kick": "游戏开始后不能踢人。", + "cannot_kick_host": "不能踢出房主。", + "spectator_readonly": "观战模式下不能执行该操作。", + "ai_invalid_json": "AI 返回了无效 JSON,本轮未结算,请稍后重试。", +} +ALLOWED_THEMES = {"minimal", "dopamine", "midnight", "mint", "sunset"} + + +def readable_error(error_code: str) -> str: + if error_code.startswith("ai_call_failed:"): + detail = error_code.split(":", 1)[1].strip() + return f"AI 调用失败:{detail or 'unknown'}。本轮未结算,请重试。" + if error_code.startswith("ai_invalid_schema:"): + detail = error_code.split(":", 1)[1].strip() + return f"AI 输出结构不符合约束({detail or 'schema_error'}),本轮未结算,请重试。" + return ERROR_TEXTS.get(error_code, f"操作失败:{error_code}") + + +def normalize_theme(theme_value: Any, default: str = "dopamine") -> str: + theme = str(theme_value or "").strip().lower() + if theme in ALLOWED_THEMES: + return theme + return default + + +def parse_json_object(raw_text: str) -> dict[str, Any] | None: + text = str(raw_text or "").strip() + if not text: + return None + candidates: list[str] = [text] + # Try fenced JSON blocks first. + fenced_blocks = re.findall(r"```(?:json)?\s*([\s\S]*?)```", text, flags=re.IGNORECASE) + for block in fenced_blocks: + block_text = block.strip() + if block_text: + candidates.append(block_text) + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1 and end > start: + candidates.append(text[start : end + 1]) + + decoder = json.JSONDecoder() + for candidate in candidates: + candidate = candidate.strip() + if not candidate: + continue + try: + payload = json.loads(candidate) + if isinstance(payload, dict): + return payload + except json.JSONDecodeError: + pass + # Try scanning for the first decodable object to handle wrappers/noise. + for idx, ch in enumerate(candidate): + if ch != "{": + continue + try: + payload, _ = decoder.raw_decode(candidate[idx:]) + if isinstance(payload, dict): + return payload + except json.JSONDecodeError: + continue + return None + + +def parse_text_list(raw_value: Any, *, limit: int = 8) -> list[str]: + if isinstance(raw_value, list): + rows = [str(item).strip() for item in raw_value if str(item).strip()] + else: + rows = [row.strip() for row in re.split(r"[\n,,]", str(raw_value or "")) if row.strip()] + return rows[:limit] + + +def parse_json_list(raw_value: Any) -> list[Any]: + if isinstance(raw_value, list): + return raw_value + text = str(raw_value or "").strip() + if not text: + return [] + try: + payload = json.loads(text) + if isinstance(payload, list): + return payload + except json.JSONDecodeError: + return [] + return [] + + +def normalize_assist_current(payload: Any) -> dict[str, Any]: + raw = payload if isinstance(payload, dict) else {} + + data_scope = raw.get("data_scope", raw.get("data_scope_json", {})) + if not isinstance(data_scope, dict): + data_scope = parse_json_object(data_scope) or {} + + visibility = raw.get("visibility", raw.get("visibility_json", {})) + if not isinstance(visibility, dict): + visibility = parse_json_object(visibility) or {} + + systems = raw.get("systems", raw.get("systems_json", {})) + if not isinstance(systems, dict): + systems = parse_json_object(systems) or {} + + metrics = raw.get("metrics", []) + if not isinstance(metrics, list): + metrics = parse_json_list(metrics) + metrics = [row for row in metrics if isinstance(row, dict)][:20] + + status_tracks = raw.get("status_tracks", []) + if not isinstance(status_tracks, list): + status_tracks = parse_json_list(status_tracks) + status_tracks = [row for row in status_tracks if isinstance(row, dict)][:20] + + end_conditions = raw.get("end_conditions", []) + if not isinstance(end_conditions, list): + end_conditions = parse_json_list(end_conditions) + end_conditions = [row for row in end_conditions if isinstance(row, dict)][:30] + + return { + "title": str(raw.get("title", "")).strip()[:84], + "subtitle": str(raw.get("subtitle", "")).strip()[:84], + "summary": str(raw.get("summary", "")).strip()[:420], + "opening_text": str(raw.get("opening_text", "")).strip()[:660], + "starter_goals": parse_text_list(raw.get("starter_goals", []), limit=8), + "metrics": metrics, + "status_tracks": status_tracks, + "data_scope": data_scope, + "visibility": visibility, + "state_fields": parse_text_list(raw.get("state_fields", []), limit=8), + "end_conditions": end_conditions, + "systems": systems, + "genre_tags": parse_text_list(raw.get("genre_tags", []), limit=10), + "feature_badges": parse_text_list(raw.get("feature_badges", []), limit=12), + "world_lore": str(raw.get("world_lore", "")).strip()[:3600], + "core_conflict": str(raw.get("core_conflict", "")).strip()[:1800], + "player_roles": parse_text_list(raw.get("player_roles", []), limit=12), + "interaction_modes": parse_text_list(raw.get("interaction_modes", []), limit=12), + "system_rules": parse_text_list(raw.get("system_rules", []), limit=14), + "npc_roster": parse_text_list(raw.get("npc_roster", []), limit=16), + "scene_blueprint": parse_text_list(raw.get("scene_blueprint", []), limit=16), + "ending_catalog": parse_text_list(raw.get("ending_catalog", []), limit=12), + "hidden_truths": parse_text_list(raw.get("hidden_truths", []), limit=12), + "inventory_label": str(raw.get("inventory_label", "")).strip()[:48], + "clues_label": str(raw.get("clues_label", "")).strip()[:48], + "choices_label": str(raw.get("choices_label", "")).strip()[:48], + "scene_label": str(raw.get("scene_label", "")).strip()[:48], + "hero_status_label": str(raw.get("hero_status_label", "")).strip()[:48], + "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], + "design_notes": parse_text_list(raw.get("design_notes", []), limit=8), + } + + +def _merge_unique_texts(base_value: Any, patch_value: Any, *, limit: int) -> list[str]: + base = parse_text_list(base_value, limit=limit * 3) + patch = parse_text_list(patch_value, limit=limit * 3) + merged = [] + seen: set[str] = set() + for row in base + patch: + if not row or row in seen: + continue + seen.add(row) + merged.append(row) + if len(merged) >= limit: + break + return merged + + +def _merge_object_list_by_key(base_value: Any, patch_value: Any, *, key_field: str, limit: int) -> list[dict[str, Any]]: + base_rows = base_value if isinstance(base_value, list) else [] + patch_rows = patch_value if isinstance(patch_value, list) else [] + out: list[dict[str, Any]] = [] + index_by_key: dict[str, int] = {} + + def push_row(row: Any) -> None: + if not isinstance(row, dict): + return + key = str(row.get(key_field, "")).strip() + if key: + if key in index_by_key: + out[index_by_key[key]] = {**out[index_by_key[key]], **row} + return + index_by_key[key] = len(out) + out.append(dict(row)) + + for row in base_rows: + push_row(row) + for row in patch_rows: + push_row(row) + return out[:limit] + + +def _deep_merge(base: Any, patch: Any) -> Any: + if not isinstance(base, dict) or not isinstance(patch, dict): + return patch + merged: dict[str, Any] = {**base} + for key, value in patch.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def normalize_card_patch(raw_patch: Any) -> dict[str, Any]: + patch = raw_patch if isinstance(raw_patch, dict) else {} + normalized: dict[str, Any] = {} + + scalar_limits = { + "title": 84, + "subtitle": 84, + "summary": 420, + "opening_text": 660, + "world_lore": 3600, + "core_conflict": 1800, + "inventory_label": 48, + "clues_label": 48, + "choices_label": 48, + "scene_label": 48, + "hero_status_label": 48, + "hero_status_color": 7, + "cover_url": 720, + "room_background_url": 720, + } + for key, limit in scalar_limits.items(): + if key in patch: + normalized[key] = str(patch.get(key, "")).strip()[:limit] + + text_list_limits = { + "starter_goals": 8, + "state_fields": 8, + "genre_tags": 10, + "feature_badges": 12, + "player_roles": 12, + "interaction_modes": 12, + "system_rules": 14, + "npc_roster": 16, + "scene_blueprint": 16, + "ending_catalog": 12, + "hidden_truths": 12, + "design_notes": 8, + } + for key, limit in text_list_limits.items(): + if key in patch: + normalized[key] = parse_text_list(patch.get(key, []), limit=limit) + + object_keys = {"data_scope", "visibility", "systems"} + for key in object_keys: + value = patch.get(key) + if isinstance(value, dict): + normalized[key] = value + + if isinstance(patch.get("metrics"), list): + normalized["metrics"] = [row for row in patch.get("metrics", []) if isinstance(row, dict)][:20] + if isinstance(patch.get("status_tracks"), list): + normalized["status_tracks"] = [row for row in patch.get("status_tracks", []) if isinstance(row, dict)][:20] + if isinstance(patch.get("end_conditions"), list): + normalized["end_conditions"] = [row for row in patch.get("end_conditions", []) if isinstance(row, dict)][:30] + + return normalized + + +def apply_card_patch(current: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]: + merged = dict(current) + + replace_keys = { + "title", + "subtitle", + "summary", + "opening_text", + "world_lore", + "core_conflict", + "inventory_label", + "clues_label", + "choices_label", + "scene_label", + "hero_status_label", + "hero_status_color", + "cover_url", + "room_background_url", + } + for key in replace_keys: + if key in patch and str(patch.get(key, "")).strip(): + merged[key] = patch[key] + + text_list_limits = { + "starter_goals": 8, + "state_fields": 8, + "genre_tags": 10, + "feature_badges": 12, + "player_roles": 12, + "interaction_modes": 12, + "system_rules": 14, + "npc_roster": 16, + "scene_blueprint": 16, + "ending_catalog": 12, + "hidden_truths": 12, + "design_notes": 8, + } + for key, limit in text_list_limits.items(): + if key in patch: + merged[key] = _merge_unique_texts(merged.get(key, []), patch.get(key, []), limit=limit) + + if "metrics" in patch: + merged["metrics"] = _merge_object_list_by_key( + merged.get("metrics", []), patch.get("metrics", []), key_field="key", limit=20 + ) + if "status_tracks" in patch: + merged["status_tracks"] = _merge_object_list_by_key( + merged.get("status_tracks", []), patch.get("status_tracks", []), key_field="key", limit=20 + ) + if "end_conditions" in patch: + merged["end_conditions"] = _merge_object_list_by_key( + merged.get("end_conditions", []), patch.get("end_conditions", []), key_field="id", limit=30 + ) + + if "data_scope" in patch: + merged["data_scope"] = _deep_merge(merged.get("data_scope", {}), patch.get("data_scope", {})) + if "visibility" in patch: + merged["visibility"] = _deep_merge(merged.get("visibility", {}), patch.get("visibility", {})) + if "systems" in patch: + merged["systems"] = _deep_merge(merged.get("systems", {}), patch.get("systems", {})) + + return merged + + +def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: + app = Flask(__name__) + app.config.from_object(config_object) + + socketio = SocketIO(app, cors_allowed_origins="*", async_mode="threading") + + persistence = SQLitePersistence(app.config["DB_PATH"]) + story_engine = StoryEngine() + state_manager = StateManager(persistence, story_engine) + action_resolver = ActionResolver() + deepseek_client = DeepSeekClient( + api_key=app.config.get("DEEPSEEK_API_KEY", ""), + base_url=app.config.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), + model=app.config.get("DEEPSEEK_MODEL", "deepseek-chat"), + timeout=float(app.config.get("DEEPSEEK_TIMEOUT", 25)), + connect_timeout=float(app.config.get("DEEPSEEK_CONNECT_TIMEOUT", 8)), + read_timeout=float(app.config.get("DEEPSEEK_READ_TIMEOUT", app.config.get("DEEPSEEK_TIMEOUT", 25))), + retries=int(app.config.get("DEEPSEEK_RETRIES", 2)), + ) + room_manager = RoomManager( + persistence, + state_manager, + story_engine, + action_resolver, + deepseek_client, + max_players=int(app.config.get("MAX_ROOM_PLAYERS", 8)), + max_action_length=int(app.config.get("MAX_ACTION_LENGTH", 280)), + ) + + app.extensions["persistence"] = persistence + app.extensions["story_engine"] = story_engine + app.extensions["room_manager"] = room_manager + + def current_user() -> dict[str, Any] | None: + user_id = session.get("user_id") + if not user_id: + return None + return persistence.get_user_by_id(str(user_id)) + + def serialize_user(user: dict[str, Any] | None) -> dict[str, Any] | None: + if user is None: + return None + return { + "user_id": user["user_id"], + "username": user["username"], + "display_name": user.get("display_name", user["username"]), + "public_name": user["username"], + "role": user.get("role", "player"), + "avatar_color": user.get("avatar_color", "#0070f2"), + "avatar_url": user.get("avatar_url", ""), + "theme": normalize_theme(user.get("theme", "minimal")), + "bio": user.get("bio", ""), + } + + def require_admin_json() -> tuple[dict[str, Any] | None, Any | None]: + user = current_user() + if user is None: + return None, (jsonify({"ok": False, "error": readable_error("auth_required")}), 401) + if str(user.get("role", "player")) != "admin": + return None, (jsonify({"ok": False, "error": readable_error("admin_required")}), 403) + return user, None + + @app.get("/") + def index() -> str: + return render_template("index.html", user=serialize_user(current_user())) + + @app.get("/plaza") + def plaza_page() -> str: + return render_template("plaza.html", user=serialize_user(current_user())) + + @app.get("/cards/new") + def card_new_page() -> Any: + user = current_user() + if user is None: + return redirect(url_for("login_page")) + return render_template("card_editor.html", user=serialize_user(user), card_id="") + + @app.get("/cards//edit") + def card_edit_page(card_id: str) -> Any: + user = current_user() + if user is None: + return redirect(url_for("login_page")) + card = persistence.get_game_card(card_id.strip()) + if card is None: + return redirect(url_for("plaza_page")) + is_admin = str(user.get("role", "player")) == "admin" + is_creator = str(card.get("creator_user_id") or "") == str(user["user_id"]) + if not is_admin and not is_creator: + return redirect(url_for("plaza_page")) + return render_template("card_editor.html", user=serialize_user(user), card_id=card["card_id"]) + + @app.get("/login") + def login_page() -> str: + return render_template("login.html", user=serialize_user(current_user())) + + @app.get("/register") + def register_page() -> str: + return render_template("register.html", user=serialize_user(current_user())) + + @app.get("/admin") + def admin_page() -> Any: + user = current_user() + if user is None or str(user.get("role", "player")) != "admin": + return redirect(url_for("index")) + return render_template("admin.html", user=serialize_user(user)) + + @app.post("/api/auth/register") + def auth_register_api() -> Any: + payload = request.get_json(silent=True) or {} + username = str(payload.get("username", "")).strip().lower() + password = str(payload.get("password", "")) + display_name = username + + if not re.match(r"^[a-zA-Z0-9_]{3,20}$", username): + return jsonify({"ok": False, "error": readable_error("invalid_username")}), 400 + if len(password) < 6: + return jsonify({"ok": False, "error": readable_error("weak_password")}), 400 + + try: + user = persistence.create_user( + username=username, + password_hash=generate_password_hash(password, method="pbkdf2:sha256"), + display_name=display_name[:24] or username, + ) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + session["user_id"] = user["user_id"] + return jsonify({"ok": True, "user": serialize_user(user)}) + + @app.post("/api/auth/login") + def auth_login_api() -> Any: + payload = request.get_json(silent=True) or {} + username = str(payload.get("username", "")).strip().lower() + password = str(payload.get("password", "")) + + user = persistence.get_user_by_username(username) + if user is None or not check_password_hash(user["password_hash"], password): + return jsonify({"ok": False, "error": readable_error("bad_credentials")}), 400 + + session["user_id"] = user["user_id"] + return jsonify({"ok": True, "user": serialize_user(user)}) + + @app.post("/api/auth/logout") + def auth_logout_api() -> Any: + session.pop("user_id", None) + return jsonify({"ok": True}) + + @app.get("/api/me") + def me_api() -> Any: + user = current_user() + return jsonify({"ok": True, "logged_in": bool(user), "user": serialize_user(user)}) + + @app.patch("/api/me/profile") + def me_profile_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + + payload = request.get_json(silent=True) or {} + avatar_color = payload.get("avatar_color") + avatar_url = payload.get("avatar_url") + theme = payload.get("theme") + bio = payload.get("bio") + + if avatar_color is not None: + avatar_color = str(avatar_color).strip() + if not re.match(r"^#[0-9a-fA-F]{6}$", avatar_color): + avatar_color = user.get("avatar_color", "#0070f2") + if avatar_url is not None: + avatar_url = str(avatar_url).strip()[:280] + if avatar_url and not ( + avatar_url.startswith("/") + or avatar_url.startswith("http://") + or avatar_url.startswith("https://") + ): + avatar_url = "" + if bio is not None: + bio = str(bio).strip()[:160] + if theme is not None: + theme = normalize_theme(theme, normalize_theme(user.get("theme", "minimal"))) + + try: + updated = persistence.update_user_profile( + user["user_id"], + avatar_color=avatar_color, + avatar_url=avatar_url, + theme=theme, + bio=bio, + ) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + return jsonify({"ok": True, "user": serialize_user(updated)}) + + def build_card_config(payload: dict[str, Any]) -> dict[str, Any]: + def parse_bool(value: Any, default: bool) -> bool: + if isinstance(value, bool): + return value + text = str(value or "").strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off"}: + return False + return default + + title = str(payload.get("title", "")).strip()[:84] + subtitle = str(payload.get("subtitle", "")).strip()[:84] + summary = str(payload.get("summary", "")).strip()[:420] + cover_url = str(payload.get("cover_url", "")).strip()[:720] or "/static/img/cards/fogharbor.svg" + opening_text = str(payload.get("opening_text", "")).strip()[:660] + if not title or not summary or not opening_text: + raise ValueError("invalid_card_payload") + + starter_goals = parse_text_list(payload.get("starter_goals", []), limit=8) + if not starter_goals: + starter_goals = ["先确认队伍分工", "与 NPC 交谈获取线索", "推进第一轮行动"] + + raw_metrics = payload.get("metrics", []) + metric_rows = raw_metrics if isinstance(raw_metrics, list) else parse_json_list(raw_metrics) + metrics: list[dict[str, Any]] = [] + if isinstance(metric_rows, list): + for idx, row in enumerate(metric_rows[:8]): + if not isinstance(row, dict): + continue + key = str(row.get("key", "")).strip()[:96] + label = str(row.get("label", "")).strip()[:72] + if not key or not label: + continue + min_val = row.get("min", 0) + max_val = row.get("max", 10) + init_val = row.get("initial", 0) + try: + min_num = int(min_val) + max_num = int(max_val) + init_num = int(init_val) + except (TypeError, ValueError): + min_num, max_num, init_num = 0, 10, 0 + if max_num < min_num: + max_num = min_num + init_num = max(min_num, min(max_num, init_num)) + color = str(row.get("color", "")).strip() + if not re.match(r"^#[0-9a-fA-F]{6}$", color): + color = "" + metrics.append( + { + "key": key, + "label": label, + "min": min_num, + "max": max_num, + "initial": init_num, + "color": color, + } + ) + + if not metrics: + state_fields = parse_text_list(payload.get("state_fields", []), limit=4) + for idx, label in enumerate(state_fields): + safe_key = re.sub(r"[^a-z0-9_]+", "_", label.lower())[:20] or f"metric_{idx+1}" + metrics.append({"key": safe_key, "label": label, "min": 0, "max": 100, "initial": 0, "color": ""}) + if not metrics: + metrics = [ + {"key": "tension", "label": "局势紧张度", "min": 0, "max": 100, "initial": 15, "color": "#ff9f43"}, + {"key": "progress", "label": "推进度", "min": 0, "max": 100, "initial": 0, "color": "#3b82f6"}, + ] + + metric_by_key = {row["key"]: row for row in metrics} + hero_status_label = str(payload.get("hero_status_label", "")).strip() + hero_status_key = str(payload.get("hero_status_key", "")).strip() + hero_status_color = str(payload.get("hero_status_color", "")).strip() + if not re.match(r"^#[0-9a-fA-F]{6}$", hero_status_color): + hero_status_color = "" + + selected_metric = metric_by_key.get(hero_status_key) if hero_status_key else None + if selected_metric is None and hero_status_label: + selected_metric = next((row for row in metrics if row["label"] == hero_status_label), None) + if selected_metric is None: + selected_metric = metrics[0] + + raw_tracks = payload.get("status_tracks", []) + track_rows = raw_tracks if isinstance(raw_tracks, list) else parse_json_list(raw_tracks) + status_tracks: list[dict[str, Any]] = [] + for row in track_rows[:6]: + if isinstance(row, str): + metric = metric_by_key.get(row.strip()) + if metric: + status_tracks.append( + { + "key": metric["key"], + "label": metric["label"], + "min": metric["min"], + "max": metric["max"], + "color": metric.get("color") or "", + } + ) + elif isinstance(row, dict): + key = str(row.get("key", "")).strip() + if not key or key not in metric_by_key: + continue + metric = metric_by_key[key] + color = str(row.get("color", "")).strip() + if not re.match(r"^#[0-9a-fA-F]{6}$", color): + color = metric.get("color", "") + raw_min = row.get("min", metric["min"]) + raw_max = row.get("max", metric["max"]) + try: + track_min = int(raw_min) + track_max = int(raw_max) + except (TypeError, ValueError): + track_min = int(metric["min"]) + track_max = int(metric["max"]) + if track_max < track_min: + track_max = track_min + status_tracks.append( + { + "key": key, + "label": str(row.get("label", metric["label"])).strip()[:72] or metric["label"], + "min": track_min, + "max": track_max, + "color": color, + } + ) + if not status_tracks: + status_tracks = [ + { + "key": selected_metric["key"], + "label": hero_status_label or selected_metric["label"], + "min": selected_metric["min"], + "max": selected_metric["max"], + "color": hero_status_color or selected_metric.get("color", ""), + } + ] + + labels = { + "inventory": str(payload.get("inventory_label", "")).strip() or "我的道具", + "clues": str(payload.get("clues_label", "")).strip() or "关键信息", + "choices": str(payload.get("choices_label", "")).strip() or "可执行动作", + "scene": str(payload.get("scene_label", "")).strip() or "当前场景", + } + + scope_raw = payload.get("data_scope", {}) + if not isinstance(scope_raw, dict) or not scope_raw: + scope_json_raw = payload.get("data_scope_json", {}) + scope_raw = scope_json_raw if isinstance(scope_json_raw, dict) else (parse_json_object(scope_json_raw) or {}) + allowed_scope = {"shared", "per_player"} + data_scope = { + "metrics": str(scope_raw.get("metrics", "shared")).strip().lower(), + "inventory": str(scope_raw.get("inventory", "per_player")).strip().lower(), + "clues": str(scope_raw.get("clues", "shared")).strip().lower(), + } + for key in list(data_scope.keys()): + if data_scope[key] not in allowed_scope: + data_scope[key] = "shared" if key != "inventory" else "per_player" + + visibility_raw = payload.get("visibility", {}) + if not isinstance(visibility_raw, dict) or not visibility_raw: + visibility_json_raw = payload.get("visibility_json", {}) + visibility_raw = visibility_json_raw if isinstance(visibility_json_raw, dict) else (parse_json_object(visibility_json_raw) or {}) + visibility = { + "show_all_player_metrics": parse_bool(visibility_raw.get("show_all_player_metrics", True), True), + "show_all_player_inventory": parse_bool(visibility_raw.get("show_all_player_inventory", True), True), + } + + state_schema = { + "metrics": metrics, + "labels": labels, + "data_scope": data_scope, + "visibility": visibility, + "ui": { + "room_background_url": str(payload.get("room_background_url", "")).strip()[:720] or cover_url, + "hero_status": { + "key": selected_metric["key"], + "label": hero_status_label or selected_metric["label"], + "min": selected_metric["min"], + "max": selected_metric["max"], + "color": hero_status_color or selected_metric.get("color", "") or "#ffb500", + }, + "status_tracks": status_tracks, + }, + } + + systems_json_raw = payload.get("systems_json", {}) + systems_payload = systems_json_raw if isinstance(systems_json_raw, dict) else parse_json_object(systems_json_raw) + if not isinstance(systems_payload, dict): + systems_payload = {} + + content_pack = { + "genre_tags": parse_text_list(payload.get("genre_tags", []), limit=10), + "feature_badges": parse_text_list(payload.get("feature_badges", []), limit=12), + "world_lore": str(payload.get("world_lore", "")).strip()[:3600], + "core_conflict": str(payload.get("core_conflict", "")).strip()[:1800], + "player_roles": parse_text_list(payload.get("player_roles", []), limit=12), + "interaction_modes": parse_text_list(payload.get("interaction_modes", []), limit=12), + "system_rules": parse_text_list(payload.get("system_rules", []), limit=14), + "npc_roster": parse_text_list(payload.get("npc_roster", []), limit=16), + "scene_blueprint": parse_text_list(payload.get("scene_blueprint", []), limit=16), + "ending_catalog": parse_text_list(payload.get("ending_catalog", []), limit=12), + "hidden_truths": parse_text_list(payload.get("hidden_truths", []), limit=12), + "end_conditions": parse_json_list(payload.get("end_conditions", []))[:20], + "systems": systems_payload, + } + if not content_pack["genre_tags"]: + content_pack["genre_tags"] = ["自定义冒险"] + if not content_pack["feature_badges"]: + content_pack["feature_badges"] = ["多人协作", "AI主持"] + if not content_pack["world_lore"]: + content_pack["world_lore"] = f"{title} 的世界被异常事件覆盖,玩家需要在信息不完整中推进调查。" + if not content_pack["core_conflict"]: + content_pack["core_conflict"] = "在有限回合中达成目标,同时处理队伍内分歧与外部风险。" + + return { + "title": title, + "subtitle": subtitle, + "summary": summary, + "cover_url": cover_url, + "opening_text": opening_text, + "starter_goals": starter_goals, + "state_schema": state_schema, + "content_pack": content_pack, + } + + @app.get("/api/cards") + def list_cards_api() -> Any: + cards = persistence.list_game_cards() + return jsonify({"ok": True, "cards": cards}) + + @app.get("/api/cards/") + def get_card_api(card_id: str) -> Any: + card = persistence.get_game_card(card_id.strip()) + if card is None: + return jsonify({"ok": False, "error": readable_error("invalid_card_id")}), 404 + return jsonify({"ok": True, "card": card}) + + @app.get("/api/admin/users") + def admin_users_api() -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + users = persistence.list_users(limit=600) + return jsonify({"ok": True, "users": users}) + + @app.patch("/api/admin/users//role") + def admin_update_user_role_api(user_id: str) -> Any: + me, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + role = str(payload.get("role", "")).strip() + target_id = user_id.strip() + try: + updated = persistence.update_user_role(target_id, role) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + if me and target_id == me["user_id"] and role != "admin": + session.pop("user_id", None) + return jsonify({"ok": True, "user": serialize_user(updated)}) + + @app.get("/api/admin/rooms") + def admin_rooms_api() -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + rows = persistence.list_rooms_admin(limit=400) + card_map = {row["card_id"]: row for row in persistence.list_game_cards()} + for row in rows: + row["card_title"] = card_map.get(row.get("game_card_id", ""), {}).get("title", "未知剧本") + return jsonify({"ok": True, "rooms": rows}) + + @app.delete("/api/admin/rooms/") + def admin_delete_room_api(room_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + safe_room_id = room_id.strip().upper() + try: + room_manager.force_delete_room(safe_room_id) + return jsonify({"ok": True, "room_id": safe_room_id}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/admin/cards") + def admin_cards_api() -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + cards = persistence.list_game_cards() + for card in cards: + creator = persistence.get_user_by_id(card.get("creator_user_id")) + card["creator_display_name"] = creator["username"] if creator else ("官方" if card.get("is_official") else "玩家") + return jsonify({"ok": True, "cards": cards}) + + @app.patch("/api/admin/cards/") + def admin_patch_card_api(card_id: str) -> Any: + _, error_resp = require_admin_json() + if error_resp: + return error_resp + payload = request.get_json(silent=True) or {} + is_official = bool(payload.get("is_official", False)) + try: + card = persistence.set_game_card_official(card_id.strip(), is_official) + return jsonify({"ok": True, "card": card}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.delete("/api/admin/cards/") + def admin_delete_card_api(card_id: str) -> Any: + user, error_resp = require_admin_json() + if error_resp: + return error_resp + try: + persistence.delete_game_card(card_id.strip(), user["user_id"], as_admin=True) + return jsonify({"ok": True, "card_id": card_id.strip()}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.delete("/api/cards/") + def delete_card_api(card_id: str) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + try: + persistence.delete_game_card(card_id.strip(), user["user_id"]) + return jsonify({"ok": True, "card_id": card_id.strip()}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.post("/api/cards/assist") + def assist_card_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + + payload = request.get_json(silent=True) or {} + concept = str(payload.get("concept", "")).strip() + tone = str(payload.get("tone", "")).strip() or "悬疑冒险" + difficulty = str(payload.get("difficulty", "")).strip() or "中等" + if not concept: + return jsonify({"ok": False, "error": readable_error("invalid_assist_prompt")}), 400 + + # Keep schema concise to avoid model truncation and JSON parse failures. + schema_hint = { + "title": "string", + "subtitle": "string", + "summary": "string", + "opening_text": "string", + "starter_goals": ["string", "string", "string"], + "metrics": [{"key": "string", "label": "string", "min": 0, "max": 100, "initial": 0, "color": "#RRGGBB"}], + "status_tracks": [{"key": "string", "label": "string", "min": 0, "max": 100, "color": "#RRGGBB"}], + "data_scope": {"metrics": "shared|per_player", "inventory": "shared|per_player", "clues": "shared|per_player"}, + "visibility": {"show_all_player_metrics": "boolean", "show_all_player_inventory": "boolean"}, + "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}}, + "genre_tags": ["string"], + "feature_badges": ["string"], + "world_lore": "string", + "core_conflict": "string", + "player_roles": ["string"], + "interaction_modes": ["string"], + "system_rules": ["string"], + "npc_roster": ["string"], + "scene_blueprint": ["string"], + "ending_catalog": ["string"], + "hidden_truths": ["string"], + "labels": {"inventory": "string", "clues": "string", "choices": "string", "scene": "string"}, + "hero_status_label": "string", + "hero_status_color": "#RRGGBB", + "cover_url": "string", + "room_background_url": "string", + "design_notes": ["string", "string", "string"], + } + messages_primary = [ + { + "role": "system", + "content": ( + "你是多人联机文字冒险的高级策划。" + "必须只输出一个合法 JSON 对象,不能输出 markdown 或解释。" + "所有文案使用中文,内容具体可落地。" + ), + }, + { + "role": "user", + "content": ( + "根据以下创意生成游戏卡草稿:" + f"\n创意:{concept}\n风格:{tone}\n难度:{difficulty}" + "\n输出必须包含这些字段(可留空数组但字段必须存在):" + f"\n{json.dumps(schema_hint, ensure_ascii=False)}" + "\n约束:starter_goals 3 条;metrics 2-4 条;status_tracks 2-4 条;" + "genre_tags 3-6 条;feature_badges 3-6 条;design_notes 3 条;" + "world_lore 与 core_conflict 需为非空中文段落。" + ), + }, + ] + ai_result = deepseek_client.chat_json(messages_primary, temperature=0.7, max_tokens=1800) + draft: dict[str, Any] | None = None + ai_error = "" + ai_error_detail = "" + + if ai_result.get("ok"): + draft = parse_json_object(ai_result.get("content", "")) + if not isinstance(draft, dict): + ai_error = "invalid_json_from_model" + else: + ai_error = str(ai_result.get("error", "assist_ai_failed")) + ai_error_detail = str(ai_result.get("detail", "")) + return jsonify( + { + "ok": False, + "error": f"AI 生成失败:{ai_error}", + "ai_error": ai_error, + "ai_error_detail": ai_error_detail, + } + ), 502 + + # Second pass: ask the model to repair to strict JSON if first output is unparsable. + if not isinstance(draft, dict) and ai_result.get("ok"): + repair_messages = [ + { + "role": "system", + "content": "你是 JSON 修复器。你只能输出一个合法 JSON 对象,不能输出其他文本。", + }, + { + "role": "user", + "content": ( + "把下面文本修复成合法 JSON 对象,且字段至少包含:" + f"{json.dumps(schema_hint, ensure_ascii=False)}" + "\n文本如下:\n" + + str(ai_result.get("content", ""))[:5000] + ), + }, + ] + repair_result = deepseek_client.chat_json(repair_messages, temperature=0.2, max_tokens=1800) + if repair_result.get("ok"): + draft = parse_json_object(repair_result.get("content", "")) + if not isinstance(draft, dict): + ai_error = f"{ai_error or 'invalid_json_from_model'};repair_failed" + if not ai_error_detail: + ai_error_detail = str(repair_result.get("detail", "")) + + if not isinstance(draft, dict): + return jsonify( + { + "ok": False, + "error": f"AI 返回内容无法解析为合法 JSON:{ai_error or 'invalid_json_from_model'}", + "ai_error": ai_error or "invalid_json_from_model", + "ai_error_detail": ai_error_detail, + } + ), 502 + + def _to_str_list(value: Any, limit: int = 5) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()][:limit] + + labels = draft.get("labels", {}) if isinstance(draft.get("labels"), dict) else {} + result = { + "title": str(draft.get("title", ""))[:84], + "subtitle": str(draft.get("subtitle", ""))[:84], + "summary": str(draft.get("summary", ""))[:420], + "opening_text": str(draft.get("opening_text", ""))[:660], + "starter_goals": _to_str_list(draft.get("starter_goals"), 5), + "metrics": draft.get("metrics", []) if isinstance(draft.get("metrics"), list) else [], + "status_tracks": draft.get("status_tracks", []) if isinstance(draft.get("status_tracks"), list) else [], + "data_scope": draft.get("data_scope", {}) if isinstance(draft.get("data_scope"), dict) else {}, + "visibility": draft.get("visibility", {}) if isinstance(draft.get("visibility"), dict) else {}, + "state_fields": _to_str_list(draft.get("state_fields"), 4), + "end_conditions": draft.get("end_conditions", []) if isinstance(draft.get("end_conditions"), list) else [], + "systems": draft.get("systems", {}) if isinstance(draft.get("systems"), dict) else {}, + "genre_tags": _to_str_list(draft.get("genre_tags"), 8), + "feature_badges": _to_str_list(draft.get("feature_badges"), 8), + "world_lore": str(draft.get("world_lore", ""))[:3600], + "core_conflict": str(draft.get("core_conflict", ""))[:1800], + "player_roles": _to_str_list(draft.get("player_roles"), 8), + "interaction_modes": _to_str_list(draft.get("interaction_modes"), 8), + "system_rules": _to_str_list(draft.get("system_rules"), 10), + "npc_roster": _to_str_list(draft.get("npc_roster"), 12), + "scene_blueprint": _to_str_list(draft.get("scene_blueprint"), 10), + "ending_catalog": _to_str_list(draft.get("ending_catalog"), 8), + "hidden_truths": _to_str_list(draft.get("hidden_truths"), 8), + "inventory_label": str(labels.get("inventory", ""))[:48], + "clues_label": str(labels.get("clues", ""))[:48], + "choices_label": str(labels.get("choices", ""))[:48], + "scene_label": str(labels.get("scene", ""))[:48], + "hero_status_label": str(draft.get("hero_status_label", ""))[:48], + "hero_status_color": str(draft.get("hero_status_color", ""))[:7], + "cover_url": str(draft.get("cover_url", ""))[:720], + "room_background_url": str(draft.get("room_background_url", ""))[:720], + "design_notes": _to_str_list(draft.get("design_notes"), 5), + } + if not result["title"]: + result["title"] = f"{''.join(concept.split())[:24] or '新世界'}行动录" + if not result["subtitle"]: + result["subtitle"] = f"{tone} / 多人合作" + if not result["summary"]: + result["summary"] = f"围绕「{concept}」展开的多人冒险,玩家需要共享信息并做出关键抉择。" + if not result["opening_text"]: + result["opening_text"] = f"你们抵达「{concept}」现场,异常规则已经启动。" + if not re.match(r"^#[0-9a-fA-F]{6}$", result["hero_status_color"]): + result["hero_status_color"] = "#ff8c2f" + if not result["cover_url"]: + result["cover_url"] = "/static/img/cards/fogharbor.svg" + if not result["room_background_url"]: + result["room_background_url"] = result["cover_url"] + if not result["starter_goals"]: + result["starter_goals"] = ["先统一目标分工", "与关键NPC交谈", "推进第一轮调查"] + if not result["state_fields"]: + result["state_fields"] = ["紧张度", "推进度", "资源储备"] + if not result["metrics"]: + result["metrics"] = [ + {"key": "tension", "label": "紧张度", "min": 0, "max": 100, "initial": 25, "color": "#ff8c2f"}, + {"key": "progress", "label": "推进度", "min": 0, "max": 100, "initial": 0, "color": "#3b82f6"}, + ] + if not result["status_tracks"]: + result["status_tracks"] = [ + {"key": "tension", "label": "紧张度", "min": 0, "max": 100, "color": "#ff8c2f"}, + {"key": "progress", "label": "推进度", "min": 0, "max": 100, "color": "#3b82f6"}, + ] + if not result["data_scope"]: + result["data_scope"] = {"metrics": "per_player", "inventory": "per_player", "clues": "shared"} + if not result["visibility"]: + result["visibility"] = {"show_all_player_metrics": True, "show_all_player_inventory": True} + if not result["genre_tags"]: + result["genre_tags"] = ["悬疑", "合作", "多结局"] + if not result["feature_badges"]: + result["feature_badges"] = ["剧情分支", "团队决策"] + if not result["world_lore"]: + result["world_lore"] = ( + f"在「{concept}」中,现实会被集体记忆改写。每一次公开讨论都会改变世界规则," + "玩家必须持续校验彼此信息,才能接近真相。" + ) + if not result["core_conflict"]: + result["core_conflict"] = "队伍需要在有限时间内兼顾生存、调查与立场分歧,并决定是否牺牲局部利益换取全局突破。" + if not result["player_roles"]: + result["player_roles"] = ["调查者", "交涉者", "执行者", "记录者"] + if not result["interaction_modes"]: + result["interaction_modes"] = ["公开讨论", "分组行动", "协商投票", "紧急决断"] + if not result["system_rules"]: + result["system_rules"] = ["每轮至少一人提交推进行动", "冲突行动会增加系统压力", "关键结论需至少两人交叉验证"] + if not result["npc_roster"]: + result["npc_roster"] = ["引导型 NPC:提供线索但有所保留", "对抗型 NPC:制造误导与压力", "中立观察者:记录并反馈玩家选择"] + if not result["scene_blueprint"]: + result["scene_blueprint"] = ["开场:规则显现", "中段:线索分叉", "后段:矛盾爆发", "终局:代价抉择"] + if not result["ending_catalog"]: + result["ending_catalog"] = ["全员生还并破解真相", "达成目标但付出牺牲", "内部分裂导致失败", "触发隐藏路线结局"] + if not result["hidden_truths"]: + result["hidden_truths"] = ["关键真相不会一次性出现", "NPC 阵营会被玩家行为动态影响"] + if not result["design_notes"]: + result["design_notes"] = ["先给玩家明确短目标", "每幕至少提供一次分歧选择", "关键线索建议可被不同路径验证"] + + return jsonify( + { + "ok": True, + "draft": result, + "source": "ai", + "ai_error": ai_error, + "ai_error_detail": ai_error_detail, + } + ) + + @app.post("/api/cards/assist_patch") + def assist_card_patch_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + + payload = request.get_json(silent=True) or {} + instruction = str(payload.get("instruction", "")).strip() + if not instruction: + return jsonify({"ok": False, "error": readable_error("invalid_assist_modify_prompt")}), 400 + + current_raw = payload.get("current", {}) + current = normalize_assist_current(current_raw) + + patch_hint = { + "summary": "string(optional)", + "opening_text": "string(optional)", + "starter_goals": ["string(optional)"], + "metrics": [{"key": "string", "label": "string"}], + "status_tracks": [{"key": "string", "label": "string"}], + "end_conditions": [{"id": "string", "metric": "string"}], + "systems": {"any_system": {"enabled": True}}, + "feature_badges": ["string(optional)"], + "system_rules": ["string(optional)"], + "player_roles": ["string(optional)"], + "interaction_modes": ["string(optional)"], + "npc_roster": ["string(optional)"], + "scene_blueprint": ["string(optional)"], + "design_notes": ["string(optional)"], + } + messages = [ + { + "role": "system", + "content": ( + "你是游戏卡增量修改助手。" + "你只能输出一个合法 JSON 对象,内容是 patch(仅包含需要修改的字段)。" + "禁止输出 markdown、解释、前后缀。" + ), + }, + { + "role": "user", + "content": ( + f"修改指令:{instruction}\n" + "当前游戏卡内容(JSON):\n" + f"{json.dumps(current, ensure_ascii=False)}\n" + "返回 patch JSON,允许字段示例:\n" + f"{json.dumps(patch_hint, ensure_ascii=False)}\n" + "要求:尽量保留现有内容,按需新增或局部修改,不要把全部字段重写。" + ), + }, + ] + + ai_result = deepseek_client.chat_json(messages, temperature=0.5, max_tokens=1400) + ai_error = "" + ai_error_detail = "" + patch_payload: dict[str, Any] | None = None + + if ai_result.get("ok"): + patch_payload = parse_json_object(ai_result.get("content", "")) + if not isinstance(patch_payload, dict): + ai_error = "invalid_json_from_model" + else: + ai_error = str(ai_result.get("error", "assist_ai_failed")) + ai_error_detail = str(ai_result.get("detail", "")) + return jsonify( + { + "ok": False, + "error": f"AI 修改失败:{ai_error}", + "ai_error": ai_error, + "ai_error_detail": ai_error_detail, + } + ), 502 + + if not isinstance(patch_payload, dict) and ai_result.get("ok"): + repair_messages = [ + { + "role": "system", + "content": "你是 JSON 修复器。只输出一个合法 JSON 对象(patch)。", + }, + { + "role": "user", + "content": ( + f"把以下文本修复成 JSON patch:\n{str(ai_result.get('content', ''))[:5000]}" + ), + }, + ] + repair_result = deepseek_client.chat_json(repair_messages, temperature=0.2, max_tokens=1000) + if repair_result.get("ok"): + patch_payload = parse_json_object(repair_result.get("content", "")) + if not isinstance(patch_payload, dict): + ai_error = f"{ai_error or 'invalid_json_from_model'};repair_failed" + if not ai_error_detail: + ai_error_detail = str(repair_result.get("detail", "")) + + if not isinstance(patch_payload, dict): + return jsonify( + { + "ok": False, + "error": f"AI 返回 patch 不是合法 JSON:{ai_error or 'invalid_json_from_model'}", + "ai_error": ai_error or "invalid_json_from_model", + "ai_error_detail": ai_error_detail, + } + ), 502 + + patch = normalize_card_patch(patch_payload) + if not patch: + return jsonify( + { + "ok": False, + "error": "AI 返回 patch 为空,未执行修改。", + "ai_error": ai_error or "empty_patch_from_model", + "ai_error_detail": ai_error_detail, + } + ), 502 + + updated = apply_card_patch(current, patch) + + return jsonify( + { + "ok": True, + "patch": patch, + "updated": updated, + "source": "ai", + "ai_error": ai_error, + "ai_error_detail": ai_error_detail, + } + ) + + @app.post("/api/cards") + def create_card_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + + payload = request.get_json(silent=True) or {} + try: + card_config = build_card_config(payload) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + card = persistence.create_game_card( + title=card_config["title"], + subtitle=card_config["subtitle"], + summary=card_config["summary"], + cover_url=card_config["cover_url"], + opening_text=card_config["opening_text"], + starter_goals=card_config["starter_goals"], + state_schema=card_config["state_schema"], + content_pack=card_config["content_pack"], + creator_user_id=user["user_id"], + ) + return jsonify({"ok": True, "card": card}) + + @app.patch("/api/cards/") + def update_card_api(card_id: str) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + + card = persistence.get_game_card(card_id.strip()) + if card is None: + return jsonify({"ok": False, "error": readable_error("invalid_card_id")}), 404 + + is_admin = str(user.get("role", "player")) == "admin" + is_creator = str(card.get("creator_user_id") or "") == str(user["user_id"]) + if not is_admin and not is_creator: + return jsonify({"ok": False, "error": readable_error("card_edit_forbidden")}), 403 + + if bool(card.get("is_official")) and not is_admin: + return jsonify({"ok": False, "error": readable_error("card_edit_forbidden")}), 403 + + payload = request.get_json(silent=True) or {} + try: + card_config = build_card_config(payload) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + try: + updated = persistence.update_game_card( + card["card_id"], + title=card_config["title"], + subtitle=card_config["subtitle"], + summary=card_config["summary"], + cover_url=card_config["cover_url"], + opening_text=card_config["opening_text"], + starter_goals=card_config["starter_goals"], + state_schema=card_config["state_schema"], + content_pack=card_config["content_pack"], + ) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + return jsonify({"ok": True, "card": updated}) + + @app.delete("/api/rooms/") + def delete_room_api(room_id: str) -> Any: + room_id = room_id.strip().upper() + payload = request.get_json(silent=True) or {} + player_id = str(payload.get("player_id", "")).strip() or str( + request.args.get("player_id", "") + ).strip() + if not player_id: + return jsonify({"ok": False, "error": readable_error("missing_room_or_player")}), 400 + user = current_user() + try: + room_manager.delete_room( + room_id, + player_id, + requester_user_id=(user or {}).get("user_id"), + ) + return jsonify({"ok": True, "room_id": room_id}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.get("/api/me/rooms") + def my_rooms_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": True, "rooms": []}) + + rows = persistence.list_user_rooms(user["user_id"], limit=12) + for row in rows: + card = persistence.get_game_card(row.get("game_card_id", "")) + row["card_title"] = card["title"] if card else "未知剧本" + return jsonify({"ok": True, "rooms": rows}) + + @app.get("/api/rooms/public") + def public_rooms_api() -> Any: + limit_raw = request.args.get("limit", "30") + try: + limit = max(1, min(100, int(str(limit_raw)))) + except ValueError: + limit = 30 + rows = persistence.list_public_rooms(limit=limit) + for row in rows: + card = persistence.get_game_card(row.get("game_card_id", "")) + row["card_title"] = card["title"] if card else "未知剧本" + row["card_cover_url"] = card.get("cover_url", "") if card else "" + row["card_subtitle"] = card.get("subtitle", "") if card else "" + return jsonify({"ok": True, "rooms": rows}) + + @app.post("/api/rooms/create") + def create_room_api() -> Any: + payload = request.get_json(silent=True) or {} + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + card_id = str(payload.get("card_id", "")).strip() or "fogharbor" + card = persistence.get_game_card(card_id) + if card is None: + return jsonify({"ok": False, "error": readable_error("invalid_card_id")}), 400 + + name = str(user["username"]).strip() + user_id = user["user_id"] + + if not name: + return jsonify({"ok": False, "error": "用户名无效"}), 400 + + room = room_manager.create_room(name, user_id=user_id, game_card=card) + return jsonify( + { + "ok": True, + "room_id": room["room_id"], + "player_id": room["player_id"], + "player_name": room["player_name"], + "redirect": url_for( + "room_page", + room_id=room["room_id"], + player_id=room["player_id"], + ), + } + ) + + @app.patch("/api/rooms//visibility") + def set_room_visibility_api(room_id: str) -> Any: + safe_room_id = room_id.strip().upper() + payload = request.get_json(silent=True) or {} + player_id = str(payload.get("player_id", "")).strip() + is_public = bool(payload.get("is_public", False)) + if not player_id: + return jsonify({"ok": False, "error": readable_error("missing_room_or_player")}), 400 + try: + result = room_manager.set_room_public(safe_room_id, player_id, is_public) + snapshot = room_manager.get_snapshot(safe_room_id) + return jsonify({"ok": True, "room_id": result["room_id"], "is_public": result["is_public"], "snapshot": snapshot}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + @app.post("/api/rooms/join") + def join_room_api() -> Any: + payload = request.get_json(silent=True) or {} + room_id = str(payload.get("room_id", "")).strip().upper() + player_id = str(payload.get("player_id", "")).strip() or None + user = current_user() + + if not room_id: + return jsonify({"ok": False, "error": "请输入房间码"}), 400 + + room = persistence.get_room(room_id) + if room is None: + return jsonify({"ok": False, "error": readable_error("room_not_found")}), 400 + + if room["status"] == "lobby": + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + name = str(user["username"]).strip() + user_id = user["user_id"] + try: + join_info, _ = room_manager.join_room(room_id, name, player_id, user_id=user_id) + return jsonify( + { + "ok": True, + "room_id": room_id, + "player_id": join_info["player_id"], + "player_name": join_info["player_name"], + "is_spectator": False, + "redirect": url_for( + "room_page", + room_id=room_id, + player_id=join_info["player_id"], + ), + } + ) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + + # running / ended: existing player can reconnect, others进入观战 + if user: + existing = persistence.get_player_by_user(room_id, user["user_id"]) + if existing: + try: + join_info, _ = room_manager.join_room( + room_id, + str(user["username"]).strip(), + existing["player_id"], + user_id=user["user_id"], + ) + return jsonify( + { + "ok": True, + "room_id": room_id, + "player_id": join_info["player_id"], + "player_name": join_info["player_name"], + "is_spectator": False, + "redirect": url_for( + "room_page", + room_id=room_id, + player_id=join_info["player_id"], + ), + } + ) + except ValueError: + pass + + return jsonify( + { + "ok": True, + "room_id": room_id, + "is_spectator": True, + "redirect": url_for("room_page", room_id=room_id, spectator=1), + } + ) + + @app.get("/invite/") + def invite_link_page(room_id: str) -> Any: + safe_room_id = room_id.strip().upper() + room = persistence.get_room(safe_room_id) + if room is None: + return redirect(url_for("index")) + + mode = str(request.args.get("mode", "join")).strip().lower() + user = current_user() + if mode == "spectate" or room["status"] != "lobby": + return redirect(url_for("room_page", room_id=safe_room_id, spectator=1)) + + if user is None: + return redirect(url_for("login_page")) + + existing = persistence.get_player_by_user(safe_room_id, user["user_id"]) + if existing: + return redirect( + url_for("room_page", room_id=safe_room_id, player_id=existing["player_id"]) + ) + return redirect(url_for("index", invite=safe_room_id)) + + @app.get("/api/rooms//invite") + def room_invite_api(room_id: str) -> Any: + safe_room_id = room_id.strip().upper() + room = persistence.get_room(safe_room_id) + if room is None: + return jsonify({"ok": False, "error": readable_error("room_not_found")}), 404 + base = request.host_url.rstrip("/") + return jsonify( + { + "ok": True, + "room_id": safe_room_id, + "join_link": f"{base}/invite/{safe_room_id}?mode=join", + "spectate_link": f"{base}/invite/{safe_room_id}?mode=spectate", + } + ) + + @app.get("/room/") + def room_page(room_id: str) -> Any: + room_id = room_id.strip().upper() + player_id = request.args.get("player_id", "").strip() + spectator = str(request.args.get("spectator", "0")).strip() in {"1", "true", "yes"} + user = current_user() + + try: + snapshot = room_manager.get_snapshot(room_id) + except ValueError: + return redirect(url_for("index")) + + if spectator: + if snapshot["status"] == "lobby": + return redirect(url_for("index")) + return render_template( + "room.html", + room_id=room_id, + player_id="", + player_name="观战者", + spectator=True, + user=serialize_user(user), + ) + + if not player_id and user: + row = persistence.get_player_by_user(room_id, user["user_id"]) + if row: + return redirect(url_for("room_page", room_id=room_id, player_id=row["player_id"])) + + if not player_id: + return redirect(url_for("index")) + + player = next((row for row in snapshot["players"] if row["player_id"] == player_id), None) + if player is None: + return redirect(url_for("index")) + + if player.get("user_id") and ((not user) or player.get("user_id") != user["user_id"]): + return redirect(url_for("index")) + + return render_template( + "room.html", + room_id=room_id, + player_id=player_id, + player_name=player["name"], + spectator=False, + user=serialize_user(user), + ) + + @app.get("/api/rooms//snapshot") + def room_snapshot_api(room_id: str) -> Any: + try: + return jsonify({"ok": True, "snapshot": room_manager.get_snapshot(room_id.upper())}) + except ValueError: + return jsonify({"ok": False, "error": "room_not_found"}), 404 + + @socketio.on("join_room_socket") + def on_join_room_socket(data: dict[str, Any]) -> None: + room_id = str(data.get("room_id", "")).strip().upper() + player_id = str(data.get("player_id", "")).strip() + spectator = bool(data.get("spectator", False)) + if not room_id: + emit("error_message", {"error": readable_error("missing_room_or_player")}) + return + + try: + snapshot = room_manager.get_snapshot(room_id) + except ValueError: + emit("error_message", {"error": readable_error("room_not_found")}) + return + + if spectator: + if snapshot["status"] == "lobby": + emit("error_message", {"error": "当前房间尚未开局,观战暂不可用。"}) + return + sio_join_room(room_id) + emit("room_snapshot", snapshot) + emit("system_message", {"content": "观战者已连接。"}, to=room_id) + return + + if not player_id: + emit("error_message", {"error": readable_error("missing_room_or_player")}) + return + + player = next((row for row in snapshot["players"] if row["player_id"] == player_id), None) + if player is None: + emit("error_message", {"error": readable_error("player_not_in_room")}) + return + + room_manager.attach_socket(room_id, player_id, request.sid) + sio_join_room(room_id) + emit("room_snapshot", snapshot) + emit("system_message", {"content": f"{player['name']} 已连接。"}, to=room_id) + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + + @socketio.on("request_snapshot") + def on_request_snapshot(data: dict[str, Any]) -> None: + room_id = str(data.get("room_id", "")).strip().upper() + if not room_id: + return + try: + emit("room_snapshot", room_manager.get_snapshot(room_id)) + except ValueError: + emit("error_message", {"error": readable_error("room_not_found")}) + + @socketio.on("toggle_ready") + def on_toggle_ready(data: dict[str, Any]) -> None: + room_id = str(data.get("room_id", "")).strip().upper() + player_id = str(data.get("player_id", "")).strip() + ready = bool(data.get("ready", False)) + if not player_id: + emit("error_message", {"error": readable_error("spectator_readonly")}) + return + try: + room_manager.set_ready(room_id, player_id, ready) + player = persistence.get_player(room_id, player_id) + name = player["name"] if player else player_id + emit( + "system_message", + {"content": f"{name} {'已准备' if ready else '取消准备'}"}, + to=room_id, + ) + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + except ValueError as exc: + emit("error_message", {"error": readable_error(str(exc))}) + + @socketio.on("start_game") + def on_start_game(data: dict[str, Any]) -> None: + room_id = str(data.get("room_id", "")).strip().upper() + player_id = str(data.get("player_id", "")).strip() + if not player_id: + emit("error_message", {"error": readable_error("spectator_readonly")}) + return + try: + room_manager.start_game(room_id, player_id) + emit("system_message", {"content": "游戏开始:第一幕开启。"}, to=room_id) + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + except ValueError as exc: + emit("error_message", {"error": readable_error(str(exc))}) + + @socketio.on("transfer_host") + def on_transfer_host(data: dict[str, Any]) -> None: + room_id = str(data.get("room_id", "")).strip().upper() + player_id = str(data.get("player_id", "")).strip() + target_player_id = str(data.get("target_player_id", "")).strip() + if not player_id: + emit("error_message", {"error": readable_error("spectator_readonly")}) + return + try: + room_manager.transfer_host(room_id, player_id, target_player_id) + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + except ValueError as exc: + emit("error_message", {"error": readable_error(str(exc))}) + + @socketio.on("kick_player") + def on_kick_player(data: dict[str, Any]) -> None: + room_id = str(data.get("room_id", "")).strip().upper() + player_id = str(data.get("player_id", "")).strip() + target_player_id = str(data.get("target_player_id", "")).strip() + if not player_id: + emit("error_message", {"error": readable_error("spectator_readonly")}) + return + try: + room_manager.kick_player(room_id, player_id, target_player_id) + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + except ValueError as exc: + emit("error_message", {"error": readable_error(str(exc))}) + + @socketio.on("send_chat") + def on_send_chat(data: dict[str, Any]) -> None: + room_id = str(data.get("room_id", "")).strip().upper() + player_id = str(data.get("player_id", "")).strip() + content = str(data.get("content", "")) + if not player_id: + emit("error_message", {"error": readable_error("spectator_readonly")}) + return + try: + room_manager.submit_chat(room_id, player_id, content) + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + except ValueError as exc: + emit("error_message", {"error": readable_error(str(exc))}) + + @socketio.on("submit_action") + def on_submit_action(data: dict[str, Any]) -> None: + room_id = str(data.get("room_id", "")).strip().upper() + player_id = str(data.get("player_id", "")).strip() + content = str(data.get("content", "")) + if not player_id: + emit("error_message", {"error": readable_error("spectator_readonly")}) + return + + try: + result = room_manager.submit_action(room_id, player_id, content) + emit( + "system_message", + {"content": f"{result.get('player_name', player_id)} 已提交行动,等待处理。"}, + to=room_id, + ) + emit("pending_action_update", result, to=room_id) + if result.get("should_auto_resolve"): + resolved = room_manager.resolve_round(room_id, player_id) + emit("round_resolved", resolved["round_result"], to=room_id) + emit("room_snapshot", resolved["snapshot"], to=room_id) + snapshot = resolved["snapshot"] or {} + game_state = snapshot.get("game_state", {}) if isinstance(snapshot, dict) else {} + if snapshot.get("status") == "ended" or game_state.get("game_phase") == "ended": + emit( + "game_ended", + { + "room_id": room_id, + "ending_state": game_state.get("ending_state"), + "ending_summary": game_state.get("global_flags", {}).get("ending_summary"), + }, + to=room_id, + ) + for private_player_id, messages in resolved["private_updates"].items(): + sid = room_manager.sid_for_player(room_id, private_player_id) + if sid: + emit( + "private_updates", + {"messages": messages, "player_id": private_player_id}, + to=sid, + ) + else: + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + except ValueError as exc: + emit("error_message", {"error": readable_error(str(exc))}) + try: + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + except ValueError: + pass + except Exception as exc: # pragma: no cover - runtime safety net + emit("error_message", {"error": f"处理失败:{exc}"}) + + @socketio.on("resolve_round") + def on_resolve_round(data: dict[str, Any]) -> None: + room_id = str(data.get("room_id", "")).strip().upper() + player_id = str(data.get("player_id", "")).strip() + + try: + result = room_manager.resolve_round(room_id, player_id) + emit("round_resolved", result["round_result"], to=room_id) + emit("room_snapshot", result["snapshot"], to=room_id) + snapshot = result["snapshot"] or {} + game_state = snapshot.get("game_state", {}) if isinstance(snapshot, dict) else {} + if snapshot.get("status") == "ended" or game_state.get("game_phase") == "ended": + emit( + "game_ended", + { + "room_id": room_id, + "ending_state": game_state.get("ending_state"), + "ending_summary": game_state.get("global_flags", {}).get("ending_summary"), + }, + to=room_id, + ) + + for private_player_id, messages in result["private_updates"].items(): + sid = room_manager.sid_for_player(room_id, private_player_id) + if sid: + emit( + "private_updates", + {"messages": messages, "player_id": private_player_id}, + to=sid, + ) + except ValueError as exc: + emit("error_message", {"error": readable_error(str(exc))}) + try: + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + except ValueError: + pass + except Exception as exc: # pragma: no cover - runtime safety net + emit("error_message", {"error": f"处理失败:{exc}"}) + + @socketio.on("disconnect") + def on_disconnect() -> None: + mapping = room_manager.detach_socket(request.sid) + if not mapping: + return + room_id, player_id = mapping + player = persistence.get_player(room_id, player_id) + name = player["name"] if player else player_id + try: + emit("system_message", {"content": f"{name} 已离线。"}, to=room_id) + emit("room_snapshot", room_manager.get_snapshot(room_id), to=room_id) + except ValueError: + pass + + return app, socketio + + +app, socketio = create_app() + + +if __name__ == "__main__": + socketio.run( + app, + host=str(app.config.get("APP_HOST", "0.0.0.0")), + port=int(app.config.get("APP_PORT", 8095)), + debug=bool(app.config.get("DEBUG", True)), + allow_unsafe_werkzeug=True, + ) diff --git a/config.py b/config.py new file mode 100644 index 0000000..1d0f2f0 --- /dev/null +++ b/config.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +BASE_DIR = Path(__file__).resolve().parent +SETTINGS_FILE = BASE_DIR / "settings.json" + +DEFAULT_SETTINGS: dict[str, Any] = { + "secret_key": "echo-tavern-dev-secret", + "db_path": "echo_tavern.db", + "deepseek_api_key": "sk-9e94feccd21640ada969990e792cbc11", + "deepseek_base_url": "https://api.deepseek.com", + "deepseek_model": "deepseek-chat", + "deepseek_timeout": 25, + "deepseek_connect_timeout": 8, + "deepseek_read_timeout": 90, + "deepseek_retries": 2, + "max_room_players": 8, + "max_action_length": 280, + "app_host": "0.0.0.0", + "app_port": 8095, + "debug": True, +} + + +def _resolve_db_path(db_path: str) -> str: + path = Path(db_path) + if path.is_absolute(): + return str(path) + return str((BASE_DIR / path).resolve()) + + +def _load_settings() -> dict[str, Any]: + if not SETTINGS_FILE.exists(): + SETTINGS_FILE.write_text( + json.dumps(DEFAULT_SETTINGS, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return dict(DEFAULT_SETTINGS) + + try: + payload = json.loads(SETTINGS_FILE.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + return dict(DEFAULT_SETTINGS) + except (OSError, json.JSONDecodeError): + return dict(DEFAULT_SETTINGS) + + merged = dict(DEFAULT_SETTINGS) + merged.update(payload) + return merged + + +SETTINGS = _load_settings() + + +class Config: + SECRET_KEY = str(SETTINGS.get("secret_key", DEFAULT_SETTINGS["secret_key"])) + DB_PATH = _resolve_db_path(str(SETTINGS.get("db_path", DEFAULT_SETTINGS["db_path"]))) + DEEPSEEK_API_KEY = str(SETTINGS.get("deepseek_api_key", DEFAULT_SETTINGS["deepseek_api_key"])) + DEEPSEEK_BASE_URL = str( + SETTINGS.get("deepseek_base_url", DEFAULT_SETTINGS["deepseek_base_url"]) + ) + DEEPSEEK_MODEL = str(SETTINGS.get("deepseek_model", DEFAULT_SETTINGS["deepseek_model"])) + DEEPSEEK_TIMEOUT = float( + SETTINGS.get("deepseek_timeout", DEFAULT_SETTINGS["deepseek_timeout"]) + ) + DEEPSEEK_CONNECT_TIMEOUT = float( + SETTINGS.get("deepseek_connect_timeout", DEFAULT_SETTINGS["deepseek_connect_timeout"]) + ) + DEEPSEEK_READ_TIMEOUT = float( + SETTINGS.get("deepseek_read_timeout", DEFAULT_SETTINGS["deepseek_read_timeout"]) + ) + DEEPSEEK_RETRIES = int(SETTINGS.get("deepseek_retries", DEFAULT_SETTINGS["deepseek_retries"])) + MAX_ROOM_PLAYERS = int( + SETTINGS.get("max_room_players", DEFAULT_SETTINGS["max_room_players"]) + ) + MAX_ACTION_LENGTH = int( + SETTINGS.get("max_action_length", DEFAULT_SETTINGS["max_action_length"]) + ) + APP_HOST = str(SETTINGS.get("app_host", DEFAULT_SETTINGS["app_host"])) + APP_PORT = int(SETTINGS.get("app_port", DEFAULT_SETTINGS["app_port"])) + DEBUG = bool(SETTINGS.get("debug", DEFAULT_SETTINGS["debug"])) + + +class TestConfig(Config): + TESTING = True + SECRET_KEY = "test-secret" + DEEPSEEK_API_KEY = "" diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/action_resolver.py b/game/action_resolver.py new file mode 100644 index 0000000..461e1e0 --- /dev/null +++ b/game/action_resolver.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import hashlib +import re +from collections import defaultdict +from typing import Any + +from .models import PlayerAction + +CATEGORY_KEYWORDS = { + "aggressive": ["攻击", "威胁", "强闯", "砸", "杀", "attack", "force"], + "social": ["说服", "交谈", "询问", "谈判", "欺骗", "ask", "talk", "persuade"], + "stealth": ["潜行", "偷偷", "跟踪", "偷", "藏", "sneak", "track", "steal"], + "explore": ["检查", "调查", "搜索", "观察", "前往", "进入", "inspect", "search", "go"], +} + +CATEGORY_PRIORITY = { + "aggressive": 4, + "stealth": 3, + "social": 2, + "explore": 1, + "unknown": 1, +} + +CONFLICT_PAIRS = { + ("aggressive", "social"), + ("social", "aggressive"), + ("aggressive", "stealth"), + ("stealth", "aggressive"), +} + +TARGET_REGEX = re.compile(r"(?:检查|调查|跟踪|攻击|说服|询问|进入|前往|偷|观察)([^,。;,.;]{1,14})") + + +class ActionResolver: + def categorize(self, content: str) -> str: + lowered = content.lower() + for category, keywords in CATEGORY_KEYWORDS.items(): + if any(keyword in lowered for keyword in keywords): + return category + return "unknown" + + def extract_target(self, content: str) -> str: + match = TARGET_REGEX.search(content) + if match: + target = match.group(1).strip() + target = re.split(r"(?:并且|并|然后|再|开门|强闯|进入|离开|逃跑)", target)[0] + target = target.strip(" 的了着吧吗") + if target: + return target[:12] + + words = re.findall(r"[\w\u4e00-\u9fff]{2,}", content) + if words: + return words[-1][:12] + return "环境" + + def _roll(self, room_id: str, round_no: int, player_id: str, content: str) -> float: + digest = hashlib.sha256(f"{room_id}:{round_no}:{player_id}:{content}".encode("utf-8")).hexdigest() + return (int(digest[:8], 16) % 1000) / 1000.0 + + def merge_actions( + self, actions: list[PlayerAction], room_id: str, round_no: int + ) -> dict[str, Any]: + if not actions: + return { + "ordered_actions": [], + "conflicts": [], + "summary": "本轮没有玩家行动。", + "triggered_players": [], + } + + enriched: list[dict[str, Any]] = [] + for action in actions: + category = self.categorize(action.content) + target = self.extract_target(action.content) + score = CATEGORY_PRIORITY.get(category, 1) + self._roll( + room_id, round_no, action.player_id, action.content + ) + enriched.append( + { + "player_id": action.player_id, + "player_name": action.player_name, + "content": action.content, + "category": category, + "target": target, + "score": score, + "resolution": "primary", + } + ) + + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for item in enriched: + grouped[item["target"]].append(item) + + conflicts = [] + for target, rows in grouped.items(): + if len(rows) < 2: + continue + rows.sort(key=lambda x: x["score"], reverse=True) + winner = rows[0] + losers = [] + for row in rows[1:]: + conflict_key = (winner["category"], row["category"]) + if conflict_key in CONFLICT_PAIRS: + row["resolution"] = "blocked" + losers.append(row) + else: + row["resolution"] = "support" + + if losers: + conflicts.append( + { + "target": target, + "winner_player_id": winner["player_id"], + "winner_name": winner["player_name"], + "loser_player_ids": [row["player_id"] for row in losers], + "reason": "同一目标上发生冲突,按行动优先级+掷骰规则裁定。", + } + ) + + ordered = sorted(enriched, key=lambda x: (x["resolution"] != "primary", -x["score"])) + triggered_players = [row["player_id"] for row in ordered if row["resolution"] == "primary"] + + summary_parts = [] + for row in ordered: + prefix = "触发" if row["resolution"] == "primary" else "协同" + if row["resolution"] == "blocked": + prefix = "受阻" + summary_parts.append(f"{prefix}: {row['player_name']} -> {row['content']}") + + return { + "ordered_actions": ordered, + "conflicts": conflicts, + "summary": ";".join(summary_parts), + "triggered_players": triggered_players, + } diff --git a/game/models.py b/game/models.py new file mode 100644 index 0000000..b6faebd --- /dev/null +++ b/game/models.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class Player: + player_id: str + room_id: str + name: str + is_host: bool = False + is_ready: bool = False + is_online: bool = True + joined_at: str = field(default_factory=utc_now_iso) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class PlayerAction: + player_id: str + player_name: str + content: str + submitted_at: str = field(default_factory=utc_now_iso) + action_type: str = "action" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class GameState: + room_id: str + game_phase: str = "lobby" + chapter: int = 0 + scene_id: str = "lobby" + scene_description: str = "玩家们聚集在雾港旅店的大堂,暴雨拍打着窗户。" + global_flags: dict[str, Any] = field(default_factory=dict) + discovered_clues: list[str] = field(default_factory=list) + inventory_by_player: dict[str, list[str]] = field(default_factory=dict) + npc_states: dict[str, dict[str, Any]] = field(default_factory=dict) + danger_level: int = 1 + action_history: list[dict[str, Any]] = field(default_factory=list) + pending_choices: list[str] = field(default_factory=list) + ending_state: str | None = None + round_no: int = 0 + last_updated_at: str = field(default_factory=utc_now_iso) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, payload: dict[str, Any]) -> "GameState": + return cls( + room_id=payload.get("room_id", ""), + game_phase=payload.get("game_phase", "lobby"), + chapter=int(payload.get("chapter", 0)), + scene_id=payload.get("scene_id", "lobby"), + scene_description=payload.get( + "scene_description", "玩家们聚集在雾港旅店的大堂,暴雨拍打着窗户。" + ), + global_flags=dict(payload.get("global_flags", {})), + discovered_clues=list(payload.get("discovered_clues", [])), + inventory_by_player={ + key: list(value) for key, value in dict(payload.get("inventory_by_player", {})).items() + }, + npc_states={ + key: dict(value) for key, value in dict(payload.get("npc_states", {})).items() + }, + danger_level=int(payload.get("danger_level", 1)), + action_history=list(payload.get("action_history", [])), + pending_choices=list(payload.get("pending_choices", [])), + ending_state=payload.get("ending_state"), + round_no=int(payload.get("round_no", 0)), + last_updated_at=payload.get("last_updated_at", utc_now_iso()), + ) + + +@dataclass +class RoomSnapshot: + room_id: str + status: str + host_player_id: str + players: list[dict[str, Any]] + game_state: dict[str, Any] + pending_actions: list[dict[str, Any]] + messages: list[dict[str, Any]] + + def to_dict(self) -> dict[str, Any]: + return asdict(self) diff --git a/game/prompts.py b/game/prompts.py new file mode 100644 index 0000000..ab5c857 --- /dev/null +++ b/game/prompts.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from typing import Any + +SCHEMA_DESCRIPTION = { + "narration": "string", + "npc_dialogues": [{"npc": "string", "line": "string", "tone": "string"}], + "state_updates": { + "danger_delta": "integer", + "add_clues": ["string"], + "set_flags": {"flag_name": "bool|string|number"}, + "set_flags.player_metric_set": {"player_id": {"metric_key": "number"}}, + "set_flags.player_metric_delta": {"player_id": {"metric_key": "number"}}, + "npc_changes": {"npc_id": {"mood": "string", "trust": "number", "location": "string"}}, + "inventory_add": {"player_id": ["string"]}, + "inventory_remove": {"player_id": ["string"]}, + }, + "private_player_updates": [{"player_id": "string", "message": "string"}], + "choices": ["string"], + "should_end_scene": "boolean", + "should_end_game": "boolean", + "ending_summary": "string|null", +} + + +def build_system_prompt() -> str: + return ( + "你是《Echo Tavern》联机文字冒险的主持 AI。" + "你只能在给定剧本骨架内叙述,不得凭空改写核心真相。" + "你必须输出严格 JSON,不要输出 markdown 或额外说明。" + "你需要综合多名玩家本轮行动,并明确谁触发了结果。" + "如果行动冲突,按提供的冲突解析结果进行叙述。" + "state_updates 仅可做增量更新,不可删除已有关键线索。" + "若本轮发生道具消耗,必须在 inventory_remove 填写被消耗的道具。" + "不要写过长文本,每段叙述控制在 120 中文字以内。" + ) + + +def build_user_prompt(context: dict[str, Any]) -> str: + story_context = context.get("story_context", {}) + allowed_clues = story_context.get("allowed_clues", []) + clue_rule = "关键线索只可从允许列表中新增" + if allowed_clues == "ANY_STRING": + clue_rule = "线索字段可新增任意短文本,但必须与当前游戏卡世界观一致" + + payload = { + "story_context": story_context, + "game_state": context.get("game_state", {}), + "players": context.get("players", []), + "merged_actions": context.get("merged_actions", {}), + "schema": SCHEMA_DESCRIPTION, + "constraints": [ + clue_rule, + "danger_delta 取值范围 -2 到 3", + "可在 set_flags 中写入任意指标字段(如 hp=90)或 *_delta 增量(如 hp_delta=-12)", + "当数据作用域为 per_player 时,优先在 set_flags.player_metric_set / player_metric_delta 中按玩家更新", + "当背包作用域为 shared 时,inventory_add 写任意玩家键都会合并到共享背包", + "当背包作用域为 shared 时,inventory_remove 写任意玩家键都会从共享背包移除", + "如果行动描述是“使用/消耗/投掷/服用/注射某道具”,且结果生效,请在 inventory_remove 删除该道具", + "自定义卡会按 content_pack.end_conditions 由服务端判定结局", + "choices 最多 4 条", + "private_player_updates 可为空", + "如果故事未满足结局条件,应设置 should_end_game=false", + ], + } + return ( + "以下是当前回合上下文,请根据上下文推进剧情并返回 JSON。" + "\n\n上下文:\n" + + json.dumps(payload, ensure_ascii=False) + + "\n\n仅返回合法 JSON。" + ) + + +def build_messages(context: dict[str, Any]) -> list[dict[str, str]]: + return [ + {"role": "system", "content": build_system_prompt()}, + {"role": "user", "content": build_user_prompt(context)}, + ] diff --git a/game/room_manager.py b/game/room_manager.py new file mode 100644 index 0000000..6c891ca --- /dev/null +++ b/game/room_manager.py @@ -0,0 +1,590 @@ +from __future__ import annotations + +import html +import secrets +import string +import threading +import uuid +from dataclasses import dataclass, field +from typing import Any + +from .action_resolver import ActionResolver +from .models import PlayerAction +from .prompts import build_messages +from .schemas import parse_json_payload, validate_ai_payload +from .state_manager import StateManager +from .story_engine import StoryEngine +from services.deepseek_client import DeepSeekClient +from services.persistence import SQLitePersistence + + +@dataclass +class RoomRuntime: + lock: threading.RLock = field(default_factory=threading.RLock) + pending_actions: dict[str, PlayerAction] = field(default_factory=dict) + sid_by_player: dict[str, str] = field(default_factory=dict) + resolving_round: bool = False + + +class RoomManager: + def __init__( + self, + persistence: SQLitePersistence, + state_manager: StateManager, + story_engine: StoryEngine, + action_resolver: ActionResolver, + deepseek_client: DeepSeekClient, + max_players: int = 8, + max_action_length: int = 280, + ): + self.persistence = persistence + self.state_manager = state_manager + self.story_engine = story_engine + self.action_resolver = action_resolver + self.deepseek_client = deepseek_client + self.max_players = max_players + self.max_action_length = max_action_length + + self._lock = threading.RLock() + self._rooms: dict[str, RoomRuntime] = {} + self._sid_index: dict[str, tuple[str, str]] = {} + + def _generate_room_id(self) -> str: + alphabet = string.ascii_uppercase + string.digits + for _ in range(20): + room_id = "".join(secrets.choice(alphabet) for _ in range(6)) + if self.persistence.get_room(room_id) is None: + return room_id + raise RuntimeError("failed_to_generate_room_id") + + def _runtime(self, room_id: str) -> RoomRuntime: + with self._lock: + runtime = self._rooms.get(room_id) + if runtime is None: + runtime = RoomRuntime() + self._rooms[room_id] = runtime + return runtime + + def _sanitize(self, content: str) -> str: + normalized = " ".join(content.strip().split()) + if len(normalized) > self.max_action_length: + normalized = normalized[: self.max_action_length] + return html.escape(normalized) + + def _ensure_unique_name(self, name: str, existing_names: set[str]) -> str: + if name not in existing_names: + return name + suffix = 2 + while f"{name}{suffix}" in existing_names: + suffix += 1 + return f"{name}{suffix}" + + def create_room( + self, + host_name: str, + *, + user_id: str | None = None, + game_card: dict[str, Any] | None = None, + ) -> dict[str, Any]: + clean_name = self._sanitize(host_name) or "冒险者" + room_id = self._generate_room_id() + host_player_id = uuid.uuid4().hex[:12] + card = game_card or self.persistence.get_default_card() or {} + self.state_manager.create_new_room( + room_id, + host_player_id, + clean_name, + host_user_id=user_id, + game_card=card, + ) + card_title = str(card.get("title", "雾港旅店")) + self.persistence.append_message(room_id, "system", f"{clean_name} 创建了房间。剧本卡:{card_title}") + return { + "room_id": room_id, + "player_id": host_player_id, + "player_name": clean_name, + "is_host": True, + } + + def join_room( + self, + room_id: str, + name: str, + player_id: str | None = None, + *, + user_id: str | None = None, + ) -> tuple[dict[str, Any], dict[str, Any]]: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + + players = self.persistence.list_players(room_id) + clean_name = self._sanitize(name) or "冒险者" + existing = None + + if user_id: + existing = self.persistence.get_player_by_user(room_id, user_id) + if existing: + self.persistence.add_or_update_player( + room_id, + existing["player_id"], + existing["name"], + is_host=existing["is_host"], + is_ready=existing["is_ready"], + is_online=True, + user_id=user_id, + ) + return ( + { + "room_id": room_id, + "player_id": existing["player_id"], + "player_name": existing["name"], + "is_host": existing["is_host"], + }, + room, + ) + + if player_id: + existing = self.persistence.get_player(room_id, player_id) + if existing: + self.persistence.add_or_update_player( + room_id, + player_id, + existing["name"], + is_host=existing["is_host"], + is_ready=existing["is_ready"], + is_online=True, + user_id=existing.get("user_id"), + ) + return ( + { + "room_id": room_id, + "player_id": player_id, + "player_name": existing["name"], + "is_host": existing["is_host"], + }, + room, + ) + + if room["status"] != "lobby": + raise ValueError("game_in_progress") + + if len(players) >= self.max_players: + raise ValueError("room_full") + + existing_names = {player["name"] for player in players} + clean_name = self._ensure_unique_name(clean_name, existing_names) + + new_player_id = uuid.uuid4().hex[:12] + self.state_manager.add_player( + room_id, + new_player_id, + clean_name, + is_host=False, + user_id=user_id, + ) + self.persistence.append_message(room_id, "system", f"{clean_name} 加入了房间。", player_id=new_player_id) + return ( + { + "room_id": room_id, + "player_id": new_player_id, + "player_name": clean_name, + "is_host": False, + }, + room, + ) + + def attach_socket(self, room_id: str, player_id: str, sid: str) -> None: + runtime = self._runtime(room_id) + with runtime.lock, self._lock: + runtime.sid_by_player[player_id] = sid + self._sid_index[sid] = (room_id, player_id) + self.persistence.update_player_online(room_id, player_id, True) + + def detach_socket(self, sid: str) -> tuple[str, str] | None: + with self._lock: + mapping = self._sid_index.pop(sid, None) + if not mapping: + return None + + room_id, player_id = mapping + runtime = self._runtime(room_id) + with runtime.lock: + if runtime.sid_by_player.get(player_id) == sid: + runtime.sid_by_player.pop(player_id, None) + self.persistence.update_player_online(room_id, player_id, False) + return room_id, player_id + + def transfer_host(self, room_id: str, operator_player_id: str, target_player_id: str) -> dict[str, Any]: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + if room["host_player_id"] != operator_player_id: + raise ValueError("only_host_can_transfer") + if operator_player_id == target_player_id: + raise ValueError("cannot_transfer_to_self") + + target = self.persistence.get_player(room_id, target_player_id) + if target is None: + raise ValueError("target_player_not_found") + + self.persistence.set_room_host(room_id, target_player_id) + self.persistence.append_message(room_id, "system", f"房主已手动移交给 {target['name']}。") + return { + "room_id": room_id, + "host_player_id": target_player_id, + "host_player_name": target["name"], + } + + def kick_player(self, room_id: str, operator_player_id: str, target_player_id: str) -> dict[str, Any]: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + if room["host_player_id"] != operator_player_id: + raise ValueError("only_host_can_kick") + if room["status"] != "lobby" or room["game_state"].get("game_phase") != "lobby": + raise ValueError("only_lobby_can_kick") + + target = self.persistence.get_player(room_id, target_player_id) + if target is None: + raise ValueError("target_player_not_found") + if target.get("is_host"): + raise ValueError("cannot_kick_host") + + runtime = self._runtime(room_id) + with runtime.lock, self._lock: + runtime.pending_actions.pop(target_player_id, None) + sid = runtime.sid_by_player.pop(target_player_id, None) + if sid: + self._sid_index.pop(sid, None) + + state = room["game_state"] + inventory_map = state.get("inventory_by_player", {}) + if isinstance(inventory_map, dict): + inventory_map.pop(target_player_id, None) + + global_flags = state.get("global_flags", {}) + if isinstance(global_flags, dict): + clues_by_player = global_flags.get("clues_by_player", {}) + if isinstance(clues_by_player, dict): + clues_by_player.pop(target_player_id, None) + metric_values_by_player = global_flags.get("metric_values_by_player", {}) + if isinstance(metric_values_by_player, dict): + metric_values_by_player.pop(target_player_id, None) + + self.persistence.remove_player(room_id, target_player_id) + self.state_manager.set_room_state(room_id, room["status"], state) + self.persistence.append_message(room_id, "system", f"{target['name']} 已被房主移出房间。") + return {"room_id": room_id, "target_player_id": target_player_id, "target_name": target["name"]} + + def delete_room( + self, + room_id: str, + player_id: str, + *, + requester_user_id: str | None = None, + ) -> None: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + + player = self.persistence.get_player(room_id, player_id) + if player is None: + raise ValueError("player_not_in_room") + + bound_user_id = str(player.get("user_id") or "") + if requester_user_id and bound_user_id and bound_user_id != requester_user_id: + raise ValueError("player_not_in_room") + + if not player.get("is_host"): + raise ValueError("only_host_can_delete") + + self.persistence.delete_room(room_id) + + with self._lock: + runtime = self._rooms.pop(room_id, None) + if runtime: + for sid in runtime.sid_by_player.values(): + self._sid_index.pop(sid, None) + + def set_room_public(self, room_id: str, operator_player_id: str, is_public: bool) -> dict[str, Any]: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + if room["host_player_id"] != operator_player_id: + raise ValueError("only_host_can_manage_public") + self.persistence.update_room_public(room_id, is_public) + return {"room_id": room_id, "is_public": bool(is_public)} + + def force_delete_room(self, room_id: str) -> None: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + self.persistence.delete_room(room_id) + with self._lock: + runtime = self._rooms.pop(room_id, None) + if runtime: + for sid in runtime.sid_by_player.values(): + self._sid_index.pop(sid, None) + + def set_ready(self, room_id: str, player_id: str, ready: bool) -> None: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + if room["status"] != "lobby": + raise ValueError("game_already_started") + self.persistence.update_player_ready(room_id, player_id, ready) + + def start_game(self, room_id: str, player_id: str) -> None: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + if room["host_player_id"] != player_id: + raise ValueError("only_host_can_start") + if room["status"] != "lobby" or room["game_state"].get("game_phase") != "lobby": + raise ValueError("game_already_started") + + players = self.persistence.list_players(room_id) + non_host_players = [row for row in players if not row["is_host"]] + if non_host_players and not all(row["is_ready"] for row in non_host_players): + raise ValueError("players_not_ready") + + state = room["game_state"] + state["game_phase"] = "running" + if int(state.get("chapter", 0)) < 1: + state["chapter"] = 1 + if not state.get("pending_choices"): + starter = state.get("global_flags", {}).get("starter_goals", []) + if isinstance(starter, list) and starter: + state["pending_choices"] = [str(row) for row in starter[:4]] + else: + state["pending_choices"] = ["先观察环境", "与关键角色交谈", "团队分工行动"] + + self.state_manager.set_room_state(room_id, "running", state) + self.persistence.append_message(room_id, "system", "房主已开始游戏,第一幕开启。") + scene_intro = str(state.get("scene_description", "")).strip() + if scene_intro: + self.persistence.append_message(room_id, "ai", scene_intro) + + def submit_chat(self, room_id: str, player_id: str, content: str) -> dict[str, Any]: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + if room["status"] == "ended" or room["game_state"].get("game_phase") == "ended": + raise ValueError("game_ended") + + text = self._sanitize(content) + if not text: + raise ValueError("empty_message") + player = self.persistence.get_player(room_id, player_id) + if not player: + raise ValueError("player_not_found") + rendered = f"{player['name']}: {text}" + self.persistence.append_message(room_id, "chat", rendered, player_id=player_id) + return {"kind": "chat", "player_id": player_id, "content": rendered} + + def submit_action(self, room_id: str, player_id: str, content: str) -> dict[str, Any]: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + + state = room["game_state"] + if room["status"] != "running" or state.get("game_phase") != "running": + raise ValueError("game_not_running") + + player = self.persistence.get_player(room_id, player_id) + if not player: + raise ValueError("player_not_found") + + text = self._sanitize(content) + if not text: + raise ValueError("empty_action") + + runtime = self._runtime(room_id) + with runtime.lock: + if runtime.resolving_round: + raise ValueError("round_resolving") + if player_id in runtime.pending_actions: + raise ValueError("action_already_submitted") + runtime.pending_actions[player_id] = PlayerAction( + player_id=player_id, + player_name=player["name"], + content=text, + action_type="action", + ) + pending_count = len(runtime.pending_actions) + + self.persistence.append_action(room_id, int(state.get("round_no", 0)) + 1, player_id, text) + self.persistence.append_message( + room_id, + "action", + f"{player['name']}: {text}", + player_id=player_id, + ) + + online_players_count = sum(1 for row in self.persistence.list_players(room_id) if row["is_online"]) + should_auto_resolve = pending_count >= max(1, online_players_count) + + return { + "kind": "action", + "player_id": player_id, + "player_name": player["name"], + "content": text, + "pending_count": pending_count, + "should_auto_resolve": should_auto_resolve, + } + + def resolve_round(self, room_id: str, resolver_player_id: str) -> dict[str, Any]: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + + state = room["game_state"] + if room["status"] != "running" or state.get("game_phase") != "running": + raise ValueError("game_not_running") + + players = self.persistence.list_players(room_id) + runtime = self._runtime(room_id) + actions: list[PlayerAction] = [] + with runtime.lock: + if runtime.resolving_round: + raise ValueError("round_resolving") + actions = list(runtime.pending_actions.values()) + if not actions: + raise ValueError("no_pending_actions") + runtime.resolving_round = True + runtime.pending_actions = {} + + try: + merge_result = self.action_resolver.merge_actions(actions, room_id, int(state.get("round_no", 0)) + 1) + prompt_context = { + "story_context": self.story_engine.story_context(state.get("scene_id", "lobby"), state), + "game_state": state, + "players": [ + {"player_id": player["player_id"], "name": player["name"], "is_online": player["is_online"]} + for player in players + ], + "merged_actions": merge_result, + } + + ai_payload = None + ai_call = self.deepseek_client.chat_json(build_messages(prompt_context)) + if ai_call.get("ok"): + parsed, parse_error = parse_json_payload(ai_call.get("content") or "") + if parse_error: + raise ValueError("ai_invalid_json") + else: + ok, errors, normalized = validate_ai_payload(parsed or {}) + ai_payload = normalized + if not ok: + raise ValueError(f"ai_invalid_schema:{','.join(errors[:3])}") + else: + ai_error = str(ai_call.get("error", "ai_call_failed")).strip() or "ai_call_failed" + raise ValueError(f"ai_call_failed:{ai_error}") + + if ai_payload is None: + raise ValueError("ai_call_failed:empty_ai_payload") + + 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) + + if round_result.get("merge_summary"): + self.persistence.append_message(room_id, "system", round_result["merge_summary"]) + if round_result.get("narration"): + self.persistence.append_message(room_id, "ai", round_result["narration"]) + for row in round_result.get("npc_dialogues", []): + npc = row.get("npc", "NPC") + line = row.get("line", "") + if line: + self.persistence.append_message(room_id, "npc", f"{npc}: {line}") + + private_payload: dict[str, list[str]] = {} + for row in round_result.get("private_player_updates", []): + player_id = row.get("player_id") + message = row.get("message") + if not player_id or not message: + continue + private_payload.setdefault(player_id, []).append(message) + self.persistence.append_message( + room_id, + "private", + message, + player_id=player_id, + visibility="private", + ) + + return { + "round_result": round_result, + "private_updates": private_payload, + "snapshot": self.get_snapshot(room_id), + } + except Exception: + with runtime.lock: + if runtime.resolving_round: + for action in actions: + runtime.pending_actions[action.player_id] = action + raise + finally: + with runtime.lock: + runtime.resolving_round = False + + def get_snapshot(self, room_id: str) -> dict[str, Any]: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + runtime = self._runtime(room_id) + with runtime.lock: + pending_actions = [action.to_dict() for action in runtime.pending_actions.values()] + resolving_round = bool(runtime.resolving_round) + + players = self.persistence.list_players(room_id) + messages = self.persistence.list_messages(room_id) + game_card = self.persistence.get_game_card(room.get("game_card_id", "")) + state = room["game_state"] + user_cache: dict[str, dict[str, Any]] = {} + for player in players: + user_id = str(player.get("user_id") or "") + if not user_id or user_id in user_cache: + continue + user_cache[user_id] = self.persistence.get_user_by_id(user_id) or {} + scene_guidance = self.story_engine.scene_guidance( + state.get("scene_id", "lobby"), + int(state.get("round_no", 0)), + list(state.get("pending_choices", [])), + ) + return { + "room_id": room_id, + "status": room["status"], + "is_public": bool(room.get("is_public", False)), + "host_player_id": room["host_player_id"], + "game_card": game_card, + "players": [ + { + "player_id": player["player_id"], + "name": player["name"], + "user_id": player.get("user_id"), + "avatar_color": user_cache.get(str(player.get("user_id") or ""), {}).get("avatar_color", "#0070f2"), + "avatar_url": user_cache.get(str(player.get("user_id") or ""), {}).get("avatar_url", ""), + "is_host": player["is_host"], + "is_ready": player["is_ready"], + "is_online": player["is_online"], + } + for player in players + ], + "game_state": state, + "scene_guidance": scene_guidance, + "pending_actions": pending_actions, + "is_resolving_round": resolving_round, + "messages": messages, + } + + def room_sids(self, room_id: str) -> list[str]: + runtime = self._runtime(room_id) + with runtime.lock: + return list(runtime.sid_by_player.values()) + + def sid_for_player(self, room_id: str, player_id: str) -> str | None: + runtime = self._runtime(room_id) + with runtime.lock: + return runtime.sid_by_player.get(player_id) diff --git a/game/schemas.py b/game/schemas.py new file mode 100644 index 0000000..ac7322a --- /dev/null +++ b/game/schemas.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import json +from typing import Any + +ALLOWED_TOP_LEVEL_KEYS = { + "narration", + "npc_dialogues", + "state_updates", + "private_player_updates", + "choices", + "should_end_scene", + "should_end_game", + "ending_summary", +} + + +def fallback_ai_payload(reason: str = "AI 响应异常,主持规则兜底推进。") -> dict[str, Any]: + return { + "narration": reason, + "npc_dialogues": [], + "state_updates": { + "danger_delta": 0, + "add_clues": [], + "set_flags": {}, + "npc_changes": {}, + "inventory_add": {}, + "inventory_remove": {}, + }, + "private_player_updates": [], + "choices": ["继续探索大厅", "分头调查可疑 NPC", "检查已获得线索"], + "should_end_scene": False, + "should_end_game": False, + "ending_summary": None, + } + + +def parse_json_payload(raw_text: str) -> tuple[dict[str, Any] | None, str | None]: + if not raw_text: + return None, "empty_response" + text = raw_text.strip() + + candidates = [text] + start = text.find("{") + end = text.rfind("}") + if start != -1 and end != -1 and end > start: + candidates.append(text[start : end + 1]) + + for candidate in candidates: + try: + parsed = json.loads(candidate) + if isinstance(parsed, dict): + return parsed, None + except json.JSONDecodeError: + continue + return None, "invalid_json" + + +def _ensure_list_of_str(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + output: list[str] = [] + for item in value: + if isinstance(item, str): + output.append(item.strip()) + return output + + +def validate_ai_payload(payload: dict[str, Any]) -> tuple[bool, list[str], dict[str, Any]]: + errors: list[str] = [] + normalized = fallback_ai_payload(reason="") + + unknown_keys = set(payload.keys()) - ALLOWED_TOP_LEVEL_KEYS + if unknown_keys: + errors.append(f"unknown_top_keys:{sorted(list(unknown_keys))}") + + narration = payload.get("narration") + if isinstance(narration, str) and narration.strip(): + normalized["narration"] = narration.strip() + else: + errors.append("invalid_narration") + + npc_dialogues = payload.get("npc_dialogues", []) + normalized_dialogues = [] + if isinstance(npc_dialogues, list): + for row in npc_dialogues: + if not isinstance(row, dict): + continue + npc = row.get("npc") + line = row.get("line") + tone = row.get("tone", "neutral") + if isinstance(npc, str) and isinstance(line, str): + normalized_dialogues.append( + {"npc": npc.strip(), "line": line.strip(), "tone": str(tone).strip()} + ) + else: + errors.append("invalid_npc_dialogues") + normalized["npc_dialogues"] = normalized_dialogues + + state_updates = payload.get("state_updates", {}) + if not isinstance(state_updates, dict): + errors.append("invalid_state_updates") + state_updates = {} + + danger_delta = state_updates.get("danger_delta", 0) + if not isinstance(danger_delta, int): + errors.append("invalid_danger_delta") + danger_delta = 0 + danger_delta = max(-2, min(3, danger_delta)) + + add_clues = _ensure_list_of_str(state_updates.get("add_clues", [])) + set_flags_raw = state_updates.get("set_flags", {}) + set_flags = set_flags_raw if isinstance(set_flags_raw, dict) else {} + if not isinstance(set_flags_raw, dict): + errors.append("invalid_set_flags") + + npc_changes_raw = state_updates.get("npc_changes", {}) + npc_changes = npc_changes_raw if isinstance(npc_changes_raw, dict) else {} + if not isinstance(npc_changes_raw, dict): + errors.append("invalid_npc_changes") + + inventory_add_raw = state_updates.get("inventory_add", {}) + inventory_add: dict[str, list[str]] = {} + if isinstance(inventory_add_raw, dict): + for player_id, items in inventory_add_raw.items(): + if isinstance(player_id, str): + inventory_add[player_id] = _ensure_list_of_str(items) + else: + errors.append("invalid_inventory_add") + + inventory_remove_raw = state_updates.get("inventory_remove", {}) + inventory_remove: dict[str, list[str]] = {} + if isinstance(inventory_remove_raw, dict): + for player_id, items in inventory_remove_raw.items(): + if isinstance(player_id, str): + inventory_remove[player_id] = _ensure_list_of_str(items) + else: + errors.append("invalid_inventory_remove") + + normalized["state_updates"] = { + "danger_delta": danger_delta, + "add_clues": add_clues, + "set_flags": set_flags, + "npc_changes": npc_changes, + "inventory_add": inventory_add, + "inventory_remove": inventory_remove, + } + + private_updates = payload.get("private_player_updates", []) + normalized_private = [] + if isinstance(private_updates, list): + for row in private_updates: + if isinstance(row, dict) and isinstance(row.get("player_id"), str) and isinstance( + row.get("message"), str + ): + normalized_private.append( + { + "player_id": row["player_id"].strip(), + "message": row["message"].strip(), + } + ) + else: + errors.append("invalid_private_updates") + normalized["private_player_updates"] = normalized_private + + choices = _ensure_list_of_str(payload.get("choices", []))[:4] + if not choices: + choices = ["继续探索", "与 NPC 交谈", "整理线索"] + normalized["choices"] = choices + + should_end_scene = payload.get("should_end_scene", False) + if not isinstance(should_end_scene, bool): + errors.append("invalid_should_end_scene") + should_end_scene = False + normalized["should_end_scene"] = should_end_scene + + should_end_game = payload.get("should_end_game", False) + if not isinstance(should_end_game, bool): + errors.append("invalid_should_end_game") + should_end_game = False + normalized["should_end_game"] = should_end_game + + ending_summary = payload.get("ending_summary") + if ending_summary is not None and not isinstance(ending_summary, str): + errors.append("invalid_ending_summary") + ending_summary = None + normalized["ending_summary"] = ending_summary + + if not normalized["narration"]: + normalized["narration"] = "主持规则接管:本轮叙述采用系统兜底。" + + return len(errors) == 0, errors, normalized diff --git a/game/state_manager.py b/game/state_manager.py new file mode 100644 index 0000000..4390e46 --- /dev/null +++ b/game/state_manager.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +from .story_engine import StoryEngine +from services.persistence import SQLitePersistence + + +class StateManager: + def __init__(self, persistence: SQLitePersistence, story_engine: StoryEngine): + self.persistence = persistence + self.story_engine = story_engine + + def create_new_room( + self, + room_id: str, + host_player_id: str, + host_name: str, + *, + host_user_id: str | None = None, + game_card: dict[str, Any] | None = None, + ) -> dict[str, Any]: + state = self.story_engine.initial_state( + room_id, + [host_player_id], + game_card=game_card, + ).to_dict() + game_card_id = (game_card or {}).get("card_id", "fogharbor") + self.persistence.create_room(room_id, host_player_id, state, game_card_id=game_card_id) + self.persistence.add_or_update_player( + room_id, + host_player_id, + host_name, + is_host=True, + is_ready=False, + is_online=True, + user_id=host_user_id, + ) + return state + + def add_player( + self, + room_id: str, + player_id: str, + name: str, + is_host: bool = False, + *, + user_id: str | None = None, + ) -> dict[str, Any]: + room = self.persistence.get_room(room_id) + if room is None: + raise ValueError("room_not_found") + + game_state = room["game_state"] + game_state.setdefault("inventory_by_player", {}) + game_state["inventory_by_player"].setdefault(player_id, []) + + self.persistence.add_or_update_player( + room_id, + player_id, + name, + is_host=is_host, + is_ready=False, + is_online=True, + user_id=user_id, + ) + self.persistence.update_room_state(room_id, room["status"], game_state) + return game_state + + def get_room_state(self, room_id: str) -> tuple[dict[str, Any], list[dict[str, Any]], str] | None: + room = self.persistence.get_room(room_id) + if room is None: + return None + players = self.persistence.list_players(room_id) + return room["game_state"], players, room["status"] + + def set_room_state(self, room_id: str, status: str, state: dict[str, Any]) -> None: + self.persistence.update_room_state(room_id, status, state) diff --git a/game/story_engine.py b/game/story_engine.py new file mode 100644 index 0000000..9713700 --- /dev/null +++ b/game/story_engine.py @@ -0,0 +1,690 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +from .models import GameState, utc_now_iso + +ALLOWED_CLUES = { + "bloodied_key", + "torn_manifest", + "silver_bell", + "maid_diary", + "cellar_sigil", + "missing_guest_note", +} +FOGHARBOR_ITEM_LABELS = { + "bloodied_key": "染血钥匙", + "torn_manifest": "残破船务清单", + "silver_bell": "银铃", + "maid_diary": "女仆日记", + "cellar_sigil": "地下室印记", + "missing_guest_note": "失踪者便条", +} +FOGHARBOR_ITEM_ALIASES = { + **{key: key for key in FOGHARBOR_ITEM_LABELS}, + **{value: key for key, value in FOGHARBOR_ITEM_LABELS.items()}, +} + +NPC_IDS = {"innkeeper", "maid", "silent_traveler"} + +SCENES = { + "lobby": { + "chapter": 1, + "title": "大厅", + "description": "壁炉忽明忽暗,旅店老板正用审视的目光打量每个人。", + "choices": ["检查登记台", "询问旅店老板", "观察沉默旅客"], + }, + "dining_room": { + "chapter": 2, + "title": "餐厅", + "description": "餐厅里摆着尚有余温的餐具,女仆反复擦拭同一张桌子。", + "choices": ["检查餐桌残渣", "说服女仆开口", "监听老板与旅客对话"], + }, + "corridor": { + "chapter": 3, + "title": "走廊", + "description": "长走廊上挂满褪色油画,尽头传来断续的敲击声。", + "choices": ["追踪敲击声", "查看 301 房门", "搜索暗门机关"], + }, + "guest_room": { + "chapter": 4, + "title": "客房", + "description": "客房内潮湿阴冷,地板下有若隐若现的符号刻痕。", + "choices": ["翻找床底箱子", "解读墙上符号", "寻找地下室入口"], + }, + "basement": { + "chapter": 5, + "title": "地下室", + "description": "地下室中央摆着仪式台,真相与代价都已无处回避。", + "choices": ["破坏仪式", "与隐藏势力谈判", "尝试组织全员逃离"], + }, +} + +ENDING_SUMMARIES = { + "escaped": "你们揭穿了旅店真相并组织逃离,雾港旅店在身后沉入迷雾。", + "sacrifice": "真相被揭开,但有人留下挡住了灾厄,你们带着代价离开。", + "trapped": "危险失控,旅店的规则吞没了所有出口,你们被永困其中。", + "pact": "你们选择与隐藏势力交易,离开旅店,却成为新的规则执行者。", +} + + +class StoryEngine: + def initial_state( + self, + room_id: str, + player_ids: list[str], + game_card: dict[str, Any] | None = None, + ) -> GameState: + inventory = {player_id: [] for player_id in player_ids} + card = game_card or {} + opening_text = str(card.get("opening_text") or "暴风雨夜,你们推开雾港旅店大门,空气里有潮湿铁锈味。") + starter_goals = [str(item) for item in card.get("starter_goals", []) if isinstance(item, str)] + game_card_id = str(card.get("card_id", "fogharbor")) + game_card_title = str(card.get("title", "雾港旅店")) + state_schema = dict(card.get("state_schema", {})) + 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", {})) + 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 ( + starter_goals[:3] if starter_goals else ["先观察环境", "与关键角色对话", "团队分工行动"] + ) + + metric_values: dict[str, int] = {} + for metric in state_schema.get("metrics", []): + key = str(metric.get("key", "")).strip() + if not key: + continue + metric_values[key] = int(metric.get("initial", 0)) + + metric_values_by_player: dict[str, dict[str, int]] = {} + if data_scope["metrics"] == "per_player": + for player_id in player_ids: + metric_values_by_player[player_id] = dict(metric_values) + + 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 {} + + return GameState( + room_id=room_id, + game_phase="lobby", + chapter=0, + scene_id=initial_scene, + scene_description=opening_text, + global_flags={ + "truth_revealed": False, + "escape_route_open": False, + "found_service_key": False, + "room_301_unlocked": False, + "cellar_entry_found": False, + "someone_sacrificed": False, + "allied_hidden_force": False, + "game_card_id": game_card_id, + "game_card_title": game_card_title, + "starter_goals": starter_goals, + "card_mode": card_mode, + "state_schema": state_schema, + "card_content_pack": content_pack, + "data_scope": data_scope, + "visibility": visibility, + "metric_values": metric_values, + "metric_values_by_player": metric_values_by_player, + "shared_inventory": shared_inventory, + "clues_by_player": clues_by_player, + }, + discovered_clues=[], + inventory_by_player=inventory, + npc_states={ + "innkeeper": {"mood": "guarded", "trust": 1, "location": "lobby"}, + "maid": {"mood": "anxious", "trust": 1, "location": "dining_room"}, + "silent_traveler": {"mood": "cold", "trust": 0, "location": "lobby"}, + }, + danger_level=1, + action_history=[], + pending_choices=initial_choices, + ending_state=None, + round_no=0, + ) + + 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") + if card_mode != "fogharbor": + content_pack = (state or {}).get("global_flags", {}).get("card_content_pack", {}) + return { + "setting": (state or {}).get("global_flags", {}).get("game_card_title", "自定义剧本"), + "scene": { + "id": scene_id, + "title": scene_id, + "description": (state or {}).get("scene_description", ""), + "default_choices": list((state or {}).get("pending_choices", [])), + }, + "content_pack": content_pack, + "allowed_clues": "ANY_STRING", + "npcs": "ANY_NPC", + "rules": [ + "这是自定义游戏卡,状态字段以 state_schema 为准", + "玩家/共享数据范围以 state_schema.data_scope 为准", + "不要强制套用雾港旅店的固定场景和真相", + "结局判定优先由 content_pack.end_conditions 与服务端规则执行", + "输出必须仍满足 JSON schema", + ], + } + return { + "setting": "雾港旅店", + "scene": { + "id": scene_id, + "title": scene["title"], + "description": scene["description"], + "default_choices": scene["choices"], + }, + "allowed_clues": sorted(ALLOWED_CLUES), + "npcs": sorted(NPC_IDS), + "endings": list(ENDING_SUMMARIES.keys()), + "rules": [ + "关键真相与结局由服务端状态机控制", + "危险值达到 10 会触发被困结局", + "多人冲突由系统先裁定,再交给 AI 叙述", + ], + } + + def scene_guidance(self, scene_id: str, round_no: int, pending_choices: list[str]) -> list[str]: + if round_no == 0: + return [ + "先阅读场景描述,确认当前最可疑的人或地点。", + "至少一名玩家提交“调查/交谈”类行动,触发第一轮反馈。", + "多人局建议先分工:一人问 NPC,一人搜线索,一人记录关键信息。", + ] + if pending_choices: + return [f"可优先尝试:{choice}" for choice in pending_choices[:3]] + scene = SCENES.get(scene_id, SCENES["lobby"]) + return [f"继续推进:{choice}" for choice in scene["choices"][:3]] + + def apply_turn( + self, + state_dict: dict[str, Any], + ai_payload: dict[str, Any], + merge_result: dict[str, Any], + players: list[dict[str, Any]], + ) -> tuple[dict[str, Any], dict[str, Any]]: + state = GameState.from_dict(state_dict) + player_ids = {player["player_id"] for player in players} + + for player in players: + state.inventory_by_player.setdefault(player["player_id"], []) + + 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", {})) + player_ids_list = [player["player_id"] for player in players] + danger_delta = int(updates.get("danger_delta", 0)) + if card_mode == "fogharbor": + state.danger_level = max(0, min(10, state.danger_level + danger_delta)) + + clue_targets = merge_result.get("triggered_players", []) + if not isinstance(clue_targets, list) or not clue_targets: + clue_targets = player_ids_list + for clue in updates.get("add_clues", []): + if not isinstance(clue, str): + continue + clue_value = clue.strip() + if not clue_value: + continue + if card_mode == "fogharbor" and clue_value not in ALLOWED_CLUES: + continue + if data_scope["clues"] == "per_player": + clues_by_player = state.global_flags.get("clues_by_player", {}) + if not isinstance(clues_by_player, dict): + clues_by_player = {} + for target_id in clue_targets: + if target_id not in player_ids: + continue + rows = clues_by_player.get(target_id, []) + if clue_value not in rows: + rows.append(clue_value) + clues_by_player[target_id] = rows + state.global_flags["clues_by_player"] = clues_by_player + else: + if clue_value not in state.discovered_clues: + state.discovered_clues.append(clue_value) + + flags = updates.get("set_flags", {}) + for key, value in flags.items(): + if isinstance(key, str): + state.global_flags[key] = value + + npc_changes = updates.get("npc_changes", {}) + for npc_id, patch in npc_changes.items(): + if npc_id not in NPC_IDS or not isinstance(patch, dict): + continue + state.npc_states.setdefault(npc_id, {}) + for patch_key, patch_value in patch.items(): + state.npc_states[npc_id][patch_key] = patch_value + + inventory_add = updates.get("inventory_add", {}) + inventory_remove = updates.get("inventory_remove", {}) + if data_scope["inventory"] == "shared": + shared_inventory = state.global_flags.get("shared_inventory", []) + if not isinstance(shared_inventory, list): + shared_inventory = [] + for _, items in inventory_add.items(): + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, str): + continue + item_value = self._normalize_inventory_item_key(item.strip(), card_mode) + if not item_value: + continue + if card_mode == "fogharbor" and item_value not in ALLOWED_CLUES: + continue + if item_value not in shared_inventory: + shared_inventory.append(item_value) + for _, items in inventory_remove.items(): + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, str): + continue + item_value = self._normalize_inventory_item_key(item.strip(), card_mode) + if not item_value: + continue + if card_mode == "fogharbor" and item_value not in ALLOWED_CLUES: + continue + shared_inventory = [row for row in shared_inventory if row != item_value] + state.global_flags["shared_inventory"] = shared_inventory + else: + for player_id, items in inventory_add.items(): + if player_id not in player_ids: + continue + state.inventory_by_player.setdefault(player_id, []) + for item in items: + if not isinstance(item, str): + continue + item_value = self._normalize_inventory_item_key(item.strip(), card_mode) + if not item_value: + continue + if card_mode == "fogharbor" and item_value not in ALLOWED_CLUES: + continue + if item_value not in state.inventory_by_player[player_id]: + state.inventory_by_player[player_id].append(item_value) + for player_id, items in inventory_remove.items(): + target_players = [player_id] + if player_id.strip().lower() in {"*", "all"}: + target_players = list(player_ids) + for target_player_id in target_players: + if target_player_id not in player_ids: + continue + state.inventory_by_player.setdefault(target_player_id, []) + if not isinstance(items, list): + continue + for item in items: + if not isinstance(item, str): + continue + item_value = self._normalize_inventory_item_key(item.strip(), card_mode) + if not item_value: + continue + if card_mode == "fogharbor" and item_value not in ALLOWED_CLUES: + continue + state.inventory_by_player[target_player_id] = [ + row for row in state.inventory_by_player[target_player_id] if row != item_value + ] + if card_mode != "fogharbor": + self._apply_custom_metrics(state, updates, flags, players, merge_result) + + 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 + state.last_updated_at = utc_now_iso() + + history_row = { + "round": state.round_no, + "scene_id": state.scene_id, + "merged_actions": merge_result, + "narration": ai_payload.get("narration", ""), + "npc_dialogues": ai_payload.get("npc_dialogues", []), + } + state.action_history.append(history_row) + if len(state.action_history) > 50: + state.action_history = state.action_history[-50:] + + transitioned = None + ending_state = None + if card_mode == "fogharbor": + transitioned = self._auto_progress_scene(state) + ending_state = self._check_endings(state) + else: + transitioned = self._custom_scene_progress(state, ai_payload) + ending_state = self._check_custom_endings(state, ai_payload) + 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() + + round_result = { + "narration": ai_payload.get("narration", ""), + "npc_dialogues": ai_payload.get("npc_dialogues", []), + "private_player_updates": ai_payload.get("private_player_updates", []), + "choices": state.pending_choices, + "merge_summary": merge_result.get("summary", ""), + "conflicts": merge_result.get("conflicts", []), + "triggered_players": merge_result.get("triggered_players", []), + "scene_transition": transitioned, + "ending_state": state.ending_state, + "ending_summary": ENDING_SUMMARIES.get(state.ending_state), + "guidance": self.scene_guidance(state.scene_id, state.round_no, state.pending_choices), + } + return state.to_dict(), round_result + + def _apply_custom_metrics( + self, + state: GameState, + updates: dict[str, Any], + flags: dict[str, Any], + players: list[dict[str, Any]], + merge_result: dict[str, Any], + ) -> None: + schema = dict(state.global_flags.get("state_schema", {})) + metrics = schema.get("metrics", []) + data_scope = self._normalize_data_scope(state.global_flags.get("data_scope", {})) + player_ids = [player["player_id"] for player in players] + triggered_players = merge_result.get("triggered_players", []) + if not isinstance(triggered_players, list) or not triggered_players: + triggered_players = list(player_ids) + + if data_scope["metrics"] == "per_player": + self._apply_player_metrics(state, metrics, flags, updates, player_ids, triggered_players) + return + + metric_values = dict(state.global_flags.get("metric_values", {})) + for metric in metrics: + key = str(metric.get("key", "")).strip() + if not key: + continue + current_value = int(metric_values.get(key, 0)) + if isinstance(flags.get(key), (int, float)): + current_value = int(flags[key]) + delta_key = f"{key}_delta" + if isinstance(flags.get(delta_key), (int, float)): + current_value += int(flags[delta_key]) + + if key == "danger_level": + current_value += int(updates.get("danger_delta", 0)) + + max_value = int(metric.get("max", 10)) + min_value = int(metric.get("min", 0)) + current_value = max(min_value, min(max_value, current_value)) + metric_values[key] = current_value + + state.global_flags["metric_values"] = metric_values + + def _apply_player_metrics( + self, + state: GameState, + metrics: list[dict[str, Any]], + flags: dict[str, Any], + updates: dict[str, Any], + player_ids: list[str], + triggered_players: list[str], + ) -> None: + map_by_player = state.global_flags.get("metric_values_by_player", {}) + if not isinstance(map_by_player, dict): + map_by_player = {} + default_metric_values = state.global_flags.get("metric_values", {}) + if not isinstance(default_metric_values, dict): + default_metric_values = {} + + for player_id in player_ids: + player_metrics = map_by_player.get(player_id) + if not isinstance(player_metrics, dict): + player_metrics = dict(default_metric_values) + for metric in metrics: + key = str(metric.get("key", "")).strip() + if not key: + continue + if key not in player_metrics: + player_metrics[key] = int(metric.get("initial", 0)) + map_by_player[player_id] = player_metrics + + player_metric_set = flags.get("player_metric_set", {}) + if not isinstance(player_metric_set, dict): + player_metric_set = {} + player_metric_delta = flags.get("player_metric_delta", {}) + if not isinstance(player_metric_delta, dict): + player_metric_delta = {} + + for metric in metrics: + key = str(metric.get("key", "")).strip() + if not key: + continue + min_value = int(metric.get("min", 0)) + max_value = int(metric.get("max", 10)) + if max_value < min_value: + max_value = min_value + + for player_id in player_ids: + value = int(map_by_player[player_id].get(key, 0)) + + set_payload = player_metric_set.get(player_id, {}) + if isinstance(set_payload, dict) and isinstance(set_payload.get(key), (int, float)): + value = int(set_payload[key]) + + delta_payload = player_metric_delta.get(player_id, {}) + if isinstance(delta_payload, dict) and isinstance(delta_payload.get(key), (int, float)): + value += int(delta_payload[key]) + + if player_id in triggered_players: + if isinstance(flags.get(key), (int, float)): + value = int(flags[key]) + delta_key = f"{key}_delta" + if isinstance(flags.get(delta_key), (int, float)): + value += int(flags[delta_key]) + if key == "danger_level": + value += int(updates.get("danger_delta", 0)) + + value = max(min_value, min(max_value, value)) + map_by_player[player_id][key] = value + + state.global_flags["metric_values_by_player"] = map_by_player + + def _normalize_data_scope(self, raw_scope: Any) -> dict[str, str]: + scope = raw_scope if isinstance(raw_scope, dict) else {} + out = { + "metrics": str(scope.get("metrics", "shared")).strip().lower(), + "inventory": str(scope.get("inventory", "per_player")).strip().lower(), + "clues": str(scope.get("clues", "shared")).strip().lower(), + } + for key in ("metrics", "inventory", "clues"): + if out[key] not in {"shared", "per_player"}: + out[key] = "shared" if key != "inventory" else "per_player" + return out + + def _normalize_visibility(self, raw_visibility: Any) -> dict[str, bool]: + visibility = raw_visibility if isinstance(raw_visibility, 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 + return { + "show_all_player_metrics": _to_bool(visibility.get("show_all_player_metrics", True), True), + "show_all_player_inventory": _to_bool(visibility.get("show_all_player_inventory", True), True), + } + + def _normalize_inventory_item_key(self, item_value: str, card_mode: str) -> str: + value = str(item_value or "").strip() + if not value: + return "" + if card_mode == "fogharbor": + return FOGHARBOR_ITEM_ALIASES.get(value, value) + return value + + def _custom_scene_progress(self, state: GameState, ai_payload: dict[str, Any]) -> dict[str, Any] | None: + if not ai_payload.get("should_end_scene"): + return None + prev_scene = state.scene_id + state.chapter = int(state.chapter) + 1 + state.scene_id = f"scene_{state.chapter}" + return { + "from": prev_scene, + "to": state.scene_id, + "chapter": state.chapter, + "description": state.scene_description, + } + + def _check_custom_endings(self, state: GameState, ai_payload: dict[str, Any]) -> str | None: + configured = self._evaluate_custom_end_conditions(state) + if configured: + ending_state, ending_summary = configured + state.global_flags["custom_ending_state"] = ending_state + if ending_summary: + state.global_flags["ending_summary"] = ending_summary + return ending_state + + if ai_payload.get("should_end_game"): + custom = state.global_flags.get("custom_ending_state") + if isinstance(custom, str) and custom.strip(): + return custom.strip() + return "custom_end" + return None + + def _evaluate_custom_end_conditions(self, state: GameState) -> tuple[str, str] | None: + content_pack = state.global_flags.get("card_content_pack", {}) + if not isinstance(content_pack, dict): + return None + conditions = content_pack.get("end_conditions", []) + if not isinstance(conditions, list): + return None + + metrics = state.global_flags.get("metric_values", {}) + if not isinstance(metrics, dict): + metrics = {} + + for row in conditions[:40]: + if not isinstance(row, dict): + continue + + has_clause = False + passed = True + + round_gate = row.get("round") + if isinstance(round_gate, (int, float)): + has_clause = True + if int(state.round_no) < int(round_gate): + passed = False + + metric_key = str(row.get("metric", "")).strip() + if metric_key: + has_clause = True + metric_value = metrics.get(metric_key, state.global_flags.get(metric_key)) + expected_value = row.get("value") + op = str(row.get("op", ">=")).strip() or ">=" + if not self._compare_condition(metric_value, op, expected_value): + passed = False + + flag_key = str(row.get("flag", "")).strip() + if flag_key: + has_clause = True + expected_flag = row.get("flag_equals", True) + actual_flag = state.global_flags.get(flag_key) + if actual_flag != expected_flag: + passed = False + + if not has_clause or not passed: + continue + + ending_state = str(row.get("ending_state", "")).strip() or str(row.get("id", "")).strip() or "custom_end" + ending_summary = str(row.get("ending_summary", "")).strip() + return ending_state, ending_summary + return None + + def _compare_condition(self, actual: Any, op: str, expected: Any) -> bool: + numeric_ops = {">", ">=", "<", "<="} + if op in numeric_ops: + if not isinstance(actual, (int, float)) or not isinstance(expected, (int, float)): + return False + if op == ">": + return actual > expected + if op == ">=": + return actual >= expected + if op == "<": + return actual < expected + if op == "<=": + return actual <= expected + + if op == "==": + return actual == expected + if op == "!=": + return actual != expected + return False + + def _auto_progress_scene(self, state: GameState) -> dict[str, Any] | None: + prev_scene = state.scene_id + flags = state.global_flags + clues = set(state.discovered_clues) + + if state.scene_id == "lobby" and ( + "torn_manifest" in clues or "missing_guest_note" in clues or flags.get("found_service_key") + ): + state.scene_id = "dining_room" + elif state.scene_id == "dining_room" and ( + flags.get("found_service_key") or "silver_bell" in clues or flags.get("room_301_unlocked") + ): + state.scene_id = "corridor" + elif state.scene_id == "corridor" and ( + flags.get("room_301_unlocked") or "maid_diary" in clues + ): + state.scene_id = "guest_room" + elif state.scene_id == "guest_room" and ( + flags.get("cellar_entry_found") or "cellar_sigil" in clues + ): + state.scene_id = "basement" + + if state.scene_id != prev_scene: + scene = SCENES[state.scene_id] + state.chapter = int(scene["chapter"]) + state.scene_description = scene["description"] + if not state.pending_choices: + state.pending_choices = deepcopy(scene["choices"]) + return { + "from": prev_scene, + "to": state.scene_id, + "chapter": state.chapter, + "description": scene["description"], + } + + if state.chapter == 0: + state.chapter = 1 + return None + + def _check_endings(self, state: GameState) -> str | None: + flags = state.global_flags + clues = set(state.discovered_clues) + + if state.danger_level >= 10: + return "trapped" + + if ( + state.scene_id == "basement" + and flags.get("truth_revealed") + and flags.get("escape_route_open") + and state.danger_level <= 7 + and len(clues) >= 4 + ): + return "escaped" + + if flags.get("truth_revealed") and flags.get("someone_sacrificed"): + return "sacrifice" + + if state.scene_id == "basement" and flags.get("allied_hidden_force"): + return "pact" + + return None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9f32236 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask>=3.0,<4.0 +Flask-SocketIO>=5.3,<6.0 +requests>=2.31,<3.0 +pytest>=8.0,<9.0 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/deepseek_client.py b/services/deepseek_client.py new file mode 100644 index 0000000..cb71a85 --- /dev/null +++ b/services/deepseek_client.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import time +from typing import Any + +import requests + + +class DeepSeekClient: + def __init__( + self, + api_key: str, + base_url: str = "https://api.deepseek.com", + model: str = "deepseek-chat", + timeout: float = 25.0, + connect_timeout: float | None = None, + read_timeout: float | None = None, + retries: int = 2, + ): + self.api_key = api_key.strip() + self.base_url = base_url.rstrip("/") + self.model = model + self.timeout = float(timeout) + self.connect_timeout = float(connect_timeout) if connect_timeout is not None else 8.0 + self.read_timeout = float(read_timeout) if read_timeout is not None else float(timeout) + self.retries = retries + + def chat_json( + self, + messages: list[dict[str, str]], + *, + temperature: float = 0.6, + max_tokens: int = 800, + ) -> dict[str, Any]: + if not self.api_key: + return {"ok": False, "error": "missing_api_key", "content": None, "detail": "api_key_empty"} + + url = f"{self.base_url}/v1/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + payload = { + "model": self.model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "response_format": {"type": "json_object"}, + } + + last_error = "request_failed" + last_detail = "" + for attempt in range(self.retries + 1): + try: + response = requests.post( + url, + headers=headers, + json=payload, + timeout=(self.connect_timeout, self.read_timeout), + ) + if response.status_code >= 500: + last_error = f"server_error_{response.status_code}" + raise requests.HTTPError(last_error) + + if response.status_code >= 400: + return { + "ok": False, + "error": f"http_{response.status_code}", + "content": None, + "detail": "", + "raw": response.text, + } + + body = response.json() + choices = body.get("choices", []) + if not choices: + return {"ok": False, "error": "empty_choices", "content": None, "detail": "", "raw": body} + + content = choices[0].get("message", {}).get("content", "") + if not isinstance(content, str) or not content.strip(): + return {"ok": False, "error": "empty_content", "content": None, "detail": "", "raw": body} + + return {"ok": True, "error": None, "content": content, "detail": "", "raw": body} + + except requests.Timeout as exc: + last_error = "request_timeout" + last_detail = str(exc) + except requests.ConnectionError as exc: + detail = str(exc) + lower = detail.lower() + if "nodename nor servname provided" in lower or "name or service not known" in lower: + last_error = "dns_resolution_failed" + elif "connection refused" in lower: + last_error = "connection_refused" + elif "read timed out" in lower: + last_error = "request_timeout" + else: + last_error = "network_connection_error" + last_detail = detail + except (requests.HTTPError, ValueError): + if last_error == "request_failed": + last_error = "invalid_response" + last_detail = "" + + if attempt < self.retries: + time.sleep(0.4 * (attempt + 1)) + + return {"ok": False, "error": last_error, "content": None, "detail": last_detail} diff --git a/services/persistence.py b/services/persistence.py new file mode 100644 index 0000000..918a03e --- /dev/null +++ b/services/persistence.py @@ -0,0 +1,1126 @@ +from __future__ import annotations + +import json +import re +import sqlite3 +import threading +import uuid +from pathlib import Path +from typing import Any + +from game.models import utc_now_iso + + +DEFAULT_CARD_ID = "fogharbor" + + +class SQLitePersistence: + def __init__(self, db_path: str): + self.db_path = str(Path(db_path)) + self._lock = threading.RLock() + self.init_schema() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + def _ensure_column(self, conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + columns = {row["name"] for row in rows} + if column not in columns: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}") + + def init_schema(self) -> None: + with self._lock, self._connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'player', + avatar_color TEXT NOT NULL DEFAULT '#0070f2', + avatar_url TEXT NOT NULL DEFAULT '', + theme TEXT NOT NULL DEFAULT 'dopamine', + bio TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS game_cards ( + card_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + subtitle TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL, + cover_url TEXT NOT NULL, + opening_text TEXT NOT NULL, + starter_goals TEXT NOT NULL, + state_schema TEXT NOT NULL DEFAULT '{}', + content_pack TEXT NOT NULL DEFAULT '{}', + creator_user_id TEXT, + is_official INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS rooms ( + room_id TEXT PRIMARY KEY, + host_player_id TEXT NOT NULL, + is_public INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + chapter INTEGER NOT NULL, + scene_id TEXT NOT NULL, + game_state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS players ( + player_id TEXT PRIMARY KEY, + room_id TEXT NOT NULL, + name TEXT NOT NULL, + is_host INTEGER NOT NULL DEFAULT 0, + is_ready INTEGER NOT NULL DEFAULT 0, + is_online INTEGER NOT NULL DEFAULT 1, + joined_at TEXT NOT NULL, + last_seen TEXT NOT NULL, + FOREIGN KEY(room_id) REFERENCES rooms(room_id) + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL, + kind TEXT NOT NULL, + player_id TEXT, + visibility TEXT NOT NULL DEFAULT 'public', + content TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(room_id) REFERENCES rooms(room_id) + ); + + CREATE TABLE IF NOT EXISTS actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL, + round_no INTEGER NOT NULL, + player_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(room_id) REFERENCES rooms(room_id) + ); + """ + ) + + self._ensure_column(conn, "players", "user_id", "TEXT") + self._ensure_column(conn, "rooms", "game_card_id", f"TEXT NOT NULL DEFAULT '{DEFAULT_CARD_ID}'") + self._ensure_column(conn, "rooms", "is_public", "INTEGER NOT NULL DEFAULT 0") + self._ensure_column(conn, "game_cards", "state_schema", "TEXT NOT NULL DEFAULT '{}'") + self._ensure_column(conn, "game_cards", "content_pack", "TEXT NOT NULL DEFAULT '{}'") + self._ensure_column(conn, "users", "role", "TEXT NOT NULL DEFAULT 'player'") + self._ensure_column(conn, "users", "avatar_url", "TEXT NOT NULL DEFAULT ''") + self._ensure_column(conn, "users", "theme", "TEXT NOT NULL DEFAULT 'dopamine'") + + conn.commit() + self._seed_default_cards(conn) + conn.commit() + + def _seed_default_cards(self, conn: sqlite3.Connection) -> None: + now = utc_now_iso() + cards = [ + { + "card_id": "fogharbor", + "title": "雾港旅店", + "subtitle": "合作推理 / 诡异悬疑", + "summary": "暴风雨夜,一间旅店困住了所有人。你们要在规则与谎言中找出真相。", + "cover_url": "/static/img/cards/fogharbor.svg", + "opening_text": "暴风雨夜,你们推开雾港旅店大门,空气里有潮湿铁锈味。", + "starter_goals": ["先查看大厅可疑区域", "与旅店老板交谈", "确定队伍分工"], + "state_schema": { + "metrics": [ + {"key": "team_stability", "label": "队伍稳定度", "min": 0, "max": 100, "initial": 68, "color": "#ff8c42"}, + {"key": "truth_progress", "label": "真相推进", "min": 0, "max": 100, "initial": 5, "color": "#4b7cff"}, + {"key": "night_rule_pressure", "label": "夜规压迫", "min": 0, "max": 100, "initial": 20, "color": "#8b5cf6"}, + ], + "labels": { + "inventory": "个人携行物", + "clues": "线索墙", + "choices": "可执行指令", + "scene": "当前区域", + }, + "scene_labels": { + "lobby": "大厅", + "dining_room": "餐厅", + "corridor": "走廊", + "guest_room": "客房", + "basement": "地下室", + }, + "data_scope": { + "metrics": "per_player", + "inventory": "per_player", + "clues": "shared", + }, + "visibility": { + "show_all_player_metrics": True, + "show_all_player_inventory": True, + }, + "ui": { + "room_background_url": "/static/img/cards/fogharbor.svg", + "hero_status": { + "key": "night_rule_pressure", + "label": "夜规压迫", + "min": 0, + "max": 100, + "color": "#8b5cf6", + }, + "status_tracks": [ + {"key": "night_rule_pressure", "label": "夜规压迫", "min": 0, "max": 100, "color": "#8b5cf6"}, + {"key": "truth_progress", "label": "真相推进", "min": 0, "max": 100, "color": "#4b7cff"}, + {"key": "team_stability", "label": "队伍稳定度", "min": 0, "max": 100, "color": "#ff8c42"}, + ], + }, + }, + "content_pack": { + "genre_tags": ["悬疑", "诡异", "合作推理"], + "feature_badges": ["多结局", "NPC交锋", "规则怪谈"], + "world_lore": "雾港旅店坐落在暴雨断航的旧码头,每逢风暴夜就会重复一次失踪事件。", + "core_conflict": "玩家要在旅店规则与NPC谎言中找出失踪者去向,并决定是否牺牲某人换取逃离。", + "player_roles": ["调查者", "谈判者", "记录者", "潜行者"], + "interaction_modes": ["合作调查", "分头行动", "公开投票", "私下交易"], + "system_rules": [ + "每轮至少提交一条调查或交涉行动", + "危险值越高,NPC越不可信", + "公开讨论可降低误判,但会抬升紧张度", + ], + "npc_roster": [ + "旅店老板:掌握旧账本,言辞含糊", + "女仆:知道地下室入口,害怕被追责", + "沉默旅客:与失踪者有关,但拒绝明说", + ], + "scene_blueprint": [ + "大厅:建立怀疑对象与分工", + "餐厅:线索交叉验证", + "走廊/客房:揭示隐藏关系", + "地下室:结局抉择", + ], + "ending_catalog": ["成功逃离", "真相伴随牺牲", "被旅店困住", "与隐藏势力交易"], + "hidden_truths": ["失踪者并非单独行动", "旅店规则有人为操控", "地下室仪式可被篡改"], + "systems": { + "investigation": {"enabled": True, "suggestion": "多人交叉验证提高真相推进"}, + "social": {"enabled": True, "trust_field": "team_stability"}, + }, + "end_conditions": [ + { + "id": "escape_success", + "metric": "truth_progress", + "op": ">=", + "value": 88, + "flag": "escape_route_open", + "flag_equals": True, + "ending_state": "escaped", + "ending_summary": "你们破解了旅店夜规并打开逃生路线,成功离开雾港。", + }, + { + "id": "night_consumed", + "metric": "night_rule_pressure", + "op": ">=", + "value": 100, + "ending_state": "trapped", + "ending_summary": "夜规彻底接管旅店,队伍被困在永夜轮回里。", + }, + ], + }, + }, + { + "card_id": "sunken_arcade", + "title": "沉没游乐城", + "subtitle": "探索冒险 / 机关解谜", + "summary": "被海水吞没的游乐城仍在夜里通电,机械与记忆一起复苏。", + "cover_url": "/static/img/cards/sunken_arcade.svg", + "opening_text": "霓虹在海雾中一闪一灭,沉没游乐城的闸门缓缓开启。", + "starter_goals": ["寻找主控电源", "确认出口是否畅通", "排查游乐设施异常"], + "state_schema": { + "metrics": [ + {"key": "pressure", "label": "水压警戒", "min": 0, "max": 120, "initial": 24, "color": "#26b7ff"}, + {"key": "power_grid", "label": "电网稳定", "min": 0, "max": 100, "initial": 40, "color": "#7affb3"}, + {"key": "team_oxygen", "label": "队伍氧量", "min": 0, "max": 100, "initial": 85, "color": "#ffd166"}, + ], + "labels": { + "inventory": "潜行装备", + "clues": "异常记录", + "choices": "可执行指令", + "scene": "当前区段", + }, + "data_scope": { + "metrics": "per_player", + "inventory": "shared", + "clues": "shared", + }, + "visibility": { + "show_all_player_metrics": True, + "show_all_player_inventory": True, + }, + "ui": { + "room_background_url": "/static/img/cards/sunken_arcade.svg", + "hero_status": { + "key": "pressure", + "label": "水压警戒", + "min": 0, + "max": 120, + "color": "#26b7ff", + }, + "status_tracks": [ + {"key": "pressure", "label": "水压警戒", "min": 0, "max": 120, "color": "#26b7ff"}, + {"key": "power_grid", "label": "电网稳定", "min": 0, "max": 100, "color": "#7affb3"}, + {"key": "team_oxygen", "label": "队伍氧量", "min": 0, "max": 100, "color": "#ffd166"}, + ], + }, + }, + "content_pack": { + "genre_tags": ["科幻", "探索", "解谜"], + "feature_badges": ["资源管理", "机关联动", "团队协作"], + "world_lore": "沉没游乐城位于海沟边缘,夜晚会短暂恢复供电并广播失落游客名单。", + "core_conflict": "玩家必须在水压持续上升前修复主控系统,决定牺牲供电区还是冒险突破。", + "player_roles": ["工程师", "侦查员", "安全员"], + "interaction_modes": ["区域分队", "同步解谜", "风险分摊"], + "system_rules": [ + "每轮需声明要优先处理的设施", + "电网稳定度过低会关闭部分选项", + "冒险行动收益高但会显著增加水压警戒", + ], + "npc_roster": [ + "售票机器人:可提供旧地图但需要通电", + "失忆保安:记得撤离路线碎片", + "海沟回声:会伪装成队友语音", + ], + "scene_blueprint": ["入口检修区", "霓虹主街", "维护中枢", "深层观景廊", "总控塔"], + "ending_catalog": ["全员撤离", "部分区段沉没", "系统失控永久封锁"], + "hidden_truths": ["事故由内部测试引发", "主控塔有备用逃生通道"], + "systems": { + "hazard": {"enabled": True, "primary_metric": "pressure"}, + "resource": {"enabled": True, "shared_inventory": True}, + }, + "end_conditions": [ + { + "id": "arcade_escape", + "metric": "power_grid", + "op": ">=", + "value": 85, + "flag": "escape_route_open", + "flag_equals": True, + "ending_state": "escaped", + "ending_summary": "你们重启了主控网络并打开泄压门,全员脱离沉没游乐城。", + }, + { + "id": "oxygen_exhausted", + "metric": "team_oxygen", + "op": "<=", + "value": 0, + "ending_state": "trapped", + "ending_summary": "氧量耗尽,队伍被困在黑暗海沟中。", + }, + ], + }, + }, + { + "card_id": "skyrail_omega", + "title": "天轨终点站", + "subtitle": "科幻悬疑 / 团队抉择", + "summary": "无人列车驶向失落终点,车厢里的每个人都藏着不同目的。", + "cover_url": "/static/img/cards/skyrail_omega.svg", + "opening_text": "列车广播响起:下一站,终点站。窗外却只有无尽云海。", + "starter_goals": ["检查列车广播来源", "盘问可疑乘客", "确认车头控制权"], + "state_schema": { + "metrics": [ + {"key": "suspicion", "label": "全列车怀疑度", "min": 0, "max": 100, "initial": 30, "color": "#ff6a6a"}, + {"key": "signal_integrity", "label": "信号完整度", "min": 0, "max": 100, "initial": 55, "color": "#4d84ff"}, + {"key": "authority", "label": "队伍控制权", "min": 0, "max": 100, "initial": 35, "color": "#7affb3"}, + ], + "labels": { + "inventory": "随身模块", + "clues": "乘客情报", + "choices": "可选路线", + "scene": "当前车厢", + }, + "data_scope": { + "metrics": "shared", + "inventory": "per_player", + "clues": "shared", + }, + "visibility": { + "show_all_player_metrics": True, + "show_all_player_inventory": True, + }, + "ui": { + "room_background_url": "/static/img/cards/skyrail_omega.svg", + "hero_status": { + "key": "signal_integrity", + "label": "信号完整度", + "min": 0, + "max": 100, + "color": "#4d84ff", + }, + "status_tracks": [ + {"key": "signal_integrity", "label": "信号完整度", "min": 0, "max": 100, "color": "#4d84ff"}, + {"key": "authority", "label": "队伍控制权", "min": 0, "max": 100, "color": "#7affb3"}, + {"key": "suspicion", "label": "全列车怀疑度", "min": 0, "max": 100, "color": "#ff6a6a"}, + ], + }, + }, + "content_pack": { + "genre_tags": ["悬疑", "科幻", "心理博弈"], + "feature_badges": ["信任系统", "隐私事件", "阵营抉择"], + "world_lore": "天轨列车在云海上循环行驶,乘客档案会在每次广播后被篡改。", + "core_conflict": "玩家要查明列车终点的真实含义,并在互不信任的乘客间维持秩序。", + "player_roles": ["安保官", "情报员", "调度员", "医生"], + "interaction_modes": ["信息交换", "临时联盟", "公开审讯"], + "system_rules": [ + "怀疑度过高会触发乘客暴动", + "信号完整度决定可访问车厢范围", + "隐私情报可影响公开投票结果", + ], + "npc_roster": [ + "乘务长:强行维持秩序", + "失忆科学家:掌握终点数据", + "匿名旅客:不断制造误导", + ], + "scene_blueprint": ["前段车厢", "中段控制桥", "封闭实验舱", "终点前置区"], + "ending_catalog": ["重置列车并返航", "牺牲车厢换取生存", "接受终点协议"], + "hidden_truths": ["终点站并不存在实体站台", "广播系统受外部势力操纵"], + "systems": { + "trust": {"enabled": True, "metric": "suspicion"}, + "command": {"enabled": True, "metric": "authority"}, + }, + "end_conditions": [ + { + "id": "retake_control", + "metric": "authority", + "op": ">=", + "value": 88, + "flag": "escape_route_open", + "flag_equals": True, + "ending_state": "escaped", + "ending_summary": "你们重掌列车控制桥,终点协议被解除,列车返航。", + }, + { + "id": "riot_failure", + "metric": "suspicion", + "op": ">=", + "value": 95, + "ending_state": "trapped", + "ending_summary": "乘客暴动失控,列车陷入不可逆循环。", + }, + ], + }, + }, + ] + + count_row = conn.execute("SELECT COUNT(*) AS c FROM game_cards").fetchone() + is_empty = bool(count_row and int(count_row["c"]) == 0) + for card in cards: + if is_empty: + conn.execute( + """ + INSERT INTO game_cards( + card_id, title, subtitle, summary, cover_url, opening_text, + starter_goals, state_schema, content_pack, creator_user_id, is_official, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + card["card_id"], + card["title"], + card["subtitle"], + card["summary"], + card["cover_url"], + card["opening_text"], + json.dumps(card["starter_goals"], ensure_ascii=False), + json.dumps(card["state_schema"], ensure_ascii=False), + json.dumps(card.get("content_pack", {}), ensure_ascii=False), + None, + 1, + now, + now, + ), + ) + else: + row = conn.execute( + "SELECT card_id, state_schema, content_pack FROM game_cards WHERE card_id=?", + (card["card_id"],), + ).fetchone() + if row is None: + conn.execute( + """ + INSERT INTO game_cards( + card_id, title, subtitle, summary, cover_url, opening_text, + starter_goals, state_schema, content_pack, creator_user_id, is_official, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + card["card_id"], + card["title"], + card["subtitle"], + card["summary"], + card["cover_url"], + card["opening_text"], + json.dumps(card["starter_goals"], ensure_ascii=False), + json.dumps(card["state_schema"], ensure_ascii=False), + json.dumps(card.get("content_pack", {}), ensure_ascii=False), + None, + 1, + now, + now, + ), + ) + else: + conn.execute( + """ + UPDATE game_cards + SET + title=?, + subtitle=?, + summary=?, + cover_url=?, + opening_text=?, + starter_goals=?, + state_schema=?, + content_pack=?, + is_official=1, + updated_at=? + WHERE card_id=? + """, + ( + card["title"], + card["subtitle"], + card["summary"], + card["cover_url"], + card["opening_text"], + json.dumps(card["starter_goals"], ensure_ascii=False), + json.dumps(card["state_schema"], ensure_ascii=False), + json.dumps(card.get("content_pack", {}), ensure_ascii=False), + now, + card["card_id"], + ), + ) + + def create_room( + self, + room_id: str, + host_player_id: str, + game_state: dict[str, Any], + *, + game_card_id: str = DEFAULT_CARD_ID, + is_public: bool = False, + ) -> None: + now = utc_now_iso() + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO rooms( + room_id, host_player_id, is_public, status, chapter, scene_id, + game_state, game_card_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + room_id, + host_player_id, + 1 if is_public else 0, + game_state.get("game_phase", "lobby"), + int(game_state.get("chapter", 0)), + game_state.get("scene_id", "lobby"), + json.dumps(game_state, ensure_ascii=False), + game_card_id, + now, + now, + ), + ) + conn.commit() + + def add_or_update_player( + self, + room_id: str, + player_id: str, + name: str, + *, + is_host: bool = False, + is_ready: bool = False, + is_online: bool = True, + user_id: str | None = None, + ) -> None: + now = utc_now_iso() + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO players( + player_id, room_id, name, is_host, is_ready, is_online, user_id, joined_at, last_seen + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(player_id) DO UPDATE SET + name=excluded.name, + is_host=excluded.is_host, + is_ready=excluded.is_ready, + is_online=excluded.is_online, + user_id=COALESCE(excluded.user_id, players.user_id), + last_seen=excluded.last_seen + """, + ( + player_id, + room_id, + name, + 1 if is_host else 0, + 1 if is_ready else 0, + 1 if is_online else 0, + user_id, + now, + now, + ), + ) + conn.commit() + + def set_room_host(self, room_id: str, host_player_id: str) -> None: + with self._lock, self._connect() as conn: + conn.execute( + "UPDATE rooms SET host_player_id=?, updated_at=? WHERE room_id=?", + (host_player_id, utc_now_iso(), room_id), + ) + conn.execute( + "UPDATE players SET is_host=CASE WHEN player_id=? THEN 1 ELSE 0 END WHERE room_id=?", + (host_player_id, room_id), + ) + conn.commit() + + def get_room(self, room_id: str) -> dict[str, Any] | None: + with self._lock, self._connect() as conn: + row = conn.execute("SELECT * FROM rooms WHERE room_id=?", (room_id,)).fetchone() + if row is None: + return None + result = dict(row) + result["game_state"] = json.loads(result["game_state"]) + result["is_public"] = bool(result.get("is_public", 0)) + return result + + def update_room_public(self, room_id: str, is_public: bool) -> None: + with self._lock, self._connect() as conn: + conn.execute( + "UPDATE rooms SET is_public=?, updated_at=? WHERE room_id=?", + (1 if is_public else 0, utc_now_iso(), room_id), + ) + conn.commit() + + def list_players(self, room_id: str) -> list[dict[str, Any]]: + with self._lock, self._connect() as conn: + rows = conn.execute( + "SELECT * FROM players WHERE room_id=? ORDER BY joined_at ASC", (room_id,) + ).fetchall() + players = [] + for row in rows: + item = dict(row) + item["is_host"] = bool(item["is_host"]) + item["is_ready"] = bool(item["is_ready"]) + item["is_online"] = bool(item["is_online"]) + players.append(item) + return players + + def get_player(self, room_id: str, player_id: str) -> dict[str, Any] | None: + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT * FROM players WHERE room_id=? AND player_id=?", (room_id, player_id) + ).fetchone() + if row is None: + return None + item = dict(row) + item["is_host"] = bool(item["is_host"]) + item["is_ready"] = bool(item["is_ready"]) + item["is_online"] = bool(item["is_online"]) + return item + + def get_player_by_user(self, room_id: str, user_id: str) -> dict[str, Any] | None: + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT * FROM players WHERE room_id=? AND user_id=? ORDER BY joined_at ASC LIMIT 1", + (room_id, user_id), + ).fetchone() + if row is None: + return None + item = dict(row) + item["is_host"] = bool(item["is_host"]) + item["is_ready"] = bool(item["is_ready"]) + item["is_online"] = bool(item["is_online"]) + return item + + def update_player_ready(self, room_id: str, player_id: str, ready: bool) -> None: + with self._lock, self._connect() as conn: + conn.execute( + "UPDATE players SET is_ready=?, last_seen=? WHERE room_id=? AND player_id=?", + (1 if ready else 0, utc_now_iso(), room_id, player_id), + ) + conn.commit() + + def update_player_online(self, room_id: str, player_id: str, is_online: bool) -> None: + with self._lock, self._connect() as conn: + conn.execute( + "UPDATE players SET is_online=?, last_seen=? WHERE room_id=? AND player_id=?", + (1 if is_online else 0, utc_now_iso(), room_id, player_id), + ) + conn.commit() + + def remove_player(self, room_id: str, player_id: str) -> None: + with self._lock, self._connect() as conn: + conn.execute("DELETE FROM players WHERE room_id=? AND player_id=?", (room_id, player_id)) + conn.commit() + + def update_room_state(self, room_id: str, status: str, game_state: dict[str, Any]) -> None: + with self._lock, self._connect() as conn: + conn.execute( + """ + UPDATE rooms + SET status=?, chapter=?, scene_id=?, game_state=?, updated_at=? + WHERE room_id=? + """, + ( + status, + int(game_state.get("chapter", 0)), + game_state.get("scene_id", "lobby"), + json.dumps(game_state, ensure_ascii=False), + utc_now_iso(), + room_id, + ), + ) + conn.commit() + + def append_message( + self, + room_id: str, + kind: str, + content: str, + *, + player_id: str | None = None, + visibility: str = "public", + ) -> None: + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO messages(room_id, kind, player_id, visibility, content, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (room_id, kind, player_id, visibility, content, utc_now_iso()), + ) + conn.commit() + + def list_messages(self, room_id: str, limit: int = 200) -> list[dict[str, Any]]: + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT id, room_id, kind, player_id, visibility, content, created_at + FROM messages + WHERE room_id=? + ORDER BY id DESC + LIMIT ? + """, + (room_id, limit), + ).fetchall() + return [dict(row) for row in reversed(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( + """ + INSERT INTO actions(room_id, round_no, player_id, content, created_at) + VALUES (?, ?, ?, ?, ?) + """, + (room_id, round_no, player_id, content, utc_now_iso()), + ) + conn.commit() + + def delete_room(self, room_id: str) -> None: + with self._lock, self._connect() as conn: + conn.execute("DELETE FROM actions WHERE room_id=?", (room_id,)) + conn.execute("DELETE FROM messages WHERE room_id=?", (room_id,)) + conn.execute("DELETE FROM players WHERE room_id=?", (room_id,)) + conn.execute("DELETE FROM rooms WHERE room_id=?", (room_id,)) + conn.commit() + + def load_room_snapshot(self, room_id: str) -> dict[str, Any] | None: + room = self.get_room(room_id) + if room is None: + return None + players = self.list_players(room_id) + messages = self.list_messages(room_id) + return { + "room": room, + "players": players, + "messages": messages, + } + + def create_user(self, username: str, password_hash: str, display_name: str) -> dict[str, Any]: + user_id = uuid.uuid4().hex + now = utc_now_iso() + with self._lock, self._connect() as conn: + admin_row = conn.execute( + "SELECT COUNT(*) AS c FROM users WHERE role='admin'" + ).fetchone() + role = "admin" if int(admin_row["c"] if admin_row else 0) == 0 else "player" + try: + conn.execute( + """ + INSERT INTO users( + user_id, username, password_hash, display_name, role, avatar_color, avatar_url, theme, bio, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (user_id, username, password_hash, display_name, role, "#0070f2", "", "dopamine", "", now, now), + ) + conn.commit() + except sqlite3.IntegrityError as exc: + raise ValueError("username_taken") from exc + user = self.get_user_by_id(user_id) + if user is None: + raise ValueError("user_create_failed") + return user + + def get_user_by_username(self, username: str) -> dict[str, Any] | None: + with self._lock, self._connect() as conn: + row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone() + return dict(row) if row else None + + def get_user_by_id(self, user_id: str | None) -> dict[str, Any] | None: + if not user_id: + return None + with self._lock, self._connect() as conn: + row = conn.execute("SELECT * FROM users WHERE user_id=?", (user_id,)).fetchone() + return dict(row) if row else None + + def update_user_profile( + self, + user_id: str, + *, + display_name: str | None = None, + avatar_color: str | None = None, + avatar_url: str | None = None, + theme: str | None = None, + bio: str | None = None, + ) -> dict[str, Any]: + existing = self.get_user_by_id(user_id) + if existing is None: + raise ValueError("user_not_found") + + merged_name = display_name if display_name is not None else existing["display_name"] + merged_color = avatar_color if avatar_color is not None else existing["avatar_color"] + merged_avatar_url = avatar_url if avatar_url is not None else existing.get("avatar_url", "") + merged_theme = theme if theme is not None else existing.get("theme", "minimal") + merged_bio = bio if bio is not None else existing["bio"] + + with self._lock, self._connect() as conn: + conn.execute( + """ + UPDATE users + SET display_name=?, avatar_color=?, avatar_url=?, theme=?, bio=?, updated_at=? + WHERE user_id=? + """, + (merged_name, merged_color, merged_avatar_url, merged_theme, merged_bio, utc_now_iso(), user_id), + ) + conn.commit() + + updated = self.get_user_by_id(user_id) + if updated is None: + raise ValueError("user_not_found") + return updated + + def list_user_rooms(self, user_id: str, limit: int = 10) -> list[dict[str, Any]]: + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT r.room_id, r.status, r.scene_id, r.chapter, r.game_card_id, + r.is_public, p.player_id, p.name AS player_name, p.is_host, p.is_online, + r.updated_at + FROM players p + JOIN rooms r ON r.room_id = p.room_id + WHERE p.user_id=? + ORDER BY r.updated_at DESC + LIMIT ? + """, + (user_id, limit), + ).fetchall() + results = [] + for row in rows: + item = dict(row) + item["is_public"] = bool(item.get("is_public", 0)) + item["is_host"] = bool(item["is_host"]) + item["is_online"] = bool(item["is_online"]) + results.append(item) + return results + + def list_public_rooms(self, limit: int = 40) -> list[dict[str, Any]]: + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT + r.room_id, + r.status, + r.chapter, + r.scene_id, + r.game_card_id, + r.updated_at, + r.created_at, + hp.name AS host_name, + ( + SELECT COUNT(*) FROM players p WHERE p.room_id = r.room_id + ) AS player_count, + ( + SELECT COUNT(*) FROM players p WHERE p.room_id = r.room_id AND p.is_online = 1 + ) AS online_count + FROM rooms r + LEFT JOIN players hp ON hp.player_id = r.host_player_id + WHERE r.is_public = 1 AND r.status IN ('lobby', 'running') + ORDER BY CASE WHEN r.status='running' THEN 0 ELSE 1 END ASC, r.updated_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + def list_users(self, limit: int = 500) -> list[dict[str, Any]]: + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT user_id, username, display_name, role, avatar_color, avatar_url, bio, created_at, updated_at + , theme + FROM users + ORDER BY created_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + def update_user_role(self, user_id: str, role: str) -> dict[str, Any]: + role = str(role).strip() + if role not in {"player", "moderator", "admin"}: + raise ValueError("invalid_role") + existing = self.get_user_by_id(user_id) + if existing is None: + raise ValueError("user_not_found") + + with self._lock, self._connect() as conn: + if existing.get("role") == "admin" and role != "admin": + row = conn.execute("SELECT COUNT(*) AS c FROM users WHERE role='admin'").fetchone() + admin_count = int(row["c"]) if row else 0 + if admin_count <= 1: + raise ValueError("cannot_remove_last_admin") + + conn.execute( + "UPDATE users SET role=?, updated_at=? WHERE user_id=?", + (role, utc_now_iso(), user_id), + ) + conn.commit() + + updated = self.get_user_by_id(user_id) + if updated is None: + raise ValueError("user_not_found") + return updated + + def list_rooms_admin(self, limit: int = 300) -> list[dict[str, Any]]: + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT + r.room_id, + r.status, + r.chapter, + r.scene_id, + r.game_card_id, + r.host_player_id, + hp.name AS host_name, + r.created_at, + r.updated_at, + ( + SELECT COUNT(*) FROM players p WHERE p.room_id = r.room_id + ) AS player_count + FROM rooms r + LEFT JOIN players hp ON hp.player_id = r.host_player_id + ORDER BY r.updated_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + def _build_card_payload(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + item = dict(row) + try: + item["starter_goals"] = json.loads(item.get("starter_goals", "[]")) + except json.JSONDecodeError: + item["starter_goals"] = [] + try: + item["state_schema"] = json.loads(item.get("state_schema", "{}")) + except json.JSONDecodeError: + item["state_schema"] = {} + try: + item["content_pack"] = json.loads(item.get("content_pack", "{}")) + except json.JSONDecodeError: + item["content_pack"] = {} + item["is_official"] = bool(item.get("is_official", 0)) + return item + + def list_game_cards(self) -> list[dict[str, Any]]: + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT * FROM game_cards + ORDER BY is_official DESC, created_at DESC + """ + ).fetchall() + return [self._build_card_payload(row) for row in rows] + + def get_game_card(self, card_id: str) -> dict[str, Any] | None: + with self._lock, self._connect() as conn: + row = conn.execute("SELECT * FROM game_cards WHERE card_id=?", (card_id,)).fetchone() + if row is None: + return None + return self._build_card_payload(row) + + def get_default_card(self) -> dict[str, Any] | None: + return self.get_game_card(DEFAULT_CARD_ID) + + def create_game_card( + self, + *, + title: str, + subtitle: str, + summary: str, + cover_url: str, + opening_text: str, + starter_goals: list[str], + state_schema: dict[str, Any] | None, + content_pack: dict[str, Any] | None, + creator_user_id: str, + ) -> dict[str, Any]: + base_slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "card" + card_id = base_slug[:32] + suffix = 2 + while self.get_game_card(card_id) is not None: + card_id = f"{base_slug[:28]}-{suffix}" + suffix += 1 + + now = utc_now_iso() + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO game_cards( + card_id, title, subtitle, summary, cover_url, opening_text, + starter_goals, state_schema, content_pack, creator_user_id, is_official, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + card_id, + title, + subtitle, + summary, + cover_url, + opening_text, + json.dumps(starter_goals, ensure_ascii=False), + json.dumps(state_schema or {}, ensure_ascii=False), + json.dumps(content_pack or {}, ensure_ascii=False), + creator_user_id, + 0, + now, + now, + ), + ) + conn.commit() + + card = self.get_game_card(card_id) + if card is None: + raise ValueError("card_create_failed") + return card + + def update_game_card( + self, + card_id: str, + *, + title: str, + subtitle: str, + summary: str, + cover_url: str, + opening_text: str, + starter_goals: list[str], + state_schema: dict[str, Any] | None, + content_pack: dict[str, Any] | None, + ) -> dict[str, Any]: + existing = self.get_game_card(card_id) + if existing is None: + raise ValueError("invalid_card_id") + + with self._lock, self._connect() as conn: + conn.execute( + """ + UPDATE game_cards + SET + title=?, + subtitle=?, + summary=?, + cover_url=?, + opening_text=?, + starter_goals=?, + state_schema=?, + content_pack=?, + updated_at=? + WHERE card_id=? + """, + ( + title, + subtitle, + summary, + cover_url, + opening_text, + json.dumps(starter_goals, ensure_ascii=False), + json.dumps(state_schema or {}, ensure_ascii=False), + json.dumps(content_pack or {}, ensure_ascii=False), + utc_now_iso(), + card_id, + ), + ) + conn.commit() + + card = self.get_game_card(card_id) + if card is None: + raise ValueError("invalid_card_id") + return card + + def delete_game_card(self, card_id: str, user_id: str, *, as_admin: bool = False) -> None: + card = self.get_game_card(card_id) + if card is None: + raise ValueError("invalid_card_id") + if not as_admin: + if card.get("is_official"): + raise ValueError("card_delete_forbidden") + if str(card.get("creator_user_id") or "") != str(user_id): + raise ValueError("card_delete_forbidden") + + with self._lock, self._connect() as conn: + active_row = conn.execute( + """ + SELECT COUNT(*) AS c + FROM rooms + WHERE game_card_id=? AND status IN ('lobby', 'running') + """, + (card_id,), + ).fetchone() + active_count = int(active_row["c"]) if active_row else 0 + if active_count > 0: + raise ValueError("card_in_use") + + conn.execute("DELETE FROM game_cards WHERE card_id=?", (card_id,)) + conn.commit() + + def set_game_card_official(self, card_id: str, is_official: bool) -> dict[str, Any]: + with self._lock, self._connect() as conn: + row = conn.execute("SELECT card_id FROM game_cards WHERE card_id=?", (card_id,)).fetchone() + if row is None: + raise ValueError("invalid_card_id") + conn.execute( + "UPDATE game_cards SET is_official=?, updated_at=? WHERE card_id=?", + (1 if is_official else 0, utc_now_iso(), card_id), + ) + conn.commit() + card = self.get_game_card(card_id) + if card is None: + raise ValueError("invalid_card_id") + return card diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..c50aa5e --- /dev/null +++ b/settings.json @@ -0,0 +1,16 @@ +{ + "secret_key": "echo-tavern-dev-secret", + "db_path": "echo_tavern.db", + "deepseek_api_key": "", + "deepseek_base_url": "https://api.deepseek.com", + "deepseek_model": "deepseek-chat", + "deepseek_timeout": 25, + "deepseek_connect_timeout": 8, + "deepseek_read_timeout": 90, + "deepseek_retries": 2, + "max_room_players": 8, + "max_action_length": 280, + "app_host": "0.0.0.0", + "app_port": 8095, + "debug": true +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..f048a6c --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,2079 @@ +:root { + --bg: #eef2f8; + --surface: #ffffff; + --surface-soft: #f5f8ff; + --text: #1a2236; + --muted: #5e6b86; + --border: #d7dff1; + + --n-red: #ff3157; + --n-blue: #2563ff; + --n-yellow: #ffbf1f; + --n-green: #00b66a; + --btn-primary-end: #ff7b2f; + --btn-secondary-start: #56a4ff; + --btn-secondary-end: #336bff; + --link-btn-start: #2b82ff; + --link-btn-end: #3657ff; + --ghost-fg: #2047aa; + --ghost-bg: rgba(255, 255, 255, 0.92); + + --shadow: 0 14px 34px rgba(16, 30, 56, 0.12); +} + +body[data-theme="minimal"] { + --bg: #eef2f8; + --surface: #ffffff; + --surface-soft: #f5f8ff; + --text: #1a2236; + --muted: #5e6b86; + --border: #d7dff1; + --n-red: #ff3157; + --n-blue: #2563ff; + --n-yellow: #ffbf1f; + --n-green: #00b66a; + --btn-primary-end: #ff7b2f; + --btn-secondary-start: #56a4ff; + --btn-secondary-end: #336bff; + --link-btn-start: #2b82ff; + --link-btn-end: #3657ff; + --ghost-fg: #2047aa; + --ghost-bg: rgba(255, 255, 255, 0.92); +} + +body[data-theme="dopamine"] { + --bg: #fff5e8; + --surface: #fffefa; + --surface-soft: #fff5f0; + --text: #2a1730; + --muted: #6a4e73; + --border: #ffd4b2; + --n-red: #ff4d6d; + --n-blue: #4f6dff; + --n-yellow: #ffb627; + --n-green: #00bf8f; + --btn-primary-end: #ff7a2f; + --btn-secondary-start: #6e9dff; + --btn-secondary-end: #4f6dff; + --link-btn-start: #5d86ff; + --link-btn-end: #4f6dff; + --ghost-fg: #3d49c9; + --ghost-bg: rgba(255, 252, 246, 0.92); +} + +body[data-theme="midnight"] { + --bg: #0f1328; + --surface: #161c36; + --surface-soft: #1d2547; + --text: #ebf0ff; + --muted: #aab6dc; + --border: #2b3b75; + --n-red: #ff5d7e; + --n-blue: #4d84ff; + --n-yellow: #ffd166; + --n-green: #3ee4a6; + --btn-primary-end: #ff7f50; + --btn-secondary-start: #67a4ff; + --btn-secondary-end: #4d84ff; + --link-btn-start: #5e98ff; + --link-btn-end: #4d84ff; + --ghost-fg: #b8ccff; + --ghost-bg: rgba(19, 28, 54, 0.88); + --shadow: 0 16px 42px rgba(3, 8, 24, 0.45); +} + +body[data-theme="mint"] { + --bg: #eef8f3; + --surface: #ffffff; + --surface-soft: #f1fcf6; + --text: #1b2d2a; + --muted: #58716e; + --border: #c6e6dd; + --n-red: #ff6f61; + --n-blue: #2f7cff; + --n-yellow: #ffcb57; + --n-green: #18b77e; + --btn-primary-end: #ff9552; + --btn-secondary-start: #67b0ff; + --btn-secondary-end: #2f7cff; + --link-btn-start: #4f96ff; + --link-btn-end: #2f7cff; + --ghost-fg: #1f5f6a; + --ghost-bg: rgba(248, 255, 251, 0.94); +} + +body[data-theme="sunset"] { + --bg: #fff1ea; + --surface: #fffaf5; + --surface-soft: #fff3ea; + --text: #311f24; + --muted: #7a5760; + --border: #f0c4ba; + --n-red: #ff5a5f; + --n-blue: #4d7dff; + --n-yellow: #ffba49; + --n-green: #1fb785; + --btn-primary-end: #ff9445; + --btn-secondary-start: #6da8ff; + --btn-secondary-end: #4d7dff; + --link-btn-start: #5a93ff; + --link-btn-end: #4d7dff; + --ghost-fg: #6a3f50; + --ghost-bg: rgba(255, 248, 241, 0.93); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--text); + font-family: "SF Pro Display", "Avenir Next", "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif; + background: + radial-gradient(circle at 5% -5%, rgba(37, 99, 255, 0.11) 0, transparent 30%), + radial-gradient(circle at 95% 0%, rgba(255, 49, 87, 0.08) 0, transparent 26%), + radial-gradient(circle at 50% 120%, rgba(255, 191, 31, 0.08) 0, transparent 34%), + var(--bg); +} + +body[data-theme="dopamine"] { + background: + radial-gradient(circle at 10% -10%, rgba(255, 77, 109, 0.23) 0, transparent 34%), + radial-gradient(circle at 92% 4%, rgba(79, 109, 255, 0.23) 0, transparent 30%), + radial-gradient(circle at 50% 115%, rgba(255, 182, 39, 0.22) 0, transparent 38%), + var(--bg); +} + +body[data-theme="midnight"] { + background: + radial-gradient(circle at 8% -8%, rgba(77, 132, 255, 0.2) 0, transparent 30%), + radial-gradient(circle at 86% 0%, rgba(255, 93, 126, 0.2) 0, transparent 28%), + radial-gradient(circle at 50% 120%, rgba(62, 228, 166, 0.18) 0, transparent 34%), + linear-gradient(180deg, #0b1126, #121a35 55%, #0b1126); +} + +body[data-theme="mint"] { + background: + radial-gradient(circle at 9% -8%, rgba(24, 183, 126, 0.15) 0, transparent 34%), + radial-gradient(circle at 93% 5%, rgba(47, 124, 255, 0.14) 0, transparent 32%), + radial-gradient(circle at 50% 120%, rgba(255, 203, 87, 0.13) 0, transparent 34%), + var(--bg); +} + +body[data-theme="sunset"] { + background: + radial-gradient(circle at 12% -8%, rgba(255, 90, 95, 0.2) 0, transparent 34%), + radial-gradient(circle at 90% 4%, rgba(77, 125, 255, 0.16) 0, transparent 30%), + radial-gradient(circle at 50% 120%, rgba(255, 186, 73, 0.18) 0, transparent 36%), + var(--bg); +} + +h1, +h2, +h3 { + margin: 0 0 0.55rem; + line-height: 1.25; + letter-spacing: 0.2px; +} + +p { + margin: 0; +} + +input, +textarea, +select, +button { + width: 100%; + border-radius: 12px; + border: 2px solid var(--border); + background: #fff; + color: var(--text); + padding: 0.68rem 0.8rem; + font: inherit; +} + +textarea { + min-height: 92px; + resize: vertical; +} + +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--n-blue); + box-shadow: 0 0 0 3px rgba(0, 112, 242, 0.15); +} + +button { + border: none; + background: linear-gradient(135deg, var(--n-red) 0%, var(--btn-primary-end) 100%); + color: #fff; + font-weight: 800; + letter-spacing: 0.2px; + cursor: pointer; + box-shadow: 0 10px 18px rgba(255, 94, 77, 0.25); + transition: transform 120ms ease; +} + +button:hover { + transform: translateY(-1px); +} + +button:active { + transform: translateY(0); +} + +button:disabled { + background: #c0cbdf; + box-shadow: none; + cursor: not-allowed; + transform: none; +} + +.secondary-btn { + background: linear-gradient(135deg, var(--btn-secondary-start) 0%, var(--btn-secondary-end) 100%); + box-shadow: 0 8px 16px rgba(34, 88, 255, 0.24); +} + +.ghost-btn { + background: var(--ghost-bg); + color: var(--ghost-fg); + border: 2px solid rgba(143, 175, 244, 0.72); + box-shadow: none; +} + +.helper-text { + margin-top: 0.55rem; + color: var(--muted); + font-size: 0.88rem; +} + +.link-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem 0.95rem; + border-radius: 999px; + text-decoration: none; + color: #fff; + background: linear-gradient(135deg, var(--link-btn-start) 0%, var(--link-btn-end) 100%); + font-weight: 700; + box-shadow: 0 8px 16px rgba(45, 95, 255, 0.26); + white-space: nowrap; + width: auto; +} + +.link-btn.disabled { + opacity: 0.5; + pointer-events: none; +} + +.hidden { + display: none !important; +} + +.user-badge { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-weight: 700; + color: var(--text); + padding: 0.35rem 0.58rem; + border-radius: 999px; + background: #fff; + border: 2px solid var(--border); +} + +.user-badge i { + width: 12px; + height: 12px; + border-radius: 999px; + background: var(--avatar-color, var(--n-blue)); + background-size: cover; + background-position: center; +} + +.user-badge.has-avatar i { + width: 22px; + height: 22px; + border: 1px solid rgba(141, 173, 238, 0.58); + background-image: var(--avatar-url, none); + background-color: transparent; +} + +.status-banner { + display: none; + max-width: 1180px; + margin: 0.5rem auto 1.2rem; + padding: 0.8rem 1rem; + color: #f1f5ff; + border-radius: 12px; + background: linear-gradient(135deg, rgba(35, 74, 149, 0.78), rgba(28, 120, 167, 0.76)); + border: 1px solid rgba(182, 214, 255, 0.5); + backdrop-filter: blur(6px); +} + +.status-banner:not(:empty) { + display: block; +} + +.lobby-topbar, +.room-topbar { + max-width: 1180px; + margin: 0 auto; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.brand-block h1, +.room-title h1 { + font-size: 2.15rem; + font-weight: 900; + letter-spacing: 0.3px; +} + +.brand-block p, +.room-title p, +.header-note { + color: var(--muted); + margin-top: 0.25rem; +} + +.top-actions, +.header-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.top-actions { + justify-content: flex-end; +} + +.top-actions > *, +.header-actions > * { + width: auto; +} + +.header-actions button { + min-width: 112px; + padding: 0.62rem 0.95rem; +} + +.lobby-grid { + max-width: 1180px; + margin: 0 auto; + padding: 0 1rem 1rem; + display: grid; + grid-template-columns: 1.2fr 1.2fr 1fr 1fr; + gap: 0.9rem; +} + +.card-select-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.45rem; + margin: 0.55rem 0 0.6rem; + align-items: start; +} + +.card-select-item { + width: 100%; + padding: 0.32rem; + border-radius: 12px; + border: 1px solid rgba(139, 173, 236, 0.6); + background: rgba(240, 247, 255, 0.88); + box-shadow: none; + display: grid; + gap: 0.24rem; + text-align: left; + color: #1f2f52; + overflow: hidden; + min-width: 0; +} + +.card-select-item .thumb { + display: block; + width: 100%; + max-width: 100%; + aspect-ratio: 16 / 9; + border-radius: 8px; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + border: 1px solid rgba(164, 190, 239, 0.52); + overflow: hidden; + min-width: 0; +} + +.card-select-item .title { + font-size: 0.78rem; + font-weight: 800; + line-height: 1.2; +} + +.card-select-item.active { + border-color: rgba(57, 111, 224, 0.96); + box-shadow: 0 8px 16px rgba(58, 109, 218, 0.2); + transform: translateY(-1px); +} + +.selected-card-detail { + border-radius: 14px; + border: 1px solid rgba(139, 173, 236, 0.6); + background: rgba(246, 250, 255, 0.92); + display: grid; + grid-template-columns: 1fr; + gap: 0.45rem; + padding: 0.52rem; + margin-bottom: 0.62rem; +} + +.selected-card-cover { + border-radius: 10px; + min-height: 112px; + width: 100%; + max-width: 100%; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + border: 1px solid rgba(164, 190, 239, 0.55); + overflow: hidden; +} + +.selected-card-meta h3 { + font-size: 1rem; + margin-bottom: 0.22rem; +} + +.selected-card-meta p { + margin-bottom: 0.34rem; + font-size: 0.87rem; + color: #324567; +} + +.tag-row { + display: flex; + flex-wrap: wrap; + gap: 0.28rem; +} + +.tag-row span { + font-size: 0.74rem; + padding: 0.14rem 0.42rem; + border-radius: 999px; + background: rgba(57, 111, 224, 0.12); + color: #2852a0; + border: 1px solid rgba(104, 147, 233, 0.4); +} + +.tag-row.feature span { + background: rgba(255, 132, 74, 0.15); + color: #8a411f; + border-color: rgba(255, 143, 90, 0.45); +} + +.plaza-grid { + max-width: 1180px; + margin: 0 auto; + padding: 0 1rem 1rem; + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1rem; +} + +.card, +.panel { + background: rgba(255, 255, 255, 0.86); + border: 1px solid rgba(160, 183, 235, 0.54); + border-radius: 20px; + padding: 1rem; + box-shadow: var(--shadow); + backdrop-filter: blur(8px); +} + +.card form button, +.composer-card form button, +.plaza-create form button { + width: 100%; +} + +.color-row { + display: flex; + gap: 0.55rem; + align-items: center; +} + +input[type="color"] { + width: 58px; + min-width: 58px; + height: 42px; + padding: 0; + border-radius: 10px; + overflow: hidden; +} + +input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; +} + +input[type="color"]::-webkit-color-swatch { + border: none; +} + +.color-preview { + width: 28px; + height: 28px; + border-radius: 999px; + border: 2px solid var(--border); + background: var(--n-blue); +} + +.card h2, +.panel h2 { + font-size: 1.5rem; + font-weight: 900; +} + +.compact-list, +ul { + list-style: none; + margin: 0; + padding: 0; +} + +.compact-list li, +ul li { + background: rgba(248, 251, 255, 0.76); + border: 1px solid rgba(174, 194, 238, 0.65); + border-radius: 10px; + margin-bottom: 0.45rem; + padding: 0.4rem 0.55rem; + font-size: 0.9rem; +} + +body.lobby-page { + background: + linear-gradient(120deg, rgba(10, 20, 38, 0.28), rgba(6, 48, 74, 0.2)), + radial-gradient(circle at 18% 0%, rgba(255, 69, 106, 0.22), transparent 34%), + radial-gradient(circle at 85% 4%, rgba(37, 99, 255, 0.24), transparent 32%), + radial-gradient(circle at 50% 110%, rgba(255, 191, 31, 0.22), transparent 36%), + #eef2f8; +} + +body.lobby-page .brand-block h1 { + font-size: 2.45rem; +} + +body.lobby-page .brand-block p { + color: #415070; + font-weight: 600; +} + +body.lobby-page .card { + background: linear-gradient(155deg, rgba(255, 255, 255, 0.9), rgba(244, 248, 255, 0.85)); + border-color: rgba(147, 178, 239, 0.52); +} + +body[data-theme="midnight"] .card, +body[data-theme="midnight"] .panel, +body[data-theme="midnight"] .meta-block, +body[data-theme="midnight"] .invite-panel, +body[data-theme="midnight"] .auth-card { + background: linear-gradient(155deg, rgba(21, 30, 58, 0.92), rgba(15, 22, 45, 0.9)); + border-color: rgba(82, 116, 208, 0.52); +} + +body[data-theme="midnight"] input, +body[data-theme="midnight"] textarea, +body[data-theme="midnight"] select { + background: rgba(17, 25, 49, 0.88); + color: #ebf0ff; + border-color: rgba(86, 119, 207, 0.65); +} + +body[data-theme="midnight"] ul li, +body[data-theme="midnight"] .compact-list li, +body[data-theme="midnight"] .msg { + background: rgba(22, 33, 66, 0.72); + border-color: rgba(94, 127, 218, 0.5); + color: #ebf0ff; +} + +body[data-theme="midnight"] .helper-text, +body[data-theme="midnight"] .brand-block p, +body[data-theme="midnight"] .room-title p, +body[data-theme="midnight"] .header-note { + color: #b9c8f2; +} + +.compact-list a { + text-decoration: none; + color: var(--n-blue); + font-weight: 700; +} + +.cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.85rem; +} + +.game-card { + overflow: hidden; + border-radius: 16px; + border: 2px solid var(--border); + background: #fff; + min-width: 0; +} + +.game-card-cover { + height: 160px; + width: 100%; + max-width: 100%; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + overflow: hidden; +} + +.game-card-body { + padding: 0.8rem; +} + +.game-card-body > button { + margin-top: 0.45rem; +} + +.card-actions-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.55rem; +} + +.card-actions-row > * { + width: auto; +} + +.game-card-sub { + color: var(--muted); + font-size: 0.88rem; +} + +.card-pack-detail { + margin-top: 0.45rem; + border-radius: 10px; + border: 1px dashed rgba(127, 160, 228, 0.52); + background: rgba(246, 250, 255, 0.75); + padding: 0.35rem 0.5rem; +} + +.card-pack-detail summary { + cursor: pointer; + font-size: 0.82rem; + font-weight: 700; + color: #2f4a86; +} + +.card-pack-detail p { + margin: 0.36rem 0 0; + font-size: 0.8rem; + color: #304768; + line-height: 1.4; +} + +.card-guide, +.assist-panel { + margin-bottom: 0.9rem; + padding: 0.75rem; + border-radius: 14px; + border: 1px solid rgba(157, 186, 241, 0.5); + background: rgba(245, 250, 255, 0.8); +} + +.card-guide h3, +.assist-panel h3 { + margin: 0 0 0.45rem; + font-size: 1.04rem; +} + +.guide-list { + margin: 0; + padding-left: 1.15rem; + display: grid; + gap: 0.24rem; + color: #2c3b5c; + font-size: 0.88rem; + line-height: 1.4; +} + +.assist-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.55rem; +} + +.assist-panel textarea { + min-height: 84px; +} + +.advanced-pack { + margin: 0.7rem 0; + padding: 0.58rem; + border-radius: 12px; + border: 1px solid rgba(157, 186, 241, 0.48); + background: rgba(242, 248, 255, 0.85); +} + +.advanced-pack summary { + cursor: pointer; + font-weight: 800; + color: #2a4587; + margin-bottom: 0.48rem; +} + +.advanced-pack[open] summary { + margin-bottom: 0.62rem; +} + +body.card-editor-page { + background: + radial-gradient(circle at 10% 0%, rgba(255, 104, 72, 0.22), transparent 30%), + radial-gradient(circle at 90% 0%, rgba(45, 105, 255, 0.25), transparent 34%), + linear-gradient(180deg, #eef3ff 0%, #e8eefb 100%); +} + +.card-editor-shell { + max-width: 1240px; + margin: 0 auto; + padding: 0 1rem 1rem; + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(300px, 1fr); + gap: 0.95rem; +} + +.editor-form-card { + padding: 1rem; +} + +.editor-form-grid { + display: grid; + gap: 0.42rem; +} + +.editor-form-grid h2 { + margin-top: 0.7rem; + margin-bottom: 0.15rem; + font-size: 1.15rem; + color: #233d78; +} + +.editor-form-grid .code-input { + min-height: 150px; + font-family: "SFMono-Regular", "Menlo", "Consolas", monospace; + font-size: 0.84rem; + line-height: 1.45; +} + +.json-help { + margin: 0.08rem 0 0.46rem; + font-size: 0.84rem; + color: #3a4e73; + line-height: 1.45; +} + +.json-help code { + background: rgba(38, 90, 208, 0.1); + padding: 0.08rem 0.28rem; + border-radius: 6px; + color: #21418f; +} + +.editor-guide-card { + align-self: start; + position: sticky; + top: 0.9rem; + max-height: calc(100vh - 1.8rem); + overflow-y: auto; +} + +.assist-loading { + margin-top: 0.55rem; + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.42rem 0.58rem; + border-radius: 999px; + border: 1px solid rgba(90, 131, 220, 0.35); + background: rgba(52, 104, 214, 0.1); + color: #2a4b94; + font-size: 0.86rem; + font-weight: 700; +} + +.assist-loading[hidden] { + display: none !important; +} + +.assist-spinner { + width: 13px; + height: 13px; + border-radius: 50%; + border: 2px solid rgba(58, 96, 178, 0.3); + border-top-color: #3b63c8; + animation: assist-spin 900ms linear infinite; +} + +.assist-dots { + display: inline-flex; + gap: 3px; + align-items: center; +} + +.assist-dots i { + width: 5px; + height: 5px; + border-radius: 50%; + background: #3b63c8; + display: block; + animation: assist-dot-bounce 1s infinite ease-in-out; +} + +.assist-dots i:nth-child(2) { + animation-delay: 120ms; +} + +.assist-dots i:nth-child(3) { + animation-delay: 240ms; +} + +@keyframes assist-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes assist-dot-bounce { + 0%, + 80%, + 100% { + transform: scale(0.8); + opacity: 0.45; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +.auth-page { + display: grid; + place-items: center; +} + +.auth-card { + width: min(420px, calc(100% - 2rem)); + margin: 2rem auto; + background: #fff; + border: 2px solid var(--border); + border-radius: 18px; + padding: 1.1rem; + box-shadow: var(--shadow); +} + +.auth-links { + margin-top: 0.65rem; + color: var(--muted); + font-size: 0.92rem; +} + +.auth-links a { + color: var(--n-blue); +} + +body.admin-page { + background: + radial-gradient(circle at 8% 0%, rgba(37, 99, 255, 0.16), transparent 28%), + radial-gradient(circle at 92% 0%, rgba(255, 76, 97, 0.16), transparent 26%), + linear-gradient(180deg, #ecf2ff 0%, #e7eefb 100%); +} + +.admin-grid { + max-width: 1240px; + margin: 0 auto; + padding: 0 1rem 1rem; + display: grid; + grid-template-columns: 1fr; + gap: 0.9rem; +} + +.admin-panel h2 { + margin-bottom: 0.55rem; +} + +.table-wrap { + width: 100%; + overflow-x: auto; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + min-width: 880px; +} + +.admin-table th, +.admin-table td { + text-align: left; + padding: 0.5rem 0.55rem; + border-bottom: 1px solid rgba(171, 189, 228, 0.42); + vertical-align: top; +} + +.admin-table th { + font-size: 0.85rem; + color: #425176; +} + +.admin-table td { + font-size: 0.88rem; +} + +.admin-table td button { + width: auto; +} + +.admin-table td select { + min-width: 110px; +} + +body.room-page { + height: 100vh; + overflow: hidden; + position: relative; + font-size: 13px; + background: + radial-gradient(circle at 12% 8%, rgba(255, 73, 0, 0.3) 0, transparent 30%), + radial-gradient(circle at 86% 10%, rgba(0, 131, 255, 0.35) 0, transparent 30%), + radial-gradient(circle at 50% 100%, rgba(255, 213, 0, 0.25) 0, transparent 35%), + linear-gradient(135deg, rgba(14, 25, 54, 0.9), rgba(9, 34, 57, 0.92)), + var(--room-bg-image); + background-size: cover; + background-position: center; +} + +body.room-page::before { + content: ""; + position: fixed; + inset: 0; + background: linear-gradient(180deg, rgba(7, 14, 28, 0.45), rgba(9, 12, 22, 0.6)); + pointer-events: none; + z-index: 0; +} + +.room-topbar-immersive, +.room-shell { + position: relative; + z-index: 1; +} + +.room-topbar-immersive { + max-width: none; + width: 100%; + padding: 0.44rem 0.72rem; +} + +.room-topbar-immersive .room-title h1, +.room-topbar-immersive .room-title p { + color: #f4f8ff; +} + +.room-topbar-immersive .room-title p { + opacity: 0.95; +} + +.room-topbar-immersive .header-actions { + align-self: center; +} + +.room-topbar-immersive .back-home-link { + background: linear-gradient(135deg, #2c8cff 0%, #0055ff 100%); +} + +.room-topbar-immersive .ghost-btn { + background: rgba(255, 255, 255, 0.9); + border: 2px solid rgba(255, 255, 255, 0.35); +} + +.room-shell { + width: 100%; + height: calc(100vh - 68px); + padding: 0 0.62rem 0.58rem; + display: grid; + grid-template-columns: minmax(0, 1.78fr) minmax(520px, 1.1fr); + gap: 0.5rem; +} + +.story-stage, +.command-stage { + min-height: 0; + border: 2px solid rgba(179, 206, 255, 0.45); + background: linear-gradient(165deg, rgba(18, 30, 56, 0.72), rgba(12, 44, 68, 0.62)); + backdrop-filter: blur(8px); + padding: 0.58rem; +} + +.story-stage { + display: grid; + grid-template-rows: minmax(170px, 26vh) minmax(0, 1fr); + gap: 0.55rem; +} + +.scene-hero { + position: relative; + border-radius: 16px; + overflow: hidden; + border: 2px solid rgba(157, 198, 255, 0.7); + min-height: 184px; +} + +.immersive-hero::before { + content: ""; + position: absolute; + inset: 0; + background-image: var(--scene-bg-image); + background-size: cover; + background-position: center; + filter: blur(1px) saturate(1.4); + transform: scale(1.05); + z-index: 0; +} + +.immersive-hero .scene-overlay { + position: relative; + z-index: 1; +} + +.scene-overlay { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 0.8rem; + background: linear-gradient( + 180deg, + rgba(11, 18, 34, 0.78) 0%, + rgba(11, 18, 34, 0.58) 36%, + rgba(11, 18, 34, 0.22) 60%, + rgba(20, 27, 40, 0.88) 100% + ); + color: #fff; +} + +.scene-overlay h2 { + font-size: 1.52rem; + color: #f7fbff; + text-shadow: 0 2px 10px rgba(6, 11, 24, 0.58); +} + +.scene-overlay p { + display: inline-block; + color: #f6fbff; + margin-bottom: 0.45rem; + padding: 0.22rem 0.48rem; + border-radius: 8px; + background: rgba(8, 15, 30, 0.46); + border: 1px solid rgba(178, 205, 255, 0.22); + text-shadow: 0 2px 8px rgba(6, 11, 24, 0.5); +} + +.hero-status-track { + display: grid; + gap: 0.25rem; + font-size: 0.86rem; +} + +.hero-status-list { + display: grid; + gap: 0.38rem; + margin-top: 0.12rem; + padding-top: 0.22rem; + border-top: 1px solid rgba(175, 202, 255, 0.28); +} + +.hero-status-head { + display: flex; + justify-content: space-between; + align-items: baseline; + font-weight: 800; +} + +.hero-status-bar-bg { + width: 100%; + height: 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.28); + overflow: hidden; +} + +.hero-status-bar { + height: 100%; + width: 0; + border-radius: 999px; + background: var(--hero-status-color, #ffb500); + box-shadow: 0 0 18px color-mix(in srgb, var(--hero-status-color, #ffb500) 64%, transparent); +} + +.story-major-block { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 0.45rem; +} + +.story-major-block h3 { + font-size: 1.22rem; + font-weight: 900; + color: #f5f8ff; +} + +.story-major { + height: 100%; + min-height: 0; + max-height: none; + font-size: 1.04rem; + line-height: 1.56; +} + +.command-stage { + min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 0.5rem; +} + +.quick-panel h2, +.chat-panel h3, +.meta-panel h3 { + margin-bottom: 0.42rem; + color: #f5f8ff; +} + +.quick-grid { + display: flex; + flex-wrap: wrap; + gap: 0.48rem; + margin-bottom: 0.45rem; +} + +.choice-chip { + width: auto !important; + flex: 0 0 auto; + padding: 0.52rem 0.78rem; + border-radius: 12px; + background: linear-gradient(135deg, #ffd65f, #ff8f00); + color: #1a1f2e; + border: 2px solid rgba(255, 255, 255, 0.35); + box-shadow: 0 8px 16px rgba(255, 143, 0, 0.25); + font-weight: 700; +} + +.choice-chip:hover { + transform: translateY(-1px); +} + +.quick-hint { + color: #dce8ff; + font-size: 0.9rem; +} + +.chat-panel form { + display: grid; + gap: 0.45rem; +} + +.chat-panel input { + margin-bottom: 0.2rem; +} + +.meta-panel { + min-height: 0; + overflow-y: auto; + padding-right: 0.1rem; +} + +.meta-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.42rem; +} + +.meta-block { + border: 2px solid rgba(171, 200, 255, 0.33); + border-radius: 12px; + background: rgba(12, 24, 48, 0.4); + padding: 0.38rem; +} + +.meta-block h3 { + margin-bottom: 0.35rem; +} + +.meta-block-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + margin-bottom: 0.35rem; +} + +.meta-block-head h3 { + margin-bottom: 0; +} + +.list-collapsible { + transition: max-height 0.2s ease; +} + +.list-collapsible.is-collapsed { + max-height: 220px; + overflow: hidden; +} + +.log { + border: 2px solid rgba(182, 207, 255, 0.48); + border-radius: 14px; + background: rgba(7, 16, 36, 0.43); + padding: 0.62rem; + min-height: 180px; + max-height: 280px; + overflow-y: auto; +} + +.log.small { + min-height: 92px; + max-height: 140px; +} + +.msg { + padding: 0.45rem 0.56rem; + margin-bottom: 0.44rem; + border-radius: 10px; + line-height: 1.45; + font-size: 0.92rem; + color: #f4f7ff; + background: rgba(26, 43, 78, 0.62); + border: 2px solid rgba(170, 199, 255, 0.38); +} + +.msg.system { + border-color: rgba(128, 191, 255, 0.55); + background: rgba(47, 101, 165, 0.45); +} + +.msg.ai { + border-color: rgba(121, 236, 180, 0.5); + background: rgba(24, 118, 92, 0.48); +} + +.msg.npc { + border-color: rgba(255, 224, 143, 0.56); + background: rgba(130, 90, 16, 0.46); +} + +.msg.action { + border-color: rgba(255, 184, 205, 0.56); + background: rgba(134, 44, 72, 0.45); +} + +.msg.chat { + border-color: rgba(193, 203, 231, 0.46); + background: rgba(38, 57, 96, 0.5); +} + +.msg.private { + border-color: rgba(255, 199, 126, 0.58); + background: rgba(131, 84, 24, 0.47); +} + +.status-grid { + display: grid; + gap: 0.28rem; + padding: 0.45rem; + border: 2px solid rgba(171, 200, 255, 0.4); + border-radius: 12px; + background: rgba(15, 29, 58, 0.5); + color: #f3f7ff; + margin-bottom: 0; +} + +body.room-page .meta-panel li { + color: #f0f6ff; + background: rgba(14, 31, 58, 0.52); + border-color: rgba(174, 205, 255, 0.36); +} + +.ending-overlay { + position: fixed; + inset: 0; + background: rgba(17, 26, 42, 0.78); + z-index: 1000; + display: grid; + place-items: center; + padding: 1rem; +} + +.ending-overlay.hidden { + display: none; +} + +.invite-modal { + position: fixed; + inset: 0; + z-index: 1200; + background: rgba(7, 14, 30, 0.62); + backdrop-filter: blur(8px); + padding: 1rem; + display: grid; + place-items: center; +} + +.invite-panel { + width: min(920px, 100%); + border-radius: 22px; + border: 2px solid rgba(165, 196, 255, 0.6); + background: + radial-gradient(circle at 12% 0%, rgba(255, 89, 116, 0.17), transparent 35%), + radial-gradient(circle at 88% 10%, rgba(32, 109, 255, 0.19), transparent 35%), + linear-gradient(160deg, rgba(23, 38, 71, 0.92), rgba(12, 26, 56, 0.9)); + box-shadow: 0 24px 52px rgba(6, 14, 31, 0.5); + color: #f3f7ff; + padding: 1rem; + display: grid; + gap: 0.7rem; +} + +.invite-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.invite-header h2 { + margin: 0; +} + +.invite-tip { + color: rgba(230, 240, 255, 0.92); + font-size: 0.92rem; +} + +.invite-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.7rem; +} + +.invite-card { + border-radius: 16px; + border: 2px solid rgba(183, 210, 255, 0.46); + background: rgba(8, 22, 52, 0.56); + padding: 0.7rem; + display: grid; + gap: 0.56rem; +} + +.invite-card h3 { + margin: 0; + color: #f8fbff; +} + +.invite-link { + margin: 0; + font-size: 0.84rem; + line-height: 1.45; + color: #d5e5ff; + word-break: break-all; + border-radius: 10px; + border: 1px solid rgba(156, 188, 247, 0.45); + background: rgba(10, 24, 52, 0.52); + padding: 0.48rem 0.55rem; +} + +.invite-actions { + display: flex; + flex-wrap: wrap; + gap: 0.42rem; +} + +.invite-actions button, +.invite-actions .link-btn { + width: auto; + flex: 1 1 140px; + min-height: 40px; +} + +.invite-qr { + width: min(220px, 100%); + aspect-ratio: 1 / 1; + border-radius: 14px; + object-fit: cover; + border: 2px solid rgba(177, 207, 255, 0.56); + background: #fff; +} + +.ending-card { + width: min(640px, 100%); + background: #fff; + border: 3px solid #ff4f63; + border-radius: 20px; + padding: 1.2rem; + box-shadow: 0 18px 40px rgba(6, 14, 29, 0.35); + text-align: center; +} + +.ending-card h2 { + color: var(--n-red); + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.ending-card p { + color: #334055; + margin-bottom: 1rem; + font-size: 1.02rem; +} + +.player-host::after { + content: " 房主"; + color: var(--n-red); + font-weight: 800; +} + +.player-row { + display: grid; + gap: 0.35rem; +} + +.player-profile { + display: inline-flex; + align-items: center; + gap: 0.42rem; +} + +.player-profile img { + width: 22px; + height: 22px; + border-radius: 50%; + object-fit: cover; + border: 1px solid rgba(181, 205, 255, 0.52); +} + +.player-dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; +} + +.player-ops { + display: flex; + gap: 0.34rem; +} + +.ghost-btn.slim { + width: auto; + padding: 0.18rem 0.5rem; + font-size: 0.78rem; + border-radius: 9px; +} + +.player-offline { + opacity: 0.66; +} + +.badge { + margin-left: 0.36rem; + font-size: 0.75rem; + padding: 0.05rem 0.34rem; + border-radius: 999px; +} + +.badge.ready { + background: #d5ffe9; + color: #11663f; +} + +.badge.danger { + background: #ffe4e7; + color: #9f1f2f; +} + +/* ===== Theme Overrides (Strong Visual Separation) ===== */ + +/* Minimal: flat, restrained, almost monochrome */ +body[data-theme="minimal"] { + background: #f5f6f8 !important; +} + +body[data-theme="minimal"] .card, +body[data-theme="minimal"] .panel, +body[data-theme="minimal"] .meta-block, +body[data-theme="minimal"] .auth-card, +body[data-theme="minimal"] .invite-panel { + background: #ffffff !important; + border: 1px solid #d9dde4 !important; + box-shadow: none !important; + backdrop-filter: none !important; + border-radius: 10px !important; +} + +body[data-theme="minimal"] .status-banner { + background: #ffffff !important; + color: #111827 !important; + border: 1px solid #cfd6df !important; + box-shadow: none !important; + backdrop-filter: none !important; + border-radius: 10px !important; +} + +body[data-theme="minimal"] .scene-hero, +body[data-theme="minimal"] .log, +body[data-theme="minimal"] .status-grid, +body[data-theme="minimal"] .invite-card, +body[data-theme="minimal"] .selected-card-detail, +body[data-theme="minimal"] .card-guide, +body[data-theme="minimal"] .assist-panel { + background: #ffffff !important; + border: 1px solid #d9dde4 !important; + box-shadow: none !important; + color: #1c212b !important; +} + +body[data-theme="minimal"] .msg, +body[data-theme="minimal"] ul li, +body[data-theme="minimal"] .compact-list li { + background: #fafbfc !important; + border: 1px solid #e1e4ea !important; + color: #1f2633 !important; +} + +body[data-theme="minimal"] button, +body[data-theme="minimal"] .link-btn, +body[data-theme="minimal"] .choice-chip { + background: #1f2937 !important; + border: 1px solid #1f2937 !important; + box-shadow: none !important; + color: #ffffff !important; + border-radius: 8px !important; +} + +body[data-theme="minimal"] .ghost-btn { + background: #ffffff !important; + color: #1f2937 !important; + border: 1px solid #cfd6e2 !important; +} + +body[data-theme="minimal"] .room-topbar-immersive .room-title h1, +body[data-theme="minimal"] .room-topbar-immersive .room-title p, +body[data-theme="minimal"] .quick-panel h2, +body[data-theme="minimal"] .chat-panel h3, +body[data-theme="minimal"] .meta-panel h3, +body[data-theme="minimal"] .story-major-block h3 { + color: #1f2937 !important; +} + +body[data-theme="minimal"] .scene-overlay { + background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(248, 250, 252, 0.96) 58%) !important; + color: #111827 !important; +} + +body[data-theme="minimal"].room-page { + background: #f3f4f6 !important; +} + +body[data-theme="minimal"].room-page::before { + display: none !important; +} + +/* Dopamine: bold saturated blocks and playful contrast */ +body[data-theme="dopamine"] { + background: + linear-gradient(135deg, #ff006e 0%, #ffbe0b 28%, #00bbf9 62%, #8338ec 100%) !important; +} + +body[data-theme="dopamine"] .card, +body[data-theme="dopamine"] .panel, +body[data-theme="dopamine"] .meta-block, +body[data-theme="dopamine"] .auth-card, +body[data-theme="dopamine"] .invite-panel { + background: rgba(255, 255, 255, 0.92) !important; + border: 3px solid #111827 !important; + box-shadow: 10px 10px 0 rgba(17, 24, 39, 0.25) !important; + border-radius: 20px !important; +} + +body[data-theme="dopamine"] .quick-panel h2, +body[data-theme="dopamine"] .chat-panel h3, +body[data-theme="dopamine"] .meta-panel h3, +body[data-theme="dopamine"] .story-major-block h3, +body[data-theme="dopamine"] .helper-text, +body[data-theme="dopamine"] .header-note { + color: #1f1147 !important; +} + +body[data-theme="dopamine"] .quick-hint, +body[data-theme="dopamine"] .room-title p { + color: #2d1a66 !important; +} + +body[data-theme="dopamine"].room-page .quick-panel h2, +body[data-theme="dopamine"].room-page .chat-panel h3, +body[data-theme="dopamine"].room-page .meta-panel h3, +body[data-theme="dopamine"].room-page .story-major-block h3, +body[data-theme="dopamine"].room-page .quick-hint { + color: #1f1147 !important; +} + +body[data-theme="dopamine"].room-page .chat-panel label, +body[data-theme="dopamine"].room-page .quick-panel label { + color: #26164f !important; +} + +body[data-theme="dopamine"].room-page #choices-title, +body[data-theme="dopamine"].room-page #clues-title, +body[data-theme="dopamine"].room-page #inventory-title { + color: #1f1147 !important; +} + +body[data-theme="dopamine"].room-page .quick-panel h2, +body[data-theme="dopamine"].room-page .chat-panel h3, +body[data-theme="dopamine"].room-page .meta-panel h3, +body[data-theme="dopamine"].room-page .meta-block h3, +body[data-theme="dopamine"].room-page #choices-title { + display: inline-block; + padding: 0.08rem 0.46rem; + border-radius: 8px; + background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(31, 17, 71, 0.28); + text-shadow: none !important; +} + +body[data-theme="dopamine"].room-page .meta-block-head h3 { + margin-bottom: 0; +} + +body[data-theme="dopamine"] .status-grid { + background: #ffffff !important; + border-color: #1f1147 !important; + color: #1f1147 !important; +} + +body[data-theme="dopamine"] .room-topbar-immersive .room-title h1, +body[data-theme="dopamine"] .room-topbar-immersive .room-title p { + color: #ffffff !important; +} + +body[data-theme="dopamine"] button { + background: linear-gradient(135deg, #ff0054 0%, #ff7b00 55%, #ffd60a 100%) !important; + border: 2px solid #1f1147 !important; + color: #ffffff !important; + box-shadow: 0 8px 0 #1f1147 !important; + text-transform: none; +} + +body[data-theme="dopamine"] .secondary-btn, +body[data-theme="dopamine"] .link-btn { + background: linear-gradient(135deg, #00bbf9 0%, #3a86ff 45%, #8338ec 100%) !important; + border: 2px solid #1f1147 !important; + box-shadow: 0 8px 0 #1f1147 !important; +} + +body[data-theme="dopamine"] .ghost-btn, +body[data-theme="dopamine"] .choice-chip { + background: #ffffff !important; + color: #1f1147 !important; + border: 2px solid #1f1147 !important; + box-shadow: 0 6px 0 #1f1147 !important; +} + +body[data-theme="dopamine"] .msg.system { + background: #00bbf9 !important; + border-color: #1f1147 !important; + color: #ffffff !important; +} + +body[data-theme="dopamine"] .msg.ai { + background: #06d6a0 !important; + border-color: #1f1147 !important; + color: #102a1f !important; +} + +body[data-theme="dopamine"] .msg.npc { + background: #ffbe0b !important; + border-color: #1f1147 !important; + color: #3c2d00 !important; +} + +body[data-theme="dopamine"] .msg.chat { + background: #ffd6ff !important; + border-color: #1f1147 !important; + color: #3b184f !important; +} + +body[data-theme="dopamine"] .scene-overlay { + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(31, 17, 71, 0.9) 58%) !important; +} + +body[data-theme="dopamine"].room-page .status-grid, +body[data-theme="dopamine"].room-page .log, +body[data-theme="dopamine"].room-page .meta-block, +body[data-theme="dopamine"].room-page .msg, +body[data-theme="dopamine"].room-page .choice-chip { + text-shadow: none; +} + +/* Midnight: deep dark + neon edges */ +body[data-theme="midnight"] { + background: + radial-gradient(circle at 15% -8%, rgba(72, 125, 255, 0.38), transparent 32%), + radial-gradient(circle at 86% 0%, rgba(255, 83, 136, 0.3), transparent 28%), + linear-gradient(180deg, #070b1a 0%, #0b1228 55%, #070b1a 100%) !important; +} + +body[data-theme="midnight"] .card, +body[data-theme="midnight"] .panel, +body[data-theme="midnight"] .meta-block, +body[data-theme="midnight"] .auth-card, +body[data-theme="midnight"] .invite-panel { + border: 1px solid rgba(112, 151, 255, 0.55) !important; + box-shadow: 0 0 0 1px rgba(72, 125, 255, 0.22), 0 16px 48px rgba(4, 8, 24, 0.55) !important; +} + +body[data-theme="midnight"] button, +body[data-theme="midnight"] .link-btn { + background: linear-gradient(135deg, #3f6fff 0%, #5a3dff 100%) !important; + border: 1px solid rgba(161, 187, 255, 0.6) !important; + box-shadow: 0 0 16px rgba(87, 131, 255, 0.45) !important; +} + +body[data-theme="midnight"] .secondary-btn { + background: linear-gradient(135deg, #00c2ff 0%, #4d84ff 100%) !important; +} + +body[data-theme="midnight"] .choice-chip { + background: linear-gradient(135deg, #00d1ff 0%, #3f6fff 100%) !important; + border-color: rgba(176, 206, 255, 0.65) !important; +} + +/* Mint: airy, calm, high readability */ +body[data-theme="mint"] { + background: + radial-gradient(circle at 12% -10%, rgba(24, 183, 126, 0.22), transparent 34%), + radial-gradient(circle at 90% 2%, rgba(47, 124, 255, 0.2), transparent 28%), + linear-gradient(180deg, #edf8f3 0%, #e7f6f0 55%, #edf8f3 100%) !important; +} + +body[data-theme="mint"] .card, +body[data-theme="mint"] .panel, +body[data-theme="mint"] .meta-block, +body[data-theme="mint"] .auth-card { + background: rgba(255, 255, 255, 0.92) !important; + border: 1px solid #b9dfd2 !important; + box-shadow: 0 10px 28px rgba(41, 114, 96, 0.12) !important; +} + +body[data-theme="mint"] button { + background: linear-gradient(135deg, #18b77e 0%, #2f7cff 100%) !important; +} + +body[data-theme="mint"] .ghost-btn { + background: #f6fffb !important; + color: #17695c !important; + border-color: #9fd5c6 !important; +} + +/* Sunset: warm cinematic, soft contrast */ +body[data-theme="sunset"] { + background: + radial-gradient(circle at 18% -8%, rgba(255, 111, 97, 0.28), transparent 33%), + radial-gradient(circle at 88% 0%, rgba(255, 186, 73, 0.26), transparent 28%), + linear-gradient(180deg, #fff1ea 0%, #ffe8da 58%, #ffe3d1 100%) !important; +} + +body[data-theme="sunset"] .card, +body[data-theme="sunset"] .panel, +body[data-theme="sunset"] .meta-block, +body[data-theme="sunset"] .auth-card, +body[data-theme="sunset"] .invite-panel { + background: rgba(255, 250, 243, 0.92) !important; + border: 1px solid #eab8a8 !important; + box-shadow: 0 12px 30px rgba(137, 73, 62, 0.15) !important; +} + +body[data-theme="sunset"] button { + background: linear-gradient(135deg, #ff5a5f 0%, #ff9445 100%) !important; +} + +body[data-theme="sunset"] .secondary-btn, +body[data-theme="sunset"] .link-btn { + background: linear-gradient(135deg, #4d7dff 0%, #6d90ff 100%) !important; +} + +/* ===== Room Theme Contrast Guardrails ===== */ +/* Light-background themes in room view: force high-contrast titles/labels */ +body[data-theme="mint"].room-page .quick-panel h2, +body[data-theme="mint"].room-page .chat-panel h3, +body[data-theme="mint"].room-page .meta-panel h3, +body[data-theme="mint"].room-page .meta-block h3, +body[data-theme="mint"].room-page .story-major-block h3, +body[data-theme="sunset"].room-page .quick-panel h2, +body[data-theme="sunset"].room-page .chat-panel h3, +body[data-theme="sunset"].room-page .meta-panel h3, +body[data-theme="sunset"].room-page .meta-block h3, +body[data-theme="sunset"].room-page .story-major-block h3 { + color: #1f2937 !important; + text-shadow: none !important; +} + +body[data-theme="mint"].room-page .quick-panel label, +body[data-theme="mint"].room-page .chat-panel label, +body[data-theme="sunset"].room-page .quick-panel label, +body[data-theme="sunset"].room-page .chat-panel label { + color: #334155 !important; +} + +body[data-theme="mint"].room-page .quick-hint, +body[data-theme="sunset"].room-page .quick-hint { + color: #475569 !important; +} + +body[data-theme="mint"].room-page #choices-title, +body[data-theme="mint"].room-page #clues-title, +body[data-theme="mint"].room-page #inventory-title, +body[data-theme="sunset"].room-page #choices-title, +body[data-theme="sunset"].room-page #clues-title, +body[data-theme="sunset"].room-page #inventory-title { + color: #1f2937 !important; +} + +body[data-theme="mint"].room-page .status-grid, +body[data-theme="sunset"].room-page .status-grid { + color: #1f2937 !important; +} + +body[data-theme="mint"].room-page .meta-panel li, +body[data-theme="sunset"].room-page .meta-panel li { + color: #1f2937 !important; +} + +@media (max-width: 1180px) { + .lobby-grid { + grid-template-columns: 1fr 1fr; + } + + .plaza-grid { + grid-template-columns: 1fr; + } + + .card-editor-shell { + grid-template-columns: 1fr; + } + + .editor-guide-card { + position: static; + max-height: none; + } + + .room-shell { + grid-template-columns: 1fr; + height: calc(100vh - 96px); + } + + .story-stage { + grid-template-rows: 220px minmax(0, 1fr); + } + + .command-stage { + grid-template-rows: auto auto auto; + overflow-y: auto; + } + + .meta-grid { + grid-template-columns: 1fr; + } + + .card-select-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .invite-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + body.room-page { + height: auto; + min-height: 100vh; + overflow: auto; + } + + .lobby-topbar, + .room-topbar { + flex-direction: column; + } + + .top-actions, + .header-actions { + width: 100%; + flex-wrap: wrap; + } + + .lobby-grid { + grid-template-columns: 1fr; + } + + .card-select-grid { + grid-template-columns: 1fr 1fr; + } + + .room-shell { + padding: 0 0.58rem 0.62rem; + gap: 0.65rem; + height: auto; + min-height: calc(100vh - 96px); + grid-template-columns: 1fr; + grid-template-rows: auto auto; + overflow: visible; + } + + .room-topbar-immersive { + padding: 0.62rem; + } + + .story-stage { + grid-template-rows: auto minmax(230px, auto); + } + + .scene-hero { + min-height: 240px; + max-height: 56vh; + } + + .scene-overlay { + max-height: calc(56vh - 12px); + overflow-y: auto; + padding-right: 0.5rem; + } + + .scene-overlay h2 { + font-size: 1.16rem; + } + + .scene-overlay p { + font-size: 0.9rem; + margin-bottom: 0.36rem; + } + + .hero-status-list { + max-height: 26vh; + overflow-y: auto; + padding-right: 0.2rem; + } + + .command-stage { + grid-template-rows: auto auto auto; + overflow: visible; + gap: 0.58rem; + } + + .quick-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.42rem; + } + + .choice-chip { + width: 100% !important; + text-align: center; + padding: 0.48rem 0.52rem; + font-size: 0.84rem; + } + + .log { + max-height: none; + min-height: 180px; + } + + .log.small { + max-height: none; + min-height: 120px; + } + + .meta-panel { + overflow: visible; + padding-right: 0; + } + + .meta-grid { + grid-template-columns: 1fr; + gap: 0.44rem; + } + + .meta-block { + padding: 0.42rem; + } + + .header-actions button, + .header-actions .link-btn { + min-height: 38px; + padding: 0.45rem 0.66rem; + font-size: 0.86rem; + } + + .header-actions > * { + flex: 1 1 calc(50% - 0.35rem); + justify-content: center; + } + + .ending-card { + padding: 0.9rem 0.75rem; + } + + .ending-card h2 { + font-size: 1.45rem; + } + + .invite-modal { + padding: 0.65rem; + align-items: end; + } + + .invite-panel { + border-radius: 16px; + padding: 0.72rem; + gap: 0.56rem; + max-height: min(92vh, 760px); + overflow-y: auto; + } + + .invite-actions button, + .invite-actions .link-btn { + flex: 1 1 100%; + } + + .invite-qr { + width: min(190px, 100%); + } +} diff --git a/static/img/cards/fogharbor.svg b/static/img/cards/fogharbor.svg new file mode 100644 index 0000000..ad427dd --- /dev/null +++ b/static/img/cards/fogharbor.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + 雾港旅店 + 暴风雨夜 · 规则诡局 · 团队抉择 + diff --git a/static/img/cards/skyrail_omega.svg b/static/img/cards/skyrail_omega.svg new file mode 100644 index 0000000..89b0c63 --- /dev/null +++ b/static/img/cards/skyrail_omega.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + 天轨终点站 + 无人列车 · 云海终点 · 信任博弈 + diff --git a/static/img/cards/sunken_arcade.svg b/static/img/cards/sunken_arcade.svg new file mode 100644 index 0000000..2b6cd59 --- /dev/null +++ b/static/img/cards/sunken_arcade.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + 沉没游乐城 + 海雾霓虹 · 机关异响 · 分头探索 + diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..d11b9ac --- /dev/null +++ b/static/js/admin.js @@ -0,0 +1,282 @@ +(function () { + const body = document.body; + const user = (() => { + try { + return JSON.parse(body.dataset.user || 'null'); + } catch { + return null; + } + })(); + + const statusEl = document.getElementById('status'); + const usersTable = document.getElementById('users-table'); + const roomsTable = document.getElementById('rooms-table'); + const cardsTable = document.getElementById('cards-table'); + + const ROLE_OPTIONS = ['player', 'moderator', 'admin']; + + const setStatus = (text) => { + statusEl.textContent = text || ''; + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const patchJson = async (url, payload) => { + const res = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const deleteJson = async (url) => { + const res = await fetch(url, { method: 'DELETE' }); + return res.json(); + }; + + const formatDate = (isoText) => { + const raw = String(isoText || ''); + if (!raw) return '-'; + try { + return new Date(raw).toLocaleString(); + } catch { + return raw; + } + }; + + const makeTable = (headers) => { + const table = document.createElement('table'); + table.className = 'admin-table'; + const thead = document.createElement('thead'); + const tr = document.createElement('tr'); + headers.forEach((h) => { + const th = document.createElement('th'); + th.textContent = h; + tr.appendChild(th); + }); + thead.appendChild(tr); + table.appendChild(thead); + const tbody = document.createElement('tbody'); + table.appendChild(tbody); + return { table, tbody }; + }; + + const renderUsers = (rows) => { + usersTable.textContent = ''; + const { table, tbody } = makeTable(['用户名', '权限组', '创建时间', '操作']); + rows.forEach((row) => { + const tr = document.createElement('tr'); + + const tdUsername = document.createElement('td'); + tdUsername.textContent = row.username || '-'; + tr.appendChild(tdUsername); + + const tdRole = document.createElement('td'); + const select = document.createElement('select'); + ROLE_OPTIONS.forEach((role) => { + const option = document.createElement('option'); + option.value = role; + option.textContent = role; + if (role === row.role) option.selected = true; + select.appendChild(option); + }); + tdRole.appendChild(select); + tr.appendChild(tdRole); + + const tdCreated = document.createElement('td'); + tdCreated.textContent = formatDate(row.created_at); + tr.appendChild(tdCreated); + + const tdAction = document.createElement('td'); + const saveBtn = document.createElement('button'); + saveBtn.type = 'button'; + saveBtn.className = 'secondary-btn'; + saveBtn.textContent = '保存权限'; + saveBtn.addEventListener('click', async () => { + setStatus(`正在更新 ${row.username} 的权限...`); + try { + const data = await patchJson(`/api/admin/users/${row.user_id}/role`, { role: select.value }); + if (!data.ok) { + setStatus(`更新失败: ${data.error || '未知错误'}`); + return; + } + setStatus(`已更新 ${row.username} 权限为 ${select.value}`); + loadAll(); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + tdAction.appendChild(saveBtn); + tr.appendChild(tdAction); + + tbody.appendChild(tr); + }); + usersTable.appendChild(table); + }; + + const renderRooms = (rows) => { + roomsTable.textContent = ''; + const { table, tbody } = makeTable(['房间码', '剧本卡', '状态', '玩家数', '最近更新', '操作']); + rows.forEach((row) => { + const tr = document.createElement('tr'); + + const tdRoom = document.createElement('td'); + tdRoom.textContent = row.room_id || '-'; + tr.appendChild(tdRoom); + + const tdCard = document.createElement('td'); + tdCard.textContent = row.card_title || row.game_card_id || '-'; + tr.appendChild(tdCard); + + const tdStatus = document.createElement('td'); + tdStatus.textContent = row.status || '-'; + tr.appendChild(tdStatus); + + const tdCount = document.createElement('td'); + tdCount.textContent = String(row.player_count ?? 0); + tr.appendChild(tdCount); + + const tdUpdated = document.createElement('td'); + tdUpdated.textContent = formatDate(row.updated_at); + tr.appendChild(tdUpdated); + + const tdAction = document.createElement('td'); + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'ghost-btn'; + delBtn.textContent = '强制结束并删除'; + delBtn.addEventListener('click', async () => { + if (!confirm(`确认删除房间 ${row.room_id} 吗?`)) return; + setStatus(`正在删除房间 ${row.room_id}...`); + try { + const data = await deleteJson(`/api/admin/rooms/${row.room_id}`); + if (!data.ok) { + setStatus(`删除失败: ${data.error || '未知错误'}`); + return; + } + setStatus(`房间 ${row.room_id} 已删除。`); + loadAll(); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + tdAction.appendChild(delBtn); + tr.appendChild(tdAction); + + tbody.appendChild(tr); + }); + roomsTable.appendChild(table); + }; + + const renderCards = (rows) => { + cardsTable.textContent = ''; + const { table, tbody } = makeTable(['标题', '作者', '官方', '创建时间', '操作']); + rows.forEach((row) => { + const tr = document.createElement('tr'); + + const tdTitle = document.createElement('td'); + tdTitle.textContent = row.title || '-'; + tr.appendChild(tdTitle); + + const tdCreator = document.createElement('td'); + tdCreator.textContent = row.creator_display_name || '-'; + tr.appendChild(tdCreator); + + const tdOfficial = document.createElement('td'); + tdOfficial.textContent = row.is_official ? '是' : '否'; + tr.appendChild(tdOfficial); + + const tdCreated = document.createElement('td'); + tdCreated.textContent = formatDate(row.created_at); + tr.appendChild(tdCreated); + + const tdAction = document.createElement('td'); + const editBtn = document.createElement('a'); + editBtn.className = 'link-btn'; + editBtn.href = `/cards/${row.card_id}/edit`; + editBtn.textContent = '编辑'; + tdAction.appendChild(editBtn); + + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'secondary-btn'; + toggleBtn.textContent = row.is_official ? '设为玩家卡' : '设为官方卡'; + toggleBtn.style.marginTop = '0.4rem'; + toggleBtn.addEventListener('click', async () => { + setStatus(`正在更新游戏卡「${row.title}」...`); + try { + const data = await patchJson(`/api/admin/cards/${row.card_id}`, { + is_official: !row.is_official, + }); + if (!data.ok) { + setStatus(`更新失败: ${data.error || '未知错误'}`); + return; + } + setStatus(`游戏卡「${row.title}」已更新。`); + loadAll(); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + tdAction.appendChild(toggleBtn); + + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'ghost-btn'; + delBtn.style.marginTop = '0.4rem'; + delBtn.textContent = '删除游戏卡'; + delBtn.addEventListener('click', async () => { + if (!confirm(`确认删除游戏卡「${row.title}」吗?`)) return; + setStatus(`正在删除游戏卡「${row.title}」...`); + try { + const data = await deleteJson(`/api/admin/cards/${row.card_id}`); + if (!data.ok) { + setStatus(`删除失败: ${data.error || '未知错误'}`); + return; + } + setStatus(`游戏卡「${row.title}」已删除。`); + loadAll(); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + tdAction.appendChild(delBtn); + + tr.appendChild(tdAction); + tbody.appendChild(tr); + }); + cardsTable.appendChild(table); + }; + + const loadAll = async () => { + if (!user || user.role !== 'admin') { + window.location.href = '/'; + return; + } + setStatus('正在加载后台数据...'); + try { + const [usersResp, roomsResp, cardsResp] = await Promise.all([ + getJson('/api/admin/users'), + getJson('/api/admin/rooms'), + getJson('/api/admin/cards'), + ]); + if (!usersResp.ok || !roomsResp.ok || !cardsResp.ok) { + const msg = usersResp.error || roomsResp.error || cardsResp.error || '加载失败'; + setStatus(msg); + return; + } + renderUsers(usersResp.users || []); + renderRooms(roomsResp.rooms || []); + renderCards(cardsResp.cards || []); + setStatus('后台数据已更新。'); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }; + + loadAll(); +})(); diff --git a/static/js/auth.js b/static/js/auth.js new file mode 100644 index 0000000..b7efc38 --- /dev/null +++ b/static/js/auth.js @@ -0,0 +1,68 @@ +(function () { + const statusEl = document.getElementById('status'); + const loginForm = document.getElementById('login-form'); + const registerForm = document.getElementById('register-form'); + + const setStatus = (text) => { + if (!statusEl) return; + statusEl.textContent = text || ''; + }; + + const postJson = async (url, payload) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + if (loginForm) { + loginForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('password').value; + if (!username || !password) { + setStatus('请填写登录账号和密码。'); + return; + } + setStatus('登录中...'); + try { + const data = await postJson('/api/auth/login', { username, password }); + if (!data.ok) { + setStatus(data.error || '登录失败'); + return; + } + window.location.href = '/'; + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + } + + if (registerForm) { + registerForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('password').value; + if (!username || !password) { + setStatus('请填写完整信息。'); + return; + } + setStatus('注册中...'); + try { + const data = await postJson('/api/auth/register', { + username, + password, + }); + if (!data.ok) { + setStatus(data.error || '注册失败'); + return; + } + window.location.href = '/'; + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + } +})(); diff --git a/static/js/card_editor.js b/static/js/card_editor.js new file mode 100644 index 0000000..f2533a5 --- /dev/null +++ b/static/js/card_editor.js @@ -0,0 +1,450 @@ +(function () { + const body = document.body; + const user = (() => { + try { + return JSON.parse(body.dataset.user || 'null'); + } catch { + return null; + } + })(); + + const cardId = String(body.dataset.cardId || '').trim(); + const isEditMode = Boolean(cardId); + + const statusEl = document.getElementById('status'); + const form = document.getElementById('card-form'); + const saveBtn = document.getElementById('save-card-btn'); + const pageTitle = document.getElementById('editor-page-title'); + const pageSubtitle = document.getElementById('editor-page-subtitle'); + const cardMetaNote = document.getElementById('card-meta-note'); + + const assistBtn = document.getElementById('assist-btn'); + const assistLoading = document.getElementById('assist-loading'); + const assistModifyBtn = document.getElementById('assist-modify-btn'); + const assistModifyLoading = document.getElementById('assist-modify-loading'); + const assistModifyInput = document.getElementById('assist-modify-instruction'); + const assistNotes = document.getElementById('assist-notes'); + + const fields = { + title: document.getElementById('card-title'), + subtitle: document.getElementById('card-subtitle'), + summary: document.getElementById('card-summary'), + cover: document.getElementById('card-cover'), + roomBg: document.getElementById('card-room-bg'), + opening: document.getElementById('card-opening'), + goals: document.getElementById('card-goals'), + inventoryLabel: document.getElementById('card-inventory-label'), + cluesLabel: document.getElementById('card-clues-label'), + choicesLabel: document.getElementById('card-choices-label'), + sceneLabel: document.getElementById('card-scene-label'), + heroStatusLabel: document.getElementById('card-hero-status-label'), + heroStatusKey: document.getElementById('card-hero-status-key'), + heroStatusColor: document.getElementById('card-hero-status-color'), + dataScopeJson: document.getElementById('card-data-scope-json'), + visibilityJson: document.getElementById('card-visibility-json'), + metricsJson: document.getElementById('card-metrics-json'), + statusTracksJson: document.getElementById('card-status-tracks-json'), + endConditionsJson: document.getElementById('card-end-conditions-json'), + systemsJson: document.getElementById('card-systems-json'), + genreTags: document.getElementById('card-genre-tags'), + featureBadges: document.getElementById('card-feature-badges'), + worldLore: document.getElementById('card-world-lore'), + coreConflict: document.getElementById('card-core-conflict'), + playerRoles: document.getElementById('card-player-roles'), + interactionModes: document.getElementById('card-interaction-modes'), + systemRules: document.getElementById('card-system-rules'), + npcRoster: document.getElementById('card-npc-roster'), + sceneBlueprint: document.getElementById('card-scene-blueprint'), + endingCatalog: document.getElementById('card-ending-catalog'), + hiddenTruths: document.getElementById('card-hidden-truths'), + }; + + const setStatus = (text) => { + statusEl.textContent = text || ''; + }; + + const setAssistLoading = (loading, mode = 'draft') => { + if (assistBtn) { + assistBtn.disabled = loading; + assistBtn.textContent = loading ? 'AI 生成中...' : 'AI 生成草稿并填充'; + } + if (assistModifyBtn) { + assistModifyBtn.disabled = loading; + assistModifyBtn.textContent = loading ? 'AI 修改中...' : 'AI 按指令修改当前内容'; + } + if (assistLoading) { + assistLoading.hidden = !(loading && mode === 'draft'); + } + if (assistModifyLoading) { + assistModifyLoading.hidden = !(loading && mode === 'modify'); + } + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const sendJson = async (url, method, payload) => { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const listToMultiline = (arr) => { + if (!Array.isArray(arr)) return ''; + return arr.map((row) => String(row || '').trim()).filter(Boolean).join('\n'); + }; + + const prettyJson = (value) => { + try { + return JSON.stringify(value, null, 2); + } catch { + return ''; + } + }; + + const parseJsonField = (element, fallbackValue, expectedType) => { + const text = String(element?.value || '').trim(); + if (!text) return fallbackValue; + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new Error(`${element.id} 不是合法 JSON`); + } + + if (expectedType === 'array' && !Array.isArray(parsed)) { + throw new Error(`${element.id} 需要是数组 JSON`); + } + if (expectedType === 'object' && (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))) { + throw new Error(`${element.id} 需要是对象 JSON`); + } + return parsed; + }; + + const ensureDefaults = () => { + if (!fields.dataScopeJson.value.trim()) { + fields.dataScopeJson.value = prettyJson({ + metrics: 'per_player', + inventory: 'per_player', + clues: 'shared', + }); + } + if (!fields.visibilityJson.value.trim()) { + fields.visibilityJson.value = prettyJson({ + show_all_player_metrics: true, + show_all_player_inventory: true, + }); + } + if (!fields.metricsJson.value.trim()) { + fields.metricsJson.value = prettyJson([ + { key: 'tension', label: '紧张度', min: 0, max: 100, initial: 20, color: '#ff8c2f' }, + { key: 'progress', label: '推进度', min: 0, max: 100, initial: 0, color: '#3b82f6' }, + ]); + } + if (!fields.statusTracksJson.value.trim()) { + fields.statusTracksJson.value = prettyJson([ + { key: 'tension', label: '紧张度', min: 0, max: 100, color: '#ff8c2f' }, + { key: 'progress', label: '推进度', min: 0, max: 100, color: '#3b82f6' }, + ]); + } + if (!fields.endConditionsJson.value.trim()) { + fields.endConditionsJson.value = '[]'; + } + if (!fields.systemsJson.value.trim()) { + fields.systemsJson.value = '{}'; + } + }; + + const fillForm = (card) => { + const schema = card?.state_schema || {}; + const labels = schema.labels || {}; + const dataScope = schema.data_scope || {}; + const visibility = schema.visibility || {}; + const ui = schema.ui || {}; + const hero = ui.hero_status || {}; + const pack = card?.content_pack || {}; + + fields.title.value = card?.title || ''; + fields.subtitle.value = card?.subtitle || ''; + fields.summary.value = card?.summary || ''; + fields.cover.value = card?.cover_url || ''; + fields.roomBg.value = ui.room_background_url || card?.cover_url || ''; + fields.opening.value = card?.opening_text || ''; + fields.goals.value = listToMultiline(card?.starter_goals || []); + + fields.inventoryLabel.value = labels.inventory || ''; + fields.cluesLabel.value = labels.clues || ''; + fields.choicesLabel.value = labels.choices || ''; + fields.sceneLabel.value = labels.scene || ''; + + fields.heroStatusLabel.value = hero.label || ''; + fields.heroStatusKey.value = hero.key || ''; + fields.heroStatusColor.value = /^#[0-9a-fA-F]{6}$/.test(String(hero.color || '')) ? hero.color : '#ffb500'; + + fields.dataScopeJson.value = prettyJson(dataScope); + fields.visibilityJson.value = prettyJson(visibility); + 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.genreTags.value = (Array.isArray(pack.genre_tags) ? pack.genre_tags : []).join(', '); + fields.featureBadges.value = (Array.isArray(pack.feature_badges) ? pack.feature_badges : []).join(', '); + fields.worldLore.value = pack.world_lore || ''; + fields.coreConflict.value = pack.core_conflict || ''; + fields.playerRoles.value = listToMultiline(pack.player_roles || []); + fields.interactionModes.value = listToMultiline(pack.interaction_modes || []); + fields.systemRules.value = listToMultiline(pack.system_rules || []); + fields.npcRoster.value = listToMultiline(pack.npc_roster || []); + fields.sceneBlueprint.value = listToMultiline(pack.scene_blueprint || []); + fields.endingCatalog.value = listToMultiline(pack.ending_catalog || []); + fields.hiddenTruths.value = listToMultiline(pack.hidden_truths || []); + + ensureDefaults(); + }; + + const fillWithDraft = (draft) => { + fields.title.value = draft.title || fields.title.value; + fields.subtitle.value = draft.subtitle || fields.subtitle.value; + fields.summary.value = draft.summary || fields.summary.value; + fields.cover.value = draft.cover_url || fields.cover.value; + fields.roomBg.value = draft.room_background_url || fields.roomBg.value; + fields.opening.value = draft.opening_text || fields.opening.value; + + if (Array.isArray(draft.starter_goals)) fields.goals.value = listToMultiline(draft.starter_goals); + if (draft.data_scope && typeof draft.data_scope === 'object') fields.dataScopeJson.value = prettyJson(draft.data_scope); + if (draft.visibility && typeof draft.visibility === 'object') fields.visibilityJson.value = prettyJson(draft.visibility); + if (Array.isArray(draft.metrics)) fields.metricsJson.value = prettyJson(draft.metrics); + 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); + + fields.inventoryLabel.value = draft.inventory_label || fields.inventoryLabel.value; + fields.cluesLabel.value = draft.clues_label || fields.cluesLabel.value; + fields.choicesLabel.value = draft.choices_label || fields.choicesLabel.value; + fields.sceneLabel.value = draft.scene_label || fields.sceneLabel.value; + fields.heroStatusLabel.value = draft.hero_status_label || fields.heroStatusLabel.value; + fields.heroStatusColor.value = /^#[0-9a-fA-F]{6}$/.test(String(draft.hero_status_color || '')) + ? draft.hero_status_color + : fields.heroStatusColor.value; + + if (Array.isArray(draft.genre_tags)) fields.genreTags.value = draft.genre_tags.join(', '); + if (Array.isArray(draft.feature_badges)) fields.featureBadges.value = draft.feature_badges.join(', '); + fields.worldLore.value = draft.world_lore || fields.worldLore.value; + fields.coreConflict.value = draft.core_conflict || fields.coreConflict.value; + if (Array.isArray(draft.player_roles)) fields.playerRoles.value = listToMultiline(draft.player_roles); + if (Array.isArray(draft.interaction_modes)) fields.interactionModes.value = listToMultiline(draft.interaction_modes); + if (Array.isArray(draft.system_rules)) fields.systemRules.value = listToMultiline(draft.system_rules); + if (Array.isArray(draft.npc_roster)) fields.npcRoster.value = listToMultiline(draft.npc_roster); + if (Array.isArray(draft.scene_blueprint)) fields.sceneBlueprint.value = listToMultiline(draft.scene_blueprint); + if (Array.isArray(draft.ending_catalog)) fields.endingCatalog.value = listToMultiline(draft.ending_catalog); + if (Array.isArray(draft.hidden_truths)) fields.hiddenTruths.value = listToMultiline(draft.hidden_truths); + + const notes = Array.isArray(draft.design_notes) ? draft.design_notes : []; + assistNotes.textContent = ''; + notes.forEach((row) => { + const li = document.createElement('li'); + li.textContent = row; + assistNotes.appendChild(li); + }); + + ensureDefaults(); + }; + + const addAssistNote = (text) => { + if (!assistNotes || !text) return; + const li = document.createElement('li'); + li.textContent = text; + assistNotes.appendChild(li); + }; + + const buildPayload = () => { + const metrics = parseJsonField(fields.metricsJson, [], 'array'); + const statusTracks = parseJsonField(fields.statusTracksJson, [], 'array'); + const endConditions = parseJsonField(fields.endConditionsJson, [], 'array'); + const dataScope = parseJsonField(fields.dataScopeJson, {}, 'object'); + const visibility = parseJsonField(fields.visibilityJson, {}, 'object'); + const systems = parseJsonField(fields.systemsJson, {}, 'object'); + + return { + title: fields.title.value.trim(), + subtitle: fields.subtitle.value.trim(), + summary: fields.summary.value.trim(), + cover_url: fields.cover.value.trim(), + room_background_url: fields.roomBg.value.trim(), + opening_text: fields.opening.value.trim(), + starter_goals: fields.goals.value.trim(), + + inventory_label: fields.inventoryLabel.value.trim(), + clues_label: fields.cluesLabel.value.trim(), + choices_label: fields.choicesLabel.value.trim(), + scene_label: fields.sceneLabel.value.trim(), + hero_status_label: fields.heroStatusLabel.value.trim(), + hero_status_key: fields.heroStatusKey.value.trim(), + hero_status_color: fields.heroStatusColor.value.trim(), + + data_scope_json: dataScope, + visibility_json: visibility, + metrics, + status_tracks: statusTracks, + systems_json: systems, + + genre_tags: fields.genreTags.value.trim(), + feature_badges: fields.featureBadges.value.trim(), + world_lore: fields.worldLore.value.trim(), + core_conflict: fields.coreConflict.value.trim(), + player_roles: fields.playerRoles.value.trim(), + interaction_modes: fields.interactionModes.value.trim(), + system_rules: fields.systemRules.value.trim(), + npc_roster: fields.npcRoster.value.trim(), + scene_blueprint: fields.sceneBlueprint.value.trim(), + ending_catalog: fields.endingCatalog.value.trim(), + hidden_truths: fields.hiddenTruths.value.trim(), + end_conditions: endConditions, + }; + }; + + const initMode = () => { + if (isEditMode) { + pageTitle.textContent = '编辑游戏卡'; + pageSubtitle.textContent = '你正在修改已发布的游戏卡,保存后会用于新开房间。'; + saveBtn.textContent = '保存修改'; + cardMetaNote.textContent = `当前卡片 ID:${cardId}`; + } else { + pageTitle.textContent = '新建游戏卡'; + pageSubtitle.textContent = '创建一张可复用的多人冒险卡,支持多状态条和复杂系统。'; + saveBtn.textContent = '发布游戏卡'; + cardMetaNote.textContent = '保存后会自动跳转到该卡的编辑页。'; + ensureDefaults(); + } + }; + + const loadCard = async () => { + if (!isEditMode) return; + setStatus('正在加载游戏卡...'); + const data = await getJson(`/api/cards/${cardId}`); + if (!data.ok) { + setStatus(data.error || '加载失败'); + return; + } + fillForm(data.card || {}); + setStatus('已加载,可继续编辑。'); + }; + + if (assistBtn) { + assistBtn.addEventListener('click', async () => { + const concept = String(document.getElementById('assist-concept').value || '').trim(); + const tone = String(document.getElementById('assist-tone').value || '').trim(); + const difficulty = String(document.getElementById('assist-difficulty').value || '').trim(); + if (!concept) { + setStatus('请先输入创意草案。'); + return; + } + setAssistLoading(true, 'draft'); + setStatus('AI 正在生成游戏卡草稿...'); + try { + const data = await sendJson('/api/cards/assist', 'POST', { concept, tone, difficulty }); + if (!data.ok) { + setStatus(data.error || 'AI 生成失败'); + return; + } + fillWithDraft(data.draft || {}); + setStatus('AI 草稿已填充,请检查后保存。'); + } catch (error) { + setStatus(`网络错误: ${error}`); + } finally { + setAssistLoading(false, 'draft'); + } + }); + } + + if (assistModifyBtn) { + assistModifyBtn.addEventListener('click', async () => { + const instruction = String(assistModifyInput?.value || '').trim(); + if (!instruction) { + setStatus('请先输入修改指令。'); + return; + } + + let current; + try { + current = buildPayload(); + } catch (error) { + setStatus(error instanceof Error ? error.message : `当前配置有误: ${error}`); + return; + } + + setAssistLoading(true, 'modify'); + setStatus('AI 正在按指令增量修改...'); + try { + const data = await sendJson('/api/cards/assist_patch', 'POST', { instruction, current }); + if (!data.ok) { + setStatus(data.error || 'AI 修改失败'); + return; + } + fillWithDraft(data.updated || {}); + const changedKeys = Object.keys(data.patch || {}); + if (changedKeys.length) { + addAssistNote(`本次增量修改字段:${changedKeys.join('、')}`); + } + setStatus('AI 增量修改已应用,请检查并保存。'); + } catch (error) { + setStatus(`网络错误: ${error}`); + } finally { + setAssistLoading(false, 'modify'); + } + }); + } + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + let payload; + try { + payload = buildPayload(); + } catch (error) { + setStatus(error instanceof Error ? error.message : `配置有误: ${error}`); + return; + } + + const method = isEditMode ? 'PATCH' : 'POST'; + const endpoint = isEditMode ? `/api/cards/${cardId}` : '/api/cards'; + saveBtn.disabled = true; + setStatus(isEditMode ? '正在保存修改...' : '正在发布...'); + + try { + const data = await sendJson(endpoint, method, payload); + if (!data.ok) { + setStatus(data.error || '保存失败'); + return; + } + const nextCard = data.card || {}; + setStatus('保存成功。'); + + if (!isEditMode && nextCard.card_id) { + window.location.href = `/cards/${nextCard.card_id}/edit`; + } + } catch (error) { + setStatus(`网络错误: ${error}`); + } finally { + saveBtn.disabled = false; + } + }); + + if (!user) { + setStatus('请先登录再编辑游戏卡。'); + saveBtn.disabled = true; + if (assistBtn) assistBtn.disabled = true; + if (assistModifyBtn) assistModifyBtn.disabled = true; + return; + } + + initMode(); + loadCard().catch((error) => { + setStatus(`加载失败: ${error}`); + }); +})(); diff --git a/static/js/lobby.js b/static/js/lobby.js new file mode 100644 index 0000000..073ef01 --- /dev/null +++ b/static/js/lobby.js @@ -0,0 +1,404 @@ +(function () { + const body = document.body; + const user = (() => { + try { + return JSON.parse(body.dataset.user || 'null'); + } catch { + return null; + } + })(); + + const createForm = document.getElementById('create-form'); + const joinForm = document.getElementById('join-form'); + const profileForm = document.getElementById('profile-form'); + const profileCard = document.getElementById('profile-card'); + const createCardSelect = document.getElementById('create-card'); + const createCardGallery = document.getElementById('create-card-gallery'); + const selectedCardDetail = document.getElementById('selected-card-detail'); + const rejoinList = document.getElementById('rejoin-list'); + const statusEl = document.getElementById('status'); + const logoutBtn = document.getElementById('logout-btn'); + const topUserBadge = document.getElementById('top-user-badge'); + const profileColorInput = document.getElementById('profile-color'); + const profileAvatarInput = document.getElementById('profile-avatar-url'); + const profileColorPreview = document.getElementById('profile-color-preview'); + const profileThemeInput = document.getElementById('profile-theme'); + const createNameHint = document.getElementById('create-name-hint'); + const profileUsername = document.getElementById('profile-username'); + const lobbyState = { + cards: [], + selectedCardId: '', + }; + + const setStatus = (text) => { + statusEl.textContent = text || ''; + }; + + const safeCoverUrl = (url) => { + const value = String(url || '').trim(); + if (!value) return ''; + return value.replace(/["\\]/g, '\\$&'); + }; + + const postJson = async (url, payload) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const patchJson = async (url, payload) => { + const res = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const savePlayerRef = (roomId, playerId, playerName) => { + localStorage.setItem(`echo-player-${roomId}`, playerId); + localStorage.setItem('echo-last-name', playerName); + }; + + const redirectToRoom = (data) => { + savePlayerRef(data.room_id, data.player_id, data.player_name); + window.location.href = data.redirect; + }; + + const renderCards = (cards) => { + lobbyState.cards = cards; + createCardSelect.textContent = ''; + cards.forEach((card) => { + const option = document.createElement('option'); + option.value = card.card_id; + option.textContent = `${card.title} · ${card.subtitle || '自定义冒险'}`; + createCardSelect.appendChild(option); + }); + + if (!cards.length) { + if (createCardGallery) createCardGallery.textContent = ''; + if (selectedCardDetail) selectedCardDetail.textContent = '暂无可选游戏卡'; + return; + } + + if (!lobbyState.selectedCardId || !cards.some((card) => card.card_id === lobbyState.selectedCardId)) { + lobbyState.selectedCardId = cards[0].card_id; + } + createCardSelect.value = lobbyState.selectedCardId; + renderCardGallery(); + renderSelectedCardDetail(); + }; + + const renderSelectedCardDetail = () => { + if (!selectedCardDetail) return; + const card = lobbyState.cards.find((row) => row.card_id === lobbyState.selectedCardId); + if (!card) { + selectedCardDetail.textContent = ''; + return; + } + const pack = card.content_pack || {}; + const tags = Array.isArray(pack.genre_tags) ? pack.genre_tags.slice(0, 4) : []; + const features = Array.isArray(pack.feature_badges) ? pack.feature_badges.slice(0, 4) : []; + selectedCardDetail.textContent = ''; + const cover = document.createElement('div'); + cover.className = 'selected-card-cover'; + cover.style.backgroundImage = `url("${safeCoverUrl(card.cover_url)}")`; + selectedCardDetail.appendChild(cover); + + const meta = document.createElement('div'); + meta.className = 'selected-card-meta'; + const title = document.createElement('h3'); + title.textContent = card.title || '未命名游戏卡'; + const sub = document.createElement('p'); + sub.className = 'game-card-sub'; + sub.textContent = card.subtitle || '自定义冒险'; + const summary = document.createElement('p'); + summary.textContent = card.summary || ''; + meta.appendChild(title); + meta.appendChild(sub); + meta.appendChild(summary); + + const tagRow = document.createElement('div'); + tagRow.className = 'tag-row'; + tags.forEach((tag) => { + const span = document.createElement('span'); + span.textContent = tag; + tagRow.appendChild(span); + }); + meta.appendChild(tagRow); + + const featureRow = document.createElement('div'); + featureRow.className = 'tag-row feature'; + features.forEach((feature) => { + const span = document.createElement('span'); + span.textContent = feature; + featureRow.appendChild(span); + }); + meta.appendChild(featureRow); + + selectedCardDetail.appendChild(meta); + }; + + const renderCardGallery = () => { + if (!createCardGallery) return; + createCardGallery.textContent = ''; + lobbyState.cards.forEach((card) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `card-select-item ${card.card_id === lobbyState.selectedCardId ? 'active' : ''}`; + const thumb = document.createElement('span'); + thumb.className = 'thumb'; + thumb.style.backgroundImage = `url("${safeCoverUrl(card.cover_url)}")`; + const title = document.createElement('span'); + title.className = 'title'; + title.textContent = card.title || '未命名游戏卡'; + btn.appendChild(thumb); + btn.appendChild(title); + btn.addEventListener('click', () => { + lobbyState.selectedCardId = card.card_id; + createCardSelect.value = card.card_id; + renderCardGallery(); + renderSelectedCardDetail(); + }); + createCardGallery.appendChild(btn); + }); + }; + + const normalizeColor = (color) => { + const text = String(color || '').trim(); + if (/^#[0-9a-fA-F]{6}$/.test(text)) return text; + return '#0070f2'; + }; + + const normalizeTheme = (theme) => { + const value = String(theme || '').trim().toLowerCase(); + if (['minimal', 'dopamine', 'midnight', 'mint', 'sunset'].includes(value)) return value; + return 'dopamine'; + }; + + const applyColorPreview = (color) => { + const normalized = normalizeColor(color); + if (profileColorInput) profileColorInput.value = normalized; + if (profileColorPreview) profileColorPreview.style.background = normalized; + if (topUserBadge) topUserBadge.style.setProperty('--avatar-color', normalized); + }; + + const applyBadgeAvatar = (avatarUrl) => { + if (!topUserBadge) return; + const safeUrl = String(avatarUrl || '').trim(); + topUserBadge.style.setProperty('--avatar-url', safeUrl ? `url('${safeUrl.replace(/'/g, "\\'")}')` : 'none'); + if (safeUrl) { + topUserBadge.classList.add('has-avatar'); + } else { + topUserBadge.classList.remove('has-avatar'); + } + }; + + const renderRejoinRooms = (rooms) => { + rejoinList.textContent = ''; + if (!rooms.length) { + const li = document.createElement('li'); + li.textContent = '暂无可重连房间'; + rejoinList.appendChild(li); + return; + } + + rooms.forEach((room) => { + const statusLabel = room.status === 'running' + ? '进行中' + : room.status === 'ended' + ? '已结束' + : '准备中'; + const li = document.createElement('li'); + const link = document.createElement('a'); + link.href = `/room/${room.room_id}?player_id=${room.player_id}`; + link.textContent = `${room.room_id} · ${room.card_title} · ${statusLabel}`; + li.appendChild(link); + + if (room.is_host) { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'ghost-btn'; + delBtn.textContent = '删除'; + delBtn.style.width = 'auto'; + delBtn.style.marginLeft = '0.5rem'; + delBtn.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + if (!confirm(`确认删除房间 ${room.room_id} 吗?`)) return; + try { + const res = await fetch(`/api/rooms/${room.room_id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ player_id: room.player_id }), + }); + const data = await res.json(); + if (!data.ok) { + setStatus(`删除失败: ${data.error || '未知错误'}`); + return; + } + setStatus(`房间 ${room.room_id} 已删除。`); + loadBootstrap(); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + li.appendChild(delBtn); + } + rejoinList.appendChild(li); + }); + }; + + const loadBootstrap = async () => { + try { + const [cardsResp, roomsResp] = await Promise.all([ + getJson('/api/cards'), + getJson('/api/me/rooms'), + ]); + if (cardsResp.ok) { + renderCards(cardsResp.cards || []); + } + if (roomsResp.ok) { + renderRejoinRooms(roomsResp.rooms || []); + } + } catch (error) { + setStatus(`加载大厅信息失败: ${error}`); + } + }; + + if (!user) { + profileCard.style.display = 'none'; + if (createNameHint) createNameHint.textContent = '请先登录后创建房间(游戏内统一使用用户名)'; + const createBtn = createForm.querySelector('button[type="submit"]'); + if (createBtn) createBtn.disabled = true; + setStatus('登录后可创建房间;进行中的房间可通过邀请链接直接观战。'); + } else { + if (profileUsername) profileUsername.textContent = user.username || ''; + applyColorPreview(user.avatar_color || '#0070f2'); + if (profileThemeInput) profileThemeInput.value = normalizeTheme(user.theme || 'dopamine'); + if (window.EchoTheme && typeof window.EchoTheme.applyTheme === 'function') { + window.EchoTheme.applyTheme(normalizeTheme(user.theme || 'dopamine')); + } + if (profileAvatarInput) profileAvatarInput.value = user.avatar_url || ''; + applyBadgeAvatar(user.avatar_url || ''); + document.getElementById('profile-bio').value = user.bio || ''; + if (createNameHint) createNameHint.textContent = `将使用用户名「${user.username || '未设置'}」创建房间`; + } + + if (profileColorInput) { + profileColorInput.addEventListener('input', () => { + applyColorPreview(profileColorInput.value); + }); + } + + createForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const cardId = lobbyState.selectedCardId || createCardSelect.value; + if (!user) { + setStatus('请先登录后再创建房间。'); + return; + } + + setStatus('正在创建房间...'); + try { + const data = await postJson('/api/rooms/create', { card_id: cardId }); + if (!data.ok) { + setStatus(`创建失败: ${data.error}`); + return; + } + redirectToRoom(data); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + + joinForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const roomId = document.getElementById('join-room').value.trim().toUpperCase(); + if (!roomId) { + setStatus('请输入房间码'); + return; + } + + setStatus('正在加入房间...'); + + try { + const cachedPlayerId = localStorage.getItem(`echo-player-${roomId}`) || ''; + const data = await postJson('/api/rooms/join', { + room_id: roomId, + player_id: cachedPlayerId, + }); + if (!data.ok) { + setStatus(`加入失败: ${data.error}`); + return; + } + if (data.is_spectator) { + window.location.href = data.redirect; + return; + } + redirectToRoom(data); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + + createCardSelect.addEventListener('change', () => { + lobbyState.selectedCardId = createCardSelect.value; + renderCardGallery(); + renderSelectedCardDetail(); + }); + + if (profileForm) { + profileForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const payload = { + avatar_color: normalizeColor(profileColorInput?.value), + avatar_url: String(profileAvatarInput?.value || '').trim(), + theme: normalizeTheme(profileThemeInput?.value || 'dopamine'), + bio: document.getElementById('profile-bio').value.trim(), + }; + setStatus('正在保存个性化...'); + try { + const data = await patchJson('/api/me/profile', payload); + if (!data.ok) { + setStatus(`保存失败: ${data.error}`); + return; + } + localStorage.setItem('echo-last-name', data.user.username || ''); + applyColorPreview(data.user.avatar_color || payload.avatar_color); + applyBadgeAvatar(data.user.avatar_url || payload.avatar_url || ''); + if (profileThemeInput) profileThemeInput.value = normalizeTheme(data.user.theme || payload.theme); + if (window.EchoTheme && typeof window.EchoTheme.applyTheme === 'function') { + window.EchoTheme.applyTheme(normalizeTheme(data.user.theme || payload.theme)); + } + setStatus('个性化保存成功。'); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + } + + const inviteCode = new URLSearchParams(window.location.search).get('invite'); + if (inviteCode) { + const joinInput = document.getElementById('join-room'); + if (joinInput) joinInput.value = String(inviteCode).trim().toUpperCase(); + setStatus('检测到邀请链接,已填入房间码。'); + } + + if (logoutBtn) { + logoutBtn.addEventListener('click', async () => { + await postJson('/api/auth/logout', {}); + window.location.href = '/'; + }); + } + + loadBootstrap(); +})(); diff --git a/static/js/plaza.js b/static/js/plaza.js new file mode 100644 index 0000000..9362238 --- /dev/null +++ b/static/js/plaza.js @@ -0,0 +1,261 @@ +(function () { + const body = document.body; + const user = (() => { + try { + return JSON.parse(body.dataset.user || 'null'); + } catch { + return null; + } + })(); + + const statusEl = document.getElementById('status'); + const cardsGrid = document.getElementById('cards-grid'); + const publicRoomsList = document.getElementById('public-rooms-list'); + + const setStatus = (text) => { + statusEl.textContent = text || ''; + }; + + const safeCoverUrl = (url) => { + const value = String(url || '').trim(); + if (!value) return ''; + return value.replace(/["\\]/g, '\\$&'); + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const postJson = async (url, payload) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const deleteJson = async (url) => { + const res = await fetch(url, { method: 'DELETE' }); + return res.json(); + }; + + const joinRoom = async (roomId) => { + const cachedPlayerId = localStorage.getItem(`echo-player-${roomId}`) || ''; + const data = await postJson('/api/rooms/join', { + room_id: roomId, + player_id: cachedPlayerId, + }); + if (!data.ok) { + setStatus(`加入失败: ${data.error}`); + return; + } + if (data.is_spectator) { + window.location.href = data.redirect; + return; + } + localStorage.setItem(`echo-player-${data.room_id}`, data.player_id); + localStorage.setItem('echo-last-name', data.player_name); + window.location.href = data.redirect; + }; + + const createRoomFromCard = async (cardId) => { + if (!user) { + setStatus('请先登录账号后再开局。'); + return; + } + setStatus('正在创建房间...'); + const data = await postJson('/api/rooms/create', { card_id: cardId }); + if (!data.ok) { + setStatus(`创建失败: ${data.error}`); + return; + } + localStorage.setItem(`echo-player-${data.room_id}`, data.player_id); + localStorage.setItem('echo-last-name', data.player_name); + window.location.href = data.redirect; + }; + + const renderCards = (cards) => { + cardsGrid.textContent = ''; + cards.forEach((card) => { + const article = document.createElement('article'); + article.className = 'game-card'; + + const cover = document.createElement('div'); + cover.className = 'game-card-cover'; + cover.style.backgroundImage = `url("${safeCoverUrl(card.cover_url)}")`; + + const panel = document.createElement('div'); + panel.className = 'game-card-body'; + + const h3 = document.createElement('h3'); + h3.textContent = card.title || '未命名游戏卡'; + panel.appendChild(h3); + + const sub = document.createElement('p'); + sub.className = 'game-card-sub'; + sub.textContent = card.subtitle || '自定义冒险'; + panel.appendChild(sub); + + const summary = document.createElement('p'); + summary.textContent = card.summary || ''; + panel.appendChild(summary); + + const owner = document.createElement('p'); + owner.className = 'game-card-sub'; + owner.textContent = card.is_official ? '官方卡' : '玩家创作'; + panel.appendChild(owner); + + const pack = card.content_pack || {}; + const tags = Array.isArray(pack.genre_tags) ? pack.genre_tags.slice(0, 5) : []; + const features = Array.isArray(pack.feature_badges) ? pack.feature_badges.slice(0, 5) : []; + + if (tags.length) { + const tagRow = document.createElement('div'); + tagRow.className = 'tag-row'; + tags.forEach((tag) => { + const chip = document.createElement('span'); + chip.textContent = tag; + tagRow.appendChild(chip); + }); + panel.appendChild(tagRow); + } + + if (features.length) { + const featureRow = document.createElement('div'); + featureRow.className = 'tag-row feature'; + features.forEach((tag) => { + const chip = document.createElement('span'); + chip.textContent = tag; + featureRow.appendChild(chip); + }); + panel.appendChild(featureRow); + } + + const goals = document.createElement('ul'); + goals.className = 'compact-list'; + (card.starter_goals || []).slice(0, 3).forEach((goal) => { + const li = document.createElement('li'); + li.textContent = goal; + goals.appendChild(li); + }); + if (goals.childNodes.length) { + panel.appendChild(goals); + } + + const actionsRow = document.createElement('div'); + actionsRow.className = 'card-actions-row'; + + const startBtn = document.createElement('button'); + startBtn.type = 'button'; + startBtn.textContent = '用此卡开一局'; + startBtn.addEventListener('click', () => { + createRoomFromCard(card.card_id).catch((error) => setStatus(`网络错误: ${error}`)); + }); + actionsRow.appendChild(startBtn); + + const isOwner = Boolean(user && String(card.creator_user_id || '') === String(user.user_id || '')); + const isAdmin = Boolean(user && String(user.role || '') === 'admin'); + const canEdit = Boolean(user && (isOwner || isAdmin)); + const canDelete = Boolean(user && !card.is_official && isOwner); + + if (canEdit) { + const editBtn = document.createElement('a'); + editBtn.className = 'link-btn'; + editBtn.href = `/cards/${card.card_id}/edit`; + editBtn.textContent = '编辑游戏卡'; + actionsRow.appendChild(editBtn); + } + + if (canDelete) { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'ghost-btn'; + delBtn.textContent = '删除'; + delBtn.addEventListener('click', async () => { + if (!confirm(`确认删除游戏卡「${card.title}」吗?`)) return; + setStatus('正在删除游戏卡...'); + try { + const data = await deleteJson(`/api/cards/${card.card_id}`); + if (!data.ok) { + setStatus(`删除失败: ${data.error || '未知错误'}`); + return; + } + setStatus('删除成功。'); + loadCards(); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + actionsRow.appendChild(delBtn); + } + + panel.appendChild(actionsRow); + article.appendChild(cover); + article.appendChild(panel); + cardsGrid.appendChild(article); + }); + }; + + const renderPublicRooms = (rooms) => { + if (!publicRoomsList) return; + publicRoomsList.textContent = ''; + if (!rooms.length) { + const li = document.createElement('li'); + li.textContent = '当前暂无公开房间'; + publicRoomsList.appendChild(li); + return; + } + + rooms.forEach((room) => { + const li = document.createElement('li'); + const status = room.status === 'running' ? '进行中' : '准备中'; + const title = room.card_title || '未知剧本'; + const host = room.host_name || '未知房主'; + li.textContent = `${room.room_id} · ${title} · ${status} · 房主:${host} · 在线:${room.online_count}/${room.player_count}`; + + const actionBtn = document.createElement('button'); + actionBtn.type = 'button'; + actionBtn.className = 'secondary-btn'; + actionBtn.style.width = 'auto'; + actionBtn.style.marginTop = '0.4rem'; + actionBtn.textContent = room.status === 'running' ? '观战/重连' : '加入房间'; + actionBtn.addEventListener('click', () => { + joinRoom(room.room_id).catch((error) => setStatus(`网络错误: ${error}`)); + }); + li.appendChild(actionBtn); + publicRoomsList.appendChild(li); + }); + }; + + const loadCards = async () => { + try { + const data = await getJson('/api/cards'); + if (!data.ok) { + setStatus(data.error || '加载失败'); + return; + } + renderCards(data.cards || []); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }; + + const loadPublicRooms = async () => { + try { + const data = await getJson('/api/rooms/public?limit=40'); + if (!data.ok) { + setStatus(data.error || '加载联机广场失败'); + return; + } + renderPublicRooms(data.rooms || []); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }; + + loadCards(); + loadPublicRooms(); + window.setInterval(loadPublicRooms, 12000); +})(); diff --git a/static/js/room.js b/static/js/room.js new file mode 100644 index 0000000..6be6b53 --- /dev/null +++ b/static/js/room.js @@ -0,0 +1,1221 @@ +(function () { + const body = document.body; + const roomId = body.dataset.roomId; + const playerId = body.dataset.playerId; + const playerName = body.dataset.playerName; + const isSpectator = body.dataset.spectator === '1'; + + const sceneHero = document.getElementById('scene-hero'); + const sceneTitle = document.getElementById('scene-title'); + const sceneDescription = document.getElementById('scene-description'); + const heroStatusList = document.getElementById('hero-status-list'); + + const storyLog = document.getElementById('story-log'); + const chatLog = document.getElementById('chat-log'); + const playersList = document.getElementById('players-list'); + const statusPanel = document.getElementById('status-panel'); + const cluesList = document.getElementById('clues-list'); + const cluesToggleBtn = document.getElementById('clues-toggle-btn'); + const inventoryList = document.getElementById('inventory-list'); + const allStatusList = document.getElementById('all-status-list'); + const allInventoryList = document.getElementById('all-inventory-list'); + const pendingActions = document.getElementById('pending-actions'); + const privateList = document.getElementById('private-list'); + const choiceChips = document.getElementById('choice-chips'); + + const cluesTitle = document.getElementById('clues-title'); + const inventoryTitle = document.getElementById('inventory-title'); + const choicesTitle = document.getElementById('choices-title'); + + const endingOverlay = document.getElementById('ending-overlay'); + const endingTitle = document.getElementById('ending-title'); + const endingSummary = document.getElementById('ending-summary'); + + const readyBtn = document.getElementById('ready-btn'); + const startBtn = document.getElementById('start-btn'); + const deleteRoomBtn = document.getElementById('delete-room-btn'); + const publicBtn = document.getElementById('public-btn'); + const inviteBtn = document.getElementById('invite-btn'); + const inviteModal = document.getElementById('invite-modal'); + const inviteCloseBtn = document.getElementById('invite-close-btn'); + const inviteJoinLink = document.getElementById('invite-join-link'); + const inviteSpectateLink = document.getElementById('invite-spectate-link'); + const inviteJoinQr = document.getElementById('invite-join-qr'); + const inviteSpectateQr = document.getElementById('invite-spectate-qr'); + const inviteCopyJoinBtn = document.getElementById('invite-copy-join-btn'); + const inviteCopySpectateBtn = document.getElementById('invite-copy-spectate-btn'); + const inviteOpenJoinBtn = document.getElementById('invite-open-join-btn'); + const inviteOpenSpectateBtn = document.getElementById('invite-open-spectate-btn'); + + const actionForm = document.getElementById('action-form'); + const actionInput = document.getElementById('action-input'); + const actionSubmitBtn = actionForm.querySelector('button[type="submit"]'); + const chatForm = document.getElementById('chat-form'); + const chatInput = document.getElementById('chat-input'); + const chatSubmitBtn = chatForm.querySelector('button[type="submit"]'); + + const localState = { + snapshot: null, + privateMessages: [], + autoResolveTimer: null, + actionLocked: false, + lastSubmittedRound: null, + invitePayload: null, + inviteLoading: false, + forcedEnding: null, + cluesCollapsed: true, + }; + + const PHASE_LABELS = { + lobby: '大厅阶段', + running: '进行中', + ended: '已结束', + }; + + const DEFAULT_SCENE_LABELS = { + lobby: '大厅', + dining_room: '餐厅', + corridor: '走廊', + guest_room: '客房', + basement: '地下室', + opening: '开场', + }; + + const ENDING_LABELS = { + escaped: '成功逃离', + sacrifice: '找到真相但有人牺牲', + trapped: '被旅店困住', + pact: '与隐藏势力合作', + custom_end: '本局结局', + }; + + const ITEM_LABELS = { + bloodied_key: '染血钥匙', + torn_manifest: '残破船务清单', + silver_bell: '银铃', + maid_diary: '女仆日记', + cellar_sigil: '地下室印记', + missing_guest_note: '失踪者便条', + }; + + const NPC_NAME_LABELS = { + innkeeper: '旅店老板', + maid: '女仆', + silent_traveler: '沉默旅客', + }; + + let socket = null; + + if (!isSpectator && playerId) { + localStorage.setItem(`echo-player-${roomId}`, playerId); + localStorage.setItem('echo-last-name', playerName); + } + + const localizeStoryText = (text) => { + let output = String(text || ''); + output = output.replace(/\bsilent_traveler\b/gi, NPC_NAME_LABELS.silent_traveler); + output = output.replace(/\bsilent traveler\b/gi, NPC_NAME_LABELS.silent_traveler); + output = output.replace(/\binnkeeper\b/gi, NPC_NAME_LABELS.innkeeper); + output = output.replace(/\bmaid\b/gi, NPC_NAME_LABELS.maid); + return output; + }; + + const getLabel = (value, mapping) => { + if (!value) return '-'; + return mapping[value] || value; + }; + + const getItemLabel = (value) => { + if (!value) return '-'; + return ITEM_LABELS[value] || value; + }; + + const createMsg = (kind, text) => { + const div = document.createElement('div'); + div.className = `msg ${kind}`; + div.textContent = localizeStoryText(text); + return div; + }; + + const appendSystem = (text) => { + if (!text) return; + chatLog.appendChild(createMsg('system', text)); + chatLog.scrollTop = chatLog.scrollHeight; + }; + + const fallbackCopyByExecCommand = (text) => { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch { + ok = false; + } + document.body.removeChild(textarea); + return ok; + }; + + const copyInviteText = async (text) => { + if (!text) return false; + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // continue fallback + } + } + if (fallbackCopyByExecCommand(text)) { + return true; + } + return false; + }; + + const qrUrl = (text) => { + return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=10&data=${encodeURIComponent(text || '')}`; + }; + + const setInviteLoading = (loading) => { + localState.inviteLoading = !!loading; + if (!inviteBtn) return; + inviteBtn.disabled = !!loading; + inviteBtn.textContent = loading ? '生成中...' : '邀请链接'; + }; + + const closeInviteModal = () => { + if (!inviteModal) return; + inviteModal.classList.add('hidden'); + inviteModal.setAttribute('aria-hidden', 'true'); + }; + + const openInviteModal = () => { + if (!inviteModal) return; + inviteModal.classList.remove('hidden'); + inviteModal.setAttribute('aria-hidden', 'false'); + }; + + const renderInvitePanel = (payload) => { + localState.invitePayload = payload || null; + const joinLink = String(payload?.join_link || ''); + const spectateLink = String(payload?.spectate_link || ''); + + if (inviteJoinLink) inviteJoinLink.textContent = joinLink || '暂无'; + if (inviteSpectateLink) inviteSpectateLink.textContent = spectateLink || '暂无'; + + if (inviteOpenJoinBtn) inviteOpenJoinBtn.href = joinLink || '#'; + if (inviteOpenSpectateBtn) inviteOpenSpectateBtn.href = spectateLink || '#'; + if (inviteOpenJoinBtn) inviteOpenJoinBtn.classList.toggle('disabled', !joinLink); + if (inviteOpenSpectateBtn) inviteOpenSpectateBtn.classList.toggle('disabled', !spectateLink); + + if (inviteCopyJoinBtn) inviteCopyJoinBtn.disabled = !joinLink; + if (inviteCopySpectateBtn) inviteCopySpectateBtn.disabled = !spectateLink; + + if (inviteJoinQr) { + inviteJoinQr.src = joinLink ? qrUrl(joinLink) : ''; + inviteJoinQr.style.visibility = joinLink ? 'visible' : 'hidden'; + } + if (inviteSpectateQr) { + inviteSpectateQr.src = spectateLink ? qrUrl(spectateLink) : ''; + inviteSpectateQr.style.visibility = spectateLink ? 'visible' : 'hidden'; + } + }; + + const currentSchema = () => { + const snapshot = localState.snapshot || {}; + const cardSchema = snapshot?.game_card?.state_schema || {}; + return { + labels: cardSchema.labels || {}, + metrics: Array.isArray(cardSchema.metrics) ? cardSchema.metrics : [], + sceneLabels: cardSchema.scene_labels || {}, + dataScope: cardSchema.data_scope || {}, + visibility: cardSchema.visibility || {}, + ui: cardSchema.ui || {}, + }; + }; + + const normalizeScope = (schema) => { + const raw = schema?.dataScope || {}; + const out = { + metrics: String(raw.metrics || 'shared').toLowerCase(), + inventory: String(raw.inventory || 'per_player').toLowerCase(), + clues: String(raw.clues || 'shared').toLowerCase(), + }; + if (!['shared', 'per_player'].includes(out.metrics)) out.metrics = 'shared'; + if (!['shared', 'per_player'].includes(out.inventory)) out.inventory = 'per_player'; + if (!['shared', 'per_player'].includes(out.clues)) out.clues = 'shared'; + return out; + }; + + const normalizeVisibility = (schema) => { + const raw = schema?.visibility || {}; + return { + showAllPlayerMetrics: raw.show_all_player_metrics !== false, + showAllPlayerInventory: raw.show_all_player_inventory !== false, + }; + }; + + const metricValue = (key, gameState, forPlayerId = null) => { + if (typeof gameState?.[key] === 'number') return gameState[key]; + const scope = normalizeScope(currentSchema()); + if (scope.metrics === 'per_player') { + const playerValues = gameState?.global_flags?.metric_values_by_player || {}; + const targetValues = playerValues[forPlayerId || playerId] || {}; + const v = targetValues[key]; + if (typeof v === 'number') return v; + } + const sharedValues = gameState?.global_flags?.metric_values || {}; + const sharedVal = sharedValues[key]; + return typeof sharedVal === 'number' ? sharedVal : null; + }; + + const statusTrackConfigs = (schema, gameState) => { + const ui = schema.ui || {}; + const metrics = schema.metrics || []; + const metricMap = {}; + metrics.forEach((metric) => { + const key = String(metric.key || '').trim(); + if (!key) return; + metricMap[key] = metric; + }); + + const out = []; + const uiTracks = Array.isArray(ui.status_tracks) ? ui.status_tracks : []; + uiTracks.forEach((track) => { + if (!track || typeof track !== 'object') return; + const key = String(track.key || '').trim(); + if (!key) return; + const metric = metricMap[key] || {}; + const value = metricValue(key, gameState, playerId); + if (value === null) return; + const min = Number(track.min ?? metric.min ?? 0) || 0; + const max = Number(track.max ?? metric.max ?? 10) || 10; + const label = String(track.label || metric.label || key); + const color = /^#[0-9a-fA-F]{6}$/.test(String(track.color || metric.color || '')) + ? (track.color || metric.color) + : '#ffb500'; + out.push({ key, value, min, max: Math.max(min, max), label, color }); + }); + + if (out.length) return out; + + const uiStatus = ui.hero_status && typeof ui.hero_status === 'object' ? ui.hero_status : {}; + const uiKey = String(uiStatus.key || '').trim(); + if (uiKey) { + const v = metricValue(uiKey, gameState, playerId); + if (v !== null) { + return [{ + key: uiKey, + value: v, + min: Number(uiStatus.min || 0) || 0, + max: Number(uiStatus.max || 10) || 10, + label: String(uiStatus.label || uiKey), + color: /^#[0-9a-fA-F]{6}$/.test(String(uiStatus.color || '')) ? uiStatus.color : '#ffb500', + }]; + } + } + + for (const metric of metrics) { + const key = String(metric.key || '').trim(); + if (!key) continue; + const v = metricValue(key, gameState, playerId); + if (v === null) continue; + return [{ + key, + value: v, + min: Number(metric.min || 0) || 0, + max: Number(metric.max || 10) || 10, + label: String(metric.label || key), + color: /^#[0-9a-fA-F]{6}$/.test(String(metric.color || '')) ? metric.color : '#ffb500', + }]; + } + + return [{ + key: 'danger_level', + value: Number(gameState?.danger_level || 0), + min: 0, + max: 10, + label: '危险值', + color: '#ffb500', + }]; + }; + + const getMyPending = (snapshot) => { + const rows = snapshot?.pending_actions || []; + return rows.some((row) => row.player_id === playerId); + }; + + const renderSectionLabels = () => { + const schema = currentSchema(); + const labels = schema.labels; + const scope = normalizeScope(schema); + cluesTitle.textContent = labels.clues || '线索墙'; + inventoryTitle.textContent = scope.inventory === 'shared' ? `共享${labels.inventory || '背包'}` : (labels.inventory || '我的背包'); + choicesTitle.textContent = labels.choices || 'AI 快速行动'; + }; + + const applyCardTheme = () => { + const snapshot = localState.snapshot; + if (!snapshot) return; + const card = snapshot.game_card || {}; + const schema = currentSchema(); + const ui = schema.ui || {}; + const bg = String(ui.room_background_url || card.cover_url || '/static/img/cards/fogharbor.svg'); + body.style.setProperty('--room-bg-image', `url("${bg}")`); + sceneHero.style.setProperty('--scene-bg-image', `url("${bg}")`); + }; + + const renderSceneBoard = () => { + const snapshot = localState.snapshot; + if (!snapshot) return; + const gameState = snapshot.game_state || {}; + const card = snapshot.game_card || {}; + const schema = currentSchema(); + const sceneLabels = { ...DEFAULT_SCENE_LABELS, ...schema.sceneLabels }; + + const sceneLabel = getLabel(gameState.scene_id, sceneLabels); + const sceneCaption = schema.labels.scene || '场景'; + sceneTitle.textContent = `${card.title || '未知剧本'} · ${sceneCaption}${sceneLabel !== '-' ? `:${sceneLabel}` : ''}`; + sceneDescription.textContent = localizeStoryText(gameState.scene_description || '等待剧情描述...'); + + const tracks = statusTrackConfigs(schema, gameState); + heroStatusList.textContent = ''; + tracks.forEach((track, index) => { + const row = document.createElement('div'); + row.className = 'hero-status-track'; + + const head = document.createElement('div'); + head.className = 'hero-status-head'; + + const label = document.createElement('span'); + label.textContent = track.label; + const value = document.createElement('span'); + value.textContent = `${track.value} / ${track.max}`; + head.appendChild(label); + head.appendChild(value); + + const bg = document.createElement('div'); + bg.className = 'hero-status-bar-bg'; + const bar = document.createElement('div'); + bar.className = 'hero-status-bar'; + const span = Math.max(1, track.max - track.min); + const percent = Math.max(0, Math.min(100, Math.round(((track.value - track.min) / span) * 100))); + bar.style.width = `${percent}%`; + bar.style.background = track.color; + bg.appendChild(bar); + + row.appendChild(head); + row.appendChild(bg); + heroStatusList.appendChild(row); + + if (index === 0) { + body.style.setProperty('--hero-status-color', track.color); + } + }); + }; + + const renderLogs = () => { + storyLog.textContent = ''; + chatLog.textContent = ''; + if (!localState.snapshot) return; + + const messages = localState.snapshot.messages || []; + let hasStory = false; + messages.forEach((row) => { + if (row.visibility === 'private' && row.player_id !== playerId) { + return; + } + const kind = row.kind || 'system'; + const text = row.content || ''; + const isStoryKind = ['ai', 'npc', 'action'].includes(kind) || (kind === 'system' && text.includes('触发:')); + if (isStoryKind) { + storyLog.appendChild(createMsg(kind, text)); + hasStory = true; + } else { + chatLog.appendChild(createMsg(kind, text)); + } + }); + + if (!hasStory) { + const sceneText = localState.snapshot?.game_state?.scene_description || ''; + if (sceneText) { + storyLog.appendChild(createMsg('ai', sceneText)); + } + } + + storyLog.scrollTop = storyLog.scrollHeight; + chatLog.scrollTop = chatLog.scrollHeight; + }; + + const renderPlayers = () => { + playersList.textContent = ''; + const players = localState.snapshot?.players || []; + const me = players.find((p) => p.player_id === playerId); + const canManage = Boolean(me?.is_host && localState.snapshot?.status === 'lobby'); + players.forEach((p) => { + const li = document.createElement('li'); + li.className = `player-row ${p.is_host ? 'player-host ' : ''}${!p.is_online ? 'player-offline' : ''}`.trim(); + + const profile = document.createElement('div'); + profile.className = 'player-profile'; + if (p.avatar_url) { + const img = document.createElement('img'); + img.src = p.avatar_url; + img.alt = p.name; + profile.appendChild(img); + } else { + const dot = document.createElement('span'); + dot.className = 'player-dot'; + dot.style.background = p.avatar_color || '#0070f2'; + profile.appendChild(dot); + } + const name = document.createElement('span'); + name.textContent = p.name; + profile.appendChild(name); + li.appendChild(profile); + + if (p.is_ready) { + const badge = document.createElement('span'); + badge.className = 'badge ready'; + badge.textContent = '准备'; + li.appendChild(badge); + } + if (!p.is_online) { + const badge = document.createElement('span'); + badge.className = 'badge danger'; + badge.textContent = '离线'; + li.appendChild(badge); + } + + if (canManage && !p.is_host) { + const ops = document.createElement('div'); + ops.className = 'player-ops'; + + const transferBtn = document.createElement('button'); + transferBtn.type = 'button'; + transferBtn.className = 'ghost-btn slim'; + transferBtn.textContent = '转交房主'; + transferBtn.addEventListener('click', () => { + if (!confirm(`确认将房主转交给 ${p.name} 吗?`)) return; + emitSafe('transfer_host', { room_id: roomId, player_id: playerId, target_player_id: p.player_id }); + }); + + const kickBtn = document.createElement('button'); + kickBtn.type = 'button'; + kickBtn.className = 'ghost-btn slim'; + kickBtn.textContent = '踢出'; + kickBtn.addEventListener('click', () => { + if (!confirm(`确认踢出 ${p.name} 吗?`)) return; + emitSafe('kick_player', { room_id: roomId, player_id: playerId, target_player_id: p.player_id }); + }); + + ops.appendChild(transferBtn); + ops.appendChild(kickBtn); + li.appendChild(ops); + } + playersList.appendChild(li); + }); + }; + + const renderStatus = () => { + statusPanel.textContent = ''; + const gameState = localState.snapshot?.game_state || {}; + const schema = currentSchema(); + const ending = getLabel(gameState.ending_state, ENDING_LABELS); + const sceneLabels = { ...DEFAULT_SCENE_LABELS, ...schema.sceneLabels }; + + const rows = [ + `阶段:${getLabel(gameState.game_phase, PHASE_LABELS)}`, + `场景:${getLabel(gameState.scene_id, sceneLabels)}`, + `公开:${localState.snapshot?.is_public ? '是' : '否'}`, + `回合:${gameState.round_no ?? 0}`, + `结局:${ending === '-' ? '未达成' : ending}`, + ]; + + const metrics = schema.metrics; + const scope = normalizeScope(schema); + if (metrics.length > 0) { + metrics.forEach((metric) => { + const key = String(metric.key || '').trim(); + if (!key) return; + const val = metricValue(key, gameState, playerId); + const max = Number(metric.max || 0); + const label = String(metric.label || key); + if (val !== null) { + rows.push(`${label}:${max > 0 ? `${val} / ${max}` : val}`); + } + }); + if (scope.metrics === 'per_player') { + rows.push('状态模式:按玩家独立'); + } + } else { + rows.push(`章节:第 ${gameState.chapter ?? '-'} 章`); + } + + rows.forEach((text) => { + const div = document.createElement('div'); + div.textContent = text; + statusPanel.appendChild(div); + }); + }; + + const renderClues = () => { + cluesList.textContent = ''; + const schema = currentSchema(); + const scope = normalizeScope(schema); + let clues = localState.snapshot?.game_state?.discovered_clues || []; + if (scope.clues === 'per_player') { + const byPlayer = localState.snapshot?.game_state?.global_flags?.clues_by_player || {}; + clues = byPlayer[playerId] || []; + } + if (!clues.length) { + const li = document.createElement('li'); + li.textContent = '暂无线索'; + cluesList.appendChild(li); + syncCluesCollapse(); + return; + } + clues.forEach((item) => { + const li = document.createElement('li'); + li.textContent = getItemLabel(item); + cluesList.appendChild(li); + }); + syncCluesCollapse(); + }; + + const syncCluesCollapse = () => { + if (!cluesList || !cluesToggleBtn) return; + const clueCount = cluesList.children.length; + const canCollapse = clueCount > 8; + cluesToggleBtn.classList.toggle('hidden', !canCollapse); + cluesList.classList.toggle('is-collapsed', canCollapse && localState.cluesCollapsed); + cluesToggleBtn.textContent = localState.cluesCollapsed ? '展开线索' : '收起线索'; + }; + + const renderInventory = () => { + inventoryList.textContent = ''; + const scope = normalizeScope(currentSchema()); + const gameState = localState.snapshot?.game_state || {}; + if (scope.inventory === 'shared') { + const shared = gameState?.global_flags?.shared_inventory || []; + if (!shared.length) { + const li = document.createElement('li'); + li.textContent = '共享背包为空'; + inventoryList.appendChild(li); + return; + } + shared.forEach((item) => { + const li = document.createElement('li'); + li.textContent = getItemLabel(item); + inventoryList.appendChild(li); + }); + return; + } + const allInventory = localState.snapshot?.game_state?.inventory_by_player || {}; + const inventory = allInventory[playerId] || []; + if (!inventory.length) { + const li = document.createElement('li'); + li.textContent = '背包为空'; + inventoryList.appendChild(li); + return; + } + inventory.forEach((item) => { + const li = document.createElement('li'); + li.textContent = getItemLabel(item); + inventoryList.appendChild(li); + }); + }; + + const renderAllStatuses = () => { + allStatusList.textContent = ''; + const snapshot = localState.snapshot || {}; + const schema = currentSchema(); + const visibility = normalizeVisibility(schema); + if (!visibility.showAllPlayerMetrics) { + const li = document.createElement('li'); + li.textContent = '此游戏卡未开启全员状态展示'; + allStatusList.appendChild(li); + return; + } + const metrics = schema.metrics || []; + const players = snapshot.players || []; + if (!players.length || !metrics.length) { + const li = document.createElement('li'); + li.textContent = '暂无状态信息'; + allStatusList.appendChild(li); + return; + } + players.forEach((player) => { + const parts = []; + metrics.forEach((metric) => { + const key = String(metric.key || '').trim(); + if (!key) return; + const val = metricValue(key, snapshot.game_state || {}, player.player_id); + if (val === null) return; + const label = String(metric.label || key); + const max = Number(metric.max || 0); + parts.push(`${label}${max > 0 ? ` ${val}/${max}` : ` ${val}`}`); + }); + const li = document.createElement('li'); + li.textContent = `${player.name}:${parts.length ? parts.join(' · ') : '暂无'}`; + allStatusList.appendChild(li); + }); + }; + + const renderAllInventory = () => { + allInventoryList.textContent = ''; + const snapshot = localState.snapshot || {}; + const schema = currentSchema(); + const visibility = normalizeVisibility(schema); + if (!visibility.showAllPlayerInventory) { + const li = document.createElement('li'); + li.textContent = '此游戏卡未开启全员背包展示'; + allInventoryList.appendChild(li); + return; + } + const scope = normalizeScope(schema); + if (scope.inventory === 'shared') { + const shared = snapshot?.game_state?.global_flags?.shared_inventory || []; + const li = document.createElement('li'); + li.textContent = `共享背包:${shared.length ? shared.map(getItemLabel).join('、') : '为空'}`; + allInventoryList.appendChild(li); + return; + } + + const players = snapshot.players || []; + const allInventory = snapshot?.game_state?.inventory_by_player || {}; + if (!players.length) { + const li = document.createElement('li'); + li.textContent = '暂无背包信息'; + allInventoryList.appendChild(li); + return; + } + players.forEach((player) => { + const items = allInventory[player.player_id] || []; + const li = document.createElement('li'); + li.textContent = `${player.name}:${items.length ? items.map(getItemLabel).join('、') : '空'}`; + allInventoryList.appendChild(li); + }); + }; + + const renderPending = () => { + pendingActions.textContent = ''; + const rows = localState.snapshot?.pending_actions || []; + const resolvingRound = Boolean(localState.snapshot?.is_resolving_round); + if (resolvingRound && !rows.length) { + const li = document.createElement('li'); + li.textContent = '本轮正在结算中...'; + pendingActions.appendChild(li); + return; + } + if (!rows.length) { + const li = document.createElement('li'); + li.textContent = '暂无待处理行动'; + pendingActions.appendChild(li); + return; + } + rows.forEach((row) => { + const li = document.createElement('li'); + li.textContent = `${row.player_name}: ${row.content}`; + pendingActions.appendChild(li); + }); + }; + + const trySubmitAction = (content) => { + const text = String(content || '').trim(); + if (!text) return false; + const snapshot = localState.snapshot || {}; + const running = snapshot.status === 'running' && snapshot.game_state?.game_phase === 'running'; + if (!running) { + appendSystem('请等待房主开始游戏。'); + return false; + } + if (localState.actionLocked || getMyPending(snapshot)) { + appendSystem('你已提交本轮行动,请等待本轮AI结算。'); + return false; + } + if (emitSafe('submit_action', { room_id: roomId, player_id: playerId, content: text })) { + localState.actionLocked = true; + localState.lastSubmittedRound = Number(snapshot.game_state?.round_no || 0) + 1; + renderAll(); + scheduleResolveFallback(); + return true; + } + return false; + }; + + const renderQuickChoices = () => { + choiceChips.textContent = ''; + const rows = localState.snapshot?.game_state?.pending_choices || []; + + if (!rows.length) { + const hint = document.createElement('div'); + hint.className = 'quick-hint'; + hint.textContent = '等待 AI 生成下一批可执行行动...'; + choiceChips.appendChild(hint); + return; + } + + rows.forEach((row) => { + const chip = document.createElement('button'); + chip.type = 'button'; + chip.className = 'choice-chip'; + chip.textContent = row; + chip.addEventListener('click', () => { + trySubmitAction(row); + }); + choiceChips.appendChild(chip); + }); + }; + + const renderPrivate = () => { + privateList.textContent = ''; + if (!localState.privateMessages.length) { + const li = document.createElement('li'); + li.textContent = '暂无私密信息'; + privateList.appendChild(li); + return; + } + localState.privateMessages.slice(-20).forEach((text) => { + const li = document.createElement('li'); + li.textContent = text; + privateList.appendChild(li); + }); + }; + + const syncActionLock = () => { + const snapshot = localState.snapshot || {}; + const ended = snapshot.game_state?.game_phase === 'ended' || snapshot.status === 'ended'; + const running = snapshot.status === 'running' && snapshot.game_state?.game_phase === 'running'; + const resolvingRound = Boolean(snapshot.is_resolving_round); + const myPending = getMyPending(snapshot); + const blocked = isSpectator || ended || !running || resolvingRound || myPending || localState.actionLocked; + + actionInput.disabled = blocked; + if (actionSubmitBtn) actionSubmitBtn.disabled = blocked; + const chatBlocked = ended || isSpectator; + chatInput.disabled = chatBlocked; + if (chatSubmitBtn) chatSubmitBtn.disabled = chatBlocked; + + const quickButtons = choiceChips.querySelectorAll('button'); + quickButtons.forEach((btn) => { + btn.disabled = blocked; + }); + + if (ended) { + actionInput.placeholder = '本局已结束'; + chatInput.placeholder = '本局已结束'; + } else if (isSpectator) { + actionInput.placeholder = '观战模式不可提交行动'; + chatInput.placeholder = '观战模式不可发送讨论'; + } else if (!running) { + actionInput.placeholder = '请等待房主开始游戏'; + chatInput.placeholder = '例如:我问老板,你搜柜台'; + } else if (myPending || localState.actionLocked) { + actionInput.placeholder = '你已提交本轮行动,等待AI结算...'; + chatInput.placeholder = '讨论可继续发送'; + } else { + actionInput.placeholder = '例如:检查登记台抽屉里的账本'; + chatInput.placeholder = '例如:我问老板,你搜柜台'; + } + }; + + const renderEnding = () => { + const snapshot = localState.snapshot; + if (!snapshot) return; + const endedBySnapshot = snapshot.game_state?.game_phase === 'ended' || snapshot.status === 'ended'; + const forced = localState.forcedEnding || {}; + const endedByForced = Boolean(forced.ending_state || forced.ending_summary); + const ended = endedBySnapshot || endedByForced; + chatInput.disabled = ended || isSpectator; + if (chatSubmitBtn) chatSubmitBtn.disabled = ended || isSpectator; + if (!ended) { + endingOverlay.classList.add('hidden'); + return; + } + const endingState = snapshot.game_state?.ending_state || forced.ending_state; + endingTitle.textContent = `游戏结束:${getLabel(endingState, ENDING_LABELS)}`; + + const fromRound = (snapshot.game_state?.action_history || []).slice(-1)[0] || {}; + const endingText = forced.ending_summary + || snapshot.game_state?.global_flags?.ending_summary + || fromRound.narration + || snapshot.game_state?.scene_description + || '本局已结束。'; + endingSummary.textContent = localizeStoryText(endingText); + endingOverlay.classList.remove('hidden'); + }; + + const syncButtons = () => { + const snapshot = localState.snapshot; + if (!snapshot) return; + const me = (snapshot.players || []).find((p) => p.player_id === playerId); + const running = snapshot.status === 'running' && snapshot.game_state?.game_phase === 'running'; + const ended = snapshot.status === 'ended' || snapshot.game_state?.game_phase === 'ended'; + const isPublicRoom = Boolean(snapshot.is_public); + + readyBtn.textContent = me?.is_ready ? '取消准备' : '准备'; + readyBtn.disabled = isSpectator || running || ended; + + startBtn.textContent = running ? '游戏进行中' : ended ? '本局已结束' : '房主开始'; + startBtn.disabled = isSpectator || !me?.is_host || running || ended; + + deleteRoomBtn.disabled = isSpectator || !me?.is_host; + if (publicBtn) { + publicBtn.textContent = isPublicRoom ? '取消公开' : '公开房间'; + publicBtn.disabled = isSpectator || !me?.is_host; + } + }; + + const renderAll = () => { + renderSectionLabels(); + applyCardTheme(); + renderSceneBoard(); + renderLogs(); + renderPlayers(); + renderStatus(); + renderClues(); + renderInventory(); + renderAllStatuses(); + renderAllInventory(); + renderPending(); + renderQuickChoices(); + renderPrivate(); + syncButtons(); + syncActionLock(); + renderEnding(); + }; + + const loadSnapshotOnce = async () => { + try { + const res = await fetch(`/api/rooms/${roomId}/snapshot`); + const data = await res.json(); + if (data.ok) { + localState.snapshot = data.snapshot; + renderAll(); + } + } catch (error) { + appendSystem(`加载快照失败: ${error}`); + } + }; + + const emitSafe = (event, payload) => { + if (!socket || !socket.connected) { + appendSystem('连接未建立,操作未发送。'); + return false; + } + if (localState.snapshot?.game_state?.game_phase === 'ended' && event !== 'request_snapshot') { + appendSystem('本局已结束,请返回大厅开始新游戏。'); + return false; + } + socket.emit(event, payload); + return true; + }; + + const scheduleResolveFallback = () => { + if (localState.autoResolveTimer) { + clearTimeout(localState.autoResolveTimer); + } + localState.autoResolveTimer = window.setTimeout(() => { + const pendingCount = (localState.snapshot?.pending_actions || []).length; + const running = localState.snapshot?.game_state?.game_phase === 'running'; + const resolvingRound = Boolean(localState.snapshot?.is_resolving_round); + if (!running || pendingCount < 1 || resolvingRound) return; + emitSafe('resolve_round', { room_id: roomId, player_id: playerId }); + }, 2200); + }; + + const bindPreventSubmit = (form) => { + if (!form) return; + form.addEventListener('submit', (event) => { + event.preventDefault(); + }); + }; + + bindPreventSubmit(actionForm); + bindPreventSubmit(chatForm); + + if (cluesToggleBtn) { + cluesToggleBtn.addEventListener('click', () => { + localState.cluesCollapsed = !localState.cluesCollapsed; + syncCluesCollapse(); + }); + } + + readyBtn.addEventListener('click', () => { + if (isSpectator) { + appendSystem('观战模式不可操作。'); + return; + } + const me = (localState.snapshot?.players || []).find((p) => p.player_id === playerId); + const nextReady = !(me?.is_ready); + emitSafe('toggle_ready', { room_id: roomId, player_id: playerId, ready: nextReady }); + }); + + startBtn.addEventListener('click', () => { + if (isSpectator) { + appendSystem('观战模式不可操作。'); + return; + } + emitSafe('start_game', { room_id: roomId, player_id: playerId }); + }); + + deleteRoomBtn.addEventListener('click', async () => { + if (isSpectator) { + appendSystem('观战模式不可操作。'); + return; + } + if (!confirm('确认删除这个房间吗?删除后该局记录会被清空。')) { + return; + } + try { + const res = await fetch(`/api/rooms/${roomId}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ player_id: playerId }), + }); + const data = await res.json(); + if (!data.ok) { + appendSystem(data.error || '删除失败。'); + return; + } + window.location.href = '/'; + } catch (error) { + appendSystem(`删除失败: ${error}`); + } + }); + + if (publicBtn) { + publicBtn.addEventListener('click', async () => { + if (isSpectator) { + appendSystem('观战模式不可操作。'); + return; + } + const snapshot = localState.snapshot || {}; + const me = (snapshot.players || []).find((p) => p.player_id === playerId); + if (!me?.is_host) { + appendSystem('只有房主可以设置房间公开状态。'); + return; + } + const nextPublic = !Boolean(snapshot.is_public); + try { + const res = await fetch(`/api/rooms/${roomId}/visibility`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ player_id: playerId, is_public: nextPublic }), + }); + const data = await res.json(); + if (!data.ok) { + appendSystem(`设置失败:${data.error || '未知错误'}`); + return; + } + localState.snapshot = data.snapshot || localState.snapshot; + appendSystem(nextPublic ? '房间已公开,已出现在联机广场。' : '房间已取消公开。'); + renderAll(); + } catch (error) { + appendSystem(`网络错误: ${error}`); + } + }); + } + + actionForm.addEventListener('submit', (event) => { + event.preventDefault(); + if (isSpectator) { + appendSystem('观战模式不可提交行动。'); + return; + } + const content = actionInput.value.trim(); + if (!content) return; + if (trySubmitAction(content)) { + actionInput.value = ''; + } + }); + + chatForm.addEventListener('submit', (event) => { + event.preventDefault(); + if (isSpectator) { + appendSystem('观战模式不可发送讨论。'); + return; + } + const content = chatInput.value.trim(); + if (!content) return; + if (emitSafe('send_chat', { room_id: roomId, player_id: playerId, content })) { + chatInput.value = ''; + } + }); + + if (inviteBtn) { + inviteBtn.addEventListener('click', async () => { + if (localState.inviteLoading) return; + try { + setInviteLoading(true); + const resp = await fetch(`/api/rooms/${roomId}/invite`); + const data = await resp.json(); + if (!data.ok) { + appendSystem(data.error || '邀请链接生成失败。'); + return; + } + renderInvitePanel(data); + openInviteModal(); + const copied = await copyInviteText(data.join_link || ''); + appendSystem(copied ? '邀请面板已打开,加入链接已自动复制。' : '邀请面板已打开,请点击按钮复制链接。'); + } catch (error) { + appendSystem(`邀请面板打开失败: ${error}`); + } finally { + setInviteLoading(false); + } + }); + } + + if (inviteCloseBtn) { + inviteCloseBtn.addEventListener('click', closeInviteModal); + } + + if (inviteModal) { + inviteModal.addEventListener('click', (event) => { + if (event.target === inviteModal) { + closeInviteModal(); + } + }); + } + + if (inviteCopyJoinBtn) { + inviteCopyJoinBtn.addEventListener('click', async () => { + const ok = await copyInviteText(localState.invitePayload?.join_link || ''); + appendSystem(ok ? '加入链接已复制。' : '复制失败:当前浏览器限制复制权限。'); + }); + } + + if (inviteCopySpectateBtn) { + inviteCopySpectateBtn.addEventListener('click', async () => { + const ok = await copyInviteText(localState.invitePayload?.spectate_link || ''); + appendSystem(ok ? '观战链接已复制。' : '复制失败:当前浏览器限制复制权限。'); + }); + } + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && inviteModal && !inviteModal.classList.contains('hidden')) { + closeInviteModal(); + } + }); + + if (typeof window.io !== 'function') { + appendSystem('Socket.IO 客户端加载失败,请刷新页面。'); + loadSnapshotOnce(); + return; + } + + socket = window.io(); + + socket.on('connect', () => { + socket.emit('join_room_socket', { room_id: roomId, player_id: playerId, spectator: isSpectator }); + }); + + socket.on('connect_error', (error) => { + appendSystem(`连接失败: ${error?.message || '未知错误'}`); + }); + + socket.on('room_snapshot', (snapshot) => { + localState.snapshot = snapshot; + const endedNow = snapshot?.status === 'ended' || snapshot?.game_state?.game_phase === 'ended'; + if (!endedNow) { + localState.forcedEnding = null; + } + const running = snapshot?.status === 'running' && snapshot?.game_state?.game_phase === 'running'; + const myPending = getMyPending(snapshot); + const roundNo = Number(snapshot?.game_state?.round_no || 0); + if (!running) { + localState.actionLocked = false; + localState.lastSubmittedRound = null; + } else if (!myPending && localState.lastSubmittedRound !== null && roundNo >= localState.lastSubmittedRound) { + localState.actionLocked = false; + localState.lastSubmittedRound = null; + } + renderAll(); + }); + + socket.on('system_message', (payload) => { + appendSystem(payload?.content); + }); + + socket.on('pending_action_update', (payload) => { + if (payload?.should_auto_resolve) { + if (localState.autoResolveTimer) { + clearTimeout(localState.autoResolveTimer); + localState.autoResolveTimer = null; + } + return; + } + scheduleResolveFallback(); + }); + + socket.on('round_resolved', (payload) => { + appendSystem('本轮行动已处理,故事继续推进。'); + if (payload?.scene_transition) { + const t = payload.scene_transition; + storyLog.appendChild(createMsg('system', `场景推进:${t.from} -> ${t.to}`)); + } + if (payload?.ending_summary) { + storyLog.appendChild(createMsg('ai', `结局:${payload.ending_summary}`)); + } + if (payload?.ending_state || payload?.ending_summary) { + localState.forcedEnding = { + ending_state: payload?.ending_state || null, + ending_summary: payload?.ending_summary || '', + }; + } + if (localState.autoResolveTimer) { + clearTimeout(localState.autoResolveTimer); + localState.autoResolveTimer = null; + } + localState.actionLocked = false; + localState.lastSubmittedRound = null; + renderAll(); + }); + + socket.on('game_ended', (payload) => { + localState.forcedEnding = { + ending_state: payload?.ending_state || null, + ending_summary: payload?.ending_summary || '', + }; + appendSystem('本局已结束。'); + renderAll(); + }); + + socket.on('private_updates', (payload) => { + (payload?.messages || []).forEach((msg) => { + localState.privateMessages.push(msg); + }); + renderPrivate(); + }); + + socket.on('error_message', (payload) => { + const errorText = String(payload?.error || '未知错误'); + if ( + errorText.includes('你不在该房间中') + || errorText.includes('目标玩家不存在') + || errorText.includes('玩家信息不存在') + ) { + appendSystem('你已离开该房间,即将返回大厅。'); + window.setTimeout(() => { + window.location.href = '/'; + }, 900); + return; + } + if (!errorText.includes('已提交本轮行动') && !errorText.includes('正在结算')) { + localState.actionLocked = false; + localState.lastSubmittedRound = null; + } + appendSystem(`提示:${errorText}`); + renderAll(); + }); + + loadSnapshotOnce(); +})(); diff --git a/static/js/socket.io.min.js b/static/js/socket.io.min.js new file mode 100644 index 0000000..d6b2d60 --- /dev/null +++ b/static/js/socket.io.min.js @@ -0,0 +1,7 @@ +/*! + * Socket.IO v4.7.5 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).io=t()}(this,(function(){"use strict";function e(t){return e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},e(t)}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var o,s=!0,a=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return s=e.done,e},e:function(e){a=!0,o=e},f:function(){try{s||null==n.return||n.return()}finally{if(a)throw o}}}}var v=Object.create(null);v.open="0",v.close="1",v.ping="2",v.pong="3",v.message="4",v.upgrade="5",v.noop="6";var g=Object.create(null);Object.keys(v).forEach((function(e){g[v[e]]=e}));var m,b={type:"error",data:"parser error"},k="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===Object.prototype.toString.call(Blob),w="function"==typeof ArrayBuffer,_=function(e){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer instanceof ArrayBuffer},E=function(e,t,n){var r=e.type,i=e.data;return k&&i instanceof Blob?t?n(i):A(i,n):w&&(i instanceof ArrayBuffer||_(i))?t?n(i):A(new Blob([i]),n):n(v[r]+(i||""))},A=function(e,t){var n=new FileReader;return n.onload=function(){var e=n.result.split(",")[1];t("b"+(e||""))},n.readAsDataURL(e)};function O(e){return e instanceof Uint8Array?e:e instanceof ArrayBuffer?new Uint8Array(e):new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}for(var T="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",R="undefined"==typeof Uint8Array?[]:new Uint8Array(256),C=0;C<64;C++)R[T.charCodeAt(C)]=C;var B,S="function"==typeof ArrayBuffer,N=function(e,t){if("string"!=typeof e)return{type:"message",data:x(e,t)};var n=e.charAt(0);return"b"===n?{type:"message",data:L(e.substring(1),t)}:g[n]?e.length>1?{type:g[n],data:e.substring(1)}:{type:g[n]}:b},L=function(e,t){if(S){var n=function(e){var t,n,r,i,o,s=.75*e.length,a=e.length,c=0;"="===e[e.length-1]&&(s--,"="===e[e.length-2]&&s--);var u=new ArrayBuffer(s),h=new Uint8Array(u);for(t=0;t>4,h[c++]=(15&r)<<4|i>>2,h[c++]=(3&i)<<6|63&o;return u}(e);return x(n,t)}return{base64:!0,data:e}},x=function(e,t){return"blob"===t?e instanceof Blob?e:new Blob([e]):e instanceof ArrayBuffer?e:e.buffer},P=String.fromCharCode(30);function j(){return new TransformStream({transform:function(e,t){!function(e,t){k&&e.data instanceof Blob?e.data.arrayBuffer().then(O).then(t):w&&(e.data instanceof ArrayBuffer||_(e.data))?t(O(e.data)):E(e,!1,(function(e){m||(m=new TextEncoder),t(m.encode(e))}))}(e,(function(n){var r,i=n.length;if(i<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,i);else if(i<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,i)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(i))}e.data&&"string"!=typeof e.data&&(r[0]|=128),t.enqueue(r),t.enqueue(n)}))}})}function q(e){return e.reduce((function(e,t){return e+t.length}),0)}function D(e,t){if(e[0].length===t)return e.shift();for(var n=new Uint8Array(t),r=0,i=0;i1?t-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return e+"://"+this._hostname()+this._port()+this.opts.path+this._query(t)}},{key:"_hostname",value:function(){var e=this.opts.hostname;return-1===e.indexOf(":")?e:"["+e+"]"}},{key:"_port",value:function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""}},{key:"_query",value:function(e){var t=function(e){var t="";for(var n in e)e.hasOwnProperty(n)&&(t.length&&(t+="&"),t+=encodeURIComponent(n)+"="+encodeURIComponent(e[n]));return t}(e);return t.length?"?"+t:""}}]),i}(U),z="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_".split(""),J=64,$={},Q=0,X=0;function G(e){var t="";do{t=z[e%J]+t,e=Math.floor(e/J)}while(e>0);return t}function Z(){var e=G(+new Date);return e!==K?(Q=0,K=e):e+"."+G(Q++)}for(;X0&&void 0!==arguments[0]?arguments[0]:{};return i(e,{xd:this.xd,cookieJar:this.cookieJar},this.opts),new se(this.uri(),e)}},{key:"doWrite",value:function(e,t){var n=this,r=this.request({method:"POST",data:e});r.on("success",t),r.on("error",(function(e,t){n.onError("xhr post error",e,t)}))}},{key:"doPoll",value:function(){var e=this,t=this.request();t.on("data",this.onData.bind(this)),t.on("error",(function(t,n){e.onError("xhr poll error",t,n)})),this.pollXhr=t}}]),s}(W),se=function(e){o(i,e);var n=l(i);function i(e,r){var o;return t(this,i),H(f(o=n.call(this)),r),o.opts=r,o.method=r.method||"GET",o.uri=e,o.data=void 0!==r.data?r.data:null,o.create(),o}return r(i,[{key:"create",value:function(){var e,t=this,n=F(this.opts,"agent","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","autoUnref");n.xdomain=!!this.opts.xd;var r=this.xhr=new ne(n);try{r.open(this.method,this.uri,!0);try{if(this.opts.extraHeaders)for(var o in r.setDisableHeaderCheck&&r.setDisableHeaderCheck(!0),this.opts.extraHeaders)this.opts.extraHeaders.hasOwnProperty(o)&&r.setRequestHeader(o,this.opts.extraHeaders[o])}catch(e){}if("POST"===this.method)try{r.setRequestHeader("Content-type","text/plain;charset=UTF-8")}catch(e){}try{r.setRequestHeader("Accept","*/*")}catch(e){}null===(e=this.opts.cookieJar)||void 0===e||e.addCookies(r),"withCredentials"in r&&(r.withCredentials=this.opts.withCredentials),this.opts.requestTimeout&&(r.timeout=this.opts.requestTimeout),r.onreadystatechange=function(){var e;3===r.readyState&&(null===(e=t.opts.cookieJar)||void 0===e||e.parseCookies(r)),4===r.readyState&&(200===r.status||1223===r.status?t.onLoad():t.setTimeoutFn((function(){t.onError("number"==typeof r.status?r.status:0)}),0))},r.send(this.data)}catch(e){return void this.setTimeoutFn((function(){t.onError(e)}),0)}"undefined"!=typeof document&&(this.index=i.requestsCount++,i.requests[this.index]=this)}},{key:"onError",value:function(e){this.emitReserved("error",e,this.xhr),this.cleanup(!0)}},{key:"cleanup",value:function(e){if(void 0!==this.xhr&&null!==this.xhr){if(this.xhr.onreadystatechange=re,e)try{this.xhr.abort()}catch(e){}"undefined"!=typeof document&&delete i.requests[this.index],this.xhr=null}}},{key:"onLoad",value:function(){var e=this.xhr.responseText;null!==e&&(this.emitReserved("data",e),this.emitReserved("success"),this.cleanup())}},{key:"abort",value:function(){this.cleanup()}}]),i}(U);if(se.requestsCount=0,se.requests={},"undefined"!=typeof document)if("function"==typeof attachEvent)attachEvent("onunload",ae);else if("function"==typeof addEventListener){addEventListener("onpagehide"in I?"pagehide":"unload",ae,!1)}function ae(){for(var e in se.requests)se.requests.hasOwnProperty(e)&&se.requests[e].abort()}var ce="function"==typeof Promise&&"function"==typeof Promise.resolve?function(e){return Promise.resolve().then(e)}:function(e,t){return t(e,0)},ue=I.WebSocket||I.MozWebSocket,he="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),fe=function(e){o(i,e);var n=l(i);function i(e){var r;return t(this,i),(r=n.call(this,e)).supportsBinary=!e.forceBase64,r}return r(i,[{key:"name",get:function(){return"websocket"}},{key:"doOpen",value:function(){if(this.check()){var e=this.uri(),t=this.opts.protocols,n=he?{}:F(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(n.headers=this.opts.extraHeaders);try{this.ws=he?new ue(e,t,n):t?new ue(e,t):new ue(e)}catch(e){return this.emitReserved("error",e)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()}}},{key:"addEventListeners",value:function(){var e=this;this.ws.onopen=function(){e.opts.autoUnref&&e.ws._socket.unref(),e.onOpen()},this.ws.onclose=function(t){return e.onClose({description:"websocket connection closed",context:t})},this.ws.onmessage=function(t){return e.onData(t.data)},this.ws.onerror=function(t){return e.onError("websocket error",t)}}},{key:"write",value:function(e){var t=this;this.writable=!1;for(var n=function(){var n=e[r],i=r===e.length-1;E(n,t.supportsBinary,(function(e){try{t.ws.send(e)}catch(e){}i&&ce((function(){t.writable=!0,t.emitReserved("drain")}),t.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){a.enqueue(b);break}i=l*Math.pow(2,32)+f.getUint32(4),r=3}else{if(q(n)e){a.enqueue(b);break}}}})}(Number.MAX_SAFE_INTEGER,e.socket.binaryType),r=t.readable.pipeThrough(n).getReader(),i=j();i.readable.pipeTo(t.writable),e.writer=i.writable.getWriter();!function t(){r.read().then((function(n){var r=n.done,i=n.value;r||(e.onPacket(i),t())})).catch((function(e){}))}();var o={type:"open"};e.query.sid&&(o.data='{"sid":"'.concat(e.query.sid,'"}')),e.writer.write(o).then((function(){return e.onOpen()}))}))})))}},{key:"write",value:function(e){var t=this;this.writable=!1;for(var n=function(){var n=e[r],i=r===e.length-1;t.writer.write(n).then((function(){i&&ce((function(){t.writable=!0,t.emitReserved("drain")}),t.setTimeoutFn)}))},r=0;r1&&void 0!==arguments[1]?arguments[1]:{};return t(this,a),(r=s.call(this)).binaryType="arraybuffer",r.writeBuffer=[],n&&"object"===e(n)&&(o=n,n=null),n?(n=ve(n),o.hostname=n.host,o.secure="https"===n.protocol||"wss"===n.protocol,o.port=n.port,n.query&&(o.query=n.query)):o.host&&(o.hostname=ve(o.host).host),H(f(r),o),r.secure=null!=o.secure?o.secure:"undefined"!=typeof location&&"https:"===location.protocol,o.hostname&&!o.port&&(o.port=r.secure?"443":"80"),r.hostname=o.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=o.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=o.transports||["polling","websocket","webtransport"],r.writeBuffer=[],r.prevBufferLen=0,r.opts=i({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},o),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(e){for(var t={},n=e.split("&"),r=0,i=n.length;r1))return this.writeBuffer;for(var e,t=1,n=0;n=57344?n+=3:(r++,n+=4);return n}(e):Math.ceil(1.33*(e.byteLength||e.size))),n>0&&t>this.maxPayload)return this.writeBuffer.slice(0,n);t+=2}return this.writeBuffer}},{key:"write",value:function(e,t,n){return this.sendPacket("message",e,t,n),this}},{key:"send",value:function(e,t,n){return this.sendPacket("message",e,t,n),this}},{key:"sendPacket",value:function(e,t,n,r){if("function"==typeof t&&(r=t,t=void 0),"function"==typeof n&&(r=n,n=null),"closing"!==this.readyState&&"closed"!==this.readyState){(n=n||{}).compress=!1!==n.compress;var i={type:e,data:t,options:n};this.emitReserved("packetCreate",i),this.writeBuffer.push(i),r&&this.once("flush",r),this.flush()}}},{key:"close",value:function(){var e=this,t=function(){e.onClose("forced close"),e.transport.close()},n=function n(){e.off("upgrade",n),e.off("upgradeError",n),t()},r=function(){e.once("upgrade",n),e.once("upgradeError",n)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){e.upgrading?r():t()})):this.upgrading?r():t()),this}},{key:"onError",value:function(e){a.priorWebsocketSuccess=!1,this.emitReserved("error",e),this.onClose("transport error",e)}},{key:"onClose",value:function(e,t){"opening"!==this.readyState&&"open"!==this.readyState&&"closing"!==this.readyState||(this.clearTimeoutFn(this.pingTimeoutTimer),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),"function"==typeof removeEventListener&&(removeEventListener("beforeunload",this.beforeunloadEventListener,!1),removeEventListener("offline",this.offlineEventListener,!1)),this.readyState="closed",this.id=null,this.emitReserved("close",e,t),this.writeBuffer=[],this.prevBufferLen=0)}},{key:"filterUpgrades",value:function(e){for(var t=[],n=0,r=e.length;n=0&&t.num1?t-1:0),r=1;r1?n-1:0),i=1;in._opts.retries&&(n._queue.shift(),t&&t(e));else if(n._queue.shift(),t){for(var i=arguments.length,o=new Array(i>1?i-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this._queue.length){var t=this._queue[0];t.pending&&!e||(t.pending=!0,t.tryCount++,this.flags=t.flags,this.emit.apply(this,t.args))}}},{key:"packet",value:function(e){e.nsp=this.nsp,this.io._packet(e)}},{key:"onopen",value:function(){var e=this;"function"==typeof this.auth?this.auth((function(t){e._sendConnectPacket(t)})):this._sendConnectPacket(this.auth)}},{key:"_sendConnectPacket",value:function(e){this.packet({type:Be.CONNECT,data:this._pid?i({pid:this._pid,offset:this._lastOffset},e):e})}},{key:"onerror",value:function(e){this.connected||this.emitReserved("connect_error",e)}},{key:"onclose",value:function(e,t){this.connected=!1,delete this.id,this.emitReserved("disconnect",e,t),this._clearAcks()}},{key:"_clearAcks",value:function(){var e=this;Object.keys(this.acks).forEach((function(t){if(!e.sendBuffer.some((function(e){return String(e.id)===t}))){var n=e.acks[t];delete e.acks[t],n.withError&&n.call(e,new Error("socket has been disconnected"))}}))}},{key:"onpacket",value:function(e){if(e.nsp===this.nsp)switch(e.type){case Be.CONNECT:e.data&&e.data.sid?this.onconnect(e.data.sid,e.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Be.EVENT:case Be.BINARY_EVENT:this.onevent(e);break;case Be.ACK:case Be.BINARY_ACK:this.onack(e);break;case Be.DISCONNECT:this.ondisconnect();break;case Be.CONNECT_ERROR:this.destroy();var t=new Error(e.data.message);t.data=e.data.data,this.emitReserved("connect_error",t)}}},{key:"onevent",value:function(e){var t=e.data||[];null!=e.id&&t.push(this.ack(e.id)),this.connected?this.emitEvent(t):this.receiveBuffer.push(Object.freeze(t))}},{key:"emitEvent",value:function(e){if(this._anyListeners&&this._anyListeners.length){var t,n=y(this._anyListeners.slice());try{for(n.s();!(t=n.n()).done;){t.value.apply(this,e)}}catch(e){n.e(e)}finally{n.f()}}p(s(a.prototype),"emit",this).apply(this,e),this._pid&&e.length&&"string"==typeof e[e.length-1]&&(this._lastOffset=e[e.length-1])}},{key:"ack",value:function(e){var t=this,n=!1;return function(){if(!n){n=!0;for(var r=arguments.length,i=new Array(r),o=0;o0&&e.jitter<=1?e.jitter:0,this.attempts=0}Ie.prototype.duration=function(){var e=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var t=Math.random(),n=Math.floor(t*this.jitter*e);e=0==(1&Math.floor(10*t))?e-n:e+n}return 0|Math.min(e,this.max)},Ie.prototype.reset=function(){this.attempts=0},Ie.prototype.setMin=function(e){this.ms=e},Ie.prototype.setMax=function(e){this.max=e},Ie.prototype.setJitter=function(e){this.jitter=e};var Fe=function(n){o(s,n);var i=l(s);function s(n,r){var o,a;t(this,s),(o=i.call(this)).nsps={},o.subs=[],n&&"object"===e(n)&&(r=n,n=void 0),(r=r||{}).path=r.path||"/socket.io",o.opts=r,H(f(o),r),o.reconnection(!1!==r.reconnection),o.reconnectionAttempts(r.reconnectionAttempts||1/0),o.reconnectionDelay(r.reconnectionDelay||1e3),o.reconnectionDelayMax(r.reconnectionDelayMax||5e3),o.randomizationFactor(null!==(a=r.randomizationFactor)&&void 0!==a?a:.5),o.backoff=new Ie({min:o.reconnectionDelay(),max:o.reconnectionDelayMax(),jitter:o.randomizationFactor()}),o.timeout(null==r.timeout?2e4:r.timeout),o._readyState="closed",o.uri=n;var c=r.parser||je;return o.encoder=new c.Encoder,o.decoder=new c.Decoder,o._autoConnect=!1!==r.autoConnect,o._autoConnect&&o.open(),o}return r(s,[{key:"reconnection",value:function(e){return arguments.length?(this._reconnection=!!e,this):this._reconnection}},{key:"reconnectionAttempts",value:function(e){return void 0===e?this._reconnectionAttempts:(this._reconnectionAttempts=e,this)}},{key:"reconnectionDelay",value:function(e){var t;return void 0===e?this._reconnectionDelay:(this._reconnectionDelay=e,null===(t=this.backoff)||void 0===t||t.setMin(e),this)}},{key:"randomizationFactor",value:function(e){var t;return void 0===e?this._randomizationFactor:(this._randomizationFactor=e,null===(t=this.backoff)||void 0===t||t.setJitter(e),this)}},{key:"reconnectionDelayMax",value:function(e){var t;return void 0===e?this._reconnectionDelayMax:(this._reconnectionDelayMax=e,null===(t=this.backoff)||void 0===t||t.setMax(e),this)}},{key:"timeout",value:function(e){return arguments.length?(this._timeout=e,this):this._timeout}},{key:"maybeReconnectOnOpen",value:function(){!this._reconnecting&&this._reconnection&&0===this.backoff.attempts&&this.reconnect()}},{key:"open",value:function(e){var t=this;if(~this._readyState.indexOf("open"))return this;this.engine=new ge(this.uri,this.opts);var n=this.engine,r=this;this._readyState="opening",this.skipReconnect=!1;var i=qe(n,"open",(function(){r.onopen(),e&&e()})),o=function(n){t.cleanup(),t._readyState="closed",t.emitReserved("error",n),e?e(n):t.maybeReconnectOnOpen()},s=qe(n,"error",o);if(!1!==this._timeout){var a=this._timeout,c=this.setTimeoutFn((function(){i(),o(new Error("timeout")),n.close()}),a);this.opts.autoUnref&&c.unref(),this.subs.push((function(){t.clearTimeoutFn(c)}))}return this.subs.push(i),this.subs.push(s),this}},{key:"connect",value:function(e){return this.open(e)}},{key:"onopen",value:function(){this.cleanup(),this._readyState="open",this.emitReserved("open");var e=this.engine;this.subs.push(qe(e,"ping",this.onping.bind(this)),qe(e,"data",this.ondata.bind(this)),qe(e,"error",this.onerror.bind(this)),qe(e,"close",this.onclose.bind(this)),qe(this.decoder,"decoded",this.ondecoded.bind(this)))}},{key:"onping",value:function(){this.emitReserved("ping")}},{key:"ondata",value:function(e){try{this.decoder.add(e)}catch(e){this.onclose("parse error",e)}}},{key:"ondecoded",value:function(e){var t=this;ce((function(){t.emitReserved("packet",e)}),this.setTimeoutFn)}},{key:"onerror",value:function(e){this.emitReserved("error",e)}},{key:"socket",value:function(e,t){var n=this.nsps[e];return n?this._autoConnect&&!n.active&&n.connect():(n=new Ue(this,e,t),this.nsps[e]=n),n}},{key:"_destroy",value:function(e){for(var t=0,n=Object.keys(this.nsps);t=this._reconnectionAttempts)this.backoff.reset(),this.emitReserved("reconnect_failed"),this._reconnecting=!1;else{var n=this.backoff.duration();this._reconnecting=!0;var r=this.setTimeoutFn((function(){t.skipReconnect||(e.emitReserved("reconnect_attempt",t.backoff.attempts),t.skipReconnect||t.open((function(n){n?(t._reconnecting=!1,t.reconnect(),e.emitReserved("reconnect_error",n)):t.onreconnect()})))}),n);this.opts.autoUnref&&r.unref(),this.subs.push((function(){e.clearTimeoutFn(r)}))}}},{key:"onreconnect",value:function(){var e=this.backoff.attempts;this._reconnecting=!1,this.backoff.reset(),this.emitReserved("reconnect",e)}}]),s}(U),Me={};function Ve(t,n){"object"===e(t)&&(n=t,t=void 0);var r,i=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2?arguments[2]:void 0,r=e;n=n||"undefined"!=typeof location&&location,null==e&&(e=n.protocol+"//"+n.host),"string"==typeof e&&("/"===e.charAt(0)&&(e="/"===e.charAt(1)?n.protocol+e:n.host+e),/^(https?|wss?):\/\//.test(e)||(e=void 0!==n?n.protocol+"//"+e:"https://"+e),r=ve(e)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var i=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+i+":"+r.port+t,r.href=r.protocol+"://"+i+(n&&n.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),o=i.source,s=i.id,a=i.path,c=Me[s]&&a in Me[s].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||c?r=new Fe(o,n):(Me[s]||(Me[s]=new Fe(o,n)),r=Me[s]),i.query&&!n.query&&(n.query=i.queryKey),r.socket(i.path,n)}return i(Ve,{Manager:Fe,Socket:Ue,io:Ve,connect:Ve}),Ve})); +//# sourceMappingURL=socket.io.min.js.map diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..822a337 --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,39 @@ +(function () { + const body = document.body; + if (!body) return; + + const THEME_KEY = 'echo-theme'; + const THEMES = new Set(['minimal', 'dopamine', 'midnight', 'mint', 'sunset']); + + const normalizeTheme = (value) => { + const theme = String(value || '').trim().toLowerCase(); + if (THEMES.has(theme)) return theme; + return 'dopamine'; + }; + + const applyTheme = (theme) => { + const normalized = normalizeTheme(theme); + body.setAttribute('data-theme', normalized); + localStorage.setItem(THEME_KEY, normalized); + return normalized; + }; + + let user = null; + try { + user = JSON.parse(body.dataset.user || 'null'); + } catch { + user = null; + } + + const fromUser = user && user.theme ? normalizeTheme(user.theme) : ''; + const fromBody = normalizeTheme(body.dataset.theme || 'dopamine'); + const fromLocal = normalizeTheme(localStorage.getItem(THEME_KEY) || ''); + const initial = fromUser || fromLocal || fromBody || 'dopamine'; + applyTheme(initial); + + window.EchoTheme = { + applyTheme, + normalizeTheme, + themes: Array.from(THEMES), + }; +})(); diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..ecbd725 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,44 @@ + + + + + + 后台管理 - Echo Tavern + + + +
+
+

后台管理

+

管理员控制台:用户权限、房间状态、游戏卡治理

+
+
+ + {{ user.username }}(管理员) + + 返回大厅 +
+
+ +
+
+

用户权限管理

+
+
+ +
+

房间与游戏监控

+
+
+ +
+

游戏卡治理

+
+
+
+ +
+ + + + diff --git a/templates/card_editor.html b/templates/card_editor.html new file mode 100644 index 0000000..34a7607 --- /dev/null +++ b/templates/card_editor.html @@ -0,0 +1,235 @@ + + + + + + 游戏卡编辑器 - Echo Tavern + + + +
+
+

游戏卡编辑器

+

创建可复用的多人文字冒险卡,支持多状态条、结束条件与扩展系统。

+
+ +
+ +
+
+
+

AI 辅助写卡

+ + +
+
+ + +
+
+ + +
+
+ + + + + + + +
    +
    + +
    +

    基础信息

    + + + + + + + + + + + + + + + + + + + + + +

    界面与分区

    + + + + + + + + + + + + + + + + + + + + + +

    数据作用域(共享/个人)

    + + +

    + 作用:决定哪些数据是“每位玩家独立”,哪些是“全队共享”。可选值只有 shared 或 per_player。 + 推荐:线索 shared,背包和状态 per_player。 +

    + + + +

    + 作用:控制房间是否展示“全员状态”和“全员背包”看板。填 true/false。 +

    + +

    数值系统

    + + +

    + 字段说明:key 英文唯一标识(如 hp)、label 中文名、min/max 范围、initial 初始值、color 颜色。 +

    + + + +

    + 这里决定“顶部会显示哪些状态条”。key 必须来自状态字段 JSON。你可以展示 1 条或多条。 +

    + + + +

    + 每条结束条件满足后会立即结束游戏。支持比较符:>=、<=、>、<、==、!=。 +

    + + + +

    + 这是“高自由扩展区”。可写战斗、技能、资源、法术、阵营等结构,AI 会读取并在叙述中遵循。 +

    + +

    内容包

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + +
    + +
    + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..88abde1 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,98 @@ + + + + + + Echo Tavern - 大厅 + + + +
    +
    +

    Echo Tavern

    +

    多人联机派对文字冒险

    +
    +
    + {% if user %} + + {{ user.username }} + + {% if user.role == 'admin' %} + 管理后台 + {% endif %} + 游戏卡广场 + + {% else %} + 登录 + 注册 + {% endif %} +
    +
    + +
    +
    +

    创建房间

    +
    +

    将使用用户名创建房间

    + + + + +
    + + +
    +
    + +
    +

    加入房间

    +
    + + + +
    +

    将使用用户名加入。支持断线重连:输入同房间码会优先恢复你在该房间的身份。

    +
    + +
    +

    快速重连

    +
      +
      + +
      +

      账号个性化

      +
      +

      游戏内统一使用登录账号:{{ user.username if user else '' }}

      + + +
      + + +
      + + + + + + + + + + + +
      +
      +
      + +
      + + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..14e85cc --- /dev/null +++ b/templates/login.html @@ -0,0 +1,26 @@ + + + + + + 登录 - Echo Tavern + + + +
      +

      登录 Echo Tavern

      +
      + + + + + +
      + + +
      +
      + + + + diff --git a/templates/plaza.html b/templates/plaza.html new file mode 100644 index 0000000..2afccc5 --- /dev/null +++ b/templates/plaza.html @@ -0,0 +1,52 @@ + + + + + + 游戏卡广场 - Echo Tavern + + + +
      +
      +

      游戏卡广场

      +

      浏览剧本卡、查看封面与简介、快速开局。复杂创卡和编辑在独立界面完成。

      +
      +
      + {% if user and user.role == 'admin' %} + 管理后台 + {% endif %} + {% if user %} + 新建游戏卡 + {% endif %} + 返回大厅 +
      +
      + +
      +
      +

      可游玩游戏卡

      +

      点击卡片即可查看详情;你自己发布的卡支持直接编辑。

      +
      +
      +
      +

      创作入口

      +

      复杂配置(多状态条、结束条件、战斗系统)已迁移到独立编辑器。

      + {% if user %} + 进入游戏卡编辑器 +

      在广场里点击你自己的卡片可直接进入编辑。

      + {% else %} +

      请先登录后创建或编辑游戏卡。

      + {% endif %} + +

      联机广场

      +

      房主将房间设为公开后,会显示在这里。未开始可加入,进行中可观战。

      +
        +
        +
        + +
        + + + + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..d777cc9 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,26 @@ + + + + + + 注册 - Echo Tavern + + + +
        +

        创建账号

        +
        + + + + + +
        + + +
        +
        + + + + diff --git a/templates/room.html b/templates/room.html new file mode 100644 index 0000000..6d3af2a --- /dev/null +++ b/templates/room.html @@ -0,0 +1,161 @@ + + + + + + Echo Tavern - 房间 {{ room_id }} + + + +
        +
        +

        Echo Tavern

        +

        房间码 {{ room_id }} · 玩家 {{ player_name }}

        +
        +
        + 返回主页 + + + + + +
        +
        + +
        +
        +
        +
        +

        当前场景

        +

        正在加载场景...

        +
        +
        +
        + +
        +

        剧情进展

        +
        +
        +
        + + +
        + + + + + + + + + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cec5cda --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app import create_app +from config import Config + + +@pytest.fixture() +def test_db_path(tmp_path: Path) -> str: + return str(tmp_path / "test_echo_tavern.db") + + +@pytest.fixture() +def app_instance(test_db_path: str): + class TestConfig(Config): + TESTING = True + DB_PATH = test_db_path + DEEPSEEK_API_KEY = "" + MAX_ROOM_PLAYERS = 8 + + app, socketio = create_app(TestConfig) + return app, socketio + + +@pytest.fixture() +def client(app_instance): + app, _ = app_instance + return app.test_client() + + +@pytest.fixture() +def room_manager(app_instance): + app, _ = app_instance + return app.extensions["room_manager"] + + +@pytest.fixture() +def persistence(app_instance): + app, _ = app_instance + return app.extensions["persistence"] diff --git a/tests/test_action_resolution.py b/tests/test_action_resolution.py new file mode 100644 index 0000000..0cb8891 --- /dev/null +++ b/tests/test_action_resolution.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from game.action_resolver import ActionResolver +from game.models import PlayerAction + + +def test_merge_actions_resolves_conflict_deterministically(): + resolver = ActionResolver() + actions = [ + PlayerAction(player_id="p1", player_name="A", content="说服守卫开门"), + PlayerAction(player_id="p2", player_name="B", content="攻击守卫并强闯"), + PlayerAction(player_id="p3", player_name="C", content="检查大厅烛台"), + ] + + result1 = resolver.merge_actions(actions, "ROOMAA", 1) + result2 = resolver.merge_actions(actions, "ROOMAA", 1) + + assert result1["summary"] == result2["summary"] + assert len(result1["conflicts"]) == 1 + + conflict = result1["conflicts"][0] + assert conflict["target"] + assert conflict["winner_player_id"] in {"p1", "p2"} + assert len(conflict["loser_player_ids"]) == 1 + + blocked = [row for row in result1["ordered_actions"] if row["resolution"] == "blocked"] + assert len(blocked) == 1 diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..cbdf129 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,101 @@ +from __future__ import annotations + + +def test_first_registered_user_is_admin(client): + register = client.post( + '/api/auth/register', + json={'username': 'admin_seed', 'password': 'secret12', 'display_name': 'AdminSeed'}, + ) + data = register.get_json() + assert register.status_code == 200 + assert data['ok'] is True + assert data['user']['role'] == 'admin' + + +def test_non_admin_cannot_access_admin_api(client): + client.post( + '/api/auth/register', + json={'username': 'root_user', 'password': 'secret12', 'display_name': 'Root'}, + ) + client.post('/api/auth/logout', json={}) + client.post( + '/api/auth/register', + json={'username': 'normal_user', 'password': 'secret12', 'display_name': 'Normal'}, + ) + denied = client.get('/api/admin/users') + assert denied.status_code == 403 + assert denied.get_json()['ok'] is False + + +def test_admin_page_access_control(client): + guest_page = client.get('/admin') + assert guest_page.status_code == 302 + + client.post( + '/api/auth/register', + json={'username': 'admin_page_owner', 'password': 'secret12', 'display_name': 'AdminPage'}, + ) + admin_page = client.get('/admin') + assert admin_page.status_code == 200 + assert '后台管理' in admin_page.get_data(as_text=True) + + +def test_admin_can_manage_users_rooms_cards(client): + admin_username = 'boss_admin' + admin_password = 'secret12' + client.post( + '/api/auth/register', + json={'username': admin_username, 'password': admin_password, 'display_name': 'Boss'}, + ) + client.post('/api/auth/logout', json={}) + + player_username = 'guest_player' + player_password = 'secret12' + client.post( + '/api/auth/register', + json={'username': player_username, 'password': player_password, 'display_name': 'Guest'}, + ) + created_room = client.post('/api/rooms/create', json={}).get_json() + created_card = client.post( + '/api/cards', + json={'title': '后台删卡测试', 'summary': 'for admin', 'opening_text': 'open'}, + ).get_json() + room_id = created_room['room_id'] + card_id = created_card['card']['card_id'] + guest_user_id = created_card['card']['creator_user_id'] + + client.post('/api/auth/logout', json={}) + login = client.post('/api/auth/login', json={'username': admin_username, 'password': admin_password}) + assert login.status_code == 200 + + users_resp = client.get('/api/admin/users') + assert users_resp.status_code == 200 + users_data = users_resp.get_json() + assert users_data['ok'] is True + assert any(row['user_id'] == guest_user_id for row in users_data['users']) + + role_update = client.patch(f'/api/admin/users/{guest_user_id}/role', json={'role': 'moderator'}) + assert role_update.status_code == 200 + assert role_update.get_json()['user']['role'] == 'moderator' + + rooms_resp = client.get('/api/admin/rooms') + assert rooms_resp.status_code == 200 + rooms_data = rooms_resp.get_json()['rooms'] + assert any(row['room_id'] == room_id for row in rooms_data) + + delete_room = client.delete(f'/api/admin/rooms/{room_id}') + assert delete_room.status_code == 200 + assert delete_room.get_json()['ok'] is True + + cards_resp = client.get('/api/admin/cards') + assert cards_resp.status_code == 200 + cards_data = cards_resp.get_json()['cards'] + assert any(row['card_id'] == card_id for row in cards_data) + + set_official = client.patch(f'/api/admin/cards/{card_id}', json={'is_official': True}) + assert set_official.status_code == 200 + assert set_official.get_json()['card']['is_official'] is True + + delete_card = client.delete(f'/api/admin/cards/{card_id}') + assert delete_card.status_code == 200 + assert delete_card.get_json()['ok'] is True diff --git a/tests/test_auth_cards.py b/tests/test_auth_cards.py new file mode 100644 index 0000000..f980414 --- /dev/null +++ b/tests/test_auth_cards.py @@ -0,0 +1,404 @@ +from __future__ import annotations + + +def test_auth_register_login_profile(client): + register = client.post( + '/api/auth/register', + json={'username': 'alice_1', 'password': 'secret12', 'display_name': 'Alice'}, + ) + assert register.status_code == 200 + data = register.get_json() + assert data['ok'] is True + assert data['user']['username'] == 'alice_1' + + me = client.get('/api/me').get_json() + assert me['logged_in'] is True + assert me['user']['username'] == 'alice_1' + + profile = client.patch( + '/api/me/profile', + json={'avatar_color': '#ff3366', 'avatar_url': 'https://example.com/a.png', 'theme': 'dopamine', 'bio': 'test'}, + ) + profile_data = profile.get_json() + assert profile_data['ok'] is True + assert profile_data['user']['username'] == 'alice_1' + assert profile_data['user']['avatar_color'] == '#ff3366' + assert profile_data['user']['avatar_url'] == 'https://example.com/a.png' + assert profile_data['user']['theme'] == 'dopamine' + + client.post('/api/auth/logout', json={}) + me_after = client.get('/api/me').get_json() + assert me_after['logged_in'] is False + + login = client.post('/api/auth/login', json={'username': 'alice_1', 'password': 'secret12'}) + assert login.status_code == 200 + assert login.get_json()['ok'] is True + + +def test_cards_seed_and_custom_create(client): + seeded = client.get('/api/cards').get_json() + assert seeded['ok'] is True + assert len(seeded['cards']) >= 3 + + unauthorized = client.post('/api/cards', json={'title': 'X'}) + assert unauthorized.status_code == 401 + + client.post( + '/api/auth/register', + json={'username': 'maker_1', 'password': 'secret12', 'display_name': 'Maker'}, + ) + + created = client.post( + '/api/cards', + json={ + 'title': '午夜电台', + 'subtitle': '都市怪谈', + 'summary': '信号里传来未来事故播报。', + 'cover_url': '/static/img/cards/fogharbor.svg', + 'room_background_url': '/static/img/cards/skyrail_omega.svg', + 'opening_text': '午夜十二点,电台忽然播报了你今天的名字。', + 'starter_goals': '先确认信号源,再联系队友,调查现场', + 'state_fields': '紧急度,求生率', + 'hero_status_label': '紧急度', + 'hero_status_color': '#00b66a', + 'genre_tags': '都市怪谈,广播异常', + 'feature_badges': '多结局,线索拼图', + 'world_lore': '城市中每晚都会出现一段来自未来的播报。', + 'core_conflict': '玩家要决定相信播报还是阻止播报。', + 'npc_roster': '电台主持,夜巡警员', + 'scene_blueprint': '电台值班室,午夜街区,废弃天台', + 'ending_catalog': '阻止灾难,被卷入时间循环', + }, + ) + created_data = created.get_json() + assert created_data['ok'] is True + assert created_data['card']['title'] == '午夜电台' + ui_schema = created_data['card']['state_schema']['ui'] + content_pack = created_data['card']['content_pack'] + assert ui_schema['room_background_url'] == '/static/img/cards/skyrail_omega.svg' + assert ui_schema['hero_status']['label'] == '紧急度' + assert ui_schema['hero_status']['color'] == '#00b66a' + assert '都市怪谈' in content_pack['genre_tags'] + assert '电台主持' in content_pack['npc_roster'] + + cards = client.get('/api/cards').get_json()['cards'] + assert any(card['title'] == '午夜电台' for card in cards) + assert any((card.get('content_pack') or {}).get('genre_tags') for card in cards) + + +def test_profile_invalid_theme_keeps_previous(client): + client.post( + '/api/auth/register', + json={'username': 'theme_user_1', 'password': 'secret12', 'display_name': 'ThemeUser'}, + ) + first = client.patch('/api/me/profile', json={'theme': 'mint'}).get_json() + assert first['ok'] is True + assert first['user']['theme'] == 'mint' + + second = client.patch('/api/me/profile', json={'theme': 'unknown_theme'}).get_json() + assert second['ok'] is True + assert second['user']['theme'] == 'mint' + + +def test_user_can_rejoin_running_room_by_account(client, app_instance): + client.post( + '/api/auth/register', + json={'username': 'rejoin_1', 'password': 'secret12', 'display_name': 'RejoinUser'}, + ) + created = client.post('/api/rooms/create', json={'name': 'RejoinUser'}).get_json() + room_id = created['room_id'] + player_id = created['player_id'] + + app, _ = app_instance + manager = app.extensions['room_manager'] + manager.start_game(room_id, player_id) + + rejoin = client.post('/api/rooms/join', json={'room_id': room_id, 'name': 'ChangedName'}) + assert rejoin.status_code == 200 + data = rejoin.get_json() + assert data['ok'] is True + assert data['player_id'] == player_id + + +def test_logged_user_create_room_uses_profile_display_name(client): + client.post( + '/api/auth/register', + json={'username': 'display_1', 'password': 'secret12', 'display_name': '固定昵称'}, + ) + created = client.post('/api/rooms/create', json={'name': '临时昵称'}).get_json() + assert created['ok'] is True + assert created['player_name'] == 'display_1' + + +def test_user_can_delete_own_custom_card(client): + client.post( + '/api/auth/register', + json={'username': 'card_owner', 'password': 'secret12', 'display_name': 'Owner'}, + ) + created = client.post( + '/api/cards', + json={ + 'title': '可删卡', + 'summary': '测试删除', + 'opening_text': '开场', + }, + ).get_json() + card_id = created['card']['card_id'] + + deleted = client.delete(f'/api/cards/{card_id}') + assert deleted.status_code == 200 + assert deleted.get_json()['ok'] is True + + cards = client.get('/api/cards').get_json()['cards'] + assert all(row['card_id'] != card_id for row in cards) + + +def test_user_cannot_delete_official_card(client): + client.post( + '/api/auth/register', + json={'username': 'card_owner2', 'password': 'secret12', 'display_name': 'Owner2'}, + ) + denied = client.delete('/api/cards/fogharbor') + assert denied.status_code == 400 + assert '只能删除自己创建' in denied.get_json()['error'] + + +def test_card_assist_api_uses_ai_draft(client, app_instance): + client.post( + '/api/auth/register', + json={'username': 'assist_user', 'password': 'secret12', 'display_name': 'Assist'}, + ) + + app, _ = app_instance + app.extensions['room_manager'].deepseek_client.chat_json = lambda *args, **kwargs: { + 'ok': True, + 'content': ( + '{"title":"星港戒严","subtitle":"合作悬疑","summary":"港口全面戒严,玩家需找出内鬼并脱身。",' + '"opening_text":"警报响起,你们被锁在星港中层。","starter_goals":["确认封锁原因","接触管制官","排查可疑货仓"],' + '"state_fields":["警戒值","推进度","物资"],"labels":{"inventory":"资源仓","clues":"情报板","choices":"行动菜单","scene":"区域"},' + '"hero_status_label":"警戒值","hero_status_color":"#ff5533","cover_url":"/static/img/cards/skyrail_omega.svg",' + '"room_background_url":"/static/img/cards/skyrail_omega.svg","design_notes":["先稳住局势","再分组调查","保留撤离方案"]}' + ), + } + + resp = client.post('/api/cards/assist', json={'concept': '太空港封锁事件'}) + data = resp.get_json() + assert resp.status_code == 200 + assert data['ok'] is True + assert data['draft']['title'] == '星港戒严' + assert data['draft']['inventory_label'] == '资源仓' + assert len(data['draft']['design_notes']) >= 1 + assert isinstance(data['draft']['genre_tags'], list) + assert data['draft']['world_lore'] + assert data['draft']['core_conflict'] + assert len(data['draft']['player_roles']) >= 1 + assert data['source'] == 'ai' + + +def test_card_assist_api_returns_error_when_ai_fails(client, app_instance): + client.post( + '/api/auth/register', + json={'username': 'assist_user_2', 'password': 'secret12', 'display_name': 'Assist2'}, + ) + + app, _ = app_instance + app.extensions['room_manager'].deepseek_client.chat_json = lambda *args, **kwargs: { + 'ok': False, + 'error': 'network_timeout', + 'content': None, + } + + resp = client.post('/api/cards/assist', json={'concept': '深海科研站断电事件'}) + data = resp.get_json() + assert resp.status_code == 502 + assert data['ok'] is False + assert data['ai_error'] == 'network_timeout' + assert 'AI 生成失败' in data['error'] + + +def test_card_assist_patch_api_applies_incremental_update(client, app_instance): + client.post( + '/api/auth/register', + json={'username': 'assist_patch_1', 'password': 'secret12', 'display_name': 'AssistPatch'}, + ) + app, _ = app_instance + app.extensions['room_manager'].deepseek_client.chat_json = lambda *args, **kwargs: { + 'ok': True, + 'content': ( + '{"systems":{"talent_system":{"enabled":true,"pick_count":1,"options":["鹰眼侦测","临场说服"]}},' + '"feature_badges":["天赋系统"],' + '"system_rules":["每名玩家开局可选择 1 项天赋。"]}' + ), + } + + payload = { + 'instruction': '我想增加一个天赋系统', + 'current': { + 'title': '雾海列车', + 'summary': '调查列车异变并合作生存。', + 'opening_text': '列车广播突然中断。', + 'feature_badges': '多结局, 协作', + 'system_rules': '每轮每人一条行动', + 'systems_json': {'dice': {'enabled': True, 'formula': 'd20'}}, + 'metrics': [], + 'status_tracks': [], + 'end_conditions': [], + 'data_scope_json': {'metrics': 'per_player', 'inventory': 'per_player', 'clues': 'shared'}, + 'visibility_json': {'show_all_player_metrics': True, 'show_all_player_inventory': True}, + }, + } + resp = client.post('/api/cards/assist_patch', json=payload) + data = resp.get_json() + assert resp.status_code == 200 + assert data['ok'] is True + assert data['source'] == 'ai' + assert 'systems' in data['patch'] + systems = data['updated']['systems'] + assert systems['dice']['enabled'] is True + assert systems['talent_system']['enabled'] is True + assert '天赋系统' in data['updated']['feature_badges'] + assert any('天赋' in row for row in data['updated']['system_rules']) + + +def test_card_assist_patch_api_returns_error_when_ai_fails(client, app_instance): + client.post( + '/api/auth/register', + json={'username': 'assist_patch_2', 'password': 'secret12', 'display_name': 'AssistPatch2'}, + ) + app, _ = app_instance + app.extensions['room_manager'].deepseek_client.chat_json = lambda *args, **kwargs: { + 'ok': False, + 'error': 'request_timeout', + 'detail': 'Read timed out.', + 'content': None, + } + + resp = client.post( + '/api/cards/assist_patch', + json={ + 'instruction': '请增加一个天赋系统,支持开局选天赋', + 'current': {'title': '暗潮据点', 'summary': '测试', 'opening_text': '测试开场'}, + }, + ) + data = resp.get_json() + assert resp.status_code == 502 + assert data['ok'] is False + assert data['ai_error'] == 'request_timeout' + assert 'AI 修改失败' in data['error'] + + +def test_card_create_supports_custom_metrics_tracks_and_end_conditions(client): + client.post( + '/api/auth/register', + json={'username': 'card_rich_1', 'password': 'secret12', 'display_name': 'RichMaker'}, + ) + + created = client.post( + '/api/cards', + json={ + 'title': '机库攻防', + 'summary': '支持战斗系统与多状态条的剧本卡。', + 'opening_text': '警报拉响,机库闸门即将关闭。', + 'metrics': [ + {'key': 'hp', 'label': '队伍生命', 'min': 0, 'max': 200, 'initial': 150, 'color': '#ff5a4d'}, + {'key': 'threat', 'label': '威胁等级', 'min': 0, 'max': 99, 'initial': 18, 'color': '#5d8dff'}, + ], + 'status_tracks': [ + {'key': 'hp', 'label': '队伍生命', 'min': 0, 'max': 200, 'color': '#ff5a4d'}, + {'key': 'threat', 'label': '威胁等级', 'min': 0, 'max': 99, 'color': '#5d8dff'}, + ], + 'data_scope_json': { + 'metrics': 'per_player', + 'inventory': 'per_player', + 'clues': 'shared', + }, + 'visibility_json': { + 'show_all_player_metrics': True, + 'show_all_player_inventory': True, + }, + 'end_conditions': [ + { + 'id': 'victory', + 'metric': 'threat', + 'op': '<=', + 'value': 5, + 'ending_state': 'victory', + 'ending_summary': '威胁被压制,守住了机库。', + } + ], + 'systems_json': { + 'combat': {'enabled': True, 'turn_order': 'initiative'}, + 'dice': {'enabled': True, 'formula': 'd20'}, + }, + }, + ) + payload = created.get_json() + assert created.status_code == 200 + assert payload['ok'] is True + + card = payload['card'] + metrics = card['state_schema']['metrics'] + assert any(row['key'] == 'hp' and row['max'] == 200 for row in metrics) + assert len(card['state_schema']['ui']['status_tracks']) >= 2 + assert card['state_schema']['data_scope']['metrics'] == 'per_player' + assert card['state_schema']['visibility']['show_all_player_inventory'] is True + assert card['content_pack']['end_conditions'][0]['ending_state'] == 'victory' + assert card['content_pack']['systems']['combat']['enabled'] is True + + +def test_user_can_update_own_published_card(client): + client.post( + '/api/auth/register', + json={'username': 'card_editor_1', 'password': 'secret12', 'display_name': 'CardEditor'}, + ) + created = client.post( + '/api/cards', + json={'title': '可编辑卡', 'summary': '旧简介', 'opening_text': '旧开场'}, + ).get_json() + card_id = created['card']['card_id'] + + updated = client.patch( + f'/api/cards/{card_id}', + json={ + 'title': '可编辑卡', + 'subtitle': '已修改', + 'summary': '新简介', + 'opening_text': '新开场', + 'metrics': [{'key': 'heat', 'label': '热度', 'min': -20, 'max': 120, 'initial': 5, 'color': '#ff7b2f'}], + 'status_tracks': [{'key': 'heat', 'label': '热度', 'min': -20, 'max': 120, 'color': '#ff7b2f'}], + }, + ) + assert updated.status_code == 200 + data = updated.get_json() + assert data['ok'] is True + assert data['card']['summary'] == '新简介' + assert data['card']['state_schema']['metrics'][0]['key'] == 'heat' + assert data['card']['state_schema']['ui']['status_tracks'][0]['max'] == 120 + + loaded = client.get(f'/api/cards/{card_id}') + assert loaded.status_code == 200 + assert loaded.get_json()['card']['subtitle'] == '已修改' + + +def test_user_cannot_update_other_players_card(client): + client.post( + '/api/auth/register', + json={'username': 'owner_a', 'password': 'secret12', 'display_name': 'OwnerA'}, + ) + card = client.post( + '/api/cards', + json={'title': '他人卡片', 'summary': 'owner a', 'opening_text': 'open'}, + ).get_json()['card'] + card_id = card['card_id'] + + client.post('/api/auth/logout', json={}) + client.post( + '/api/auth/register', + json={'username': 'owner_b', 'password': 'secret12', 'display_name': 'OwnerB'}, + ) + denied = client.patch( + f'/api/cards/{card_id}', + json={'title': '他人卡片', 'summary': '篡改', 'opening_text': 'x'}, + ) + assert denied.status_code == 403 + assert denied.get_json()['ok'] is False diff --git a/tests/test_card_modes.py b/tests/test_card_modes.py new file mode 100644 index 0000000..c712d18 --- /dev/null +++ b/tests/test_card_modes.py @@ -0,0 +1,270 @@ +from __future__ import annotations + +import json + +import pytest + + +def test_non_fog_card_keeps_custom_opening_scene(room_manager, persistence): + card = persistence.get_game_card('sunken_arcade') + assert card is not None + + created = room_manager.create_room('Host', game_card=card) + room_id = created['room_id'] + host_id = created['player_id'] + + room_manager.start_game(room_id, host_id) + snapshot = room_manager.get_snapshot(room_id) + + assert snapshot['game_card']['card_id'] == 'sunken_arcade' + assert snapshot['game_state']['scene_id'] == 'opening' + assert '沉没游乐城' in snapshot['game_state']['global_flags']['game_card_title'] + assert isinstance(snapshot['game_state']['global_flags'].get('card_content_pack'), dict) + + +def test_game_ended_blocks_chat(room_manager): + created = room_manager.create_room('Host') + room_id = created['room_id'] + host_id = created['player_id'] + + room_manager.start_game(room_id, host_id) + room = room_manager.persistence.get_room(room_id) + state = room['game_state'] + state['game_phase'] = 'ended' + room_manager.state_manager.set_room_state(room_id, 'ended', state) + + with pytest.raises(ValueError, match='game_ended'): + room_manager.submit_chat(room_id, host_id, 'hello') + + +def test_custom_card_end_conditions_can_end_game(room_manager, persistence): + custom_card = persistence.create_game_card( + title='战斗测试卡', + subtitle='', + summary='用于验证自定义结束条件', + cover_url='/static/img/cards/fogharbor.svg', + opening_text='战斗即将开始。', + starter_goals=['提交一次战斗行动'], + state_schema={ + 'metrics': [ + {'key': 'hp', 'label': '队伍生命', 'min': 0, 'max': 100, 'initial': 50}, + {'key': 'rage', 'label': '敌方怒气', 'min': 0, 'max': 100, 'initial': 10}, + ], + 'ui': { + 'status_tracks': [ + {'key': 'hp', 'label': '队伍生命', 'min': 0, 'max': 100, 'color': '#ff5a4d'}, + {'key': 'rage', 'label': '敌方怒气', 'min': 0, 'max': 100, 'color': '#6aa3ff'}, + ] + }, + }, + content_pack={ + 'end_conditions': [ + { + 'id': 'team_wiped', + 'metric': 'hp', + 'op': '<=', + 'value': 0, + 'ending_state': 'defeat', + 'ending_summary': '生命归零,队伍倒下。', + } + ] + }, + creator_user_id='test-user', + ) + + created = room_manager.create_room('Host', game_card=custom_card) + room_id = created['room_id'] + host_id = created['player_id'] + room_manager.start_game(room_id, host_id) + + room_manager.deepseek_client.chat_json = lambda *args, **kwargs: { + 'ok': True, + 'content': json.dumps( + { + 'narration': '敌方重击命中,队伍承受严重伤害。', + 'npc_dialogues': [], + 'state_updates': { + 'danger_delta': 0, + 'add_clues': [], + 'set_flags': {'hp_delta': -80}, + 'npc_changes': {}, + 'inventory_add': {}, + }, + 'private_player_updates': [], + 'choices': ['紧急撤离'], + 'should_end_scene': False, + 'should_end_game': False, + 'ending_summary': None, + }, + ensure_ascii=False, + ), + } + + room_manager.submit_action(room_id, host_id, '发起正面进攻') + room_manager.resolve_round(room_id, host_id) + + snapshot = room_manager.get_snapshot(room_id) + state = snapshot['game_state'] + assert state['game_phase'] == 'ended' + assert state['ending_state'] == 'defeat' + assert state['global_flags'].get('ending_summary') == '生命归零,队伍倒下。' + + +def test_custom_card_scope_supports_per_player_metrics_and_shared_inventory(room_manager, persistence): + card = persistence.create_game_card( + title='作用域测试卡', + subtitle='', + summary='验证 shared/per_player 作用域', + cover_url='/static/img/cards/fogharbor.svg', + opening_text='你们踏入测试场。', + starter_goals=['提交行动'], + state_schema={ + 'metrics': [ + {'key': 'hp', 'label': '生命', 'min': 0, 'max': 100, 'initial': 50}, + ], + 'data_scope': { + 'metrics': 'per_player', + 'inventory': 'shared', + 'clues': 'shared', + }, + 'visibility': { + 'show_all_player_metrics': True, + 'show_all_player_inventory': True, + }, + 'ui': { + 'status_tracks': [ + {'key': 'hp', 'label': '生命', 'min': 0, 'max': 100, 'color': '#ff5a4d'}, + ] + }, + }, + content_pack={}, + creator_user_id='scope-user', + ) + created = room_manager.create_room('Host', game_card=card) + room_id = created['room_id'] + host_id = created['player_id'] + + joined, _ = room_manager.join_room(room_id, 'Mate') + mate_id = joined['player_id'] + room_manager.set_ready(room_id, mate_id, True) + room_manager.start_game(room_id, host_id) + + room_manager.deepseek_client.chat_json = lambda *args, **kwargs: { + 'ok': True, + 'content': json.dumps( + { + 'narration': '敌人命中主攻位,队伍拿到补给。', + 'npc_dialogues': [], + 'state_updates': { + 'danger_delta': 0, + 'add_clues': [], + 'set_flags': {'hp_delta': -10}, + 'npc_changes': {}, + 'inventory_add': {host_id: ['repair_kit']}, + }, + 'private_player_updates': [], + 'choices': ['继续推进'], + 'should_end_scene': False, + 'should_end_game': False, + 'ending_summary': None, + }, + ensure_ascii=False, + ), + } + + room_manager.submit_action(room_id, host_id, '吸引火力') + room_manager.resolve_round(room_id, host_id) + snapshot = room_manager.get_snapshot(room_id) + game_state = snapshot['game_state'] + + by_player = game_state['global_flags'].get('metric_values_by_player', {}) + assert by_player[host_id]['hp'] == 40 + assert by_player[mate_id]['hp'] == 50 + assert 'repair_kit' in game_state['global_flags'].get('shared_inventory', []) + + +def test_inventory_remove_consumes_used_item(room_manager): + created = room_manager.create_room('Host') + room_id = created['room_id'] + host_id = created['player_id'] + room_manager.start_game(room_id, host_id) + + room = room_manager.persistence.get_room(room_id) + state = room['game_state'] + state['inventory_by_player'][host_id] = ['silver_bell', 'torn_manifest'] + room_manager.state_manager.set_room_state(room_id, 'running', state) + + room_manager.deepseek_client.chat_json = lambda *args, **kwargs: { + 'ok': True, + 'content': json.dumps( + { + 'narration': '你敲响银铃吸引注意,铃体裂开彻底损毁。', + 'npc_dialogues': [], + 'state_updates': { + 'danger_delta': 0, + 'add_clues': [], + 'set_flags': {}, + 'npc_changes': {}, + 'inventory_add': {}, + 'inventory_remove': {host_id: ['silver_bell']}, + }, + 'private_player_updates': [], + 'choices': ['继续调查'], + 'should_end_scene': False, + 'should_end_game': False, + 'ending_summary': None, + }, + ensure_ascii=False, + ), + } + + room_manager.submit_action(room_id, host_id, '使用银铃引开旅店老板') + room_manager.resolve_round(room_id, host_id) + + snapshot = room_manager.get_snapshot(room_id) + inventory = snapshot['game_state']['inventory_by_player'][host_id] + assert 'silver_bell' not in inventory + assert 'torn_manifest' in inventory + + +def test_inventory_kept_when_ai_does_not_emit_remove(room_manager): + created = room_manager.create_room('Host') + room_id = created['room_id'] + host_id = created['player_id'] + room_manager.start_game(room_id, host_id) + + room = room_manager.persistence.get_room(room_id) + state = room['game_state'] + state['inventory_by_player'][host_id] = ['silver_bell'] + room_manager.state_manager.set_room_state(room_id, 'running', state) + + room_manager.deepseek_client.chat_json = lambda *args, **kwargs: { + 'ok': True, + 'content': json.dumps( + { + 'narration': '你使用银铃制造声响,守卫被成功引开。', + 'npc_dialogues': [], + 'state_updates': { + 'danger_delta': 0, + 'add_clues': [], + 'set_flags': {}, + 'npc_changes': {}, + 'inventory_add': {}, + 'inventory_remove': {}, + }, + 'private_player_updates': [], + 'choices': ['继续潜入'], + 'should_end_scene': False, + 'should_end_game': False, + 'ending_summary': None, + }, + ensure_ascii=False, + ), + } + + room_manager.submit_action(room_id, host_id, '使用银铃吸引注意') + room_manager.resolve_round(room_id, host_id) + + snapshot = room_manager.get_snapshot(room_id) + inventory = snapshot['game_state']['inventory_by_player'][host_id] + assert 'silver_bell' in inventory diff --git a/tests/test_deepseek_client.py b/tests/test_deepseek_client.py new file mode 100644 index 0000000..642aa24 --- /dev/null +++ b/tests/test_deepseek_client.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import requests + +from services.deepseek_client import DeepSeekClient + + +class DummyResponse: + def __init__(self, status_code, body): + self.status_code = status_code + self._body = body + self.text = str(body) + + def json(self): + return self._body + + +def test_missing_api_key_returns_error(): + client = DeepSeekClient(api_key="") + result = client.chat_json([{"role": "user", "content": "hi"}]) + assert result["ok"] is False + assert result["error"] == "missing_api_key" + + +def test_chat_json_success(monkeypatch): + def fake_post(url, headers, json, timeout): + assert "/v1/chat/completions" in url + assert headers["Authorization"].startswith("Bearer ") + assert json["model"] == "deepseek-chat" + return DummyResponse( + 200, + {"choices": [{"message": {"content": '{"narration":"ok"}'}}]}, + ) + + monkeypatch.setattr("services.deepseek_client.requests.post", fake_post) + client = DeepSeekClient(api_key="sk-test") + result = client.chat_json([{"role": "user", "content": "hi"}]) + assert result["ok"] is True + assert result["content"] == '{"narration":"ok"}' + + +def test_chat_json_http_error(monkeypatch): + def fake_post(url, headers, json, timeout): + return DummyResponse(401, {"error": "unauthorized"}) + + monkeypatch.setattr("services.deepseek_client.requests.post", fake_post) + client = DeepSeekClient(api_key="sk-bad") + result = client.chat_json([{"role": "user", "content": "hi"}]) + assert result["ok"] is False + assert result["error"] == "http_401" + + +def test_chat_json_dns_resolution_error(monkeypatch): + def fake_post(url, headers, json, timeout): + raise requests.ConnectionError("nodename nor servname provided, or not known") + + monkeypatch.setattr("services.deepseek_client.requests.post", fake_post) + client = DeepSeekClient(api_key="sk-test", retries=0) + result = client.chat_json([{"role": "user", "content": "hi"}]) + assert result["ok"] is False + assert result["error"] == "dns_resolution_failed" + assert "nodename nor servname" in result["detail"] + + +def test_chat_json_request_timeout(monkeypatch): + def fake_post(url, headers, json, timeout): + raise requests.Timeout("request timed out") + + monkeypatch.setattr("services.deepseek_client.requests.post", fake_post) + client = DeepSeekClient(api_key="sk-test", retries=0) + result = client.chat_json([{"role": "user", "content": "hi"}]) + assert result["ok"] is False + assert result["error"] == "request_timeout" + assert "timed out" in result["detail"] + + +def test_chat_json_read_timeout_wrapped_as_connection_error(monkeypatch): + def fake_post(url, headers, json, timeout): + raise requests.ConnectionError("HTTPSConnectionPool(host='api.deepseek.com', port=443): Read timed out.") + + monkeypatch.setattr("services.deepseek_client.requests.post", fake_post) + client = DeepSeekClient(api_key="sk-test", retries=0) + result = client.chat_json([{"role": "user", "content": "hi"}]) + assert result["ok"] is False + assert result["error"] == "request_timeout" + assert "Read timed out" in result["detail"] diff --git a/tests/test_room_governance.py b/tests/test_room_governance.py new file mode 100644 index 0000000..aeed3d4 --- /dev/null +++ b/tests/test_room_governance.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import pytest + + +def test_room_has_single_host(room_manager): + created = room_manager.create_room("Host") + room_id = created["room_id"] + + room_manager.join_room(room_id, "A") + room_manager.join_room(room_id, "B") + + snapshot = room_manager.get_snapshot(room_id) + host_rows = [row for row in snapshot["players"] if row["is_host"]] + assert len(host_rows) == 1 + assert host_rows[0]["player_id"] == snapshot["host_player_id"] + + +def test_start_game_only_once(room_manager): + created = room_manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + + room_manager.start_game(room_id, host_id) + with pytest.raises(ValueError, match="game_already_started"): + room_manager.start_game(room_id, host_id) + + +def test_cannot_join_after_game_started_without_reconnect(room_manager): + created = room_manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + + room_manager.start_game(room_id, host_id) + + with pytest.raises(ValueError, match="game_in_progress"): + room_manager.join_room(room_id, "LatePlayer") + + +def test_host_disconnect_does_not_auto_transfer(room_manager): + created = room_manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + + join_info, _ = room_manager.join_room(room_id, "Mate") + mate_id = join_info["player_id"] + + room_manager.attach_socket(room_id, host_id, "sid-host") + room_manager.attach_socket(room_id, mate_id, "sid-mate") + + room_manager.detach_socket("sid-host") + + snapshot = room_manager.get_snapshot(room_id) + assert snapshot["host_player_id"] == host_id + + host_rows = [row for row in snapshot["players"] if row["is_host"]] + assert len(host_rows) == 1 + assert host_rows[0]["player_id"] == host_id + + +def test_manual_transfer_host(room_manager): + created = room_manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + + join_info, _ = room_manager.join_room(room_id, "Mate") + mate_id = join_info["player_id"] + + room_manager.transfer_host(room_id, host_id, mate_id) + snapshot = room_manager.get_snapshot(room_id) + assert snapshot["host_player_id"] == mate_id + + +def test_host_can_kick_player_only_in_lobby(room_manager): + created = room_manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + join_info, _ = room_manager.join_room(room_id, "Mate") + mate_id = join_info["player_id"] + + room_manager.kick_player(room_id, host_id, mate_id) + snapshot = room_manager.get_snapshot(room_id) + ids = {row["player_id"] for row in snapshot["players"]} + assert mate_id not in ids + + join_info2, _ = room_manager.join_room(room_id, "Mate2") + mate2_id = join_info2["player_id"] + room_manager.set_ready(room_id, mate2_id, True) + room_manager.start_game(room_id, host_id) + + with pytest.raises(ValueError, match="only_lobby_can_kick"): + room_manager.kick_player(room_id, host_id, mate2_id) diff --git a/tests/test_room_manager_flow.py b/tests/test_room_manager_flow.py new file mode 100644 index 0000000..491ec25 --- /dev/null +++ b/tests/test_room_manager_flow.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import json +import threading + +import pytest + +from game.action_resolver import ActionResolver +from game.room_manager import RoomManager +from game.state_manager import StateManager +from game.story_engine import StoryEngine +from services.deepseek_client import DeepSeekClient +from services.persistence import SQLitePersistence + + +def _build_manager(db_path: str) -> RoomManager: + persistence = SQLitePersistence(db_path) + story_engine = StoryEngine() + state_manager = StateManager(persistence, story_engine) + action_resolver = ActionResolver() + deepseek_client = DeepSeekClient(api_key="") + return RoomManager( + persistence, + state_manager, + story_engine, + action_resolver, + deepseek_client, + max_players=8, + max_action_length=280, + ) + + +def test_start_game_writes_initial_narration(test_db_path): + manager = _build_manager(test_db_path) + created = manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + + manager.start_game(room_id, host_id) + snapshot = manager.get_snapshot(room_id) + + ai_rows = [row for row in snapshot["messages"] if row["kind"] == "ai"] + assert ai_rows + assert "雾港旅店" in ai_rows[-1]["content"] + + +def test_single_player_action_auto_resolves_flag(test_db_path): + manager = _build_manager(test_db_path) + created = manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + + manager.start_game(room_id, host_id) + action_result = manager.submit_action(room_id, host_id, "检查登记台") + + assert action_result["should_auto_resolve"] is True + + +def test_multi_player_action_waits_until_all_online_submitted(test_db_path): + manager = _build_manager(test_db_path) + created = manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + + join_info, _ = manager.join_room(room_id, "Mate") + mate_id = join_info["player_id"] + manager.set_ready(room_id, mate_id, True) + + manager.start_game(room_id, host_id) + + first = manager.submit_action(room_id, host_id, "先查看油画") + assert first["should_auto_resolve"] is False + + second = manager.submit_action(room_id, mate_id, "检查登记台") + assert second["should_auto_resolve"] is True + + +def test_same_player_cannot_submit_twice_before_round_resolve(test_db_path): + manager = _build_manager(test_db_path) + created = manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + + join_info, _ = manager.join_room(room_id, "Mate") + mate_id = join_info["player_id"] + manager.set_ready(room_id, mate_id, True) + manager.start_game(room_id, host_id) + + manager.submit_action(room_id, host_id, "先查看前台账本") + with pytest.raises(ValueError, match="action_already_submitted"): + manager.submit_action(room_id, host_id, "再翻一遍前台账本") + + +def test_ai_failure_does_not_resolve_round_or_clear_pending_actions(test_db_path): + manager = _build_manager(test_db_path) + created = manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + manager.start_game(room_id, host_id) + + manager.deepseek_client.chat_json = lambda *args, **kwargs: { + "ok": False, + "error": "request_timeout", + "detail": "Read timed out", + } + + manager.submit_action(room_id, host_id, "检查登记台") + with pytest.raises(ValueError, match="ai_call_failed:request_timeout"): + manager.resolve_round(room_id, host_id) + + snapshot = manager.get_snapshot(room_id) + assert snapshot["pending_actions"] + assert snapshot["game_state"]["round_no"] == 0 + + +def test_concurrent_resolve_round_only_one_can_run(test_db_path): + manager = _build_manager(test_db_path) + created = manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + manager.start_game(room_id, host_id) + manager.submit_action(room_id, host_id, "检查登记台") + + started = threading.Event() + release = threading.Event() + + def slow_ai(*args, **kwargs): + started.set() + release.wait(timeout=2) + return { + "ok": True, + "content": json.dumps( + { + "narration": "你翻开了登记台账本,发现被撕掉的一页。", + "npc_dialogues": [], + "state_updates": { + "danger_delta": 0, + "add_clues": [], + "set_flags": {}, + "npc_changes": {}, + "inventory_add": {}, + "inventory_remove": {}, + }, + "private_player_updates": [], + "choices": ["继续调查"], + "should_end_scene": False, + "should_end_game": False, + "ending_summary": None, + }, + ensure_ascii=False, + ), + } + + manager.deepseek_client.chat_json = slow_ai + + outcomes: list[tuple[str, str]] = [] + + def runner(): + try: + manager.resolve_round(room_id, host_id) + outcomes.append(("ok", "")) + except ValueError as exc: + outcomes.append(("err", str(exc))) + + t1 = threading.Thread(target=runner) + t1.start() + assert started.wait(timeout=1.5) + + t2 = threading.Thread(target=runner) + t2.start() + t2.join(timeout=2) + + release.set() + t1.join(timeout=2) + + ok_count = sum(1 for kind, _ in outcomes if kind == "ok") + err_codes = [code for kind, code in outcomes if kind == "err"] + assert ok_count == 1 + assert any(code == "round_resolving" for code in err_codes) + + snapshot = manager.get_snapshot(room_id) + assert snapshot["game_state"]["round_no"] == 1 diff --git a/tests/test_rooms.py b/tests/test_rooms.py new file mode 100644 index 0000000..a7254df --- /dev/null +++ b/tests/test_rooms.py @@ -0,0 +1,203 @@ +from __future__ import annotations + + +def _login(client, username: str) -> None: + client.post('/api/auth/register', json={'username': username, 'password': 'secret12'}) + + +def test_create_room_and_join_room(client): + _login(client, 'alice_room') + create_resp = client.post('/api/rooms/create', json={}) + assert create_resp.status_code == 200 + create_data = create_resp.get_json() + assert create_data['ok'] is True + + room_id = create_data['room_id'] + host_player_id = create_data['player_id'] + assert len(room_id) == 6 + + client.post('/api/auth/logout', json={}) + _login(client, 'bob_room') + join_resp = client.post( + '/api/rooms/join', + json={'room_id': room_id}, + ) + assert join_resp.status_code == 200 + join_data = join_resp.get_json() + assert join_data['ok'] is True + assert join_data['player_id'] != host_player_id + + +def test_join_room_reconnect_with_cached_player_id(client): + _login(client, 'host_reconnect') + create_data = client.post('/api/rooms/create', json={}).get_json() + room_id = create_data['room_id'] + + client.post('/api/auth/logout', json={}) + _login(client, 'member_reconnect') + join_data = client.post( + '/api/rooms/join', + json={'room_id': room_id}, + ).get_json() + + reconnect = client.post( + '/api/rooms/join', + json={'room_id': room_id, 'player_id': join_data['player_id']}, + ) + assert reconnect.status_code == 200 + reconnect_data = reconnect.get_json() + assert reconnect_data['player_id'] == join_data['player_id'] + assert reconnect_data['player_name'] == 'member_reconnect' + + +def test_join_room_missing_room(client): + _login(client, 'missing_room_user') + resp = client.post( + '/api/rooms/join', + json={'room_id': 'XXXXXX'}, + ) + assert resp.status_code == 400 + data = resp.get_json() + assert data['ok'] is False + assert '房间不存在' in data['error'] + + +def test_host_can_delete_room(client): + _login(client, 'host_delete') + created = client.post('/api/rooms/create', json={}).get_json() + room_id = created['room_id'] + host_player_id = created['player_id'] + + deleted = client.delete(f'/api/rooms/{room_id}', json={'player_id': host_player_id}) + assert deleted.status_code == 200 + assert deleted.get_json()['ok'] is True + + snapshot = client.get(f'/api/rooms/{room_id}/snapshot') + assert snapshot.status_code == 404 + + +def test_non_host_cannot_delete_room(client): + _login(client, 'host_non_delete') + created = client.post('/api/rooms/create', json={}).get_json() + room_id = created['room_id'] + + client.post('/api/auth/logout', json={}) + _login(client, 'guest_non_delete') + guest = client.post('/api/rooms/join', json={'room_id': room_id}).get_json() + + denied = client.delete(f'/api/rooms/{room_id}', json={'player_id': guest['player_id']}) + assert denied.status_code == 400 + assert '只有房主可以删除房间' in denied.get_json()['error'] + + +def test_lobby_join_requires_login_and_running_allows_spectator(client, app_instance): + _login(client, 'host_invite') + created = client.post('/api/rooms/create', json={}).get_json() + room_id = created['room_id'] + host_id = created['player_id'] + + client.post('/api/auth/logout', json={}) + denied = client.post('/api/rooms/join', json={'room_id': room_id}) + assert denied.status_code == 401 + + _login(client, 'mate_invite') + mate = client.post('/api/rooms/join', json={'room_id': room_id}).get_json() + client.post('/api/auth/logout', json={}) + _login(client, 'host_invite') + app, _ = app_instance + app.extensions['room_manager'].set_ready(room_id, mate['player_id'], True) + app.extensions['room_manager'].start_game(room_id, host_id) + client.post('/api/auth/logout', json={}) + + spectate = client.post('/api/rooms/join', json={'room_id': room_id}) + payload = spectate.get_json() + assert spectate.status_code == 200 + assert payload['ok'] is True + assert payload['is_spectator'] is True + + +def test_running_room_supports_spectator_page_without_login(client, app_instance): + _login(client, 'spectator_host') + created = client.post('/api/rooms/create', json={}).get_json() + room_id = created['room_id'] + host_id = created['player_id'] + app, _ = app_instance + app.extensions['room_manager'].start_game(room_id, host_id) + client.post('/api/auth/logout', json={}) + + page = client.get(f'/room/{room_id}?spectator=1') + assert page.status_code == 200 + html = page.get_data(as_text=True) + assert 'data-spectator="1"' in html + + +def test_my_rooms_api_keeps_ended_status(client, app_instance): + _login(client, 'ended_room_owner') + created = client.post('/api/rooms/create', json={}).get_json() + room_id = created['room_id'] + host_id = created['player_id'] + + app, _ = app_instance + manager = app.extensions['room_manager'] + persistence = app.extensions['persistence'] + manager.start_game(room_id, host_id) + + room = persistence.get_room(room_id) + state = dict(room['game_state']) + state['game_phase'] = 'ended' + state['ending_state'] = 'custom_end' + state.setdefault('global_flags', {}) + state['global_flags']['ending_summary'] = '测试结局' + persistence.update_room_state(room_id, 'ended', state) + + rooms_resp = client.get('/api/me/rooms') + payload = rooms_resp.get_json() + assert rooms_resp.status_code == 200 + assert payload['ok'] is True + row = next((item for item in payload['rooms'] if item['room_id'] == room_id), None) + assert row is not None + assert row['status'] == 'ended' + + +def test_host_can_set_room_public_and_public_list_can_see_it(client): + _login(client, 'host_public_1') + created = client.post('/api/rooms/create', json={}).get_json() + room_id = created['room_id'] + host_id = created['player_id'] + + toggled = client.patch( + f'/api/rooms/{room_id}/visibility', + json={'player_id': host_id, 'is_public': True}, + ) + assert toggled.status_code == 200 + data = toggled.get_json() + assert data['ok'] is True + assert data['is_public'] is True + assert data['snapshot']['is_public'] is True + + public_resp = client.get('/api/rooms/public?limit=20') + assert public_resp.status_code == 200 + public_data = public_resp.get_json() + assert public_data['ok'] is True + row = next((item for item in public_data['rooms'] if item['room_id'] == room_id), None) + assert row is not None + assert row['status'] == 'lobby' + + +def test_non_host_cannot_set_room_public(client): + _login(client, 'host_public_2') + created = client.post('/api/rooms/create', json={}).get_json() + room_id = created['room_id'] + + client.post('/api/auth/logout', json={}) + _login(client, 'guest_public_2') + guest = client.post('/api/rooms/join', json={'room_id': room_id}).get_json() + + denied = client.patch( + f'/api/rooms/{room_id}/visibility', + json={'player_id': guest['player_id'], 'is_public': True}, + ) + assert denied.status_code == 400 + payload = denied.get_json() + assert payload['ok'] is False + assert '只有房主可以设置房间公开状态' in payload['error'] diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..05b0c40 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from game.schemas import fallback_ai_payload, parse_json_payload, validate_ai_payload + + +def test_parse_and_validate_ai_payload_success(): + raw = '{"narration":"场景推进","npc_dialogues":[{"npc":"老板","line":"别乱动","tone":"cold"}],"state_updates":{"danger_delta":1,"add_clues":["torn_manifest"],"set_flags":{"found_service_key":true},"npc_changes":{},"inventory_add":{},"inventory_remove":{"p1":["silver_bell"]}},"private_player_updates":[],"choices":["搜查大厅"],"should_end_scene":false,"should_end_game":false,"ending_summary":null}' + payload, err = parse_json_payload(raw) + assert err is None + ok, errors, normalized = validate_ai_payload(payload or {}) + assert ok is True + assert not errors + assert normalized["state_updates"]["danger_delta"] == 1 + assert normalized["state_updates"]["inventory_remove"]["p1"] == ["silver_bell"] + assert normalized["choices"] == ["搜查大厅"] + + +def test_parse_invalid_json_returns_error(): + payload, err = parse_json_payload("not-json") + assert payload is None + assert err == "invalid_json" + + +def test_validate_invalid_payload_falls_back(): + ok, errors, normalized = validate_ai_payload({"narration": 123, "state_updates": []}) + assert ok is False + assert "invalid_narration" in errors + assert normalized["narration"] + assert isinstance(fallback_ai_payload()["choices"], list) diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..07a4fc6 --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from game.state_manager import StateManager +from game.story_engine import StoryEngine +from services.persistence import SQLitePersistence + + +def test_state_persistence_roundtrip(test_db_path): + persistence = SQLitePersistence(test_db_path) + story_engine = StoryEngine() + state_manager = StateManager(persistence, story_engine) + + state_manager.create_new_room("ROOM01", "host01", "Host") + state_manager.add_player("ROOM01", "p02", "Bob") + + room = persistence.get_room("ROOM01") + assert room is not None + assert room["game_state"]["room_id"] == "ROOM01" + assert "p02" in room["game_state"]["inventory_by_player"] + + room["game_state"]["global_flags"]["truth_revealed"] = True + state_manager.set_room_state("ROOM01", "running", room["game_state"]) + + persistence2 = SQLitePersistence(test_db_path) + room2 = persistence2.get_room("ROOM01") + assert room2 is not None + assert room2["status"] == "running" + assert room2["game_state"]["global_flags"]["truth_revealed"] is True + + +def test_snapshot_contains_players_and_messages(test_db_path): + persistence = SQLitePersistence(test_db_path) + story_engine = StoryEngine() + state_manager = StateManager(persistence, story_engine) + + state_manager.create_new_room("ROOM02", "host02", "Host") + persistence.add_or_update_player("ROOM02", "p03", "Carol") + persistence.append_message("ROOM02", "system", "hello") + + snapshot = persistence.load_room_snapshot("ROOM02") + assert snapshot is not None + assert len(snapshot["players"]) == 2 + assert snapshot["messages"][0]["content"] == "hello" diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py new file mode 100644 index 0000000..3f97820 --- /dev/null +++ b/tests/test_state_machine.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import json + + +def _ai_json(*, narration, add_clues=None, set_flags=None, danger_delta=0): + payload = { + "narration": narration, + "npc_dialogues": [{"npc": "老板", "line": "我在看着你们", "tone": "cold"}], + "state_updates": { + "danger_delta": danger_delta, + "add_clues": add_clues or [], + "set_flags": set_flags or {}, + "npc_changes": {}, + "inventory_add": {}, + }, + "private_player_updates": [], + "choices": ["继续调查", "与NPC交谈"], + "should_end_scene": False, + "should_end_game": False, + "ending_summary": None, + } + return json.dumps(payload, ensure_ascii=False) + + +def test_state_machine_start_progress_end(room_manager, persistence): + created = room_manager.create_room("Host") + room_id = created["room_id"] + host_id = created["player_id"] + + room_manager.start_game(room_id, host_id) + snapshot = room_manager.get_snapshot(room_id) + assert snapshot["status"] == "running" + assert snapshot["game_state"]["game_phase"] == "running" + + responses = [ + _ai_json(narration="你们在大厅发现被撕毁的船务清单。", add_clues=["torn_manifest"]), + _ai_json( + narration="餐厅里传来钟铃声,301 房门钥匙的线索浮现。", + add_clues=["silver_bell"], + set_flags={"room_301_unlocked": True}, + ), + _ai_json(narration="走廊尽头客房内找到女仆日记。", add_clues=["maid_diary"]), + _ai_json( + narration="地下室入口出现,你们洞悉真相并找到逃生通道。", + add_clues=["cellar_sigil", "bloodied_key"], + set_flags={"cellar_entry_found": True, "truth_revealed": True, "escape_route_open": True}, + ), + ] + + def fake_chat_json(messages, temperature=0.6, max_tokens=800): + return {"ok": True, "content": responses.pop(0)} + + room_manager.deepseek_client.chat_json = fake_chat_json + + for i in range(4): + room_manager.submit_action(room_id, host_id, f"第{i + 1}轮行动") + room_manager.resolve_round(room_id, host_id) + + final_snapshot = room_manager.get_snapshot(room_id) + game_state = final_snapshot["game_state"] + assert game_state["game_phase"] == "ended" + assert game_state["ending_state"] == "escaped" + assert game_state["scene_id"] == "basement" + assert len(game_state["discovered_clues"]) >= 4 + + room = persistence.get_room(room_id) + assert room["status"] == "ended" diff --git a/tests/test_templates.py b/tests/test_templates.py new file mode 100644 index 0000000..6823d59 --- /dev/null +++ b/tests/test_templates.py @@ -0,0 +1,51 @@ +from __future__ import annotations + + +def test_room_template_uses_local_socketio_client(client): + client.post('/api/auth/register', json={'username': 'template_host', 'password': 'secret12'}) + created = client.post('/api/rooms/create', json={}).get_json() + room_id = created['room_id'] + player_id = created['player_id'] + + resp = client.get(f'/room/{room_id}?player_id={player_id}') + assert resp.status_code == 200 + + html = resp.get_data(as_text=True) + assert '/static/js/socket.io.min.js' in html + assert '/socket.io/socket.io.js' not in html + assert 'onsubmit="return false;"' in html + + +def test_card_editor_route_requires_login(client): + guest = client.get('/cards/new') + assert guest.status_code == 302 + assert '/login' in guest.headers['Location'] + + +def test_card_editor_page_and_permissions(client): + client.post( + '/api/auth/register', + json={'username': 'editor_owner', 'password': 'secret12', 'display_name': 'Owner'}, + ) + new_page = client.get('/cards/new') + assert new_page.status_code == 200 + assert '游戏卡编辑器' in new_page.get_data(as_text=True) + + created = client.post( + '/api/cards', + json={'title': '编辑页卡片', 'summary': 'for edit page', 'opening_text': '开场'}, + ).get_json()['card'] + card_id = created['card_id'] + + own_edit = client.get(f'/cards/{card_id}/edit') + assert own_edit.status_code == 200 + assert 'data-card-id' in own_edit.get_data(as_text=True) + + client.post('/api/auth/logout', json={}) + client.post( + '/api/auth/register', + json={'username': 'editor_other', 'password': 'secret12', 'display_name': 'Other'}, + ) + denied = client.get(f'/cards/{card_id}/edit') + assert denied.status_code == 302 + assert '/plaza' in denied.headers['Location']