feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -0,0 +1,416 @@
using System.IO;
using System.Text;
using PCL.Core.Logging;
namespace PCL;
internal sealed class CrashLogPreparer(CrashAnalysisContext context)
{
private static readonly HashSet<string> KnownGameLogNames = new(StringComparer.OrdinalIgnoreCase)
{
"latest.log",
"latest log.txt",
"游戏崩溃前的输出.txt",
"rawoutput.log"
};
private static readonly HashSet<string> KnownDebugLogNames = new(StringComparer.OrdinalIgnoreCase)
{
"debug.log",
"debug log.txt"
};
private static readonly HashSet<string> KnownLauncherLogNames = new(StringComparer.OrdinalIgnoreCase)
{
"启动器日志.txt",
"PCL2 启动器日志.txt",
"PCL 启动器日志.txt",
"PCL CE 启动器日志.txt",
"log1.txt",
"log-ce1.log"
};
public bool Prepare()
{
LogWrapper.Info("Crash", "步骤 2:准备日志文本");
context.DirectOpenFile = null;
context.PreparedLogs = null;
context.OutputFiles.Clear();
var classifiedFiles = _ClassifyFiles();
var analyzable = classifiedFiles
.Where(item => item.Kind is not null)
.ToList();
context.DirectOpenFile = analyzable
.OrderBy(item => _IsGeneratedLog(item.File))
.ThenByDescending(item => _GetLastWriteTime(item.File))
.Select(item => item.File)
.FirstOrDefault();
var extraFiles = classifiedFiles
.Where(item => item.Kind is null)
.Select(item => item.File)
.ToList();
if (analyzable.Count == 0 && extraFiles.Count > 0)
{
LogWrapper.Info("Crash", "由于仅发现了额外日志,将它们视作 Minecraft 日志进行分析");
analyzable = extraFiles
.Select(file => new ClassifiedCrashLog(file, CrashLogKind.Game))
.ToList();
extraFiles.Clear();
}
var game = _SelectGameLog(analyzable);
var debug = _SelectDebugLog(analyzable);
var crashReport = _SelectNewest(analyzable, CrashLogKind.CrashReport, 300, 700);
var hsErr = _SelectNewest(analyzable, CrashLogKind.HsErr, 200, 100);
foreach (var extraFile in extraFiles)
{
context.OutputFiles.Add(extraFile.FullPath);
LogWrapper.Info("Crash", $"输出报告:{extraFile.FullPath},不用作分析");
}
var logs = new CrashLogSet
{
Game = game,
Debug = debug,
CrashReport = crashReport,
HsErr = hsErr,
All = string.Concat(
game?.Text ?? debug?.Text ?? string.Empty,
hsErr?.Text ?? string.Empty,
crashReport?.Text ?? string.Empty)
};
context.PreparedLogs = logs;
context.LogAll = logs.All;
if (logs.HasAnalyzableLog)
LogWrapper.Info(
"Crash",
("步骤 2:准备日志文本完成,找到" +
(game is null ? "" : "游戏日志、") +
(debug is null ? "" : "游戏 Debug 日志、") +
(hsErr is null ? "" : "虚拟机日志、") +
(crashReport is null ? "" : "崩溃日志、"))
.TrimEnd('、') + "用作分析");
else
LogWrapper.Info("Crash", "步骤 2:准备日志文本完成,没有任何可供分析的日志");
return logs.HasAnalyzableLog;
}
private List<ClassifiedCrashLog> _ClassifyFiles()
{
var result = new List<ClassifiedCrashLog>();
foreach (var file in context.RawFiles)
{
var name = file.FileName.ToLowerInvariant();
if (!file.Lines.Any())
{
LogWrapper.Info("Crash", $"{name} 由于内容为空跳过");
continue;
}
var kind = _ClassifyLogKind(file);
if (kind is null && !_IsExtraLogLike(file))
{
LogWrapper.Info("Crash", $"{name} 分类为 Ignore");
continue;
}
result.Add(new ClassifiedCrashLog(file, kind));
LogWrapper.Info("Crash", $"{name} 分类为 {kind?.ToString() ?? "Extra"}");
}
return result;
}
private static CrashLogKind? _ClassifyLogKind(CrashLogEntry file)
{
var name = file.FileName.ToLowerInvariant();
if (name.StartsWith("hs_err", StringComparison.Ordinal))
return CrashLogKind.HsErr;
if (name.StartsWith("crash-", StringComparison.Ordinal))
return CrashLogKind.CrashReport;
if (KnownDebugLogNames.Contains(name))
return CrashLogKind.Debug;
if (KnownGameLogNames.Contains(name))
return CrashLogKind.Game;
if (KnownLauncherLogNames.Contains(name) &&
file.Lines.Any(line => line.Contains("以下为游戏输出的最后一段内容")))
return CrashLogKind.Game;
return null;
}
private static bool _IsExtraLogLike(CrashLogEntry file)
{
var name = file.FileName.ToLowerInvariant();
return name.EndsWith(".log", StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase);
}
private CrashLogText? _SelectGameLog(IReadOnlyList<ClassifiedCrashLog> files)
{
var gameFiles = files
.Where(item => item.Kind is CrashLogKind.Game or CrashLogKind.Debug)
.Select(item => item.File)
.ToList();
if (gameFiles.Count == 0)
return null;
var byName = gameFiles
.GroupBy(file => file.FileName.ToLowerInvariant())
.ToDictionary(
group => group.Key,
group => group.OrderBy(_GetLastWriteTime).Last(),
StringComparer.OrdinalIgnoreCase);
var text = "";
foreach (var name in new[]
{
"rawoutput.log",
"启动器日志.txt",
"log1.txt",
"log-ce1.log",
"游戏崩溃前的输出.txt",
"PCL CE 启动器日志.txt",
"PCL2 启动器日志.txt",
"PCL 启动器日志.txt"
})
{
if (!byName.TryGetValue(name, out var currentLog))
continue;
text += _ExtractLauncherGameOutput(currentLog);
context.OutputFiles.Add(currentLog.FullPath);
LogWrapper.Info("Crash", $"导入分析:{currentLog.FullPath},作为启动器日志");
break;
}
foreach (var name in new[]
{
"latest.log",
"latest log.txt",
"debug.log",
"debug log.txt"
})
{
if (!byName.TryGetValue(name, out var currentLog))
continue;
text += _GetHeadTailLines(currentLog.Lines, 1500, 500);
context.OutputFiles.Add(currentLog.FullPath);
LogWrapper.Info("Crash", $"导入分析:{currentLog.FullPath},作为 Minecraft 日志");
break;
}
if (string.IsNullOrEmpty(text))
{
var fallback = gameFiles
.OrderBy(_GetLastWriteTime)
.Last();
text = _GetHeadTailLines(fallback.Lines, 1500, 500);
context.OutputFiles.Add(fallback.FullPath);
LogWrapper.Info("Crash", $"导入分析:{fallback.FullPath},作为兜底日志");
}
foreach (var file in gameFiles.Where(file => !context.OutputFiles.Contains(file.FullPath)))
{
context.OutputFiles.Add(file.FullPath);
LogWrapper.Info("Crash", $"输出报告:{file.FullPath},作为 Minecraft 或启动器日志");
}
return new CrashLogText
{
Kind = CrashLogKind.Game,
Text = text.TrimEnd('\r', '\n'),
FilePath = gameFiles.FirstOrDefault()?.FullPath
};
}
private CrashLogText? _SelectDebugLog(IReadOnlyList<ClassifiedCrashLog> files)
{
var debugFiles = files
.Where(item => item.Kind == CrashLogKind.Debug)
.Select(item => item.File)
.ToList();
if (debugFiles.Count == 0)
return null;
var selected = debugFiles
.OrderBy(_GetLastWriteTime)
.Last();
if (!context.OutputFiles.Contains(selected.FullPath))
context.OutputFiles.Add(selected.FullPath);
LogWrapper.Info("Crash", $"导入分析:{selected.FullPath},作为 Minecraft Debug 日志");
return new CrashLogText
{
Kind = CrashLogKind.Debug,
Text = _GetHeadTailLines(selected.Lines, 1000, 0),
FilePath = selected.FullPath
};
}
private CrashLogText? _SelectNewest(
IReadOnlyList<ClassifiedCrashLog> files,
CrashLogKind kind,
int head,
int tail)
{
var selectedFiles = files
.Where(item => item.Kind == kind)
.Select(item => item.File)
.ToList();
if (selectedFiles.Count == 0)
return null;
var selected = selectedFiles
.OrderBy(_GetLastWriteTime)
.Last();
context.OutputFiles.Add(selected.FullPath);
LogWrapper.Info(
"Crash",
$"输出报告:{selected.FullPath}{(kind == CrashLogKind.HsErr ? "" : " Minecraft ")}");
LogWrapper.Info(
"Crash",
$"导入分析:{selected.FullPath}{(kind == CrashLogKind.HsErr ? "" : " Minecraft ")}");
return new CrashLogText
{
Kind = kind,
Text = _GetHeadTailLines(selected.Lines, head, tail),
FilePath = selected.FullPath
};
}
private static string _ExtractLauncherGameOutput(CrashLogEntry log)
{
var text = "";
var hasLauncherMark = false;
foreach (var line in log.Lines)
if (hasLauncherMark)
{
text += line + "\n";
}
else if (line.Contains("以下为游戏输出的最后一段内容"))
{
hasLauncherMark = true;
LogWrapper.Info("Crash", "找到 PCL 输出的游戏实时日志头");
}
return hasLauncherMark
? text.TrimEnd('\r', '\n')
: _GetHeadTailLines(log.Lines, 0, 500);
}
private static DateTime _GetLastWriteTime(CrashLogEntry file)
{
try
{
return new FileInfo(file.FullPath).LastWriteTime;
}
catch (Exception ex)
{
LogWrapper.Warn(ex, "Crash", "获取日志文件修改时间失败");
return new DateTime(1900, 1, 1);
}
}
private bool _IsGeneratedLog(CrashLogEntry file) =>
file.FullPath.StartsWith(context.TempFolder, StringComparison.OrdinalIgnoreCase);
private static string _GetHeadTailLines(
IReadOnlyList<string> raw,
int headLines,
int tailLines)
{
if (raw.Count <= headLines + tailLines)
return string.Join("\n", raw.Distinct());
var lines = new List<string>();
var seen = new HashSet<string>();
var realHeadLines = 0;
int viewedLines;
for (viewedLines = 0; viewedLines <= raw.Count - 1; viewedLines++)
{
if (!seen.Add(raw[viewedLines]))
continue;
realHeadLines += 1;
lines.Add(raw[viewedLines]);
if (realHeadLines >= headLines)
break;
}
var realTailLines = 0;
for (var i = raw.Count - 1; i >= viewedLines; i -= 1)
{
if (!seen.Add(raw[i]))
continue;
realTailLines += 1;
lines.Insert(realHeadLines, raw[i]);
if (realTailLines >= tailLines)
break;
}
var result = new StringBuilder();
foreach (var line in lines.Where(line => !string.IsNullOrEmpty(line)))
{
result.Append(line);
result.Append('\n');
}
return result.ToString();
}
private sealed class ClassifiedCrashLog(
CrashLogEntry file,
CrashLogKind? kind)
{
public CrashLogEntry File { get; } = file;
public CrashLogKind? Kind { get; } = kind;
}
}