Files
Echo-Tavern/app.py
T
leeexinandClaude Opus 4.6 ae478755be feat(auth,admin): add LinuxDO OAuth login and admin settings
Implement LinuxDO OAuth authorization-code login flow with state validation and local account binding, and add admin APIs/UI to manage OAuth runtime settings with URL validation and secret masking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:14:46 +08:00

2088 lines
91 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
import re
import secrets
from urllib.parse import urlencode, urlparse
from typing import Any
import requests
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, DEFAULT_SETTINGS, SETTINGS_FILE
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 load_runtime_settings() -> dict[str, Any]:
defaults = dict(DEFAULT_SETTINGS)
defaults["linuxdo_oauth"] = dict(DEFAULT_SETTINGS.get("linuxdo_oauth", {}))
try:
payload = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return defaults
if not isinstance(payload, dict):
return defaults
merged = dict(defaults)
merged.update(payload)
raw_oauth = payload.get("linuxdo_oauth", {})
if isinstance(raw_oauth, dict):
merged["linuxdo_oauth"] = {**defaults["linuxdo_oauth"], **raw_oauth}
else:
merged["linuxdo_oauth"] = dict(defaults["linuxdo_oauth"])
return merged
def save_runtime_settings(settings_payload: dict[str, Any]) -> None:
SETTINGS_FILE.write_text(
json.dumps(settings_payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _is_valid_http_url(url_text: Any) -> bool:
url = str(url_text or "").strip()
if not url:
return False
parsed = urlparse(url)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def get_linuxdo_oauth_config(*, mask_secret: bool = False) -> dict[str, Any]:
defaults = DEFAULT_SETTINGS.get("linuxdo_oauth", {})
runtime = load_runtime_settings().get("linuxdo_oauth", {})
raw = runtime if isinstance(runtime, dict) else {}
cfg = {
"enabled": bool(raw.get("enabled", defaults.get("enabled", False))),
"client_id": str(raw.get("client_id", defaults.get("client_id", ""))).strip(),
"client_secret": str(raw.get("client_secret", defaults.get("client_secret", ""))).strip(),
"authorize_url": str(raw.get("authorize_url", defaults.get("authorize_url", ""))).strip(),
"token_url": str(raw.get("token_url", defaults.get("token_url", ""))).strip(),
"userinfo_url": str(raw.get("userinfo_url", defaults.get("userinfo_url", ""))).strip(),
"scope": str(raw.get("scope", defaults.get("scope", ""))).strip(),
}
if mask_secret:
secret = cfg["client_secret"]
cfg["client_secret"] = "" if not secret else ("*" * 8)
cfg["client_secret_configured"] = bool(secret)
return cfg
def oauth_error_redirect(message: str) -> Any:
return redirect(url_for("login_page", oauth_error=message))
def resolve_linuxdo_identity(userinfo: dict[str, Any]) -> tuple[str, str]:
raw_id = userinfo.get("id")
if raw_id in {None, ""} and isinstance(userinfo.get("user"), dict):
raw_id = userinfo["user"].get("id")
if raw_id in {None, ""}:
raise ValueError("missing_user_id")
safe_id = re.sub(r"[^a-z0-9_]+", "_", str(raw_id).strip().lower()).strip("_") or "user"
username = f"linuxdo_{safe_id}"[:32]
if len(username) < 3:
username = "linuxdo_user"
display_name = str(
userinfo.get("name")
or userinfo.get("username")
or (userinfo.get("user", {}) if isinstance(userinfo.get("user"), dict) else {}).get("username")
or username
).strip()
return username, (display_name[:24] or username)
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/<card_id>/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:
oauth_error = str(request.args.get("oauth_error", "")).strip()[:120]
return render_template("login.html", user=serialize_user(current_user()), oauth_error=oauth_error)
@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/auth/oauth/linuxdo/start")
def auth_oauth_linuxdo_start_api() -> Any:
cfg = get_linuxdo_oauth_config()
if not cfg.get("enabled"):
return jsonify({"ok": False, "error": "Linux DO OAuth 未启用。"}), 400
required_fields = ["client_id", "client_secret", "authorize_url", "token_url", "userinfo_url"]
missing = [field for field in required_fields if not str(cfg.get(field, "")).strip()]
if missing:
return jsonify({"ok": False, "error": f"Linux DO OAuth 配置缺失:{', '.join(missing)}"}), 400
for url_field in ("authorize_url", "token_url", "userinfo_url"):
if not _is_valid_http_url(cfg.get(url_field, "")):
return jsonify({"ok": False, "error": f"Linux DO OAuth 配置无效:{url_field}"}), 400
state = secrets.token_urlsafe(24)
session["linuxdo_oauth_state"] = state
callback_url = url_for("auth_oauth_linuxdo_callback_api", _external=True)
query = {
"client_id": cfg["client_id"],
"response_type": "code",
"redirect_uri": callback_url,
"state": state,
}
scope = str(cfg.get("scope", "")).strip()
if scope:
query["scope"] = scope
authorize_url = f"{cfg['authorize_url']}?{urlencode(query)}"
return redirect(authorize_url)
@app.get("/api/auth/oauth/linuxdo/callback")
def auth_oauth_linuxdo_callback_api() -> Any:
cfg = get_linuxdo_oauth_config()
if not cfg.get("enabled"):
return oauth_error_redirect("Linux DO OAuth 未启用")
state = str(request.args.get("state", "")).strip()
code = str(request.args.get("code", "")).strip()
expected_state = str(session.pop("linuxdo_oauth_state", "")).strip()
if not code:
return oauth_error_redirect("缺少授权 code")
if not state or state != expected_state:
return oauth_error_redirect("state 校验失败")
callback_url = url_for("auth_oauth_linuxdo_callback_api", _external=True)
try:
token_resp = requests.post(
cfg["token_url"],
data={
"grant_type": "authorization_code",
"code": code,
"client_id": cfg["client_id"],
"client_secret": cfg["client_secret"],
"redirect_uri": callback_url,
},
timeout=12,
)
token_resp.raise_for_status()
token_payload = token_resp.json()
except Exception as exc:
return oauth_error_redirect(f"获取 access_token 失败: {exc}")
access_token = str(token_payload.get("access_token", "")).strip()
if not access_token:
return oauth_error_redirect("access_token 为空")
try:
userinfo_resp = requests.get(
cfg["userinfo_url"],
headers={"Authorization": f"Bearer {access_token}"},
timeout=12,
)
userinfo_resp.raise_for_status()
userinfo = userinfo_resp.json()
if not isinstance(userinfo, dict):
return oauth_error_redirect("userinfo 响应格式无效")
except Exception as exc:
return oauth_error_redirect(f"获取用户信息失败: {exc}")
try:
username, display_name = resolve_linuxdo_identity(userinfo)
except ValueError:
return oauth_error_redirect("用户信息缺少 id")
user = persistence.get_user_by_username(username)
if user is None:
random_password = secrets.token_urlsafe(32)
try:
user = persistence.create_user(
username=username,
password_hash=generate_password_hash(random_password, method="pbkdf2:sha256"),
display_name=display_name,
)
except ValueError as exc:
return oauth_error_redirect(f"创建本地账号失败: {readable_error(str(exc))}")
session["user_id"] = user["user_id"]
return redirect(url_for("index"))
@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/<card_id>")
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/<user_id>/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/<room_id>")
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.get("/api/admin/settings/oauth/linuxdo")
def admin_linuxdo_oauth_get_api() -> Any:
_, error_resp = require_admin_json()
if error_resp:
return error_resp
return jsonify({"ok": True, "config": get_linuxdo_oauth_config(mask_secret=True)})
@app.patch("/api/admin/settings/oauth/linuxdo")
def admin_linuxdo_oauth_patch_api() -> Any:
_, error_resp = require_admin_json()
if error_resp:
return error_resp
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
payload = {}
try:
raw_settings = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raw_settings = {}
if not isinstance(raw_settings, dict):
raw_settings = {}
defaults = DEFAULT_SETTINGS.get("linuxdo_oauth", {})
existing_raw = raw_settings.get("linuxdo_oauth", {})
existing_cfg = {**defaults, **(existing_raw if isinstance(existing_raw, dict) else {})}
enabled = bool(payload.get("enabled", existing_cfg.get("enabled", False)))
client_id = str(payload.get("client_id", existing_cfg.get("client_id", ""))).strip()
incoming_secret = payload.get("client_secret")
if incoming_secret is None:
client_secret = str(existing_cfg.get("client_secret", "")).strip()
else:
normalized_secret = str(incoming_secret).strip()
client_secret = normalized_secret or str(existing_cfg.get("client_secret", "")).strip()
authorize_url = str(payload.get("authorize_url", existing_cfg.get("authorize_url", ""))).strip()
token_url = str(payload.get("token_url", existing_cfg.get("token_url", ""))).strip()
userinfo_url = str(payload.get("userinfo_url", existing_cfg.get("userinfo_url", ""))).strip()
scope = str(payload.get("scope", existing_cfg.get("scope", ""))).strip()
for field, url_value in (
("authorize_url", authorize_url),
("token_url", token_url),
("userinfo_url", userinfo_url),
):
if not _is_valid_http_url(url_value):
return jsonify({"ok": False, "error": f"Linux DO OAuth 配置无效:{field}"}), 400
if enabled and not client_id:
return jsonify({"ok": False, "error": "Linux DO OAuth 配置缺失:client_id"}), 400
if enabled and not client_secret:
return jsonify({"ok": False, "error": "Linux DO OAuth 配置缺失:client_secret"}), 400
raw_settings["linuxdo_oauth"] = {
"enabled": enabled,
"client_id": client_id,
"client_secret": client_secret,
"authorize_url": authorize_url,
"token_url": token_url,
"userinfo_url": userinfo_url,
"scope": scope,
}
save_runtime_settings(raw_settings)
return jsonify({"ok": True, "config": get_linuxdo_oauth_config(mask_secret=True)})
@app.patch("/api/admin/cards/<card_id>")
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/<card_id>")
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/<card_id>")
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/<card_id>")
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/<room_id>")
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/<room_id>/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/<room_id>")
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/<room_id>/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/<room_id>")
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/<room_id>/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,
)