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
+38 -8
View File
@@ -1,8 +1,8 @@
# YRTV 2.0.0 Alpha # YRTV 2.0.0 Beta
YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行榜,而是让队员拥有类似职业选手的个人主页,并为战队提供比赛档案、队内比较、阵容分析、对手情报和战术工具。 YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行榜,而是让队员拥有类似职业选手的个人主页,并为战队提供比赛档案、队内比较、阵容分析、对手情报和战术工具。
当前 Alpha 版本已经建立可重复运行的数据流水线、数据库治理职业主页数据集市,重点服务 active roster。 当前 Beta 版本已经建立可重复运行的数据流水线、数据库治理职业主页和可追溯的阵容版本模型,重点服务 active roster。
## 当前基线 ## 当前基线
@@ -18,8 +18,15 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
- 236 条武器统计 - 236 条武器统计
- 54 条时间窗口统计 - 54 条时间窗口统计
- 54 条个人职业纪录 - 54 条个人职业纪录
- 24 项自动化测试通过 - 36 个 roster 二人组
- 28 项数据完整性检查通过 - 200 个实际 2-5 人阵容组合
- 208 份赛后报告
- 885 份队员赛后对比
- 203 次职业纪录刷新事件
- 2 个战队赛季
- 111 个日/周/月/季/年度奖项
- 31 项自动化测试通过
- 39 项数据完整性检查通过
数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。 数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。
@@ -39,6 +46,9 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
### 比赛中心 ### 比赛中心
- 比赛列表、地图、比分、平均 ELO 和己方结果 - 比赛列表、地图、比分、平均 ELO 和己方结果
- 自动赛后报告与队内 MVP
- 相对此前 20 场的 Rating、K/D、ADR 变化
- 本场纪录刷新、状态标签和最强二人组
- Active roster 与 Party 识别 - Active roster 与 Party 识别
- 双方玩家表现和 Rating 排序 - 双方玩家表现和 Rating 排序
- Head-to-head 击杀矩阵 - Head-to-head 击杀矩阵
@@ -48,12 +58,25 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
### 战队与战术 ### 战队与战术
- Active roster 管理 - Active roster 管理
- Roster version 生效区间和历史版本
- Starter、substitute、member 角色管理
- 战队履历、赛季战绩和地图表现
- 实际同队二人组与 2-5 人阵容组合
- 玩家搜索、签入和移出 - 玩家搜索、签入和移出
- 2-5 人同队比赛与 Chemistry 分析 - 2-5 人同队比赛与 Chemistry 分析
- 对手档案和真实交手记录 - 对手档案和真实交手记录
- 地图战术板、阵容数据中心和经济工具 - 地图战术板、阵容数据中心和经济工具
- Wiki、玩家标签、备注和评论 - Wiki、玩家标签、备注和评论
### 赛季与荣誉
- 按日历年度生成战队赛季
- 赛季战绩、Rating、最佳地图和赛季选手
- 单日最佳、星期最佳、月度最佳、季度最佳、年度最佳
- 周/月/季/年奖项使用最低场次门槛
- 个人主页展示荣誉数量和近期奖章
- 每次职业纪录刷新保留时间和对应比赛
### 数据运营 ### 数据运营
- Admin 上传 `iframe_network.json` - Admin 上传 `iframe_network.json`
@@ -183,12 +206,19 @@ Flask services and player profiles
- `dm_player_weapon_stats` - `dm_player_weapon_stats`
- `dm_player_period_stats` - `dm_player_period_stats`
- `dm_player_records` - `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:应用状态 ### Web:应用状态
- 数据库:`database/Web/Web_App.sqlite` - 数据库:`database/Web/Web_App.sqlite`
- Schema`database/Web/schema.sql` - Schema`database/Web/schema.sql`
- 当前 schema version2 - 当前 schema version3
- 保存 lineup、玩家备注、评论、Wiki、战术板、导入登记和 ETL 作业 - 保存 lineup、玩家备注、评论、Wiki、战术板、导入登记和 ETL 作业
## 数据库治理 ## 数据库治理
@@ -253,17 +283,17 @@ yrtv/
└── wsgi.py └── wsgi.py
``` ```
## Alpha 限制 ## Beta 限制
- 当前主要数据源为 5E iframe 网络响应。 - 当前主要数据源为 5E iframe 网络响应。
- 不包含自动网页下载器和 Demo parser。 - 不包含自动网页下载器和 Demo parser。
- 认证仍是单一 Admin Token,适合私人部署,不适合开放注册。 - 认证仍是单一 Admin Token,适合私人部署,不适合开放注册。
- SQLite 适合当前单战队规模,不面向高并发多租户。 - SQLite 适合当前单战队规模,不面向高并发多租户。
- 部分高级空间能力需要地图边界、路径和 Demo 数据,当前显示 `N/A` - 部分高级空间能力需要地图边界、路径和 Demo 数据,当前显示 `N/A`
- `StatsService` 仍保留部分兼容逻辑,后续会继续按领域拆分 - `StatsService` 仍保留部分兼容逻辑;新功能已拆入 Player Profile、Team Performance、Roster Version 等领域服务
## 版本 ## 版本
当前版本:`2.0.0 Alpha` 当前版本:`2.0.0 Beta`
这一版本的目标是建立可信、可恢复、可持续导入的私人战队 HLTV 基线,而不是冻结产品功能。 这一版本的目标是建立可信、可恢复、可持续导入的私人战队 HLTV 基线,而不是冻结产品功能。
-65
View File
@@ -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做了数据清洗和特征工程,构建了玩家画像
**优化表述**:通过PythonPandas/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高效落地**。
**技能关键词**PythonPandas/NumPy/多线程/矢量化运算)、SQLite、SQL、ETL自动化、数据仓库设计、特征工程、Playwright、Flask、团队管理、电竞赛事数据分析
### 五、高端项目启发:从电竞数据项目到企业级数据项目的业务价值思维
你的CS2项目已经具备企业级高端数据项目的核心雏形,高端项目对**业务价值**的要求会更强调**「规模化、可复用、商业变现」**,核心启发有3点:
1. **从“单战队价值”到“行业规模化价值”**:企业级项目不仅服务单一业务方,而是能复用到整个行业/公司多业务线,比如你的CS2特征模型可从单战队拓展至青训选手选拔、赛事直播数据可视化、电竞俱乐部数据中台搭建;
2. **从“战术价值”到“商业价值”**:高端项目需将数据价值转化为**可量化的商业收益**,比如电竞数据平台可通过为赛事方/俱乐部提供付费数据分析服务、为品牌方提供选手粉丝画像实现商业变现,企业中则是将数据成果转化为GMV提升、营收增长、获客成本降低;
3. **从“人工落地”到“自动化决策”**:你的项目实现了“数据支撑战术决策”,高端项目会进一步实现**“数据自动化输出决策建议”**,比如通过Python搭建实时战术推荐模型,比赛中根据战局动态输出最优站位/道具使用建议,企业中则是智能推荐、自动化风控、精准营销等场景。
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 argparse
import concurrent.futures import concurrent.futures
from collections import defaultdict, deque from collections import defaultdict, deque
from itertools import combinations
from typing import Optional from typing import Optional
# Setup logging # 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] processed_ids = [str(row[0]) for row in players]
_update_percentiles(conn_l3, processed_ids) _update_percentiles(conn_l3, processed_ids)
_rebuild_auxiliary_marts(conn_l2, 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] quick_check = conn_l3.execute("PRAGMA quick_check").fetchone()[0]
if quick_check != 'ok': if quick_check != 'ok':
@@ -778,6 +787,153 @@ def _calculate_record_rows(history_rows):
return result 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): def _update_percentiles(conn_l3, steam_ids):
"""Calculate a real percentile among eligible players in this build.""" """Calculate a real percentile among eligible players in this build."""
if not steam_ids: 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 CREATE INDEX IF NOT EXISTS idx_player_records_player
ON dm_player_records(steam_id_64, record_key); 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 -- Schema Summary
-- ============================================================================ -- ============================================================================
@@ -443,4 +602,11 @@ ON dm_player_records(steam_id_64, record_key);
-- dm_player_weapon_stats: Weapon usage statistics -- dm_player_weapon_stats: Weapon usage statistics
-- dm_player_period_stats: Career/recent time-window aggregations -- dm_player_period_stats: Career/recent time-window aggregations
-- dm_player_records: Career record values and source matches -- 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) ON team_lineups(is_active)
WHERE is_active = 1; 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 ( CREATE TABLE IF NOT EXISTS wiki_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE, path TEXT NOT NULL UNIQUE,
+268 -13
View File
@@ -44,9 +44,12 @@ class ApplicationIntegrationTests(unittest.TestCase):
paths = [ paths = [
'/', '/',
'/matches/', '/matches/',
'/reports/',
'/awards/',
'/players/', '/players/',
f'/players/{self.roster_ids[0]}', f'/players/{self.roster_ids[0]}',
'/teams/', '/teams/',
'/teams/performance',
'/tactics/', '/tactics/',
'/opponents/', '/opponents/',
] ]
@@ -56,6 +59,47 @@ class ApplicationIntegrationTests(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertGreater(len(response.data), 100) 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): def test_admin_integrity_page_and_json_render(self):
with self.client.session_transaction() as session: with self.client.session_transaction() as session:
session['is_admin'] = True session['is_admin'] = True
@@ -93,19 +137,26 @@ class ApplicationIntegrationTests(unittest.TestCase):
""" """
).fetchone()[0] ).fetchone()[0]
response = self.client.post( upload = io.BytesIO(raw.encode('utf-8'))
'/admin/import-match', try:
data={ response = self.client.post(
'capture': ( '/admin/import-match',
io.BytesIO(raw.encode('utf-8')), data={
'iframe_network.json', 'capture': (
), upload,
}, 'iframe_network.json',
content_type='multipart/form-data', ),
follow_redirects=True, },
) content_type='multipart/form-data',
self.assertEqual(response.status_code, 200) follow_redirects=True,
self.assertIn(b'already imported with identical data', response.data) )
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): def test_player_search_works_across_l2_and_l3(self):
response = self.client.get('/players/?search=jAck') response = self.client.get('/players/?search=jAck')
@@ -238,11 +289,18 @@ class L3MartBuilderTests(unittest.TestCase):
) )
l3.commit() l3.commit()
for table in ( 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_match_history',
'dm_player_map_stats', 'dm_player_map_stats',
'dm_player_period_stats', 'dm_player_period_stats',
'dm_player_record_events',
'dm_player_records', 'dm_player_records',
'dm_player_weapon_stats', 'dm_player_weapon_stats',
'dm_team_season_stats',
): ):
count = l3.execute( count = l3.execute(
f'SELECT COUNT(*) FROM {table}' f'SELECT COUNT(*) FROM {table}'
@@ -615,6 +673,203 @@ class DatabaseGovernanceTests(unittest.TestCase):
l3.close() l3.close()
l2.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__': if __name__ == '__main__':
unittest.main() unittest.main()
+16 -1
View File
@@ -13,9 +13,22 @@ def create_app(config_object=Config):
app.config.from_object(config_object) app.config.from_object(config_object)
initialize_web_db() initialize_web_db()
from web.services.roster_version_service import RosterVersionService
RosterVersionService.ensure_initial_version()
app.teardown_appcontext(close_dbs) 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(main.bp)
app.register_blueprint(matches.bp) app.register_blueprint(matches.bp)
app.register_blueprint(players.bp) app.register_blueprint(players.bp)
@@ -24,6 +37,8 @@ def create_app(config_object=Config):
app.register_blueprint(admin.bp) app.register_blueprint(admin.bp)
app.register_blueprint(wiki.bp) app.register_blueprint(wiki.bp)
app.register_blueprint(opponents.bp) app.register_blueprint(opponents.bp)
app.register_blueprint(reports.bp)
app.register_blueprint(awards.bp)
return app return app
+1 -1
View File
@@ -13,7 +13,7 @@ class Config:
DB_L3_PATH = str(L3_DB) DB_L3_PATH = str(L3_DB)
DB_WEB_PATH = str(WEB_DB) DB_WEB_PATH = str(WEB_DB)
DB_WEB_SCHEMA_PATH = str(WEB_SCHEMA) DB_WEB_SCHEMA_PATH = str(WEB_SCHEMA)
WEB_SCHEMA_VERSION = 2 WEB_SCHEMA_VERSION = 3
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 MAX_CONTENT_LENGTH = 5 * 1024 * 1024
SQLITE_TIMEOUT_SECONDS = 15 SQLITE_TIMEOUT_SECONDS = 15
+1 -1
View File
@@ -83,7 +83,7 @@ def initialize_web_db():
""", """,
[ [
Config.WEB_SCHEMA_VERSION, Config.WEB_SCHEMA_VERSION,
'ETL jobs, match imports and active lineup governance', 'Roster versions, member roles and team performance governance',
], ],
) )
db.commit() db.commit()
+19
View File
@@ -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,
)
+3 -1
View File
@@ -1,5 +1,6 @@
from flask import Blueprint, render_template, request, Response from flask import Blueprint, render_template, request, Response
from web.services.stats_service import StatsService from web.services.stats_service import StatsService
from web.services.narrative_service import NarrativeService
from web.config import Config from web.config import Config
import json import json
@@ -108,7 +109,8 @@ def detail(match_id):
rounds=rounds, rounds=rounds,
h2h_matrix=h2h_matrix, h2h_matrix=h2h_matrix,
round_details=round_details, 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('/<match_id>/raw') @bp.route('/<match_id>/raw')
def raw_json(match_id): def raw_json(match_id):
+9 -1
View File
@@ -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.stats_service import StatsService
from web.services.feature_service import FeatureService from web.services.feature_service import FeatureService
from web.services.player_profile_service import PlayerProfileService from web.services.player_profile_service import PlayerProfileService
from web.services.narrative_service import NarrativeService
from web.services.web_service import WebService from web.services.web_service import WebService
from web.database import execute_db, query_db from web.database import execute_db, query_db
from web.config import Config from web.config import Config
from datetime import datetime from datetime import datetime
import os import os
import json import json
import logging
bp = Blueprint('players', __name__, url_prefix='/players') bp = Blueprint('players', __name__, url_prefix='/players')
logger = logging.getLogger(__name__)
ALLOWED_AVATAR_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'} ALLOWED_AVATAR_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
@bp.route('/') @bp.route('/')
@@ -64,7 +67,10 @@ def detail(steam_id):
flash('Avatar updated successfully.', 'success') flash('Avatar updated successfully.', 'success')
except Exception as e: 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') flash('Error uploading avatar.', 'error')
WebService.update_player_metadata(steam_id, notes=notes) WebService.update_player_metadata(steam_id, notes=notes)
@@ -197,6 +203,8 @@ def detail(steam_id):
map_stats=map_stats_list, map_stats=map_stats_list,
period_stats=period_stats, period_stats=period_stats,
records=records, records=records,
professional_identity=NarrativeService.get_player_identity(steam_id),
honors=NarrativeService.get_player_honors(steam_id),
l2_stats=l2_stats, l2_stats=l2_stats,
side_stats=side_stats) side_stats=side_stats)
+15
View File
@@ -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),
)
+58 -17
View File
@@ -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.web_service import WebService
from web.services.stats_service import StatsService from web.services.stats_service import StatsService
from web.services.feature_service import FeatureService 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 json
import logging
bp = Blueprint('teams', __name__, url_prefix='/teams') bp = Blueprint('teams', __name__, url_prefix='/teams')
logger = logging.getLogger(__name__)
# --- API Endpoints --- # --- API Endpoints ---
@bp.route('/api/search') @bp.route('/api/search')
def api_search(): def api_search():
query = request.args.get('q', '').strip() # Strip whitespace query = request.args.get('q', '').strip() # Strip whitespace
print(f"DEBUG: Search Query Received: '{query}'") # Debug Log
if len(query) < 2: if len(query) < 2:
return jsonify([]) return jsonify([])
@@ -20,9 +24,7 @@ def api_search():
# Support sorting by matches for better "Find Player" experience # Support sorting by matches for better "Find Player" experience
sort_by = request.args.get('sort', 'matches') 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) 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 # Format for frontend
results = [] results = []
@@ -62,7 +64,6 @@ def api_search():
results.sort(key=lambda x: x['matches'], reverse=True) results.sort(key=lambda x: x['matches'], reverse=True)
print(f"DEBUG: Returning {len(results)} results")
return jsonify(results) return jsonify(results)
@bp.route('/api/roster', methods=['GET', 'POST']) @bp.route('/api/roster', methods=['GET', 'POST'])
@@ -88,6 +89,7 @@ def api_roster():
current_ids = json.loads(target_team['player_ids_json']) current_ids = json.loads(target_team['player_ids_json'])
except: except:
pass pass
previous_ids = list(current_ids)
if action == 'add': if action == 'add':
if steam_id not in current_ids: if steam_id not in current_ids:
@@ -95,20 +97,36 @@ def api_roster():
elif action == 'remove': elif action == 'remove':
if steam_id in current_ids: if steam_id in current_ids:
current_ids.remove(steam_id) 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(
WebService.save_lineup(target_team['name'], target_team['description'], current_ids, lineup_id=target_team['id']) 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}) return jsonify({'status': 'success', 'roster': current_ids})
# GET: Return detailed player info # GET: Return detailed player info
try: 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 = target_team.get('player_ids_json', '[]')
p_ids = json.loads(p_ids_json) p_ids = json.loads(p_ids_json)
print(f"DEBUG: Player IDs: {p_ids}")
players = StatsService.get_players_by_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 # Add extra stats needed for cards
enriched = [] enriched = []
@@ -130,9 +148,10 @@ def api_roster():
enriched.append(p_dict) enriched.append(p_dict)
except Exception as inner_e: except Exception as inner_e:
print(f"ERROR: Processing player failed: {inner_e}") logger.exception(
import traceback "Failed to enrich roster player %s",
traceback.print_exc() p.get('steam_id_64') if hasattr(p, 'get') else 'unknown',
)
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
@@ -140,9 +159,7 @@ def api_roster():
'roster': enriched 'roster': enriched
}) })
except Exception as e: except Exception as e:
print(f"CRITICAL ERROR in api_roster: {e}") logger.exception("Roster API failed")
import traceback
traceback.print_exc()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
# --- Views --- # --- Views ---
@@ -151,6 +168,30 @@ def index():
# Directly render the Clubhouse SPA # Directly render the Clubhouse SPA
return render_template('teams/clubhouse.html') 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) # Deprecated routes (kept for compatibility if needed, but hidden)
@bp.route('/list') @bp.route('/list')
def list_view(): 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) 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: except Exception as e:
import traceback logger.exception("Lineup detail failed for lineup_id=%s", lineup_id)
return f"<pre>{traceback.format_exc()}</pre>", 500 return "Unable to load lineup", 500
+106
View File
@@ -246,12 +246,19 @@ class IntegrityService:
@staticmethod @staticmethod
def _check_l3(db, roster_ids, checks, counts): def _check_l3(db, roster_ids, checks, counts):
required_tables = { required_tables = {
'dm_duo_stats',
'dm_match_player_reports',
'dm_match_reports',
'dm_player_features', 'dm_player_features',
'dm_player_awards',
'dm_player_match_history', 'dm_player_match_history',
'dm_player_map_stats', 'dm_player_map_stats',
'dm_player_period_stats', 'dm_player_period_stats',
'dm_player_record_events',
'dm_player_records', 'dm_player_records',
'dm_player_weapon_stats', 'dm_player_weapon_stats',
'dm_lineup_stats',
'dm_team_season_stats',
} }
missing = sorted(required_tables - IntegrityService._table_names(db)) missing = sorted(required_tables - IntegrityService._table_names(db))
IntegrityService._check( IntegrityService._check(
@@ -281,6 +288,42 @@ class IntegrityService:
counts['l3_records'] = db.execute( counts['l3_records'] = db.execute(
'SELECT COUNT(*) FROM dm_player_records' 'SELECT COUNT(*) FROM dm_player_records'
).fetchone()[0] ).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: if roster_ids:
placeholders = ','.join('?' for _ in roster_ids) placeholders = ','.join('?' for _ in roster_ids)
@@ -323,6 +366,21 @@ class IntegrityService:
f'{actual_history}/{expected_history} player-match rows materialized', f'{actual_history}/{expected_history} player-match rows materialized',
actual_history, 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( score_rows = db.execute(
f""" f"""
@@ -378,6 +436,13 @@ class IntegrityService:
('l3_weapons', 'Player weapon stats mart'), ('l3_weapons', 'Player weapon stats mart'),
('l3_periods', 'Player period stats mart'), ('l3_periods', 'Player period stats mart'),
('l3_records', 'Player records 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] value = counts[key]
IntegrityService._check( IntegrityService._check(
@@ -424,6 +489,8 @@ class IntegrityService:
'schema_migrations', 'schema_migrations',
'strategy_boards', 'strategy_boards',
'team_lineups', 'team_lineups',
'team_roster_members',
'team_roster_versions',
'wiki_pages', 'wiki_pages',
} }
missing = sorted(required_tables - IntegrityService._table_names(db)) missing = sorted(required_tables - IntegrityService._table_names(db))
@@ -509,3 +576,42 @@ class IntegrityService:
f'{active_count} active lineups configured', f'{active_count} active lineups configured',
active_count, active_count,
) )
current_versions = db.execute(
"""
SELECT id
FROM team_roster_versions
WHERE is_current = 1
"""
).fetchall()
current_member_count = 0
if len(current_versions) == 1:
current_member_count = db.execute(
"""
SELECT COUNT(*)
FROM team_roster_members
WHERE roster_version_id = ?
""",
[current_versions[0]['id']],
).fetchone()[0]
counts['roster_versions'] = db.execute(
'SELECT COUNT(*) FROM team_roster_versions'
).fetchone()[0]
counts['current_roster_members'] = current_member_count
IntegrityService._check(
checks,
'Current roster version',
'pass' if len(current_versions) == 1 else 'fail',
f'{len(current_versions)} current roster versions',
len(current_versions),
)
IntegrityService._check(
checks,
'Roster version membership',
'pass' if current_member_count == counts.get('active_roster', 0) else 'fail',
(
f"{current_member_count}/"
f"{counts.get('active_roster', 0)} active members versioned"
),
current_member_count,
)
+327
View File
@@ -0,0 +1,327 @@
import json
from web.database import query_db
class NarrativeService:
AWARD_TYPE_LABELS = {
'daily': '单日最佳',
'weekly': '星期最佳',
'monthly': '月度最佳',
'quarterly': '季度最佳',
'yearly': '年度最佳',
}
PERFORMANCE_LABELS = {
'surge': '状态爆发',
'above_form': '高于近期',
'stable': '稳定发挥',
'below_form': '低于近期',
'slump': '状态低迷',
'insufficient_sample': '样本不足',
}
RECORD_LABELS = {
'highest_rating': '最高 Rating',
'most_kills': '最多击杀',
'highest_adr': '最高 ADR',
'highest_kd': '最高 K/D',
'most_headshots': '最多爆头',
}
@staticmethod
def _identity_map(steam_ids):
steam_ids = sorted({str(value) for value in steam_ids if value})
if not steam_ids:
return {}
placeholders = ','.join('?' for _ in steam_ids)
rows = query_db(
'l2',
f"""
SELECT steam_id_64, username, avatar_url
FROM dim_players
WHERE steam_id_64 IN ({placeholders})
""",
steam_ids,
)
return {str(row['steam_id_64']): dict(row) for row in rows}
@staticmethod
def get_match_report(match_id):
report = query_db(
'l3',
'SELECT * FROM dm_match_reports WHERE match_id = ?',
[match_id],
one=True,
)
if not report:
return None
result = dict(report)
player_rows = query_db(
'l3',
"""
SELECT *
FROM dm_match_player_reports
WHERE match_id = ?
ORDER BY rating DESC
""",
[match_id],
)
player_reports = [dict(row) for row in player_rows]
steam_ids = [row['steam_id_64'] for row in player_reports]
steam_ids.extend([
result.get('mvp_steam_id'),
result.get('improver_steam_id'),
])
duo = json.loads(result.get('strongest_duo_json') or 'null')
if duo:
steam_ids.extend(duo.get('steam_ids') or [])
identities = NarrativeService._identity_map(steam_ids)
for player in player_reports:
player['identity'] = identities.get(
str(player['steam_id_64']),
{'username': player['steam_id_64']},
)
player['performance_text'] = NarrativeService.PERFORMANCE_LABELS.get(
player['performance_label'],
player['performance_label'],
)
record_keys = json.loads(player['record_keys_json'] or '[]')
player['record_labels'] = [
NarrativeService.RECORD_LABELS.get(key, key)
for key in record_keys
]
result['players'] = player_reports
result['mvp'] = identities.get(str(result.get('mvp_steam_id')), {})
result['improver'] = identities.get(
str(result.get('improver_steam_id')),
{},
)
if duo:
duo['players'] = [
identities.get(str(steam_id), {'username': steam_id})
for steam_id in duo.get('steam_ids', [])
]
result['strongest_duo'] = duo
return result
@staticmethod
def list_match_reports(limit=30):
rows = query_db(
'l3',
"""
SELECT *
FROM dm_match_reports
ORDER BY match_date DESC, match_id DESC
LIMIT ?
""",
[limit],
)
identities = NarrativeService._identity_map(
[row['mvp_steam_id'] for row in rows]
)
result = []
for row in rows:
item = dict(row)
item['mvp'] = identities.get(str(item['mvp_steam_id']), {})
result.append(item)
return result
@staticmethod
def list_awards(award_type=None, limit=100):
args = []
where = ''
if award_type in NarrativeService.AWARD_TYPE_LABELS:
where = 'WHERE award_type = ?'
args.append(award_type)
args.append(limit)
rows = query_db(
'l3',
f"""
SELECT *
FROM dm_player_awards
{where}
ORDER BY period_start DESC,
CASE award_type
WHEN 'yearly' THEN 1
WHEN 'quarterly' THEN 2
WHEN 'monthly' THEN 3
WHEN 'weekly' THEN 4
ELSE 5
END
LIMIT ?
""",
args,
)
identities = NarrativeService._identity_map(
[row['steam_id_64'] for row in rows]
)
result = []
for row in rows:
item = dict(row)
item['identity'] = identities.get(str(item['steam_id_64']), {})
item['award_label'] = NarrativeService.AWARD_TYPE_LABELS.get(
item['award_type'],
item['award_type'],
)
result.append(item)
return result
@staticmethod
def get_award_summary():
rows = query_db(
'l3',
"""
SELECT
steam_id_64,
COUNT(*) AS awards,
SUM(CASE WHEN award_type = 'daily' THEN 1 ELSE 0 END) AS daily,
SUM(CASE WHEN award_type = 'weekly' THEN 1 ELSE 0 END) AS weekly,
SUM(CASE WHEN award_type = 'monthly' THEN 1 ELSE 0 END) AS monthly,
SUM(CASE WHEN award_type = 'quarterly' THEN 1 ELSE 0 END) AS quarterly,
SUM(CASE WHEN award_type = 'yearly' THEN 1 ELSE 0 END) AS yearly
FROM dm_player_awards
GROUP BY steam_id_64
ORDER BY yearly DESC, quarterly DESC, monthly DESC,
weekly DESC, daily DESC
""",
)
identities = NarrativeService._identity_map(
[row['steam_id_64'] for row in rows]
)
result = []
for row in rows:
item = dict(row)
item['identity'] = identities.get(str(item['steam_id_64']), {})
result.append(item)
return result
@staticmethod
def get_seasons():
rows = query_db(
'l3',
"""
SELECT *
FROM dm_team_season_stats
ORDER BY season_key DESC
""",
)
identities = NarrativeService._identity_map(
[row['top_player_steam_id'] for row in rows]
)
result = []
for row in rows:
item = dict(row)
item['top_player'] = identities.get(
str(item['top_player_steam_id']),
{},
)
result.append(item)
return result
@staticmethod
def get_player_honors(steam_id, limit=20):
rows = query_db(
'l3',
"""
SELECT *
FROM dm_player_awards
WHERE steam_id_64 = ?
ORDER BY period_start DESC
LIMIT ?
""",
[steam_id, limit],
)
awards = []
for row in rows:
item = dict(row)
item['award_label'] = NarrativeService.AWARD_TYPE_LABELS.get(
item['award_type'],
item['award_type'],
)
awards.append(item)
record_rows = query_db(
'l3',
"""
SELECT *
FROM dm_player_record_events
WHERE steam_id_64 = ?
ORDER BY match_date DESC
LIMIT ?
""",
[steam_id, limit],
)
record_events = []
for row in record_rows:
item = dict(row)
item['record_label'] = NarrativeService.RECORD_LABELS.get(
item['record_key'],
item['record_key'],
)
record_events.append(item)
return {
'awards': awards,
'award_count': len(awards),
'record_events': record_events,
}
@staticmethod
def get_player_identity(steam_id):
feature = query_db(
'l3',
"""
SELECT
total_matches,
first_match_date,
last_match_date,
core_top_weapon,
meta_map_best_map,
tier_percentile
FROM dm_player_features
WHERE steam_id_64 = ?
""",
[steam_id],
one=True,
)
roster = query_db(
'web',
"""
SELECT
member.member_role,
version.name AS roster_version,
(
SELECT MIN(history_version.effective_from)
FROM team_roster_members history_member
JOIN team_roster_versions history_version
ON history_version.id = history_member.roster_version_id
WHERE history_member.steam_id_64 = member.steam_id_64
) AS effective_from
FROM team_roster_members member
JOIN team_roster_versions version
ON version.id = member.roster_version_id
WHERE member.steam_id_64 = ?
ORDER BY version.is_current DESC, version.effective_from DESC
LIMIT 1
""",
[steam_id],
one=True,
)
awards = query_db(
'l3',
"""
SELECT award_type, COUNT(*) AS count
FROM dm_player_awards
WHERE steam_id_64 = ?
GROUP BY award_type
""",
[steam_id],
)
result = dict(feature) if feature else {}
if roster:
result.update(dict(roster))
result['awards_by_type'] = {
row['award_type']: row['count'] for row in awards
}
result['award_count'] = sum(result['awards_by_type'].values())
return result
+249
View File
@@ -0,0 +1,249 @@
import json
import sqlite3
import time
from web.config import Config
class RosterVersionService:
@staticmethod
def _connect_web(web_db_path=None):
db = sqlite3.connect(web_db_path or Config.DB_WEB_PATH, timeout=30)
db.row_factory = sqlite3.Row
db.execute('PRAGMA foreign_keys = ON')
return db
@staticmethod
def _normalize_ids(player_ids):
seen = set()
result = []
for value in player_ids or []:
steam_id = str(value).strip()
if steam_id and steam_id not in seen:
seen.add(steam_id)
result.append(steam_id)
return result
@staticmethod
def ensure_initial_version(web_db_path=None, l2_db_path=None):
web = RosterVersionService._connect_web(web_db_path)
try:
current = web.execute(
"""
SELECT id
FROM team_roster_versions
WHERE is_current = 1
LIMIT 1
"""
).fetchone()
if current:
return int(current['id'])
lineup = web.execute(
"""
SELECT player_ids_json
FROM team_lineups
WHERE is_active = 1
ORDER BY created_at DESC, id DESC
LIMIT 1
"""
).fetchone()
if not lineup:
return None
try:
player_ids = RosterVersionService._normalize_ids(
json.loads(lineup['player_ids_json'] or '[]')
)
except (TypeError, json.JSONDecodeError):
return None
if not player_ids:
return None
effective_from = int(time.time())
l2_path = l2_db_path or Config.DB_L2_PATH
l2 = sqlite3.connect(l2_path)
try:
placeholders = ','.join('?' for _ in player_ids)
row = l2.execute(
f"""
SELECT MIN(m.start_time)
FROM fact_match_players p
JOIN fact_matches m ON m.match_id = p.match_id
WHERE p.steam_id_64 IN ({placeholders})
""",
player_ids,
).fetchone()
if row and row[0]:
effective_from = int(row[0])
finally:
l2.close()
cursor = web.execute(
"""
INSERT INTO team_roster_versions (
name, effective_from, is_current, notes
) VALUES (?, ?, 1, ?)
""",
['2.0.0 Beta Initial Roster', effective_from, 'Bootstrapped from active lineup'],
)
version_id = int(cursor.lastrowid)
web.executemany(
"""
INSERT INTO team_roster_members (
roster_version_id, steam_id_64, member_role, position_order
) VALUES (?, ?, 'member', ?)
""",
[
(version_id, steam_id, index)
for index, steam_id in enumerate(player_ids)
],
)
web.commit()
return version_id
finally:
web.close()
@staticmethod
def snapshot_roster(
player_ids,
name=None,
effective_from=None,
roles=None,
web_db_path=None,
):
normalized_ids = RosterVersionService._normalize_ids(player_ids)
if not normalized_ids:
raise ValueError('Roster version requires at least one player')
roles = roles or {}
effective_from = int(effective_from or time.time())
web = RosterVersionService._connect_web(web_db_path)
try:
current = web.execute(
"""
SELECT *
FROM team_roster_versions
WHERE is_current = 1
LIMIT 1
"""
).fetchone()
if current:
current_ids = [
str(row[0]) for row in web.execute(
"""
SELECT steam_id_64
FROM team_roster_members
WHERE roster_version_id = ?
ORDER BY position_order, steam_id_64
""",
[current['id']],
)
]
if current_ids == normalized_ids:
return int(current['id']), False
web.execute(
"""
UPDATE team_roster_versions
SET is_current = 0, effective_to = ?
WHERE id = ?
""",
[effective_from - 1, current['id']],
)
cursor = web.execute(
"""
INSERT INTO team_roster_versions (
name, effective_from, is_current
) VALUES (?, ?, 1)
""",
[
name or f'Roster {time.strftime("%Y-%m-%d")}',
effective_from,
],
)
version_id = int(cursor.lastrowid)
values = []
for index, steam_id in enumerate(normalized_ids):
role = roles.get(steam_id, 'member')
if role not in {'starter', 'substitute', 'member'}:
role = 'member'
values.append((version_id, steam_id, role, index))
web.executemany(
"""
INSERT INTO team_roster_members (
roster_version_id, steam_id_64, member_role, position_order
) VALUES (?, ?, ?, ?)
""",
values,
)
web.commit()
return version_id, True
except Exception:
web.rollback()
raise
finally:
web.close()
@staticmethod
def list_versions(web_db_path=None):
web = RosterVersionService._connect_web(web_db_path)
try:
rows = web.execute(
"""
SELECT
version.*,
COUNT(member.steam_id_64) AS member_count
FROM team_roster_versions version
LEFT JOIN team_roster_members member
ON member.roster_version_id = version.id
GROUP BY version.id
ORDER BY version.effective_from DESC, version.id DESC
"""
).fetchall()
return [dict(row) for row in rows]
finally:
web.close()
@staticmethod
def get_current_members(web_db_path=None):
web = RosterVersionService._connect_web(web_db_path)
try:
rows = web.execute(
"""
SELECT member.*, version.name AS version_name
FROM team_roster_members member
JOIN team_roster_versions version
ON version.id = member.roster_version_id
WHERE version.is_current = 1
ORDER BY member.position_order, member.steam_id_64
"""
).fetchall()
return [dict(row) for row in rows]
finally:
web.close()
@staticmethod
def update_current_member_role(steam_id, member_role, web_db_path=None):
if member_role not in {'starter', 'substitute', 'member'}:
raise ValueError('Invalid roster member role')
web = RosterVersionService._connect_web(web_db_path)
try:
cursor = web.execute(
"""
UPDATE team_roster_members
SET member_role = ?
WHERE steam_id_64 = ?
AND roster_version_id = (
SELECT id
FROM team_roster_versions
WHERE is_current = 1
LIMIT 1
)
""",
[member_role, str(steam_id)],
)
if cursor.rowcount != 1:
raise ValueError('Player is not in the current roster version')
web.commit()
finally:
web.close()
+173
View File
@@ -0,0 +1,173 @@
import json
from web.database import query_db
from web.services.roster_version_service import RosterVersionService
from web.services.team_context_service import TeamContextService
class TeamPerformanceService:
@staticmethod
def _player_identity_map(steam_ids):
if not steam_ids:
return {}
placeholders = ','.join('?' for _ in steam_ids)
rows = query_db(
'l2',
f"""
SELECT steam_id_64, username, avatar_url
FROM dim_players
WHERE steam_id_64 IN ({placeholders})
""",
steam_ids,
)
return {str(row['steam_id_64']): dict(row) for row in rows}
@staticmethod
def get_team_summary():
roster_ids = TeamContextService.get_active_roster_ids()
if not roster_ids:
return {
'matches': 0,
'wins': 0,
'win_rate': 0,
'avg_rating': 0,
'maps': [],
}
placeholders = ','.join('?' for _ in roster_ids)
rows = query_db(
'l2',
f"""
SELECT
p.match_id,
CASE
WHEN p.group_id IN (1, 2) THEN p.group_id
WHEN p.team_id IN (1, 2) THEN p.team_id
END AS team_key,
COUNT(DISTINCT p.steam_id_64) AS roster_count,
AVG(p.rating) AS avg_rating,
MAX(p.is_win) AS is_win,
m.map_name,
m.start_time
FROM fact_match_players p
JOIN fact_matches m ON m.match_id = p.match_id
WHERE p.steam_id_64 IN ({placeholders})
GROUP BY p.match_id, team_key
HAVING roster_count >= 2 AND team_key IS NOT NULL
ORDER BY m.start_time DESC
""",
roster_ids,
)
matches = len(rows)
wins = sum(int(row['is_win'] or 0) for row in rows)
map_stats = {}
for row in rows:
item = map_stats.setdefault(
row['map_name'] or 'Unknown',
{'map_name': row['map_name'] or 'Unknown', 'matches': 0, 'wins': 0},
)
item['matches'] += 1
item['wins'] += int(row['is_win'] or 0)
maps = []
for item in map_stats.values():
item['win_rate'] = item['wins'] / item['matches']
maps.append(item)
maps.sort(key=lambda item: item['matches'], reverse=True)
return {
'matches': matches,
'wins': wins,
'losses': matches - wins,
'win_rate': wins / matches if matches else 0,
'avg_rating': (
sum(float(row['avg_rating'] or 0) for row in rows) / matches
if matches else 0
),
'maps': maps,
}
@staticmethod
def get_current_roster():
members = RosterVersionService.get_current_members()
identities = TeamPerformanceService._player_identity_map(
[member['steam_id_64'] for member in members]
)
result = []
for member in members:
item = dict(member)
item.update(identities.get(str(member['steam_id_64']), {}))
result.append(item)
return result
@staticmethod
def get_top_duos(limit=12):
rows = query_db(
'l3',
"""
SELECT *
FROM dm_duo_stats
ORDER BY sample_reliable DESC, matches DESC,
win_rate DESC, avg_combined_rating DESC
LIMIT ?
""",
[limit],
)
steam_ids = {
str(value)
for row in rows
for value in (row['steam_id_a'], row['steam_id_b'])
}
identities = TeamPerformanceService._player_identity_map(sorted(steam_ids))
result = []
for row in rows:
item = dict(row)
item['player_a'] = identities.get(str(row['steam_id_a']), {})
item['player_b'] = identities.get(str(row['steam_id_b']), {})
result.append(item)
return result
@staticmethod
def get_top_lineups(player_count=None, limit=12):
args = []
where = ''
if player_count:
where = 'WHERE player_count = ?'
args.append(int(player_count))
args.append(limit)
rows = query_db(
'l3',
f"""
SELECT *
FROM dm_lineup_stats
{where}
ORDER BY sample_reliable DESC, matches DESC,
win_rate DESC, avg_team_rating DESC
LIMIT ?
""",
args,
)
all_ids = set()
parsed = []
for row in rows:
item = dict(row)
item['player_ids'] = [
str(value) for value in json.loads(item['player_ids_json'])
]
all_ids.update(item['player_ids'])
parsed.append(item)
identities = TeamPerformanceService._player_identity_map(sorted(all_ids))
for item in parsed:
item['players'] = [
identities.get(steam_id, {'username': steam_id})
for steam_id in item['player_ids']
]
return parsed
@staticmethod
def get_dashboard():
return {
'summary': TeamPerformanceService.get_team_summary(),
'roster': TeamPerformanceService.get_current_roster(),
'versions': RosterVersionService.list_versions(),
'duos': TeamPerformanceService.get_top_duos(),
'lineups': TeamPerformanceService.get_top_lineups(),
}
+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}战队荣誉 - YRTV{% endblock %}
{% block content %}
<div class="space-y-8 px-4 sm:px-0">
<div>
<p class="text-xs font-bold uppercase tracking-widest text-amber-600">YRTV Awards</p>
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">周期最佳与荣誉榜</h1>
<p class="mt-2 text-sm text-gray-500">完全基于已导入比赛。月、季度和年度奖项设有最低场次门槛。</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="{{ url_for('awards.index') }}" class="rounded-full px-4 py-2 text-sm font-bold {% if not award_type %}bg-amber-500 text-white{% else %}bg-white text-gray-600 dark:bg-slate-800 dark:text-gray-300{% endif %}">全部</a>
{% for key, label in award_types.items() %}
<a href="{{ url_for('awards.index', type=key) }}" class="rounded-full px-4 py-2 text-sm font-bold {% if award_type == key %}bg-amber-500 text-white{% else %}bg-white text-gray-600 dark:bg-slate-800 dark:text-gray-300{% endif %}">{{ label }}</a>
{% endfor %}
</div>
<div class="grid grid-cols-1 gap-8 xl:grid-cols-3">
<section class="rounded-2xl bg-gradient-to-br from-amber-500 to-orange-600 p-6 text-white shadow-xl">
<h2 class="text-xl font-black">荣誉榜</h2>
<div class="mt-5 space-y-3">
{% for player in leaderboard[:10] %}
<a href="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" class="flex items-center justify-between rounded-xl bg-white/10 p-3 hover:bg-white/20">
<div>
<div class="font-bold">{{ loop.index }}. {{ player.identity.username or player.steam_id_64 }}</div>
<div class="text-xs text-amber-100">
年 {{ player.yearly }} · 季 {{ player.quarterly }} · 月 {{ player.monthly }} · 周 {{ player.weekly }} · 日 {{ player.daily }}
</div>
</div>
<div class="text-2xl font-black">{{ player.awards }}</div>
</a>
{% else %}
<div class="py-8 text-center text-amber-100">暂无奖项</div>
{% endfor %}
</div>
</section>
<section class="space-y-4 xl:col-span-2">
{% for award in awards %}
<article class="rounded-2xl border border-gray-100 bg-white p-5 shadow dark:border-slate-700 dark:bg-slate-800">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-4">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-100 text-2xl text-amber-700">🏆</div>
<div>
<div class="text-xs font-bold uppercase tracking-wider text-amber-600">{{ award.award_label }}</div>
<a href="{{ url_for('players.detail', steam_id=award.steam_id_64) }}" class="text-xl font-black text-gray-900 hover:text-yrtv-600 dark:text-white">{{ award.identity.username or award.steam_id_64 }}</a>
<div class="text-xs text-gray-400">{{ award.period_key }} · {{ award.matches }} 场</div>
</div>
</div>
<div class="grid grid-cols-4 gap-4 text-center">
<div><div class="text-[10px] uppercase text-gray-400">Rating</div><div class="font-black">{{ '%.2f'|format(award.avg_rating) }}</div></div>
<div><div class="text-[10px] uppercase text-gray-400">K/D</div><div class="font-black">{{ '%.2f'|format(award.avg_kd) }}</div></div>
<div><div class="text-[10px] uppercase text-gray-400">ADR</div><div class="font-black">{{ '%.1f'|format(award.avg_adr) }}</div></div>
<div><div class="text-[10px] uppercase text-gray-400">Win</div><div class="font-black">{{ '%.0f%%'|format(award.win_rate * 100) }}</div></div>
</div>
</div>
</article>
{% else %}
<div class="rounded-2xl bg-white py-16 text-center text-gray-400 dark:bg-slate-800">当前筛选没有满足最低场次的奖项。</div>
{% endfor %}
</section>
</div>
</div>
{% endblock %}
+4
View File
@@ -46,6 +46,8 @@
<a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">首页</a> <a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">首页</a>
<a href="{{ url_for('matches.index') }}" class="{% if request.endpoint and 'matches' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">比赛</a> <a href="{{ url_for('matches.index') }}" class="{% if request.endpoint and 'matches' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">比赛</a>
<a href="{{ url_for('players.index') }}" class="{% if request.endpoint and 'players' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">玩家</a> <a href="{{ url_for('players.index') }}" class="{% if request.endpoint and 'players' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">玩家</a>
<a href="{{ url_for('reports.index') }}" class="{% if request.endpoint and 'reports' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">报告</a>
<a href="{{ url_for('awards.index') }}" class="{% if request.endpoint and 'awards' in request.endpoint %}border-amber-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">荣誉</a>
<a href="{{ url_for('teams.index') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战队</a> <a href="{{ url_for('teams.index') }}" class="{% if request.endpoint and 'teams' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战队</a>
<a href="{{ url_for('opponents.index') }}" class="{% if request.endpoint and 'opponents' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">对手</a> <a href="{{ url_for('opponents.index') }}" class="{% if request.endpoint and 'opponents' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">对手</a>
<a href="{{ url_for('tactics.index') }}" class="{% if request.endpoint and 'tactics' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战术</a> <a href="{{ url_for('tactics.index') }}" class="{% if request.endpoint and 'tactics' in request.endpoint %}border-yrtv-500 text-gray-900 dark:text-white{% else %}border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-white{% endif %} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">战术</a>
@@ -84,6 +86,8 @@
<a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}bg-yrtv-50 border-yrtv-500 text-yrtv-700{% else %}border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700{% endif %} block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">首页</a> <a href="{{ url_for('main.index') }}" class="{% if request.endpoint == 'main.index' %}bg-yrtv-50 border-yrtv-500 text-yrtv-700{% else %}border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700{% endif %} block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">首页</a>
<a href="{{ url_for('matches.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">比赛</a> <a href="{{ url_for('matches.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">比赛</a>
<a href="{{ url_for('players.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">玩家</a> <a href="{{ url_for('players.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">玩家</a>
<a href="{{ url_for('reports.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">报告</a>
<a href="{{ url_for('awards.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">荣誉</a>
<a href="{{ url_for('teams.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战队</a> <a href="{{ url_for('teams.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战队</a>
<a href="{{ url_for('opponents.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">对手</a> <a href="{{ url_for('opponents.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">对手</a>
<a href="{{ url_for('tactics.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战术</a> <a href="{{ url_for('tactics.index') }}" class="border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700 block pl-3 pr-4 py-2 border-l-4 text-base font-medium dark:text-white dark:hover:bg-slate-700">战术</a>
@@ -0,0 +1,68 @@
{% if post_match_report %}
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
<div class="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="flex items-center gap-3">
<span class="rounded-lg px-3 py-1 text-xs font-black {% if post_match_report.is_win %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-600{% endif %}">
{{ 'VICTORY' if post_match_report.is_win else 'DEFEAT' }}
</span>
<h2 class="text-xl font-black text-gray-900 dark:text-white">赛后报告</h2>
</div>
<p class="mt-3 max-w-3xl text-sm text-gray-600 dark:text-gray-300">{{ post_match_report.summary_text }}</p>
</div>
<div class="grid grid-cols-3 gap-5 text-center">
<div><div class="text-2xl font-black text-yrtv-600">{{ '%.2f'|format(post_match_report.team_avg_rating) }}</div><div class="text-[10px] uppercase text-gray-400">Team Rating</div></div>
<div><div class="text-2xl font-black text-gray-900 dark:text-white">{{ '%.1f'|format(post_match_report.team_avg_adr) }}</div><div class="text-[10px] uppercase text-gray-400">Team ADR</div></div>
<div><div class="text-2xl font-black text-amber-600">{{ post_match_report.record_break_count }}</div><div class="text-[10px] uppercase text-gray-400">Records</div></div>
</div>
</div>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="text-xs font-bold uppercase text-amber-600">Match MVP</div>
<a href="{{ url_for('players.detail', steam_id=post_match_report.mvp_steam_id) }}" class="mt-1 block text-lg font-black text-gray-900 dark:text-white">{{ post_match_report.mvp.username or post_match_report.mvp_steam_id }}</a>
<div class="text-sm text-gray-500">Rating {{ '%.2f'|format(post_match_report.mvp_rating) }}</div>
</div>
<div class="rounded-xl bg-green-50 p-4 dark:bg-green-900/20">
<div class="text-xs font-bold uppercase text-green-600">Form Improver</div>
{% if post_match_report.improver_steam_id %}
<a href="{{ url_for('players.detail', steam_id=post_match_report.improver_steam_id) }}" class="mt-1 block text-lg font-black text-gray-900 dark:text-white">{{ post_match_report.improver.username or post_match_report.improver_steam_id }}</a>
<div class="text-sm text-green-600">Rating {{ '%+.2f'|format(post_match_report.improver_delta) }}</div>
{% else %}
<div class="mt-1 text-sm text-gray-400">近期样本不足</div>
{% endif %}
</div>
<div class="rounded-xl bg-yrtv-50 p-4 dark:bg-yrtv-900/20">
<div class="text-xs font-bold uppercase text-yrtv-600">Strongest Duo</div>
{% if post_match_report.strongest_duo %}
<div class="mt-1 text-lg font-black text-gray-900 dark:text-white">
{% for player in post_match_report.strongest_duo.players %}
{{ player.username }}{% if not loop.last %} + {% endif %}
{% endfor %}
</div>
<div class="text-sm text-gray-500">Rating {{ '%.2f'|format(post_match_report.strongest_duo.avg_rating) }}</div>
{% else %}
<div class="mt-1 text-sm text-gray-400">本场无二人组</div>
{% endif %}
</div>
</div>
<div class="mt-6 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-slate-700">
<thead><tr class="text-left text-xs uppercase text-gray-400"><th class="py-2">Player</th><th>Rating</th><th>vs Prior 20</th><th>ADR Δ</th><th>Status</th><th>Record</th></tr></thead>
<tbody class="divide-y divide-gray-100 dark:divide-slate-700">
{% for player in post_match_report.players %}
<tr>
<td class="py-3 font-bold"><a href="{{ url_for('players.detail', steam_id=player.steam_id_64) }}" class="hover:text-yrtv-600">{{ player.identity.username or player.steam_id_64 }}</a></td>
<td class="font-mono">{{ '%.2f'|format(player.rating or 0) }}</td>
<td class="font-mono {% if (player.rating_delta or 0) > 0 %}text-green-600{% elif (player.rating_delta or 0) < 0 %}text-red-500{% endif %}">{{ '%+.2f'|format(player.rating_delta) if player.rating_delta is not none else 'N/A' }}</td>
<td class="font-mono">{{ '%+.1f'|format(player.adr_delta) if player.adr_delta is not none else 'N/A' }}</td>
<td>{{ player.performance_text }}</td>
<td>{% for label in player.record_labels %}<span class="mr-1 rounded bg-amber-100 px-2 py-1 text-[10px] font-bold text-amber-700">{{ label }}</span>{% else %}<span class="text-gray-300">-</span>{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
+2
View File
@@ -43,6 +43,8 @@
</div> </div>
</div> </div>
{% include "matches/_post_match_report.html" %}
<!-- Tab: Overview --> <!-- Tab: Overview -->
<div x-show="tab === 'overview'" class="space-y-6"> <div x-show="tab === 'overview'" class="space-y-6">
<!-- Team 1 Stats --> <!-- Team 1 Stats -->
@@ -0,0 +1,45 @@
<section class="overflow-hidden rounded-2xl border border-gray-100 bg-gradient-to-r from-slate-900 via-slate-800 to-yrtv-900 p-6 text-white shadow-xl dark:border-slate-700">
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div>
<div class="text-xs font-bold uppercase tracking-[0.25em] text-yrtv-300">Professional Identity</div>
<div class="mt-2 flex flex-wrap items-center gap-3">
<span class="rounded-full bg-white/10 px-3 py-1 text-sm font-bold uppercase">{{ professional_identity.member_role or 'member' }}</span>
<span class="text-sm text-slate-300">{{ professional_identity.roster_version or 'Active Roster' }}</span>
</div>
<div class="mt-4 text-sm text-slate-300">
{{ professional_identity.total_matches or 0 }} 场效力 · 队内 Percentile {{ '%.0f%%'|format(professional_identity.tier_percentile or 0) }}
</div>
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="rounded-xl bg-white/10 p-3">
<div class="text-[10px] uppercase text-slate-400">代表武器</div>
<div class="mt-1 font-black">{{ professional_identity.core_top_weapon or 'N/A' }}</div>
</div>
<div class="rounded-xl bg-white/10 p-3">
<div class="text-[10px] uppercase text-slate-400">最佳地图</div>
<div class="mt-1 font-black">{{ professional_identity.meta_map_best_map or 'N/A' }}</div>
</div>
<div class="rounded-xl bg-white/10 p-3">
<div class="text-[10px] uppercase text-slate-400">荣誉</div>
<div class="mt-1 text-2xl font-black text-amber-400">{{ professional_identity.award_count or 0 }}</div>
</div>
<div class="rounded-xl bg-white/10 p-3">
<div class="text-[10px] uppercase text-slate-400">月/季/年最佳</div>
<div class="mt-1 font-black text-amber-300">
{{ professional_identity.awards_by_type.get('monthly', 0) }}/{{ professional_identity.awards_by_type.get('quarterly', 0) }}/{{ professional_identity.awards_by_type.get('yearly', 0) }}
</div>
</div>
</div>
</div>
{% if honors.awards %}
<div class="mt-5 flex gap-2 overflow-x-auto border-t border-white/10 pt-4">
{% for award in honors.awards[:8] %}
<a href="{{ url_for('awards.index', type=award.award_type) }}" class="whitespace-nowrap rounded-full bg-amber-400/20 px-3 py-1 text-xs font-bold text-amber-200">
🏆 {{ award.period_key }} {{ award.award_label }}
</a>
{% endfor %}
</div>
{% endif %}
</section>
+2
View File
@@ -148,6 +148,8 @@
</div> </div>
</div> </div>
{% include "players/_professional_identity.html" %}
<!-- 1.5 Lifetime Stats (Quantity) --> <!-- 1.5 Lifetime Stats (Quantity) -->
<div class="bg-white dark:bg-slate-800 shadow-xl rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700 p-6"> <div class="bg-white dark:bg-slate-800 shadow-xl rounded-2xl overflow-hidden border border-gray-100 dark:border-slate-700 p-6">
<div class="flex flex-col lg:flex-row gap-8 items-center"> <div class="flex flex-col lg:flex-row gap-8 items-center">
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}赛后报告 - YRTV{% endblock %}
{% block content %}
<div class="space-y-6 px-4 sm:px-0">
<div>
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Post Match</p>
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">赛后报告中心</h1>
<p class="mt-2 text-sm text-gray-500">表现变化只与该队员比赛发生前的最近 20 场比较。</p>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{% for report in reports %}
<a href="{{ url_for('matches.detail', match_id=report.match_id) }}" class="rounded-2xl border border-gray-100 bg-white p-5 shadow transition hover:border-yrtv-300 hover:shadow-lg dark:border-slate-700 dark:bg-slate-800">
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2">
<span class="rounded px-2 py-1 text-xs font-black {% if report.is_win %}bg-green-100 text-green-700{% else %}bg-red-100 text-red-600{% endif %}">{{ 'WIN' if report.is_win else 'LOSS' }}</span>
<span class="font-black text-gray-900 dark:text-white">{{ report.map_name }}</span>
</div>
<p class="mt-3 text-sm text-gray-600 dark:text-gray-300">{{ report.summary_text }}</p>
<div class="mt-3 text-xs text-gray-400">{{ report.roster_count }} 名 roster 队员 · {{ report.record_break_count }} 项纪录刷新</div>
</div>
<div class="text-right">
<div class="text-2xl font-black text-yrtv-600">{{ '%.2f'|format(report.team_avg_rating) }}</div>
<div class="text-[10px] uppercase text-gray-400">Team Rating</div>
<div class="mt-2 text-xs text-gray-500">MVP {{ report.mvp.username or report.mvp_steam_id }}</div>
</div>
</div>
</a>
{% else %}
<div class="col-span-full rounded-2xl bg-white py-16 text-center text-gray-400 dark:bg-slate-800">暂无赛后报告</div>
{% endfor %}
</div>
</div>
{% endblock %}
+3
View File
@@ -13,6 +13,9 @@
</h2> </h2>
</div> </div>
<div class="mt-4 flex md:mt-0 md:ml-4"> <div class="mt-4 flex md:mt-0 md:ml-4">
<a href="{{ url_for('teams.performance') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
Team Career
</a>
{% if session.get('is_admin') %} {% if session.get('is_admin') %}
<button @click="showScoutModal = true" type="button" class="ml-3 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500"> <button @click="showScoutModal = true" type="button" class="ml-3 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-yrtv-600 hover:bg-yrtv-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yrtv-500">
<span class="mr-2">🔍</span> Scout Player <span class="mr-2">🔍</span> Scout Player
+156
View File
@@ -0,0 +1,156 @@
{% extends "base.html" %}
{% block title %}战队履历 - YRTV{% endblock %}
{% block content %}
<div class="space-y-8 px-4 sm:px-0">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-bold uppercase tracking-widest text-yrtv-600">Team Career</p>
<h1 class="mt-1 text-3xl font-black text-gray-900 dark:text-white">战队履历与阵容表现</h1>
<p class="mt-2 text-sm text-gray-500">只统计 roster 成员在同一队伍实际共同出场的比赛。</p>
</div>
<a href="{{ url_for('teams.index') }}" class="rounded-lg bg-yrtv-600 px-4 py-2 text-sm font-bold text-white hover:bg-yrtv-500">
返回 Clubhouse
</a>
</div>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-5">
{% for label, value, style in [
('Matches', summary.matches, ''),
('Wins', summary.wins, 'text-green-600'),
('Losses', summary.losses, 'text-red-500'),
('Win Rate', '%.1f%%'|format(summary.win_rate * 100), 'text-yrtv-600'),
('Team Rating', '%.2f'|format(summary.avg_rating), '')
] %}
<div class="rounded-2xl border border-gray-100 bg-white p-5 shadow dark:border-slate-700 dark:bg-slate-800">
<div class="text-xs font-bold uppercase text-gray-400">{{ label }}</div>
<div class="mt-2 text-3xl font-black text-gray-900 dark:text-white {{ style }}">{{ value }}</div>
</div>
{% endfor %}
</div>
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">战队赛季</h2>
<a href="{{ url_for('awards.index') }}" class="text-sm font-bold text-amber-600">查看周期荣誉</a>
</div>
<div class="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{% for season in seasons %}
<div class="rounded-xl bg-gradient-to-br from-slate-900 to-slate-700 p-5 text-white">
<div class="text-xs font-bold uppercase tracking-widest text-slate-300">{{ season.season_label }}</div>
<div class="mt-3 grid grid-cols-3 gap-3 text-center">
<div><div class="text-2xl font-black">{{ season.matches }}</div><div class="text-[10px] uppercase text-slate-400">Matches</div></div>
<div><div class="text-2xl font-black text-green-400">{{ '%.0f%%'|format(season.win_rate * 100) }}</div><div class="text-[10px] uppercase text-slate-400">Win</div></div>
<div><div class="text-2xl font-black">{{ '%.2f'|format(season.avg_team_rating) }}</div><div class="text-[10px] uppercase text-slate-400">Rating</div></div>
</div>
<div class="mt-4 border-t border-white/10 pt-3 text-xs text-slate-300">
最佳地图 {{ season.best_map }} ({{ '%.0f%%'|format(season.best_map_win_rate * 100) }})<br>
赛季选手 {{ season.top_player.username or season.top_player_steam_id }} ({{ '%.2f'|format(season.top_player_rating) }})
</div>
</div>
{% else %}
<div class="text-gray-400">暂无赛季数据</div>
{% endfor %}
</div>
</section>
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">当前 Roster Version</h2>
{% if versions %}
<span class="rounded-full bg-green-100 px-3 py-1 text-xs font-bold text-green-700">{{ versions[0].name }}</span>
{% endif %}
</div>
<div class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{% for member in roster %}
<div class="rounded-xl bg-gray-50 p-3 transition hover:bg-yrtv-50 dark:bg-slate-700/40">
<a href="{{ url_for('players.detail', steam_id=member.steam_id_64) }}"
class="block truncate font-bold text-gray-900 dark:text-white">{{ member.username or member.steam_id_64 }}</a>
{% if session.get('is_admin') %}
<form action="{{ url_for('teams.roster_role') }}" method="POST" class="mt-2">
<input type="hidden" name="steam_id" value="{{ member.steam_id_64 }}">
<select name="member_role" onchange="this.form.submit()"
class="w-full rounded border-gray-200 bg-white py-1 text-xs uppercase dark:border-slate-600 dark:bg-slate-700">
{% for role in ['starter', 'substitute', 'member'] %}
<option value="{{ role }}" {% if member.member_role == role %}selected{% endif %}>{{ role }}</option>
{% endfor %}
</select>
</form>
{% else %}
<div class="mt-1 text-xs uppercase text-gray-400">{{ member.member_role }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</section>
<div class="grid grid-cols-1 gap-8 xl:grid-cols-2">
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">二人组表现</h2>
<div class="mt-5 space-y-3">
{% for duo in duos %}
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-4 dark:bg-slate-700/40">
<div>
<div class="font-bold text-gray-900 dark:text-white">
{{ duo.player_a.username or duo.steam_id_a }} + {{ duo.player_b.username or duo.steam_id_b }}
</div>
<div class="mt-1 text-xs text-gray-400">{{ duo.matches }} 场 · 平均 Rating {{ '%.2f'|format(duo.avg_combined_rating) }}</div>
</div>
<div class="text-right">
<div class="text-xl font-black {% if duo.win_rate >= 0.5 %}text-green-600{% else %}text-red-500{% endif %}">
{{ '%.0f%%'|format(duo.win_rate * 100) }}
</div>
<div class="text-[10px] text-gray-400">{{ '可靠样本' if duo.sample_reliable else '小样本' }}</div>
</div>
</div>
{% else %}
<div class="py-8 text-center text-gray-400">暂无二人组数据</div>
{% endfor %}
</div>
</section>
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">实际阵容组合</h2>
<div class="mt-5 space-y-3">
{% for lineup in lineups %}
<div class="rounded-xl bg-gray-50 p-4 dark:bg-slate-700/40">
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex flex-wrap gap-1">
{% for player in lineup.players %}
<span class="rounded bg-white px-2 py-1 text-xs font-bold text-gray-700 dark:bg-slate-600 dark:text-white">{{ player.username }}</span>
{% endfor %}
</div>
<div class="mt-2 text-xs text-gray-400">{{ lineup.player_count }} 人 · {{ lineup.matches }} 场 · Rating {{ '%.2f'|format(lineup.avg_team_rating) }}</div>
</div>
<div class="text-xl font-black {% if lineup.win_rate >= 0.5 %}text-green-600{% else %}text-red-500{% endif %}">
{{ '%.0f%%'|format(lineup.win_rate * 100) }}
</div>
</div>
</div>
{% else %}
<div class="py-8 text-center text-gray-400">暂无阵容组合数据</div>
{% endfor %}
</div>
</section>
</div>
<section class="rounded-2xl border border-gray-100 bg-white p-6 shadow dark:border-slate-700 dark:bg-slate-800">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Roster Version 历史</h2>
<div class="mt-5 divide-y divide-gray-100 dark:divide-slate-700">
{% for version in versions %}
<div class="flex items-center justify-between py-3">
<div>
<div class="font-bold text-gray-900 dark:text-white">{{ version.name }}</div>
<div class="text-xs text-gray-400">{{ version.member_count }} members · {{ version.effective_from }}</div>
</div>
<span class="rounded-full px-3 py-1 text-xs font-bold {% if version.is_current %}bg-green-100 text-green-700{% else %}bg-gray-100 text-gray-500{% endif %}">
{{ 'Current' if version.is_current else 'Archived' }}
</span>
</div>
{% endfor %}
</div>
</section>
</div>
{% endblock %}