400 lines
14 KiB
Python
400 lines
14 KiB
Python
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
|
|
],
|
|
)
|
|
|