from collections import defaultdict from datetime import datetime, timezone import json from statistics import pstdev class DiscoveryProcessor: ELO_SEGMENTS = ( (0, 1200, '<1200', '<1200 ELO'), (1200, 1400, '1200-1399', '1200-1399 ELO'), (1400, 1600, '1400-1599', '1400-1599 ELO'), (1600, 1800, '1600-1799', '1600-1799 ELO'), (1800, 2000, '1800-1999', '1800-1999 ELO'), (2000, float('inf'), '2000+', '2000+ ELO'), ) MEDAL_TIERS = {1: 'gold', 2: 'silver', 3: 'bronze'} @staticmethod def rebuild(conn_l2, conn_l3, roster_ids): conn_l3.execute('DELETE FROM dm_discovery_insights') conn_l3.execute('DELETE FROM dm_performance_medals') rows = DiscoveryProcessor._load_rows(conn_l2, roster_ids) insights = DiscoveryProcessor._build_insights(rows) medals = DiscoveryProcessor._build_medals(rows) DiscoveryProcessor._insert_insights(conn_l3, insights) DiscoveryProcessor._insert_medals(conn_l3, medals) return { 'insights': len(insights), 'medals': len(medals), } @staticmethod def _load_rows(conn_l2, roster_ids): if not roster_ids: return [] placeholders = ','.join('?' for _ in roster_ids) rows = conn_l2.execute( f""" SELECT p.match_id, p.steam_id_64, p.rating, p.kd_ratio, p.adr, p.kills, p.deaths, p.headshot_count, p.first_kill, p.first_death, p.throw_harm, p.flash_enemy, p.is_win, p.origin_elo, m.map_name, m.start_time FROM fact_match_players p JOIN fact_matches m ON m.match_id = p.match_id WHERE p.steam_id_64 IN ({placeholders}) ORDER BY m.start_time, p.match_id """, roster_ids, ).fetchall() return [dict(row) for row in rows] @staticmethod def _elo_segment(value): value = float(value or 0) for minimum, maximum, key, label in DiscoveryProcessor.ELO_SEGMENTS: if minimum <= value < maximum: return key, label return None, None @staticmethod def _aggregate(rows): matches = len(rows) wins = sum(int(row['is_win'] or 0) for row in rows) kills = sum(int(row['kills'] or 0) for row in rows) deaths = sum(int(row['deaths'] or 0) for row in rows) return { 'matches': matches, 'wins': wins, 'win_rate': wins / matches if matches else 0, 'avg_rating': ( sum(float(row['rating'] or 0) for row in rows) / matches if matches else 0 ), 'avg_kd': kills / deaths if deaths else float(kills), 'avg_adr': ( sum(float(row['adr'] or 0) for row in rows) / matches if matches else 0 ), } @staticmethod def _build_medals(rows): dimensions = defaultdict(lambda: defaultdict(list)) labels = {} for row in rows: steam_id = str(row['steam_id_64']) map_name = row['map_name'] or 'Unknown' dimensions[('map', map_name)][steam_id].append(row) labels[('map', map_name)] = map_name segment_key, segment_label = DiscoveryProcessor._elo_segment( row['origin_elo'] ) if segment_key: dimensions[('elo', segment_key)][steam_id].append(row) labels[('elo', segment_key)] = segment_label medals = [] for (dimension_type, dimension_key), player_rows in dimensions.items(): candidates = [] for steam_id, matches in player_rows.items(): if len(matches) < 5: continue stats = DiscoveryProcessor._aggregate(matches) candidates.append((steam_id, stats)) candidates.sort( key=lambda item: ( item[1]['avg_rating'], item[1]['avg_adr'], item[1]['matches'], ), reverse=True, ) for rank, (steam_id, stats) in enumerate(candidates[:3], 1): medals.append({ 'dimension_type': dimension_type, 'dimension_key': dimension_key, 'dimension_label': labels[(dimension_type, dimension_key)], 'medal_rank': rank, 'medal_tier': DiscoveryProcessor.MEDAL_TIERS[rank], 'steam_id_64': steam_id, **stats, 'sample_reliable': int(stats['matches'] >= 10), }) return medals @staticmethod def _streaks(rows): win_best = loss_best = win_current = loss_current = 0 for row in rows: if row['is_win']: win_current += 1 loss_current = 0 else: loss_current += 1 win_current = 0 win_best = max(win_best, win_current) loss_best = max(loss_best, loss_current) return win_best, loss_best @staticmethod def _player_stats(rows): grouped = defaultdict(list) for row in rows: grouped[str(row['steam_id_64'])].append(row) result = {} for steam_id, matches in grouped.items(): ratings = [float(row['rating'] or 0) for row in matches] night_matches = [ row for row in matches if datetime.fromtimestamp( int(row['start_time']), timezone.utc, ).hour in {23, 0, 1, 2, 3, 4, 5} ] win_streak, loss_streak = DiscoveryProcessor._streaks(matches) map_rows = defaultdict(list) for row in matches: map_rows[row['map_name'] or 'Unknown'].append(row) eligible_maps = [ (map_name, DiscoveryProcessor._aggregate(map_matches)) for map_name, map_matches in map_rows.items() if len(map_matches) >= 5 ] best_map = ( max(eligible_maps, key=lambda item: item[1]['avg_rating']) if eligible_maps else None ) result[steam_id] = { 'matches': len(matches), 'carry_losses': sum( 1 for row in matches if not row['is_win'] and float(row['rating'] or 0) >= 1.2 ), 'monster_games': sum( 1 for row in matches if float(row['rating'] or 0) >= 1.5 ), 'rough_games': sum( 1 for row in matches if float(row['rating'] or 0) < 0.7 ), 'lucky_wins': sum( 1 for row in matches if row['is_win'] and float(row['rating'] or 0) < 0.8 ), 'opening_balance': sum( int(row['first_kill'] or 0) - int(row['first_death'] or 0) for row in matches ), 'rating_volatility': pstdev(ratings) if len(ratings) > 1 else 0, 'night_matches': len(night_matches), 'night_share': len(night_matches) / len(matches), 'win_streak': win_streak, 'loss_streak': loss_streak, 'best_map': best_map, 'best_game': max( matches, key=lambda row: float(row['rating'] or 0), ), } return result @staticmethod def _insight( key, steam_id, tone, category, title, description, metric_label, metric_value, metric_unit='', match_id=None, evidence=None, order=0, ): return { 'insight_key': key, 'steam_id_64': steam_id, 'tone': tone, 'category': category, 'title': title, 'description': description, 'metric_label': metric_label, 'metric_value': float(metric_value), 'metric_unit': metric_unit, 'match_id': match_id, 'evidence_json': json.dumps(evidence or {}, ensure_ascii=False), 'display_order': order, } @staticmethod def _build_insights(rows): stats = DiscoveryProcessor._player_stats(rows) if not stats: return [] insights = [] definitions = ( ( 'monster_games', max, 'positive', '爆发', '爆种制造机', 'Rating ≥ 1.50 的比赛次数全队最多。', '爆种局', '场', 10, ), ( 'carry_losses', max, 'positive', '抗压', '逆风尽力王', '失利时 Rating ≥ 1.20 的比赛次数全队最多。', '尽力局', '场', 20, ), ( 'opening_balance', max, 'positive', '突破', '开门红专家', '生涯首杀减首死净值全队最高。', 'FK-FD', '', 30, ), ( 'rough_games', max, 'negative', '低谷', '低谷收藏家', 'Rating < 0.70 的比赛次数全队最多。', '低迷局', '场', 40, ), ( 'lucky_wins', max, 'fun', '趣味', '躺赢许可证', '赢球但个人 Rating < 0.80 的次数全队最多。', '幸运胜场', '场', 50, ), ( 'rating_volatility', max, 'fun', '稳定性', '过山车选手', 'Rating 标准差全队最高,状态最有悬念。', '波动', '', 60, ), ( 'rating_volatility', min, 'positive', '稳定性', '定海神针', 'Rating 标准差全队最低,发挥最稳定。', '波动', '', 70, ), ( 'night_share', max, 'fun', '时段', '夜猫子', '23:00-05:59 UTC 比赛占比全队最高。', '夜间占比', '%', 80, ), ( 'win_streak', max, 'positive', '连胜', '连胜发动机', '个人最长连续胜场全队最高。', '最长连胜', '场', 90, ), ( 'loss_streak', max, 'negative', '连败', '逆境耐受测试', '个人最长连续败场全队最高。', '最长连败', '场', 100, ), ) for metric, selector, tone, category, title, description, label, unit, order in definitions: steam_id, player = selector( stats.items(), key=lambda item: item[1][metric], ) value = player[metric] display_value = value * 100 if unit == '%' else value if display_value <= 0: continue insights.append(DiscoveryProcessor._insight( f'global:{metric}:{selector.__name__}', steam_id, tone, category, title, description, label, display_value, unit, evidence={'matches': player['matches']}, order=order, )) for index, (steam_id, player) in enumerate(stats.items(), 1): if not player['best_map']: continue map_name, map_stats = player['best_map'] insights.append(DiscoveryProcessor._insight( f'player:{steam_id}:map_specialist', steam_id, 'positive', '地图', f'{map_name} 地头蛇', '个人至少 5 场地图中,平均 Rating 最高的一张。', '地图 Rating', map_stats['avg_rating'], '', evidence={ 'map': map_name, 'matches': map_stats['matches'], 'win_rate': map_stats['win_rate'], }, order=120 + index, )) return insights @staticmethod def _insert_insights(conn_l3, rows): conn_l3.executemany( """ INSERT INTO dm_discovery_insights ( insight_key, steam_id_64, tone, category, title, description, metric_label, metric_value, metric_unit, match_id, evidence_json, display_order ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ ( row['insight_key'], row['steam_id_64'], row['tone'], row['category'], row['title'], row['description'], row['metric_label'], row['metric_value'], row['metric_unit'], row['match_id'], row['evidence_json'], row['display_order'], ) for row in rows ], ) @staticmethod def _insert_medals(conn_l3, rows): conn_l3.executemany( """ INSERT INTO dm_performance_medals ( dimension_type, dimension_key, dimension_label, medal_rank, medal_tier, steam_id_64, matches, wins, win_rate, avg_rating, avg_kd, avg_adr, sample_reliable ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ ( row['dimension_type'], row['dimension_key'], row['dimension_label'], row['medal_rank'], row['medal_tier'], row['steam_id_64'], row['matches'], row['wins'], row['win_rate'], row['avg_rating'], row['avg_kd'], row['avg_adr'], row['sample_reliable'], ) for row in rows ], )