focus on intervals, and fix provider
This commit is contained in:
+77
-10
@@ -1,4 +1,4 @@
|
||||
using AnotherReplayReader.ReplayFile;
|
||||
using AnotherReplayReader.ReplayFile;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
@@ -175,17 +175,18 @@ namespace AnotherReplayReader.Utils
|
||||
# 输出要求
|
||||
## 1. 总览阶段
|
||||
触发条件:用户输入包含:""请先对整局进行总览""
|
||||
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
|
||||
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样;本阶段不会获取原始操作记录
|
||||
- 你的任务:
|
||||
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
|
||||
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
|
||||
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
|
||||
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
|
||||
- 只描述对局摘要中明确支持的内容,不要展开推断摘要没有依据的整局走势
|
||||
- 如果某个分段在后续分析时可能需要对局摘要之外的原始区间,在对应行后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议
|
||||
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
|
||||
|
||||
## 2. 分段分析、推理阶段
|
||||
触发条件:用户输入类似于:""请重点分析第N段([BEGIN]至[END])""
|
||||
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
|
||||
- 输入中包含:当前分段的原始操作记录切片、之前各阶段的已发现事实摘要,以及整局总览
|
||||
- 程序会把一个分段按时间切分为若干“分析窗口”(每轮一个窗口)。你当前分析的是其中一个窗口,但完整的分段切片仍然是你能看到的数据范围
|
||||
- 你的重点任务:分析当前窗口时间范围内的主要事件与上下文;但同时应主动查看并关联窗口之外、仍在本段切片中的相关事件(例如生产、建造、打包/展开、技能释放的后续影响、部队调动)
|
||||
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
|
||||
- 选取该阶段的主要事件,以及和它们的上下文
|
||||
- 也可以选择数个其他有分析价值的事件
|
||||
@@ -197,7 +198,7 @@ namespace AnotherReplayReader.Utils
|
||||
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
|
||||
- 输出:该阶段的各个主要事件,以及你的推理和发现
|
||||
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
|
||||
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
|
||||
- 最后用一行 `[小结]` 输出 2~3 句该份分析最重要的结论,供后续分析窗口与后续分段参考
|
||||
|
||||
## 3. 最终总结阶段
|
||||
触发条件:用户输入包含:""请对以上内容进行总结""
|
||||
@@ -1069,9 +1070,11 @@ PlayerA: 开始出兵
|
||||
public static string BuildOverviewUserPrompt(IReadOnlyList<ReplaySlice> slices)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("请先对整局进行总览。");
|
||||
sb.AppendLine("请基于对局摘要对分段进行总览。");
|
||||
sb.AppendLine("下方是程序生成的分段元数据(分段边界由程序预先切好,不要修改)。对局摘要已在系统消息中提供。");
|
||||
sb.AppendLine("请描述整局走势(允许跨分段),并为每个分段给出标题与一句话概述。");
|
||||
sb.AppendLine("本阶段不会获取原始操作记录。请只根据对局摘要和分段元数据,为每个分段给出标题与一句话概述。");
|
||||
sb.AppendLine("如果某段在后续分析时可能需要对局摘要之外的原始区间,请在该段后另起一行写 `回查: mm:ss~mm:ss`,程序会把它作为该段的回查建议。");
|
||||
sb.AppendLine("不要展开推断对局摘要没有明确支持的内容。");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("[分段元数据]");
|
||||
foreach (var slice in slices)
|
||||
@@ -1079,7 +1082,7 @@ PlayerA: 开始出兵
|
||||
sb.AppendLine($"#{(slice.Index + 1)} {MatchDigestBuilder.FormatTime(slice.Start)}~{MatchDigestBuilder.FormatTime(slice.End)} 事件数 {slice.EventCount} 约 {slice.EstimatedTokens} token");
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("输出格式:先自由描述整局走势与跨段线索,然后输出 [分段概述] 块,每段一行 `#N 标题:概述`;如某段需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`。");
|
||||
sb.AppendLine("输出格式:直接输出 [分段概述] 块,每段一行 `#N 标题:概述`;如某段建议后续回查,在对应行后另起一行写 `回查: mm:ss~mm:ss`。不要输出整局叙述。");
|
||||
return sb.ToString().Replace("\r", "");
|
||||
}
|
||||
|
||||
@@ -1114,6 +1117,70 @@ PlayerA: 开始出兵
|
||||
return instruction.Trim().Replace("\r", "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 段内焦点窗口指令:数据范围 = 整个机械分段切片(尽量长,但不超过上下文上限),
|
||||
/// 分析“重点”只落在窗口时间段内;同时鼓励模型跨时间关联窗口外的背景事件。
|
||||
/// </summary>
|
||||
public static string BuildFocusWindowUserPromptV2(
|
||||
int segmentIndex,
|
||||
int totalSegments,
|
||||
ReplaySlice slice,
|
||||
int windowIndex,
|
||||
int windowCount,
|
||||
FocusWindow window,
|
||||
int sliceEventCount,
|
||||
int windowEventCount,
|
||||
string? title,
|
||||
string? description = null,
|
||||
IEnumerable<string>? backqueryHints = null)
|
||||
{
|
||||
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
|
||||
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
|
||||
var windowBeginText = MatchDigestBuilder.FormatTime(window.Start);
|
||||
var windowEndText = MatchDigestBuilder.FormatTime(window.End);
|
||||
var titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
|
||||
var descriptionLine = string.IsNullOrWhiteSpace(description) ? "" : $"\n段落概述:{description}";
|
||||
var hintLine = backqueryHints is { } hints && hints.Any()
|
||||
? "\n总览建议可回查区间:" + string.Join("、", hints)
|
||||
: "";
|
||||
|
||||
string windowInstruction;
|
||||
string windowHeader;
|
||||
if (windowCount <= 1)
|
||||
{
|
||||
windowInstruction =
|
||||
$"请重点分析 {beginText} 至 {endText} 时间段的操作数据。";
|
||||
windowHeader = "\n本段数据量适中,作为一个整体重点时间段分析。";
|
||||
}
|
||||
else
|
||||
{
|
||||
windowInstruction =
|
||||
$"请重点分析 {windowBeginText} 至 {windowEndText} 时间段的操作数据。";
|
||||
windowHeader =
|
||||
$"\n本段已按时间划分为 {windowCount} 个重点时间段,当前是第 {windowIndex + 1} 个(后续时间段会依次分析)。";
|
||||
}
|
||||
|
||||
var instruction = @$"
|
||||
{windowInstruction}
|
||||
{windowHeader}
|
||||
本重点时间段约有 {windowEventCount} 条操作信息。
|
||||
下方已提供第{segmentIndex + 1}段({beginText}至{endText})的完整操作记录(约 {sliceEventCount} 条)作为数据与背景。{titleLine}{descriptionLine}
|
||||
{hintLine}
|
||||
你可以参考输入中的对局摘要、整局总览与之前各窗口/各段的已发现事实。
|
||||
|
||||
你的重点任务是分析本重点时间段内的主要事件与它们的上下文,但不要局限于此时间段:
|
||||
- 请主动查找并关联本时间段之外、但处于本段完整记录中的相关事件。如果当前事件与更早或更晚的事件存在关联(例如生产、建造、打包/展开、技能释放的后续影响、部队调动),请结合这些跨时间事件进行分析。
|
||||
- 如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间。
|
||||
- 不要因为重点时间段短就把每一条操作都当作单独事件,也不要省略该时间段内的重要事件。
|
||||
|
||||
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对本时间段内的事件进行分析和推理。
|
||||
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
|
||||
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空 JSON 对象。
|
||||
最后用一行 `[小结]` 输出 2~3 句本重点时间段最重要的结论。
|
||||
";
|
||||
return instruction.Trim().Replace("\r", "");
|
||||
}
|
||||
|
||||
public static string BuildSummaryUserPromptV2(int totalEventCount)
|
||||
{
|
||||
var instruction = $@"
|
||||
|
||||
+50
-4
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -298,9 +298,55 @@ namespace AnotherReplayReader
|
||||
public List<AiProvider> Providers { get; set; } = [];
|
||||
public AiPromptSettings Prompt { get; set; } = new();
|
||||
|
||||
// 以下两个不持久化,由 UI 层维护当前选中项
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public int CurrentProviderIndex { get; set; }
|
||||
/// <summary>
|
||||
/// 上次选中的 Provider 名称(持久化;用于下次打开设置页时恢复)。
|
||||
/// 按名称识别,Provider 被重命名/删除后自动回退到第一个 Provider。
|
||||
/// </summary>
|
||||
public string? CurrentProviderName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上次选中的模型 ID(持久化;与 <see cref="CurrentProviderName"/> 配合使用)。
|
||||
/// 模型被删除后自动回退到该 Provider 的第一个模型。
|
||||
/// </summary>
|
||||
public string? CurrentModelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新并持久化当前选中的 Provider 与模型。
|
||||
/// </summary>
|
||||
public void SetCurrentSelection(AiProvider provider, AiModel model)
|
||||
{
|
||||
CurrentProviderName = provider.Name;
|
||||
CurrentModelId = model.ModelId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析持久化的上次选择。任一标识缺失或对应 Provider/Model 已不存在时返回 null。
|
||||
/// </summary>
|
||||
public (AiProvider Provider, AiModel Model)? ResolveLastSelection()
|
||||
{
|
||||
var providerName = CurrentProviderName?.Trim();
|
||||
var modelId = CurrentModelId?.Trim();
|
||||
if (string.IsNullOrEmpty(providerName) || string.IsNullOrEmpty(modelId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var provider = Providers.FirstOrDefault(p =>
|
||||
string.Equals(p.Name, providerName, StringComparison.OrdinalIgnoreCase));
|
||||
if (provider is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var model = provider.Models.FirstOrDefault(m =>
|
||||
string.Equals(m.ModelId, modelId, StringComparison.OrdinalIgnoreCase));
|
||||
if (model is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (provider, model);
|
||||
}
|
||||
|
||||
private static readonly string ConfigPath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
|
||||
+358
-47
@@ -189,6 +189,154 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 段内的焦点窗口:模型每轮分析的重点时间范围,而不是数据切片的边界。
|
||||
/// 数据层仍提供整个机械分段切片(上下文允许时尽量长),焦点窗口只决定“重点分析哪段时间”。
|
||||
/// </summary>
|
||||
internal sealed record FocusWindow(int Index, TimeSpan Start, TimeSpan End, int EventCount, int EstimatedTokens);
|
||||
|
||||
/// <summary>
|
||||
/// 把机械分段切分为多个“焦点窗口”。与 MechanicalSegmenter 不同:
|
||||
/// 焦点窗口不改变模型可见的数据范围,只划分每轮分析的重点,用于避免模型一次分析过长的时间段。
|
||||
/// </summary>
|
||||
internal static class FocusPlanner
|
||||
{
|
||||
/// <summary>焦点窗口的目标 token 大小(近似)。</summary>
|
||||
public const int DefaultWindowTokens = 12_000;
|
||||
/// <summary>单个机械分段最多切分的焦点窗口数。</summary>
|
||||
public const int MaxWindowsPerSlice = 5;
|
||||
/// <summary>小于该 token 数的机械分段不再细分(直接作为单一焦点窗口)。</summary>
|
||||
public const int MinSliceForSplitTokens = DefaultWindowTokens * 2;
|
||||
|
||||
public static ImmutableArray<FocusWindow> Plan(
|
||||
ReplaySlice slice,
|
||||
ImmutableArray<EventSpan> fullSpans)
|
||||
{
|
||||
if (slice.EventCount <= 0 || fullSpans.IsEmpty)
|
||||
{
|
||||
return ImmutableArray<FocusWindow>.Empty;
|
||||
}
|
||||
|
||||
// 直接用切片自身的字符区间(StartIndex/Length)在 span 索引中定位,
|
||||
// 避免按时间范围匹配与机械分段(含重叠)的实际内容不一致。
|
||||
var startIndex = 0;
|
||||
while (startIndex < fullSpans.Length
|
||||
&& fullSpans[startIndex].StartIndex < slice.StartIndex)
|
||||
{
|
||||
startIndex++;
|
||||
}
|
||||
var endIndexExclusive = startIndex;
|
||||
while (endIndexExclusive < fullSpans.Length
|
||||
&& fullSpans[endIndexExclusive].StartIndex < slice.StartIndex + slice.Length)
|
||||
{
|
||||
endIndexExclusive++;
|
||||
}
|
||||
if (startIndex >= fullSpans.Length || endIndexExclusive <= startIndex)
|
||||
{
|
||||
// 回退:用切片自身的长度作为单一窗口(不应发生,防御性处理)。
|
||||
return ImmutableArray.Create(
|
||||
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, slice.EstimatedTokens));
|
||||
}
|
||||
|
||||
// 若切片本身不大,或者时间太短,则单一窗口。
|
||||
var totalTokens = 0;
|
||||
for (var i = startIndex; i < endIndexExclusive; ++i)
|
||||
{
|
||||
totalTokens += fullSpans[i].EstimatedTokens;
|
||||
}
|
||||
if (totalTokens <= MinSliceForSplitTokens
|
||||
|| endIndexExclusive - startIndex <= 1)
|
||||
{
|
||||
return ImmutableArray.Create(
|
||||
new FocusWindow(0, slice.Start, slice.End, slice.EventCount, totalTokens));
|
||||
}
|
||||
|
||||
var windows = new List<FocusWindow>();
|
||||
var acc = 0;
|
||||
var winStart = startIndex;
|
||||
for (var i = startIndex; i < endIndexExclusive; ++i)
|
||||
{
|
||||
var span = fullSpans[i];
|
||||
if (acc > 0 && acc + span.EstimatedTokens > DefaultWindowTokens
|
||||
&& i - winStart >= 1)
|
||||
{
|
||||
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, i));
|
||||
winStart = i;
|
||||
acc = 0;
|
||||
}
|
||||
acc += span.EstimatedTokens;
|
||||
}
|
||||
if (winStart < endIndexExclusive)
|
||||
{
|
||||
windows.Add(CreateWindow(windows.Count, slice, fullSpans, winStart, endIndexExclusive));
|
||||
}
|
||||
|
||||
// 超过上限时合并尾部窗口(优先合并 token 较小的相邻窗口,保持时间顺序)。
|
||||
while (windows.Count > MaxWindowsPerSlice)
|
||||
{
|
||||
var best = -1;
|
||||
var bestTokens = int.MaxValue;
|
||||
for (var i = 0; i < windows.Count - 1 && windows.Count > MaxWindowsPerSlice; ++i)
|
||||
{
|
||||
var merged = windows[i].EstimatedTokens + windows[i + 1].EstimatedTokens;
|
||||
if (merged < bestTokens)
|
||||
{
|
||||
best = i;
|
||||
bestTokens = merged;
|
||||
}
|
||||
}
|
||||
if (best < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
windows[best] = MergeWindows(windows[best], windows[best + 1]);
|
||||
windows.RemoveAt(best + 1);
|
||||
RenumberWindows(windows);
|
||||
}
|
||||
return windows.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static FocusWindow CreateWindow(
|
||||
int index,
|
||||
ReplaySlice slice,
|
||||
ImmutableArray<EventSpan> fullSpans,
|
||||
int start,
|
||||
int endExclusive)
|
||||
{
|
||||
var tokens = 0;
|
||||
var events = 0;
|
||||
for (var j = start; j < endExclusive; ++j)
|
||||
{
|
||||
tokens += fullSpans[j].EstimatedTokens;
|
||||
events++;
|
||||
}
|
||||
return new FocusWindow(
|
||||
index,
|
||||
fullSpans[start].Time,
|
||||
fullSpans[endExclusive - 1].Time,
|
||||
events,
|
||||
tokens);
|
||||
}
|
||||
|
||||
private static FocusWindow MergeWindows(FocusWindow a, FocusWindow b)
|
||||
{
|
||||
return new FocusWindow(
|
||||
a.Index,
|
||||
a.Start,
|
||||
b.End,
|
||||
a.EventCount + b.EventCount,
|
||||
a.EstimatedTokens + b.EstimatedTokens);
|
||||
}
|
||||
|
||||
private static void RenumberWindows(List<FocusWindow> windows)
|
||||
{
|
||||
for (var i = 0; i < windows.Count; ++i)
|
||||
{
|
||||
windows[i] = windows[i] with { Index = i };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确定性对局摘要:由 ReplayFactIndex + 规则采样生成,不依赖 LLM,保证同一次运行内稳定。
|
||||
/// </summary>
|
||||
@@ -207,55 +355,109 @@ namespace AnotherReplayReader.Utils
|
||||
sb.AppendLine("# 玩家");
|
||||
foreach (var kv in players)
|
||||
{
|
||||
var factionName = ModData.GetFaction(mod, kv.Value.FactionId).Name;
|
||||
var kind = kv.Value.IsComputer ? "电脑" : "玩家";
|
||||
sb.AppendLine($"- 玩家#{kv.Key} {kv.Value.PlayerName}({names[kv.Key]}),{factionName},队伍{kv.Value.Team},{kind}");
|
||||
var faction = ModData.GetFaction(mod, kv.Value.FactionId);
|
||||
var factionName = faction.Name;
|
||||
if (faction.Kind == FactionKind.Observer)
|
||||
{
|
||||
sb.AppendLine(
|
||||
$"- 玩家#{kv.Key} {kv.Value.PlayerName}({names[kv.Key]}),"
|
||||
+ $"{factionName},解说员(观战),不参与对局");
|
||||
}
|
||||
else
|
||||
{
|
||||
var kind = kv.Value.IsComputer ? "电脑" : "玩家";
|
||||
var teamText = kv.Value.Team < 0 ? "无队伍" : $"队伍{kv.Value.Team}";
|
||||
sb.AppendLine(
|
||||
$"- 玩家#{kv.Key} {kv.Value.PlayerName}({names[kv.Key]}),"
|
||||
+ $"{factionName},{teamText},{kind}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("# 首次出兵时间表");
|
||||
foreach (var kv in factIndex.PlayerFirstProductionTime.OrderBy(k => k.Key))
|
||||
sb.AppendLine("# 首次出兵时间表(命令开始时间)");
|
||||
if (factIndex.PlayerFirstProductionTime.IsEmpty)
|
||||
{
|
||||
var productions = kv.Value
|
||||
.OrderBy(x => x.Value)
|
||||
.Take(15)
|
||||
.Select(x => $"{x.Key}@{FormatTime(x.Value)}");
|
||||
sb.AppendLine($"- 玩家#{kv.Key}({names[kv.Key]}):{string.Join("、", productions)}");
|
||||
sb.AppendLine("- (无)");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var kv in factIndex.PlayerFirstProductionTime.OrderBy(k => k.Key))
|
||||
{
|
||||
var productions = kv.Value
|
||||
.OrderBy(x => x.Value)
|
||||
.Take(15)
|
||||
.Select(x => $"{x.Key}@{FormatTime(x.Value)}");
|
||||
sb.AppendLine($"- 玩家#{kv.Key}({names[kv.Key]}):{string.Join("、", productions)}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("# 协议选择");
|
||||
foreach (var kv in factIndex.PlayerTechChoices.OrderBy(k => k.Key))
|
||||
if (factIndex.PlayerTechChoices.IsEmpty)
|
||||
{
|
||||
sb.AppendLine($"- 玩家#{kv.Key}({names[kv.Key]}):{string.Join("、", kv.Value.OrderBy(x => x))}");
|
||||
sb.AppendLine("- (无)");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var kv in factIndex.PlayerTechChoices.OrderBy(k => k.Key))
|
||||
{
|
||||
sb.AppendLine($"- 玩家#{kv.Key}({names[kv.Key]}):{string.Join("、", kv.Value.OrderBy(x => x))}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
var unitRoles = BuildUnitRoleMap(factIndex);
|
||||
sb.AppendLine("# 所有权证据(节选)");
|
||||
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds.OrderBy(k => k.Key))
|
||||
{
|
||||
if (IsObserver(mod, players, kv.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var ids = kv.Value.OrderBy(x => x).Take(20);
|
||||
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
|
||||
sb.AppendLine($"- 玩家#{kv.Key}({names[kv.Key]}):强证据 {kv.Value.Count} 个 UnitId({string.Join("、", ids)}{suffix})");
|
||||
sb.AppendLine(
|
||||
$"- 玩家#{kv.Key}({names[kv.Key]}):强证据 {kv.Value.Count} 个 UnitId"
|
||||
+ $"({string.Join("、", ids.Select(x => FormatUnitIdWithRole(x, unitRoles)))}{suffix})");
|
||||
}
|
||||
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds.OrderBy(k => k.Key))
|
||||
{
|
||||
if (IsObserver(mod, players, kv.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var ids = kv.Value.OrderBy(x => x).Take(20);
|
||||
var suffix = kv.Value.Count > 20 ? "…" : string.Empty;
|
||||
sb.AppendLine($"- 玩家#{kv.Key}({names[kv.Key]}):弱证据 {kv.Value.Count} 个 UnitId({string.Join("、", ids)}{suffix})");
|
||||
sb.AppendLine(
|
||||
$"- 玩家#{kv.Key}({names[kv.Key]}):弱证据 {kv.Value.Count} 个 UnitId"
|
||||
+ $"({string.Join("、", ids.Select(x => FormatUnitIdWithRole(x, unitRoles)))}{suffix})");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
sb.AppendLine("# 打包/展开");
|
||||
var packUnits = factIndex.UnitIdSpecialPowers
|
||||
.Where(kv2 => kv2.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
|
||||
.OrderBy(kv2 => kv2.Key);
|
||||
foreach (var kv2 in packUnits)
|
||||
sb.AppendLine("# 打包/展开(按实际事件时间)");
|
||||
var packEvents = factIndex.SpecialPowerEvents
|
||||
.Where(e => ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|
||||
|| ContainsIgnoreCase(e.PowerName, "UnpackReplaceSelf"))
|
||||
.OrderBy(e => e.Time)
|
||||
.ThenBy(e => e.PlayerIndex);
|
||||
if (!packEvents.Any())
|
||||
{
|
||||
var firstTime = factIndex.UnitIdFirstObservedTime.TryGetValue(kv2.Key, out var t)
|
||||
? FormatTime(t)
|
||||
: "?";
|
||||
sb.AppendLine($"- UnitId {kv2.Key}(首次出现 {firstTime}):{string.Join("、", kv2.Value.OrderBy(x => x))}");
|
||||
sb.AppendLine("- (无)");
|
||||
}
|
||||
foreach (var e in packEvents)
|
||||
{
|
||||
var action = ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|
||||
? "Pack"
|
||||
: "Unpack";
|
||||
var playerName = names.TryGetValue(e.PlayerIndex, out var name)
|
||||
? name
|
||||
: $"玩家#{e.PlayerIndex}";
|
||||
var conflict = IsPackFactionConflict(mod, players, e)
|
||||
? " [阵营冲突,需回查]"
|
||||
: string.Empty;
|
||||
sb.AppendLine(
|
||||
$"- 玩家#{e.PlayerIndex}({playerName})UnitId {e.UnitId}:"
|
||||
+ $"{action}@{FormatTime(e.Time)}{conflict}");
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
@@ -287,41 +489,150 @@ namespace AnotherReplayReader.Utils
|
||||
private static ImmutableArray<string> SampleKeyEvents(string text, int maxEvents)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var currentTime = "";
|
||||
var counts = new Dictionary<(string Player, string Category), int>();
|
||||
var seen = new HashSet<string>();
|
||||
var currentTime = string.Empty;
|
||||
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
|
||||
{
|
||||
var line = rawLine.Trim();
|
||||
if (line.StartsWith("[") && line.Contains("]"))
|
||||
if (result.Count >= maxEvents)
|
||||
{
|
||||
var end = line.IndexOf(']');
|
||||
currentTime = line.Substring(1, end - 1);
|
||||
var rest = line.Substring(end + 1).Trim();
|
||||
if (IsKeyEventLine(rest))
|
||||
{
|
||||
result.Add($"[{currentTime}] {rest}");
|
||||
if (result.Count >= maxEvents)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
var line = rawLine.TrimEnd();
|
||||
var trimmed = line.Trim();
|
||||
if (TimeStampPattern.IsMatch(trimmed))
|
||||
{
|
||||
currentTime = TimeStampPattern.Match(trimmed).Groups[1].Value;
|
||||
continue;
|
||||
}
|
||||
if (IsKeyEventLine(line))
|
||||
|
||||
// 参数/续行统一跳过,避免把 [UnitId]... 当作事件
|
||||
if (string.IsNullOrWhiteSpace(trimmed)
|
||||
|| line.StartsWith(" ", StringComparison.Ordinal)
|
||||
|| line.StartsWith("\t", StringComparison.Ordinal)
|
||||
|| trimmed.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
result.Add($"[{currentTime}] {line}");
|
||||
if (result.Count >= maxEvents)
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var commandMatch = CommandPattern.Match(trimmed);
|
||||
if (!commandMatch.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var player = commandMatch.Groups[1].Value.Trim();
|
||||
var command = commandMatch.Groups[2].Value.Trim();
|
||||
var category = GetEventCategory(command);
|
||||
if (category is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(player + "|" + command))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = (player, category);
|
||||
counts.TryGetValue(key, out var count);
|
||||
if (count >= 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
counts[key] = count + 1;
|
||||
result.Add($"[{currentTime}] {player}: {command}");
|
||||
}
|
||||
return result.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static bool IsKeyEventLine(string line) =>
|
||||
line.Contains("开始建造") || line.Contains("摆放建筑") || line.Contains("出售建筑") ||
|
||||
line.Contains("释放特殊能力") || line.Contains("选择协议") || line.Contains("开始出兵") ||
|
||||
line.Contains("开始升级");
|
||||
private static string? GetEventCategory(string command)
|
||||
{
|
||||
if (command.Contains("开始建造")) return "建造";
|
||||
if (command.Contains("摆放建筑")) return "摆放";
|
||||
if (command.Contains("出售建筑")) return "出售";
|
||||
if (command.Contains("开始出兵")) return "生产";
|
||||
if (command.Contains("开始升级")) return "升级";
|
||||
if (command.Contains("选择协议")) return "协议";
|
||||
if (command.Contains("释放特殊能力")) return "技能";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly Regex TimeStampPattern = new(
|
||||
@"^\[(\d+:\d+(?:\.\d+)?)\]$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex CommandPattern = new(
|
||||
@"^([^::,,]+)\s*[::,,]\s*(.+)$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static Dictionary<uint, HashSet<string>> BuildUnitRoleMap(ReplayFactIndex factIndex)
|
||||
{
|
||||
var roles = new Dictionary<uint, HashSet<string>>();
|
||||
void Add(uint unitId, string role)
|
||||
{
|
||||
if (!roles.TryGetValue(unitId, out var set))
|
||||
{
|
||||
set = new HashSet<string>();
|
||||
roles[unitId] = set;
|
||||
}
|
||||
set.Add(role);
|
||||
}
|
||||
|
||||
foreach (var id in factIndex.BuilderUnitIds)
|
||||
{
|
||||
Add(id, "建造者");
|
||||
}
|
||||
foreach (var id in factIndex.ProducerUnitIds)
|
||||
{
|
||||
Add(id, "出兵建筑");
|
||||
}
|
||||
foreach (var kv in factIndex.UnitIdSpecialPowers)
|
||||
{
|
||||
if (kv.Value.Any(p => p.Contains("PackReplaceSelf") || p.Contains("UnpackReplaceSelf")))
|
||||
{
|
||||
Add(kv.Key, "打包/展开");
|
||||
}
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
private static string FormatUnitIdWithRole(
|
||||
uint unitId,
|
||||
Dictionary<uint, HashSet<string>> unitRoles)
|
||||
{
|
||||
if (!unitRoles.TryGetValue(unitId, out var roles) || roles.Count == 0)
|
||||
{
|
||||
return unitId.ToString();
|
||||
}
|
||||
return $"{unitId}({string.Join("/", roles.OrderBy(x => x))})";
|
||||
}
|
||||
|
||||
private static bool IsObserver(
|
||||
Mod mod,
|
||||
ImmutableSortedDictionary<int, Player> players,
|
||||
int playerIndex)
|
||||
{
|
||||
return players.TryGetValue(playerIndex, out var player)
|
||||
&& ModData.GetFaction(mod, player.FactionId).Kind == FactionKind.Observer;
|
||||
}
|
||||
|
||||
private static bool IsPackFactionConflict(
|
||||
Mod mod,
|
||||
ImmutableSortedDictionary<int, Player> players,
|
||||
SpecialPowerEvent e)
|
||||
{
|
||||
if (!ContainsIgnoreCase(e.PowerName, "PackReplaceSelf")
|
||||
|| !players.TryGetValue(e.PlayerIndex, out var player))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return ModData.GetFaction(mod, player.FactionId).Name != "盟军";
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(string text, string value) =>
|
||||
text.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
public static string FormatTime(TimeSpan t) => $"{(int)t.TotalMinutes}:{t:ss\\.ff}";
|
||||
}
|
||||
|
||||
+110
-14
@@ -6,6 +6,13 @@ using System.Linq;
|
||||
|
||||
namespace AnotherReplayReader.Utils
|
||||
{
|
||||
/// <summary>一次特殊能力事件(含真实发生时间与玩家),用于生成可读的打包/展开时间线。</summary>
|
||||
internal sealed record SpecialPowerEvent(
|
||||
TimeSpan Time,
|
||||
int PlayerIndex,
|
||||
uint UnitId,
|
||||
string PowerName);
|
||||
|
||||
/// <summary>
|
||||
/// Index of replay facts extracted from CommandChunk data.
|
||||
/// Used by AIAnalysisValidation to cross-reference LLM claims against
|
||||
@@ -19,6 +26,9 @@ namespace AnotherReplayReader.Utils
|
||||
/// <summary>Special powers used by each UnitId.</summary>
|
||||
public ImmutableDictionary<uint, ImmutableHashSet<string>> UnitIdSpecialPowers { get; }
|
||||
|
||||
/// <summary>按时间排序的特殊能力事件列表。</summary>
|
||||
public ImmutableArray<SpecialPowerEvent> SpecialPowerEvents { get; }
|
||||
|
||||
/// <summary>UnitIds that appeared as builder ("建造者") in construction commands.</summary>
|
||||
public ImmutableHashSet<uint> BuilderUnitIds { get; }
|
||||
|
||||
@@ -43,6 +53,7 @@ namespace AnotherReplayReader.Utils
|
||||
public ReplayFactIndex(
|
||||
ImmutableDictionary<uint, TimeSpan> unitIdFirstObservedTime,
|
||||
ImmutableDictionary<uint, ImmutableHashSet<string>> unitIdSpecialPowers,
|
||||
ImmutableArray<SpecialPowerEvent> specialPowerEvents,
|
||||
ImmutableHashSet<uint> builderUnitIds,
|
||||
ImmutableHashSet<uint> producerUnitIds,
|
||||
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> playerFirstProductionTime,
|
||||
@@ -53,6 +64,7 @@ namespace AnotherReplayReader.Utils
|
||||
{
|
||||
UnitIdFirstObservedTime = unitIdFirstObservedTime;
|
||||
UnitIdSpecialPowers = unitIdSpecialPowers;
|
||||
SpecialPowerEvents = specialPowerEvents;
|
||||
BuilderUnitIds = builderUnitIds;
|
||||
ProducerUnitIds = producerUnitIds;
|
||||
PlayerFirstProductionTime = playerFirstProductionTime;
|
||||
@@ -68,6 +80,7 @@ namespace AnotherReplayReader.Utils
|
||||
{
|
||||
var unitFirstObserved = new Dictionary<uint, TimeSpan>();
|
||||
var unitSpecialPowers = new Dictionary<uint, HashSet<string>>();
|
||||
var specialPowerEvents = new List<SpecialPowerEvent>();
|
||||
var builderUnits = new HashSet<uint>();
|
||||
var producerUnits = new HashSet<uint>();
|
||||
var playerFirstProduction = new Dictionary<int, Dictionary<string, TimeSpan>>();
|
||||
@@ -83,7 +96,7 @@ namespace AnotherReplayReader.Utils
|
||||
foreach (var command in commands)
|
||||
{
|
||||
ProcessCommand(time, command, stringHashTable,
|
||||
unitFirstObserved, unitSpecialPowers,
|
||||
unitFirstObserved, unitSpecialPowers, specialPowerEvents,
|
||||
builderUnits, producerUnits,
|
||||
playerFirstProduction, playerSelected,
|
||||
playerStrongOwnership, playerWeakOwnership,
|
||||
@@ -95,6 +108,11 @@ namespace AnotherReplayReader.Utils
|
||||
unitFirstObserved.ToImmutableDictionary(),
|
||||
unitSpecialPowers.ToImmutableDictionary(
|
||||
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
|
||||
specialPowerEvents
|
||||
.OrderBy(e => e.Time)
|
||||
.ThenBy(e => e.PlayerIndex)
|
||||
.ThenBy(e => e.UnitId)
|
||||
.ToImmutableArray(),
|
||||
builderUnits.ToImmutableHashSet(),
|
||||
producerUnits.ToImmutableHashSet(),
|
||||
playerFirstProduction.ToImmutableDictionary(
|
||||
@@ -115,6 +133,7 @@ namespace AnotherReplayReader.Utils
|
||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
Dictionary<uint, HashSet<string>> unitSpecialPowers,
|
||||
List<SpecialPowerEvent> specialPowerEvents,
|
||||
HashSet<uint> builderUnits,
|
||||
HashSet<uint> producerUnits,
|
||||
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
|
||||
@@ -148,7 +167,8 @@ namespace AnotherReplayReader.Utils
|
||||
// special power (target position and angle): 0x200 —— 布局确凿,ObjectId 是施法者
|
||||
case 0x200:
|
||||
RecordSpecialPower(time, command, player, stringHashTable,
|
||||
unitFirstObserved, unitSpecialPowers, playerStrongOwnership);
|
||||
unitFirstObserved, unitSpecialPowers, playerStrongOwnership,
|
||||
specialPowerEvents);
|
||||
break;
|
||||
|
||||
// special power (target position): 0x1FF —— ObjectId 语义待核实,只记录"出现过"
|
||||
@@ -163,7 +183,7 @@ namespace AnotherReplayReader.Utils
|
||||
// start production: 0x205
|
||||
case 0x205:
|
||||
RecordProduction(time, command, player, unitFirstObserved,
|
||||
producerUnits, playerFirstProduction, playerStrongOwnership);
|
||||
stringHashTable, producerUnits, playerFirstProduction, playerStrongOwnership);
|
||||
break;
|
||||
|
||||
// start construction: 0x207
|
||||
@@ -208,7 +228,7 @@ namespace AnotherReplayReader.Utils
|
||||
|
||||
// 选择协议:全局生效,无 UnitId
|
||||
case 0x24E:
|
||||
RecordTechChoice(time, command, player, playerTechChoices);
|
||||
RecordTechChoice(time, command, player, stringHashTable, playerTechChoices);
|
||||
break;
|
||||
|
||||
// move: 0x214
|
||||
@@ -263,7 +283,8 @@ namespace AnotherReplayReader.Utils
|
||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
Dictionary<uint, HashSet<string>> unitSpecialPowers,
|
||||
Dictionary<int, HashSet<uint>> playerStrongOwnership)
|
||||
Dictionary<int, HashSet<uint>> playerStrongOwnership,
|
||||
List<SpecialPowerEvent> specialPowerEvents)
|
||||
{
|
||||
string? powerName = null;
|
||||
var unitIds = new List<uint>();
|
||||
@@ -323,6 +344,8 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
powers.Add(powerName);
|
||||
RecordPlayerOwnership(player, unitId, playerStrongOwnership);
|
||||
specialPowerEvents.Add(new SpecialPowerEvent(
|
||||
time, player, unitId, powerName));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +354,7 @@ namespace AnotherReplayReader.Utils
|
||||
CommandChunk command,
|
||||
int player,
|
||||
Dictionary<uint, TimeSpan> unitFirstObserved,
|
||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||
HashSet<uint> producerUnits,
|
||||
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
|
||||
Dictionary<int, HashSet<uint>> playerStrongOwnership)
|
||||
@@ -362,8 +386,14 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
break;
|
||||
case CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString
|
||||
or CommandArgumentType.Int32
|
||||
or CommandArgumentType.UInt32
|
||||
or CommandArgumentType.UInt32_2
|
||||
when unitName is null:
|
||||
unitName = entry.Value.ToString() ?? string.Empty;
|
||||
if (TryReadCommandName(entry, stringHashTable, out var resolvedName))
|
||||
{
|
||||
unitName = resolvedName;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -382,7 +412,7 @@ namespace AnotherReplayReader.Utils
|
||||
perPlayer = new Dictionary<string, TimeSpan>();
|
||||
playerFirstProduction[player] = perPlayer;
|
||||
}
|
||||
if (!perPlayer.ContainsKey(unitName))
|
||||
if (unitName is not null && !perPlayer.ContainsKey(unitName))
|
||||
{
|
||||
perPlayer[unitName] = time;
|
||||
}
|
||||
@@ -552,18 +582,18 @@ namespace AnotherReplayReader.Utils
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
int player,
|
||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||
Dictionary<int, HashSet<string>> playerTechChoices)
|
||||
{
|
||||
foreach (var entry in command.Data)
|
||||
{
|
||||
if (entry.Type is CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString)
|
||||
if (entry.Type is CommandArgumentType.AsciiString
|
||||
or CommandArgumentType.UnicodeString
|
||||
or CommandArgumentType.Int32
|
||||
or CommandArgumentType.UInt32
|
||||
or CommandArgumentType.UInt32_2)
|
||||
{
|
||||
var tech = entry.Count == 1
|
||||
? entry.Value.ToString()
|
||||
: entry.Value is string[] strings
|
||||
? strings.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))
|
||||
: null;
|
||||
if (string.IsNullOrWhiteSpace(tech))
|
||||
if (!TryReadCommandName(entry, stringHashTable, out var tech))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -578,6 +608,72 @@ namespace AnotherReplayReader.Utils
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadCommandName(
|
||||
CommandArgumentEntry entry,
|
||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||
out string name)
|
||||
{
|
||||
name = string.Empty;
|
||||
if (entry.Type is CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString)
|
||||
{
|
||||
if (entry.Count == 1 && entry.Value is string single)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(single))
|
||||
{
|
||||
name = single;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (entry.Value is string[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
name = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryReadCommandNameAsHash(entry, stringHashTable, out name);
|
||||
}
|
||||
|
||||
private static bool TryReadCommandNameAsHash(
|
||||
CommandArgumentEntry entry,
|
||||
IReadOnlyDictionary<uint, string> stringHashTable,
|
||||
out string name)
|
||||
{
|
||||
name = string.Empty;
|
||||
IEnumerable<uint> hashes = entry.Type switch
|
||||
{
|
||||
CommandArgumentType.Int32 when entry.Count == 1 && entry.Value is int singleInt =>
|
||||
new[] { unchecked((uint)singleInt) },
|
||||
CommandArgumentType.Int32 when entry.Value is int[] ints =>
|
||||
ints.Select(x => unchecked((uint)x)),
|
||||
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2
|
||||
when entry.Count == 1 && entry.Value is uint singleUint =>
|
||||
new[] { singleUint },
|
||||
CommandArgumentType.UInt32 or CommandArgumentType.UInt32_2
|
||||
when entry.Value is uint[] uints =>
|
||||
uints,
|
||||
_ => Array.Empty<uint>(),
|
||||
};
|
||||
|
||||
foreach (var hash in hashes)
|
||||
{
|
||||
if (stringHashTable.TryGetValue(hash, out var resolved)
|
||||
&& !string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
name = resolved;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void RecordObjectReferenceWithOwnership(
|
||||
TimeSpan time,
|
||||
CommandChunk command,
|
||||
|
||||
Reference in New Issue
Block a user