2.0.0-rc1 : Profile and achievements update.

This commit is contained in:
2026-08-09 00:50:45 +08:00
parent 562775e5db
commit 3874bf57a9
31 changed files with 2618 additions and 108 deletions
+106
View File
@@ -246,12 +246,19 @@ class IntegrityService:
@staticmethod
def _check_l3(db, roster_ids, checks, counts):
required_tables = {
'dm_duo_stats',
'dm_match_player_reports',
'dm_match_reports',
'dm_player_features',
'dm_player_awards',
'dm_player_match_history',
'dm_player_map_stats',
'dm_player_period_stats',
'dm_player_record_events',
'dm_player_records',
'dm_player_weapon_stats',
'dm_lineup_stats',
'dm_team_season_stats',
}
missing = sorted(required_tables - IntegrityService._table_names(db))
IntegrityService._check(
@@ -281,6 +288,42 @@ class IntegrityService:
counts['l3_records'] = db.execute(
'SELECT COUNT(*) FROM dm_player_records'
).fetchone()[0]
counts['l3_duos'] = db.execute(
'SELECT COUNT(*) FROM dm_duo_stats'
).fetchone()[0]
counts['l3_lineups'] = db.execute(
'SELECT COUNT(*) FROM dm_lineup_stats'
).fetchone()[0]
counts['l3_match_reports'] = db.execute(
'SELECT COUNT(*) FROM dm_match_reports'
).fetchone()[0]
counts['l3_player_reports'] = db.execute(
'SELECT COUNT(*) FROM dm_match_player_reports'
).fetchone()[0]
counts['l3_record_events'] = db.execute(
'SELECT COUNT(*) FROM dm_player_record_events'
).fetchone()[0]
counts['l3_seasons'] = db.execute(
'SELECT COUNT(*) FROM dm_team_season_stats'
).fetchone()[0]
counts['l3_awards'] = db.execute(
'SELECT COUNT(*) FROM dm_player_awards'
).fetchone()[0]
expected_matches = counts.get('matches', 0)
IntegrityService._check(
checks,
'Post-match report coverage',
(
'pass'
if counts['l3_match_reports'] == expected_matches
else 'fail'
),
(
f"{counts['l3_match_reports']}/"
f"{expected_matches} matches reported"
),
counts['l3_match_reports'],
)
if roster_ids:
placeholders = ','.join('?' for _ in roster_ids)
@@ -323,6 +366,21 @@ class IntegrityService:
f'{actual_history}/{expected_history} player-match rows materialized',
actual_history,
)
player_report_count = db.execute(
f"""
SELECT COUNT(*)
FROM dm_match_player_reports
WHERE steam_id_64 IN ({placeholders})
""",
roster_ids,
).fetchone()[0]
IntegrityService._check(
checks,
'Roster player-report completeness',
'pass' if player_report_count == expected_history else 'fail',
f'{player_report_count}/{expected_history} player reports',
player_report_count,
)
score_rows = db.execute(
f"""
@@ -378,6 +436,13 @@ class IntegrityService:
('l3_weapons', 'Player weapon stats mart'),
('l3_periods', 'Player period stats mart'),
('l3_records', 'Player records mart'),
('l3_duos', 'Team duo stats mart'),
('l3_lineups', 'Team lineup stats mart'),
('l3_match_reports', 'Post-match report mart'),
('l3_player_reports', 'Player post-match report mart'),
('l3_record_events', 'Record event mart'),
('l3_seasons', 'Team season mart'),
('l3_awards', 'Player award mart'),
):
value = counts[key]
IntegrityService._check(
@@ -424,6 +489,8 @@ class IntegrityService:
'schema_migrations',
'strategy_boards',
'team_lineups',
'team_roster_members',
'team_roster_versions',
'wiki_pages',
}
missing = sorted(required_tables - IntegrityService._table_names(db))
@@ -509,3 +576,42 @@ class IntegrityService:
f'{active_count} active lineups configured',
active_count,
)
current_versions = db.execute(
"""
SELECT id
FROM team_roster_versions
WHERE is_current = 1
"""
).fetchall()
current_member_count = 0
if len(current_versions) == 1:
current_member_count = db.execute(
"""
SELECT COUNT(*)
FROM team_roster_members
WHERE roster_version_id = ?
""",
[current_versions[0]['id']],
).fetchone()[0]
counts['roster_versions'] = db.execute(
'SELECT COUNT(*) FROM team_roster_versions'
).fetchone()[0]
counts['current_roster_members'] = current_member_count
IntegrityService._check(
checks,
'Current roster version',
'pass' if len(current_versions) == 1 else 'fail',
f'{len(current_versions)} current roster versions',
len(current_versions),
)
IntegrityService._check(
checks,
'Roster version membership',
'pass' if current_member_count == counts.get('active_roster', 0) else 'fail',
(
f"{current_member_count}/"
f"{counts.get('active_roster', 0)} active members versioned"
),
current_member_count,
)
+327
View File
@@ -0,0 +1,327 @@
import json
from web.database import query_db
class NarrativeService:
AWARD_TYPE_LABELS = {
'daily': '单日最佳',
'weekly': '星期最佳',
'monthly': '月度最佳',
'quarterly': '季度最佳',
'yearly': '年度最佳',
}
PERFORMANCE_LABELS = {
'surge': '状态爆发',
'above_form': '高于近期',
'stable': '稳定发挥',
'below_form': '低于近期',
'slump': '状态低迷',
'insufficient_sample': '样本不足',
}
RECORD_LABELS = {
'highest_rating': '最高 Rating',
'most_kills': '最多击杀',
'highest_adr': '最高 ADR',
'highest_kd': '最高 K/D',
'most_headshots': '最多爆头',
}
@staticmethod
def _identity_map(steam_ids):
steam_ids = sorted({str(value) for value in steam_ids if value})
if not steam_ids:
return {}
placeholders = ','.join('?' for _ in steam_ids)
rows = query_db(
'l2',
f"""
SELECT steam_id_64, username, avatar_url
FROM dim_players
WHERE steam_id_64 IN ({placeholders})
""",
steam_ids,
)
return {str(row['steam_id_64']): dict(row) for row in rows}
@staticmethod
def get_match_report(match_id):
report = query_db(
'l3',
'SELECT * FROM dm_match_reports WHERE match_id = ?',
[match_id],
one=True,
)
if not report:
return None
result = dict(report)
player_rows = query_db(
'l3',
"""
SELECT *
FROM dm_match_player_reports
WHERE match_id = ?
ORDER BY rating DESC
""",
[match_id],
)
player_reports = [dict(row) for row in player_rows]
steam_ids = [row['steam_id_64'] for row in player_reports]
steam_ids.extend([
result.get('mvp_steam_id'),
result.get('improver_steam_id'),
])
duo = json.loads(result.get('strongest_duo_json') or 'null')
if duo:
steam_ids.extend(duo.get('steam_ids') or [])
identities = NarrativeService._identity_map(steam_ids)
for player in player_reports:
player['identity'] = identities.get(
str(player['steam_id_64']),
{'username': player['steam_id_64']},
)
player['performance_text'] = NarrativeService.PERFORMANCE_LABELS.get(
player['performance_label'],
player['performance_label'],
)
record_keys = json.loads(player['record_keys_json'] or '[]')
player['record_labels'] = [
NarrativeService.RECORD_LABELS.get(key, key)
for key in record_keys
]
result['players'] = player_reports
result['mvp'] = identities.get(str(result.get('mvp_steam_id')), {})
result['improver'] = identities.get(
str(result.get('improver_steam_id')),
{},
)
if duo:
duo['players'] = [
identities.get(str(steam_id), {'username': steam_id})
for steam_id in duo.get('steam_ids', [])
]
result['strongest_duo'] = duo
return result
@staticmethod
def list_match_reports(limit=30):
rows = query_db(
'l3',
"""
SELECT *
FROM dm_match_reports
ORDER BY match_date DESC, match_id DESC
LIMIT ?
""",
[limit],
)
identities = NarrativeService._identity_map(
[row['mvp_steam_id'] for row in rows]
)
result = []
for row in rows:
item = dict(row)
item['mvp'] = identities.get(str(item['mvp_steam_id']), {})
result.append(item)
return result
@staticmethod
def list_awards(award_type=None, limit=100):
args = []
where = ''
if award_type in NarrativeService.AWARD_TYPE_LABELS:
where = 'WHERE award_type = ?'
args.append(award_type)
args.append(limit)
rows = query_db(
'l3',
f"""
SELECT *
FROM dm_player_awards
{where}
ORDER BY period_start DESC,
CASE award_type
WHEN 'yearly' THEN 1
WHEN 'quarterly' THEN 2
WHEN 'monthly' THEN 3
WHEN 'weekly' THEN 4
ELSE 5
END
LIMIT ?
""",
args,
)
identities = NarrativeService._identity_map(
[row['steam_id_64'] for row in rows]
)
result = []
for row in rows:
item = dict(row)
item['identity'] = identities.get(str(item['steam_id_64']), {})
item['award_label'] = NarrativeService.AWARD_TYPE_LABELS.get(
item['award_type'],
item['award_type'],
)
result.append(item)
return result
@staticmethod
def get_award_summary():
rows = query_db(
'l3',
"""
SELECT
steam_id_64,
COUNT(*) AS awards,
SUM(CASE WHEN award_type = 'daily' THEN 1 ELSE 0 END) AS daily,
SUM(CASE WHEN award_type = 'weekly' THEN 1 ELSE 0 END) AS weekly,
SUM(CASE WHEN award_type = 'monthly' THEN 1 ELSE 0 END) AS monthly,
SUM(CASE WHEN award_type = 'quarterly' THEN 1 ELSE 0 END) AS quarterly,
SUM(CASE WHEN award_type = 'yearly' THEN 1 ELSE 0 END) AS yearly
FROM dm_player_awards
GROUP BY steam_id_64
ORDER BY yearly DESC, quarterly DESC, monthly DESC,
weekly DESC, daily DESC
""",
)
identities = NarrativeService._identity_map(
[row['steam_id_64'] for row in rows]
)
result = []
for row in rows:
item = dict(row)
item['identity'] = identities.get(str(item['steam_id_64']), {})
result.append(item)
return result
@staticmethod
def get_seasons():
rows = query_db(
'l3',
"""
SELECT *
FROM dm_team_season_stats
ORDER BY season_key DESC
""",
)
identities = NarrativeService._identity_map(
[row['top_player_steam_id'] for row in rows]
)
result = []
for row in rows:
item = dict(row)
item['top_player'] = identities.get(
str(item['top_player_steam_id']),
{},
)
result.append(item)
return result
@staticmethod
def get_player_honors(steam_id, limit=20):
rows = query_db(
'l3',
"""
SELECT *
FROM dm_player_awards
WHERE steam_id_64 = ?
ORDER BY period_start DESC
LIMIT ?
""",
[steam_id, limit],
)
awards = []
for row in rows:
item = dict(row)
item['award_label'] = NarrativeService.AWARD_TYPE_LABELS.get(
item['award_type'],
item['award_type'],
)
awards.append(item)
record_rows = query_db(
'l3',
"""
SELECT *
FROM dm_player_record_events
WHERE steam_id_64 = ?
ORDER BY match_date DESC
LIMIT ?
""",
[steam_id, limit],
)
record_events = []
for row in record_rows:
item = dict(row)
item['record_label'] = NarrativeService.RECORD_LABELS.get(
item['record_key'],
item['record_key'],
)
record_events.append(item)
return {
'awards': awards,
'award_count': len(awards),
'record_events': record_events,
}
@staticmethod
def get_player_identity(steam_id):
feature = query_db(
'l3',
"""
SELECT
total_matches,
first_match_date,
last_match_date,
core_top_weapon,
meta_map_best_map,
tier_percentile
FROM dm_player_features
WHERE steam_id_64 = ?
""",
[steam_id],
one=True,
)
roster = query_db(
'web',
"""
SELECT
member.member_role,
version.name AS roster_version,
(
SELECT MIN(history_version.effective_from)
FROM team_roster_members history_member
JOIN team_roster_versions history_version
ON history_version.id = history_member.roster_version_id
WHERE history_member.steam_id_64 = member.steam_id_64
) AS effective_from
FROM team_roster_members member
JOIN team_roster_versions version
ON version.id = member.roster_version_id
WHERE member.steam_id_64 = ?
ORDER BY version.is_current DESC, version.effective_from DESC
LIMIT 1
""",
[steam_id],
one=True,
)
awards = query_db(
'l3',
"""
SELECT award_type, COUNT(*) AS count
FROM dm_player_awards
WHERE steam_id_64 = ?
GROUP BY award_type
""",
[steam_id],
)
result = dict(feature) if feature else {}
if roster:
result.update(dict(roster))
result['awards_by_type'] = {
row['award_type']: row['count'] for row in awards
}
result['award_count'] = sum(result['awards_by_type'].values())
return result
+249
View File
@@ -0,0 +1,249 @@
import json
import sqlite3
import time
from web.config import Config
class RosterVersionService:
@staticmethod
def _connect_web(web_db_path=None):
db = sqlite3.connect(web_db_path or Config.DB_WEB_PATH, timeout=30)
db.row_factory = sqlite3.Row
db.execute('PRAGMA foreign_keys = ON')
return db
@staticmethod
def _normalize_ids(player_ids):
seen = set()
result = []
for value in player_ids or []:
steam_id = str(value).strip()
if steam_id and steam_id not in seen:
seen.add(steam_id)
result.append(steam_id)
return result
@staticmethod
def ensure_initial_version(web_db_path=None, l2_db_path=None):
web = RosterVersionService._connect_web(web_db_path)
try:
current = web.execute(
"""
SELECT id
FROM team_roster_versions
WHERE is_current = 1
LIMIT 1
"""
).fetchone()
if current:
return int(current['id'])
lineup = web.execute(
"""
SELECT player_ids_json
FROM team_lineups
WHERE is_active = 1
ORDER BY created_at DESC, id DESC
LIMIT 1
"""
).fetchone()
if not lineup:
return None
try:
player_ids = RosterVersionService._normalize_ids(
json.loads(lineup['player_ids_json'] or '[]')
)
except (TypeError, json.JSONDecodeError):
return None
if not player_ids:
return None
effective_from = int(time.time())
l2_path = l2_db_path or Config.DB_L2_PATH
l2 = sqlite3.connect(l2_path)
try:
placeholders = ','.join('?' for _ in player_ids)
row = l2.execute(
f"""
SELECT MIN(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})
""",
player_ids,
).fetchone()
if row and row[0]:
effective_from = int(row[0])
finally:
l2.close()
cursor = web.execute(
"""
INSERT INTO team_roster_versions (
name, effective_from, is_current, notes
) VALUES (?, ?, 1, ?)
""",
['2.0.0 Beta Initial Roster', effective_from, 'Bootstrapped from active lineup'],
)
version_id = int(cursor.lastrowid)
web.executemany(
"""
INSERT INTO team_roster_members (
roster_version_id, steam_id_64, member_role, position_order
) VALUES (?, ?, 'member', ?)
""",
[
(version_id, steam_id, index)
for index, steam_id in enumerate(player_ids)
],
)
web.commit()
return version_id
finally:
web.close()
@staticmethod
def snapshot_roster(
player_ids,
name=None,
effective_from=None,
roles=None,
web_db_path=None,
):
normalized_ids = RosterVersionService._normalize_ids(player_ids)
if not normalized_ids:
raise ValueError('Roster version requires at least one player')
roles = roles or {}
effective_from = int(effective_from or time.time())
web = RosterVersionService._connect_web(web_db_path)
try:
current = web.execute(
"""
SELECT *
FROM team_roster_versions
WHERE is_current = 1
LIMIT 1
"""
).fetchone()
if current:
current_ids = [
str(row[0]) for row in web.execute(
"""
SELECT steam_id_64
FROM team_roster_members
WHERE roster_version_id = ?
ORDER BY position_order, steam_id_64
""",
[current['id']],
)
]
if current_ids == normalized_ids:
return int(current['id']), False
web.execute(
"""
UPDATE team_roster_versions
SET is_current = 0, effective_to = ?
WHERE id = ?
""",
[effective_from - 1, current['id']],
)
cursor = web.execute(
"""
INSERT INTO team_roster_versions (
name, effective_from, is_current
) VALUES (?, ?, 1)
""",
[
name or f'Roster {time.strftime("%Y-%m-%d")}',
effective_from,
],
)
version_id = int(cursor.lastrowid)
values = []
for index, steam_id in enumerate(normalized_ids):
role = roles.get(steam_id, 'member')
if role not in {'starter', 'substitute', 'member'}:
role = 'member'
values.append((version_id, steam_id, role, index))
web.executemany(
"""
INSERT INTO team_roster_members (
roster_version_id, steam_id_64, member_role, position_order
) VALUES (?, ?, ?, ?)
""",
values,
)
web.commit()
return version_id, True
except Exception:
web.rollback()
raise
finally:
web.close()
@staticmethod
def list_versions(web_db_path=None):
web = RosterVersionService._connect_web(web_db_path)
try:
rows = web.execute(
"""
SELECT
version.*,
COUNT(member.steam_id_64) AS member_count
FROM team_roster_versions version
LEFT JOIN team_roster_members member
ON member.roster_version_id = version.id
GROUP BY version.id
ORDER BY version.effective_from DESC, version.id DESC
"""
).fetchall()
return [dict(row) for row in rows]
finally:
web.close()
@staticmethod
def get_current_members(web_db_path=None):
web = RosterVersionService._connect_web(web_db_path)
try:
rows = web.execute(
"""
SELECT member.*, version.name AS version_name
FROM team_roster_members member
JOIN team_roster_versions version
ON version.id = member.roster_version_id
WHERE version.is_current = 1
ORDER BY member.position_order, member.steam_id_64
"""
).fetchall()
return [dict(row) for row in rows]
finally:
web.close()
@staticmethod
def update_current_member_role(steam_id, member_role, web_db_path=None):
if member_role not in {'starter', 'substitute', 'member'}:
raise ValueError('Invalid roster member role')
web = RosterVersionService._connect_web(web_db_path)
try:
cursor = web.execute(
"""
UPDATE team_roster_members
SET member_role = ?
WHERE steam_id_64 = ?
AND roster_version_id = (
SELECT id
FROM team_roster_versions
WHERE is_current = 1
LIMIT 1
)
""",
[member_role, str(steam_id)],
)
if cursor.rowcount != 1:
raise ValueError('Player is not in the current roster version')
web.commit()
finally:
web.close()
+173
View File
@@ -0,0 +1,173 @@
import json
from web.database import query_db
from web.services.roster_version_service import RosterVersionService
from web.services.team_context_service import TeamContextService
class TeamPerformanceService:
@staticmethod
def _player_identity_map(steam_ids):
if not steam_ids:
return {}
placeholders = ','.join('?' for _ in steam_ids)
rows = query_db(
'l2',
f"""
SELECT steam_id_64, username, avatar_url
FROM dim_players
WHERE steam_id_64 IN ({placeholders})
""",
steam_ids,
)
return {str(row['steam_id_64']): dict(row) for row in rows}
@staticmethod
def get_team_summary():
roster_ids = TeamContextService.get_active_roster_ids()
if not roster_ids:
return {
'matches': 0,
'wins': 0,
'win_rate': 0,
'avg_rating': 0,
'maps': [],
}
placeholders = ','.join('?' for _ in roster_ids)
rows = query_db(
'l2',
f"""
SELECT
p.match_id,
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,
COUNT(DISTINCT p.steam_id_64) AS roster_count,
AVG(p.rating) AS avg_rating,
MAX(p.is_win) AS is_win,
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})
GROUP BY p.match_id, team_key
HAVING roster_count >= 2 AND team_key IS NOT NULL
ORDER BY m.start_time DESC
""",
roster_ids,
)
matches = len(rows)
wins = sum(int(row['is_win'] or 0) for row in rows)
map_stats = {}
for row in rows:
item = map_stats.setdefault(
row['map_name'] or 'Unknown',
{'map_name': row['map_name'] or 'Unknown', 'matches': 0, 'wins': 0},
)
item['matches'] += 1
item['wins'] += int(row['is_win'] or 0)
maps = []
for item in map_stats.values():
item['win_rate'] = item['wins'] / item['matches']
maps.append(item)
maps.sort(key=lambda item: item['matches'], reverse=True)
return {
'matches': matches,
'wins': wins,
'losses': matches - wins,
'win_rate': wins / matches if matches else 0,
'avg_rating': (
sum(float(row['avg_rating'] or 0) for row in rows) / matches
if matches else 0
),
'maps': maps,
}
@staticmethod
def get_current_roster():
members = RosterVersionService.get_current_members()
identities = TeamPerformanceService._player_identity_map(
[member['steam_id_64'] for member in members]
)
result = []
for member in members:
item = dict(member)
item.update(identities.get(str(member['steam_id_64']), {}))
result.append(item)
return result
@staticmethod
def get_top_duos(limit=12):
rows = query_db(
'l3',
"""
SELECT *
FROM dm_duo_stats
ORDER BY sample_reliable DESC, matches DESC,
win_rate DESC, avg_combined_rating DESC
LIMIT ?
""",
[limit],
)
steam_ids = {
str(value)
for row in rows
for value in (row['steam_id_a'], row['steam_id_b'])
}
identities = TeamPerformanceService._player_identity_map(sorted(steam_ids))
result = []
for row in rows:
item = dict(row)
item['player_a'] = identities.get(str(row['steam_id_a']), {})
item['player_b'] = identities.get(str(row['steam_id_b']), {})
result.append(item)
return result
@staticmethod
def get_top_lineups(player_count=None, limit=12):
args = []
where = ''
if player_count:
where = 'WHERE player_count = ?'
args.append(int(player_count))
args.append(limit)
rows = query_db(
'l3',
f"""
SELECT *
FROM dm_lineup_stats
{where}
ORDER BY sample_reliable DESC, matches DESC,
win_rate DESC, avg_team_rating DESC
LIMIT ?
""",
args,
)
all_ids = set()
parsed = []
for row in rows:
item = dict(row)
item['player_ids'] = [
str(value) for value in json.loads(item['player_ids_json'])
]
all_ids.update(item['player_ids'])
parsed.append(item)
identities = TeamPerformanceService._player_identity_map(sorted(all_ids))
for item in parsed:
item['players'] = [
identities.get(steam_id, {'username': steam_id})
for steam_id in item['player_ids']
]
return parsed
@staticmethod
def get_dashboard():
return {
'summary': TeamPerformanceService.get_team_summary(),
'roster': TeamPerformanceService.get_current_roster(),
'versions': RosterVersionService.list_versions(),
'duos': TeamPerformanceService.get_top_duos(),
'lineups': TeamPerformanceService.get_top_lineups(),
}