mirror of
https://github.com/StarCitizenToolBox/app.git
synced 2026-01-13 19:50:28 +00:00
840 lines
31 KiB
Dart
840 lines
31 KiB
Dart
import 'dart:io';
|
||
import 'dart:convert';
|
||
import 'dart:isolate';
|
||
import 'package:starcitizen_doctor/common/helper/game_log_analyzer.dart';
|
||
|
||
/// 年度报告数据类
|
||
class YearlyReportData {
|
||
// 基础统计
|
||
final int totalLaunchCount; // 累计启动次数
|
||
final Duration totalPlayTime; // 累计游玩时长
|
||
final int yearlyLaunchCount; // 年度启动次数
|
||
final Duration yearlyPlayTime; // 年度游玩时长
|
||
final int totalCrashCount; // 总崩溃次数
|
||
final int yearlyCrashCount; // 年度崩溃次数
|
||
|
||
// 时间统计
|
||
final DateTime? yearlyFirstLaunchTime; // 年度第一次启动时间
|
||
final DateTime? earliestPlayDate; // 年度游玩最早的一天 (05:00及以后)
|
||
final DateTime? latestPlayDate; // 年度游玩最晚的一天 (04:00及以前)
|
||
|
||
// 游玩时长统计
|
||
final Duration? longestSession; // 最长单次游玩时长
|
||
final DateTime? longestSessionDate; // 最长游玩那一天
|
||
final Duration? shortestSession; // 最短单次游玩时长 (超过5分钟的)
|
||
final DateTime? shortestSessionDate; // 最短游玩那一天
|
||
final Duration? averageSessionTime; // 平均单次游玩时长
|
||
|
||
// 载具统计
|
||
final int yearlyVehicleDestructionCount; // 年度炸船次数
|
||
final String? mostDestroyedVehicle; // 年度炸的最多的船
|
||
final int mostDestroyedVehicleCount; // 炸的最多的船的次数
|
||
final String? mostPilotedVehicle; // 年度最爱驾驶的载具
|
||
final int mostPilotedVehicleCount; // 驾驶次数
|
||
|
||
// 账号统计
|
||
final int accountCount; // 账号数量
|
||
final String? mostPlayedAccount; // 游玩最多的账号
|
||
final int mostPlayedAccountSessionCount; // 游玩最多的账号的会话次数
|
||
|
||
// 地点统计
|
||
final List<MapEntry<String, int>> topLocations; // Top 地点访问统计
|
||
|
||
// 击杀统计 (K/D)
|
||
final int yearlyKillCount; // 年度击杀次数
|
||
final int yearlyDeathCount; // 年度死亡次数
|
||
final int yearlySelfKillCount; // 年度自杀次数
|
||
|
||
// 月份统计
|
||
final int? mostPlayedMonth; // 游玩最多的月份 (1-12)
|
||
final int mostPlayedMonthCount; // 该月游玩次数
|
||
final int? leastPlayedMonth; // 游玩最少的月份 (1-12, 不包括完全没上游戏的月份)
|
||
final int leastPlayedMonthCount; // 该月游玩次数
|
||
|
||
// 连续游玩/离线统计
|
||
final int longestPlayStreak; // 最长连续游玩天数
|
||
final DateTime? playStreakStartDate; // 连续游玩开始日期
|
||
final DateTime? playStreakEndDate; // 连续游玩结束日期
|
||
final int longestOfflineStreak; // 最长连续离线天数
|
||
final DateTime? offlineStreakStartDate; // 连续离线开始日期
|
||
final DateTime? offlineStreakEndDate; // 连续离线结束日期
|
||
|
||
// 详细数据 (用于展示)
|
||
final Map<String, int> vehiclePilotedDetails; // 驾驶载具详情
|
||
final Map<String, int> accountSessionDetails; // 账号会话详情
|
||
final Map<String, int> locationDetails; // 地点访问详情
|
||
|
||
const YearlyReportData({
|
||
required this.totalLaunchCount,
|
||
required this.totalPlayTime,
|
||
required this.yearlyLaunchCount,
|
||
required this.yearlyPlayTime,
|
||
required this.totalCrashCount,
|
||
required this.yearlyCrashCount,
|
||
this.yearlyFirstLaunchTime,
|
||
this.earliestPlayDate,
|
||
this.latestPlayDate,
|
||
this.longestSession,
|
||
this.longestSessionDate,
|
||
this.shortestSession,
|
||
this.shortestSessionDate,
|
||
this.averageSessionTime,
|
||
required this.yearlyVehicleDestructionCount,
|
||
this.mostDestroyedVehicle,
|
||
required this.mostDestroyedVehicleCount,
|
||
this.mostPilotedVehicle,
|
||
required this.mostPilotedVehicleCount,
|
||
required this.accountCount,
|
||
this.mostPlayedAccount,
|
||
required this.mostPlayedAccountSessionCount,
|
||
required this.topLocations,
|
||
required this.yearlyKillCount,
|
||
required this.yearlyDeathCount,
|
||
required this.yearlySelfKillCount,
|
||
this.mostPlayedMonth,
|
||
required this.mostPlayedMonthCount,
|
||
this.leastPlayedMonth,
|
||
required this.leastPlayedMonthCount,
|
||
required this.longestPlayStreak,
|
||
this.playStreakStartDate,
|
||
this.playStreakEndDate,
|
||
required this.longestOfflineStreak,
|
||
this.offlineStreakStartDate,
|
||
this.offlineStreakEndDate,
|
||
required this.vehiclePilotedDetails,
|
||
required this.accountSessionDetails,
|
||
required this.locationDetails,
|
||
});
|
||
|
||
/// 将 DateTime 转换为 UTC 毫秒时间戳
|
||
static int? _toUtcTimestamp(DateTime? dateTime) {
|
||
if (dateTime == null) return null;
|
||
return dateTime.toUtc().millisecondsSinceEpoch;
|
||
}
|
||
|
||
/// 转换为 JSON Map
|
||
///
|
||
/// 时间字段使用 UTC 毫秒时间戳 (int),配合 timezoneOffsetMinutes 可在客户端还原本地时间
|
||
Map<String, dynamic> toJson() {
|
||
final now = DateTime.now();
|
||
final offset = now.timeZoneOffset;
|
||
|
||
return {
|
||
// 元数据
|
||
'generatedAtUtc': _toUtcTimestamp(now),
|
||
'timezoneOffsetMinutes': offset.inMinutes,
|
||
|
||
// 基础统计
|
||
'totalLaunchCount': totalLaunchCount,
|
||
'totalPlayTimeMs': totalPlayTime.inMilliseconds,
|
||
'yearlyLaunchCount': yearlyLaunchCount,
|
||
'yearlyPlayTimeMs': yearlyPlayTime.inMilliseconds,
|
||
'totalCrashCount': totalCrashCount,
|
||
'yearlyCrashCount': yearlyCrashCount,
|
||
|
||
// 时间统计 (UTC 毫秒时间戳)
|
||
'yearlyFirstLaunchTimeUtc': _toUtcTimestamp(yearlyFirstLaunchTime),
|
||
'earliestPlayDateUtc': _toUtcTimestamp(earliestPlayDate),
|
||
'latestPlayDateUtc': _toUtcTimestamp(latestPlayDate),
|
||
|
||
// 游玩时长统计
|
||
'longestSessionMs': longestSession?.inMilliseconds,
|
||
'longestSessionDateUtc': _toUtcTimestamp(longestSessionDate),
|
||
'shortestSessionMs': shortestSession?.inMilliseconds,
|
||
'shortestSessionDateUtc': _toUtcTimestamp(shortestSessionDate),
|
||
'averageSessionTimeMs': averageSessionTime?.inMilliseconds,
|
||
|
||
// 载具统计
|
||
'yearlyVehicleDestructionCount': yearlyVehicleDestructionCount,
|
||
'mostDestroyedVehicle': mostDestroyedVehicle,
|
||
'mostDestroyedVehicleCount': mostDestroyedVehicleCount,
|
||
'mostPilotedVehicle': mostPilotedVehicle,
|
||
'mostPilotedVehicleCount': mostPilotedVehicleCount,
|
||
|
||
// 账号统计
|
||
'accountCount': accountCount,
|
||
'mostPlayedAccount': mostPlayedAccount,
|
||
'mostPlayedAccountSessionCount': mostPlayedAccountSessionCount,
|
||
|
||
// 地点统计
|
||
'topLocations': topLocations.map((e) => {'location': e.key, 'count': e.value}).toList(),
|
||
|
||
// 击杀统计
|
||
'yearlyKillCount': yearlyKillCount,
|
||
'yearlyDeathCount': yearlyDeathCount,
|
||
'yearlySelfKillCount': yearlySelfKillCount,
|
||
|
||
// 月份统计
|
||
'mostPlayedMonth': mostPlayedMonth,
|
||
'mostPlayedMonthCount': mostPlayedMonthCount,
|
||
'leastPlayedMonth': leastPlayedMonth,
|
||
'leastPlayedMonthCount': leastPlayedMonthCount,
|
||
|
||
// 连续游玩/离线统计
|
||
'longestPlayStreak': longestPlayStreak,
|
||
'playStreakStartDateUtc': _toUtcTimestamp(playStreakStartDate),
|
||
'playStreakEndDateUtc': _toUtcTimestamp(playStreakEndDate),
|
||
'longestOfflineStreak': longestOfflineStreak,
|
||
'offlineStreakStartDateUtc': _toUtcTimestamp(offlineStreakStartDate),
|
||
'offlineStreakEndDateUtc': _toUtcTimestamp(offlineStreakEndDate),
|
||
|
||
// 详细数据
|
||
'vehiclePilotedDetails': vehiclePilotedDetails,
|
||
'accountSessionDetails': accountSessionDetails,
|
||
'locationDetails': locationDetails,
|
||
};
|
||
}
|
||
|
||
@override
|
||
String toString() {
|
||
return '''YearlyReportData(
|
||
totalLaunchCount: $totalLaunchCount,
|
||
totalPlayTime: $totalPlayTime,
|
||
yearlyLaunchCount: $yearlyLaunchCount,
|
||
yearlyPlayTime: $yearlyPlayTime,
|
||
totalCrashCount: $totalCrashCount,
|
||
yearlyCrashCount: $yearlyCrashCount,
|
||
yearlyFirstLaunchTime: $yearlyFirstLaunchTime,
|
||
earliestPlayDate: $earliestPlayDate,
|
||
latestPlayDate: $latestPlayDate,
|
||
longestSession: $longestSession (on $longestSessionDate),
|
||
shortestSession: $shortestSession (on $shortestSessionDate),
|
||
averageSessionTime: $averageSessionTime,
|
||
yearlyVehicleDestructionCount: $yearlyVehicleDestructionCount,
|
||
mostDestroyedVehicle: $mostDestroyedVehicle ($mostDestroyedVehicleCount),
|
||
mostPilotedVehicle: $mostPilotedVehicle ($mostPilotedVehicleCount),
|
||
accountCount: $accountCount,
|
||
mostPlayedAccount: $mostPlayedAccount ($mostPlayedAccountSessionCount),
|
||
topLocations: ${topLocations.take(5).map((e) => '${e.key}: ${e.value}').join(', ')},
|
||
)''';
|
||
}
|
||
}
|
||
|
||
/// 单个日志文件的统计结果 (内部使用)
|
||
class _LogFileStats {
|
||
DateTime? startTime;
|
||
DateTime? endTime;
|
||
bool hasCrash = false;
|
||
int killCount = 0;
|
||
int deathCount = 0;
|
||
int selfKillCount = 0;
|
||
Set<String> playerNames = {};
|
||
String? currentPlayerName;
|
||
String? firstPlayerName; // 第一个检测到的玩家名,用于去重
|
||
|
||
// 载具损毁: 载具型号 (去除ID后) -> 次数
|
||
Map<String, int> vehicleDestruction = {};
|
||
|
||
// 驾驶载具: 载具型号 (去除ID后) -> 次数
|
||
Map<String, int> vehiclePiloted = {};
|
||
|
||
// 地点访问: 地点名 -> 次数
|
||
Map<String, int> locationVisits = {};
|
||
|
||
// 上次记录死亡的时间 (用于 2s 内去重)
|
||
DateTime? _lastDeathTime;
|
||
|
||
// 年度内的会话记录
|
||
List<_SessionInfo> yearlySessions = [];
|
||
|
||
/// 生成用于去重的唯一标识
|
||
/// 基于启动时间和第一个玩家名生成
|
||
String? get uniqueKey {
|
||
if (startTime == null) return null;
|
||
final timeKey = startTime!.toUtc().toIso8601String();
|
||
final playerKey = firstPlayerName ?? 'unknown';
|
||
return '$timeKey|$playerKey';
|
||
}
|
||
}
|
||
|
||
/// 单次游玩会话信息
|
||
class _SessionInfo {
|
||
final DateTime startTime;
|
||
final DateTime endTime;
|
||
|
||
_SessionInfo({required this.startTime, required this.endTime});
|
||
|
||
Duration get duration => endTime.difference(startTime);
|
||
}
|
||
|
||
/// 年度报告分析器
|
||
class YearlyReportAnalyzer {
|
||
static final _characterNamePattern = RegExp(r'name\s+([^-]+)');
|
||
static final _vehicleDestructionPattern = RegExp(
|
||
r"Vehicle\s+'([^']+)'.*?" // 载具型号
|
||
r"in zone\s+'([^']+)'.*?" // Zone
|
||
r"destroy level \d+ to (\d+).*?" // 损毁等级
|
||
r"caused by\s+'([^']+)'", // 责任方
|
||
);
|
||
static final _actorDeathPattern = RegExp(
|
||
r"Actor '([^']+)'.*?" // 受害者ID
|
||
r"ejected from zone '([^']+)'.*?" // 原载具/区域
|
||
r"to zone '([^']+)'", // 目标区域
|
||
);
|
||
|
||
// Legacy 格式的正则表达式 (旧版日志)
|
||
static final _legacyActorDeathPattern = RegExp(
|
||
r"CActor::Kill: '([^']+)'.*?" // 受害者ID
|
||
r"in zone '([^']+)'.*?" // 死亡位置区域
|
||
r"killed by '([^']+)'.*?" // 击杀者ID
|
||
r"with damage type '([^']+)'", // 伤害类型
|
||
);
|
||
static final _requestLocationInventoryPattern = RegExp(r"Player\[([^\]]+)\].*?Location\[([^\]]+)\]");
|
||
|
||
/// 分析单个日志文件
|
||
static Future<_LogFileStats> _analyzeLogFile(File logFile, int targetYear) async {
|
||
final stats = _LogFileStats();
|
||
|
||
try {
|
||
if (!(await logFile.exists())) {
|
||
return stats;
|
||
}
|
||
|
||
final content = utf8.decode(await logFile.readAsBytes(), allowMalformed: true);
|
||
final lines = content.split('\n');
|
||
|
||
for (final line in lines) {
|
||
if (line.isEmpty) continue;
|
||
|
||
final lineTime = GameLogAnalyzer.getLogLineDateTime(line);
|
||
|
||
// 记录开始时间 (第一个有效时间)
|
||
if (stats.startTime == null && lineTime != null) {
|
||
stats.startTime = lineTime;
|
||
}
|
||
|
||
// 更新结束时间 (最后一个有效时间)
|
||
if (lineTime != null) {
|
||
stats.endTime = lineTime;
|
||
}
|
||
|
||
// 检测崩溃
|
||
if (line.contains("Cloud Imperium Games public crash handler")) {
|
||
stats.hasCrash = true;
|
||
}
|
||
|
||
// 检测玩家登录
|
||
if (line.contains('AccountLoginCharacterStatus_Character')) {
|
||
final nameMatch = _characterNamePattern.firstMatch(line);
|
||
if (nameMatch != null) {
|
||
final playerName = nameMatch.group(1)?.trim();
|
||
if (playerName != null &&
|
||
playerName.isNotEmpty &&
|
||
!playerName.contains(' ') &&
|
||
!playerName.contains('/') &&
|
||
!playerName.contains(r'\\') &&
|
||
!playerName.contains('.')) {
|
||
stats.currentPlayerName = playerName;
|
||
// 去重添加到玩家列表 (忽略大小写)
|
||
if (!stats.playerNames.any((n) => n.toLowerCase() == playerName.toLowerCase())) {
|
||
stats.playerNames.add(playerName);
|
||
}
|
||
stats.firstPlayerName ??= playerName;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 年度内的统计
|
||
if (lineTime != null && lineTime.year == targetYear) {
|
||
// 检测载具损毁
|
||
final destructionMatch = _vehicleDestructionPattern.firstMatch(line);
|
||
if (destructionMatch != null) {
|
||
final vehicleModel = destructionMatch.group(1);
|
||
final causedBy = destructionMatch.group(4)?.trim();
|
||
|
||
if (vehicleModel != null &&
|
||
causedBy != null &&
|
||
stats.currentPlayerName != null &&
|
||
causedBy == stats.currentPlayerName) {
|
||
final cleanVehicleName = GameLogAnalyzer.removeVehicleId(vehicleModel);
|
||
stats.vehicleDestruction[cleanVehicleName] = (stats.vehicleDestruction[cleanVehicleName] ?? 0) + 1;
|
||
}
|
||
}
|
||
|
||
// 检测驾驶载具
|
||
final controlMatch = GameLogAnalyzer.vehicleControlPattern.firstMatch(line);
|
||
if (controlMatch != null) {
|
||
final vehicleName = controlMatch.group(1);
|
||
if (vehicleName != null) {
|
||
final cleanVehicleName = GameLogAnalyzer.removeVehicleId(vehicleName);
|
||
// 过滤掉名为 "Default" 的载具
|
||
if (cleanVehicleName != 'Default') {
|
||
stats.vehiclePiloted[cleanVehicleName] = (stats.vehiclePiloted[cleanVehicleName] ?? 0) + 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检测死亡 (新版格式)
|
||
var deathMatch = _actorDeathPattern.firstMatch(line);
|
||
if (deathMatch != null) {
|
||
final victimId = deathMatch.group(1)?.trim();
|
||
if (victimId != null && stats.currentPlayerName != null && victimId == stats.currentPlayerName) {
|
||
// 防抖去重 (2秒内不重复计数)
|
||
if (stats._lastDeathTime == null || lineTime.difference(stats._lastDeathTime!).abs().inSeconds > 2) {
|
||
stats.deathCount++;
|
||
stats._lastDeathTime = lineTime;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检测死亡 (旧版格式 - Legacy)
|
||
final legacyDeathMatch = _legacyActorDeathPattern.firstMatch(line);
|
||
if (legacyDeathMatch != null) {
|
||
final victimId = legacyDeathMatch.group(1)?.trim();
|
||
final killerId = legacyDeathMatch.group(3)?.trim();
|
||
|
||
if (victimId != null && stats.currentPlayerName != null) {
|
||
bool isRecent =
|
||
stats._lastDeathTime != null && lineTime.difference(stats._lastDeathTime!).abs().inSeconds <= 2;
|
||
|
||
// 检测自杀
|
||
// 自杀逻辑:selfKillCount 独立统计自杀次数
|
||
// deathCount 包含所有死亡(普通死亡+自杀),因此自杀时不再从 deathCount 回退
|
||
if (victimId == killerId) {
|
||
if (victimId == stats.currentPlayerName) {
|
||
if (isRecent) {
|
||
// 如果最近已经记录过一次死亡 (通用格式记录的),说明已经在 deathCount 中计入
|
||
// 只需额外标记为自杀
|
||
stats.selfKillCount++;
|
||
// 更新时间以保持锁定
|
||
stats._lastDeathTime = lineTime;
|
||
} else {
|
||
// 没有被新版格式记录过,需要同时计入 deathCount 和 selfKillCount
|
||
stats.deathCount++;
|
||
stats.selfKillCount++;
|
||
stats._lastDeathTime = lineTime;
|
||
}
|
||
}
|
||
} else {
|
||
// 检测死亡 (被击杀)
|
||
if (victimId == stats.currentPlayerName) {
|
||
// 如果最近已经记录过 (可能是通用格式),则认为是同一事件,忽略
|
||
if (!isRecent) {
|
||
stats.deathCount++;
|
||
stats._lastDeathTime = lineTime;
|
||
}
|
||
}
|
||
// 检测击杀 (杀别人)
|
||
if (killerId == stats.currentPlayerName) {
|
||
stats.killCount++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检测地点访问 (RequestLocationInventory)
|
||
final locationMatch = _requestLocationInventoryPattern.firstMatch(line);
|
||
if (locationMatch != null) {
|
||
final location = locationMatch.group(2)?.trim();
|
||
if (location != null && location.isNotEmpty) {
|
||
// 清理地点名称,移除数字ID后缀
|
||
final cleanLocation = _cleanLocationName(location);
|
||
stats.locationVisits[cleanLocation] = (stats.locationVisits[cleanLocation] ?? 0) + 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 记录会话信息
|
||
if (stats.startTime != null && stats.endTime != null && stats.startTime!.year == targetYear) {
|
||
stats.yearlySessions.add(_SessionInfo(startTime: stats.startTime!, endTime: stats.endTime!));
|
||
}
|
||
} catch (e) {
|
||
// Error handled silently in isolate
|
||
}
|
||
|
||
return stats;
|
||
}
|
||
|
||
/// 清理地点名称,移除数字ID后缀
|
||
static String _cleanLocationName(String location) {
|
||
// 移除末尾的数字ID (如 "_12345678")
|
||
final cleanPattern = RegExp(r'_\d{6,}$');
|
||
return location.replaceAll(cleanPattern, '');
|
||
}
|
||
|
||
/// 生成年度报告
|
||
///
|
||
/// [gameInstallPaths] 游戏安装路径列表 (完整路径,如 ["D:/Games/StarCitizen/LIVE", "D:/Games/StarCitizen/PTU"])
|
||
/// [targetYear] 目标年份
|
||
///
|
||
/// 该方法在独立 Isolate 中运行,避免阻塞 UI
|
||
static Future<YearlyReportData> generateReport(List<String> gameInstallPaths, int targetYear) async {
|
||
// 在独立 Isolate 中运行以避免阻塞 UI
|
||
return await Isolate.run(() async {
|
||
return await _generateReportInIsolate(gameInstallPaths, targetYear);
|
||
});
|
||
}
|
||
|
||
/// 内部方法:在 Isolate 中执行的报告生成逻辑
|
||
static Future<YearlyReportData> _generateReportInIsolate(List<String> gameInstallPaths, int targetYear) async {
|
||
final List<File> allLogFiles = [];
|
||
|
||
// 从所有安装路径收集日志文件
|
||
for (final installPath in gameInstallPaths) {
|
||
try {
|
||
final installDir = Directory(installPath);
|
||
|
||
// 检查安装目录是否存在
|
||
if (!await installDir.exists()) {
|
||
continue;
|
||
}
|
||
|
||
final gameLogFile = File('$installPath/Game.log');
|
||
final logBackupsDir = Directory('$installPath/logbackups');
|
||
|
||
// 添加当前 Game.log
|
||
try {
|
||
if (await gameLogFile.exists()) {
|
||
allLogFiles.add(gameLogFile);
|
||
}
|
||
} catch (_) {
|
||
// 忽略单个文件检查错误
|
||
}
|
||
|
||
// 添加备份日志
|
||
try {
|
||
if (await logBackupsDir.exists()) {
|
||
await for (final entity in logBackupsDir.list()) {
|
||
if (entity is File && entity.path.endsWith('.log')) {
|
||
allLogFiles.add(entity);
|
||
}
|
||
}
|
||
}
|
||
} catch (_) {
|
||
// 忽略备份目录读取错误
|
||
}
|
||
} catch (_) {
|
||
// 忽略单个安装路径的错误,继续处理其他路径
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 并发分析所有日志文件,使用错误处理确保单个文件失败不影响其他文件
|
||
final futures = allLogFiles.map((file) async {
|
||
try {
|
||
return await _analyzeLogFile(file, targetYear);
|
||
} catch (_) {
|
||
// 单个文件分析失败时返回空的统计数据
|
||
return _LogFileStats();
|
||
}
|
||
});
|
||
final allStatsRaw = await Future.wait(futures);
|
||
|
||
// 去重: 使用 uniqueKey (启动时间 + 玩家名) 来过滤重复的日志
|
||
final seenKeys = <String>{};
|
||
final allStats = <_LogFileStats>[];
|
||
|
||
for (final stats in allStatsRaw) {
|
||
final key = stats.uniqueKey;
|
||
if (key == null) {
|
||
allStats.add(stats);
|
||
} else if (!seenKeys.contains(key)) {
|
||
seenKeys.add(key);
|
||
allStats.add(stats);
|
||
}
|
||
}
|
||
|
||
// 合并统计数据
|
||
int totalLaunchCount = allStats.length;
|
||
Duration totalPlayTime = Duration.zero;
|
||
int yearlyLaunchCount = 0;
|
||
Duration yearlyPlayTime = Duration.zero;
|
||
int totalCrashCount = 0;
|
||
int yearlyCrashCount = 0;
|
||
DateTime? yearlyFirstLaunchTime;
|
||
DateTime? earliestPlayDate;
|
||
DateTime? latestPlayDate;
|
||
|
||
// 会话时长统计
|
||
Duration? longestSession;
|
||
DateTime? longestSessionDate;
|
||
Duration? shortestSession;
|
||
DateTime? shortestSessionDate;
|
||
List<Duration> allSessionDurations = [];
|
||
|
||
// K/D 统计
|
||
int yearlyKillCount = 0;
|
||
int yearlyDeathCount = 0;
|
||
int yearlySelfKillCount = 0;
|
||
|
||
final Map<String, int> vehicleDestructionDetails = {};
|
||
final Map<String, int> vehiclePilotedDetails = {};
|
||
final Map<String, int> accountSessionDetails = {};
|
||
final Map<String, int> locationDetails = {};
|
||
|
||
for (final stats in allStats) {
|
||
// 累计游玩时长
|
||
if (stats.startTime != null && stats.endTime != null) {
|
||
totalPlayTime += stats.endTime!.difference(stats.startTime!);
|
||
}
|
||
|
||
// 崩溃统计
|
||
if (stats.hasCrash) {
|
||
totalCrashCount++;
|
||
if (stats.endTime != null && stats.endTime!.year == targetYear) {
|
||
yearlyCrashCount++;
|
||
}
|
||
}
|
||
|
||
// 年度会话统计
|
||
for (final session in stats.yearlySessions) {
|
||
yearlyLaunchCount++;
|
||
final sessionDuration = session.duration;
|
||
yearlyPlayTime += sessionDuration;
|
||
allSessionDurations.add(sessionDuration);
|
||
|
||
// 年度第一次启动时间
|
||
if (yearlyFirstLaunchTime == null || session.startTime.isBefore(yearlyFirstLaunchTime)) {
|
||
yearlyFirstLaunchTime = session.startTime;
|
||
}
|
||
|
||
// 最早游玩的一天 (05:00及以后开始游戏)
|
||
if (session.startTime.hour >= 5) {
|
||
if (earliestPlayDate == null || _timeOfDayIsEarlier(session.startTime, earliestPlayDate)) {
|
||
earliestPlayDate = session.startTime;
|
||
}
|
||
}
|
||
|
||
// 最晚游玩的一天 (04:00及以前结束游戏)
|
||
if (session.endTime.hour <= 4) {
|
||
if (latestPlayDate == null || _timeOfDayIsLater(session.endTime, latestPlayDate)) {
|
||
latestPlayDate = session.endTime;
|
||
}
|
||
}
|
||
|
||
// 最长游玩时长
|
||
if (longestSession == null || sessionDuration > longestSession) {
|
||
longestSession = sessionDuration;
|
||
longestSessionDate = session.startTime;
|
||
}
|
||
|
||
// 最短游玩时长 (超过5分钟的)
|
||
if (sessionDuration.inMinutes >= 5) {
|
||
if (shortestSession == null || sessionDuration < shortestSession) {
|
||
shortestSession = sessionDuration;
|
||
shortestSessionDate = session.startTime;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 合并载具损毁详情 (过滤包含 PU 的载具)
|
||
for (final entry in stats.vehicleDestruction.entries) {
|
||
if (!entry.key.contains('PU_')) {
|
||
vehicleDestructionDetails[entry.key] = (vehicleDestructionDetails[entry.key] ?? 0) + entry.value;
|
||
}
|
||
}
|
||
|
||
// 合并驾驶载具详情
|
||
for (final entry in stats.vehiclePiloted.entries) {
|
||
vehiclePilotedDetails[entry.key] = (vehiclePilotedDetails[entry.key] ?? 0) + entry.value;
|
||
}
|
||
|
||
// 累计 K/D
|
||
yearlyKillCount += stats.killCount;
|
||
yearlyDeathCount += stats.deathCount;
|
||
yearlySelfKillCount += stats.selfKillCount;
|
||
|
||
// 合并账号会话详情
|
||
for (final playerName in stats.playerNames) {
|
||
if (playerName.length > 16) continue;
|
||
String targetKey = playerName;
|
||
// 查找是否存在忽略大小写的相同 key
|
||
for (final key in accountSessionDetails.keys) {
|
||
if (key.toLowerCase() == playerName.toLowerCase()) {
|
||
targetKey = key;
|
||
break;
|
||
}
|
||
}
|
||
accountSessionDetails[targetKey] = (accountSessionDetails[targetKey] ?? 0) + 1;
|
||
}
|
||
|
||
// 合并地点访问详情
|
||
for (final entry in stats.locationVisits.entries) {
|
||
locationDetails[entry.key] = (locationDetails[entry.key] ?? 0) + entry.value;
|
||
}
|
||
}
|
||
|
||
// 计算平均游玩时长
|
||
Duration? averageSessionTime;
|
||
if (allSessionDurations.isNotEmpty) {
|
||
final totalMs = allSessionDurations.fold<int>(0, (sum, d) => sum + d.inMilliseconds);
|
||
averageSessionTime = Duration(milliseconds: totalMs ~/ allSessionDurations.length);
|
||
}
|
||
|
||
// 计算派生统计
|
||
final yearlyVehicleDestructionCount = vehicleDestructionDetails.values.fold(0, (a, b) => a + b);
|
||
|
||
String? mostDestroyedVehicle;
|
||
int mostDestroyedVehicleCount = 0;
|
||
for (final entry in vehicleDestructionDetails.entries) {
|
||
if (entry.value > mostDestroyedVehicleCount) {
|
||
mostDestroyedVehicle = entry.key;
|
||
mostDestroyedVehicleCount = entry.value;
|
||
}
|
||
}
|
||
|
||
String? mostPilotedVehicle;
|
||
int mostPilotedVehicleCount = 0;
|
||
for (final entry in vehiclePilotedDetails.entries) {
|
||
if (entry.value > mostPilotedVehicleCount) {
|
||
mostPilotedVehicle = entry.key;
|
||
mostPilotedVehicleCount = entry.value;
|
||
}
|
||
}
|
||
|
||
String? mostPlayedAccount;
|
||
int mostPlayedAccountSessionCount = 0;
|
||
for (final entry in accountSessionDetails.entries) {
|
||
if (entry.value > mostPlayedAccountSessionCount) {
|
||
mostPlayedAccount = entry.key;
|
||
mostPlayedAccountSessionCount = entry.value;
|
||
}
|
||
}
|
||
|
||
// 计算 Top 10 地点
|
||
final sortedLocations = locationDetails.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
|
||
final topLocations = sortedLocations.take(10).toList();
|
||
|
||
// 计算月份统计
|
||
final Map<int, int> monthlyPlayCount = {};
|
||
final Set<DateTime> playDates = {}; // 所有游玩的日期 (仅日期部分)
|
||
|
||
for (final stats in allStats) {
|
||
for (final session in stats.yearlySessions) {
|
||
final month = session.startTime.month;
|
||
monthlyPlayCount[month] = (monthlyPlayCount[month] ?? 0) + 1;
|
||
// 记录游玩日期 (只保留年月日)
|
||
playDates.add(DateTime(session.startTime.year, session.startTime.month, session.startTime.day));
|
||
}
|
||
}
|
||
|
||
int? mostPlayedMonth;
|
||
int mostPlayedMonthCount = 0;
|
||
int? leastPlayedMonth;
|
||
int leastPlayedMonthCount = 0;
|
||
|
||
if (monthlyPlayCount.isNotEmpty) {
|
||
// 最多游玩的月份
|
||
for (final entry in monthlyPlayCount.entries) {
|
||
if (entry.value > mostPlayedMonthCount) {
|
||
mostPlayedMonth = entry.key;
|
||
mostPlayedMonthCount = entry.value;
|
||
}
|
||
}
|
||
// 最少游玩的月份 (不包括完全没上游戏的月份)
|
||
leastPlayedMonthCount = monthlyPlayCount.values.first;
|
||
for (final entry in monthlyPlayCount.entries) {
|
||
if (entry.value <= leastPlayedMonthCount) {
|
||
leastPlayedMonth = entry.key;
|
||
leastPlayedMonthCount = entry.value;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 计算连续游玩天数和连续离线天数
|
||
int longestPlayStreak = 0;
|
||
DateTime? playStreakStartDate;
|
||
DateTime? playStreakEndDate;
|
||
int longestOfflineStreak = 0;
|
||
DateTime? offlineStreakStartDate;
|
||
DateTime? offlineStreakEndDate;
|
||
|
||
if (playDates.isNotEmpty) {
|
||
// 将日期排序
|
||
final sortedDates = playDates.toList()..sort();
|
||
|
||
// 计算连续游玩天数
|
||
int currentStreak = 1;
|
||
DateTime streakStart = sortedDates.first;
|
||
|
||
for (int i = 1; i < sortedDates.length; i++) {
|
||
final diff = sortedDates[i].difference(sortedDates[i - 1]).inDays;
|
||
if (diff == 1) {
|
||
currentStreak++;
|
||
} else {
|
||
if (currentStreak > longestPlayStreak) {
|
||
longestPlayStreak = currentStreak;
|
||
playStreakStartDate = streakStart;
|
||
playStreakEndDate = sortedDates[i - 1];
|
||
}
|
||
currentStreak = 1;
|
||
streakStart = sortedDates[i];
|
||
}
|
||
}
|
||
// 检查最后一段连续
|
||
if (currentStreak > longestPlayStreak) {
|
||
longestPlayStreak = currentStreak;
|
||
playStreakStartDate = streakStart;
|
||
playStreakEndDate = sortedDates.last;
|
||
}
|
||
|
||
// 计算连续离线天数 (在游玩日期之间的间隔)
|
||
for (int i = 1; i < sortedDates.length; i++) {
|
||
final gapDays = sortedDates[i].difference(sortedDates[i - 1]).inDays - 1;
|
||
if (gapDays > longestOfflineStreak) {
|
||
longestOfflineStreak = gapDays;
|
||
offlineStreakStartDate = sortedDates[i - 1].add(const Duration(days: 1));
|
||
offlineStreakEndDate = sortedDates[i].subtract(const Duration(days: 1));
|
||
}
|
||
}
|
||
}
|
||
|
||
return YearlyReportData(
|
||
totalLaunchCount: totalLaunchCount,
|
||
totalPlayTime: totalPlayTime,
|
||
yearlyLaunchCount: yearlyLaunchCount,
|
||
yearlyPlayTime: yearlyPlayTime,
|
||
totalCrashCount: totalCrashCount,
|
||
yearlyCrashCount: yearlyCrashCount,
|
||
yearlyFirstLaunchTime: yearlyFirstLaunchTime,
|
||
earliestPlayDate: earliestPlayDate,
|
||
latestPlayDate: latestPlayDate,
|
||
longestSession: longestSession,
|
||
longestSessionDate: longestSessionDate,
|
||
shortestSession: shortestSession,
|
||
shortestSessionDate: shortestSessionDate,
|
||
averageSessionTime: averageSessionTime,
|
||
yearlyVehicleDestructionCount: yearlyVehicleDestructionCount,
|
||
mostDestroyedVehicle: mostDestroyedVehicle,
|
||
mostDestroyedVehicleCount: mostDestroyedVehicleCount,
|
||
mostPilotedVehicle: mostPilotedVehicle,
|
||
mostPilotedVehicleCount: mostPilotedVehicleCount,
|
||
accountCount: accountSessionDetails.length,
|
||
mostPlayedAccount: mostPlayedAccount,
|
||
mostPlayedAccountSessionCount: mostPlayedAccountSessionCount,
|
||
topLocations: topLocations,
|
||
yearlyKillCount: yearlyKillCount,
|
||
yearlyDeathCount: yearlyDeathCount,
|
||
yearlySelfKillCount: yearlySelfKillCount,
|
||
mostPlayedMonth: mostPlayedMonth,
|
||
mostPlayedMonthCount: mostPlayedMonthCount,
|
||
leastPlayedMonth: leastPlayedMonth,
|
||
leastPlayedMonthCount: leastPlayedMonthCount,
|
||
longestPlayStreak: longestPlayStreak,
|
||
playStreakStartDate: playStreakStartDate,
|
||
playStreakEndDate: playStreakEndDate,
|
||
longestOfflineStreak: longestOfflineStreak,
|
||
offlineStreakStartDate: offlineStreakStartDate,
|
||
offlineStreakEndDate: offlineStreakEndDate,
|
||
vehiclePilotedDetails: vehiclePilotedDetails,
|
||
accountSessionDetails: accountSessionDetails,
|
||
locationDetails: locationDetails,
|
||
);
|
||
}
|
||
|
||
/// 比较两个时间的 时:分 是否更早
|
||
static bool _timeOfDayIsEarlier(DateTime a, DateTime b) {
|
||
if (a.hour < b.hour) return true;
|
||
if (a.hour > b.hour) return false;
|
||
return a.minute < b.minute;
|
||
}
|
||
|
||
/// 比较两个时间的 时:分 是否更晚
|
||
static bool _timeOfDayIsLater(DateTime a, DateTime b) {
|
||
if (a.hour > b.hour) return true;
|
||
if (a.hour < b.hour) return false;
|
||
return a.minute > b.minute;
|
||
}
|
||
}
|