diff --git a/database/L2/L2_Main.sqlite b/database/L2/L2_Main.sqlite index 125dcf0..7652cfd 100644 Binary files a/database/L2/L2_Main.sqlite and b/database/L2/L2_Main.sqlite differ diff --git a/database/Web/Web_App.sqlite b/database/Web/Web_App.sqlite index 28e7df1..b97f6b7 100644 Binary files a/database/Web/Web_App.sqlite and b/database/Web/Web_App.sqlite differ diff --git a/web/debug_roster.py b/web/debug_roster.py new file mode 100644 index 0000000..d1ce632 --- /dev/null +++ b/web/debug_roster.py @@ -0,0 +1,38 @@ +from web.services.web_service import WebService +from web.services.stats_service import StatsService +import json + +def debug_roster(): + print("--- Debugging Roster Stats ---") + lineups = WebService.get_lineups() + if not lineups: + print("No lineups found via WebService.") + return + + raw_json = lineups[0]['player_ids_json'] + print(f"Raw JSON: {raw_json}") + + try: + roster_ids = json.loads(raw_json) + print(f"Parsed IDs (List): {roster_ids}") + print(f"Type of first ID: {type(roster_ids[0])}") + except Exception as e: + print(f"JSON Parse Error: {e}") + return + + target_id = roster_ids[0] # Pick first one + print(f"\nTesting for Target ID: {target_id} (Type: {type(target_id)})") + + # Test StatsService + dist = StatsService.get_roster_stats_distribution(target_id) + print(f"\nDistribution Result: {dist}") + + # Test Basic Stats + basic = StatsService.get_player_basic_stats(str(target_id)) + print(f"\nBasic Stats for {target_id}: {basic}") + +if __name__ == "__main__": + from web.app import create_app + app = create_app() + with app.app_context(): + debug_roster() \ No newline at end of file diff --git a/web/routes/matches.py b/web/routes/matches.py index a5c606a..026e6c7 100644 --- a/web/routes/matches.py +++ b/web/routes/matches.py @@ -11,10 +11,15 @@ def index(): map_name = request.args.get('map') date_from = request.args.get('date_from') + # Fetch summary stats (for the dashboard) + summary_stats = StatsService.get_team_stats_summary() + matches, total = StatsService.get_matches(page, Config.ITEMS_PER_PAGE, map_name, date_from) total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE - return render_template('matches/list.html', matches=matches, total=total, page=page, total_pages=total_pages) + return render_template('matches/list.html', + matches=matches, total=total, page=page, total_pages=total_pages, + summary_stats=summary_stats) @bp.route('/') def detail(match_id): diff --git a/web/routes/players.py b/web/routes/players.py index fa28fa6..deb8909 100644 --- a/web/routes/players.py +++ b/web/routes/players.py @@ -118,11 +118,14 @@ def detail(steam_id): comments = WebService.get_comments('player', steam_id) metadata = WebService.get_player_metadata(steam_id) + # Roster Distribution Stats + distribution = StatsService.get_roster_stats_distribution(steam_id) + # History for table (L2 Source) - Fetch ALL for history table/chart history_asc = StatsService.get_player_trend(steam_id, limit=1000) history = history_asc[::-1] if history_asc else [] - return render_template('players/profile.html', player=player, features=features, comments=comments, metadata=metadata, history=history) + return render_template('players/profile.html', player=player, features=features, comments=comments, metadata=metadata, history=history, distribution=distribution) @bp.route('/comment//like', methods=['POST']) def like_comment(comment_id): @@ -151,9 +154,14 @@ def charts_data(steam_id): trend_labels = [] trend_values = [] - for t in trends: - dt = datetime.fromtimestamp(t['start_time']) if t['start_time'] else datetime.now() - trend_labels.append(dt.strftime('%Y-%m-%d')) + match_indices = [] + for i, row in enumerate(trends): + t = dict(row) # Convert sqlite3.Row to dict + # Format: Match #Index (Map) + # Use backend-provided match_index if available, or just index + 1 + idx = t.get('match_index', i + 1) + map_name = t.get('map_name', 'Unknown') + trend_labels.append(f"#{idx} {map_name}") trend_values.append(t['rating']) return jsonify({ diff --git a/web/services/stats_service.py b/web/services/stats_service.py index 8a9fb3e..6db212c 100644 --- a/web/services/stats_service.py +++ b/web/services/stats_service.py @@ -1,6 +1,178 @@ from web.database import query_db class StatsService: + @staticmethod + def get_team_stats_summary(): + """ + Calculates aggregate statistics for matches where at least 2 roster members played together. + Returns: + { + 'map_stats': [{'map_name', 'count', 'wins', 'win_rate'}], + 'elo_stats': [{'range', 'count', 'wins', 'win_rate'}], + 'duration_stats': [{'range', 'count', 'wins', 'win_rate'}], + 'round_stats': [{'type', 'count', 'wins', 'win_rate'}] + } + """ + # 1. Get Active Roster + from web.services.web_service import WebService + import json + + lineups = WebService.get_lineups() + active_roster_ids = [] + if lineups: + try: + raw_ids = json.loads(lineups[0]['player_ids_json']) + active_roster_ids = [str(uid) for uid in raw_ids] + except: + pass + + if not active_roster_ids: + return {} + + # 2. Find matches with >= 2 roster members + # We need match_id, map_name, scores, winner_team, duration, avg_elo + # And we need to determine if "Our Team" won. + + placeholders = ','.join('?' for _ in active_roster_ids) + + # Step A: Get Candidate Match IDs (matches with >= 2 roster players) + # Also get the team_id of our players in that match to determine win + candidate_sql = f""" + SELECT mp.match_id, MAX(mp.team_id) as our_team_id + FROM fact_match_players mp + WHERE CAST(mp.steam_id_64 AS TEXT) IN ({placeholders}) + GROUP BY mp.match_id + HAVING COUNT(DISTINCT mp.steam_id_64) >= 2 + """ + candidate_rows = query_db('l2', candidate_sql, active_roster_ids) + + if not candidate_rows: + return {} + + candidate_map = {row['match_id']: row['our_team_id'] for row in candidate_rows} + match_ids = list(candidate_map.keys()) + match_placeholders = ','.join('?' for _ in match_ids) + + # Step B: Get Match Details + match_sql = f""" + SELECT m.match_id, m.map_name, m.score_team1, m.score_team2, m.winner_team, m.duration, + AVG(fmt.group_origin_elo) as avg_elo + FROM fact_matches m + LEFT JOIN fact_match_teams fmt ON m.match_id = fmt.match_id AND fmt.group_origin_elo > 0 + WHERE m.match_id IN ({match_placeholders}) + GROUP BY m.match_id + """ + match_rows = query_db('l2', match_sql, match_ids) + + # 3. Process Data + # Buckets initialization + map_stats = {} + elo_ranges = ['<1000', '1000-1200', '1200-1400', '1400-1600', '1600-1800', '1800-2000', '2000+'] + elo_stats = {r: {'wins': 0, 'total': 0} for r in elo_ranges} + + dur_ranges = ['<30m', '30-45m', '45m+'] + dur_stats = {r: {'wins': 0, 'total': 0} for r in dur_ranges} + + round_types = ['Stomp (<15)', 'Normal', 'Close (>23)', 'Choke (24)'] + round_stats = {r: {'wins': 0, 'total': 0} for r in round_types} + + for m in match_rows: + mid = m['match_id'] + # Determine Win + # Use candidate_map to get our_team_id. + # Note: winner_team is usually int (1 or 2) or string. + # our_team_id from fact_match_players is usually int (1 or 2). + # This logic assumes simple team ID matching. + # If sophisticated "UID in Winning Group" logic is needed, we'd need more queries. + # For aggregate stats, let's assume team_id matching is sufficient for 99% cases or fallback to simple check. + # Actually, let's try to be consistent with get_matches logic if possible, + # but getting group_uids for ALL matches is heavy. + # Let's trust team_id for this summary. + + our_tid = candidate_map[mid] + winner_tid = m['winner_team'] + + # Type normalization + try: + is_win = (int(our_tid) == int(winner_tid)) if (our_tid and winner_tid) else False + except: + is_win = (str(our_tid) == str(winner_tid)) if (our_tid and winner_tid) else False + + # 1. Map Stats + map_name = m['map_name'] or 'Unknown' + if map_name not in map_stats: + map_stats[map_name] = {'wins': 0, 'total': 0} + map_stats[map_name]['total'] += 1 + if is_win: map_stats[map_name]['wins'] += 1 + + # 2. ELO Stats + elo = m['avg_elo'] + if elo: + if elo < 1000: e_key = '<1000' + elif elo < 1200: e_key = '1000-1200' + elif elo < 1400: e_key = '1200-1400' + elif elo < 1600: e_key = '1400-1600' + elif elo < 1800: e_key = '1600-1800' + elif elo < 2000: e_key = '1800-2000' + else: e_key = '2000+' + elo_stats[e_key]['total'] += 1 + if is_win: elo_stats[e_key]['wins'] += 1 + + # 3. Duration Stats + dur = m['duration'] # seconds + if dur: + dur_min = dur / 60 + if dur_min < 30: d_key = '<30m' + elif dur_min < 45: d_key = '30-45m' + else: d_key = '45m+' + dur_stats[d_key]['total'] += 1 + if is_win: dur_stats[d_key]['wins'] += 1 + + # 4. Round Stats + s1 = m['score_team1'] or 0 + s2 = m['score_team2'] or 0 + total_rounds = s1 + s2 + + if total_rounds == 24: + r_key = 'Choke (24)' + round_stats[r_key]['total'] += 1 + if is_win: round_stats[r_key]['wins'] += 1 + + # Note: Close (>23) overlaps with Choke (24). + # User requirement: Close > 23 counts ALL matches > 23, regardless of other categories. + if total_rounds > 23: + r_key = 'Close (>23)' + round_stats[r_key]['total'] += 1 + if is_win: round_stats[r_key]['wins'] += 1 + + if total_rounds < 15: + r_key = 'Stomp (<15)' + round_stats[r_key]['total'] += 1 + if is_win: round_stats[r_key]['wins'] += 1 + elif total_rounds <= 23: # Only Normal if NOT Stomp and NOT Close (<= 23 and >= 15) + r_key = 'Normal' + round_stats[r_key]['total'] += 1 + if is_win: round_stats[r_key]['wins'] += 1 + + # 4. Format Results + def fmt(stats_dict): + res = [] + for k, v in stats_dict.items(): + rate = (v['wins'] / v['total'] * 100) if v['total'] > 0 else 0 + res.append({'label': k, 'count': v['total'], 'wins': v['wins'], 'win_rate': rate}) + return res + + # For maps, sort by count + map_res = fmt(map_stats) + map_res.sort(key=lambda x: x['count'], reverse=True) + + return { + 'map_stats': map_res, + 'elo_stats': fmt(elo_stats), # Keep order + 'duration_stats': fmt(dur_stats), # Keep order + 'round_stats': fmt(round_stats) # Keep order + } + @staticmethod def get_recent_matches(limit=5): sql = """ @@ -398,7 +570,12 @@ class StatsService: WHERE p2.match_id = mp.match_id AND p2.match_team_id = mp.match_team_id AND p2.match_team_id > 0 -- Ensure we don't count 0 (solo default) as a massive party - ) as party_size + ) as party_size, + ( + SELECT COUNT(*) + FROM fact_matches m2 + WHERE m2.start_time <= m.start_time + ) as match_index FROM fact_match_players mp JOIN fact_matches m ON mp.match_id = m.match_id WHERE mp.steam_id_64 = ? @@ -408,6 +585,103 @@ class StatsService: """ return query_db('l2', sql, [steam_id, limit]) + @staticmethod + def get_roster_stats_distribution(target_steam_id): + """ + Calculates rank and distribution of the target player within the active roster. + """ + from web.services.web_service import WebService + import json + import numpy as np + + # 1. Get Active Roster IDs + lineups = WebService.get_lineups() + active_roster_ids = [] + if lineups: + try: + raw_ids = json.loads(lineups[0]['player_ids_json']) + active_roster_ids = [str(uid) for uid in raw_ids] + except: + pass + + # Ensure target is in list (if not in roster, compare against roster anyway) + # If roster is empty, return None + if not active_roster_ids: + return None + + # 2. Fetch stats for all roster members + placeholders = ','.join('?' for _ in active_roster_ids) + sql = f""" + SELECT + CAST(steam_id_64 AS TEXT) as steam_id_64, + AVG(rating) as rating, + AVG(kd_ratio) as kd, + AVG(adr) as adr, + AVG(kast) as kast + FROM fact_match_players + WHERE CAST(steam_id_64 AS TEXT) IN ({placeholders}) + GROUP BY steam_id_64 + """ + rows = query_db('l2', sql, active_roster_ids) + + if not rows: + return None + + stats_map = {row['steam_id_64']: dict(row) for row in rows} + + # Ensure target_steam_id is string + target_steam_id = str(target_steam_id) + + # If target player not in stats_map (e.g. no matches), handle gracefullly + if target_steam_id not in stats_map: + # Try fetch target stats individually if not in roster list + target_stats = StatsService.get_player_basic_stats(target_steam_id) + if target_stats: + stats_map[target_steam_id] = target_stats + else: + # If still no stats, we can't rank them. + # But we can still return the roster stats for others? + # The prompt implies "No team data" appears, meaning this function returns valid structure but empty values? + # Or returns None. + # Let's verify what happens if target has no stats but others do. + # We should probably add a dummy entry for target so dashboard renders '0' instead of crashing or 'No data' + stats_map[target_steam_id] = {'rating': 0, 'kd': 0, 'adr': 0, 'kast': 0} + + # 3. Calculate Distribution + metrics = ['rating', 'kd', 'adr', 'kast'] + result = {} + + for m in metrics: + # Extract values for this metric from all players + values = [p[m] for p in stats_map.values() if p[m] is not None] + target_val = stats_map[target_steam_id].get(m) + + if target_val is None or not values: + result[m] = None + continue + + # Sort descending (higher is better) + values.sort(reverse=True) + + # Rank (1-based) + try: + rank = values.index(target_val) + 1 + except ValueError: + # Floating point precision issue? Find closest + closest = min(values, key=lambda x: abs(x - target_val)) + rank = values.index(closest) + 1 + + result[m] = { + 'val': target_val, + 'rank': rank, + 'total': len(values), + 'min': min(values), + 'max': max(values), + 'avg': sum(values) / len(values) + } + + return result + @staticmethod def get_live_matches(): # Query matches started in last 2 hours with no winner diff --git a/web/static/avatars/76561198330488905.jpg b/web/static/avatars/76561198330488905.jpg new file mode 100644 index 0000000..57edbd7 Binary files /dev/null and b/web/static/avatars/76561198330488905.jpg differ diff --git a/web/static/avatars/76561198970034329.jpg b/web/static/avatars/76561198970034329.jpg new file mode 100644 index 0000000..5650a6b Binary files /dev/null and b/web/static/avatars/76561198970034329.jpg differ diff --git a/web/static/avatars/76561199026688017.jpg b/web/static/avatars/76561199026688017.jpg new file mode 100644 index 0000000..9ae2e6c Binary files /dev/null and b/web/static/avatars/76561199026688017.jpg differ diff --git a/web/static/avatars/76561199032002725.jpg b/web/static/avatars/76561199032002725.jpg new file mode 100644 index 0000000..e0a4962 Binary files /dev/null and b/web/static/avatars/76561199032002725.jpg differ diff --git a/web/static/avatars/76561199076109761.jpg b/web/static/avatars/76561199076109761.jpg new file mode 100644 index 0000000..df93307 Binary files /dev/null and b/web/static/avatars/76561199076109761.jpg differ diff --git a/web/static/avatars/76561199078250590.jpg b/web/static/avatars/76561199078250590.jpg new file mode 100644 index 0000000..d56108b Binary files /dev/null and b/web/static/avatars/76561199078250590.jpg differ diff --git a/web/static/avatars/76561199106558767.jpg b/web/static/avatars/76561199106558767.jpg new file mode 100644 index 0000000..3e24b9f Binary files /dev/null and b/web/static/avatars/76561199106558767.jpg differ diff --git a/web/static/avatars/76561199390145159.jpg b/web/static/avatars/76561199390145159.jpg new file mode 100644 index 0000000..2d0b7a6 Binary files /dev/null and b/web/static/avatars/76561199390145159.jpg differ diff --git a/web/static/avatars/76561199417030350.jpg b/web/static/avatars/76561199417030350.jpg new file mode 100644 index 0000000..7340256 Binary files /dev/null and b/web/static/avatars/76561199417030350.jpg differ diff --git a/web/static/avatars/76561199467422873.jpg b/web/static/avatars/76561199467422873.jpg new file mode 100644 index 0000000..b4f8c68 Binary files /dev/null and b/web/static/avatars/76561199467422873.jpg differ diff --git a/web/static/avatars/76561199526984477.jpg b/web/static/avatars/76561199526984477.jpg new file mode 100644 index 0000000..2d0b7a6 Binary files /dev/null and b/web/static/avatars/76561199526984477.jpg differ diff --git a/web/templates/base.html b/web/templates/base.html index ce1f466..d90bfcb 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -5,7 +5,9 @@ {% block title %}YRTV - CS2 Data Platform{% endblock %} - + + + - - {{ m.map_name }} - - - {{ 'WIN' if m.is_win else 'LOSS' }} - - - - {% if m.party_size and m.party_size > 1 %} - {% set p = m.party_size %} - {% set party_class = 'bg-gray-100 text-gray-800' %} - {% if p == 2 %} {% set party_class = 'bg-indigo-100 text-indigo-800' %} - {% elif p == 3 %} {% set party_class = 'bg-blue-100 text-blue-800' %} - {% elif p == 4 %} {% set party_class = 'bg-purple-100 text-purple-800' %} - {% elif p >= 5 %} {% set party_class = 'bg-orange-100 text-orange-800' %} - {% endif %} - - 👥 {{ m.party_size }} - - {% else %} - Solo - {% endif %} - - {{ "%.2f"|format(m.rating or 0) }} - {{ "%.2f"|format(m.kd_ratio or 0) }} - {{ "%.1f"|format(m.adr or 0) }} - - View - - - {% else %} - - No recent matches found. - - {% endfor %} - - + + + +
+ +
+
+

比赛记录 (Match History)

+ + {{ history|length }} Matches + +
+
+ + + + + + + + + + + + + {% for m in history | reverse %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
Date/MapResultRatingK/DADRLink
+
{{ m.map_name }}
+
+ +
+
+
+ + {{ 'WIN' if m.is_win else 'LOSS' }} + + {% if m.party_size and m.party_size > 1 %} + + 👥 {{ m.party_size }} + + {% endif %} +
+
+ {% set r = m.rating or 0 %} +
+ + {{ "%.2f"|format(r) }} + + +
+
+
+
+
+ {{ "%.2f"|format(m.kd_ratio or 0) }} + + {{ "%.1f"|format(m.adr or 0) }} + + + + +
+
🏜️
+ No matches recorded yet. +
+
+
+ + +
+

留言板 (Comments)

+ +
+ + + +
+ +
+ {% for comment in comments %} +
+
+
+ {{ comment.username[:1] | upper }} +
+
+
+
+ {{ comment.username }} + {{ comment.created_at }} +
+

{{ comment.content }}

+
+ +
+
+
+ {% else %} +
No comments yet.
+ {% endfor %} +
-
-

玩家评价 ({{ comments|length }})

- - -
-
- - +
+ + +