diff --git a/README.md b/README.md index c598e80..33c7ca2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# YRTV 2.0.0 Alpha +# YRTV 2.0.0 Beta YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行榜,而是让队员拥有类似职业选手的个人主页,并为战队提供比赛档案、队内比较、阵容分析、对手情报和战术工具。 -当前 Alpha 版本已经建立可重复运行的数据流水线、数据库治理和职业主页数据集市,重点服务 active roster。 +当前 Beta 版本已经建立可重复运行的数据流水线、数据库治理、职业主页和可追溯的阵容版本模型,重点服务 active roster。 ## 当前基线 @@ -18,8 +18,15 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行 - 236 条武器统计 - 54 条时间窗口统计 - 54 条个人职业纪录 -- 24 项自动化测试通过 -- 28 项数据完整性检查通过 +- 36 个 roster 二人组 +- 200 个实际 2-5 人阵容组合 +- 208 份赛后报告 +- 885 份队员赛后对比 +- 203 次职业纪录刷新事件 +- 2 个战队赛季 +- 111 个日/周/月/季/年度奖项 +- 31 项自动化测试通过 +- 39 项数据完整性检查通过 数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。 @@ -39,6 +46,9 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行 ### 比赛中心 - 比赛列表、地图、比分、平均 ELO 和己方结果 +- 自动赛后报告与队内 MVP +- 相对此前 20 场的 Rating、K/D、ADR 变化 +- 本场纪录刷新、状态标签和最强二人组 - Active roster 与 Party 识别 - 双方玩家表现和 Rating 排序 - Head-to-head 击杀矩阵 @@ -48,12 +58,25 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行 ### 战队与战术 - Active roster 管理 +- Roster version 生效区间和历史版本 +- Starter、substitute、member 角色管理 +- 战队履历、赛季战绩和地图表现 +- 实际同队二人组与 2-5 人阵容组合 - 玩家搜索、签入和移出 - 2-5 人同队比赛与 Chemistry 分析 - 对手档案和真实交手记录 - 地图战术板、阵容数据中心和经济工具 - Wiki、玩家标签、备注和评论 +### 赛季与荣誉 + +- 按日历年度生成战队赛季 +- 赛季战绩、Rating、最佳地图和赛季选手 +- 单日最佳、星期最佳、月度最佳、季度最佳、年度最佳 +- 周/月/季/年奖项使用最低场次门槛 +- 个人主页展示荣誉数量和近期奖章 +- 每次职业纪录刷新保留时间和对应比赛 + ### 数据运营 - Admin 上传 `iframe_network.json` @@ -183,12 +206,19 @@ Flask services and player profiles - `dm_player_weapon_stats` - `dm_player_period_stats` - `dm_player_records` + - `dm_duo_stats` + - `dm_lineup_stats` + - `dm_match_reports` + - `dm_match_player_reports` + - `dm_player_record_events` + - `dm_team_season_stats` + - `dm_player_awards` ### Web:应用状态 - 数据库:`database/Web/Web_App.sqlite` - Schema:`database/Web/schema.sql` -- 当前 schema version:2 +- 当前 schema version:3 - 保存 lineup、玩家备注、评论、Wiki、战术板、导入登记和 ETL 作业 ## 数据库治理 @@ -253,17 +283,17 @@ yrtv/ └── wsgi.py ``` -## Alpha 限制 +## Beta 限制 - 当前主要数据源为 5E iframe 网络响应。 - 不包含自动网页下载器和 Demo parser。 - 认证仍是单一 Admin Token,适合私人部署,不适合开放注册。 - SQLite 适合当前单战队规模,不面向高并发多租户。 - 部分高级空间能力需要地图边界、路径和 Demo 数据,当前显示 `N/A`。 -- `StatsService` 仍保留部分兼容逻辑,后续会继续按领域拆分。 +- `StatsService` 仍保留部分兼容逻辑;新功能已拆入 Player Profile、Team Performance、Roster Version 等领域服务。 ## 版本 -当前版本:`2.0.0 Alpha` +当前版本:`2.0.0 Beta` 这一版本的目标是建立可信、可恢复、可持续导入的私人战队 HLTV 基线,而不是冻结产品功能。 diff --git a/Resume_writing.md b/Resume_writing.md deleted file mode 100644 index 45dc9f6..0000000 --- a/Resume_writing.md +++ /dev/null @@ -1,65 +0,0 @@ -展示项目业务价值的核心是打造**「技术动作→数据成果→业务落地」的闭环链路**,结合你CS2数据项目+数据分析岗的定位,同时匹配“队长带领5人团队”的角色,核心要做到**量化成果前置、技术与业务强绑定、个人贡献突出**,以下是可直接落地的方法,附专属你的CS2项目优化示例和通用模板: - -### 一、核心方法:5招落地,每招配CS2项目简历示例 -#### 1. 成果前置+强量化,抓牢HR8秒注意力 -把**最核心的业务价值**放在项目概述首位,用**对比量化(提升/降低)+绝对值量化(数据量/规模)** 替代模糊描述,电竞/数据分析岗重点突出**核心业务指标、数据处理规模、效率/成本优化**三类数据。 -**普通表述**:带领团队搭建CS2数据平台,处理了大量比赛数据,提升了战队胜率 -**优化表述**:作为队长带领5人数据团队,搭建CS2赛事全流程数据分析平台,完成1年内300+场职业比赛、1600+玩家、数十万回合级数据的结构化处理,**推动战队ELO分层胜率从42%提升至55%(+13个百分点)**,数据维护人力成本降低60%。 - -#### 2. 技术动作与业务价值强绑定,拒绝纯技术堆砌 -数据分析岗最忌只说“用Python做数据处理”,要明确**Python的具体高阶操作→带来的数据分析成果→最终落地的业务价值**,让技术成为业务价值的“桥梁”,而非孤立的技能。 -**普通表述**:用Python做了数据清洗和特征工程,构建了玩家画像 -**优化表述**:通过Python(Pandas/NumPy)实现原始JSON赛事数据的**矢量化清洗与批处理转换**,结合窗口函数完成200+维度玩家画像的高效计算,创新定义“压力表现”等战术指标,**为战队战术组提供精准的选手适配、站位优化数据支撑,成为胜率提升的核心数据依据**。 - -#### 3. STAR法则结构化,让业务价值链路更清晰 -围绕电竞行业**“经验驱动战术→缺乏精细化数据支撑”**的核心痛点搭建STAR框架,**情境(S)讲行业/业务痛点,任务(T)定团队目标+个人职责,行动(A)做技术+数据动作,结果(R)出业务+效率双成果**,同时突出队长的**团队统筹能力**。 -**CS2项目STAR落地示例**: -- 情境(S):针对电竞行业战术决策依赖经验、传统K/D指标无法量化战术价值的痛点,战队ELO分层胜率长期低于行业平均水平; -- 任务(T):带领5人团队搭建从数据采集到可视化的全流程分析平台,核心目标通过数据驱动战术优化提升战队胜率; -- 行动(A):统筹团队分工(数据采集/特征工程/可视化),制定Python代码规范,主导设计L1-L3分层数仓,开发Python多线程ETL自动化流水线; -- 结果(R):战队ELO分层胜率提升13%,300+场比赛数据实现实时入库,数据查询效率提升至毫秒级,团队开发效率提升40%。 - -#### 4. 多维度拆解业务价值,让成果更立体 -单一的胜率提升不够有说服力,结合数据分析岗的**效率、成本、复用性**,从**核心业务指标(胜率)、数据效率(处理/查询速度)、运营成本(人力/时间)、成果复用性(模型/指标的落地)**四个维度拆解,贴合企业对数据“降本增效+业务赋能”的核心需求。 -**CS2项目多维度价值示例**: -- 业务效果:ELO分层胜率42%→55%,战术优化精准度提升80%; -- 数据效率:Python矢量化处理让1600+玩家全维度数据查询效率提升至毫秒级; -- 成本优化:Python自动化ETL流水线让数据维护人力成本降低60%,赛事数据入库时间从小时级压缩至分钟级; -- 成果复用:搭建的200+维度玩家特征模型被战队战术组复用,成为日常战术分析、选手选拔的标准模型。 - -#### 5. 嵌入行业专属术语,让专业度拉满 -在描述中加入**电竞行业+数据分析岗**的专属术语,让HR/业务方快速感知你对双领域的理解,避免“外行话”,核心术语精准即可,无需堆砌。 -- 电竞行业:ELO分层胜率、战术复盘、玩家协同效率、阵容适配、回合级数据; -- 数据分析岗:L1-L3分层数仓、特征工程、ETL自动化流水线、矢量化运算、玩家画像特征集市。 - -### 二、数据分析岗专属:「技术-业务」价值句式模板 -直接套用来描述项目职责,完美实现技术动作与业务价值的绑定,适配你的CS2项目所有模块: -1. 数据处理/ETL:**通过Python+[Pandas/Playwright/多线程]完成[XX数据量]的[矢量化清洗/自动化抓取/批处理],实现[数据效率/成本]优化,保障[XX业务环节]的精准性/实时性** -2. 特征工程/建模:**基于Python+[NumPy/窗口函数]构建[XX维度]的[特征模型/用户画像],创新定义[XX高阶指标],量化[XX业务价值],为[XX业务决策]提供核心数据支撑** -3. 数仓/架构设计:**主导设计[XX架构]的数仓体系,通过[Python+XX技术]实现[多粒度数据]的关联存储,将[数据查询效率]提升X%,支撑[XX业务分析]的高效落地** -4. 团队管理(队长):**统筹X人团队分工,制定[Python/代码]规范,推动项目从0到1落地,最终实现[核心业务指标]提升X%,团队开发效率提升X%** - -### 三、避坑指南:4个最易踩的业务价值展示误区 -1. ❌ 模糊表述:用“大幅提升、有效改善、处理大量数据”替代具体数字;✅ 必须用**百分比/绝对值/对比值**量化(如胜率+13%、300+场比赛、成本降60%) -2. ❌ 技术堆砌:只罗列“Python/Pandas/SQLite”,不说技术的业务作用;✅ 技术永远为业务服务,每提一个技术,必跟上**数据成果+业务价值** -3. ❌ 弱化个人贡献:用“参与、协助”描述,忽略队长的领导力;✅ 全程用**带领/主导/统筹/牵头**等强动词,明确个人在项目中的核心作用 -4. ❌ 单一价值:只说核心业务指标(胜率),忽略效率/成本/复用性;✅ 多维度拆解,让企业看到你能为公司带来**“业务增长+降本增效”**的双重价值 - -### 四、你的CS2项目最终优化版(整合所有方法,可直接贴简历) -#### 基于CS2赛事的垂直领域数据仓库与战术分析平台 -**项目概述**:作为队长带领5人数据团队,针对电竞行业战术决策依赖经验、传统K/D指标无法量化战术价值的痛点,基于Python生态搭建「数据采集-ETL清洗-特征挖掘-可视化」全流程CS2赛事分析平台,完成1年内300+场职业比赛、1600+玩家、数十万回合级全量数据的结构化处理,**推动战队ELO分层胜率从42%提升至55%(+13个百分点)**,数据维护人力成本降低60%,搭建的特征模型成为战队战术分析/选手选拔的标准工具。 - -**核心职责与成果**: -1. **数仓架构设计(Python全栈落地)**:主导设计L1(原始)-L2(星型模型)-L3(特征集市)分层数仓,通过Python/Pandas实现非结构化JSON数据的矢量化清洗与批处理,结合SQLite构建多粒度事实表/维度表,**实现1600+玩家数据毫秒级查询,为战术分析提供高效数据支撑**; -2. **高阶特征工程(业务价值核心)**:带领团队基于Python/NumPy搭建模块化特征计算引擎,通过窗口函数完成200+维度玩家画像的高效计算,创新定义“压力表现/位置掌控”等战术指标,**量化传统指标无法反映的战术价值,战术组基于此完成80%的站位/阵容优化调整**; -3. **自动化ETL流水线(降本增效)**:牵头开发Python+Playwright分布式爬虫,结合多线程实现赛事数据抓取、校验、入库全流程自动化,**将数据入库时间从小时级压缩至分钟级,数据维护人力成本降低60%,保障300+场比赛数据的实时性与完整性**; -4. **数据驱动战术落地(闭环验证)**:通过Python实现战队ELO分层胜率预测模型,基于历史数据输出战术调整建议并落地,**完成“数据处理-特征建模-战术优化-胜率提升”的全链路闭环**; -5. **团队统筹管理(队长价值)**:统筹5人团队分模块分工(数据采集/特征工程/可视化),制定Python代码规范与Git版本管控流程,**将团队整体开发效率提升40%,保障项目从0到1高效落地**。 - -**技能关键词**:Python(Pandas/NumPy/多线程/矢量化运算)、SQLite、SQL、ETL自动化、数据仓库设计、特征工程、Playwright、Flask、团队管理、电竞赛事数据分析 - -### 五、高端项目启发:从电竞数据项目到企业级数据项目的业务价值思维 -你的CS2项目已经具备企业级高端数据项目的核心雏形,高端项目对**业务价值**的要求会更强调**「规模化、可复用、商业变现」**,核心启发有3点: -1. **从“单战队价值”到“行业规模化价值”**:企业级项目不仅服务单一业务方,而是能复用到整个行业/公司多业务线,比如你的CS2特征模型可从单战队拓展至青训选手选拔、赛事直播数据可视化、电竞俱乐部数据中台搭建; -2. **从“战术价值”到“商业价值”**:高端项目需将数据价值转化为**可量化的商业收益**,比如电竞数据平台可通过为赛事方/俱乐部提供付费数据分析服务、为品牌方提供选手粉丝画像实现商业变现,企业中则是将数据成果转化为GMV提升、营收增长、获客成本降低; -3. **从“人工落地”到“自动化决策”**:你的项目实现了“数据支撑战术决策”,高端项目会进一步实现**“数据自动化输出决策建议”**,比如通过Python搭建实时战术推荐模型,比赛中根据战局动态输出最优站位/道具使用建议,企业中则是智能推荐、自动化风控、精准营销等场景。 diff --git a/database/L2/L2.db b/database/L2/L2.db index 1eb5817..ed7a033 100644 Binary files a/database/L2/L2.db and b/database/L2/L2.db differ diff --git a/database/L3/L3.db b/database/L3/L3.db index 430987a..a0d2e32 100644 Binary files a/database/L3/L3.db and b/database/L3/L3.db differ diff --git a/database/L3/L3_Builder.py b/database/L3/L3_Builder.py index e0ae7c6..9be460a 100644 --- a/database/L3/L3_Builder.py +++ b/database/L3/L3_Builder.py @@ -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: diff --git a/database/L3/processors/narrative_processor.py b/database/L3/processors/narrative_processor.py new file mode 100644 index 0000000..822df1c --- /dev/null +++ b/database/L3/processors/narrative_processor.py @@ -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 + ], + ) diff --git a/database/L3/schema.sql b/database/L3/schema.sql index 6c3c667..8b1c572 100644 --- a/database/L3/schema.sql +++ b/database/L3/schema.sql @@ -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 -- ============================================================================ diff --git a/database/Web/Web_App.sqlite b/database/Web/Web_App.sqlite index da55218..abee742 100644 Binary files a/database/Web/Web_App.sqlite and b/database/Web/Web_App.sqlite differ diff --git a/database/Web/schema.sql b/database/Web/schema.sql index e2c1761..45f91ac 100644 --- a/database/Web/schema.sql +++ b/database/Web/schema.sql @@ -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, diff --git a/tests/test_integration.py b/tests/test_integration.py index 8aee864..4016372 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -44,9 +44,12 @@ class ApplicationIntegrationTests(unittest.TestCase): paths = [ '/', '/matches/', + '/reports/', + '/awards/', '/players/', f'/players/{self.roster_ids[0]}', '/teams/', + '/teams/performance', '/tactics/', '/opponents/', ] @@ -56,6 +59,47 @@ class ApplicationIntegrationTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertGreater(len(response.data), 100) + def test_match_detail_contains_post_match_report(self): + l3 = sqlite3.connect(Config.DB_L3_PATH) + try: + match_id = l3.execute( + """ + SELECT match_id + FROM dm_match_reports + ORDER BY match_date DESC + LIMIT 1 + """ + ).fetchone()[0] + finally: + l3.close() + response = self.client.get(f'/matches/{match_id}') + self.assertEqual(response.status_code, 200) + self.assertIn('赛后报告'.encode('utf-8'), response.data) + self.assertIn('Form Improver'.encode('utf-8'), response.data) + + def test_awards_and_professional_identity_render(self): + response = self.client.get('/awards/?type=monthly') + self.assertEqual(response.status_code, 200) + self.assertIn('周期最佳与荣誉榜'.encode('utf-8'), response.data) + self.assertIn('月度最佳'.encode('utf-8'), response.data) + + response = self.client.get(f'/players/{self.roster_ids[0]}') + self.assertEqual(response.status_code, 200) + self.assertIn(b'Professional Identity', response.data) + self.assertIn('代表武器'.encode('utf-8'), response.data) + + def test_team_performance_page_contains_beta_sections(self): + response = self.client.get('/teams/performance') + self.assertEqual(response.status_code, 200) + for label in ( + '战队履历与阵容表现', + '二人组表现', + '实际阵容组合', + 'Roster Version 历史', + ): + with self.subTest(label=label): + self.assertIn(label.encode('utf-8'), response.data) + def test_admin_integrity_page_and_json_render(self): with self.client.session_transaction() as session: session['is_admin'] = True @@ -93,19 +137,26 @@ class ApplicationIntegrationTests(unittest.TestCase): """ ).fetchone()[0] - response = self.client.post( - '/admin/import-match', - data={ - 'capture': ( - io.BytesIO(raw.encode('utf-8')), - 'iframe_network.json', - ), - }, - content_type='multipart/form-data', - follow_redirects=True, - ) - self.assertEqual(response.status_code, 200) - self.assertIn(b'already imported with identical data', response.data) + upload = io.BytesIO(raw.encode('utf-8')) + try: + response = self.client.post( + '/admin/import-match', + data={ + 'capture': ( + upload, + 'iframe_network.json', + ), + }, + content_type='multipart/form-data', + follow_redirects=True, + ) + finally: + upload.close() + try: + self.assertEqual(response.status_code, 200) + self.assertIn(b'already imported with identical data', response.data) + finally: + response.close() def test_player_search_works_across_l2_and_l3(self): response = self.client.get('/players/?search=jAck') @@ -238,11 +289,18 @@ class L3MartBuilderTests(unittest.TestCase): ) l3.commit() for table in ( + 'dm_duo_stats', + 'dm_lineup_stats', + 'dm_match_player_reports', + 'dm_match_reports', + '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_team_season_stats', ): count = l3.execute( f'SELECT COUNT(*) FROM {table}' @@ -615,6 +673,203 @@ class DatabaseGovernanceTests(unittest.TestCase): l3.close() l2.close() + def test_award_minimum_samples_and_unique_period_winner(self): + minimums = { + 'daily': 1, + 'weekly': 2, + 'monthly': 5, + 'quarterly': 10, + 'yearly': 20, + } + l3 = sqlite3.connect(Config.DB_L3_PATH) + try: + rows = l3.execute( + """ + SELECT award_type, period_key, matches + FROM dm_player_awards + """ + ).fetchall() + finally: + l3.close() + self.assertGreater(len(rows), 0) + seen = set() + for award_type, period_key, matches in rows: + with self.subTest(award_type=award_type, period_key=period_key): + self.assertGreaterEqual(matches, minimums[award_type]) + self.assertNotIn((award_type, period_key), seen) + seen.add((award_type, period_key)) + + def test_seasons_only_include_team_matches(self): + l2 = sqlite3.connect(Config.DB_L2_PATH) + l3 = sqlite3.connect(Config.DB_L3_PATH) + web = sqlite3.connect(Config.DB_WEB_PATH) + try: + roster_ids = json.loads( + web.execute( + """ + SELECT player_ids_json + FROM team_lineups + WHERE is_active = 1 + """ + ).fetchone()[0] + ) + placeholders = ','.join('?' for _ in roster_ids) + expected = { + str(row[0]): row[1] + for row in l2.execute( + f""" + SELECT + strftime('%Y', m.start_time, 'unixepoch') AS season, + COUNT(DISTINCT grouped.match_id) + FROM ( + SELECT + match_id, + CASE + WHEN group_id IN (1, 2) THEN group_id + WHEN team_id IN (1, 2) THEN team_id + END AS team_key + FROM fact_match_players + WHERE steam_id_64 IN ({placeholders}) + GROUP BY match_id, team_key + HAVING COUNT(DISTINCT steam_id_64) >= 2 + AND team_key IS NOT NULL + ) grouped + JOIN fact_matches m ON m.match_id = grouped.match_id + GROUP BY strftime('%Y', m.start_time, 'unixepoch') + """, + roster_ids, + ) + } + actual = { + row[0]: row[1] + for row in l3.execute( + 'SELECT season_key, matches FROM dm_team_season_stats' + ) + } + finally: + l2.close() + l3.close() + web.close() + self.assertEqual(actual, expected) + + def test_roster_version_snapshot_closes_previous_version(self): + from web.services.roster_version_service import RosterVersionService + + with tempfile.TemporaryDirectory() as temp_dir: + web_path = Path(temp_dir) / 'Web.sqlite' + l2_path = Path(temp_dir) / 'L2.sqlite' + with sqlite3.connect(str(web_path)) as db: + with open(Config.DB_WEB_SCHEMA_PATH, 'r', encoding='utf-8') as schema: + db.executescript(schema.read()) + db.execute( + """ + INSERT INTO team_lineups ( + name, player_ids_json, is_active + ) VALUES ('Test', '["1", "2"]', 1) + """ + ) + with sqlite3.connect(str(l2_path)) as db: + db.executescript( + """ + CREATE TABLE fact_matches ( + match_id TEXT PRIMARY KEY, + start_time INTEGER + ); + CREATE TABLE fact_match_players ( + match_id TEXT, + steam_id_64 TEXT + ); + INSERT INTO fact_matches VALUES ('m1', 100); + INSERT INTO fact_match_players VALUES ('m1', '1'); + INSERT INTO fact_match_players VALUES ('m1', '2'); + """ + ) + + first_id = RosterVersionService.ensure_initial_version( + str(web_path), + str(l2_path), + ) + second_id, created = RosterVersionService.snapshot_roster( + ['1', '3'], + effective_from=200, + web_db_path=str(web_path), + ) + RosterVersionService.update_current_member_role( + '1', + 'starter', + web_db_path=str(web_path), + ) + self.assertTrue(created) + self.assertNotEqual(first_id, second_id) + + with sqlite3.connect(str(web_path)) as db: + old = db.execute( + """ + SELECT is_current, effective_to + FROM team_roster_versions + WHERE id = ? + """, + [first_id], + ).fetchone() + current_ids = [ + row[0] for row in db.execute( + """ + SELECT steam_id_64 + FROM team_roster_members + WHERE roster_version_id = ? + ORDER BY position_order + """, + [second_id], + ) + ] + role = db.execute( + """ + SELECT member_role + FROM team_roster_members + WHERE roster_version_id = ? AND steam_id_64 = '1' + """, + [second_id], + ).fetchone()[0] + self.assertEqual(old, (0, 199)) + self.assertEqual(current_ids, ['1', '3']) + self.assertEqual(role, 'starter') + + def test_team_marts_cover_active_roster_combinations(self): + import json + from math import comb + + web = sqlite3.connect(Config.DB_WEB_PATH) + l3 = sqlite3.connect(Config.DB_L3_PATH) + try: + roster_ids = json.loads( + web.execute( + """ + SELECT player_ids_json + FROM team_lineups + WHERE is_active = 1 + """ + ).fetchone()[0] + ) + duo_count = l3.execute( + 'SELECT COUNT(*) FROM dm_duo_stats' + ).fetchone()[0] + lineup_sizes = { + row[0]: row[1] + for row in l3.execute( + """ + SELECT player_count, COUNT(*) + FROM dm_lineup_stats + GROUP BY player_count + """ + ) + } + finally: + web.close() + l3.close() + + self.assertEqual(duo_count, comb(len(roster_ids), 2)) + self.assertTrue(all(size in lineup_sizes for size in (2, 3, 4, 5))) + if __name__ == '__main__': unittest.main() diff --git a/web/app.py b/web/app.py index 6dada33..068e320 100644 --- a/web/app.py +++ b/web/app.py @@ -13,9 +13,22 @@ def create_app(config_object=Config): app.config.from_object(config_object) initialize_web_db() + from web.services.roster_version_service import RosterVersionService + RosterVersionService.ensure_initial_version() app.teardown_appcontext(close_dbs) - from web.routes import main, matches, players, teams, tactics, admin, wiki, opponents + from web.routes import ( + admin, + awards, + main, + matches, + opponents, + players, + reports, + tactics, + teams, + wiki, + ) app.register_blueprint(main.bp) app.register_blueprint(matches.bp) app.register_blueprint(players.bp) @@ -24,6 +37,8 @@ def create_app(config_object=Config): app.register_blueprint(admin.bp) app.register_blueprint(wiki.bp) app.register_blueprint(opponents.bp) + app.register_blueprint(reports.bp) + app.register_blueprint(awards.bp) return app diff --git a/web/config.py b/web/config.py index 687e3ab..510dece 100644 --- a/web/config.py +++ b/web/config.py @@ -13,7 +13,7 @@ class Config: DB_L3_PATH = str(L3_DB) DB_WEB_PATH = str(WEB_DB) DB_WEB_SCHEMA_PATH = str(WEB_SCHEMA) - WEB_SCHEMA_VERSION = 2 + WEB_SCHEMA_VERSION = 3 MAX_CONTENT_LENGTH = 5 * 1024 * 1024 SQLITE_TIMEOUT_SECONDS = 15 diff --git a/web/database.py b/web/database.py index 897d90b..efe258b 100644 --- a/web/database.py +++ b/web/database.py @@ -83,7 +83,7 @@ def initialize_web_db(): """, [ Config.WEB_SCHEMA_VERSION, - 'ETL jobs, match imports and active lineup governance', + 'Roster versions, member roles and team performance governance', ], ) db.commit() diff --git a/web/routes/awards.py b/web/routes/awards.py new file mode 100644 index 0000000..3871f03 --- /dev/null +++ b/web/routes/awards.py @@ -0,0 +1,19 @@ +from flask import Blueprint, render_template, request + +from web.services.narrative_service import NarrativeService + + +bp = Blueprint('awards', __name__, url_prefix='/awards') + + +@bp.route('/') +def index(): + award_type = request.args.get('type') + return render_template( + 'awards/index.html', + awards=NarrativeService.list_awards(award_type), + leaderboard=NarrativeService.get_award_summary(), + award_type=award_type, + award_types=NarrativeService.AWARD_TYPE_LABELS, + ) + diff --git a/web/routes/matches.py b/web/routes/matches.py index f7d434f..c94299c 100644 --- a/web/routes/matches.py +++ b/web/routes/matches.py @@ -1,5 +1,6 @@ from flask import Blueprint, render_template, request, Response from web.services.stats_service import StatsService +from web.services.narrative_service import NarrativeService from web.config import Config import json @@ -108,7 +109,8 @@ def detail(match_id): rounds=rounds, h2h_matrix=h2h_matrix, round_details=round_details, - player_name_map=player_name_map) + player_name_map=player_name_map, + post_match_report=NarrativeService.get_match_report(match_id)) @bp.route('//raw') def raw_json(match_id): diff --git a/web/routes/players.py b/web/routes/players.py index 1614878..6d73281 100644 --- a/web/routes/players.py +++ b/web/routes/players.py @@ -2,14 +2,17 @@ from flask import Blueprint, render_template, request, jsonify, redirect, url_fo from web.services.stats_service import StatsService from web.services.feature_service import FeatureService from web.services.player_profile_service import PlayerProfileService +from web.services.narrative_service import NarrativeService from web.services.web_service import WebService from web.database import execute_db, query_db from web.config import Config from datetime import datetime import os import json +import logging bp = Blueprint('players', __name__, url_prefix='/players') +logger = logging.getLogger(__name__) ALLOWED_AVATAR_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'} @bp.route('/') @@ -64,7 +67,10 @@ def detail(steam_id): flash('Avatar updated successfully.', 'success') except Exception as e: - print(f"Avatar upload error: {e}") + logger.exception( + "Avatar upload failed for player %s", + steam_id, + ) flash('Error uploading avatar.', 'error') WebService.update_player_metadata(steam_id, notes=notes) @@ -197,6 +203,8 @@ def detail(steam_id): map_stats=map_stats_list, period_stats=period_stats, records=records, + professional_identity=NarrativeService.get_player_identity(steam_id), + honors=NarrativeService.get_player_honors(steam_id), l2_stats=l2_stats, side_stats=side_stats) diff --git a/web/routes/reports.py b/web/routes/reports.py new file mode 100644 index 0000000..72af79c --- /dev/null +++ b/web/routes/reports.py @@ -0,0 +1,15 @@ +from flask import Blueprint, render_template + +from web.services.narrative_service import NarrativeService + + +bp = Blueprint('reports', __name__, url_prefix='/reports') + + +@bp.route('/') +def index(): + return render_template( + 'reports/index.html', + reports=NarrativeService.list_match_reports(50), + ) + diff --git a/web/routes/teams.py b/web/routes/teams.py index 1a2cb3b..9649695 100644 --- a/web/routes/teams.py +++ b/web/routes/teams.py @@ -2,15 +2,19 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from web.services.web_service import WebService from web.services.stats_service import StatsService from web.services.feature_service import FeatureService +from web.services.roster_version_service import RosterVersionService +from web.services.team_performance_service import TeamPerformanceService +from web.services.narrative_service import NarrativeService import json +import logging bp = Blueprint('teams', __name__, url_prefix='/teams') +logger = logging.getLogger(__name__) # --- API Endpoints --- @bp.route('/api/search') def api_search(): query = request.args.get('q', '').strip() # Strip whitespace - print(f"DEBUG: Search Query Received: '{query}'") # Debug Log if len(query) < 2: return jsonify([]) @@ -20,9 +24,7 @@ def api_search(): # Support sorting by matches for better "Find Player" experience sort_by = request.args.get('sort', 'matches') - print(f"DEBUG: Calling StatsService.get_players with search='{query}'") players, total = StatsService.get_players(page=1, per_page=50, search=query, sort_by=sort_by) - print(f"DEBUG: Found {len(players)} players (Total: {total})") # Format for frontend results = [] @@ -62,7 +64,6 @@ def api_search(): results.sort(key=lambda x: x['matches'], reverse=True) - print(f"DEBUG: Returning {len(results)} results") return jsonify(results) @bp.route('/api/roster', methods=['GET', 'POST']) @@ -88,6 +89,7 @@ def api_roster(): current_ids = json.loads(target_team['player_ids_json']) except: pass + previous_ids = list(current_ids) if action == 'add': if steam_id not in current_ids: @@ -95,20 +97,36 @@ def api_roster(): elif action == 'remove': if steam_id in current_ids: current_ids.remove(steam_id) + else: + return jsonify({'error': 'Unknown roster action'}), 400 - # Pass lineup_id=target_team['id'] to update existing lineup - WebService.save_lineup(target_team['name'], target_team['description'], current_ids, lineup_id=target_team['id']) + WebService.save_lineup( + target_team['name'], + target_team['description'], + current_ids, + lineup_id=target_team['id'], + ) + try: + version_id, created = RosterVersionService.snapshot_roster( + current_ids, + name=f"{target_team['name']} Roster", + ) + except Exception: + WebService.save_lineup( + target_team['name'], + target_team['description'], + previous_ids, + lineup_id=target_team['id'], + ) + raise return jsonify({'status': 'success', 'roster': current_ids}) # GET: Return detailed player info try: - print(f"DEBUG: api_roster GET - Target Team: {target_team.get('id')}") p_ids_json = target_team.get('player_ids_json', '[]') p_ids = json.loads(p_ids_json) - print(f"DEBUG: Player IDs: {p_ids}") players = StatsService.get_players_by_ids(p_ids) - print(f"DEBUG: Players fetched: {len(players) if players else 0}") # Add extra stats needed for cards enriched = [] @@ -130,9 +148,10 @@ def api_roster(): enriched.append(p_dict) except Exception as inner_e: - print(f"ERROR: Processing player failed: {inner_e}") - import traceback - traceback.print_exc() + logger.exception( + "Failed to enrich roster player %s", + p.get('steam_id_64') if hasattr(p, 'get') else 'unknown', + ) return jsonify({ 'status': 'success', @@ -140,9 +159,7 @@ def api_roster(): 'roster': enriched }) except Exception as e: - print(f"CRITICAL ERROR in api_roster: {e}") - import traceback - traceback.print_exc() + logger.exception("Roster API failed") return jsonify({'error': str(e)}), 500 # --- Views --- @@ -151,6 +168,30 @@ def index(): # Directly render the Clubhouse SPA return render_template('teams/clubhouse.html') +@bp.route('/performance') +def performance(): + return render_template( + 'teams/performance.html', + seasons=NarrativeService.get_seasons(), + **TeamPerformanceService.get_dashboard(), + ) + +@bp.route('/roster-role', methods=['POST']) +def roster_role(): + if not session.get('is_admin'): + return "Unauthorized", 403 + steam_id = request.form.get('steam_id') + member_role = request.form.get('member_role') + try: + RosterVersionService.update_current_member_role( + steam_id, + member_role, + ) + flash('Roster role updated.', 'success') + except ValueError as exc: + flash(str(exc), 'error') + return redirect(url_for('teams.performance')) + # Deprecated routes (kept for compatibility if needed, but hidden) @bp.route('/list') def list_view(): @@ -228,5 +269,5 @@ def detail(lineup_id): return render_template('teams/detail.html', lineup=lineup, players=players, agg_stats=agg_stats, shared_matches=shared_matches, radar_data=radar_data) except Exception as e: - import traceback - return f"
{traceback.format_exc()}
", 500 + logger.exception("Lineup detail failed for lineup_id=%s", lineup_id) + return "Unable to load lineup", 500 diff --git a/web/services/integrity_service.py b/web/services/integrity_service.py index 727662d..b26c77f 100644 --- a/web/services/integrity_service.py +++ b/web/services/integrity_service.py @@ -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, + ) diff --git a/web/services/narrative_service.py b/web/services/narrative_service.py new file mode 100644 index 0000000..b195dd8 --- /dev/null +++ b/web/services/narrative_service.py @@ -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 diff --git a/web/services/roster_version_service.py b/web/services/roster_version_service.py new file mode 100644 index 0000000..abc85bb --- /dev/null +++ b/web/services/roster_version_service.py @@ -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() diff --git a/web/services/team_performance_service.py b/web/services/team_performance_service.py new file mode 100644 index 0000000..37c4540 --- /dev/null +++ b/web/services/team_performance_service.py @@ -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(), + } + diff --git a/web/templates/awards/index.html b/web/templates/awards/index.html new file mode 100644 index 0000000..a2e238d --- /dev/null +++ b/web/templates/awards/index.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} + +{% block title %}战队荣誉 - YRTV{% endblock %} + +{% block content %} +
+
+

YRTV Awards

+

周期最佳与荣誉榜

+

完全基于已导入比赛。月、季度和年度奖项设有最低场次门槛。

+
+ +
+ 全部 + {% for key, label in award_types.items() %} + {{ label }} + {% endfor %} +
+ +
+
+

荣誉榜

+ +
+ +
+ {% for award in awards %} +
+
+
+
🏆
+
+
{{ award.award_label }}
+ {{ award.identity.username or award.steam_id_64 }} +
{{ award.period_key }} · {{ award.matches }} 场
+
+
+
+
Rating
{{ '%.2f'|format(award.avg_rating) }}
+
K/D
{{ '%.2f'|format(award.avg_kd) }}
+
ADR
{{ '%.1f'|format(award.avg_adr) }}
+
Win
{{ '%.0f%%'|format(award.win_rate * 100) }}
+
+
+
+ {% else %} +
当前筛选没有满足最低场次的奖项。
+ {% endfor %} +
+
+
+{% endblock %} diff --git a/web/templates/base.html b/web/templates/base.html index fdbc9df..5368fc0 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -46,6 +46,8 @@ 首页 比赛 玩家 + 报告 + 荣誉 战队 对手 战术 @@ -84,6 +86,8 @@ 首页 比赛 玩家 + 报告 + 荣誉 战队 对手 战术 diff --git a/web/templates/matches/_post_match_report.html b/web/templates/matches/_post_match_report.html new file mode 100644 index 0000000..020aeea --- /dev/null +++ b/web/templates/matches/_post_match_report.html @@ -0,0 +1,68 @@ +{% if post_match_report %} +
+
+
+
+ + {{ 'VICTORY' if post_match_report.is_win else 'DEFEAT' }} + +

赛后报告

+
+

{{ post_match_report.summary_text }}

+
+
+
{{ '%.2f'|format(post_match_report.team_avg_rating) }}
Team Rating
+
{{ '%.1f'|format(post_match_report.team_avg_adr) }}
Team ADR
+
{{ post_match_report.record_break_count }}
Records
+
+
+ +
+
+
Match MVP
+ {{ post_match_report.mvp.username or post_match_report.mvp_steam_id }} +
Rating {{ '%.2f'|format(post_match_report.mvp_rating) }}
+
+
+
Form Improver
+ {% if post_match_report.improver_steam_id %} + {{ post_match_report.improver.username or post_match_report.improver_steam_id }} +
Rating {{ '%+.2f'|format(post_match_report.improver_delta) }}
+ {% else %} +
近期样本不足
+ {% endif %} +
+
+
Strongest Duo
+ {% if post_match_report.strongest_duo %} +
+ {% for player in post_match_report.strongest_duo.players %} + {{ player.username }}{% if not loop.last %} + {% endif %} + {% endfor %} +
+
Rating {{ '%.2f'|format(post_match_report.strongest_duo.avg_rating) }}
+ {% else %} +
本场无二人组
+ {% endif %} +
+
+ +
+ + + + {% for player in post_match_report.players %} + + + + + + + + + {% endfor %} + +
PlayerRatingvs Prior 20ADR ΔStatusRecord
{{ player.identity.username or player.steam_id_64 }}{{ '%.2f'|format(player.rating or 0) }}{{ '%+.2f'|format(player.rating_delta) if player.rating_delta is not none else 'N/A' }}{{ '%+.1f'|format(player.adr_delta) if player.adr_delta is not none else 'N/A' }}{{ player.performance_text }}{% for label in player.record_labels %}{{ label }}{% else %}-{% endfor %}
+
+
+{% endif %} diff --git a/web/templates/matches/detail.html b/web/templates/matches/detail.html index cb36129..78e6bd0 100644 --- a/web/templates/matches/detail.html +++ b/web/templates/matches/detail.html @@ -43,6 +43,8 @@ + {% include "matches/_post_match_report.html" %} +
diff --git a/web/templates/players/_professional_identity.html b/web/templates/players/_professional_identity.html new file mode 100644 index 0000000..70d7d1d --- /dev/null +++ b/web/templates/players/_professional_identity.html @@ -0,0 +1,45 @@ +
+
+
+
Professional Identity
+
+ {{ professional_identity.member_role or 'member' }} + {{ professional_identity.roster_version or 'Active Roster' }} +
+
+ {{ professional_identity.total_matches or 0 }} 场效力 · 队内 Percentile {{ '%.0f%%'|format(professional_identity.tier_percentile or 0) }} +
+
+ +
+
+
代表武器
+
{{ professional_identity.core_top_weapon or 'N/A' }}
+
+
+
最佳地图
+
{{ professional_identity.meta_map_best_map or 'N/A' }}
+
+
+
荣誉
+
{{ professional_identity.award_count or 0 }}
+
+
+
月/季/年最佳
+
+ {{ professional_identity.awards_by_type.get('monthly', 0) }}/{{ professional_identity.awards_by_type.get('quarterly', 0) }}/{{ professional_identity.awards_by_type.get('yearly', 0) }} +
+
+
+
+ + {% if honors.awards %} +
+ {% for award in honors.awards[:8] %} + + 🏆 {{ award.period_key }} {{ award.award_label }} + + {% endfor %} +
+ {% endif %} +
diff --git a/web/templates/players/profile.html b/web/templates/players/profile.html index df7c829..3cc5b02 100644 --- a/web/templates/players/profile.html +++ b/web/templates/players/profile.html @@ -148,6 +148,8 @@
+ {% include "players/_professional_identity.html" %} +
diff --git a/web/templates/reports/index.html b/web/templates/reports/index.html new file mode 100644 index 0000000..b6921a3 --- /dev/null +++ b/web/templates/reports/index.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}赛后报告 - YRTV{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/web/templates/teams/clubhouse.html b/web/templates/teams/clubhouse.html index 5858143..0619955 100644 --- a/web/templates/teams/clubhouse.html +++ b/web/templates/teams/clubhouse.html @@ -13,6 +13,9 @@
+ + Team Career + {% if session.get('is_admin') %}