2.0.0-rc2: Achievements Refactored & Admin improved.
This commit is contained in:
Binary file not shown.
@@ -335,6 +335,13 @@ def main(force_all: bool = False, workers: int = 1, create_backup: bool = True):
|
||||
processed_ids,
|
||||
)
|
||||
logger.info("Narrative marts rebuilt: %s", narrative_counts)
|
||||
from database.L3.processors.discovery_processor import DiscoveryProcessor
|
||||
discovery_counts = DiscoveryProcessor.rebuild(
|
||||
conn_l2,
|
||||
conn_l3,
|
||||
processed_ids,
|
||||
)
|
||||
logger.info("Discovery marts rebuilt: %s", discovery_counts)
|
||||
|
||||
quick_check = conn_l3.execute("PRAGMA quick_check").fetchone()[0]
|
||||
if quick_check != 'ok':
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -587,6 +587,61 @@ ON dm_player_awards(steam_id_64, period_start DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_player_awards_period
|
||||
ON dm_player_awards(award_type, period_start DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Discovery Mart: Data-driven fun facts, both positive and negative
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS dm_discovery_insights (
|
||||
insight_key TEXT PRIMARY KEY,
|
||||
steam_id_64 TEXT,
|
||||
tone TEXT NOT NULL
|
||||
CHECK (tone IN ('positive', 'negative', 'fun')),
|
||||
category TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
metric_label TEXT,
|
||||
metric_value REAL,
|
||||
metric_unit TEXT,
|
||||
match_id TEXT,
|
||||
evidence_json TEXT NOT NULL DEFAULT '{}',
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_tone_order
|
||||
ON dm_discovery_insights(tone, display_order, insight_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_discovery_player
|
||||
ON dm_discovery_insights(steam_id_64, tone);
|
||||
|
||||
-- ============================================================================
|
||||
-- Medal Mart: Gold/silver/bronze by map and ELO segment
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS dm_performance_medals (
|
||||
dimension_type TEXT NOT NULL
|
||||
CHECK (dimension_type IN ('map', 'elo')),
|
||||
dimension_key TEXT NOT NULL,
|
||||
dimension_label TEXT NOT NULL,
|
||||
medal_rank INTEGER NOT NULL CHECK (medal_rank BETWEEN 1 AND 3),
|
||||
medal_tier TEXT NOT NULL
|
||||
CHECK (medal_tier IN ('gold', 'silver', 'bronze')),
|
||||
steam_id_64 TEXT NOT NULL,
|
||||
matches INTEGER NOT NULL,
|
||||
wins INTEGER NOT NULL,
|
||||
win_rate REAL,
|
||||
avg_rating REAL,
|
||||
avg_kd REAL,
|
||||
avg_adr REAL,
|
||||
sample_reliable BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (dimension_type, dimension_key, medal_rank)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_performance_medals_player
|
||||
ON dm_performance_medals(steam_id_64, dimension_type, medal_rank);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_performance_medals_dimension
|
||||
ON dm_performance_medals(dimension_type, dimension_key, medal_rank);
|
||||
|
||||
-- ============================================================================
|
||||
-- Schema Summary
|
||||
-- ============================================================================
|
||||
@@ -609,4 +664,6 @@ ON dm_player_awards(award_type, period_start DESC);
|
||||
-- dm_player_record_events: Historical record-breaking moments
|
||||
-- dm_team_season_stats: Calendar season team summaries
|
||||
-- dm_player_awards: Daily/weekly/monthly/quarterly/yearly awards
|
||||
-- dm_discovery_insights: Positive, negative and quirky data discoveries
|
||||
-- dm_performance_medals: Map and ELO-segment gold/silver/bronze medals
|
||||
-- ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user