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