from collections import defaultdict, deque from datetime import datetime, timedelta, timezone from itertools import combinations import json class NarrativeProcessor: RECORD_FIELDS = ( ('highest_rating', 'rating'), ('most_kills', 'kills'), ('highest_adr', 'adr'), ('highest_kd', 'kd_ratio'), ('most_headshots', 'headshot_count'), ) AWARD_PERIODS = { 'daily': ('单日最佳', 1), 'weekly': ('星期最佳', 2), 'monthly': ('月度最佳', 5), 'quarterly': ('季度最佳', 10), 'yearly': ('年度最佳', 20), } @staticmethod def rebuild(conn_l2, conn_l3, roster_ids): for table in ( 'dm_match_reports', 'dm_match_player_reports', 'dm_player_record_events', 'dm_team_season_stats', 'dm_player_awards', ): conn_l3.execute(f'DELETE FROM {table}') rows = NarrativeProcessor._load_player_rows(conn_l2, roster_ids) record_events, record_keys = NarrativeProcessor._build_record_events(rows) player_reports = NarrativeProcessor._build_player_reports(rows, record_keys) match_reports = NarrativeProcessor._build_match_reports(rows, player_reports) seasons = NarrativeProcessor._build_seasons(match_reports, player_reports) awards = NarrativeProcessor._build_awards(rows) NarrativeProcessor._insert_record_events(conn_l3, record_events) NarrativeProcessor._insert_player_reports(conn_l3, player_reports) NarrativeProcessor._insert_match_reports(conn_l3, match_reports) NarrativeProcessor._insert_seasons(conn_l3, seasons) NarrativeProcessor._insert_awards(conn_l3, awards) return { 'match_reports': len(match_reports), 'player_reports': len(player_reports), 'record_events': len(record_events), 'seasons': len(seasons), 'awards': len(awards), } @staticmethod def _load_player_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, CASE WHEN p.group_id IN (1, 2) THEN p.group_id WHEN p.team_id IN (1, 2) THEN p.team_id END AS team_key, p.rating, p.kd_ratio, p.adr, p.kast, p.kills, p.deaths, p.headshot_count, p.first_kill, p.first_death, p.throw_harm, p.flash_enemy, p.is_win, m.start_time, m.map_name, m.score_team1, m.score_team2 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, p.steam_id_64 """, roster_ids, ).fetchall() return [dict(row) for row in rows] @staticmethod def _build_record_events(rows): best_values = defaultdict(dict) last_event = {} events = [] record_keys = defaultdict(list) for row in rows: steam_id = str(row['steam_id_64']) for record_key, field in NarrativeProcessor.RECORD_FIELDS: raw_value = row.get(field) if raw_value is None: continue value = float(raw_value) previous = best_values[steam_id].get(record_key) if previous is None or value > previous: event = { 'steam_id_64': steam_id, 'record_key': record_key, 'match_id': row['match_id'], 'match_date': int(row['start_time']), 'map_name': row['map_name'], 'previous_value': previous, 'record_value': value, 'is_current_record': 0, } events.append(event) best_values[steam_id][record_key] = value last_event[(steam_id, record_key)] = event if previous is not None: record_keys[(row['match_id'], steam_id)].append( record_key ) for event in last_event.values(): event['is_current_record'] = 1 return events, record_keys @staticmethod def _average(history, field): values = [ float(row[field]) for row in history if row.get(field) is not None ] return sum(values) / len(values) if values else None @staticmethod def _delta(value, baseline): if value is None or baseline is None: return None return float(value) - float(baseline) @staticmethod def _performance_label(delta, baseline_matches): if baseline_matches < 5 or delta is None: return 'insufficient_sample' if delta >= 0.25: return 'surge' if delta >= 0.10: return 'above_form' if delta <= -0.25: return 'slump' if delta <= -0.10: return 'below_form' return 'stable' @staticmethod def _build_player_reports(rows, record_keys): histories = defaultdict(lambda: deque(maxlen=20)) reports = [] for row in rows: steam_id = str(row['steam_id_64']) history = histories[steam_id] prior_rating = NarrativeProcessor._average(history, 'rating') prior_kd = NarrativeProcessor._average(history, 'kd_ratio') prior_adr = NarrativeProcessor._average(history, 'adr') rating_delta = NarrativeProcessor._delta(row['rating'], prior_rating) reports.append({ 'match_id': row['match_id'], 'steam_id_64': steam_id, 'match_date': int(row['start_time']), 'rating': row['rating'], 'kd_ratio': row['kd_ratio'], 'adr': row['adr'], 'kills': row['kills'], 'deaths': row['deaths'], 'baseline_matches': len(history), 'prior_20_rating': prior_rating, 'prior_20_kd': prior_kd, 'prior_20_adr': prior_adr, 'rating_delta': rating_delta, 'kd_delta': NarrativeProcessor._delta(row['kd_ratio'], prior_kd), 'adr_delta': NarrativeProcessor._delta(row['adr'], prior_adr), 'performance_label': NarrativeProcessor._performance_label( rating_delta, len(history), ), 'record_keys_json': json.dumps( record_keys.get((row['match_id'], steam_id), []) ), 'team_key': row['team_key'], 'is_win': row['is_win'], 'map_name': row['map_name'], }) history.append(row) return reports @staticmethod def _build_match_reports(rows, player_reports): player_report_map = { (row['match_id'], row['steam_id_64']): row for row in player_reports } grouped = defaultdict(list) for row in rows: if row['team_key'] is not None: grouped[(row['match_id'], row['team_key'])].append(row) by_match = defaultdict(list) for (match_id, team_key), team_rows in grouped.items(): by_match[match_id].append((team_key, team_rows)) reports = [] for match_id, candidates in by_match.items(): team_key, team_rows = max( candidates, key=lambda item: (len(item[1]), -int(item[0] or 0)), ) roster_count = len(team_rows) if roster_count == 0: continue mvp = max( team_rows, key=lambda row: ( float(row['rating'] or 0), int(row['kills'] or 0), ), ) detailed = [ player_report_map[(match_id, str(row['steam_id_64']))] for row in team_rows ] eligible_improvers = [ report for report in detailed if report['baseline_matches'] >= 5 and report['rating_delta'] is not None ] improver = ( max(eligible_improvers, key=lambda report: report['rating_delta']) if eligible_improvers else None ) strongest_duo = None if roster_count >= 2: duo = max( combinations(team_rows, 2), key=lambda pair: ( float(pair[0]['rating'] or 0) + float(pair[1]['rating'] or 0) ), ) strongest_duo = { 'steam_ids': [ str(duo[0]['steam_id_64']), str(duo[1]['steam_id_64']), ], 'avg_rating': ( float(duo[0]['rating'] or 0) + float(duo[1]['rating'] or 0) ) / 2, } record_count = sum( len(json.loads(report['record_keys_json'])) for report in detailed ) is_win = bool(team_rows[0]['is_win']) team_rating = sum( float(row['rating'] or 0) for row in team_rows ) / roster_count result_text = '取胜' if is_win else '失利' summary = ( f"本场{result_text},队内平均 Rating {team_rating:.2f};" f"MVP Rating {float(mvp['rating'] or 0):.2f}" ) if record_count: summary += f",刷新 {record_count} 项个人纪录" summary += '。' reports.append({ 'match_id': match_id, 'match_date': int(team_rows[0]['start_time']), 'map_name': team_rows[0]['map_name'], 'roster_count': roster_count, 'team_key': team_key, 'is_win': int(is_win), 'team_avg_rating': team_rating, 'team_avg_adr': sum( float(row['adr'] or 0) for row in team_rows ) / roster_count, 'mvp_steam_id': str(mvp['steam_id_64']), 'mvp_rating': mvp['rating'], 'improver_steam_id': ( improver['steam_id_64'] if improver else None ), 'improver_delta': ( improver['rating_delta'] if improver else None ), 'strongest_duo_json': json.dumps(strongest_duo), 'record_break_count': record_count, 'summary_text': summary, }) reports.sort(key=lambda row: (row['match_date'], row['match_id'])) return reports @staticmethod def _build_seasons(match_reports, player_reports): team_reports = [ row for row in match_reports if row['roster_count'] >= 2 ] report_matches = {row['match_id'] for row in team_reports} seasons = defaultdict(list) for report in team_reports: year = datetime.fromtimestamp( report['match_date'], timezone.utc, ).year seasons[str(year)].append(report) result = [] for season_key, reports in sorted(seasons.items()): year = int(season_key) start = int(datetime(year, 1, 1, tzinfo=timezone.utc).timestamp()) end = int( datetime(year + 1, 1, 1, tzinfo=timezone.utc).timestamp() ) - 1 wins = sum(int(row['is_win']) for row in reports) maps = defaultdict(lambda: {'matches': 0, 'wins': 0}) for row in reports: item = maps[row['map_name'] or 'Unknown'] item['matches'] += 1 item['wins'] += int(row['is_win']) best_map, best_map_data = max( maps.items(), key=lambda item: ( item[1]['wins'] / item[1]['matches'], item[1]['matches'], ), ) ratings = defaultdict(list) for report in player_reports: if report['match_id'] not in report_matches: continue report_year = datetime.fromtimestamp( report['match_date'], timezone.utc, ).year if report_year == year and report['rating'] is not None: ratings[report['steam_id_64']].append( float(report['rating']) ) top_player, top_values = max( ratings.items(), key=lambda item: (sum(item[1]) / len(item[1]), len(item[1])), ) result.append({ 'season_key': season_key, 'season_label': f'{year} Season', 'period_start': start, 'period_end': end, 'matches': len(reports), 'wins': wins, 'losses': len(reports) - wins, 'win_rate': wins / len(reports), 'avg_team_rating': sum( row['team_avg_rating'] for row in reports ) / len(reports), 'best_map': best_map, 'best_map_win_rate': ( best_map_data['wins'] / best_map_data['matches'] ), 'top_player_steam_id': top_player, 'top_player_rating': sum(top_values) / len(top_values), }) return result @staticmethod def _period_identity(timestamp, award_type): moment = datetime.fromtimestamp(timestamp, timezone.utc) if award_type == 'daily': start = datetime(moment.year, moment.month, moment.day, tzinfo=timezone.utc) return start.strftime('%Y-%m-%d'), start, start + timedelta(days=1) if award_type == 'weekly': start = datetime( moment.year, moment.month, moment.day, tzinfo=timezone.utc, ) - timedelta(days=moment.weekday()) iso_year, iso_week, _ = start.isocalendar() return f'{iso_year}-W{iso_week:02d}', start, start + timedelta(days=7) if award_type == 'monthly': start = datetime(moment.year, moment.month, 1, tzinfo=timezone.utc) end = ( datetime(moment.year + 1, 1, 1, tzinfo=timezone.utc) if moment.month == 12 else datetime(moment.year, moment.month + 1, 1, tzinfo=timezone.utc) ) return start.strftime('%Y-%m'), start, end if award_type == 'quarterly': quarter = (moment.month - 1) // 3 + 1 start_month = (quarter - 1) * 3 + 1 start = datetime(moment.year, start_month, 1, tzinfo=timezone.utc) end = ( datetime(moment.year + 1, 1, 1, tzinfo=timezone.utc) if quarter == 4 else datetime(moment.year, start_month + 3, 1, tzinfo=timezone.utc) ) return f'{moment.year}-Q{quarter}', start, end start = datetime(moment.year, 1, 1, tzinfo=timezone.utc) return str(moment.year), start, datetime( moment.year + 1, 1, 1, tzinfo=timezone.utc, ) @staticmethod def _build_awards(rows): awards = [] for award_type, (award_name, min_matches) in ( NarrativeProcessor.AWARD_PERIODS.items() ): periods = defaultdict(lambda: defaultdict(list)) boundaries = {} for row in rows: period_key, start, end = NarrativeProcessor._period_identity( int(row['start_time']), award_type, ) periods[period_key][str(row['steam_id_64'])].append(row) boundaries[period_key] = (start, end) for period_key, player_rows in periods.items(): candidates = [] for steam_id, matches in player_rows.items(): if len(matches) < min_matches: continue wins = sum(int(row['is_win'] or 0) for row in matches) kills = sum(int(row['kills'] or 0) for row in matches) deaths = sum(int(row['deaths'] or 0) for row in matches) avg_rating = sum( float(row['rating'] or 0) for row in matches ) / len(matches) avg_adr = sum( float(row['adr'] or 0) for row in matches ) / len(matches) avg_kd = kills / deaths if deaths else float(kills) win_rate = wins / len(matches) score = avg_rating + avg_adr * 0.001 + win_rate * 0.02 candidates.append({ 'steam_id_64': steam_id, 'matches': len(matches), 'wins': wins, 'win_rate': win_rate, 'avg_rating': avg_rating, 'avg_kd': avg_kd, 'avg_adr': avg_adr, 'performance_score': score, }) if not candidates: continue winner = max( candidates, key=lambda item: ( item['performance_score'], item['matches'], ), ) start, end = boundaries[period_key] awards.append({ 'award_type': award_type, 'period_key': period_key, 'period_label': f'{period_key} {award_name}', 'period_start': int(start.timestamp()), 'period_end': int(end.timestamp()) - 1, **winner, 'sample_reliable': int( winner['matches'] >= min_matches ), }) awards.sort( key=lambda row: (row['period_start'], row['award_type']) ) return awards @staticmethod def _insert_record_events(conn, rows): conn.executemany( """ INSERT INTO dm_player_record_events ( steam_id_64, record_key, match_id, match_date, map_name, previous_value, record_value, is_current_record ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, [ ( row['steam_id_64'], row['record_key'], row['match_id'], row['match_date'], row['map_name'], row['previous_value'], row['record_value'], row['is_current_record'], ) for row in rows ], ) @staticmethod def _insert_player_reports(conn, rows): conn.executemany( """ INSERT INTO dm_match_player_reports ( match_id, steam_id_64, match_date, rating, kd_ratio, adr, kills, deaths, baseline_matches, prior_20_rating, prior_20_kd, prior_20_adr, rating_delta, kd_delta, adr_delta, performance_label, record_keys_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ ( row['match_id'], row['steam_id_64'], row['match_date'], row['rating'], row['kd_ratio'], row['adr'], row['kills'], row['deaths'], row['baseline_matches'], row['prior_20_rating'], row['prior_20_kd'], row['prior_20_adr'], row['rating_delta'], row['kd_delta'], row['adr_delta'], row['performance_label'], row['record_keys_json'], ) for row in rows ], ) @staticmethod def _insert_match_reports(conn, rows): conn.executemany( """ INSERT INTO dm_match_reports ( match_id, match_date, map_name, roster_count, team_key, is_win, team_avg_rating, team_avg_adr, mvp_steam_id, mvp_rating, improver_steam_id, improver_delta, strongest_duo_json, record_break_count, summary_text ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ tuple(row[key] for key in ( 'match_id', 'match_date', 'map_name', 'roster_count', 'team_key', 'is_win', 'team_avg_rating', 'team_avg_adr', 'mvp_steam_id', 'mvp_rating', 'improver_steam_id', 'improver_delta', 'strongest_duo_json', 'record_break_count', 'summary_text', )) for row in rows ], ) @staticmethod def _insert_seasons(conn, rows): conn.executemany( """ INSERT INTO dm_team_season_stats ( season_key, season_label, period_start, period_end, matches, wins, losses, win_rate, avg_team_rating, best_map, best_map_win_rate, top_player_steam_id, top_player_rating ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ tuple(row[key] for key in ( 'season_key', 'season_label', 'period_start', 'period_end', 'matches', 'wins', 'losses', 'win_rate', 'avg_team_rating', 'best_map', 'best_map_win_rate', 'top_player_steam_id', 'top_player_rating', )) for row in rows ], ) @staticmethod def _insert_awards(conn, rows): conn.executemany( """ INSERT INTO dm_player_awards ( award_type, period_key, period_label, period_start, period_end, steam_id_64, matches, wins, win_rate, avg_rating, avg_kd, avg_adr, performance_score, sample_reliable ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ tuple(row[key] for key in ( 'award_type', 'period_key', 'period_label', 'period_start', 'period_end', 'steam_id_64', 'matches', 'wins', 'win_rate', 'avg_rating', 'avg_kd', 'avg_adr', 'performance_score', 'sample_reliable', )) for row in rows ], )