2.0.0-rc1 : Profile and achievements update.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# YRTV 2.0.0 Alpha
|
||||
# YRTV 2.0.0 Beta
|
||||
|
||||
YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行榜,而是让队员拥有类似职业选手的个人主页,并为战队提供比赛档案、队内比较、阵容分析、对手情报和战术工具。
|
||||
|
||||
当前 Alpha 版本已经建立可重复运行的数据流水线、数据库治理和职业主页数据集市,重点服务 active roster。
|
||||
当前 Beta 版本已经建立可重复运行的数据流水线、数据库治理、职业主页和可追溯的阵容版本模型,重点服务 active roster。
|
||||
|
||||
## 当前基线
|
||||
|
||||
@@ -18,8 +18,15 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
|
||||
- 236 条武器统计
|
||||
- 54 条时间窗口统计
|
||||
- 54 条个人职业纪录
|
||||
- 24 项自动化测试通过
|
||||
- 28 项数据完整性检查通过
|
||||
- 36 个 roster 二人组
|
||||
- 200 个实际 2-5 人阵容组合
|
||||
- 208 份赛后报告
|
||||
- 885 份队员赛后对比
|
||||
- 203 次职业纪录刷新事件
|
||||
- 2 个战队赛季
|
||||
- 111 个日/周/月/季/年度奖项
|
||||
- 31 项自动化测试通过
|
||||
- 39 项数据完整性检查通过
|
||||
|
||||
数据规模会随导入变化,Admin 数据完整性中心显示的结果是运行时事实。
|
||||
|
||||
@@ -39,6 +46,9 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
|
||||
### 比赛中心
|
||||
|
||||
- 比赛列表、地图、比分、平均 ELO 和己方结果
|
||||
- 自动赛后报告与队内 MVP
|
||||
- 相对此前 20 场的 Rating、K/D、ADR 变化
|
||||
- 本场纪录刷新、状态标签和最强二人组
|
||||
- Active roster 与 Party 识别
|
||||
- 双方玩家表现和 Rating 排序
|
||||
- Head-to-head 击杀矩阵
|
||||
@@ -48,12 +58,25 @@ YRTV 是面向固定 CS2 战队的私人数据站。它不是公共玩家排行
|
||||
### 战队与战术
|
||||
|
||||
- Active roster 管理
|
||||
- Roster version 生效区间和历史版本
|
||||
- Starter、substitute、member 角色管理
|
||||
- 战队履历、赛季战绩和地图表现
|
||||
- 实际同队二人组与 2-5 人阵容组合
|
||||
- 玩家搜索、签入和移出
|
||||
- 2-5 人同队比赛与 Chemistry 分析
|
||||
- 对手档案和真实交手记录
|
||||
- 地图战术板、阵容数据中心和经济工具
|
||||
- Wiki、玩家标签、备注和评论
|
||||
|
||||
### 赛季与荣誉
|
||||
|
||||
- 按日历年度生成战队赛季
|
||||
- 赛季战绩、Rating、最佳地图和赛季选手
|
||||
- 单日最佳、星期最佳、月度最佳、季度最佳、年度最佳
|
||||
- 周/月/季/年奖项使用最低场次门槛
|
||||
- 个人主页展示荣誉数量和近期奖章
|
||||
- 每次职业纪录刷新保留时间和对应比赛
|
||||
|
||||
### 数据运营
|
||||
|
||||
- Admin 上传 `iframe_network.json`
|
||||
@@ -183,12 +206,19 @@ Flask services and player profiles
|
||||
- `dm_player_weapon_stats`
|
||||
- `dm_player_period_stats`
|
||||
- `dm_player_records`
|
||||
- `dm_duo_stats`
|
||||
- `dm_lineup_stats`
|
||||
- `dm_match_reports`
|
||||
- `dm_match_player_reports`
|
||||
- `dm_player_record_events`
|
||||
- `dm_team_season_stats`
|
||||
- `dm_player_awards`
|
||||
|
||||
### Web:应用状态
|
||||
|
||||
- 数据库:`database/Web/Web_App.sqlite`
|
||||
- Schema:`database/Web/schema.sql`
|
||||
- 当前 schema version:2
|
||||
- 当前 schema version:3
|
||||
- 保存 lineup、玩家备注、评论、Wiki、战术板、导入登记和 ETL 作业
|
||||
|
||||
## 数据库治理
|
||||
@@ -253,17 +283,17 @@ yrtv/
|
||||
└── wsgi.py
|
||||
```
|
||||
|
||||
## Alpha 限制
|
||||
## Beta 限制
|
||||
|
||||
- 当前主要数据源为 5E iframe 网络响应。
|
||||
- 不包含自动网页下载器和 Demo parser。
|
||||
- 认证仍是单一 Admin Token,适合私人部署,不适合开放注册。
|
||||
- SQLite 适合当前单战队规模,不面向高并发多租户。
|
||||
- 部分高级空间能力需要地图边界、路径和 Demo 数据,当前显示 `N/A`。
|
||||
- `StatsService` 仍保留部分兼容逻辑,后续会继续按领域拆分。
|
||||
- `StatsService` 仍保留部分兼容逻辑;新功能已拆入 Player Profile、Team Performance、Roster Version 等领域服务。
|
||||
|
||||
## 版本
|
||||
|
||||
当前版本:`2.0.0 Alpha`
|
||||
当前版本:`2.0.0 Beta`
|
||||
|
||||
这一版本的目标是建立可信、可恢复、可持续导入的私人战队 HLTV 基线,而不是冻结产品功能。
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
展示项目业务价值的核心是打造**「技术动作→数据成果→业务落地」的闭环链路**,结合你CS2数据项目+数据分析岗的定位,同时匹配“队长带领5人团队”的角色,核心要做到**量化成果前置、技术与业务强绑定、个人贡献突出**,以下是可直接落地的方法,附专属你的CS2项目优化示例和通用模板:
|
||||
|
||||
### 一、核心方法:5招落地,每招配CS2项目简历示例
|
||||
#### 1. 成果前置+强量化,抓牢HR8秒注意力
|
||||
把**最核心的业务价值**放在项目概述首位,用**对比量化(提升/降低)+绝对值量化(数据量/规模)** 替代模糊描述,电竞/数据分析岗重点突出**核心业务指标、数据处理规模、效率/成本优化**三类数据。
|
||||
**普通表述**:带领团队搭建CS2数据平台,处理了大量比赛数据,提升了战队胜率
|
||||
**优化表述**:作为队长带领5人数据团队,搭建CS2赛事全流程数据分析平台,完成1年内300+场职业比赛、1600+玩家、数十万回合级数据的结构化处理,**推动战队ELO分层胜率从42%提升至55%(+13个百分点)**,数据维护人力成本降低60%。
|
||||
|
||||
#### 2. 技术动作与业务价值强绑定,拒绝纯技术堆砌
|
||||
数据分析岗最忌只说“用Python做数据处理”,要明确**Python的具体高阶操作→带来的数据分析成果→最终落地的业务价值**,让技术成为业务价值的“桥梁”,而非孤立的技能。
|
||||
**普通表述**:用Python做了数据清洗和特征工程,构建了玩家画像
|
||||
**优化表述**:通过Python(Pandas/NumPy)实现原始JSON赛事数据的**矢量化清洗与批处理转换**,结合窗口函数完成200+维度玩家画像的高效计算,创新定义“压力表现”等战术指标,**为战队战术组提供精准的选手适配、站位优化数据支撑,成为胜率提升的核心数据依据**。
|
||||
|
||||
#### 3. STAR法则结构化,让业务价值链路更清晰
|
||||
围绕电竞行业**“经验驱动战术→缺乏精细化数据支撑”**的核心痛点搭建STAR框架,**情境(S)讲行业/业务痛点,任务(T)定团队目标+个人职责,行动(A)做技术+数据动作,结果(R)出业务+效率双成果**,同时突出队长的**团队统筹能力**。
|
||||
**CS2项目STAR落地示例**:
|
||||
- 情境(S):针对电竞行业战术决策依赖经验、传统K/D指标无法量化战术价值的痛点,战队ELO分层胜率长期低于行业平均水平;
|
||||
- 任务(T):带领5人团队搭建从数据采集到可视化的全流程分析平台,核心目标通过数据驱动战术优化提升战队胜率;
|
||||
- 行动(A):统筹团队分工(数据采集/特征工程/可视化),制定Python代码规范,主导设计L1-L3分层数仓,开发Python多线程ETL自动化流水线;
|
||||
- 结果(R):战队ELO分层胜率提升13%,300+场比赛数据实现实时入库,数据查询效率提升至毫秒级,团队开发效率提升40%。
|
||||
|
||||
#### 4. 多维度拆解业务价值,让成果更立体
|
||||
单一的胜率提升不够有说服力,结合数据分析岗的**效率、成本、复用性**,从**核心业务指标(胜率)、数据效率(处理/查询速度)、运营成本(人力/时间)、成果复用性(模型/指标的落地)**四个维度拆解,贴合企业对数据“降本增效+业务赋能”的核心需求。
|
||||
**CS2项目多维度价值示例**:
|
||||
- 业务效果:ELO分层胜率42%→55%,战术优化精准度提升80%;
|
||||
- 数据效率:Python矢量化处理让1600+玩家全维度数据查询效率提升至毫秒级;
|
||||
- 成本优化:Python自动化ETL流水线让数据维护人力成本降低60%,赛事数据入库时间从小时级压缩至分钟级;
|
||||
- 成果复用:搭建的200+维度玩家特征模型被战队战术组复用,成为日常战术分析、选手选拔的标准模型。
|
||||
|
||||
#### 5. 嵌入行业专属术语,让专业度拉满
|
||||
在描述中加入**电竞行业+数据分析岗**的专属术语,让HR/业务方快速感知你对双领域的理解,避免“外行话”,核心术语精准即可,无需堆砌。
|
||||
- 电竞行业:ELO分层胜率、战术复盘、玩家协同效率、阵容适配、回合级数据;
|
||||
- 数据分析岗:L1-L3分层数仓、特征工程、ETL自动化流水线、矢量化运算、玩家画像特征集市。
|
||||
|
||||
### 二、数据分析岗专属:「技术-业务」价值句式模板
|
||||
直接套用来描述项目职责,完美实现技术动作与业务价值的绑定,适配你的CS2项目所有模块:
|
||||
1. 数据处理/ETL:**通过Python+[Pandas/Playwright/多线程]完成[XX数据量]的[矢量化清洗/自动化抓取/批处理],实现[数据效率/成本]优化,保障[XX业务环节]的精准性/实时性**
|
||||
2. 特征工程/建模:**基于Python+[NumPy/窗口函数]构建[XX维度]的[特征模型/用户画像],创新定义[XX高阶指标],量化[XX业务价值],为[XX业务决策]提供核心数据支撑**
|
||||
3. 数仓/架构设计:**主导设计[XX架构]的数仓体系,通过[Python+XX技术]实现[多粒度数据]的关联存储,将[数据查询效率]提升X%,支撑[XX业务分析]的高效落地**
|
||||
4. 团队管理(队长):**统筹X人团队分工,制定[Python/代码]规范,推动项目从0到1落地,最终实现[核心业务指标]提升X%,团队开发效率提升X%**
|
||||
|
||||
### 三、避坑指南:4个最易踩的业务价值展示误区
|
||||
1. ❌ 模糊表述:用“大幅提升、有效改善、处理大量数据”替代具体数字;✅ 必须用**百分比/绝对值/对比值**量化(如胜率+13%、300+场比赛、成本降60%)
|
||||
2. ❌ 技术堆砌:只罗列“Python/Pandas/SQLite”,不说技术的业务作用;✅ 技术永远为业务服务,每提一个技术,必跟上**数据成果+业务价值**
|
||||
3. ❌ 弱化个人贡献:用“参与、协助”描述,忽略队长的领导力;✅ 全程用**带领/主导/统筹/牵头**等强动词,明确个人在项目中的核心作用
|
||||
4. ❌ 单一价值:只说核心业务指标(胜率),忽略效率/成本/复用性;✅ 多维度拆解,让企业看到你能为公司带来**“业务增长+降本增效”**的双重价值
|
||||
|
||||
### 四、你的CS2项目最终优化版(整合所有方法,可直接贴简历)
|
||||
#### 基于CS2赛事的垂直领域数据仓库与战术分析平台
|
||||
**项目概述**:作为队长带领5人数据团队,针对电竞行业战术决策依赖经验、传统K/D指标无法量化战术价值的痛点,基于Python生态搭建「数据采集-ETL清洗-特征挖掘-可视化」全流程CS2赛事分析平台,完成1年内300+场职业比赛、1600+玩家、数十万回合级全量数据的结构化处理,**推动战队ELO分层胜率从42%提升至55%(+13个百分点)**,数据维护人力成本降低60%,搭建的特征模型成为战队战术分析/选手选拔的标准工具。
|
||||
|
||||
**核心职责与成果**:
|
||||
1. **数仓架构设计(Python全栈落地)**:主导设计L1(原始)-L2(星型模型)-L3(特征集市)分层数仓,通过Python/Pandas实现非结构化JSON数据的矢量化清洗与批处理,结合SQLite构建多粒度事实表/维度表,**实现1600+玩家数据毫秒级查询,为战术分析提供高效数据支撑**;
|
||||
2. **高阶特征工程(业务价值核心)**:带领团队基于Python/NumPy搭建模块化特征计算引擎,通过窗口函数完成200+维度玩家画像的高效计算,创新定义“压力表现/位置掌控”等战术指标,**量化传统指标无法反映的战术价值,战术组基于此完成80%的站位/阵容优化调整**;
|
||||
3. **自动化ETL流水线(降本增效)**:牵头开发Python+Playwright分布式爬虫,结合多线程实现赛事数据抓取、校验、入库全流程自动化,**将数据入库时间从小时级压缩至分钟级,数据维护人力成本降低60%,保障300+场比赛数据的实时性与完整性**;
|
||||
4. **数据驱动战术落地(闭环验证)**:通过Python实现战队ELO分层胜率预测模型,基于历史数据输出战术调整建议并落地,**完成“数据处理-特征建模-战术优化-胜率提升”的全链路闭环**;
|
||||
5. **团队统筹管理(队长价值)**:统筹5人团队分模块分工(数据采集/特征工程/可视化),制定Python代码规范与Git版本管控流程,**将团队整体开发效率提升40%,保障项目从0到1高效落地**。
|
||||
|
||||
**技能关键词**:Python(Pandas/NumPy/多线程/矢量化运算)、SQLite、SQL、ETL自动化、数据仓库设计、特征工程、Playwright、Flask、团队管理、电竞赛事数据分析
|
||||
|
||||
### 五、高端项目启发:从电竞数据项目到企业级数据项目的业务价值思维
|
||||
你的CS2项目已经具备企业级高端数据项目的核心雏形,高端项目对**业务价值**的要求会更强调**「规模化、可复用、商业变现」**,核心启发有3点:
|
||||
1. **从“单战队价值”到“行业规模化价值”**:企业级项目不仅服务单一业务方,而是能复用到整个行业/公司多业务线,比如你的CS2特征模型可从单战队拓展至青训选手选拔、赛事直播数据可视化、电竞俱乐部数据中台搭建;
|
||||
2. **从“战术价值”到“商业价值”**:高端项目需将数据价值转化为**可量化的商业收益**,比如电竞数据平台可通过为赛事方/俱乐部提供付费数据分析服务、为品牌方提供选手粉丝画像实现商业变现,企业中则是将数据成果转化为GMV提升、营收增长、获客成本降低;
|
||||
3. **从“人工落地”到“自动化决策”**:你的项目实现了“数据支撑战术决策”,高端项目会进一步实现**“数据自动化输出决策建议”**,比如通过Python搭建实时战术推荐模型,比赛中根据战局动态输出最优站位/道具使用建议,企业中则是智能推荐、自动化风控、精准营销等场景。
|
||||
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,7 @@ import json
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
from collections import defaultdict, deque
|
||||
from itertools import combinations
|
||||
from typing import Optional
|
||||
|
||||
# Setup logging
|
||||
@@ -326,6 +327,14 @@ def main(force_all: bool = False, workers: int = 1, create_backup: bool = True):
|
||||
processed_ids = [str(row[0]) for row in players]
|
||||
_update_percentiles(conn_l3, processed_ids)
|
||||
_rebuild_auxiliary_marts(conn_l2, conn_l3, processed_ids)
|
||||
_rebuild_team_marts(conn_l2, conn_l3, processed_ids)
|
||||
from database.L3.processors.narrative_processor import NarrativeProcessor
|
||||
narrative_counts = NarrativeProcessor.rebuild(
|
||||
conn_l2,
|
||||
conn_l3,
|
||||
processed_ids,
|
||||
)
|
||||
logger.info("Narrative marts rebuilt: %s", narrative_counts)
|
||||
|
||||
quick_check = conn_l3.execute("PRAGMA quick_check").fetchone()[0]
|
||||
if quick_check != 'ok':
|
||||
@@ -778,6 +787,153 @@ def _calculate_record_rows(history_rows):
|
||||
return result
|
||||
|
||||
|
||||
def _rebuild_team_marts(conn_l2, conn_l3, steam_ids):
|
||||
if not steam_ids:
|
||||
return
|
||||
|
||||
conn_l3.execute('DELETE FROM dm_duo_stats')
|
||||
conn_l3.execute('DELETE FROM dm_lineup_stats')
|
||||
|
||||
all_rows = []
|
||||
for start in range(0, len(steam_ids), 400):
|
||||
chunk = steam_ids[start:start + 400]
|
||||
placeholders = ','.join('?' for _ in chunk)
|
||||
all_rows.extend(conn_l2.execute(
|
||||
f"""
|
||||
SELECT
|
||||
p.match_id,
|
||||
p.steam_id_64,
|
||||
CASE
|
||||
WHEN p.group_id IN (1, 2) THEN p.group_id
|
||||
WHEN p.team_id IN (1, 2) THEN p.team_id
|
||||
END AS team_key,
|
||||
p.rating,
|
||||
p.is_win,
|
||||
m.start_time
|
||||
FROM fact_match_players p
|
||||
JOIN fact_matches m ON m.match_id = p.match_id
|
||||
WHERE p.steam_id_64 IN ({placeholders})
|
||||
""",
|
||||
chunk,
|
||||
).fetchall())
|
||||
|
||||
match_teams = defaultdict(list)
|
||||
for row in all_rows:
|
||||
if row['team_key'] is None:
|
||||
continue
|
||||
match_teams[(row['match_id'], row['team_key'])].append(row)
|
||||
|
||||
duo_accumulator = {}
|
||||
lineup_accumulator = {}
|
||||
for rows in match_teams.values():
|
||||
players = {
|
||||
str(row['steam_id_64']): row
|
||||
for row in rows
|
||||
}
|
||||
player_ids = sorted(players)
|
||||
if len(player_ids) < 2:
|
||||
continue
|
||||
is_win = bool(next(iter(players.values()))['is_win'])
|
||||
match_date = int(next(iter(players.values()))['start_time'] or 0)
|
||||
|
||||
for player_a, player_b in combinations(player_ids, 2):
|
||||
key = (player_a, player_b)
|
||||
accumulator = duo_accumulator.setdefault(key, {
|
||||
'matches': 0,
|
||||
'wins': 0,
|
||||
'rating_a': 0.0,
|
||||
'rating_b': 0.0,
|
||||
'first': match_date,
|
||||
'last': match_date,
|
||||
})
|
||||
accumulator['matches'] += 1
|
||||
accumulator['wins'] += int(is_win)
|
||||
accumulator['rating_a'] += float(players[player_a]['rating'] or 0)
|
||||
accumulator['rating_b'] += float(players[player_b]['rating'] or 0)
|
||||
accumulator['first'] = min(accumulator['first'], match_date)
|
||||
accumulator['last'] = max(accumulator['last'], match_date)
|
||||
|
||||
for size in range(2, min(5, len(player_ids)) + 1):
|
||||
for selected_ids in combinations(player_ids, size):
|
||||
lineup_key = '|'.join(selected_ids)
|
||||
accumulator = lineup_accumulator.setdefault(lineup_key, {
|
||||
'player_ids': selected_ids,
|
||||
'matches': 0,
|
||||
'wins': 0,
|
||||
'rating': 0.0,
|
||||
'first': match_date,
|
||||
'last': match_date,
|
||||
})
|
||||
accumulator['matches'] += 1
|
||||
accumulator['wins'] += int(is_win)
|
||||
accumulator['rating'] += (
|
||||
sum(float(players[steam_id]['rating'] or 0) for steam_id in selected_ids)
|
||||
/ len(selected_ids)
|
||||
)
|
||||
accumulator['first'] = min(accumulator['first'], match_date)
|
||||
accumulator['last'] = max(accumulator['last'], match_date)
|
||||
|
||||
duo_values = []
|
||||
for (player_a, player_b), value in duo_accumulator.items():
|
||||
matches = value['matches']
|
||||
avg_a = value['rating_a'] / matches
|
||||
avg_b = value['rating_b'] / matches
|
||||
duo_values.append((
|
||||
player_a,
|
||||
player_b,
|
||||
matches,
|
||||
value['wins'],
|
||||
value['wins'] / matches,
|
||||
avg_a,
|
||||
avg_b,
|
||||
(avg_a + avg_b) / 2,
|
||||
value['first'],
|
||||
value['last'],
|
||||
1 if matches >= 5 else 0,
|
||||
))
|
||||
conn_l3.executemany(
|
||||
"""
|
||||
INSERT INTO dm_duo_stats (
|
||||
steam_id_a, steam_id_b, matches, wins, win_rate,
|
||||
avg_rating_a, avg_rating_b, avg_combined_rating,
|
||||
first_match_date, last_match_date, sample_reliable
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
duo_values,
|
||||
)
|
||||
|
||||
lineup_values = []
|
||||
for lineup_key, value in lineup_accumulator.items():
|
||||
matches = value['matches']
|
||||
lineup_values.append((
|
||||
lineup_key,
|
||||
json.dumps(value['player_ids']),
|
||||
len(value['player_ids']),
|
||||
matches,
|
||||
value['wins'],
|
||||
value['wins'] / matches,
|
||||
value['rating'] / matches,
|
||||
value['first'],
|
||||
value['last'],
|
||||
1 if matches >= 3 else 0,
|
||||
))
|
||||
conn_l3.executemany(
|
||||
"""
|
||||
INSERT INTO dm_lineup_stats (
|
||||
lineup_key, player_ids_json, player_count,
|
||||
matches, wins, win_rate, avg_team_rating,
|
||||
first_match_date, last_match_date, sample_reliable
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
lineup_values,
|
||||
)
|
||||
logger.info(
|
||||
'Team marts rebuilt: %s duos, %s lineups',
|
||||
len(duo_values),
|
||||
len(lineup_values),
|
||||
)
|
||||
|
||||
|
||||
def _update_percentiles(conn_l3, steam_ids):
|
||||
"""Calculate a real percentile among eligible players in this build."""
|
||||
if not steam_ids:
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from itertools import combinations
|
||||
import json
|
||||
|
||||
|
||||
class NarrativeProcessor:
|
||||
RECORD_FIELDS = (
|
||||
('highest_rating', 'rating'),
|
||||
('most_kills', 'kills'),
|
||||
('highest_adr', 'adr'),
|
||||
('highest_kd', 'kd_ratio'),
|
||||
('most_headshots', 'headshot_count'),
|
||||
)
|
||||
|
||||
AWARD_PERIODS = {
|
||||
'daily': ('单日最佳', 1),
|
||||
'weekly': ('星期最佳', 2),
|
||||
'monthly': ('月度最佳', 5),
|
||||
'quarterly': ('季度最佳', 10),
|
||||
'yearly': ('年度最佳', 20),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def rebuild(conn_l2, conn_l3, roster_ids):
|
||||
for table in (
|
||||
'dm_match_reports',
|
||||
'dm_match_player_reports',
|
||||
'dm_player_record_events',
|
||||
'dm_team_season_stats',
|
||||
'dm_player_awards',
|
||||
):
|
||||
conn_l3.execute(f'DELETE FROM {table}')
|
||||
|
||||
rows = NarrativeProcessor._load_player_rows(conn_l2, roster_ids)
|
||||
record_events, record_keys = NarrativeProcessor._build_record_events(rows)
|
||||
player_reports = NarrativeProcessor._build_player_reports(rows, record_keys)
|
||||
match_reports = NarrativeProcessor._build_match_reports(rows, player_reports)
|
||||
seasons = NarrativeProcessor._build_seasons(match_reports, player_reports)
|
||||
awards = NarrativeProcessor._build_awards(rows)
|
||||
|
||||
NarrativeProcessor._insert_record_events(conn_l3, record_events)
|
||||
NarrativeProcessor._insert_player_reports(conn_l3, player_reports)
|
||||
NarrativeProcessor._insert_match_reports(conn_l3, match_reports)
|
||||
NarrativeProcessor._insert_seasons(conn_l3, seasons)
|
||||
NarrativeProcessor._insert_awards(conn_l3, awards)
|
||||
|
||||
return {
|
||||
'match_reports': len(match_reports),
|
||||
'player_reports': len(player_reports),
|
||||
'record_events': len(record_events),
|
||||
'seasons': len(seasons),
|
||||
'awards': len(awards),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _load_player_rows(conn_l2, roster_ids):
|
||||
if not roster_ids:
|
||||
return []
|
||||
placeholders = ','.join('?' for _ in roster_ids)
|
||||
rows = conn_l2.execute(
|
||||
f"""
|
||||
SELECT
|
||||
p.match_id,
|
||||
p.steam_id_64,
|
||||
CASE
|
||||
WHEN p.group_id IN (1, 2) THEN p.group_id
|
||||
WHEN p.team_id IN (1, 2) THEN p.team_id
|
||||
END AS team_key,
|
||||
p.rating,
|
||||
p.kd_ratio,
|
||||
p.adr,
|
||||
p.kast,
|
||||
p.kills,
|
||||
p.deaths,
|
||||
p.headshot_count,
|
||||
p.first_kill,
|
||||
p.first_death,
|
||||
p.throw_harm,
|
||||
p.flash_enemy,
|
||||
p.is_win,
|
||||
m.start_time,
|
||||
m.map_name,
|
||||
m.score_team1,
|
||||
m.score_team2
|
||||
FROM fact_match_players p
|
||||
JOIN fact_matches m ON m.match_id = p.match_id
|
||||
WHERE p.steam_id_64 IN ({placeholders})
|
||||
ORDER BY m.start_time, p.match_id, p.steam_id_64
|
||||
""",
|
||||
roster_ids,
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _build_record_events(rows):
|
||||
best_values = defaultdict(dict)
|
||||
last_event = {}
|
||||
events = []
|
||||
record_keys = defaultdict(list)
|
||||
|
||||
for row in rows:
|
||||
steam_id = str(row['steam_id_64'])
|
||||
for record_key, field in NarrativeProcessor.RECORD_FIELDS:
|
||||
raw_value = row.get(field)
|
||||
if raw_value is None:
|
||||
continue
|
||||
value = float(raw_value)
|
||||
previous = best_values[steam_id].get(record_key)
|
||||
if previous is None or value > previous:
|
||||
event = {
|
||||
'steam_id_64': steam_id,
|
||||
'record_key': record_key,
|
||||
'match_id': row['match_id'],
|
||||
'match_date': int(row['start_time']),
|
||||
'map_name': row['map_name'],
|
||||
'previous_value': previous,
|
||||
'record_value': value,
|
||||
'is_current_record': 0,
|
||||
}
|
||||
events.append(event)
|
||||
best_values[steam_id][record_key] = value
|
||||
last_event[(steam_id, record_key)] = event
|
||||
if previous is not None:
|
||||
record_keys[(row['match_id'], steam_id)].append(
|
||||
record_key
|
||||
)
|
||||
|
||||
for event in last_event.values():
|
||||
event['is_current_record'] = 1
|
||||
return events, record_keys
|
||||
|
||||
@staticmethod
|
||||
def _average(history, field):
|
||||
values = [
|
||||
float(row[field])
|
||||
for row in history
|
||||
if row.get(field) is not None
|
||||
]
|
||||
return sum(values) / len(values) if values else None
|
||||
|
||||
@staticmethod
|
||||
def _delta(value, baseline):
|
||||
if value is None or baseline is None:
|
||||
return None
|
||||
return float(value) - float(baseline)
|
||||
|
||||
@staticmethod
|
||||
def _performance_label(delta, baseline_matches):
|
||||
if baseline_matches < 5 or delta is None:
|
||||
return 'insufficient_sample'
|
||||
if delta >= 0.25:
|
||||
return 'surge'
|
||||
if delta >= 0.10:
|
||||
return 'above_form'
|
||||
if delta <= -0.25:
|
||||
return 'slump'
|
||||
if delta <= -0.10:
|
||||
return 'below_form'
|
||||
return 'stable'
|
||||
|
||||
@staticmethod
|
||||
def _build_player_reports(rows, record_keys):
|
||||
histories = defaultdict(lambda: deque(maxlen=20))
|
||||
reports = []
|
||||
for row in rows:
|
||||
steam_id = str(row['steam_id_64'])
|
||||
history = histories[steam_id]
|
||||
prior_rating = NarrativeProcessor._average(history, 'rating')
|
||||
prior_kd = NarrativeProcessor._average(history, 'kd_ratio')
|
||||
prior_adr = NarrativeProcessor._average(history, 'adr')
|
||||
rating_delta = NarrativeProcessor._delta(row['rating'], prior_rating)
|
||||
reports.append({
|
||||
'match_id': row['match_id'],
|
||||
'steam_id_64': steam_id,
|
||||
'match_date': int(row['start_time']),
|
||||
'rating': row['rating'],
|
||||
'kd_ratio': row['kd_ratio'],
|
||||
'adr': row['adr'],
|
||||
'kills': row['kills'],
|
||||
'deaths': row['deaths'],
|
||||
'baseline_matches': len(history),
|
||||
'prior_20_rating': prior_rating,
|
||||
'prior_20_kd': prior_kd,
|
||||
'prior_20_adr': prior_adr,
|
||||
'rating_delta': rating_delta,
|
||||
'kd_delta': NarrativeProcessor._delta(row['kd_ratio'], prior_kd),
|
||||
'adr_delta': NarrativeProcessor._delta(row['adr'], prior_adr),
|
||||
'performance_label': NarrativeProcessor._performance_label(
|
||||
rating_delta,
|
||||
len(history),
|
||||
),
|
||||
'record_keys_json': json.dumps(
|
||||
record_keys.get((row['match_id'], steam_id), [])
|
||||
),
|
||||
'team_key': row['team_key'],
|
||||
'is_win': row['is_win'],
|
||||
'map_name': row['map_name'],
|
||||
})
|
||||
history.append(row)
|
||||
return reports
|
||||
|
||||
@staticmethod
|
||||
def _build_match_reports(rows, player_reports):
|
||||
player_report_map = {
|
||||
(row['match_id'], row['steam_id_64']): row
|
||||
for row in player_reports
|
||||
}
|
||||
grouped = defaultdict(list)
|
||||
for row in rows:
|
||||
if row['team_key'] is not None:
|
||||
grouped[(row['match_id'], row['team_key'])].append(row)
|
||||
|
||||
by_match = defaultdict(list)
|
||||
for (match_id, team_key), team_rows in grouped.items():
|
||||
by_match[match_id].append((team_key, team_rows))
|
||||
|
||||
reports = []
|
||||
for match_id, candidates in by_match.items():
|
||||
team_key, team_rows = max(
|
||||
candidates,
|
||||
key=lambda item: (len(item[1]), -int(item[0] or 0)),
|
||||
)
|
||||
roster_count = len(team_rows)
|
||||
if roster_count == 0:
|
||||
continue
|
||||
mvp = max(
|
||||
team_rows,
|
||||
key=lambda row: (
|
||||
float(row['rating'] or 0),
|
||||
int(row['kills'] or 0),
|
||||
),
|
||||
)
|
||||
detailed = [
|
||||
player_report_map[(match_id, str(row['steam_id_64']))]
|
||||
for row in team_rows
|
||||
]
|
||||
eligible_improvers = [
|
||||
report for report in detailed
|
||||
if report['baseline_matches'] >= 5
|
||||
and report['rating_delta'] is not None
|
||||
]
|
||||
improver = (
|
||||
max(eligible_improvers, key=lambda report: report['rating_delta'])
|
||||
if eligible_improvers else None
|
||||
)
|
||||
strongest_duo = None
|
||||
if roster_count >= 2:
|
||||
duo = max(
|
||||
combinations(team_rows, 2),
|
||||
key=lambda pair: (
|
||||
float(pair[0]['rating'] or 0)
|
||||
+ float(pair[1]['rating'] or 0)
|
||||
),
|
||||
)
|
||||
strongest_duo = {
|
||||
'steam_ids': [
|
||||
str(duo[0]['steam_id_64']),
|
||||
str(duo[1]['steam_id_64']),
|
||||
],
|
||||
'avg_rating': (
|
||||
float(duo[0]['rating'] or 0)
|
||||
+ float(duo[1]['rating'] or 0)
|
||||
) / 2,
|
||||
}
|
||||
record_count = sum(
|
||||
len(json.loads(report['record_keys_json']))
|
||||
for report in detailed
|
||||
)
|
||||
is_win = bool(team_rows[0]['is_win'])
|
||||
team_rating = sum(
|
||||
float(row['rating'] or 0) for row in team_rows
|
||||
) / roster_count
|
||||
result_text = '取胜' if is_win else '失利'
|
||||
summary = (
|
||||
f"本场{result_text},队内平均 Rating {team_rating:.2f};"
|
||||
f"MVP Rating {float(mvp['rating'] or 0):.2f}"
|
||||
)
|
||||
if record_count:
|
||||
summary += f",刷新 {record_count} 项个人纪录"
|
||||
summary += '。'
|
||||
reports.append({
|
||||
'match_id': match_id,
|
||||
'match_date': int(team_rows[0]['start_time']),
|
||||
'map_name': team_rows[0]['map_name'],
|
||||
'roster_count': roster_count,
|
||||
'team_key': team_key,
|
||||
'is_win': int(is_win),
|
||||
'team_avg_rating': team_rating,
|
||||
'team_avg_adr': sum(
|
||||
float(row['adr'] or 0) for row in team_rows
|
||||
) / roster_count,
|
||||
'mvp_steam_id': str(mvp['steam_id_64']),
|
||||
'mvp_rating': mvp['rating'],
|
||||
'improver_steam_id': (
|
||||
improver['steam_id_64'] if improver else None
|
||||
),
|
||||
'improver_delta': (
|
||||
improver['rating_delta'] if improver else None
|
||||
),
|
||||
'strongest_duo_json': json.dumps(strongest_duo),
|
||||
'record_break_count': record_count,
|
||||
'summary_text': summary,
|
||||
})
|
||||
reports.sort(key=lambda row: (row['match_date'], row['match_id']))
|
||||
return reports
|
||||
|
||||
@staticmethod
|
||||
def _build_seasons(match_reports, player_reports):
|
||||
team_reports = [
|
||||
row for row in match_reports if row['roster_count'] >= 2
|
||||
]
|
||||
report_matches = {row['match_id'] for row in team_reports}
|
||||
seasons = defaultdict(list)
|
||||
for report in team_reports:
|
||||
year = datetime.fromtimestamp(
|
||||
report['match_date'],
|
||||
timezone.utc,
|
||||
).year
|
||||
seasons[str(year)].append(report)
|
||||
|
||||
result = []
|
||||
for season_key, reports in sorted(seasons.items()):
|
||||
year = int(season_key)
|
||||
start = int(datetime(year, 1, 1, tzinfo=timezone.utc).timestamp())
|
||||
end = int(
|
||||
datetime(year + 1, 1, 1, tzinfo=timezone.utc).timestamp()
|
||||
) - 1
|
||||
wins = sum(int(row['is_win']) for row in reports)
|
||||
maps = defaultdict(lambda: {'matches': 0, 'wins': 0})
|
||||
for row in reports:
|
||||
item = maps[row['map_name'] or 'Unknown']
|
||||
item['matches'] += 1
|
||||
item['wins'] += int(row['is_win'])
|
||||
best_map, best_map_data = max(
|
||||
maps.items(),
|
||||
key=lambda item: (
|
||||
item[1]['wins'] / item[1]['matches'],
|
||||
item[1]['matches'],
|
||||
),
|
||||
)
|
||||
ratings = defaultdict(list)
|
||||
for report in player_reports:
|
||||
if report['match_id'] not in report_matches:
|
||||
continue
|
||||
report_year = datetime.fromtimestamp(
|
||||
report['match_date'],
|
||||
timezone.utc,
|
||||
).year
|
||||
if report_year == year and report['rating'] is not None:
|
||||
ratings[report['steam_id_64']].append(
|
||||
float(report['rating'])
|
||||
)
|
||||
top_player, top_values = max(
|
||||
ratings.items(),
|
||||
key=lambda item: (sum(item[1]) / len(item[1]), len(item[1])),
|
||||
)
|
||||
result.append({
|
||||
'season_key': season_key,
|
||||
'season_label': f'{year} Season',
|
||||
'period_start': start,
|
||||
'period_end': end,
|
||||
'matches': len(reports),
|
||||
'wins': wins,
|
||||
'losses': len(reports) - wins,
|
||||
'win_rate': wins / len(reports),
|
||||
'avg_team_rating': sum(
|
||||
row['team_avg_rating'] for row in reports
|
||||
) / len(reports),
|
||||
'best_map': best_map,
|
||||
'best_map_win_rate': (
|
||||
best_map_data['wins'] / best_map_data['matches']
|
||||
),
|
||||
'top_player_steam_id': top_player,
|
||||
'top_player_rating': sum(top_values) / len(top_values),
|
||||
})
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _period_identity(timestamp, award_type):
|
||||
moment = datetime.fromtimestamp(timestamp, timezone.utc)
|
||||
if award_type == 'daily':
|
||||
start = datetime(moment.year, moment.month, moment.day, tzinfo=timezone.utc)
|
||||
return start.strftime('%Y-%m-%d'), start, start + timedelta(days=1)
|
||||
if award_type == 'weekly':
|
||||
start = datetime(
|
||||
moment.year,
|
||||
moment.month,
|
||||
moment.day,
|
||||
tzinfo=timezone.utc,
|
||||
) - timedelta(days=moment.weekday())
|
||||
iso_year, iso_week, _ = start.isocalendar()
|
||||
return f'{iso_year}-W{iso_week:02d}', start, start + timedelta(days=7)
|
||||
if award_type == 'monthly':
|
||||
start = datetime(moment.year, moment.month, 1, tzinfo=timezone.utc)
|
||||
end = (
|
||||
datetime(moment.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||
if moment.month == 12
|
||||
else datetime(moment.year, moment.month + 1, 1, tzinfo=timezone.utc)
|
||||
)
|
||||
return start.strftime('%Y-%m'), start, end
|
||||
if award_type == 'quarterly':
|
||||
quarter = (moment.month - 1) // 3 + 1
|
||||
start_month = (quarter - 1) * 3 + 1
|
||||
start = datetime(moment.year, start_month, 1, tzinfo=timezone.utc)
|
||||
end = (
|
||||
datetime(moment.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||
if quarter == 4
|
||||
else datetime(moment.year, start_month + 3, 1, tzinfo=timezone.utc)
|
||||
)
|
||||
return f'{moment.year}-Q{quarter}', start, end
|
||||
start = datetime(moment.year, 1, 1, tzinfo=timezone.utc)
|
||||
return str(moment.year), start, datetime(
|
||||
moment.year + 1,
|
||||
1,
|
||||
1,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_awards(rows):
|
||||
awards = []
|
||||
for award_type, (award_name, min_matches) in (
|
||||
NarrativeProcessor.AWARD_PERIODS.items()
|
||||
):
|
||||
periods = defaultdict(lambda: defaultdict(list))
|
||||
boundaries = {}
|
||||
for row in rows:
|
||||
period_key, start, end = NarrativeProcessor._period_identity(
|
||||
int(row['start_time']),
|
||||
award_type,
|
||||
)
|
||||
periods[period_key][str(row['steam_id_64'])].append(row)
|
||||
boundaries[period_key] = (start, end)
|
||||
|
||||
for period_key, player_rows in periods.items():
|
||||
candidates = []
|
||||
for steam_id, matches in player_rows.items():
|
||||
if len(matches) < min_matches:
|
||||
continue
|
||||
wins = sum(int(row['is_win'] or 0) for row in matches)
|
||||
kills = sum(int(row['kills'] or 0) for row in matches)
|
||||
deaths = sum(int(row['deaths'] or 0) for row in matches)
|
||||
avg_rating = sum(
|
||||
float(row['rating'] or 0) for row in matches
|
||||
) / len(matches)
|
||||
avg_adr = sum(
|
||||
float(row['adr'] or 0) for row in matches
|
||||
) / len(matches)
|
||||
avg_kd = kills / deaths if deaths else float(kills)
|
||||
win_rate = wins / len(matches)
|
||||
score = avg_rating + avg_adr * 0.001 + win_rate * 0.02
|
||||
candidates.append({
|
||||
'steam_id_64': steam_id,
|
||||
'matches': len(matches),
|
||||
'wins': wins,
|
||||
'win_rate': win_rate,
|
||||
'avg_rating': avg_rating,
|
||||
'avg_kd': avg_kd,
|
||||
'avg_adr': avg_adr,
|
||||
'performance_score': score,
|
||||
})
|
||||
if not candidates:
|
||||
continue
|
||||
winner = max(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
item['performance_score'],
|
||||
item['matches'],
|
||||
),
|
||||
)
|
||||
start, end = boundaries[period_key]
|
||||
awards.append({
|
||||
'award_type': award_type,
|
||||
'period_key': period_key,
|
||||
'period_label': f'{period_key} {award_name}',
|
||||
'period_start': int(start.timestamp()),
|
||||
'period_end': int(end.timestamp()) - 1,
|
||||
**winner,
|
||||
'sample_reliable': int(
|
||||
winner['matches'] >= min_matches
|
||||
),
|
||||
})
|
||||
awards.sort(
|
||||
key=lambda row: (row['period_start'], row['award_type'])
|
||||
)
|
||||
return awards
|
||||
|
||||
@staticmethod
|
||||
def _insert_record_events(conn, rows):
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO dm_player_record_events (
|
||||
steam_id_64, record_key, match_id, match_date, map_name,
|
||||
previous_value, record_value, is_current_record
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
row['steam_id_64'], row['record_key'], row['match_id'],
|
||||
row['match_date'], row['map_name'], row['previous_value'],
|
||||
row['record_value'], row['is_current_record'],
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _insert_player_reports(conn, rows):
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO dm_match_player_reports (
|
||||
match_id, steam_id_64, match_date, rating, kd_ratio, adr,
|
||||
kills, deaths, baseline_matches, prior_20_rating,
|
||||
prior_20_kd, prior_20_adr, rating_delta, kd_delta, adr_delta,
|
||||
performance_label, record_keys_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
row['match_id'], row['steam_id_64'], row['match_date'],
|
||||
row['rating'], row['kd_ratio'], row['adr'], row['kills'],
|
||||
row['deaths'], row['baseline_matches'],
|
||||
row['prior_20_rating'], row['prior_20_kd'],
|
||||
row['prior_20_adr'], row['rating_delta'], row['kd_delta'],
|
||||
row['adr_delta'], row['performance_label'],
|
||||
row['record_keys_json'],
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _insert_match_reports(conn, rows):
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO dm_match_reports (
|
||||
match_id, match_date, map_name, roster_count, team_key,
|
||||
is_win, team_avg_rating, team_avg_adr, mvp_steam_id,
|
||||
mvp_rating, improver_steam_id, improver_delta,
|
||||
strongest_duo_json, record_break_count, summary_text
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
tuple(row[key] for key in (
|
||||
'match_id', 'match_date', 'map_name', 'roster_count',
|
||||
'team_key', 'is_win', 'team_avg_rating', 'team_avg_adr',
|
||||
'mvp_steam_id', 'mvp_rating', 'improver_steam_id',
|
||||
'improver_delta', 'strongest_duo_json',
|
||||
'record_break_count', 'summary_text',
|
||||
))
|
||||
for row in rows
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _insert_seasons(conn, rows):
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO dm_team_season_stats (
|
||||
season_key, season_label, period_start, period_end,
|
||||
matches, wins, losses, win_rate, avg_team_rating,
|
||||
best_map, best_map_win_rate, top_player_steam_id,
|
||||
top_player_rating
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
tuple(row[key] for key in (
|
||||
'season_key', 'season_label', 'period_start', 'period_end',
|
||||
'matches', 'wins', 'losses', 'win_rate',
|
||||
'avg_team_rating', 'best_map', 'best_map_win_rate',
|
||||
'top_player_steam_id', 'top_player_rating',
|
||||
))
|
||||
for row in rows
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _insert_awards(conn, rows):
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO dm_player_awards (
|
||||
award_type, period_key, period_label, period_start,
|
||||
period_end, steam_id_64, matches, wins, win_rate,
|
||||
avg_rating, avg_kd, avg_adr, performance_score,
|
||||
sample_reliable
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
tuple(row[key] for key in (
|
||||
'award_type', 'period_key', 'period_label',
|
||||
'period_start', 'period_end', 'steam_id_64', 'matches',
|
||||
'wins', 'win_rate', 'avg_rating', 'avg_kd', 'avg_adr',
|
||||
'performance_score', 'sample_reliable',
|
||||
))
|
||||
for row in rows
|
||||
],
|
||||
)
|
||||
@@ -428,6 +428,165 @@ CREATE TABLE IF NOT EXISTS dm_player_records (
|
||||
CREATE INDEX IF NOT EXISTS idx_player_records_player
|
||||
ON dm_player_records(steam_id_64, record_key);
|
||||
|
||||
-- ============================================================================
|
||||
-- Team Mart: Duo performance
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS dm_duo_stats (
|
||||
steam_id_a TEXT NOT NULL,
|
||||
steam_id_b TEXT NOT NULL,
|
||||
matches INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
win_rate REAL,
|
||||
avg_rating_a REAL,
|
||||
avg_rating_b REAL,
|
||||
avg_combined_rating REAL,
|
||||
first_match_date INTEGER,
|
||||
last_match_date INTEGER,
|
||||
sample_reliable BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (steam_id_a, steam_id_b)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_duo_stats_matches
|
||||
ON dm_duo_stats(matches DESC, win_rate DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Team Mart: Actual 2-5 player combinations
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS dm_lineup_stats (
|
||||
lineup_key TEXT PRIMARY KEY,
|
||||
player_ids_json TEXT NOT NULL,
|
||||
player_count INTEGER NOT NULL,
|
||||
matches INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
win_rate REAL,
|
||||
avg_team_rating REAL,
|
||||
first_match_date INTEGER,
|
||||
last_match_date INTEGER,
|
||||
sample_reliable BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_lineup_stats_size_matches
|
||||
ON dm_lineup_stats(player_count, matches DESC, win_rate DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Narrative Mart: Post-match team report
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS dm_match_reports (
|
||||
match_id TEXT PRIMARY KEY,
|
||||
match_date INTEGER NOT NULL,
|
||||
map_name TEXT,
|
||||
roster_count INTEGER NOT NULL DEFAULT 0,
|
||||
team_key INTEGER,
|
||||
is_win BOOLEAN,
|
||||
team_avg_rating REAL,
|
||||
team_avg_adr REAL,
|
||||
mvp_steam_id TEXT,
|
||||
mvp_rating REAL,
|
||||
improver_steam_id TEXT,
|
||||
improver_delta REAL,
|
||||
strongest_duo_json TEXT,
|
||||
record_break_count INTEGER NOT NULL DEFAULT 0,
|
||||
summary_text TEXT,
|
||||
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_match_reports_date
|
||||
ON dm_match_reports(match_date DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Narrative Mart: Player performance vs prior form
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS dm_match_player_reports (
|
||||
match_id TEXT NOT NULL,
|
||||
steam_id_64 TEXT NOT NULL,
|
||||
match_date INTEGER NOT NULL,
|
||||
rating REAL,
|
||||
kd_ratio REAL,
|
||||
adr REAL,
|
||||
kills INTEGER,
|
||||
deaths INTEGER,
|
||||
baseline_matches INTEGER NOT NULL DEFAULT 0,
|
||||
prior_20_rating REAL,
|
||||
prior_20_kd REAL,
|
||||
prior_20_adr REAL,
|
||||
rating_delta REAL,
|
||||
kd_delta REAL,
|
||||
adr_delta REAL,
|
||||
performance_label TEXT,
|
||||
record_keys_json TEXT NOT NULL DEFAULT '[]',
|
||||
PRIMARY KEY (match_id, steam_id_64)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_match_player_reports_player_date
|
||||
ON dm_match_player_reports(steam_id_64, match_date DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Narrative Mart: Every career-record breaking event
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS dm_player_record_events (
|
||||
steam_id_64 TEXT NOT NULL,
|
||||
record_key TEXT NOT NULL,
|
||||
match_id TEXT NOT NULL,
|
||||
match_date INTEGER NOT NULL,
|
||||
map_name TEXT,
|
||||
previous_value REAL,
|
||||
record_value REAL NOT NULL,
|
||||
is_current_record BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (steam_id_64, record_key, match_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_record_events_player_date
|
||||
ON dm_player_record_events(steam_id_64, match_date DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Team Mart: Calendar season performance
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS dm_team_season_stats (
|
||||
season_key TEXT PRIMARY KEY,
|
||||
season_label TEXT NOT NULL,
|
||||
period_start INTEGER NOT NULL,
|
||||
period_end INTEGER NOT NULL,
|
||||
matches INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
losses INTEGER NOT NULL DEFAULT 0,
|
||||
win_rate REAL,
|
||||
avg_team_rating REAL,
|
||||
best_map TEXT,
|
||||
best_map_win_rate REAL,
|
||||
top_player_steam_id TEXT,
|
||||
top_player_rating REAL,
|
||||
last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Award Mart: Best player by calendar period
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS dm_player_awards (
|
||||
award_type TEXT NOT NULL,
|
||||
period_key TEXT NOT NULL,
|
||||
period_label TEXT NOT NULL,
|
||||
period_start INTEGER NOT NULL,
|
||||
period_end INTEGER NOT NULL,
|
||||
steam_id_64 TEXT NOT NULL,
|
||||
matches INTEGER NOT NULL,
|
||||
wins INTEGER NOT NULL,
|
||||
win_rate REAL,
|
||||
avg_rating REAL,
|
||||
avg_kd REAL,
|
||||
avg_adr REAL,
|
||||
performance_score REAL,
|
||||
sample_reliable BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (award_type, period_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_player_awards_player
|
||||
ON dm_player_awards(steam_id_64, period_start DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_player_awards_period
|
||||
ON dm_player_awards(award_type, period_start DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Schema Summary
|
||||
-- ============================================================================
|
||||
@@ -443,4 +602,11 @@ ON dm_player_records(steam_id_64, record_key);
|
||||
-- dm_player_weapon_stats: Weapon usage statistics
|
||||
-- dm_player_period_stats: Career/recent time-window aggregations
|
||||
-- dm_player_records: Career record values and source matches
|
||||
-- dm_duo_stats: Same-team pair performance
|
||||
-- dm_lineup_stats: Actual same-team 2-5 player combinations
|
||||
-- dm_match_reports: Post-match team narrative
|
||||
-- dm_match_player_reports: Player form deltas per match
|
||||
-- dm_player_record_events: Historical record-breaking moments
|
||||
-- dm_team_season_stats: Calendar season team summaries
|
||||
-- dm_player_awards: Daily/weekly/monthly/quarterly/yearly awards
|
||||
-- ============================================================================
|
||||
|
||||
Binary file not shown.
@@ -48,6 +48,38 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_team_lineups_single_active
|
||||
ON team_lineups(is_active)
|
||||
WHERE is_active = 1;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_roster_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
effective_from INTEGER NOT NULL,
|
||||
effective_to INTEGER,
|
||||
is_current INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (effective_to IS NULL OR effective_to >= effective_from)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_roster_versions_single_current
|
||||
ON team_roster_versions(is_current)
|
||||
WHERE is_current = 1;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_roster_versions_period
|
||||
ON team_roster_versions(effective_from, effective_to);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_roster_members (
|
||||
roster_version_id INTEGER NOT NULL,
|
||||
steam_id_64 TEXT NOT NULL,
|
||||
member_role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK (member_role IN ('starter', 'substitute', 'member')),
|
||||
position_order INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (roster_version_id, steam_id_64),
|
||||
FOREIGN KEY (roster_version_id)
|
||||
REFERENCES team_roster_versions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_roster_members_player
|
||||
ON team_roster_members(steam_id_64, roster_version_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wiki_pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
|
||||
+256
-1
@@ -44,9 +44,12 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
||||
paths = [
|
||||
'/',
|
||||
'/matches/',
|
||||
'/reports/',
|
||||
'/awards/',
|
||||
'/players/',
|
||||
f'/players/{self.roster_ids[0]}',
|
||||
'/teams/',
|
||||
'/teams/performance',
|
||||
'/tactics/',
|
||||
'/opponents/',
|
||||
]
|
||||
@@ -56,6 +59,47 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertGreater(len(response.data), 100)
|
||||
|
||||
def test_match_detail_contains_post_match_report(self):
|
||||
l3 = sqlite3.connect(Config.DB_L3_PATH)
|
||||
try:
|
||||
match_id = l3.execute(
|
||||
"""
|
||||
SELECT match_id
|
||||
FROM dm_match_reports
|
||||
ORDER BY match_date DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
l3.close()
|
||||
response = self.client.get(f'/matches/{match_id}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('赛后报告'.encode('utf-8'), response.data)
|
||||
self.assertIn('Form Improver'.encode('utf-8'), response.data)
|
||||
|
||||
def test_awards_and_professional_identity_render(self):
|
||||
response = self.client.get('/awards/?type=monthly')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('周期最佳与荣誉榜'.encode('utf-8'), response.data)
|
||||
self.assertIn('月度最佳'.encode('utf-8'), response.data)
|
||||
|
||||
response = self.client.get(f'/players/{self.roster_ids[0]}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'Professional Identity', response.data)
|
||||
self.assertIn('代表武器'.encode('utf-8'), response.data)
|
||||
|
||||
def test_team_performance_page_contains_beta_sections(self):
|
||||
response = self.client.get('/teams/performance')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
for label in (
|
||||
'战队履历与阵容表现',
|
||||
'二人组表现',
|
||||
'实际阵容组合',
|
||||
'Roster Version 历史',
|
||||
):
|
||||
with self.subTest(label=label):
|
||||
self.assertIn(label.encode('utf-8'), response.data)
|
||||
|
||||
def test_admin_integrity_page_and_json_render(self):
|
||||
with self.client.session_transaction() as session:
|
||||
session['is_admin'] = True
|
||||
@@ -93,19 +137,26 @@ class ApplicationIntegrationTests(unittest.TestCase):
|
||||
"""
|
||||
).fetchone()[0]
|
||||
|
||||
upload = io.BytesIO(raw.encode('utf-8'))
|
||||
try:
|
||||
response = self.client.post(
|
||||
'/admin/import-match',
|
||||
data={
|
||||
'capture': (
|
||||
io.BytesIO(raw.encode('utf-8')),
|
||||
upload,
|
||||
'iframe_network.json',
|
||||
),
|
||||
},
|
||||
content_type='multipart/form-data',
|
||||
follow_redirects=True,
|
||||
)
|
||||
finally:
|
||||
upload.close()
|
||||
try:
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(b'already imported with identical data', response.data)
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
def test_player_search_works_across_l2_and_l3(self):
|
||||
response = self.client.get('/players/?search=jAck')
|
||||
@@ -238,11 +289,18 @@ class L3MartBuilderTests(unittest.TestCase):
|
||||
)
|
||||
l3.commit()
|
||||
for table in (
|
||||
'dm_duo_stats',
|
||||
'dm_lineup_stats',
|
||||
'dm_match_player_reports',
|
||||
'dm_match_reports',
|
||||
'dm_player_awards',
|
||||
'dm_player_match_history',
|
||||
'dm_player_map_stats',
|
||||
'dm_player_period_stats',
|
||||
'dm_player_record_events',
|
||||
'dm_player_records',
|
||||
'dm_player_weapon_stats',
|
||||
'dm_team_season_stats',
|
||||
):
|
||||
count = l3.execute(
|
||||
f'SELECT COUNT(*) FROM {table}'
|
||||
@@ -615,6 +673,203 @@ class DatabaseGovernanceTests(unittest.TestCase):
|
||||
l3.close()
|
||||
l2.close()
|
||||
|
||||
def test_award_minimum_samples_and_unique_period_winner(self):
|
||||
minimums = {
|
||||
'daily': 1,
|
||||
'weekly': 2,
|
||||
'monthly': 5,
|
||||
'quarterly': 10,
|
||||
'yearly': 20,
|
||||
}
|
||||
l3 = sqlite3.connect(Config.DB_L3_PATH)
|
||||
try:
|
||||
rows = l3.execute(
|
||||
"""
|
||||
SELECT award_type, period_key, matches
|
||||
FROM dm_player_awards
|
||||
"""
|
||||
).fetchall()
|
||||
finally:
|
||||
l3.close()
|
||||
self.assertGreater(len(rows), 0)
|
||||
seen = set()
|
||||
for award_type, period_key, matches in rows:
|
||||
with self.subTest(award_type=award_type, period_key=period_key):
|
||||
self.assertGreaterEqual(matches, minimums[award_type])
|
||||
self.assertNotIn((award_type, period_key), seen)
|
||||
seen.add((award_type, period_key))
|
||||
|
||||
def test_seasons_only_include_team_matches(self):
|
||||
l2 = sqlite3.connect(Config.DB_L2_PATH)
|
||||
l3 = sqlite3.connect(Config.DB_L3_PATH)
|
||||
web = sqlite3.connect(Config.DB_WEB_PATH)
|
||||
try:
|
||||
roster_ids = json.loads(
|
||||
web.execute(
|
||||
"""
|
||||
SELECT player_ids_json
|
||||
FROM team_lineups
|
||||
WHERE is_active = 1
|
||||
"""
|
||||
).fetchone()[0]
|
||||
)
|
||||
placeholders = ','.join('?' for _ in roster_ids)
|
||||
expected = {
|
||||
str(row[0]): row[1]
|
||||
for row in l2.execute(
|
||||
f"""
|
||||
SELECT
|
||||
strftime('%Y', m.start_time, 'unixepoch') AS season,
|
||||
COUNT(DISTINCT grouped.match_id)
|
||||
FROM (
|
||||
SELECT
|
||||
match_id,
|
||||
CASE
|
||||
WHEN group_id IN (1, 2) THEN group_id
|
||||
WHEN team_id IN (1, 2) THEN team_id
|
||||
END AS team_key
|
||||
FROM fact_match_players
|
||||
WHERE steam_id_64 IN ({placeholders})
|
||||
GROUP BY match_id, team_key
|
||||
HAVING COUNT(DISTINCT steam_id_64) >= 2
|
||||
AND team_key IS NOT NULL
|
||||
) grouped
|
||||
JOIN fact_matches m ON m.match_id = grouped.match_id
|
||||
GROUP BY strftime('%Y', m.start_time, 'unixepoch')
|
||||
""",
|
||||
roster_ids,
|
||||
)
|
||||
}
|
||||
actual = {
|
||||
row[0]: row[1]
|
||||
for row in l3.execute(
|
||||
'SELECT season_key, matches FROM dm_team_season_stats'
|
||||
)
|
||||
}
|
||||
finally:
|
||||
l2.close()
|
||||
l3.close()
|
||||
web.close()
|
||||
self.assertEqual(actual, expected)
|
||||
|
||||
def test_roster_version_snapshot_closes_previous_version(self):
|
||||
from web.services.roster_version_service import RosterVersionService
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
web_path = Path(temp_dir) / 'Web.sqlite'
|
||||
l2_path = Path(temp_dir) / 'L2.sqlite'
|
||||
with sqlite3.connect(str(web_path)) as db:
|
||||
with open(Config.DB_WEB_SCHEMA_PATH, 'r', encoding='utf-8') as schema:
|
||||
db.executescript(schema.read())
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO team_lineups (
|
||||
name, player_ids_json, is_active
|
||||
) VALUES ('Test', '["1", "2"]', 1)
|
||||
"""
|
||||
)
|
||||
with sqlite3.connect(str(l2_path)) as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE fact_matches (
|
||||
match_id TEXT PRIMARY KEY,
|
||||
start_time INTEGER
|
||||
);
|
||||
CREATE TABLE fact_match_players (
|
||||
match_id TEXT,
|
||||
steam_id_64 TEXT
|
||||
);
|
||||
INSERT INTO fact_matches VALUES ('m1', 100);
|
||||
INSERT INTO fact_match_players VALUES ('m1', '1');
|
||||
INSERT INTO fact_match_players VALUES ('m1', '2');
|
||||
"""
|
||||
)
|
||||
|
||||
first_id = RosterVersionService.ensure_initial_version(
|
||||
str(web_path),
|
||||
str(l2_path),
|
||||
)
|
||||
second_id, created = RosterVersionService.snapshot_roster(
|
||||
['1', '3'],
|
||||
effective_from=200,
|
||||
web_db_path=str(web_path),
|
||||
)
|
||||
RosterVersionService.update_current_member_role(
|
||||
'1',
|
||||
'starter',
|
||||
web_db_path=str(web_path),
|
||||
)
|
||||
self.assertTrue(created)
|
||||
self.assertNotEqual(first_id, second_id)
|
||||
|
||||
with sqlite3.connect(str(web_path)) as db:
|
||||
old = db.execute(
|
||||
"""
|
||||
SELECT is_current, effective_to
|
||||
FROM team_roster_versions
|
||||
WHERE id = ?
|
||||
""",
|
||||
[first_id],
|
||||
).fetchone()
|
||||
current_ids = [
|
||||
row[0] for row in db.execute(
|
||||
"""
|
||||
SELECT steam_id_64
|
||||
FROM team_roster_members
|
||||
WHERE roster_version_id = ?
|
||||
ORDER BY position_order
|
||||
""",
|
||||
[second_id],
|
||||
)
|
||||
]
|
||||
role = db.execute(
|
||||
"""
|
||||
SELECT member_role
|
||||
FROM team_roster_members
|
||||
WHERE roster_version_id = ? AND steam_id_64 = '1'
|
||||
""",
|
||||
[second_id],
|
||||
).fetchone()[0]
|
||||
self.assertEqual(old, (0, 199))
|
||||
self.assertEqual(current_ids, ['1', '3'])
|
||||
self.assertEqual(role, 'starter')
|
||||
|
||||
def test_team_marts_cover_active_roster_combinations(self):
|
||||
import json
|
||||
from math import comb
|
||||
|
||||
web = sqlite3.connect(Config.DB_WEB_PATH)
|
||||
l3 = sqlite3.connect(Config.DB_L3_PATH)
|
||||
try:
|
||||
roster_ids = json.loads(
|
||||
web.execute(
|
||||
"""
|
||||
SELECT player_ids_json
|
||||
FROM team_lineups
|
||||
WHERE is_active = 1
|
||||
"""
|
||||
).fetchone()[0]
|
||||
)
|
||||
duo_count = l3.execute(
|
||||
'SELECT COUNT(*) FROM dm_duo_stats'
|
||||
).fetchone()[0]
|
||||
lineup_sizes = {
|
||||
row[0]: row[1]
|
||||
for row in l3.execute(
|
||||
"""
|
||||
SELECT player_count, COUNT(*)
|
||||
FROM dm_lineup_stats
|
||||
GROUP BY player_count
|
||||
"""
|
||||
)
|
||||
}
|
||||
finally:
|
||||
web.close()
|
||||
l3.close()
|
||||
|
||||
self.assertEqual(duo_count, comb(len(roster_ids), 2))
|
||||
self.assertTrue(all(size in lineup_sizes for size in (2, 3, 4, 5)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
+16
-1
@@ -13,9 +13,22 @@ def create_app(config_object=Config):
|
||||
app.config.from_object(config_object)
|
||||
|
||||
initialize_web_db()
|
||||
from web.services.roster_version_service import RosterVersionService
|
||||
RosterVersionService.ensure_initial_version()
|
||||
app.teardown_appcontext(close_dbs)
|
||||
|
||||
from web.routes import main, matches, players, teams, tactics, admin, wiki, opponents
|
||||
from web.routes import (
|
||||
admin,
|
||||
awards,
|
||||
main,
|
||||
matches,
|
||||
opponents,
|
||||
players,
|
||||
reports,
|
||||
tactics,
|
||||
teams,
|
||||
wiki,
|
||||
)
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(matches.bp)
|
||||
app.register_blueprint(players.bp)
|
||||
@@ -24,6 +37,8 @@ def create_app(config_object=Config):
|
||||
app.register_blueprint(admin.bp)
|
||||
app.register_blueprint(wiki.bp)
|
||||
app.register_blueprint(opponents.bp)
|
||||
app.register_blueprint(reports.bp)
|
||||
app.register_blueprint(awards.bp)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ class Config:
|
||||
DB_L3_PATH = str(L3_DB)
|
||||
DB_WEB_PATH = str(WEB_DB)
|
||||
DB_WEB_SCHEMA_PATH = str(WEB_SCHEMA)
|
||||
WEB_SCHEMA_VERSION = 2
|
||||
WEB_SCHEMA_VERSION = 3
|
||||
|
||||
MAX_CONTENT_LENGTH = 5 * 1024 * 1024
|
||||
SQLITE_TIMEOUT_SECONDS = 15
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ def initialize_web_db():
|
||||
""",
|
||||
[
|
||||
Config.WEB_SCHEMA_VERSION,
|
||||
'ETL jobs, match imports and active lineup governance',
|
||||
'Roster versions, member roles and team performance governance',
|
||||
],
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import Blueprint, render_template, request, Response
|
||||
from web.services.stats_service import StatsService
|
||||
from web.services.narrative_service import NarrativeService
|
||||
from web.config import Config
|
||||
import json
|
||||
|
||||
@@ -108,7 +109,8 @@ def detail(match_id):
|
||||
rounds=rounds,
|
||||
h2h_matrix=h2h_matrix,
|
||||
round_details=round_details,
|
||||
player_name_map=player_name_map)
|
||||
player_name_map=player_name_map,
|
||||
post_match_report=NarrativeService.get_match_report(match_id))
|
||||
|
||||
@bp.route('/<match_id>/raw')
|
||||
def raw_json(match_id):
|
||||
|
||||
@@ -2,14 +2,17 @@ from flask import Blueprint, render_template, request, jsonify, redirect, url_fo
|
||||
from web.services.stats_service import StatsService
|
||||
from web.services.feature_service import FeatureService
|
||||
from web.services.player_profile_service import PlayerProfileService
|
||||
from web.services.narrative_service import NarrativeService
|
||||
from web.services.web_service import WebService
|
||||
from web.database import execute_db, query_db
|
||||
from web.config import Config
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
|
||||
bp = Blueprint('players', __name__, url_prefix='/players')
|
||||
logger = logging.getLogger(__name__)
|
||||
ALLOWED_AVATAR_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
|
||||
|
||||
@bp.route('/')
|
||||
@@ -64,7 +67,10 @@ def detail(steam_id):
|
||||
|
||||
flash('Avatar updated successfully.', 'success')
|
||||
except Exception as e:
|
||||
print(f"Avatar upload error: {e}")
|
||||
logger.exception(
|
||||
"Avatar upload failed for player %s",
|
||||
steam_id,
|
||||
)
|
||||
flash('Error uploading avatar.', 'error')
|
||||
|
||||
WebService.update_player_metadata(steam_id, notes=notes)
|
||||
@@ -197,6 +203,8 @@ def detail(steam_id):
|
||||
map_stats=map_stats_list,
|
||||
period_stats=period_stats,
|
||||
records=records,
|
||||
professional_identity=NarrativeService.get_player_identity(steam_id),
|
||||
honors=NarrativeService.get_player_honors(steam_id),
|
||||
l2_stats=l2_stats,
|
||||
side_stats=side_stats)
|
||||
|
||||
|
||||
@@ -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
@@ -2,15 +2,19 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
|
||||
from web.services.web_service import WebService
|
||||
from web.services.stats_service import StatsService
|
||||
from web.services.feature_service import FeatureService
|
||||
from web.services.roster_version_service import RosterVersionService
|
||||
from web.services.team_performance_service import TeamPerformanceService
|
||||
from web.services.narrative_service import NarrativeService
|
||||
import json
|
||||
import logging
|
||||
|
||||
bp = Blueprint('teams', __name__, url_prefix='/teams')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- API Endpoints ---
|
||||
@bp.route('/api/search')
|
||||
def api_search():
|
||||
query = request.args.get('q', '').strip() # Strip whitespace
|
||||
print(f"DEBUG: Search Query Received: '{query}'") # Debug Log
|
||||
|
||||
if len(query) < 2:
|
||||
return jsonify([])
|
||||
@@ -20,9 +24,7 @@ def api_search():
|
||||
# Support sorting by matches for better "Find Player" experience
|
||||
sort_by = request.args.get('sort', 'matches')
|
||||
|
||||
print(f"DEBUG: Calling StatsService.get_players with search='{query}'")
|
||||
players, total = StatsService.get_players(page=1, per_page=50, search=query, sort_by=sort_by)
|
||||
print(f"DEBUG: Found {len(players)} players (Total: {total})")
|
||||
|
||||
# Format for frontend
|
||||
results = []
|
||||
@@ -62,7 +64,6 @@ def api_search():
|
||||
|
||||
results.sort(key=lambda x: x['matches'], reverse=True)
|
||||
|
||||
print(f"DEBUG: Returning {len(results)} results")
|
||||
return jsonify(results)
|
||||
|
||||
@bp.route('/api/roster', methods=['GET', 'POST'])
|
||||
@@ -88,6 +89,7 @@ def api_roster():
|
||||
current_ids = json.loads(target_team['player_ids_json'])
|
||||
except:
|
||||
pass
|
||||
previous_ids = list(current_ids)
|
||||
|
||||
if action == 'add':
|
||||
if steam_id not in current_ids:
|
||||
@@ -95,20 +97,36 @@ def api_roster():
|
||||
elif action == 'remove':
|
||||
if steam_id in current_ids:
|
||||
current_ids.remove(steam_id)
|
||||
else:
|
||||
return jsonify({'error': 'Unknown roster action'}), 400
|
||||
|
||||
# Pass lineup_id=target_team['id'] to update existing lineup
|
||||
WebService.save_lineup(target_team['name'], target_team['description'], current_ids, lineup_id=target_team['id'])
|
||||
WebService.save_lineup(
|
||||
target_team['name'],
|
||||
target_team['description'],
|
||||
current_ids,
|
||||
lineup_id=target_team['id'],
|
||||
)
|
||||
try:
|
||||
version_id, created = RosterVersionService.snapshot_roster(
|
||||
current_ids,
|
||||
name=f"{target_team['name']} Roster",
|
||||
)
|
||||
except Exception:
|
||||
WebService.save_lineup(
|
||||
target_team['name'],
|
||||
target_team['description'],
|
||||
previous_ids,
|
||||
lineup_id=target_team['id'],
|
||||
)
|
||||
raise
|
||||
return jsonify({'status': 'success', 'roster': current_ids})
|
||||
|
||||
# GET: Return detailed player info
|
||||
try:
|
||||
print(f"DEBUG: api_roster GET - Target Team: {target_team.get('id')}")
|
||||
p_ids_json = target_team.get('player_ids_json', '[]')
|
||||
p_ids = json.loads(p_ids_json)
|
||||
print(f"DEBUG: Player IDs: {p_ids}")
|
||||
|
||||
players = StatsService.get_players_by_ids(p_ids)
|
||||
print(f"DEBUG: Players fetched: {len(players) if players else 0}")
|
||||
|
||||
# Add extra stats needed for cards
|
||||
enriched = []
|
||||
@@ -130,9 +148,10 @@ def api_roster():
|
||||
|
||||
enriched.append(p_dict)
|
||||
except Exception as inner_e:
|
||||
print(f"ERROR: Processing player failed: {inner_e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.exception(
|
||||
"Failed to enrich roster player %s",
|
||||
p.get('steam_id_64') if hasattr(p, 'get') else 'unknown',
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
@@ -140,9 +159,7 @@ def api_roster():
|
||||
'roster': enriched
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"CRITICAL ERROR in api_roster: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
logger.exception("Roster API failed")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# --- Views ---
|
||||
@@ -151,6 +168,30 @@ def index():
|
||||
# Directly render the Clubhouse SPA
|
||||
return render_template('teams/clubhouse.html')
|
||||
|
||||
@bp.route('/performance')
|
||||
def performance():
|
||||
return render_template(
|
||||
'teams/performance.html',
|
||||
seasons=NarrativeService.get_seasons(),
|
||||
**TeamPerformanceService.get_dashboard(),
|
||||
)
|
||||
|
||||
@bp.route('/roster-role', methods=['POST'])
|
||||
def roster_role():
|
||||
if not session.get('is_admin'):
|
||||
return "Unauthorized", 403
|
||||
steam_id = request.form.get('steam_id')
|
||||
member_role = request.form.get('member_role')
|
||||
try:
|
||||
RosterVersionService.update_current_member_role(
|
||||
steam_id,
|
||||
member_role,
|
||||
)
|
||||
flash('Roster role updated.', 'success')
|
||||
except ValueError as exc:
|
||||
flash(str(exc), 'error')
|
||||
return redirect(url_for('teams.performance'))
|
||||
|
||||
# Deprecated routes (kept for compatibility if needed, but hidden)
|
||||
@bp.route('/list')
|
||||
def list_view():
|
||||
@@ -228,5 +269,5 @@ def detail(lineup_id):
|
||||
|
||||
return render_template('teams/detail.html', lineup=lineup, players=players, agg_stats=agg_stats, shared_matches=shared_matches, radar_data=radar_data)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return f"<pre>{traceback.format_exc()}</pre>", 500
|
||||
logger.exception("Lineup detail failed for lineup_id=%s", lineup_id)
|
||||
return "Unable to load lineup", 500
|
||||
|
||||
@@ -246,12 +246,19 @@ class IntegrityService:
|
||||
@staticmethod
|
||||
def _check_l3(db, roster_ids, checks, counts):
|
||||
required_tables = {
|
||||
'dm_duo_stats',
|
||||
'dm_match_player_reports',
|
||||
'dm_match_reports',
|
||||
'dm_player_features',
|
||||
'dm_player_awards',
|
||||
'dm_player_match_history',
|
||||
'dm_player_map_stats',
|
||||
'dm_player_period_stats',
|
||||
'dm_player_record_events',
|
||||
'dm_player_records',
|
||||
'dm_player_weapon_stats',
|
||||
'dm_lineup_stats',
|
||||
'dm_team_season_stats',
|
||||
}
|
||||
missing = sorted(required_tables - IntegrityService._table_names(db))
|
||||
IntegrityService._check(
|
||||
@@ -281,6 +288,42 @@ class IntegrityService:
|
||||
counts['l3_records'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_player_records'
|
||||
).fetchone()[0]
|
||||
counts['l3_duos'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_duo_stats'
|
||||
).fetchone()[0]
|
||||
counts['l3_lineups'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_lineup_stats'
|
||||
).fetchone()[0]
|
||||
counts['l3_match_reports'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_match_reports'
|
||||
).fetchone()[0]
|
||||
counts['l3_player_reports'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_match_player_reports'
|
||||
).fetchone()[0]
|
||||
counts['l3_record_events'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_player_record_events'
|
||||
).fetchone()[0]
|
||||
counts['l3_seasons'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_team_season_stats'
|
||||
).fetchone()[0]
|
||||
counts['l3_awards'] = db.execute(
|
||||
'SELECT COUNT(*) FROM dm_player_awards'
|
||||
).fetchone()[0]
|
||||
expected_matches = counts.get('matches', 0)
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Post-match report coverage',
|
||||
(
|
||||
'pass'
|
||||
if counts['l3_match_reports'] == expected_matches
|
||||
else 'fail'
|
||||
),
|
||||
(
|
||||
f"{counts['l3_match_reports']}/"
|
||||
f"{expected_matches} matches reported"
|
||||
),
|
||||
counts['l3_match_reports'],
|
||||
)
|
||||
|
||||
if roster_ids:
|
||||
placeholders = ','.join('?' for _ in roster_ids)
|
||||
@@ -323,6 +366,21 @@ class IntegrityService:
|
||||
f'{actual_history}/{expected_history} player-match rows materialized',
|
||||
actual_history,
|
||||
)
|
||||
player_report_count = db.execute(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM dm_match_player_reports
|
||||
WHERE steam_id_64 IN ({placeholders})
|
||||
""",
|
||||
roster_ids,
|
||||
).fetchone()[0]
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Roster player-report completeness',
|
||||
'pass' if player_report_count == expected_history else 'fail',
|
||||
f'{player_report_count}/{expected_history} player reports',
|
||||
player_report_count,
|
||||
)
|
||||
|
||||
score_rows = db.execute(
|
||||
f"""
|
||||
@@ -378,6 +436,13 @@ class IntegrityService:
|
||||
('l3_weapons', 'Player weapon stats mart'),
|
||||
('l3_periods', 'Player period stats mart'),
|
||||
('l3_records', 'Player records mart'),
|
||||
('l3_duos', 'Team duo stats mart'),
|
||||
('l3_lineups', 'Team lineup stats mart'),
|
||||
('l3_match_reports', 'Post-match report mart'),
|
||||
('l3_player_reports', 'Player post-match report mart'),
|
||||
('l3_record_events', 'Record event mart'),
|
||||
('l3_seasons', 'Team season mart'),
|
||||
('l3_awards', 'Player award mart'),
|
||||
):
|
||||
value = counts[key]
|
||||
IntegrityService._check(
|
||||
@@ -424,6 +489,8 @@ class IntegrityService:
|
||||
'schema_migrations',
|
||||
'strategy_boards',
|
||||
'team_lineups',
|
||||
'team_roster_members',
|
||||
'team_roster_versions',
|
||||
'wiki_pages',
|
||||
}
|
||||
missing = sorted(required_tables - IntegrityService._table_names(db))
|
||||
@@ -509,3 +576,42 @@ class IntegrityService:
|
||||
f'{active_count} active lineups configured',
|
||||
active_count,
|
||||
)
|
||||
|
||||
current_versions = db.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM team_roster_versions
|
||||
WHERE is_current = 1
|
||||
"""
|
||||
).fetchall()
|
||||
current_member_count = 0
|
||||
if len(current_versions) == 1:
|
||||
current_member_count = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM team_roster_members
|
||||
WHERE roster_version_id = ?
|
||||
""",
|
||||
[current_versions[0]['id']],
|
||||
).fetchone()[0]
|
||||
counts['roster_versions'] = db.execute(
|
||||
'SELECT COUNT(*) FROM team_roster_versions'
|
||||
).fetchone()[0]
|
||||
counts['current_roster_members'] = current_member_count
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Current roster version',
|
||||
'pass' if len(current_versions) == 1 else 'fail',
|
||||
f'{len(current_versions)} current roster versions',
|
||||
len(current_versions),
|
||||
)
|
||||
IntegrityService._check(
|
||||
checks,
|
||||
'Roster version membership',
|
||||
'pass' if current_member_count == counts.get('active_roster', 0) else 'fail',
|
||||
(
|
||||
f"{current_member_count}/"
|
||||
f"{counts.get('active_roster', 0)} active members versioned"
|
||||
),
|
||||
current_member_count,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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('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('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('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>
|
||||
@@ -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('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('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('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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -43,6 +43,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "matches/_post_match_report.html" %}
|
||||
|
||||
<!-- Tab: Overview -->
|
||||
<div x-show="tab === 'overview'" class="space-y-6">
|
||||
<!-- 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>
|
||||
@@ -148,6 +148,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "players/_professional_identity.html" %}
|
||||
|
||||
<!-- 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="flex flex-col lg:flex-row gap-8 items-center">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -13,6 +13,9 @@
|
||||
</h2>
|
||||
</div>
|
||||
<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') %}
|
||||
<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
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user