feat: add leaderboard and expand theme options

This commit is contained in:
leeexin committed 2026-03-08 14:52:11 +08:00
1 parent ae478755be
commit e685fc0d65
9 files changed
+372 -8

No files matched your search

+3
View File
@@ -8,3 +8,6 @@ __pycache__/
.DS_Store
.claude/
gitea.config
CLAUDE.md
memory/
test/
+11 -1
View File
@@ -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")
+120
View File
@@ -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(
+146 -1
View File
@@ -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;
}
+26 -2
View File
@@ -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}`);
}
+1 -1
View File
@@ -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();
+5
View File
@@ -57,6 +57,9 @@
<section class="card">
<h2>快速重连</h2>
<ul id="rejoin-list" class="compact-list"></ul>
<h2 style="margin-top: 1rem;">玩家排行榜</h2>
<p class="helper-text">按胜场、参与局数和回合数综合排序。</p>
<ul id="leaderboard-list" class="compact-list"></ul>
</section>
<section class="card" id="profile-card">
@@ -83,6 +86,8 @@
<option value="midnight">午夜霓虹</option>
<option value="mint">薄荷雾光</option>
<option value="sunset">落日胶片</option>
<option value="aurora">极光谱系</option>
<option value="graphite">石墨工业</option>
</select>
<button type="submit">保存个性化</button>
+7 -3
View File
@@ -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):
+53
View File
@@ -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