diff --git a/database/L3/L3.db b/database/L3/L3.db index ca6fe45..8bb80ea 100644 Binary files a/database/L3/L3.db and b/database/L3/L3.db differ diff --git a/database/L3/L3_Builder.py b/database/L3/L3_Builder.py index 5482017..526fa19 100644 --- a/database/L3/L3_Builder.py +++ b/database/L3/L3_Builder.py @@ -4,6 +4,8 @@ import os import sys import sqlite3 import json +import argparse +import concurrent.futures # Setup logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') @@ -94,7 +96,61 @@ def _get_team_players(): logger.error(f"Error reading Web DB: {e}") return set() -def main(): +def _get_match_date_range(steam_id: str, conn_l2: sqlite3.Connection): + cursor = conn_l2.cursor() + cursor.execute(""" + SELECT MIN(m.start_time), MAX(m.start_time) + FROM fact_match_players p + JOIN fact_matches m ON p.match_id = m.match_id + WHERE p.steam_id_64 = ? + """, (steam_id,)) + date_row = cursor.fetchone() + first_match_date = date_row[0] if date_row and date_row[0] else None + last_match_date = date_row[1] if date_row and date_row[1] else None + return first_match_date, last_match_date + +def _build_player_record(steam_id: str): + try: + from database.L3.processors import ( + BasicProcessor, + TacticalProcessor, + IntelligenceProcessor, + MetaProcessor, + CompositeProcessor + ) + conn_l2 = sqlite3.connect(L2_DB_PATH) + conn_l2.row_factory = sqlite3.Row + features = {} + features.update(BasicProcessor.calculate(steam_id, conn_l2)) + features.update(TacticalProcessor.calculate(steam_id, conn_l2)) + features.update(IntelligenceProcessor.calculate(steam_id, conn_l2)) + features.update(MetaProcessor.calculate(steam_id, conn_l2)) + features.update(CompositeProcessor.calculate(steam_id, conn_l2, features)) + match_count = _get_match_count(steam_id, conn_l2) + round_count = _get_round_count(steam_id, conn_l2) + first_match_date, last_match_date = _get_match_date_range(steam_id, conn_l2) + conn_l2.close() + return { + "steam_id": steam_id, + "features": features, + "match_count": match_count, + "round_count": round_count, + "first_match_date": first_match_date, + "last_match_date": last_match_date, + "error": None, + } + except Exception as e: + return { + "steam_id": steam_id, + "features": None, + "match_count": 0, + "round_count": 0, + "first_match_date": None, + "last_match_date": None, + "error": str(e), + } + +def main(force_all: bool = False, workers: int = 1): """ Main L3 feature building pipeline using modular processors """ @@ -125,26 +181,29 @@ def main(): conn_l3 = sqlite3.connect(L3_DB_PATH) try: - # 4. Get target players (Team Lineups only) - team_players = _get_team_players() - if not team_players: - logger.warning("No players found in Team Lineups. Aborting L3 build.") - return - - # 5. Get distinct players from L2 matching Team Lineups cursor_l2 = conn_l2.cursor() - - # Build placeholder string for IN clause - placeholders = ','.join(['?' for _ in team_players]) - - sql = f""" - SELECT DISTINCT steam_id_64 - FROM dim_players - WHERE steam_id_64 IN ({placeholders}) - ORDER BY steam_id_64 - """ - - cursor_l2.execute(sql, list(team_players)) + if force_all: + logger.info("Force mode enabled: building L3 for all players in L2.") + sql = """ + SELECT DISTINCT steam_id_64 + FROM dim_players + ORDER BY steam_id_64 + """ + cursor_l2.execute(sql) + else: + team_players = _get_team_players() + if not team_players: + logger.warning("No players found in Team Lineups. Aborting L3 build.") + return + + placeholders = ','.join(['?' for _ in team_players]) + sql = f""" + SELECT DISTINCT steam_id_64 + FROM dim_players + WHERE steam_id_64 IN ({placeholders}) + ORDER BY steam_id_64 + """ + cursor_l2.execute(sql, list(team_players)) players = cursor_l2.fetchall() total_players = len(players) @@ -156,51 +215,61 @@ def main(): success_count = 0 error_count = 0 - - # 6. Process each player - for idx, row in enumerate(players, 1): - steam_id = row[0] - - try: - # Calculate features from each processor tier by tier - features = {} + processed_count = 0 + + if workers and workers > 1: + steam_ids = [row[0] for row in players] + with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as executor: + futures = [executor.submit(_build_player_record, sid) for sid in steam_ids] + for future in concurrent.futures.as_completed(futures): + result = future.result() + processed_count += 1 + if result.get("error"): + error_count += 1 + logger.error(f"Error processing player {result.get('steam_id')}: {result.get('error')}") + else: + _upsert_features( + conn_l3, + result["steam_id"], + result["features"], + result["match_count"], + result["round_count"], + None, + result["first_match_date"], + result["last_match_date"], + ) + success_count += 1 + if processed_count % 4 == 0: + conn_l3.commit() + logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)") + else: + for idx, row in enumerate(players, 1): + steam_id = row[0] - # Tier 1: CORE (41 columns) - features.update(BasicProcessor.calculate(steam_id, conn_l2)) - - # Tier 2: TACTICAL (44 columns) - features.update(TacticalProcessor.calculate(steam_id, conn_l2)) - - # Tier 3: INTELLIGENCE (53 columns) - features.update(IntelligenceProcessor.calculate(steam_id, conn_l2)) - - # Tier 4: META (52 columns) - features.update(MetaProcessor.calculate(steam_id, conn_l2)) - - # Tier 5: COMPOSITE (11 columns) - requires previous features - features.update(CompositeProcessor.calculate(steam_id, conn_l2, features)) - - # Add metadata - match_count = _get_match_count(steam_id, conn_l2) - round_count = _get_round_count(steam_id, conn_l2) - - # Insert/Update features in L3 - _upsert_features(conn_l3, steam_id, features, match_count, round_count, conn_l2) - - success_count += 1 - - # Batch commit and progress logging - if idx % 50 == 0: + try: + features = {} + features.update(BasicProcessor.calculate(steam_id, conn_l2)) + features.update(TacticalProcessor.calculate(steam_id, conn_l2)) + features.update(IntelligenceProcessor.calculate(steam_id, conn_l2)) + features.update(MetaProcessor.calculate(steam_id, conn_l2)) + features.update(CompositeProcessor.calculate(steam_id, conn_l2, features)) + match_count = _get_match_count(steam_id, conn_l2) + round_count = _get_round_count(steam_id, conn_l2) + first_match_date, last_match_date = _get_match_date_range(steam_id, conn_l2) + _upsert_features(conn_l3, steam_id, features, match_count, round_count, conn_l2, first_match_date, last_match_date) + success_count += 1 + except Exception as e: + error_count += 1 + logger.error(f"Error processing player {steam_id}: {e}") + if error_count <= 3: + import traceback + traceback.print_exc() + continue + + processed_count = idx + if processed_count % 4 == 0: conn_l3.commit() - logger.info(f"Progress: {idx}/{total_players} ({success_count} success, {error_count} errors)") - - except Exception as e: - error_count += 1 - logger.error(f"Error processing player {steam_id}: {e}") - if error_count <= 3: # Show details for first 3 errors - import traceback - traceback.print_exc() - continue + logger.info(f"Progress: {processed_count}/{total_players} ({success_count} success, {error_count} errors)") # Final commit conn_l3.commit() @@ -244,23 +313,18 @@ def _get_round_count(steam_id: str, conn_l2: sqlite3.Connection) -> int: def _upsert_features(conn_l3: sqlite3.Connection, steam_id: str, features: dict, - match_count: int, round_count: int, conn_l2: sqlite3.Connection): + match_count: int, round_count: int, conn_l2: sqlite3.Connection | None, + first_match_date=None, last_match_date=None): """ Insert or update player features in dm_player_features """ cursor_l3 = conn_l3.cursor() - cursor_l2 = conn_l2.cursor() - - # Get first and last match dates from L2 - cursor_l2.execute(""" - SELECT MIN(m.start_time), MAX(m.start_time) - FROM fact_match_players p - JOIN fact_matches m ON p.match_id = m.match_id - WHERE p.steam_id_64 = ? - """, (steam_id,)) - date_row = cursor_l2.fetchone() - first_match_date = date_row[0] if date_row and date_row[0] else None - last_match_date = date_row[1] if date_row and date_row[1] else None + if first_match_date is None or last_match_date is None: + if conn_l2 is not None: + first_match_date, last_match_date = _get_match_date_range(steam_id, conn_l2) + else: + first_match_date = None + last_match_date = None # Add metadata to features features['total_matches'] = match_count @@ -289,5 +353,12 @@ def _upsert_features(conn_l3: sqlite3.Connection, steam_id: str, features: dict, cursor_l3.execute(sql, values) +def _parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--force", action="store_true") + parser.add_argument("--workers", type=int, default=1) + return parser.parse_args() + if __name__ == "__main__": - main() + args = _parse_args() + main(force_all=args.force, workers=args.workers) diff --git a/web/routes/players.py b/web/routes/players.py index a0f9d65..5a7f444 100644 --- a/web/routes/players.py +++ b/web/routes/players.py @@ -299,20 +299,27 @@ def api_batch_stats(): # 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) + 'AIM': float(f.get('score_aim') or 0.0), + 'DEFENSE': float(f.get('score_defense') or 0.0), + 'UTILITY': float(f.get('score_utility') or 0.0), + 'CLUTCH': float(f.get('score_clutch') or 0.0), + 'ECONOMY': float(f.get('score_economy') or 0.0), + 'PACE': float(f.get('score_pace') or 0.0), + 'PISTOL': float(f.get('score_pistol') or 0.0), + 'STABILITY': float(f.get('score_stability') or 0.0) } # 2. Basic Stats for Table + rating_val = f.get('core_avg_rating2') + if rating_val is None: + rating_val = f.get('core_avg_rating') + if rating_val is None: + rating_val = f.get('basic_avg_rating') 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), + 'rating': float(rating_val or 0), + 'kd': float(f.get('core_avg_kd') or f.get('basic_avg_kd') or 0), + 'adr': float(f.get('core_avg_adr') or f.get('basic_avg_adr') or 0), + 'kast': float(f.get('core_avg_kast') or 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) diff --git a/web/routes/tactics.py b/web/routes/tactics.py index dcf5d5c..d6b1a5a 100644 --- a/web/routes/tactics.py +++ b/web/routes/tactics.py @@ -27,6 +27,7 @@ def api_analyze(): total_kd = 0 total_adr = 0 count = 0 + radar_vectors = [] for p in players: p_dict = dict(p) @@ -37,10 +38,25 @@ def api_analyze(): 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 + rating_val = stats.get('core_avg_rating2') + if rating_val is None: + rating_val = stats.get('core_avg_rating') + if rating_val is None: + rating_val = stats.get('basic_avg_rating') + total_rating += rating_val or 0 + total_kd += stats.get('core_avg_kd', stats.get('basic_avg_kd', 0)) or 0 + total_adr += stats.get('core_avg_adr', stats.get('basic_avg_adr', 0)) or 0 count += 1 + radar_vectors.append([ + float(stats.get('score_aim') or 0), + float(stats.get('score_defense') or 0), + float(stats.get('score_utility') or 0), + float(stats.get('score_clutch') or 0), + float(stats.get('score_economy') or 0), + float(stats.get('score_pace') or 0), + float(stats.get('score_pistol') or 0), + float(stats.get('score_stability') or 0) + ]) # 2. Shared Matches shared_matches = StatsService.get_shared_matches(steam_ids) @@ -53,6 +69,23 @@ def api_analyze(): 'adr': total_adr / count if count else 0 } + chemistry = 0 + if len(radar_vectors) >= 2: + def cosine_sim(a, b): + dot = sum(x * y for x, y in zip(a, b)) + na = sum(x * x for x in a) ** 0.5 + nb = sum(y * y for y in b) ** 0.5 + if na == 0 or nb == 0: + return 0 + return dot / (na * nb) + + sims = [] + for i in range(len(radar_vectors)): + for j in range(i + 1, len(radar_vectors)): + sims.append(cosine_sim(radar_vectors[i], radar_vectors[j])) + if sims: + chemistry = sum(sims) / len(sims) * 100 + # 4. Map Stats Calculation map_stats = {} # {map_name: {'count': 0, 'wins': 0}} total_shared_matches = len(shared_matches) @@ -85,7 +118,8 @@ def api_analyze(): 'shared_matches': [dict(m) for m in shared_matches], 'avg_stats': avg_stats, 'map_stats': map_stats_list, - 'total_shared_matches': total_shared_matches + 'total_shared_matches': total_shared_matches, + 'chemistry': chemistry }) # API: Save Board diff --git a/web/services/feature_service.py b/web/services/feature_service.py index 115cb30..22f403a 100644 --- a/web/services/feature_service.py +++ b/web/services/feature_service.py @@ -78,8 +78,12 @@ class FeatureService: } for legacy_key, l3_key in alias_map.items(): - if legacy_key not in f or f.get(legacy_key) is None: - f[legacy_key] = f.get(l3_key) + legacy_val = f.get(legacy_key) + l3_val = f.get(l3_key) + if legacy_val is None and l3_val is not None: + f[legacy_key] = l3_val + elif l3_val is None and legacy_val is not None: + f[l3_key] = legacy_val if f.get("matches_played") is None: f["matches_played"] = f.get("total_matches", 0) or 0 diff --git a/web/services/stats_service.py b/web/services/stats_service.py index 23738a8..4079178 100644 --- a/web/services/stats_service.py +++ b/web/services/stats_service.py @@ -733,16 +733,19 @@ class StatsService: from web.services.feature_service import FeatureService import json - # 1. Get Active Roster IDs lineups = WebService.get_lineups() active_roster_ids = [] + target_steam_id = str(target_steam_id) if lineups: - try: - raw_ids = json.loads(lineups[0]['player_ids_json']) - active_roster_ids = [str(uid) for uid in raw_ids] - except: - pass - + for lineup in lineups: + try: + raw_ids = json.loads(lineup.get('player_ids_json') or '[]') + roster_ids = [str(uid) for uid in raw_ids] + if target_steam_id in roster_ids: + active_roster_ids = roster_ids + break + except Exception: + continue if not active_roster_ids: return None @@ -752,11 +755,8 @@ class StatsService: return None stats_map = {str(row["steam_id_64"]): FeatureService._normalize_features(dict(row)) for row in rows} - target_steam_id = str(target_steam_id) - - # If target not in map (e.g. no L3 data), try to add empty default if target_steam_id not in stats_map: - stats_map[target_steam_id] = {} + return None metrics = [ # TIER 1: CORE diff --git a/web/templates/players/list.html b/web/templates/players/list.html index eb663ba..a71e879 100644 --- a/web/templates/players/list.html +++ b/web/templates/players/list.html @@ -40,15 +40,15 @@
- {{ "%.2f"|format(player.basic_avg_rating|default(0)) }} + {{ "%.2f"|format(player.core_avg_rating2 or player.core_avg_rating or 0) }} Rating
- {{ "%.2f"|format(player.basic_avg_kd|default(0)) }} + {{ "%.2f"|format(player.core_avg_kd or 0) }} K/D
- {{ "%.1f"|format((player.basic_avg_kast|default(0)) * 100) }}% + {{ "%.1f"|format((player.core_avg_kast or 0) * 100) }}% KAST
diff --git a/web/templates/tactics/board.html b/web/templates/tactics/board.html index 29e42c3..5a562c2 100644 --- a/web/templates/tactics/board.html +++ b/web/templates/tactics/board.html @@ -338,10 +338,10 @@ function tacticsBoard() { this.radarChart = new Chart(ctx, { type: 'radar', data: { - labels: ['RTG', 'K/D', 'KST', 'ADR', 'IMP', 'UTL'], + labels: ['枪法', '生存', '道具', '残局', '经济', '节奏', '手枪', '稳定'], datasets: [{ label: 'Avg', - data: [0, 0, 0, 0, 0, 0], + data: [0, 0, 0, 0, 0, 0, 0, 0], backgroundColor: 'rgba(139, 92, 246, 0.2)', borderColor: 'rgba(139, 92, 246, 1)', pointBackgroundColor: 'rgba(139, 92, 246, 1)', @@ -354,7 +354,7 @@ function tacticsBoard() { scales: { r: { beginAtZero: true, - max: 1.5, + max: 100, grid: { color: 'rgba(156, 163, 175, 0.1)' }, angleLines: { color: 'rgba(156, 163, 175, 0.1)' }, pointLabels: { font: { size: 9 } }, @@ -368,20 +368,22 @@ function tacticsBoard() { updateRadar() { if (this.activePlayers.length === 0) { - this.radarChart.data.datasets[0].data = [0, 0, 0, 0, 0, 0]; + this.radarChart.data.datasets[0].data = [0, 0, 0, 0, 0, 0, 0, 0]; this.radarChart.update(); return; } - let totals = [0, 0, 0, 0, 0, 0]; + let totals = [0, 0, 0, 0, 0, 0, 0, 0]; this.activePlayers.forEach(p => { const s = p.stats || {}; - totals[0] += s.basic_avg_rating || 0; - totals[1] += s.basic_avg_kd || 0; - totals[2] += s.basic_avg_kast || 0; - totals[3] += (s.basic_avg_adr || 0) / 100; - totals[4] += s.bat_avg_impact || 1.0; - totals[5] += s.util_usage_rate || 0.5; + totals[0] += s.score_aim || 0; + totals[1] += s.score_defense || 0; + totals[2] += s.score_utility || 0; + totals[3] += s.score_clutch || 0; + totals[4] += s.score_economy || 0; + totals[5] += s.score_pace || 0; + totals[6] += s.score_pistol || 0; + totals[7] += s.score_stability || 0; }); const count = this.activePlayers.length; @@ -393,4 +395,4 @@ function tacticsBoard() { } } -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/web/templates/tactics/index.html b/web/templates/tactics/index.html index 24f172a..5c8fe7f 100644 --- a/web/templates/tactics/index.html +++ b/web/templates/tactics/index.html @@ -120,7 +120,7 @@
- Rating: + Rating:
@@ -149,9 +149,15 @@

📈 综合评分

-
- Team Rating - +
+
+ Team Rating + +
+
+ Chemistry + +
@@ -526,13 +532,10 @@ function tacticsApp() { // Unwrap proxy if needed const rawData = JSON.parse(JSON.stringify(this.dataResult)); + const radarKeys = ['AIM', 'DEFENSE', 'UTILITY', 'CLUTCH', 'ECONOMY', 'PACE', 'PISTOL', 'STABILITY']; const datasets = rawData.map((p, idx) => { const color = this.getPlayerColor(idx); - const d = [ - p.radar.BAT || 0, p.radar.PTL || 0, p.radar.HPS || 0, - p.radar.SIDE || 0, p.radar.UTIL || 0, p.radar.STA || 0 - ]; - + const d = radarKeys.map(k => (p.radar?.[k] || 0)); return { label: p.username, data: d, @@ -543,12 +546,49 @@ function tacticsApp() { }; }); + const valuesByDim = radarKeys.map(() => []); + rawData.forEach(p => { + radarKeys.forEach((k, i) => { + valuesByDim[i].push(Number(p.radar?.[k] || 0)); + }); + }); + const avgVals = valuesByDim.map(arr => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0); + const minVals = valuesByDim.map(arr => arr.length ? Math.min(...arr) : 0); + const maxVals = valuesByDim.map(arr => arr.length ? Math.max(...arr) : 0); + + datasets.push({ + label: 'Avg', + data: avgVals, + borderColor: '#64748b', + backgroundColor: 'rgba(100, 116, 139, 0.08)', + borderWidth: 2, + pointRadius: 0 + }); + datasets.push({ + label: 'Max', + data: maxVals, + borderColor: '#16a34a', + backgroundColor: 'rgba(22, 163, 74, 0.05)', + borderWidth: 1, + borderDash: [4, 3], + pointRadius: 0 + }); + datasets.push({ + label: 'Min', + data: minVals, + borderColor: '#dc2626', + backgroundColor: 'rgba(220, 38, 38, 0.05)', + borderWidth: 1, + borderDash: [4, 3], + pointRadius: 0 + }); + // Recreate Chart with Profile-aligned config const ctx = canvas.getContext('2d'); this.radarChart = new Chart(ctx, { type: 'radar', data: { - labels: ['BAT (火力)', 'PTL (手枪)', 'HPS (抗压)', 'SIDE (阵营)', 'UTIL (道具)', 'STA (稳定)'], + labels: ['枪法 (Aim)', '生存 (Defense)', '道具 (Utility)', '残局 (Clutch)', '经济 (Economy)', '节奏 (Pace)', '手枪 (Pistol)', '稳定 (Stability)'], datasets: datasets }, options: { @@ -595,7 +635,7 @@ function tacticsApp() { this.radarChart = new Chart(ctx, { type: 'radar', data: { - labels: ['BAT (火力)', 'PTL (手枪)', 'HPS (抗压)', 'SIDE (阵营)', 'UTIL (道具)', 'STA (稳定)'], + labels: ['枪法 (Aim)', '生存 (Defense)', '道具 (Utility)', '残局 (Clutch)', '经济 (Economy)', '节奏 (Pace)', '手枪 (Pistol)', '稳定 (Stability)'], datasets: [] }, options: { @@ -777,4 +817,4 @@ function tacticsApp() { } } -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/web/templates/teams/clubhouse.html b/web/templates/teams/clubhouse.html index 0026181..1887b66 100644 --- a/web/templates/teams/clubhouse.html +++ b/web/templates/teams/clubhouse.html @@ -69,7 +69,7 @@ -
+
Rating
@@ -78,6 +78,10 @@
K/D
+
+
总评
+
+