feat: init YearlyReportUI

This commit is contained in:
xkeyC 2025-12-17 12:25:36 +08:00
parent 07f476e324
commit 9a28257f4a

View File

@ -0,0 +1,522 @@
import 'dart:io';
import 'dart:convert';
import 'package:starcitizen_doctor/common/helper/game_log_analyzer.dart';
import 'package:starcitizen_doctor/common/utils/log.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 int yearlyVehicleDestructionCount; //
final String? mostDestroyedVehicle; //
final int mostDestroyedVehicleCount; //
final String? mostPilotedVehicle; //
final int mostPilotedVehicleCount; //
//
final int yearlyKillCount; //
final int yearlyDeathCount; //
//
final int accountCount; //
final String? mostPlayedAccount; //
final int mostPlayedAccountSessionCount; //
// ()
final Map<String, int> vehicleDestructionDetails; //
final Map<String, int> vehiclePilotedDetails; //
final Map<String, int> accountSessionDetails; //
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,
required this.yearlyVehicleDestructionCount,
this.mostDestroyedVehicle,
required this.mostDestroyedVehicleCount,
this.mostPilotedVehicle,
required this.mostPilotedVehicleCount,
required this.yearlyKillCount,
required this.yearlyDeathCount,
required this.accountCount,
this.mostPlayedAccount,
required this.mostPlayedAccountSessionCount,
required this.vehicleDestructionDetails,
required this.vehiclePilotedDetails,
required this.accountSessionDetails,
});
/// DateTime ISO 8601
/// : 2025-12-17T10:30:00.000+08:00
static String? _toIso8601WithTimezone(DateTime? dateTime) {
if (dateTime == null) return null;
final local = dateTime.toLocal();
final offset = local.timeZoneOffset;
final sign = offset.isNegative ? '-' : '+';
final hours = offset.inHours.abs().toString().padLeft(2, '0');
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0');
// 使 ISO
final isoString = local.toIso8601String();
// 'Z' UTC
final baseString = isoString.endsWith('Z') ? isoString.substring(0, isoString.length - 1) : isoString;
return '$baseString$sign$hours:$minutes';
}
/// JSON Map
Map<String, dynamic> toJson() {
final now = DateTime.now();
final offset = now.timeZoneOffset;
final sign = offset.isNegative ? '-' : '+';
final hours = offset.inHours.abs().toString().padLeft(2, '0');
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0');
return {
//
'generatedAt': _toIso8601WithTimezone(now),
'timezoneOffset': '$sign$hours:$minutes',
'timezoneOffsetMinutes': offset.inMinutes,
//
'totalLaunchCount': totalLaunchCount,
'totalPlayTimeMs': totalPlayTime.inMilliseconds,
'yearlyLaunchCount': yearlyLaunchCount,
'yearlyPlayTimeMs': yearlyPlayTime.inMilliseconds,
'totalCrashCount': totalCrashCount,
'yearlyCrashCount': yearlyCrashCount,
// ()
'yearlyFirstLaunchTime': _toIso8601WithTimezone(yearlyFirstLaunchTime),
'earliestPlayDate': _toIso8601WithTimezone(earliestPlayDate),
'latestPlayDate': _toIso8601WithTimezone(latestPlayDate),
//
'yearlyVehicleDestructionCount': yearlyVehicleDestructionCount,
'mostDestroyedVehicle': mostDestroyedVehicle,
'mostDestroyedVehicleCount': mostDestroyedVehicleCount,
'mostPilotedVehicle': mostPilotedVehicle,
'mostPilotedVehicleCount': mostPilotedVehicleCount,
//
'yearlyKillCount': yearlyKillCount,
'yearlyDeathCount': yearlyDeathCount,
//
'accountCount': accountCount,
'mostPlayedAccount': mostPlayedAccount,
'mostPlayedAccountSessionCount': mostPlayedAccountSessionCount,
//
'vehicleDestructionDetails': vehicleDestructionDetails,
'vehiclePilotedDetails': vehiclePilotedDetails,
'accountSessionDetails': accountSessionDetails,
};
}
/// JSON
String toJsonString() => jsonEncode(toJson());
/// Base64 JSON
String toJsonBase64() => base64Encode(utf8.encode(toJsonString()));
@override
String toString() {
return '''YearlyReportData(
totalLaunchCount: $totalLaunchCount,
totalPlayTime: $totalPlayTime,
yearlyLaunchCount: $yearlyLaunchCount,
yearlyPlayTime: $yearlyPlayTime,
totalCrashCount: $totalCrashCount,
yearlyCrashCount: $yearlyCrashCount,
yearlyFirstLaunchTime: $yearlyFirstLaunchTime,
earliestPlayDate: $earliestPlayDate,
latestPlayDate: $latestPlayDate,
yearlyVehicleDestructionCount: $yearlyVehicleDestructionCount,
mostDestroyedVehicle: $mostDestroyedVehicle ($mostDestroyedVehicleCount),
mostPilotedVehicle: $mostPilotedVehicle ($mostPilotedVehicleCount),
yearlyKillCount: $yearlyKillCount,
yearlyDeathCount: $yearlyDeathCount,
accountCount: $accountCount,
mostPlayedAccount: $mostPlayedAccount ($mostPlayedAccountSessionCount),
)''';
}
}
/// (使)
class _LogFileStats {
DateTime? startTime;
DateTime? endTime;
bool hasCrash = false;
int killCount = 0;
int deathCount = 0;
Set<String> playerNames = {};
String? currentPlayerName;
String? firstPlayerName; //
// : (ID后) ->
Map<String, int> vehicleDestruction = {};
// : (ID后) ->
Map<String, int> vehiclePiloted = {};
//
List<DateTime> yearlyStartTimes = [];
List<DateTime> yearlyEndTimes = [];
///
///
String? get uniqueKey {
if (startTime == null) return null;
final timeKey = startTime!.toUtc().toIso8601String();
final playerKey = firstPlayerName ?? 'unknown';
return '$timeKey|$playerKey';
}
}
///
class YearlyReportAnalyzer {
// GameLogAnalyzer
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 '([^']+)'", //
);
///
static Future<_LogFileStats> _analyzeLogFile(File logFile, int targetYear) async {
final stats = _LogFileStats();
if (!(await logFile.exists())) {
return stats;
}
try {
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 (lineTime.year == targetYear) {
if (stats.yearlyStartTimes.isEmpty ||
stats.yearlyStartTimes.last.difference(lineTime).abs().inMinutes > 30) {
//
stats.yearlyStartTimes.add(lineTime);
}
//
if (stats.yearlyEndTimes.isEmpty) {
stats.yearlyEndTimes.add(lineTime);
} else {
stats.yearlyEndTimes[stats.yearlyEndTimes.length - 1] = lineTime;
}
}
}
//
if (line.contains("Cloud Imperium Games public crash handler")) {
stats.hasCrash = true;
}
//
final nameMatch = _characterNamePattern.firstMatch(line);
if (nameMatch != null) {
final playerName = nameMatch.group(1)?.trim();
if (playerName != null && playerName.isNotEmpty) {
stats.currentPlayerName = playerName;
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);
stats.vehiclePiloted[cleanVehicleName] = (stats.vehiclePiloted[cleanVehicleName] ?? 0) + 1;
}
}
//
final deathMatch = _actorDeathPattern.firstMatch(line);
if (deathMatch != null) {
final victimId = deathMatch.group(1)?.trim();
if (victimId != null && stats.currentPlayerName != null && victimId == stats.currentPlayerName) {
stats.deathCount++;
}
}
}
}
} catch (e) {
dPrint('[YearlyReportAnalyzer] Error analyzing log file: $e');
}
return stats;
}
///
///
/// [gameInstallPaths] ( ["D:/Games/StarCitizen/LIVE", "D:/Games/StarCitizen/PTU"])
/// [targetYear]
static Future<YearlyReportData> generateReport(List<String> gameInstallPaths, int targetYear) async {
final List<File> allLogFiles = [];
//
for (final installPath in gameInstallPaths) {
final installDir = Directory(installPath);
//
if (!await installDir.exists()) {
dPrint('[YearlyReportAnalyzer] Install path does not exist: $installPath');
continue;
}
final gameLogFile = File('$installPath/Game.log');
final logBackupsDir = Directory('$installPath/logbackups');
// Game.log
if (await gameLogFile.exists()) {
allLogFiles.add(gameLogFile);
}
//
if (await logBackupsDir.exists()) {
await for (final entity in logBackupsDir.list()) {
if (entity is File && entity.path.endsWith('.log')) {
allLogFiles.add(entity);
}
}
}
}
dPrint(
'[YearlyReportAnalyzer] Found ${allLogFiles.length} log files from ${gameInstallPaths.length} install paths',
);
//
final futures = allLogFiles.map((file) => _analyzeLogFile(file, targetYear));
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);
} else {
dPrint('[YearlyReportAnalyzer] Skipping duplicate log: $key');
}
}
dPrint('[YearlyReportAnalyzer] After deduplication: ${allStats.length} unique logs');
//
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;
int yearlyKillCount = 0;
int yearlyDeathCount = 0;
final Map<String, int> vehicleDestructionDetails = {};
final Map<String, int> vehiclePilotedDetails = {};
final Map<String, int> accountSessionDetails = {};
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 (int i = 0; i < stats.yearlyStartTimes.length; i++) {
yearlyLaunchCount++;
final startTime = stats.yearlyStartTimes[i];
final endTime = i < stats.yearlyEndTimes.length ? stats.yearlyEndTimes[i] : startTime;
yearlyPlayTime += endTime.difference(startTime);
//
if (yearlyFirstLaunchTime == null || startTime.isBefore(yearlyFirstLaunchTime)) {
yearlyFirstLaunchTime = startTime;
}
// (05:00)
if (startTime.hour >= 5) {
if (earliestPlayDate == null || _timeOfDayIsEarlier(startTime, earliestPlayDate)) {
earliestPlayDate = startTime;
}
}
// (04:00)
if (endTime.hour <= 4) {
if (latestPlayDate == null || _timeOfDayIsLater(endTime, latestPlayDate)) {
latestPlayDate = endTime;
}
}
}
//
yearlyKillCount += stats.killCount;
yearlyDeathCount += stats.deathCount;
//
for (final entry in stats.vehicleDestruction.entries) {
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;
}
//
for (final playerName in stats.playerNames) {
accountSessionDetails[playerName] = (accountSessionDetails[playerName] ?? 0) + 1;
}
}
//
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;
}
}
return YearlyReportData(
totalLaunchCount: totalLaunchCount,
totalPlayTime: totalPlayTime,
yearlyLaunchCount: yearlyLaunchCount,
yearlyPlayTime: yearlyPlayTime,
totalCrashCount: totalCrashCount,
yearlyCrashCount: yearlyCrashCount,
yearlyFirstLaunchTime: yearlyFirstLaunchTime,
earliestPlayDate: earliestPlayDate,
latestPlayDate: latestPlayDate,
yearlyVehicleDestructionCount: yearlyVehicleDestructionCount,
mostDestroyedVehicle: mostDestroyedVehicle,
mostDestroyedVehicleCount: mostDestroyedVehicleCount,
mostPilotedVehicle: mostPilotedVehicle,
mostPilotedVehicleCount: mostPilotedVehicleCount,
yearlyKillCount: yearlyKillCount,
yearlyDeathCount: yearlyDeathCount,
accountCount: accountSessionDetails.length,
mostPlayedAccount: mostPlayedAccount,
mostPlayedAccountSessionCount: mostPlayedAccountSessionCount,
vehicleDestructionDetails: vehicleDestructionDetails,
vehiclePilotedDetails: vehiclePilotedDetails,
accountSessionDetails: accountSessionDetails,
);
}
/// :
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;
}
}