diff --git a/.gitignore b/.gitignore index dce55c7..d6748ea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ __pycache__/ .DS_Store .claude/ gitea.config +CLAUDE.md +memory/ +test/ diff --git a/app.py b/app.py index e1b25df..0b38f5c 100644 --- a/app.py +++ b/app.py @@ -64,7 +64,7 @@ ERROR_TEXTS = { "spectator_readonly": "观战模式下不能执行该操作。", "ai_invalid_json": "AI 返回了无效 JSON,本轮未结算,请稍后重试。", } -ALLOWED_THEMES = {"minimal", "dopamine", "midnight", "mint", "sunset"} +ALLOWED_THEMES = {"minimal", "dopamine", "midnight", "mint", "sunset", "aurora", "graphite"} def readable_error(error_code: str) -> str: @@ -1610,6 +1610,16 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: 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") diff --git a/services/persistence.py b/services/persistence.py index 918a03e..ce05eef 100644 --- a/services/persistence.py +++ b/services/persistence.py @@ -876,6 +876,126 @@ class SQLitePersistence: ).fetchall() return [dict(row) for row in rows] + def _is_winning_ending(self, ending_state: Any) -> bool: + ending = str(ending_state or "").strip().lower() + if not ending: + return False + return ending in { + "escaped", + "sacrifice", + "pact", + "victory", + "success", + "win", + "custom_end", + } + + def list_player_leaderboard(self, limit: int = 20) -> list[dict[str, Any]]: + try: + safe_limit = max(1, min(100, int(limit))) + except (TypeError, ValueError): + safe_limit = 20 + + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT + u.user_id, + u.username, + u.display_name, + u.avatar_color, + u.avatar_url, + u.theme, + p.room_id, + r.game_state, + r.updated_at + FROM users u + JOIN players p ON p.user_id = u.user_id + JOIN rooms r ON r.room_id = p.room_id + WHERE r.status = 'ended' + ORDER BY r.updated_at DESC + """ + ).fetchall() + + summary: dict[str, dict[str, Any]] = {} + for row in rows: + item = dict(row) + user_id = str(item.get("user_id", "")).strip() + room_id = str(item.get("room_id", "")).strip() + if not user_id or not room_id: + continue + + entry = summary.get(user_id) + if entry is None: + entry = { + "user_id": user_id, + "username": str(item.get("username", "")).strip(), + "display_name": str(item.get("display_name", "")).strip(), + "avatar_color": str(item.get("avatar_color", "#0070f2")).strip() or "#0070f2", + "avatar_url": str(item.get("avatar_url", "")).strip(), + "theme": str(item.get("theme", "dopamine")).strip() or "dopamine", + "played_games": 0, + "wins": 0, + "total_rounds": 0, + "last_played_at": "", + "_seen_rooms": set(), + } + summary[user_id] = entry + + seen_rooms = entry["_seen_rooms"] + if room_id in seen_rooms: + continue + seen_rooms.add(room_id) + + game_state_raw = item.get("game_state") + try: + game_state = json.loads(game_state_raw) if isinstance(game_state_raw, str) else {} + except json.JSONDecodeError: + game_state = {} + if not isinstance(game_state, dict): + game_state = {} + + entry["played_games"] += 1 + if self._is_winning_ending(game_state.get("ending_state")): + entry["wins"] += 1 + + round_no_raw = game_state.get("round_no", 0) + try: + round_no = max(0, int(round_no_raw)) + except (TypeError, ValueError): + round_no = 0 + entry["total_rounds"] += round_no + + updated_at = str(item.get("updated_at", "")).strip() + if updated_at and updated_at > entry["last_played_at"]: + entry["last_played_at"] = updated_at + + results: list[dict[str, Any]] = [] + for entry in summary.values(): + entry.pop("_seen_rooms", None) + played = int(entry["played_games"]) + wins = int(entry["wins"]) + win_rate = round((wins / played) * 100, 1) if played else 0.0 + entry["win_rate"] = win_rate + entry["score"] = wins * 100 + played * 12 + int(entry["total_rounds"]) + results.append(entry) + + results.sort( + key=lambda x: ( + int(x.get("wins", 0)), + int(x.get("played_games", 0)), + int(x.get("score", 0)), + str(x.get("last_played_at", "")), + str(x.get("username", "")), + ), + reverse=True, + ) + + ranked = results[:safe_limit] + for idx, row in enumerate(ranked, start=1): + row["rank"] = idx + return ranked + def list_users(self, limit: int = 500) -> list[dict[str, Any]]: with self._lock, self._connect() as conn: rows = conn.execute( diff --git a/static/css/style.css b/static/css/style.css index f048a6c..64ab178 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -122,6 +122,47 @@ body[data-theme="sunset"] { --ghost-bg: rgba(255, 248, 241, 0.93); } +body[data-theme="aurora"] { + --bg: #eef7ff; + --surface: #f8fcff; + --surface-soft: #edf8ff; + --text: #17263f; + --muted: #4e6688; + --border: #b6d7f7; + --n-red: #ff5f7a; + --n-blue: #2a8cff; + --n-yellow: #ffcb5e; + --n-green: #00c99a; + --btn-primary-end: #3ea7ff; + --btn-secondary-start: #42d5d0; + --btn-secondary-end: #2a8cff; + --link-btn-start: #2bb6ff; + --link-btn-end: #2a8cff; + --ghost-fg: #235a9d; + --ghost-bg: rgba(245, 251, 255, 0.94); +} + +body[data-theme="graphite"] { + --bg: #1b1f28; + --surface: #242a34; + --surface-soft: #2a313d; + --text: #edf3ff; + --muted: #a8b3c6; + --border: #3c4759; + --n-red: #ff6d6d; + --n-blue: #74a6ff; + --n-yellow: #f8c66d; + --n-green: #4bd8a0; + --btn-primary-end: #4c7bff; + --btn-secondary-start: #56b3ff; + --btn-secondary-end: #3f66db; + --link-btn-start: #69a8ff; + --link-btn-end: #3f66db; + --ghost-fg: #d6e3ff; + --ghost-bg: rgba(36, 42, 52, 0.92); + --shadow: 0 16px 42px rgba(7, 11, 18, 0.5); +} + * { box-sizing: border-box; } @@ -170,6 +211,22 @@ body[data-theme="sunset"] { var(--bg); } +body[data-theme="aurora"] { + background: + radial-gradient(circle at 10% -8%, rgba(0, 201, 154, 0.2) 0, transparent 34%), + radial-gradient(circle at 92% 2%, rgba(42, 140, 255, 0.18) 0, transparent 30%), + radial-gradient(circle at 50% 120%, rgba(255, 95, 122, 0.16) 0, transparent 34%), + var(--bg); +} + +body[data-theme="graphite"] { + background: + radial-gradient(circle at 12% -8%, rgba(116, 166, 255, 0.24) 0, transparent 32%), + radial-gradient(circle at 88% 0%, rgba(255, 109, 109, 0.22) 0, transparent 30%), + radial-gradient(circle at 50% 120%, rgba(75, 216, 160, 0.2) 0, transparent 36%), + linear-gradient(180deg, #181c24 0%, #1f2530 52%, #171b23 100%); +} + h1, h2, h3 { @@ -587,6 +644,11 @@ body[data-theme="midnight"] .card, body[data-theme="midnight"] .panel, body[data-theme="midnight"] .meta-block, body[data-theme="midnight"] .invite-panel, +body[data-theme="graphite"] .card, +body[data-theme="graphite"] .panel, +body[data-theme="graphite"] .meta-block, +body[data-theme="graphite"] .invite-panel, +body[data-theme="graphite"] .auth-card, body[data-theme="midnight"] .auth-card { background: linear-gradient(155deg, rgba(21, 30, 58, 0.92), rgba(15, 22, 45, 0.9)); border-color: rgba(82, 116, 208, 0.52); @@ -594,7 +656,10 @@ body[data-theme="midnight"] .auth-card { body[data-theme="midnight"] input, body[data-theme="midnight"] textarea, -body[data-theme="midnight"] select { +body[data-theme="midnight"] select, +body[data-theme="graphite"] input, +body[data-theme="graphite"] textarea, +body[data-theme="graphite"] select { background: rgba(17, 25, 49, 0.88); color: #ebf0ff; border-color: rgba(86, 119, 207, 0.65); @@ -602,6 +667,9 @@ body[data-theme="midnight"] select { body[data-theme="midnight"] ul li, body[data-theme="midnight"] .compact-list li, +body[data-theme="graphite"] ul li, +body[data-theme="graphite"] .compact-list li, +body[data-theme="graphite"] .msg, body[data-theme="midnight"] .msg { background: rgba(22, 33, 66, 0.72); border-color: rgba(94, 127, 218, 0.5); @@ -611,6 +679,10 @@ body[data-theme="midnight"] .msg { body[data-theme="midnight"] .helper-text, body[data-theme="midnight"] .brand-block p, body[data-theme="midnight"] .room-title p, +body[data-theme="graphite"] .helper-text, +body[data-theme="graphite"] .brand-block p, +body[data-theme="graphite"] .room-title p, +body[data-theme="graphite"] .header-note, body[data-theme="midnight"] .header-note { color: #b9c8f2; } @@ -1830,6 +1902,66 @@ body[data-theme="sunset"] .link-btn { background: linear-gradient(135deg, #4d7dff 0%, #6d90ff 100%) !important; } +/* Aurora: cool gradient, bright glass panels */ +body[data-theme="aurora"] { + background: + radial-gradient(circle at 14% -10%, rgba(0, 201, 154, 0.26), transparent 34%), + radial-gradient(circle at 88% 2%, rgba(42, 140, 255, 0.25), transparent 30%), + linear-gradient(180deg, #eef7ff 0%, #e5f2ff 58%, #edf7ff 100%) !important; +} + +body[data-theme="aurora"] .card, +body[data-theme="aurora"] .panel, +body[data-theme="aurora"] .meta-block, +body[data-theme="aurora"] .auth-card, +body[data-theme="aurora"] .invite-panel { + background: rgba(250, 253, 255, 0.94) !important; + border: 1px solid #9dc8ec !important; + box-shadow: 0 12px 30px rgba(28, 110, 173, 0.14) !important; +} + +body[data-theme="aurora"] button { + background: linear-gradient(135deg, #00c99a 0%, #2a8cff 100%) !important; +} + +body[data-theme="aurora"] .secondary-btn, +body[data-theme="aurora"] .link-btn { + background: linear-gradient(135deg, #2bb6ff 0%, #2a8cff 100%) !important; +} + +/* Graphite: muted industrial dark */ +body[data-theme="graphite"] { + background: + radial-gradient(circle at 12% -8%, rgba(116, 166, 255, 0.3), transparent 34%), + radial-gradient(circle at 90% 0%, rgba(255, 109, 109, 0.24), transparent 30%), + linear-gradient(180deg, #141820 0%, #1d232d 58%, #12161d 100%) !important; +} + +body[data-theme="graphite"] .card, +body[data-theme="graphite"] .panel, +body[data-theme="graphite"] .meta-block, +body[data-theme="graphite"] .auth-card, +body[data-theme="graphite"] .invite-panel { + border: 1px solid rgba(129, 149, 181, 0.52) !important; + box-shadow: 0 14px 38px rgba(0, 0, 0, 0.35) !important; +} + +body[data-theme="graphite"] button, +body[data-theme="graphite"] .link-btn { + background: linear-gradient(135deg, #5a87ff 0%, #3f66db 100%) !important; + border: 1px solid rgba(163, 187, 236, 0.5) !important; +} + +body[data-theme="graphite"] .secondary-btn { + background: linear-gradient(135deg, #56b3ff 0%, #3f66db 100%) !important; +} + +body[data-theme="graphite"] .ghost-btn { + background: rgba(42, 49, 61, 0.9) !important; + color: #d6e3ff !important; + border-color: rgba(117, 139, 176, 0.6) !important; +} + /* ===== Room Theme Contrast Guardrails ===== */ /* Light-background themes in room view: force high-contrast titles/labels */ body[data-theme="mint"].room-page .quick-panel h2, @@ -1837,6 +1969,11 @@ body[data-theme="mint"].room-page .chat-panel h3, body[data-theme="mint"].room-page .meta-panel h3, body[data-theme="mint"].room-page .meta-block h3, body[data-theme="mint"].room-page .story-major-block h3, +body[data-theme="aurora"].room-page .quick-panel h2, +body[data-theme="aurora"].room-page .chat-panel h3, +body[data-theme="aurora"].room-page .meta-panel h3, +body[data-theme="aurora"].room-page .meta-block h3, +body[data-theme="aurora"].room-page .story-major-block h3, body[data-theme="sunset"].room-page .quick-panel h2, body[data-theme="sunset"].room-page .chat-panel h3, body[data-theme="sunset"].room-page .meta-panel h3, @@ -1848,12 +1985,15 @@ body[data-theme="sunset"].room-page .story-major-block h3 { body[data-theme="mint"].room-page .quick-panel label, body[data-theme="mint"].room-page .chat-panel label, +body[data-theme="aurora"].room-page .quick-panel label, +body[data-theme="aurora"].room-page .chat-panel label, body[data-theme="sunset"].room-page .quick-panel label, body[data-theme="sunset"].room-page .chat-panel label { color: #334155 !important; } body[data-theme="mint"].room-page .quick-hint, +body[data-theme="aurora"].room-page .quick-hint, body[data-theme="sunset"].room-page .quick-hint { color: #475569 !important; } @@ -1861,6 +2001,9 @@ body[data-theme="sunset"].room-page .quick-hint { body[data-theme="mint"].room-page #choices-title, body[data-theme="mint"].room-page #clues-title, body[data-theme="mint"].room-page #inventory-title, +body[data-theme="aurora"].room-page #choices-title, +body[data-theme="aurora"].room-page #clues-title, +body[data-theme="aurora"].room-page #inventory-title, body[data-theme="sunset"].room-page #choices-title, body[data-theme="sunset"].room-page #clues-title, body[data-theme="sunset"].room-page #inventory-title { @@ -1868,11 +2011,13 @@ body[data-theme="sunset"].room-page #inventory-title { } body[data-theme="mint"].room-page .status-grid, +body[data-theme="aurora"].room-page .status-grid, body[data-theme="sunset"].room-page .status-grid { color: #1f2937 !important; } body[data-theme="mint"].room-page .meta-panel li, +body[data-theme="aurora"].room-page .meta-panel li, body[data-theme="sunset"].room-page .meta-panel li { color: #1f2937 !important; } diff --git a/static/js/lobby.js b/static/js/lobby.js index 073ef01..608ec62 100644 --- a/static/js/lobby.js +++ b/static/js/lobby.js @@ -16,6 +16,7 @@ const createCardGallery = document.getElementById('create-card-gallery'); const selectedCardDetail = document.getElementById('selected-card-detail'); const rejoinList = document.getElementById('rejoin-list'); + const leaderboardList = document.getElementById('leaderboard-list'); const statusEl = document.getElementById('status'); const logoutBtn = document.getElementById('logout-btn'); const topUserBadge = document.getElementById('top-user-badge'); @@ -180,7 +181,7 @@ const normalizeTheme = (theme) => { const value = String(theme || '').trim().toLowerCase(); - if (['minimal', 'dopamine', 'midnight', 'mint', 'sunset'].includes(value)) return value; + if (['minimal', 'dopamine', 'midnight', 'mint', 'sunset', 'aurora', 'graphite'].includes(value)) return value; return 'dopamine'; }; @@ -257,11 +258,31 @@ }); }; + const renderLeaderboard = (rows) => { + if (!leaderboardList) return; + leaderboardList.textContent = ''; + if (!rows.length) { + const li = document.createElement('li'); + li.textContent = '暂无已完成对局数据'; + leaderboardList.appendChild(li); + return; + } + rows.forEach((row) => { + const playedGames = Number(row.played_games || 0); + const wins = Number(row.wins || 0); + const winRate = Number(row.win_rate || 0).toFixed(1); + const li = document.createElement('li'); + li.textContent = `#${row.rank || '-'} ${row.username || '匿名玩家'} · 胜场 ${wins}/${playedGames} · 胜率 ${winRate}% · 回合 ${row.total_rounds || 0}`; + leaderboardList.appendChild(li); + }); + }; + const loadBootstrap = async () => { try { - const [cardsResp, roomsResp] = await Promise.all([ + const [cardsResp, roomsResp, leaderboardResp] = await Promise.all([ getJson('/api/cards'), getJson('/api/me/rooms'), + getJson('/api/leaderboard?limit=10'), ]); if (cardsResp.ok) { renderCards(cardsResp.cards || []); @@ -269,6 +290,9 @@ if (roomsResp.ok) { renderRejoinRooms(roomsResp.rooms || []); } + if (leaderboardResp.ok) { + renderLeaderboard(leaderboardResp.leaderboard || []); + } } catch (error) { setStatus(`加载大厅信息失败: ${error}`); } diff --git a/static/js/theme.js b/static/js/theme.js index 822a337..591c7c1 100644 --- a/static/js/theme.js +++ b/static/js/theme.js @@ -3,7 +3,7 @@ if (!body) return; const THEME_KEY = 'echo-theme'; - const THEMES = new Set(['minimal', 'dopamine', 'midnight', 'mint', 'sunset']); + const THEMES = new Set(['minimal', 'dopamine', 'midnight', 'mint', 'sunset', 'aurora', 'graphite']); const normalizeTheme = (value) => { const theme = String(value || '').trim().toLowerCase(); diff --git a/templates/index.html b/templates/index.html index 88abde1..799c582 100644 --- a/templates/index.html +++ b/templates/index.html @@ -57,6 +57,9 @@

快速重连

+

玩家排行榜

+

按胜场、参与局数和回合数综合排序。

+
@@ -83,6 +86,8 @@ + + diff --git a/tests/test_auth_cards.py b/tests/test_auth_cards.py index f980414..97591a2 100644 --- a/tests/test_auth_cards.py +++ b/tests/test_auth_cards.py @@ -91,13 +91,17 @@ def test_profile_invalid_theme_keeps_previous(client): '/api/auth/register', json={'username': 'theme_user_1', 'password': 'secret12', 'display_name': 'ThemeUser'}, ) - first = client.patch('/api/me/profile', json={'theme': 'mint'}).get_json() + first = client.patch('/api/me/profile', json={'theme': 'aurora'}).get_json() assert first['ok'] is True - assert first['user']['theme'] == 'mint' + assert first['user']['theme'] == 'aurora' + + second_ok = client.patch('/api/me/profile', json={'theme': 'graphite'}).get_json() + assert second_ok['ok'] is True + assert second_ok['user']['theme'] == 'graphite' second = client.patch('/api/me/profile', json={'theme': 'unknown_theme'}).get_json() assert second['ok'] is True - assert second['user']['theme'] == 'mint' + assert second['user']['theme'] == 'graphite' def test_user_can_rejoin_running_room_by_account(client, app_instance): diff --git a/tests/test_rooms.py b/tests/test_rooms.py index a7254df..4e6e68f 100644 --- a/tests/test_rooms.py +++ b/tests/test_rooms.py @@ -201,3 +201,56 @@ def test_non_host_cannot_set_room_public(client): payload = denied.get_json() assert payload['ok'] is False assert '只有房主可以设置房间公开状态' in payload['error'] + + +def test_leaderboard_api_returns_ranked_players(client, app_instance): + app, _ = app_instance + manager = app.extensions['room_manager'] + persistence = app.extensions['persistence'] + + _login(client, 'leader_alpha') + alpha_1 = client.post('/api/rooms/create', json={}).get_json() + manager.start_game(alpha_1['room_id'], alpha_1['player_id']) + room = persistence.get_room(alpha_1['room_id']) + state = dict(room['game_state']) + state['game_phase'] = 'ended' + state['ending_state'] = 'escaped' + state['round_no'] = 6 + persistence.update_room_state(alpha_1['room_id'], 'ended', state) + + alpha_2 = client.post('/api/rooms/create', json={}).get_json() + manager.start_game(alpha_2['room_id'], alpha_2['player_id']) + room = persistence.get_room(alpha_2['room_id']) + state = dict(room['game_state']) + state['game_phase'] = 'ended' + state['ending_state'] = 'victory' + state['round_no'] = 8 + persistence.update_room_state(alpha_2['room_id'], 'ended', state) + + client.post('/api/auth/logout', json={}) + _login(client, 'leader_bravo') + bravo_1 = client.post('/api/rooms/create', json={}).get_json() + manager.start_game(bravo_1['room_id'], bravo_1['player_id']) + room = persistence.get_room(bravo_1['room_id']) + state = dict(room['game_state']) + state['game_phase'] = 'ended' + state['ending_state'] = 'trapped' + state['round_no'] = 9 + persistence.update_room_state(bravo_1['room_id'], 'ended', state) + + leaderboard_resp = client.get('/api/leaderboard?limit=10') + assert leaderboard_resp.status_code == 200 + payload = leaderboard_resp.get_json() + assert payload['ok'] is True + + rows = payload['leaderboard'] + assert len(rows) >= 2 + assert rows[0]['username'] == 'leader_alpha' + assert rows[0]['wins'] == 2 + assert rows[0]['played_games'] == 2 + assert rows[0]['rank'] == 1 + + bravo = next((row for row in rows if row['username'] == 'leader_bravo'), None) + assert bravo is not None + assert bravo['wins'] == 0 + assert bravo['played_games'] == 1