599 lines
23 KiB
Python
599 lines
23 KiB
Python
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
|
||
],
|
||
)
|