2.0.0-rc2: Achievements Refactored & Admin improved.

This commit is contained in:
2026-08-09 01:34:20 +08:00
parent 3874bf57a9
commit 63a0751aba
29 changed files with 2143 additions and 355 deletions
BIN
View File
Binary file not shown.
+7
View File
@@ -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
],
)
+57
View File
@@ -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
-- ============================================================================