TH1/Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs
2026-06-02 20:30:19 +08:00

494 lines
18 KiB
C#

/*
* @Author: Codex
* @Description: 玩家主动 Bug 汇报打包服务
* @Date: 2026年05月19日
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using Logic.Config;
using Logic.CrashSight;
using Logic.Multilingual;
using RuntimeData;
using TH1_Logic.Config;
using TH1_Logic.GameArchive;
using UnityEngine;
namespace TH1_Logic.Oss
{
public enum PlayerBugArchiveKind
{
Begin,
Continue,
End
}
[Serializable]
public class PlayerBugReportArchiveManifest
{
public string mode;
public uint mapId;
public string kind;
public string archiveFolder;
public string sourceFileName;
public string zipEntry;
public long fileSize;
public string lastWriteTimeUtc;
}
[Serializable]
public class PlayerBugReportManifest
{
public string schema = "th1.player-bug-report.v1";
public string reportId;
public string createdAtUtc;
public string createdAtLocal;
public string timezone;
public string steamId;
public string version;
public string unityVersion;
public string platform;
public string crashSightDeviceId;
public string deviceModel;
public string deviceName;
public string operatingSystem;
public string processorType;
public int processorCount;
public int systemMemorySizeMb;
public string graphicsDeviceName;
public int graphicsMemorySizeMb;
public string description;
public int archiveCount;
public PlayerBugReportArchiveManifest[] archives = Array.Empty<PlayerBugReportArchiveManifest>();
}
public class PlayerBugReportArchiveSession
{
public bool IsMulti;
public uint MapId;
public string BeginPath;
public string CompanionPath;
public PlayerBugArchiveKind CompanionKind;
public DateTime LatestWriteTimeUtc;
public string ModeLabel => IsMulti ? "multi" : "single";
public string ModeDisplay => IsMulti ? "联机" : "单机";
public string CompanionKindLabel => CompanionKind == PlayerBugArchiveKind.End ? "end" : "continue";
public long TotalBytes
{
get
{
long total = 0;
if (!string.IsNullOrEmpty(BeginPath) && File.Exists(BeginPath))
total += new FileInfo(BeginPath).Length;
if (!string.IsNullOrEmpty(CompanionPath) && File.Exists(CompanionPath))
total += new FileInfo(CompanionPath).Length;
return total;
}
}
}
public class PlayerBugReportPackage
{
public byte[] Data;
public PlayerBugReportManifest Manifest;
public List<PlayerBugReportArchiveSession> Sessions;
}
[Serializable]
public class PlayerMultilingualReportManifest
{
public string schema = "th1.player-multilingual-report.v1";
public string reportId;
public string createdAtUtc;
public string createdAtLocal;
public string timezone;
public string steamId;
public string version;
public string unityVersion;
public string platform;
public string crashSightDeviceId;
public string deviceModel;
public string deviceName;
public string operatingSystem;
public string processorType;
public int processorCount;
public int systemMemorySizeMb;
public string graphicsDeviceName;
public int graphicsMemorySizeMb;
public uint multilingualId;
public string multilingualIdText;
public string language;
public string reportedText;
public string description;
public string resolvedText;
}
public class PlayerMultilingualReportPackage
{
public byte[] Data;
public PlayerMultilingualReportManifest Manifest;
}
public static class PlayerMultilingualReportService
{
public const int MaxMultilingualReportUploadBytes = 1 * 1024 * 1024;
private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false);
public static string GetCurrentVersion()
{
try
{
if (ConfigManager.Instance.VersionCfg == null)
ConfigManager.Instance.VersionCfg = Resources.Load<VersionConfig>("Export/VersionConfig");
return ConfigManager.Instance.VersionCfg?.CurVersionInfo?.Version ?? Application.version;
}
catch (Exception)
{
return Application.version;
}
}
public static MultilingualType GetCurrentLanguage()
{
try
{
var current = MultilingualManager.Instance.CurrentType;
if (IsValidReportLanguage(current)) return current;
}
catch
{
// ignored
}
try
{
var configured = ConfigManager.Instance.Config.MultilingualType;
if (IsValidReportLanguage(configured)) return configured;
}
catch
{
// ignored
}
return MultilingualType.EN;
}
public static string ResolveText(uint multilingualId, MultilingualType language)
{
if (multilingualId == 0) return "";
language = IsValidReportLanguage(language) ? language : GetCurrentLanguage();
try
{
return MultilingualManager.Instance.GetMultilingualText(multilingualId, language) ?? "";
}
catch
{
return "";
}
}
public static PlayerMultilingualReportPackage BuildPackage(string steamId, uint multilingualId,
string reportedText, string description, string version, MultilingualType language)
{
language = IsValidReportLanguage(language) ? language : GetCurrentLanguage();
var reportId = Guid.NewGuid().ToString("N");
var manifest = new PlayerMultilingualReportManifest
{
reportId = reportId,
createdAtUtc = DateTime.UtcNow.ToString("O"),
createdAtLocal = DateTime.Now.ToString("O"),
timezone = GetLocalTimezone(),
steamId = steamId ?? "",
version = string.IsNullOrWhiteSpace(version) ? GetCurrentVersion() : version.Trim(),
unityVersion = Application.unityVersion,
platform = Application.platform.ToString(),
crashSightDeviceId = CrashSightManager.GetCrashSightDeviceId(),
deviceModel = SystemInfo.deviceModel,
deviceName = SystemInfo.deviceName,
operatingSystem = SystemInfo.operatingSystem,
processorType = SystemInfo.processorType,
processorCount = SystemInfo.processorCount,
systemMemorySizeMb = SystemInfo.systemMemorySize,
graphicsDeviceName = SystemInfo.graphicsDeviceName,
graphicsMemorySizeMb = SystemInfo.graphicsMemorySize,
multilingualId = multilingualId,
multilingualIdText = multilingualId.ToString(),
language = language.ToString(),
reportedText = reportedText ?? "",
description = description ?? "",
resolvedText = ResolveText(multilingualId, language)
};
using var stream = new MemoryStream();
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true, Utf8NoBom))
{
WriteTextEntry(zip, "reported_text.txt", manifest.reportedText);
WriteTextEntry(zip, "description.txt", manifest.description);
WriteTextEntry(zip, "manifest.json", JsonUtility.ToJson(manifest, true));
}
return new PlayerMultilingualReportPackage
{
Data = stream.ToArray(),
Manifest = manifest
};
}
private static void WriteTextEntry(ZipArchive zip, string path, string text)
{
var entry = zip.CreateEntry(path, System.IO.Compression.CompressionLevel.Optimal);
using var entryStream = entry.Open();
var bytes = Utf8NoBom.GetBytes(text ?? "");
entryStream.Write(bytes, 0, bytes.Length);
}
private static bool IsValidReportLanguage(MultilingualType language)
{
return language != MultilingualType.None && language != MultilingualType.Max;
}
private static string GetLocalTimezone()
{
try
{
var offset = DateTimeOffset.Now.Offset;
var sign = offset < TimeSpan.Zero ? "-" : "+";
return $"{TimeZoneInfo.Local.Id} (UTC{sign}{offset.Duration():hh\\:mm})";
}
catch
{
return DateTimeOffset.Now.Offset.ToString();
}
}
}
public static class PlayerBugReportService
{
public const int MaxBugReportUploadBytes = 10 * 1024 * 1024;
private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false);
public static string ConfigDirectory =>
Path.GetFullPath(Path.Combine(Application.persistentDataPath, "../Config"));
public static string GetCurrentVersion()
{
try
{
if (ConfigManager.Instance.VersionCfg == null)
ConfigManager.Instance.VersionCfg = Resources.Load<VersionConfig>("Export/VersionConfig");
return ConfigManager.Instance.VersionCfg?.CurVersionInfo?.Version ?? Application.version;
}
catch (Exception)
{
return Application.version;
}
}
public static PlayerBugReportArchiveSession GetMostRecentArchiveSession()
{
return GetLatestArchiveSessions(true, true)
.OrderByDescending(session => session.LatestWriteTimeUtc)
.FirstOrDefault();
}
public static List<PlayerBugReportArchiveSession> GetLatestArchiveSessions(bool includeSingle, bool includeMulti)
{
var sessions = new List<PlayerBugReportArchiveSession>();
if (includeSingle && TryGetLatestArchiveSession(false, out var singleSession))
sessions.Add(singleSession);
if (includeMulti && TryGetLatestArchiveSession(true, out var multiSession))
sessions.Add(multiSession);
sessions.Sort((a, b) => b.LatestWriteTimeUtc.CompareTo(a.LatestWriteTimeUtc));
return sessions;
}
public static PlayerBugReportPackage BuildPackage(string steamId, string description, string version,
bool includeArchives, bool includeSingleArchive, bool includeMultiArchive)
{
var sessions = includeArchives
? GetLatestArchiveSessions(includeSingleArchive, includeMultiArchive)
: new List<PlayerBugReportArchiveSession>();
var reportId = Guid.NewGuid().ToString("N");
var manifestArchives = new List<PlayerBugReportArchiveManifest>();
var manifest = new PlayerBugReportManifest
{
reportId = reportId,
createdAtUtc = DateTime.UtcNow.ToString("O"),
createdAtLocal = DateTime.Now.ToString("O"),
timezone = GetLocalTimezone(),
steamId = steamId ?? "",
version = string.IsNullOrWhiteSpace(version) ? GetCurrentVersion() : version.Trim(),
unityVersion = Application.unityVersion,
platform = Application.platform.ToString(),
crashSightDeviceId = CrashSightManager.GetCrashSightDeviceId(),
deviceModel = SystemInfo.deviceModel,
deviceName = SystemInfo.deviceName,
operatingSystem = SystemInfo.operatingSystem,
processorType = SystemInfo.processorType,
processorCount = SystemInfo.processorCount,
systemMemorySizeMb = SystemInfo.systemMemorySize,
graphicsDeviceName = SystemInfo.graphicsDeviceName,
graphicsMemorySizeMb = SystemInfo.graphicsMemorySize,
description = description ?? ""
};
using var stream = new MemoryStream();
using (var zip = new ZipArchive(stream, ZipArchiveMode.Create, true, Utf8NoBom))
{
WriteTextEntry(zip, "description.txt", manifest.description);
foreach (var session in sessions)
{
AddArchiveFile(zip, session, PlayerBugArchiveKind.Begin, session.BeginPath, manifestArchives);
AddArchiveFile(zip, session, session.CompanionKind, session.CompanionPath, manifestArchives);
}
manifest.archiveCount = manifestArchives.Count;
manifest.archives = manifestArchives.ToArray();
WriteTextEntry(zip, "manifest.json", JsonUtility.ToJson(manifest, true));
}
return new PlayerBugReportPackage
{
Data = stream.ToArray(),
Manifest = manifest,
Sessions = sessions
};
}
private static bool TryGetLatestArchiveSession(bool isMulti, out PlayerBugReportArchiveSession session)
{
session = null;
var expectedMode = isMulti ? NetMode.Multi : NetMode.Single;
var records = GameRecordManager.Instance.GameRecordData?.Records ?? new List<GameRecord>();
session = records
.Where(record => record != null && record.NetMode == expectedMode)
.Select(TryBuildArchiveSession)
.Where(value => value != null)
.OrderByDescending(value => value.LatestWriteTimeUtc)
.FirstOrDefault();
return session != null;
}
private static PlayerBugReportArchiveSession TryBuildArchiveSession(GameRecord record)
{
if (record == null || string.IsNullOrEmpty(record.BeginArchiveId)) return null;
if (!GameArchiveManager.Instance.TryGetArchivePath(
GameArchiveFileKind.Begin,
record.BeginArchiveId,
out var beginPath))
{
return null;
}
var companionKind = PlayerBugArchiveKind.End;
var companionArchiveKind = GameArchiveFileKind.End;
var companionArchiveId = record.EndArchiveId;
if (string.IsNullOrEmpty(companionArchiveId))
{
companionKind = PlayerBugArchiveKind.Continue;
companionArchiveKind = record.RecordKind == GameRecordKind.Quick
? GameArchiveFileKind.QuickContinue
: GameArchiveFileKind.Continue;
companionArchiveId = record.ContinueArchiveId;
}
if (string.IsNullOrEmpty(companionArchiveId)) return null;
if (!GameArchiveManager.Instance.TryGetArchivePath(
companionArchiveKind,
companionArchiveId,
out var companionPath))
return null;
return new PlayerBugReportArchiveSession
{
IsMulti = record.NetMode == NetMode.Multi,
MapId = record.MapID,
BeginPath = beginPath,
CompanionPath = companionPath,
CompanionKind = companionKind,
LatestWriteTimeUtc = File.GetLastWriteTimeUtc(beginPath) > File.GetLastWriteTimeUtc(companionPath)
? File.GetLastWriteTimeUtc(beginPath)
: File.GetLastWriteTimeUtc(companionPath)
};
}
private static void AddArchiveFile(ZipArchive zip, PlayerBugReportArchiveSession session,
PlayerBugArchiveKind kind, string sourcePath, List<PlayerBugReportArchiveManifest> manifestArchives)
{
if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath)) return;
var kindLabel = GetKindLabel(kind);
var sourceFileName = Path.GetFileName(sourcePath);
var archiveFolder = Path.GetFileName(Path.GetDirectoryName(sourcePath)) ?? "";
var entryPath = string.IsNullOrEmpty(archiveFolder)
? $"saves/{session.ModeLabel}/{sourceFileName}"
: $"saves/{session.ModeLabel}/{archiveFolder}/{sourceFileName}";
var entry = zip.CreateEntry(entryPath, System.IO.Compression.CompressionLevel.Optimal);
using (var entryStream = entry.Open())
using (var fileStream = File.OpenRead(sourcePath))
{
fileStream.CopyTo(entryStream);
}
var fileInfo = new FileInfo(sourcePath);
manifestArchives.Add(new PlayerBugReportArchiveManifest
{
mode = session.ModeLabel,
mapId = session.MapId,
kind = kindLabel,
archiveFolder = archiveFolder,
sourceFileName = sourceFileName,
zipEntry = entryPath,
fileSize = fileInfo.Length,
lastWriteTimeUtc = fileInfo.LastWriteTimeUtc.ToString("O")
});
}
private static void WriteTextEntry(ZipArchive zip, string path, string text)
{
var entry = zip.CreateEntry(path, System.IO.Compression.CompressionLevel.Optimal);
using var entryStream = entry.Open();
var bytes = Utf8NoBom.GetBytes(text ?? "");
entryStream.Write(bytes, 0, bytes.Length);
}
private static string GetKindLabel(PlayerBugArchiveKind kind)
{
return kind switch
{
PlayerBugArchiveKind.Begin => "begin",
PlayerBugArchiveKind.End => "end",
_ => "continue"
};
}
private static string GetLocalTimezone()
{
try
{
var offset = DateTimeOffset.Now.Offset;
var sign = offset < TimeSpan.Zero ? "-" : "+";
return $"{TimeZoneInfo.Local.Id} (UTC{sign}{offset.Duration():hh\\:mm})";
}
catch
{
return DateTimeOffset.Now.Offset.ToString();
}
}
}
}