feat: Add recent performance stability stats (matches/days) to player profile

This commit is contained in:
2026-01-28 14:04:32 +08:00
commit 48f1f71d3a
104 changed files with 17572 additions and 0 deletions

75
web/routes/admin.py Normal file
View File

@@ -0,0 +1,75 @@
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
from web.config import Config
from web.auth import admin_required
from web.database import query_db
import os
bp = Blueprint('admin', __name__, url_prefix='/admin')
@bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
token = request.form.get('token')
if token == Config.ADMIN_TOKEN:
session['is_admin'] = True
return redirect(url_for('admin.dashboard'))
else:
flash('Invalid Token', 'error')
return render_template('admin/login.html')
@bp.route('/logout')
def logout():
session.pop('is_admin', None)
return redirect(url_for('main.index'))
@bp.route('/')
@admin_required
def dashboard():
return render_template('admin/dashboard.html')
from web.services.etl_service import EtlService
@bp.route('/trigger_etl', methods=['POST'])
@admin_required
def trigger_etl():
script_name = request.form.get('script')
allowed = ['L1A.py', 'L2_Builder.py', 'L3_Builder.py']
if script_name not in allowed:
return "Invalid script", 400
success, message = EtlService.run_script(script_name)
status_code = 200 if success else 500
return message, status_code
@bp.route('/sql', methods=['GET', 'POST'])
@admin_required
def sql_runner():
result = None
error = None
query = ""
db_name = "l2"
if request.method == 'POST':
query = request.form.get('query')
db_name = request.form.get('db_name', 'l2')
# Safety check
forbidden = ['DELETE', 'DROP', 'UPDATE', 'INSERT', 'ALTER', 'GRANT', 'REVOKE']
if any(x in query.upper() for x in forbidden):
error = "Only SELECT queries allowed in Web Runner."
else:
try:
# Enforce limit if not present
if 'LIMIT' not in query.upper():
query += " LIMIT 50"
rows = query_db(db_name, query)
if rows:
columns = rows[0].keys()
result = {'columns': columns, 'rows': rows}
else:
result = {'columns': [], 'rows': []}
except Exception as e:
error = str(e)
return render_template('admin/sql.html', result=result, error=error, query=query, db_name=db_name)

35
web/routes/main.py Normal file
View File

@@ -0,0 +1,35 @@
from flask import Blueprint, render_template, request, jsonify
from web.services.stats_service import StatsService
import time
bp = Blueprint('main', __name__)
@bp.route('/')
def index():
recent_matches = StatsService.get_recent_matches(limit=5)
daily_counts = StatsService.get_daily_match_counts()
live_matches = StatsService.get_live_matches()
# Convert rows to dict for easier JS usage
heatmap_data = {}
if daily_counts:
for row in daily_counts:
heatmap_data[row['day']] = row['count']
return render_template('home/index.html', recent_matches=recent_matches, heatmap_data=heatmap_data, live_matches=live_matches)
from web.services.etl_service import EtlService
@bp.route('/parse_match', methods=['POST'])
def parse_match():
url = request.form.get('url')
if not url or '5eplay.com' not in url:
return jsonify({'success': False, 'message': 'Invalid 5EPlay URL'})
# Trigger L1A.py with URL argument
success, msg = EtlService.run_script('L1A.py', args=[url])
if success:
return jsonify({'success': True, 'message': 'Match parsing completed successfully!'})
else:
return jsonify({'success': False, 'message': f'Error: {msg}'})

135
web/routes/matches.py Normal file
View File

@@ -0,0 +1,135 @@
from flask import Blueprint, render_template, request, Response
from web.services.stats_service import StatsService
from web.config import Config
import json
bp = Blueprint('matches', __name__, url_prefix='/matches')
@bp.route('/')
def index():
page = request.args.get('page', 1, type=int)
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,
summary_stats=summary_stats)
@bp.route('/<match_id>')
def detail(match_id):
match = StatsService.get_match_detail(match_id)
if not match:
return "Match not found", 404
players = StatsService.get_match_players(match_id)
# Convert sqlite3.Row objects to dicts to allow modification
players = [dict(p) for p in players]
rounds = StatsService.get_match_rounds(match_id)
# --- Roster Identification ---
# Fetch active roster to identify "Our Team" players
from web.services.web_service import WebService
lineups = WebService.get_lineups()
# Assume we use the first/active lineup
active_roster_ids = []
if lineups:
try:
active_roster_ids = json.loads(lineups[0]['player_ids_json'])
except:
pass
# Mark roster players (Ensure strict string comparison)
roster_set = set(str(uid) for uid in active_roster_ids)
for p in players:
p['is_in_roster'] = str(p['steam_id_64']) in roster_set
# --- Party Size Calculation ---
# Only calculate party size for OUR ROSTER members.
# Group roster members by match_team_id
roster_parties = {} # match_team_id -> count of roster members
for p in players:
if p['is_in_roster']:
mtid = p.get('match_team_id')
if mtid and mtid > 0:
key = f"tid_{mtid}"
roster_parties[key] = roster_parties.get(key, 0) + 1
# Assign party size ONLY to roster members
for p in players:
if p['is_in_roster']:
mtid = p.get('match_team_id')
if mtid and mtid > 0:
p['party_size'] = roster_parties.get(f"tid_{mtid}", 1)
else:
p['party_size'] = 1 # Solo roster player
else:
p['party_size'] = 0 # Hide party info for non-roster players
# Organize players by Side (team_id)
# team_id 1 = Team 1, team_id 2 = Team 2
# Note: group_id 1/2 usually corresponds to Team 1/2.
# Fallback to team_id if group_id is missing or 0 (legacy data compatibility)
team1_players = [p for p in players if p.get('group_id') == 1]
team2_players = [p for p in players if p.get('group_id') == 2]
# If group_id didn't work (empty lists), try team_id grouping (if team_id is 1/2 only)
if not team1_players and not team2_players:
team1_players = [p for p in players if p['team_id'] == 1]
team2_players = [p for p in players if p['team_id'] == 2]
# Explicitly sort by Rating DESC
team1_players.sort(key=lambda x: x.get('rating', 0) or 0, reverse=True)
team2_players.sort(key=lambda x: x.get('rating', 0) or 0, reverse=True)
# New Data for Enhanced Detail View
h2h_stats = StatsService.get_head_to_head_stats(match_id)
round_details = StatsService.get_match_round_details(match_id)
# Convert H2H stats to a more usable format (nested dict)
# h2h_matrix[attacker_id][victim_id] = kills
h2h_matrix = {}
if h2h_stats:
for row in h2h_stats:
a_id = row['attacker_steam_id']
v_id = row['victim_steam_id']
kills = row['kills']
if a_id not in h2h_matrix: h2h_matrix[a_id] = {}
h2h_matrix[a_id][v_id] = kills
# Create a mapping of SteamID -> Username for the template
# We can use the players list we already have
player_name_map = {}
for p in players:
sid = p.get('steam_id_64')
name = p.get('username')
if sid and name:
player_name_map[str(sid)] = name
return render_template('matches/detail.html', match=match,
team1_players=team1_players, team2_players=team2_players,
rounds=rounds,
h2h_matrix=h2h_matrix,
round_details=round_details,
player_name_map=player_name_map)
@bp.route('/<match_id>/raw')
def raw_json(match_id):
match = StatsService.get_match_detail(match_id)
if not match:
return "Match not found", 404
# Construct a raw object from available raw fields
data = {
'round_list': json.loads(match['round_list_raw']) if match['round_list_raw'] else None,
'leetify_data': json.loads(match['leetify_data_raw']) if match['leetify_data_raw'] else None
}
return Response(json.dumps(data, indent=2, ensure_ascii=False), mimetype='application/json')

35
web/routes/opponents.py Normal file
View File

@@ -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('/<steam_id>')
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)

431
web/routes/players.py Normal file
View File

@@ -0,0 +1,431 @@
from flask import Blueprint, render_template, request, jsonify, redirect, url_for, flash, current_app, session
from web.services.stats_service import StatsService
from web.services.feature_service import FeatureService
from web.services.web_service import WebService
from web.database import execute_db, query_db
from web.config import Config
from datetime import datetime
import os
from werkzeug.utils import secure_filename
bp = Blueprint('players', __name__, url_prefix='/players')
@bp.route('/')
def index():
page = request.args.get('page', 1, type=int)
search = request.args.get('search')
# Default sort by 'matches' as requested
sort_by = request.args.get('sort', 'matches')
players, total = FeatureService.get_players_list(page, Config.ITEMS_PER_PAGE, sort_by, search)
total_pages = (total + Config.ITEMS_PER_PAGE - 1) // Config.ITEMS_PER_PAGE
return render_template('players/list.html', players=players, total=total, page=page, total_pages=total_pages, sort_by=sort_by)
@bp.route('/<steam_id>', methods=['GET', 'POST'])
def detail(steam_id):
if request.method == 'POST':
# Check if admin action
if 'admin_action' in request.form and session.get('is_admin'):
action = request.form.get('admin_action')
if action == 'update_profile':
notes = request.form.get('notes')
# Handle Avatar Upload
if 'avatar' in request.files:
file = request.files['avatar']
if file and file.filename:
try:
# Use steam_id as filename to ensure uniqueness per player
# Preserve extension
ext = os.path.splitext(file.filename)[1].lower()
if not ext: ext = '.jpg'
filename = f"{steam_id}{ext}"
upload_folder = os.path.join(current_app.root_path, 'static', 'avatars')
os.makedirs(upload_folder, exist_ok=True)
file_path = os.path.join(upload_folder, filename)
file.save(file_path)
# Generate URL (relative to web root)
avatar_url = url_for('static', filename=f'avatars/{filename}')
# Update L2 DB directly (Immediate effect)
execute_db('l2', "UPDATE dim_players SET avatar_url = ? WHERE steam_id_64 = ?", [avatar_url, steam_id])
flash('Avatar updated successfully.', 'success')
except Exception as e:
print(f"Avatar upload error: {e}")
flash('Error uploading avatar.', 'error')
WebService.update_player_metadata(steam_id, notes=notes)
flash('Profile updated.', 'success')
elif action == 'add_tag':
tag = request.form.get('tag')
if tag:
meta = WebService.get_player_metadata(steam_id)
tags = meta.get('tags', [])
if tag not in tags:
tags.append(tag)
WebService.update_player_metadata(steam_id, tags=tags)
flash('Tag added.', 'success')
elif action == 'remove_tag':
tag = request.form.get('tag')
if tag:
meta = WebService.get_player_metadata(steam_id)
tags = meta.get('tags', [])
if tag in tags:
tags.remove(tag)
WebService.update_player_metadata(steam_id, tags=tags)
flash('Tag removed.', 'success')
return redirect(url_for('players.detail', steam_id=steam_id))
# Add Comment
username = request.form.get('username', 'Anonymous')
content = request.form.get('content')
if content:
WebService.add_comment(None, username, 'player', steam_id, content)
flash('Comment added!', 'success')
return redirect(url_for('players.detail', steam_id=steam_id))
player = StatsService.get_player_info(steam_id)
if not player:
return "Player not found", 404
features = FeatureService.get_player_features(steam_id)
# --- New: Fetch Detailed Stats from L2 (Clutch, Multi-Kill, Multi-Assist) ---
sql_l2 = """
SELECT
SUM(p.clutch_1v1) as c1, SUM(p.clutch_1v2) as c2, SUM(p.clutch_1v3) as c3, SUM(p.clutch_1v4) as c4, SUM(p.clutch_1v5) as c5,
SUM(a.attempt_1v1) as att1, SUM(a.attempt_1v2) as att2, SUM(a.attempt_1v3) as att3, SUM(a.attempt_1v4) as att4, SUM(a.attempt_1v5) as att5,
SUM(p.kill_2) as k2, SUM(p.kill_3) as k3, SUM(p.kill_4) as k4, SUM(p.kill_5) as k5,
SUM(p.many_assists_cnt2) as a2, SUM(p.many_assists_cnt3) as a3, SUM(p.many_assists_cnt4) as a4, SUM(p.many_assists_cnt5) as a5,
COUNT(*) as matches,
SUM(p.round_total) as total_rounds
FROM fact_match_players p
LEFT JOIN fact_match_clutch_attempts a ON p.match_id = a.match_id AND p.steam_id_64 = a.steam_id_64
WHERE p.steam_id_64 = ?
"""
l2_stats = query_db('l2', sql_l2, [steam_id], one=True)
l2_stats = dict(l2_stats) if l2_stats else {}
# Fetch T/CT splits for comparison
# Note: We use SUM(clutch...) as Total Clutch Wins. We don't have attempts, so 'Win Rate' is effectively Wins/Rounds or just Wins count.
# User asked for 'Win Rate', but without attempts data, we'll provide Rate per Round or just Count.
# Let's provide Rate per Round for Multi-Kill/Assist, and maybe just Count for Clutch?
# User said: "总残局胜率...分t和ct在下方加入对比".
# Since we found clutch == end in DB, we treat it as Wins. We can't calc Win %.
# We will display "Clutch Wins / Round" or just "Clutch Wins".
sql_side = """
SELECT
'T' as side,
SUM(clutch_1v1+clutch_1v2+clutch_1v3+clutch_1v4+clutch_1v5) as total_clutch,
SUM(kill_2+kill_3+kill_4+kill_5) as total_multikill,
SUM(many_assists_cnt2+many_assists_cnt3+many_assists_cnt4+many_assists_cnt5) as total_multiassist,
SUM(round_total) as rounds
FROM fact_match_players_t WHERE steam_id_64 = ?
UNION ALL
SELECT
'CT' as side,
SUM(clutch_1v1+clutch_1v2+clutch_1v3+clutch_1v4+clutch_1v5) as total_clutch,
SUM(kill_2+kill_3+kill_4+kill_5) as total_multikill,
SUM(many_assists_cnt2+many_assists_cnt3+many_assists_cnt4+many_assists_cnt5) as total_multiassist,
SUM(round_total) as rounds
FROM fact_match_players_ct WHERE steam_id_64 = ?
"""
side_rows = query_db('l2', sql_side, [steam_id, steam_id])
side_stats = {row['side']: dict(row) for row in side_rows} if side_rows else {}
# Ensure basic stats fallback if features missing or incomplete
basic = StatsService.get_player_basic_stats(steam_id)
from collections import defaultdict
if not features:
# Fallback to defaultdict with basic stats
features = defaultdict(lambda: None)
if basic:
features.update({
'basic_avg_rating': basic.get('rating', 0),
'basic_avg_kd': basic.get('kd', 0),
'basic_avg_kast': basic.get('kast', 0),
'basic_avg_adr': basic.get('adr', 0),
})
else:
# Convert to defaultdict to handle missing keys gracefully (e.g. newly added columns)
# Use lambda: None so that Jinja can check 'if value is not none'
features = defaultdict(lambda: None, dict(features))
# If features exist but ADR is missing (not in L3), try to patch it from basic
if 'basic_avg_adr' not in features or features['basic_avg_adr'] is None:
features['basic_avg_adr'] = basic.get('adr', 0) if basic else 0
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 []
# Calculate Map Stats
map_stats = {}
for match in history:
m_name = match['map_name']
if m_name not in map_stats:
map_stats[m_name] = {'matches': 0, 'wins': 0, 'adr_sum': 0, 'rating_sum': 0}
map_stats[m_name]['matches'] += 1
if match['is_win']:
map_stats[m_name]['wins'] += 1
map_stats[m_name]['adr_sum'] += (match['adr'] or 0)
map_stats[m_name]['rating_sum'] += (match['rating'] or 0)
map_stats_list = []
for m_name, data in map_stats.items():
cnt = data['matches']
map_stats_list.append({
'map_name': m_name,
'matches': cnt,
'win_rate': data['wins'] / cnt,
'adr': data['adr_sum'] / cnt,
'rating': data['rating_sum'] / cnt
})
map_stats_list.sort(key=lambda x: x['matches'], reverse=True)
# --- New: Recent Performance Stats ---
recent_stats = StatsService.get_recent_performance_stats(steam_id)
return render_template('players/profile.html',
player=player,
features=features,
comments=comments,
metadata=metadata,
history=history,
distribution=distribution,
map_stats=map_stats_list,
l2_stats=l2_stats,
side_stats=side_stats,
recent_stats=recent_stats)
@bp.route('/comment/<int:comment_id>/like', methods=['POST'])
def like_comment(comment_id):
WebService.like_comment(comment_id)
return jsonify({'success': True})
@bp.route('/<steam_id>/charts_data')
def charts_data(steam_id):
# ... (existing code) ...
# Trend Data
trends = StatsService.get_player_trend(steam_id, limit=1000)
# Radar Data (Construct from features)
features = FeatureService.get_player_features(steam_id)
radar_data = {}
radar_dist = FeatureService.get_roster_features_distribution(steam_id)
if features:
# Dimensions: STA, BAT, HPS, PTL, T/CT, UTIL
# Use calculated scores (0-100 scale)
# Helper to get score safely
def get_score(key):
val = features[key] if key in features.keys() else 0
return float(val) if val else 0
radar_data = {
'STA': get_score('score_sta'),
'BAT': get_score('score_bat'),
'HPS': get_score('score_hps'),
'PTL': get_score('score_ptl'),
'SIDE': get_score('score_tct'),
'UTIL': get_score('score_util'),
'ECO': get_score('score_eco'),
'PACE': get_score('score_pace')
}
trend_labels = []
trend_values = []
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({
'trend': {'labels': trend_labels, 'values': trend_values},
'radar': radar_data,
'radar_dist': radar_dist
})
# --- API for Comparison ---
@bp.route('/api/search')
def api_search():
query = request.args.get('q', '')
if len(query) < 2:
return jsonify([])
players, _ = FeatureService.get_players_list(page=1, per_page=10, search=query)
# Return minimal data
results = [{'steam_id': p['steam_id_64'], 'username': p['username'], 'avatar_url': p['avatar_url']} for p in players]
return jsonify(results)
@bp.route('/api/batch_stats')
def api_batch_stats():
steam_ids = request.args.get('ids', '').split(',')
stats = []
for sid in steam_ids:
if not sid: continue
f = FeatureService.get_player_features(sid)
p = StatsService.get_player_info(sid)
if f and p:
# Convert sqlite3.Row to dict if necessary
if hasattr(f, 'keys'): # It's a Row object or similar
f = dict(f)
# 1. Radar Scores (Normalized 0-100)
# Use safe conversion with default 0 if None
# Force 0.0 if value is 0 or None to ensure JSON compatibility
radar = {
'STA': float(f.get('score_sta') or 0.0),
'BAT': float(f.get('score_bat') or 0.0),
'HPS': float(f.get('score_hps') or 0.0),
'PTL': float(f.get('score_ptl') or 0.0),
'SIDE': float(f.get('score_tct') or 0.0),
'UTIL': float(f.get('score_util') or 0.0)
}
# 2. Basic Stats for Table
basic = {
'rating': float(f.get('basic_avg_rating') or 0),
'kd': float(f.get('basic_avg_kd') or 0),
'adr': float(f.get('basic_avg_adr') or 0),
'kast': float(f.get('basic_avg_kast') or 0),
'hs_rate': float(f.get('basic_headshot_rate') or 0),
'fk_rate': float(f.get('basic_first_kill_rate') or 0),
'matches': int(f.get('matches_played') or 0)
}
# 3. Side Stats
side = {
'rating_t': float(f.get('side_rating_t') or 0),
'rating_ct': float(f.get('side_rating_ct') or 0),
'kd_t': float(f.get('side_kd_t') or 0),
'kd_ct': float(f.get('side_kd_ct') or 0),
'entry_t': float(f.get('side_entry_rate_t') or 0),
'entry_ct': float(f.get('side_entry_rate_ct') or 0),
'kast_t': float(f.get('side_kast_t') or 0),
'kast_ct': float(f.get('side_kast_ct') or 0),
'adr_t': float(f.get('side_adr_t') or 0),
'adr_ct': float(f.get('side_adr_ct') or 0)
}
# 4. Detailed Stats (Expanded for Data Center - Aligned with Profile)
detailed = {
# Row 1
'rating_t': float(f.get('side_rating_t') or 0),
'rating_ct': float(f.get('side_rating_ct') or 0),
'kd_t': float(f.get('side_kd_t') or 0),
'kd_ct': float(f.get('side_kd_ct') or 0),
# Row 2
'win_rate_t': float(f.get('side_win_rate_t') or 0),
'win_rate_ct': float(f.get('side_win_rate_ct') or 0),
'first_kill_t': float(f.get('side_first_kill_rate_t') or 0),
'first_kill_ct': float(f.get('side_first_kill_rate_ct') or 0),
# Row 3
'first_death_t': float(f.get('side_first_death_rate_t') or 0),
'first_death_ct': float(f.get('side_first_death_rate_ct') or 0),
'kast_t': float(f.get('side_kast_t') or 0),
'kast_ct': float(f.get('side_kast_ct') or 0),
# Row 4
'rws_t': float(f.get('side_rws_t') or 0),
'rws_ct': float(f.get('side_rws_ct') or 0),
'multikill_t': float(f.get('side_multikill_rate_t') or 0),
'multikill_ct': float(f.get('side_multikill_rate_ct') or 0),
# Row 5
'hs_t': float(f.get('side_headshot_rate_t') or 0),
'hs_ct': float(f.get('side_headshot_rate_ct') or 0),
'obj_t': float(f.get('side_obj_t') or 0),
'obj_ct': float(f.get('side_obj_ct') or 0)
}
stats.append({
'username': p['username'],
'steam_id': sid,
'avatar_url': p['avatar_url'],
'radar': radar,
'basic': basic,
'side': side,
'detailed': detailed
})
return jsonify(stats)
@bp.route('/api/batch_map_stats')
def api_batch_map_stats():
steam_ids = request.args.get('ids', '').split(',')
steam_ids = [sid for sid in steam_ids if sid]
if not steam_ids:
return jsonify({})
# Query L2 for Map Stats grouped by Player and Map
# We need to construct a query that can be executed via execute_db or query_db
# Since StatsService usually handles this, we can write raw SQL here or delegate.
# Raw SQL is easier for this specific aggregation.
placeholders = ','.join('?' for _ in steam_ids)
sql = f"""
SELECT
mp.steam_id_64,
m.map_name,
COUNT(*) as matches,
SUM(CASE WHEN mp.is_win THEN 1 ELSE 0 END) as wins,
AVG(mp.rating) as avg_rating,
AVG(mp.kd_ratio) as avg_kd,
AVG(mp.adr) as avg_adr
FROM fact_match_players mp
JOIN fact_matches m ON mp.match_id = m.match_id
WHERE mp.steam_id_64 IN ({placeholders})
GROUP BY mp.steam_id_64, m.map_name
ORDER BY matches DESC
"""
# We need to import query_db if not available in current scope (it is imported at top)
from web.database import query_db
rows = query_db('l2', sql, steam_ids)
# Structure: {steam_id: [ {map: 'de_mirage', stats...}, ... ]}
result = {}
for r in rows:
sid = r['steam_id_64']
if sid not in result:
result[sid] = []
result[sid].append({
'map_name': r['map_name'],
'matches': r['matches'],
'win_rate': (r['wins'] / r['matches']) if r['matches'] else 0,
'rating': r['avg_rating'],
'kd': r['avg_kd'],
'adr': r['avg_adr']
})
return jsonify(result)

103
web/routes/tactics.py Normal file
View File

@@ -0,0 +1,103 @@
from flask import Blueprint, render_template, request, jsonify
from web.services.web_service import WebService
from web.services.stats_service import StatsService
from web.services.feature_service import FeatureService
import json
bp = Blueprint('tactics', __name__, url_prefix='/tactics')
@bp.route('/')
def index():
return render_template('tactics/index.html')
# API: Analyze Lineup
@bp.route('/api/analyze', methods=['POST'])
def api_analyze():
data = request.json
steam_ids = data.get('steam_ids', [])
if not steam_ids:
return jsonify({'error': 'No players selected'}), 400
# 1. Get Basic Info & Stats
players = StatsService.get_players_by_ids(steam_ids)
player_data = []
total_rating = 0
total_kd = 0
total_adr = 0
count = 0
for p in players:
p_dict = dict(p)
# Fetch L3 features
f = FeatureService.get_player_features(p_dict['steam_id_64'])
stats = dict(f) if f else {}
p_dict['stats'] = stats
player_data.append(p_dict)
if stats:
total_rating += stats.get('basic_avg_rating', 0) or 0
total_kd += stats.get('basic_avg_kd', 0) or 0
total_adr += stats.get('basic_avg_adr', 0) or 0
count += 1
# 2. Shared Matches
shared_matches = StatsService.get_shared_matches(steam_ids)
# They are already dicts now with 'result_str' and 'is_win'
# 3. Aggregates
avg_stats = {
'rating': total_rating / count if count else 0,
'kd': total_kd / count if count else 0,
'adr': total_adr / count if count else 0
}
# 4. Map Stats Calculation
map_stats = {} # {map_name: {'count': 0, 'wins': 0}}
total_shared_matches = len(shared_matches)
for m in shared_matches:
map_name = m['map_name']
if map_name not in map_stats:
map_stats[map_name] = {'count': 0, 'wins': 0}
map_stats[map_name]['count'] += 1
if m['is_win']:
map_stats[map_name]['wins'] += 1
# Convert to list for frontend
map_stats_list = []
for k, v in map_stats.items():
win_rate = (v['wins'] / v['count'] * 100) if v['count'] > 0 else 0
map_stats_list.append({
'map_name': k,
'count': v['count'],
'wins': v['wins'],
'win_rate': win_rate
})
# Sort by count desc
map_stats_list.sort(key=lambda x: x['count'], reverse=True)
return jsonify({
'players': player_data,
'shared_matches': [dict(m) for m in shared_matches],
'avg_stats': avg_stats,
'map_stats': map_stats_list,
'total_shared_matches': total_shared_matches
})
# API: Save Board
@bp.route('/save_board', methods=['POST'])
def save_board():
data = request.json
title = data.get('title', 'Untitled Strategy')
map_name = data.get('map_name', 'de_mirage')
markers = data.get('markers')
if not markers:
return jsonify({'success': False, 'message': 'No markers to save'})
WebService.save_strategy_board(title, map_name, json.dumps(markers), 'Anonymous')
return jsonify({'success': True, 'message': 'Board saved successfully'})

225
web/routes/teams.py Normal file
View File

@@ -0,0 +1,225 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session
from web.services.web_service import WebService
from web.services.stats_service import StatsService
from web.services.feature_service import FeatureService
import json
bp = Blueprint('teams', __name__, url_prefix='/teams')
# --- API Endpoints ---
@bp.route('/api/search')
def api_search():
query = request.args.get('q', '').strip() # Strip whitespace
print(f"DEBUG: Search Query Received: '{query}'") # Debug Log
if len(query) < 2:
return jsonify([])
# Use L2 database for fuzzy search on username
from web.services.stats_service import StatsService
# Support sorting by matches for better "Find Player" experience
sort_by = request.args.get('sort', 'matches')
print(f"DEBUG: Calling StatsService.get_players with search='{query}'")
players, total = StatsService.get_players(page=1, per_page=50, search=query, sort_by=sort_by)
print(f"DEBUG: Found {len(players)} players (Total: {total})")
# Format for frontend
results = []
for p in players:
# Convert sqlite3.Row to dict to avoid AttributeError
p_dict = dict(p)
# Fetch feature stats for better preview
f = FeatureService.get_player_features(p_dict['steam_id_64'])
# Manually attach match count if not present
matches_played = p_dict.get('matches_played', 0)
results.append({
'steam_id': p_dict['steam_id_64'],
'name': p_dict['username'],
'avatar': p_dict['avatar_url'] or 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
'rating': (f['basic_avg_rating'] if f else 0.0),
'matches': matches_played
})
# Python-side sort if DB sort didn't work for 'matches' (since dim_players doesn't have match_count)
if sort_by == 'matches':
# We need to fetch match counts to sort!
# This is expensive for search results but necessary for "matches sample sort"
# Let's batch fetch counts for these 50 players
steam_ids = [r['steam_id'] for r in results]
if steam_ids:
from web.services.web_service import query_db
placeholders = ','.join('?' for _ in steam_ids)
sql = f"SELECT steam_id_64, COUNT(*) as cnt FROM fact_match_players WHERE steam_id_64 IN ({placeholders}) GROUP BY steam_id_64"
counts = query_db('l2', sql, steam_ids)
cnt_map = {r['steam_id_64']: r['cnt'] for r in counts}
for r in results:
r['matches'] = cnt_map.get(r['steam_id'], 0)
results.sort(key=lambda x: x['matches'], reverse=True)
print(f"DEBUG: Returning {len(results)} results")
return jsonify(results)
@bp.route('/api/roster', methods=['GET', 'POST'])
def api_roster():
# Assume single team mode, always operating on ID=1 or the first lineup
lineups = WebService.get_lineups()
if not lineups:
# Auto-create default team if none exists
WebService.save_lineup("My Team", "Default Roster", [])
lineups = WebService.get_lineups()
target_team = dict(lineups[0]) # Get the latest one
if request.method == 'POST':
# Admin Check
if not session.get('is_admin'):
return jsonify({'error': 'Unauthorized'}), 403
data = request.json
action = data.get('action')
steam_id = data.get('steam_id')
current_ids = []
try:
current_ids = json.loads(target_team['player_ids_json'])
except:
pass
if action == 'add':
if steam_id not in current_ids:
current_ids.append(steam_id)
elif action == 'remove':
if steam_id in current_ids:
current_ids.remove(steam_id)
# Pass lineup_id=target_team['id'] to update existing lineup
WebService.save_lineup(target_team['name'], target_team['description'], current_ids, lineup_id=target_team['id'])
return jsonify({'status': 'success', 'roster': current_ids})
# GET: Return detailed player info
try:
print(f"DEBUG: api_roster GET - Target Team: {target_team.get('id')}")
p_ids_json = target_team.get('player_ids_json', '[]')
p_ids = json.loads(p_ids_json)
print(f"DEBUG: Player IDs: {p_ids}")
players = StatsService.get_players_by_ids(p_ids)
print(f"DEBUG: Players fetched: {len(players) if players else 0}")
# Add extra stats needed for cards
enriched = []
if players:
for p in players:
try:
# Convert sqlite3.Row to dict
p_dict = dict(p)
# print(f"DEBUG: Processing player {p_dict.get('steam_id_64')}")
# Get features for Rating/KD display
f = FeatureService.get_player_features(p_dict['steam_id_64'])
# f might be a Row object, convert it
p_dict['stats'] = dict(f) if f else {}
# Fetch Metadata (Tags)
meta = WebService.get_player_metadata(p_dict['steam_id_64'])
p_dict['tags'] = meta.get('tags', [])
enriched.append(p_dict)
except Exception as inner_e:
print(f"ERROR: Processing player failed: {inner_e}")
import traceback
traceback.print_exc()
return jsonify({
'status': 'success',
'team': dict(target_team), # Ensure target_team is dict too
'roster': enriched
})
except Exception as e:
print(f"CRITICAL ERROR in api_roster: {e}")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500
# --- Views ---
@bp.route('/')
def index():
# Directly render the Clubhouse SPA
return render_template('teams/clubhouse.html')
# Deprecated routes (kept for compatibility if needed, but hidden)
@bp.route('/list')
def list_view():
lineups = WebService.get_lineups()
# ... existing logic ...
return render_template('teams/list.html', lineups=lineups)
@bp.route('/<int:lineup_id>')
def detail(lineup_id):
lineup = WebService.get_lineup(lineup_id)
if not lineup:
return "Lineup not found", 404
p_ids = json.loads(lineup['player_ids_json'])
players = StatsService.get_players_by_ids(p_ids)
# Shared Matches
shared_matches = StatsService.get_shared_matches(p_ids)
# Calculate Aggregate Stats
agg_stats = {
'avg_rating': 0,
'avg_kd': 0,
'avg_kast': 0
}
radar_data = {
'STA': 0, 'BAT': 0, 'HPS': 0, 'PTL': 0, 'SIDE': 0, 'UTIL': 0
}
player_features = []
if players:
count = len(players)
total_rating = 0
total_kd = 0
total_kast = 0
# Radar totals
r_totals = {k: 0 for k in radar_data}
for p in players:
# Fetch L3 features for each player
f = FeatureService.get_player_features(p['steam_id_64'])
if f:
player_features.append(f)
total_rating += f['basic_avg_rating'] or 0
total_kd += f['basic_avg_kd'] or 0
total_kast += f['basic_avg_kast'] or 0
# Radar accumulation
r_totals['STA'] += f['basic_avg_rating'] or 0
r_totals['BAT'] += f['bat_avg_duel_win_rate'] or 0
r_totals['HPS'] += f['hps_clutch_win_rate_1v1'] or 0
r_totals['PTL'] += f['ptl_pistol_win_rate'] or 0
r_totals['SIDE'] += f['side_rating_ct'] or 0
r_totals['UTIL'] += f['util_usage_rate'] or 0
else:
player_features.append(None)
if count > 0:
agg_stats['avg_rating'] = total_rating / count
agg_stats['avg_kd'] = total_kd / count
agg_stats['avg_kast'] = total_kast / count
for k in radar_data:
radar_data[k] = r_totals[k] / count
return render_template('teams/detail.html', lineup=lineup, players=players, agg_stats=agg_stats, shared_matches=shared_matches, radar_data=radar_data)

32
web/routes/wiki.py Normal file
View File

@@ -0,0 +1,32 @@
from flask import Blueprint, render_template, request, redirect, url_for, session
from web.services.web_service import WebService
from web.auth import admin_required
bp = Blueprint('wiki', __name__, url_prefix='/wiki')
@bp.route('/')
def index():
pages = WebService.get_all_wiki_pages()
return render_template('wiki/index.html', pages=pages)
@bp.route('/view/<path:page_path>')
def view(page_path):
page = WebService.get_wiki_page(page_path)
if not page:
# If admin, offer to create
if session.get('is_admin'):
return redirect(url_for('wiki.edit', page_path=page_path))
return "Page not found", 404
return render_template('wiki/view.html', page=page)
@bp.route('/edit/<path:page_path>', methods=['GET', 'POST'])
@admin_required
def edit(page_path):
if request.method == 'POST':
title = request.form.get('title')
content = request.form.get('content')
WebService.save_wiki_page(page_path, title, content, 'admin')
return redirect(url_for('wiki.view', page_path=page_path))
page = WebService.get_wiki_page(page_path)
return render_template('wiki/edit.html', page=page, page_path=page_path)