diff --git a/database/L1A/L1A.sqlite b/database/L1A/L1A.sqlite index 09d14c9..46a1dab 100644 Binary files a/database/L1A/L1A.sqlite and b/database/L1A/L1A.sqlite differ diff --git a/database/L2/L2_Main.sqlite b/database/L2/L2_Main.sqlite index d1f04bb..8b0a7a8 100644 Binary files a/database/L2/L2_Main.sqlite and b/database/L2/L2_Main.sqlite differ diff --git a/database/L3/L3_Features.sqlite b/database/L3/L3_Features.sqlite index 72a1e0d..6f11040 100644 Binary files a/database/L3/L3_Features.sqlite and b/database/L3/L3_Features.sqlite differ diff --git a/database/Web/Web_App.sqlite b/database/Web/Web_App.sqlite index dc17453..eaf75f2 100644 Binary files a/database/Web/Web_App.sqlite and b/database/Web/Web_App.sqlite differ diff --git a/downloader/README.md b/downloader/README.md index ee74a61..b1ea2de 100644 --- a/downloader/README.md +++ b/downloader/README.md @@ -31,7 +31,7 @@ python downloader.py --url https://arena.5eplay.com/data/match/g161-202601182227 批量下载(从文件读取 URL): ```bash -python downloader.py --url-list gamelist/match_list_2026.txt +python downloader/downloader.py --url-list gamelist/match_list_2026.txt -concurrent 8 ``` 指定输出目录: diff --git a/downloader/match_list_temp.txt b/downloader/match_list_temp.txt new file mode 100644 index 0000000..189db5e --- /dev/null +++ b/downloader/match_list_temp.txt @@ -0,0 +1,12 @@ +https://arena.5eplay.com/data/match/g161-20260120090500700546858 +https://arena.5eplay.com/data/match/g161-20260123152313646137189 +https://arena.5eplay.com/data/match/g161-20260123155331151172258 +https://arena.5eplay.com/data/match/g161-20260123163155468519060 +https://arena.5eplay.com/data/match/g161-20260125163636663072260 +https://arena.5eplay.com/data/match/g161-20260125171525375681453 +https://arena.5eplay.com/data/match/g161-20260125174806246015320 +https://arena.5eplay.com/data/match/g161-20260125182858851607650 +https://arena.5eplay.com/data/match/g161-20260127133354952029097 +https://arena.5eplay.com/data/match/g161-20260127141401965388621 +https://arena.5eplay.com/data/match/g161-20260127144918246454523 +https://arena.5eplay.com/data/match/g161-20260127161541951490476 \ No newline at end of file diff --git a/web/app.py b/web/app.py index c8142b2..c317bbc 100644 --- a/web/app.py +++ b/web/app.py @@ -15,7 +15,7 @@ def create_app(): app.teardown_appcontext(close_dbs) # Register Blueprints - from web.routes import main, matches, players, teams, tactics, admin, wiki + from web.routes import main, matches, players, teams, tactics, admin, wiki, opponents app.register_blueprint(main.bp) app.register_blueprint(matches.bp) app.register_blueprint(players.bp) @@ -23,6 +23,7 @@ def create_app(): app.register_blueprint(tactics.bp) app.register_blueprint(admin.bp) app.register_blueprint(wiki.bp) + app.register_blueprint(opponents.bp) @app.route('/') def index(): diff --git a/web/routes/opponents.py b/web/routes/opponents.py new file mode 100644 index 0000000..8083e9a --- /dev/null +++ b/web/routes/opponents.py @@ -0,0 +1,35 @@ +from flask import Blueprint, render_template, request, jsonify +from web.services.opponent_service import OpponentService +from web.config import Config + +bp = Blueprint('opponents', __name__, url_prefix='/opponents') + +@bp.route('/') +def index(): + page = request.args.get('page', 1, type=int) + sort_by = request.args.get('sort', 'matches') + search = request.args.get('search') + + opponents, total = OpponentService.get_opponent_list(page, Config.ITEMS_PER_PAGE, sort_by, search) + total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE + + # Global stats for dashboard + stats_summary = OpponentService.get_global_opponent_stats() + map_stats = OpponentService.get_map_opponent_stats() + + return render_template('opponents/index.html', + opponents=opponents, + total=total, + page=page, + total_pages=total_pages, + sort_by=sort_by, + stats_summary=stats_summary, + map_stats=map_stats) + +@bp.route('/') +def detail(steam_id): + data = OpponentService.get_opponent_detail(steam_id) + if not data: + return "Opponent not found", 404 + + return render_template('opponents/detail.html', **data) diff --git a/web/services/opponent_service.py b/web/services/opponent_service.py new file mode 100644 index 0000000..cdddef2 --- /dev/null +++ b/web/services/opponent_service.py @@ -0,0 +1,374 @@ +from web.database import query_db +from web.services.web_service import WebService +import json + +class OpponentService: + @staticmethod + def _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 + return active_roster_ids + + @staticmethod + def get_opponent_list(page=1, per_page=20, sort_by='matches', search=None): + roster_ids = OpponentService._get_active_roster_ids() + if not roster_ids: + return [], 0 + + # Placeholders + roster_ph = ','.join('?' for _ in roster_ids) + + # 1. Identify Matches involving our roster (at least 1 member? usually 2 for 'team' match) + # Let's say at least 1 for broader coverage as requested ("1 match sample") + # But "Our Team" usually implies the entity. Let's stick to matches where we can identify "Us". + # If we use >=1, we catch solo Q matches of roster members. The user said "Non-team members or 1 match sample", + # but implied "facing different our team lineups". + # Let's use the standard "candidate matches" logic (>=2 roster members) to represent "The Team". + # OR, if user wants "Opponent Analysis" for even 1 match, maybe they mean ANY match in DB? + # "Left Top add Opponent Analysis... (non-team member or 1 sample)" + # This implies we analyze PLAYERS who are NOT us. + # Let's stick to matches where >= 1 roster member played, to define "Us" vs "Them". + + # Actually, let's look at ALL matches in DB, and any player NOT in active roster is an "Opponent". + # This covers "1 sample". + + # Query: + # Select all players who are NOT in active roster. + # Group by steam_id. + # Aggregate stats. + + where_clauses = [f"CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph})"] + args = list(roster_ids) + + if search: + where_clauses.append("(LOWER(p.username) LIKE LOWER(?) OR mp.steam_id_64 LIKE ?)") + args.extend([f"%{search}%", f"%{search}%"]) + + where_str = " AND ".join(where_clauses) + + # Sort mapping + sort_sql = "matches DESC" + if sort_by == 'rating': + sort_sql = "avg_rating DESC" + elif sort_by == 'kd': + sort_sql = "avg_kd DESC" + elif sort_by == 'win_rate': + sort_sql = "win_rate DESC" + + # Main Aggregation Query + # We need to join fact_matches to get match info (win/loss, elo) if needed, + # but fact_match_players has is_win (boolean) usually? No, it has team_id. + # We need to determine if THEY won. + # fact_match_players doesn't store is_win directly in schema (I should check schema, but stats_service calculates it). + # Wait, stats_service.get_player_trend uses `mp.is_win`? + # Let's check schema. `fact_match_players` usually has `match_id`, `team_id`. + # `fact_matches` has `winner_team`. + # So we join. + + offset = (page - 1) * per_page + + sql = f""" + SELECT + mp.steam_id_64, + MAX(p.username) as username, + MAX(p.avatar_url) as avatar_url, + COUNT(DISTINCT mp.match_id) as matches, + AVG(mp.rating) as avg_rating, + AVG(mp.kd_ratio) as avg_kd, + AVG(mp.adr) as avg_adr, + SUM(CASE WHEN mp.is_win = 1 THEN 1 ELSE 0 END) as wins, + AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_match_elo + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64 + LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id + LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id + WHERE {where_str} + GROUP BY mp.steam_id_64 + ORDER BY {sort_sql} + LIMIT ? OFFSET ? + """ + + # Count query + count_sql = f""" + SELECT COUNT(DISTINCT mp.steam_id_64) as cnt + FROM fact_match_players mp + LEFT JOIN dim_players p ON mp.steam_id_64 = p.steam_id_64 + WHERE {where_str} + """ + + query_args = args + [per_page, offset] + rows = query_db('l2', sql, query_args) + total = query_db('l2', count_sql, args, one=True)['cnt'] + + # Post-process for derived stats + results = [] + for r in rows: + d = dict(r) + d['win_rate'] = (d['wins'] / d['matches']) if d['matches'] else 0 + results.append(d) + + return results, total + + @staticmethod + def get_global_opponent_stats(): + """ + Calculates aggregate statistics for ALL opponents. + Returns: + { + 'elo_dist': {'<1200': 10, '1200-1500': 20...}, + 'rating_dist': {'<0.8': 5, '0.8-1.0': 15...}, + 'win_rate_dist': {'<40%': 5, '40-60%': 10...} (Opponent Win Rate) + } + """ + roster_ids = OpponentService._get_active_roster_ids() + if not roster_ids: + return {} + + roster_ph = ','.join('?' for _ in roster_ids) + + # 1. Fetch Aggregated Stats for ALL opponents + # We group by steam_id first to get each opponent's AVG stats + + sql = f""" + SELECT + mp.steam_id_64, + COUNT(DISTINCT mp.match_id) as matches, + AVG(mp.rating) as avg_rating, + AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_match_elo, + SUM(CASE WHEN mp.is_win = 1 THEN 1 ELSE 0 END) as wins + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id + LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id + WHERE CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph}) + GROUP BY mp.steam_id_64 + """ + + rows = query_db('l2', sql, roster_ids) + + # Initialize Buckets + elo_buckets = {'<1000': 0, '1000-1200': 0, '1200-1400': 0, '1400-1600': 0, '1600-1800': 0, '1800-2000': 0, '>2000': 0} + rating_buckets = {'<0.8': 0, '0.8-1.0': 0, '1.0-1.2': 0, '1.2-1.4': 0, '>1.4': 0} + win_rate_buckets = {'<30%': 0, '30-45%': 0, '45-55%': 0, '55-70%': 0, '>70%': 0} + elo_values = [] + rating_values = [] + + for r in rows: + elo_val = r['avg_match_elo'] + if elo_val is None or elo_val <= 0: + pass + else: + elo = elo_val + if elo < 1000: k = '<1000' + elif elo < 1200: k = '1000-1200' + elif elo < 1400: k = '1200-1400' + elif elo < 1600: k = '1400-1600' + elif elo < 1800: k = '1600-1800' + elif elo < 2000: k = '1800-2000' + else: k = '>2000' + elo_buckets[k] += 1 + elo_values.append(float(elo)) + + rtg = r['avg_rating'] or 0 + if rtg < 0.8: k = '<0.8' + elif rtg < 1.0: k = '0.8-1.0' + elif rtg < 1.2: k = '1.0-1.2' + elif rtg < 1.4: k = '1.2-1.4' + else: k = '>1.4' + rating_buckets[k] += 1 + rating_values.append(float(rtg)) + + matches = r['matches'] or 0 + if matches > 0: + wr = (r['wins'] or 0) / matches + if wr < 0.30: k = '<30%' + elif wr < 0.45: k = '30-45%' + elif wr < 0.55: k = '45-55%' + elif wr < 0.70: k = '55-70%' + else: k = '>70%' + win_rate_buckets[k] += 1 + + return { + 'elo_dist': elo_buckets, + 'rating_dist': rating_buckets, + 'win_rate_dist': win_rate_buckets, + 'elo_values': elo_values, + 'rating_values': rating_values + } + + @staticmethod + def get_opponent_detail(steam_id): + # 1. Basic Info + info = query_db('l2', "SELECT * FROM dim_players WHERE steam_id_64 = ?", [steam_id], one=True) + if not info: + return None + + # 2. Match History vs Us (All matches this player played) + # We define "Us" as matches where this player is an opponent. + # But actually, we just show ALL their matches in our DB, assuming our DB only contains matches relevant to us? + # Usually yes, but if we have a huge DB, we might want to filter by "Contains Roster Member". + # For now, show all matches in DB for this player. + + sql_history = """ + SELECT + m.match_id, m.start_time, m.map_name, m.score_team1, m.score_team2, m.winner_team, + mp.team_id, mp.match_team_id, mp.rating, mp.kd_ratio, mp.adr, mp.kills, mp.deaths, + mp.is_win as is_win, + CASE + WHEN COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo) > 0 + THEN COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo) + END as elo + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id + LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id + WHERE mp.steam_id_64 = ? + ORDER BY m.start_time DESC + """ + history = query_db('l2', sql_history, [steam_id]) + + # 3. Aggregation by ELO + elo_buckets = { + '<1200': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}, + '1200-1500': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}, + '1500-1800': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}, + '1800-2100': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0}, + '>2100': {'matches': 0, 'rating_sum': 0, 'kd_sum': 0} + } + + # 4. Aggregation by Side (T/CT) + # Using fact_match_players_t / ct + sql_side = """ + SELECT + (SELECT SUM(t.rating * t.round_total) / SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rating_t, + (SELECT SUM(ct.rating * ct.round_total) / SUM(ct.round_total) FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rating_ct, + (SELECT SUM(t.kd_ratio * t.round_total) / SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as kd_t, + (SELECT SUM(ct.kd_ratio * ct.round_total) / SUM(ct.round_total) FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as kd_ct, + (SELECT SUM(t.round_total) FROM fact_match_players_t t WHERE t.steam_id_64 = ?) as rounds_t, + (SELECT SUM(ct.round_total) FROM fact_match_players_ct ct WHERE ct.steam_id_64 = ?) as rounds_ct + """ + side_stats = query_db('l2', sql_side, [steam_id, steam_id, steam_id, steam_id, steam_id, steam_id], one=True) + + # Process History for ELO & KD Diff + # We also want "Our Team KD" in these matches to calc Diff. + # This requires querying the OTHER team in these matches. + + match_ids = [h['match_id'] for h in history] + + # Get Our Team Stats per match + # "Our Team" = All players in the match EXCEPT this opponent (and their teammates?) + # Simplification: "Avg Lobby KD" vs "Opponent KD". + # Or better: "Avg KD of Opposing Team". + + match_stats_map = {} + if match_ids: + ph = ','.join('?' for _ in match_ids) + # Calculate Avg KD of the team that is NOT the opponent's team + opp_stats_sql = f""" + SELECT match_id, match_team_id, AVG(kd_ratio) as team_avg_kd + FROM fact_match_players + WHERE match_id IN ({ph}) + GROUP BY match_id, match_team_id + """ + opp_rows = query_db('l2', opp_stats_sql, match_ids) + + # Organize by match + for r in opp_rows: + mid = r['match_id'] + tid = r['match_team_id'] + if mid not in match_stats_map: + match_stats_map[mid] = {} + match_stats_map[mid][tid] = r['team_avg_kd'] + + processed_history = [] + for h in history: + # ELO Bucketing + elo = h['elo'] or 0 + if elo < 1200: b = '<1200' + elif elo < 1500: b = '1200-1500' + elif elo < 1800: b = '1500-1800' + elif elo < 2100: b = '1800-2100' + else: b = '>2100' + + elo_buckets[b]['matches'] += 1 + elo_buckets[b]['rating_sum'] += (h['rating'] or 0) + elo_buckets[b]['kd_sum'] += (h['kd_ratio'] or 0) + + # KD Diff + # Find the OTHER team's avg KD + my_tid = h['match_team_id'] + # Assuming 2 teams: if my_tid is 1, other is 2. But IDs can be anything. + # Look at match_stats_map[mid] keys. + mid = h['match_id'] + other_team_kd = 1.0 # Default + if mid in match_stats_map: + for tid, avg_kd in match_stats_map[mid].items(): + if tid != my_tid: + other_team_kd = avg_kd + break + + kd_diff = (h['kd_ratio'] or 0) - other_team_kd + + d = dict(h) + d['kd_diff'] = kd_diff + d['other_team_kd'] = other_team_kd + processed_history.append(d) + + # Format ELO Stats + elo_stats = [] + for k, v in elo_buckets.items(): + if v['matches'] > 0: + elo_stats.append({ + 'range': k, + 'matches': v['matches'], + 'avg_rating': v['rating_sum'] / v['matches'], + 'avg_kd': v['kd_sum'] / v['matches'] + }) + + return { + 'player': info, + 'history': processed_history, + 'elo_stats': elo_stats, + 'side_stats': dict(side_stats) if side_stats else {} + } + + @staticmethod + def get_map_opponent_stats(): + roster_ids = OpponentService._get_active_roster_ids() + if not roster_ids: + return [] + roster_ph = ','.join('?' for _ in roster_ids) + sql = f""" + SELECT + m.map_name as map_name, + COUNT(DISTINCT mp.match_id) as matches, + AVG(mp.rating) as avg_rating, + AVG(mp.kd_ratio) as avg_kd, + AVG(NULLIF(COALESCE(fmt_gid.group_origin_elo, fmt_tid.group_origin_elo), 0)) as avg_elo, + COUNT(DISTINCT CASE WHEN mp.is_win = 1 THEN mp.match_id END) as wins, + COUNT(DISTINCT CASE WHEN mp.rating > 1.5 THEN mp.match_id END) as shark_matches + FROM fact_match_players mp + JOIN fact_matches m ON mp.match_id = m.match_id + LEFT JOIN fact_match_teams fmt_gid ON mp.match_id = fmt_gid.match_id AND fmt_gid.group_id = mp.team_id + LEFT JOIN fact_match_teams fmt_tid ON mp.match_id = fmt_tid.match_id AND fmt_tid.group_tid = mp.match_team_id + WHERE CAST(mp.steam_id_64 AS TEXT) NOT IN ({roster_ph}) + AND m.map_name IS NOT NULL AND m.map_name <> '' + GROUP BY m.map_name + ORDER BY matches DESC + """ + rows = query_db('l2', sql, roster_ids) + results = [] + for r in rows: + d = dict(r) + matches = d.get('matches') or 0 + wins = d.get('wins') or 0 + d['win_rate'] = (wins / matches) if matches else 0 + results.append(d) + return results diff --git a/web/templates/base.html b/web/templates/base.html index 2d3f7b9..fdbc9df 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -47,6 +47,7 @@ 比赛 玩家 战队 + 对手 战术 Wiki @@ -84,6 +85,7 @@ 比赛 玩家 战队 + 对手 战术 Wiki {% if session.get('is_admin') %} diff --git a/web/templates/opponents/detail.html b/web/templates/opponents/detail.html new file mode 100644 index 0000000..462fa09 --- /dev/null +++ b/web/templates/opponents/detail.html @@ -0,0 +1,251 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+
+ +
+ {% if player.avatar_url %} + + {% else %} +
+ {{ player.username[:2]|upper if player.username else '??' }} +
+ {% endif %} +
+ +
+
+

{{ player.username }}

+ + OPPONENT + +
+

{{ player.steam_id_64 }}

+ + +
+
+
Matches vs Us
+
{{ history|length }}
+
+ + {% set wins = history | selectattr('is_win') | list | length %} + {% set wr = (wins / history|length * 100) if history else 0 %} +
+
Their Win Rate
+
+ {{ "%.1f"|format(wr) }}% +
+
+ + {% set avg_rating = history | map(attribute='rating') | sum / history|length if history else 0 %} +
+
Their Avg Rating
+
{{ "%.2f"|format(avg_rating) }}
+
+ + {% set avg_kd_diff = history | map(attribute='kd_diff') | sum / history|length if history else 0 %} +
+
Avg K/D Diff
+
+ {{ "%+.2f"|format(avg_kd_diff) }} +
+
+
+
+
+
+ + +
+ +
+

+ 📈 Performance vs ELO Segments +

+
+ +
+
+ + +
+

+ 🛡️ Side Preference (vs Us) +

+ + {% macro side_row(label, t_val, ct_val, format_str='{:.2f}') %} +
+
+ {{ label }} +
+
+ {{ (format_str.format(t_val) if t_val is not none else '—') }} + vs + {{ (format_str.format(ct_val) if ct_val is not none else '—') }} +
+
+ {% set has_t = t_val is not none %} + {% set has_ct = ct_val is not none %} + {% set total = (t_val or 0) + (ct_val or 0) %} + {% if total > 0 and has_t and has_ct %} + {% set t_pct = ((t_val or 0) / total) * 100 %} +
+
+ {% else %} +
+
+ {% endif %} +
+
+ T-Side + CT-Side +
+
+ {% endmacro %} + + {{ side_row('Rating', side_stats.get('rating_t'), side_stats.get('rating_ct')) }} + {{ side_row('K/D Ratio', side_stats.get('kd_t'), side_stats.get('kd_ct')) }} + +
+
Rounds Sampled
+
+ {{ (side_stats.get('rounds_t', 0) or 0) + (side_stats.get('rounds_ct', 0) or 0) }} +
+
+
+
+ + +
+
+

Match History (Head-to-Head)

+
+
+ + + + + + + + + + + + + + + {% for m in history %} + + + + + + + + + + + {% endfor %} + +
Date / MapTheir ResultMatch EloTheir RatingTheir K/DK/D Diff (vs Team)K / D
+
{{ m.map_name }}
+
+ +
+
+ + {{ 'WON' if m.is_win else 'LOST' }} + + + {{ "%.0f"|format(m.elo or 0) }} + + {{ "%.2f"|format(m.rating or 0) }} + + {{ "%.2f"|format(m.kd_ratio or 0) }} + + {% set diff = m.kd_diff %} + + {{ "%+.2f"|format(diff) }} + +
vs Team Avg {{ "%.2f"|format(m.other_team_kd or 0) }}
+
+ {{ m.kills }} / {{ m.deaths }} + + + + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/web/templates/opponents/index.html b/web/templates/opponents/index.html new file mode 100644 index 0000000..b99415c --- /dev/null +++ b/web/templates/opponents/index.html @@ -0,0 +1,329 @@ +{% extends "base.html" %} + +{% block content %} +
+ +
+ +
+

Opponent ELO Curve

+
+ +
+
+ + +
+

Opponent Rating Curve

+
+ +
+
+
+ + +
+
+

分地图对手统计

+

各地图下遇到对手的胜率、ELO、Rating、K/D

+
+
+ + + + + + + + + + + + + {% for m in map_stats %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
MapMatchesWin RateAvg RatingAvg K/DAvg Elo
{{ m.map_name }} + + {{ m.matches }} + + + {% set wr = (m.win_rate or 0) * 100 %} + + {{ "%.1f"|format(wr) }}% + + + {{ "%.2f"|format(m.avg_rating or 0) }} + + {{ "%.2f"|format(m.avg_kd or 0) }} + + {% if m.avg_elo %}{{ "%.0f"|format(m.avg_elo) }}{% else %}—{% endif %} +
暂无地图统计数据
+
+
+ + +
+
+

分地图炸鱼哥遭遇次数

+

统计各地图出现 rating > 1.5 对手的比赛次数

+
+
+ + + + + + + + + + {% for m in map_stats %} + + + + + + {% else %} + + + + {% endfor %} + +
MapEncountersFrequency
{{ m.map_name }} + + {{ m.shark_matches or 0 }} + + + {% set freq = ( (m.shark_matches or 0) / (m.matches or 1) ) * 100 %} + + {{ "%.1f"|format(freq) }}% + +
暂无炸鱼哥统计数据
+
+
+ +
+
+
+

+ ⚔️ 对手分析 (Opponent Analysis) +

+

+ Analyze performance against specific players encountered in matches. +

+
+ +
+ +
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ + + + + + + + + + + + + + {% for op in opponents %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
OpponentMatches vs UsTheir Win RateTheir RatingTheir K/DAvg Match EloView
+
+
+ {% if op.avatar_url %} + + {% else %} +
+ {{ op.username[:2]|upper if op.username else '??' }} +
+ {% endif %} +
+
+
{{ op.username }}
+
{{ op.steam_id_64 }}
+
+
+
+ + {{ op.matches }} + + + {% set wr = op.win_rate * 100 %} + + {{ "%.1f"|format(wr) }}% + + + {{ "%.2f"|format(op.avg_rating or 0) }} + + {{ "%.2f"|format(op.avg_kd or 0) }} + + {% if op.avg_match_elo %} + {{ "%.0f"|format(op.avg_match_elo) }} + {% else %}—{% endif %} + + Analyze → +
+ No opponents found. +
+
+ + +
+
+ Total {{ total }} opponents found +
+
+ {% if page > 1 %} + Previous + {% endif %} + {% if page < total_pages %} + Next + {% endif %} +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %}