2098 lines
91 KiB
Python
2098 lines
91 KiB
Python
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", "aurora", "graphite"}
|
||
|
||
|
||
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/leaderboard")
|
||
def leaderboard_api() -> Any:
|
||
limit_raw = request.args.get("limit", "20")
|
||
try:
|
||
limit = max(1, min(100, int(str(limit_raw))))
|
||
except ValueError:
|
||
limit = 20
|
||
rows = persistence.list_player_leaderboard(limit=limit)
|
||
return jsonify({"ok": True, "leaderboard": 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,
|
||
)
|