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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+156
View File
@@ -7,6 +7,7 @@ import json
import argparse
import concurrent.futures
from collections import defaultdict, deque
from itertools import combinations
from typing import Optional
# Setup logging
@@ -326,6 +327,14 @@ def main(force_all: bool = False, workers: int = 1, create_backup: bool = True):
processed_ids = [str(row[0]) for row in players]
_update_percentiles(conn_l3, processed_ids)
_rebuild_auxiliary_marts(conn_l2, conn_l3, processed_ids)
_rebuild_team_marts(conn_l2, conn_l3, processed_ids)
from database.L3.processors.narrative_processor import NarrativeProcessor
narrative_counts = NarrativeProcessor.rebuild(
conn_l2,
conn_l3,
processed_ids,
)
logger.info("Narrative marts rebuilt: %s", narrative_counts)
quick_check = conn_l3.execute("PRAGMA quick_check").fetchone()[0]
if quick_check != 'ok':
@@ -778,6 +787,153 @@ def _calculate_record_rows(history_rows):
return result
def _rebuild_team_marts(conn_l2, conn_l3, steam_ids):
if not steam_ids:
return
conn_l3.execute('DELETE FROM dm_duo_stats')
conn_l3.execute('DELETE FROM dm_lineup_stats')
all_rows = []
for start in range(0, len(steam_ids), 400):
chunk = steam_ids[start:start + 400]
placeholders = ','.join('?' for _ in chunk)
all_rows.extend(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.is_win,
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})
""",
chunk,
).fetchall())
match_teams = defaultdict(list)
for row in all_rows:
if row['team_key'] is None:
continue
match_teams[(row['match_id'], row['team_key'])].append(row)
duo_accumulator = {}
lineup_accumulator = {}
for rows in match_teams.values():
players = {
str(row['steam_id_64']): row
for row in rows
}
player_ids = sorted(players)
if len(player_ids) < 2:
continue
is_win = bool(next(iter(players.values()))['is_win'])
match_date = int(next(iter(players.values()))['start_time'] or 0)
for player_a, player_b in combinations(player_ids, 2):
key = (player_a, player_b)
accumulator = duo_accumulator.setdefault(key, {
'matches': 0,
'wins': 0,
'rating_a': 0.0,
'rating_b': 0.0,
'first': match_date,
'last': match_date,
})
accumulator['matches'] += 1
accumulator['wins'] += int(is_win)
accumulator['rating_a'] += float(players[player_a]['rating'] or 0)
accumulator['rating_b'] += float(players[player_b]['rating'] or 0)
accumulator['first'] = min(accumulator['first'], match_date)
accumulator['last'] = max(accumulator['last'], match_date)
for size in range(2, min(5, len(player_ids)) + 1):
for selected_ids in combinations(player_ids, size):
lineup_key = '|'.join(selected_ids)
accumulator = lineup_accumulator.setdefault(lineup_key, {
'player_ids': selected_ids,
'matches': 0,
'wins': 0,
'rating': 0.0,
'first': match_date,
'last': match_date,
})
accumulator['matches'] += 1
accumulator['wins'] += int(is_win)
accumulator['rating'] += (
sum(float(players[steam_id]['rating'] or 0) for steam_id in selected_ids)
/ len(selected_ids)
)
accumulator['first'] = min(accumulator['first'], match_date)
accumulator['last'] = max(accumulator['last'], match_date)
duo_values = []
for (player_a, player_b), value in duo_accumulator.items():
matches = value['matches']
avg_a = value['rating_a'] / matches
avg_b = value['rating_b'] / matches
duo_values.append((
player_a,
player_b,
matches,
value['wins'],
value['wins'] / matches,
avg_a,
avg_b,
(avg_a + avg_b) / 2,
value['first'],
value['last'],
1 if matches >= 5 else 0,
))
conn_l3.executemany(
"""
INSERT INTO dm_duo_stats (
steam_id_a, steam_id_b, matches, wins, win_rate,
avg_rating_a, avg_rating_b, avg_combined_rating,
first_match_date, last_match_date, sample_reliable
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
duo_values,
)
lineup_values = []
for lineup_key, value in lineup_accumulator.items():
matches = value['matches']
lineup_values.append((
lineup_key,
json.dumps(value['player_ids']),
len(value['player_ids']),
matches,
value['wins'],
value['wins'] / matches,
value['rating'] / matches,
value['first'],
value['last'],
1 if matches >= 3 else 0,
))
conn_l3.executemany(
"""
INSERT INTO dm_lineup_stats (
lineup_key, player_ids_json, player_count,
matches, wins, win_rate, avg_team_rating,
first_match_date, last_match_date, sample_reliable
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
lineup_values,
)
logger.info(
'Team marts rebuilt: %s duos, %s lineups',
len(duo_values),
len(lineup_values),
)
def _update_percentiles(conn_l3, steam_ids):
"""Calculate a real percentile among eligible players in this build."""
if not steam_ids:
@@ -0,0 +1,598 @@
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
],
)
+166
View File
@@ -428,6 +428,165 @@ CREATE TABLE IF NOT EXISTS dm_player_records (
CREATE INDEX IF NOT EXISTS idx_player_records_player
ON dm_player_records(steam_id_64, record_key);
-- ============================================================================
-- Team Mart: Duo performance
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_duo_stats (
steam_id_a TEXT NOT NULL,
steam_id_b TEXT NOT NULL,
matches INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
win_rate REAL,
avg_rating_a REAL,
avg_rating_b REAL,
avg_combined_rating REAL,
first_match_date INTEGER,
last_match_date INTEGER,
sample_reliable BOOLEAN NOT NULL DEFAULT 0,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (steam_id_a, steam_id_b)
);
CREATE INDEX IF NOT EXISTS idx_duo_stats_matches
ON dm_duo_stats(matches DESC, win_rate DESC);
-- ============================================================================
-- Team Mart: Actual 2-5 player combinations
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_lineup_stats (
lineup_key TEXT PRIMARY KEY,
player_ids_json TEXT NOT NULL,
player_count INTEGER NOT NULL,
matches INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
win_rate REAL,
avg_team_rating REAL,
first_match_date INTEGER,
last_match_date INTEGER,
sample_reliable BOOLEAN NOT NULL DEFAULT 0,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_lineup_stats_size_matches
ON dm_lineup_stats(player_count, matches DESC, win_rate DESC);
-- ============================================================================
-- Narrative Mart: Post-match team report
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_match_reports (
match_id TEXT PRIMARY KEY,
match_date INTEGER NOT NULL,
map_name TEXT,
roster_count INTEGER NOT NULL DEFAULT 0,
team_key INTEGER,
is_win BOOLEAN,
team_avg_rating REAL,
team_avg_adr REAL,
mvp_steam_id TEXT,
mvp_rating REAL,
improver_steam_id TEXT,
improver_delta REAL,
strongest_duo_json TEXT,
record_break_count INTEGER NOT NULL DEFAULT 0,
summary_text TEXT,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_match_reports_date
ON dm_match_reports(match_date DESC);
-- ============================================================================
-- Narrative Mart: Player performance vs prior form
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_match_player_reports (
match_id TEXT NOT NULL,
steam_id_64 TEXT NOT NULL,
match_date INTEGER NOT NULL,
rating REAL,
kd_ratio REAL,
adr REAL,
kills INTEGER,
deaths INTEGER,
baseline_matches INTEGER NOT NULL DEFAULT 0,
prior_20_rating REAL,
prior_20_kd REAL,
prior_20_adr REAL,
rating_delta REAL,
kd_delta REAL,
adr_delta REAL,
performance_label TEXT,
record_keys_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (match_id, steam_id_64)
);
CREATE INDEX IF NOT EXISTS idx_match_player_reports_player_date
ON dm_match_player_reports(steam_id_64, match_date DESC);
-- ============================================================================
-- Narrative Mart: Every career-record breaking event
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_player_record_events (
steam_id_64 TEXT NOT NULL,
record_key TEXT NOT NULL,
match_id TEXT NOT NULL,
match_date INTEGER NOT NULL,
map_name TEXT,
previous_value REAL,
record_value REAL NOT NULL,
is_current_record BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (steam_id_64, record_key, match_id)
);
CREATE INDEX IF NOT EXISTS idx_record_events_player_date
ON dm_player_record_events(steam_id_64, match_date DESC);
-- ============================================================================
-- Team Mart: Calendar season performance
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_team_season_stats (
season_key TEXT PRIMARY KEY,
season_label TEXT NOT NULL,
period_start INTEGER NOT NULL,
period_end INTEGER NOT NULL,
matches INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
win_rate REAL,
avg_team_rating REAL,
best_map TEXT,
best_map_win_rate REAL,
top_player_steam_id TEXT,
top_player_rating REAL,
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================================
-- Award Mart: Best player by calendar period
-- ============================================================================
CREATE TABLE IF NOT EXISTS dm_player_awards (
award_type TEXT NOT NULL,
period_key TEXT NOT NULL,
period_label TEXT NOT NULL,
period_start INTEGER NOT NULL,
period_end INTEGER NOT NULL,
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,
performance_score REAL,
sample_reliable BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (award_type, period_key)
);
CREATE INDEX IF NOT EXISTS idx_player_awards_player
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);
-- ============================================================================
-- Schema Summary
-- ============================================================================
@@ -443,4 +602,11 @@ ON dm_player_records(steam_id_64, record_key);
-- dm_player_weapon_stats: Weapon usage statistics
-- dm_player_period_stats: Career/recent time-window aggregations
-- dm_player_records: Career record values and source matches
-- dm_duo_stats: Same-team pair performance
-- dm_lineup_stats: Actual same-team 2-5 player combinations
-- dm_match_reports: Post-match team narrative
-- dm_match_player_reports: Player form deltas per match
-- 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
-- ============================================================================
Binary file not shown.
+32
View File
@@ -48,6 +48,38 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_team_lineups_single_active
ON team_lineups(is_active)
WHERE is_active = 1;
CREATE TABLE IF NOT EXISTS team_roster_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
effective_from INTEGER NOT NULL,
effective_to INTEGER,
is_current INTEGER NOT NULL DEFAULT 0,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (effective_to IS NULL OR effective_to >= effective_from)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_roster_versions_single_current
ON team_roster_versions(is_current)
WHERE is_current = 1;
CREATE INDEX IF NOT EXISTS idx_roster_versions_period
ON team_roster_versions(effective_from, effective_to);
CREATE TABLE IF NOT EXISTS team_roster_members (
roster_version_id INTEGER NOT NULL,
steam_id_64 TEXT NOT NULL,
member_role TEXT NOT NULL DEFAULT 'member'
CHECK (member_role IN ('starter', 'substitute', 'member')),
position_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (roster_version_id, steam_id_64),
FOREIGN KEY (roster_version_id)
REFERENCES team_roster_versions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_roster_members_player
ON team_roster_members(steam_id_64, roster_version_id);
CREATE TABLE IF NOT EXISTS wiki_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,