934 lines
36 KiB
C#
934 lines
36 KiB
C#
using AnotherReplayReader.ReplayFile;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.Collections.Immutable;
|
||
using System.Globalization;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Text.RegularExpressions;
|
||
|
||
namespace AnotherReplayReader.Utils
|
||
{
|
||
/// <summary>一段操作记录(一个时间分块)在全文中的位置与规模。</summary>
|
||
internal sealed record EventSpan(TimeSpan Time, int StartIndex, int Length, int EstimatedTokens);
|
||
|
||
/// <summary>机械分段得到的操作记录切片。</summary>
|
||
internal sealed record ReplaySlice(
|
||
int Index,
|
||
TimeSpan Start,
|
||
TimeSpan End,
|
||
int StartIndex,
|
||
int Length,
|
||
int EventCount,
|
||
int EstimatedTokens)
|
||
{
|
||
public string GetText(string fullText) => fullText.Substring(StartIndex, Length);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 机械分段:按 token 预算切分操作记录,相邻段重叠前一段尾部。
|
||
/// </summary>
|
||
internal static class MechanicalSegmenter
|
||
{
|
||
public const int MinSliceTokens = 2_000;
|
||
public const int MaxSlices = 20;
|
||
|
||
public static (ImmutableArray<ReplaySlice> Slices, ImmutableArray<string> Warnings) Slice(
|
||
string fullText,
|
||
ImmutableArray<EventSpan> spans,
|
||
int sliceTokenBudget,
|
||
int overlapTokenBudget)
|
||
{
|
||
var warnings = ImmutableArray.CreateBuilder<string>();
|
||
if (string.IsNullOrWhiteSpace(fullText) || spans.IsEmpty)
|
||
{
|
||
return (ImmutableArray<ReplaySlice>.Empty, warnings.ToImmutable());
|
||
}
|
||
|
||
var budget = Math.Max(sliceTokenBudget, MinSliceTokens);
|
||
var overlap = Math.Max(overlapTokenBudget, 200);
|
||
var slices = SliceCore(fullText, spans, budget, overlap);
|
||
|
||
if (slices.Count > MaxSlices)
|
||
{
|
||
// 先尝试压缩噪声事件块(纯选择/编队类),避免一超限就放宽预算。
|
||
var compressedSpans = CompressNoiseSpans(fullText, spans);
|
||
if (compressedSpans.Length < spans.Length && !compressedSpans.IsEmpty)
|
||
{
|
||
var compressedSlices = SliceCore(fullText, compressedSpans, budget, overlap);
|
||
if (compressedSlices.Count <= MaxSlices)
|
||
{
|
||
warnings.Add($"分段数超过上限 {MaxSlices},已过滤纯选择/编队事件块,压缩到 {compressedSlices.Count} 段。");
|
||
return (compressedSlices.ToImmutableArray(), warnings.ToImmutable());
|
||
}
|
||
|
||
var compressedTokens = compressedSpans.Sum(s => s.EstimatedTokens);
|
||
var raisedBudget = Math.Max(budget, (int)Math.Ceiling(compressedTokens / (double)MaxSlices));
|
||
slices = SliceCore(fullText, compressedSpans, raisedBudget, Math.Max(overlap, raisedBudget / 12));
|
||
warnings.Add($"分段数仍超过上限 {MaxSlices},已过滤噪声并放宽单段预算到 {raisedBudget:N0} token。");
|
||
}
|
||
else
|
||
{
|
||
var totalTokens = spans.Sum(s => s.EstimatedTokens);
|
||
var raisedBudget = Math.Max(budget, (int)Math.Ceiling(totalTokens / (double)MaxSlices));
|
||
slices = SliceCore(fullText, spans, raisedBudget, Math.Max(overlap, raisedBudget / 12));
|
||
warnings.Add($"分段数超过上限 {MaxSlices},已放宽单段预算到 {raisedBudget:N0} token。");
|
||
}
|
||
}
|
||
return (slices.ToImmutableArray(), warnings.ToImmutable());
|
||
}
|
||
|
||
private static ImmutableArray<EventSpan> CompressNoiseSpans(
|
||
string fullText,
|
||
ImmutableArray<EventSpan> spans)
|
||
{
|
||
var result = ImmutableArray.CreateBuilder<EventSpan>();
|
||
foreach (var span in spans)
|
||
{
|
||
if (!IsNoiseEventBlock(fullText.Substring(span.StartIndex, span.Length)))
|
||
{
|
||
result.Add(span);
|
||
}
|
||
}
|
||
return result.ToImmutable();
|
||
}
|
||
|
||
private static bool IsNoiseEventBlock(string text)
|
||
{
|
||
var hasCommand = false;
|
||
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
|
||
{
|
||
var line = rawLine.Trim();
|
||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("[", StringComparison.Ordinal))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 命令行通常形如 "PlayerA: 选择单位";测试/简写文本把非空行也视作命令。
|
||
hasCommand = true;
|
||
if (line.IndexOf("选择", StringComparison.Ordinal) < 0
|
||
&& line.IndexOf("编队", StringComparison.Ordinal) < 0
|
||
&& line.IndexOf("取消选择", StringComparison.Ordinal) < 0)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
return hasCommand;
|
||
}
|
||
|
||
private static List<ReplaySlice> SliceCore(
|
||
string fullText,
|
||
ImmutableArray<EventSpan> spans,
|
||
int budget,
|
||
int overlap)
|
||
{
|
||
var slices = new List<ReplaySlice>();
|
||
var segStart = 0;
|
||
var i = 0;
|
||
var acc = 0;
|
||
while (i < spans.Length)
|
||
{
|
||
var span = spans[i];
|
||
if (acc > 0 && acc + span.EstimatedTokens > budget && acc >= MinSliceTokens)
|
||
{
|
||
slices.Add(CreateSlice(slices.Count, fullText, spans, segStart, i));
|
||
|
||
// 下一段起点:重叠前一段尾部(重叠总 token ≤ overlap)
|
||
var overlapStart = i;
|
||
var overlapTokens = 0;
|
||
for (var j = i - 1; j >= segStart; --j)
|
||
{
|
||
if (overlapTokens + spans[j].EstimatedTokens > overlap)
|
||
{
|
||
break;
|
||
}
|
||
overlapTokens += spans[j].EstimatedTokens;
|
||
overlapStart = j;
|
||
}
|
||
segStart = overlapStart;
|
||
acc = 0;
|
||
for (var j = segStart; j < i; ++j)
|
||
{
|
||
acc += spans[j].EstimatedTokens;
|
||
}
|
||
continue;
|
||
}
|
||
acc += span.EstimatedTokens;
|
||
++i;
|
||
}
|
||
|
||
if (segStart < spans.Length)
|
||
{
|
||
slices.Add(CreateSlice(slices.Count, fullText, spans, segStart, spans.Length));
|
||
}
|
||
return slices;
|
||
}
|
||
|
||
private static ReplaySlice CreateSlice(
|
||
int index,
|
||
string fullText,
|
||
ImmutableArray<EventSpan> spans,
|
||
int start,
|
||
int endExclusive)
|
||
{
|
||
var first = spans[start];
|
||
var last = spans[endExclusive - 1];
|
||
var tokens = 0;
|
||
for (var j = start; j < endExclusive; ++j)
|
||
{
|
||
tokens += spans[j].EstimatedTokens;
|
||
}
|
||
return new ReplaySlice(
|
||
index,
|
||
first.Time,
|
||
last.Time,
|
||
first.StartIndex,
|
||
last.StartIndex + last.Length - first.StartIndex,
|
||
endExclusive - start,
|
||
tokens);
|
||
}
|
||
}
|
||
|
||
/// <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>
|
||
internal static class MatchDigestBuilder
|
||
{
|
||
public static string Build(
|
||
ReplayFactIndex factIndex,
|
||
ImmutableSortedDictionary<int, Player> players,
|
||
Mod mod,
|
||
ImmutableArray<ReplaySlice> slices,
|
||
string fullText)
|
||
{
|
||
var sb = new StringBuilder();
|
||
var names = AIAnalyze.PlayerNamesForAI(mod, players);
|
||
|
||
sb.AppendLine("# 玩家");
|
||
foreach (var kv in players)
|
||
{
|
||
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("# 首次出兵时间表(命令开始时间)");
|
||
if (factIndex.PlayerFirstProductionTime.IsEmpty)
|
||
{
|
||
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("# 协议选择");
|
||
if (factIndex.PlayerTechChoices.IsEmpty)
|
||
{
|
||
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.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.Select(x => FormatUnitIdWithRole(x, unitRoles)))}{suffix})");
|
||
}
|
||
sb.AppendLine();
|
||
|
||
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())
|
||
{
|
||
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();
|
||
|
||
sb.AppendLine("# 建造者/出兵建筑");
|
||
sb.AppendLine($"- 建造者:{string.Join("、", factIndex.BuilderUnitIds.OrderBy(x => x).Take(20))}");
|
||
sb.AppendLine($"- 出兵建筑:{string.Join("、", factIndex.ProducerUnitIds.OrderBy(x => x).Take(20))}");
|
||
sb.AppendLine();
|
||
|
||
sb.AppendLine("# 分段");
|
||
foreach (var slice in slices)
|
||
{
|
||
sb.AppendLine($"- 第{slice.Index + 1}段:{FormatTime(slice.Start)}~{FormatTime(slice.End)},事件数 {slice.EventCount},约 {slice.EstimatedTokens:N0} token");
|
||
}
|
||
sb.AppendLine();
|
||
|
||
sb.AppendLine("# 各段关键事件采样");
|
||
foreach (var slice in slices)
|
||
{
|
||
sb.AppendLine($"## 第{slice.Index + 1}段");
|
||
foreach (var line in SampleKeyEvents(slice.GetText(fullText), 6))
|
||
{
|
||
sb.AppendLine($"- {line}");
|
||
}
|
||
}
|
||
|
||
return sb.ToString().Replace("\r", "");
|
||
}
|
||
|
||
private static ImmutableArray<string> SampleKeyEvents(string text, int maxEvents)
|
||
{
|
||
var result = new List<string>();
|
||
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'))
|
||
{
|
||
if (result.Count >= maxEvents)
|
||
{
|
||
break;
|
||
}
|
||
|
||
var line = rawLine.TrimEnd();
|
||
var trimmed = line.Trim();
|
||
if (TimeStampPattern.IsMatch(trimmed))
|
||
{
|
||
currentTime = TimeStampPattern.Match(trimmed).Groups[1].Value;
|
||
continue;
|
||
}
|
||
|
||
// 参数/续行统一跳过,避免把 [UnitId]... 当作事件
|
||
if (string.IsNullOrWhiteSpace(trimmed)
|
||
|| line.StartsWith(" ", StringComparison.Ordinal)
|
||
|| line.StartsWith("\t", StringComparison.Ordinal)
|
||
|| trimmed.StartsWith("[", StringComparison.Ordinal))
|
||
{
|
||
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 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}";
|
||
}
|
||
|
||
internal sealed record SegmentOverview(
|
||
int Index,
|
||
string Title,
|
||
string Description,
|
||
ImmutableArray<string> BackqueryHints);
|
||
|
||
internal sealed record OverviewResult(
|
||
string Narrative,
|
||
ImmutableArray<SegmentOverview> Segments);
|
||
|
||
/// <summary>解析总览轮输出:[分段概述] 块之前的自由文本是整局叙述,块内是每段标题/概述。</summary>
|
||
internal static class OverviewParser
|
||
{
|
||
private const string Marker = "[分段概述]";
|
||
|
||
public static OverviewResult Parse(string response)
|
||
{
|
||
var markerIndex = response.LastIndexOf(Marker, StringComparison.OrdinalIgnoreCase);
|
||
if (markerIndex < 0)
|
||
{
|
||
return new OverviewResult(response.Trim(), ImmutableArray<SegmentOverview>.Empty);
|
||
}
|
||
|
||
var narrative = response.Substring(0, markerIndex).Trim();
|
||
var builder = ImmutableArray.CreateBuilder<SegmentOverview>();
|
||
var hints = new List<string>();
|
||
SegmentOverview? current = null;
|
||
|
||
foreach (var rawLine in response.Substring(markerIndex + Marker.Length).Replace("\r", "").Split('\n'))
|
||
{
|
||
var line = rawLine.Trim();
|
||
if (string.IsNullOrWhiteSpace(line))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var match = Regex.Match(line, @"^#(\d+)\s*(.*)$");
|
||
if (match.Success)
|
||
{
|
||
if (current is not null)
|
||
{
|
||
builder.Add(current);
|
||
}
|
||
var index = int.Parse(match.Groups[1].Value);
|
||
var rest = match.Groups[2].Value.Trim();
|
||
var sep = rest.IndexOfAny(new[] { ':', ':' });
|
||
var title = sep > 0 ? rest.Substring(0, sep).Trim() : rest;
|
||
var description = sep > 0 ? rest.Substring(sep + 1).Trim() : "";
|
||
hints = new List<string>();
|
||
current = new SegmentOverview(index, title, description, ImmutableArray<string>.Empty);
|
||
continue;
|
||
}
|
||
|
||
if (current is not null && line.StartsWith("回查", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var sepIndex = line.IndexOfAny(new[] { ':', ':' });
|
||
if (sepIndex >= 0)
|
||
{
|
||
hints.Add(line.Substring(sepIndex + 1).Trim());
|
||
}
|
||
current = current with { BackqueryHints = hints.ToImmutableArray() };
|
||
}
|
||
}
|
||
if (current is not null)
|
||
{
|
||
builder.Add(current);
|
||
}
|
||
|
||
return new OverviewResult(narrative, builder.ToImmutable());
|
||
}
|
||
}
|
||
|
||
/// <summary>解析段回复中的 [回查] 标记(M3 使用)。</summary>
|
||
internal static class BackqueryParser
|
||
{
|
||
public static ImmutableArray<(TimeSpan Start, TimeSpan End)> Parse(string response)
|
||
{
|
||
var result = ImmutableArray.CreateBuilder<(TimeSpan, TimeSpan)>();
|
||
foreach (var rawLine in response.Replace("\r", "").Split('\n'))
|
||
{
|
||
var line = rawLine.Trim();
|
||
if (line.IndexOf("[回查]", StringComparison.OrdinalIgnoreCase) < 0
|
||
&& !line.StartsWith("回查", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var content = line.Replace("[回查]", "").Trim();
|
||
var sep = content.IndexOfAny(new[] { ':', ':' });
|
||
if (sep >= 0 && content.Substring(0, sep).Trim().Equals("回查", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
content = content.Substring(sep + 1).Trim();
|
||
}
|
||
|
||
foreach (Match m in Regex.Matches(content, @"(\d+:\d+(?:\.\d+)?)\s*~\s*(\d+:\d+(?:\.\d+)?)"))
|
||
{
|
||
if (AiTimeParser.TryParse(m.Groups[1].Value, out var start)
|
||
&& AiTimeParser.TryParse(m.Groups[2].Value, out var end))
|
||
{
|
||
result.Add((start, end));
|
||
}
|
||
}
|
||
}
|
||
return result.ToImmutable();
|
||
}
|
||
}
|
||
|
||
/// <summary>按时间区间从全文切出回查所需的原始记录片段。</summary>
|
||
internal static class BackquerySliceExtractor
|
||
{
|
||
public const int MaxBackqueryTokens = 10_000;
|
||
|
||
public static (string? Text, string? Reason) Extract(
|
||
string fullText,
|
||
ImmutableArray<EventSpan> spans,
|
||
TimeSpan start,
|
||
TimeSpan end)
|
||
{
|
||
if (spans.IsEmpty)
|
||
{
|
||
return (null, "回放没有事件索引,无法回查。");
|
||
}
|
||
|
||
var first = -1;
|
||
var last = -1;
|
||
for (var i = 0; i < spans.Length; ++i)
|
||
{
|
||
if (first < 0 && spans[i].Time >= start)
|
||
{
|
||
first = i;
|
||
}
|
||
if (spans[i].Time <= end)
|
||
{
|
||
last = i;
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
|
||
if (first < 0 || last < 0 || last < first)
|
||
{
|
||
return (null, $"区间 {MatchDigestBuilder.FormatTime(start)}~{MatchDigestBuilder.FormatTime(end)} 内没有事件。");
|
||
}
|
||
|
||
var text = fullText.Substring(
|
||
spans[first].StartIndex,
|
||
spans[last].StartIndex + spans[last].Length - spans[first].StartIndex);
|
||
var tokens = AiContextBudget.EstimateTokens(text);
|
||
if (tokens > MaxBackqueryTokens)
|
||
{
|
||
return (null, $"回查区间过大(约 {tokens:N0} token,上限 {MaxBackqueryTokens:N0})。");
|
||
}
|
||
return (text, null);
|
||
}
|
||
}
|
||
|
||
/// <summary>容错的时间解析:失败返回 false,不抛异常。</summary>
|
||
internal static class AiTimeParser
|
||
{
|
||
public static bool TryParse(string input, out TimeSpan result)
|
||
{
|
||
result = TimeSpan.Zero;
|
||
if (string.IsNullOrWhiteSpace(input))
|
||
{
|
||
return false;
|
||
}
|
||
input = input.Trim();
|
||
var parts = input.Split(':');
|
||
if (parts.Length == 0
|
||
|| !float.TryParse(parts[parts.Length - 1], NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)
|
||
|| seconds < 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var totalSeconds = (long)(int)seconds;
|
||
var millis = (int)Math.Round((seconds - (int)seconds) * 1000);
|
||
long multiplier = 60;
|
||
for (var i = parts.Length - 2; i >= 0; --i)
|
||
{
|
||
if (!long.TryParse(parts[i], out var value) || value < 0)
|
||
{
|
||
return false;
|
||
}
|
||
totalSeconds += value * multiplier;
|
||
multiplier *= 60;
|
||
}
|
||
result = TimeSpan.FromSeconds(totalSeconds) + TimeSpan.FromMilliseconds(millis);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// <summary>把验证过的机器可读声明格式化为"已发现事实",供后续分段引用。</summary>
|
||
internal static class ClaimFindingsFormatter
|
||
{
|
||
public static string Format(AIMachineReadableClaims claims)
|
||
{
|
||
var sb = new StringBuilder();
|
||
foreach (var claim in claims.UnitClaims)
|
||
{
|
||
sb.AppendLine($"- UnitId {claim.UnitId}({claim.Player}):推测 {claim.Claim},证据等级 {LevelName(claim.EvidenceLevel)}");
|
||
if (!claim.Evidence.IsEmpty)
|
||
{
|
||
sb.AppendLine($" 证据:{string.Join(";", claim.Evidence)}");
|
||
}
|
||
if (!claim.Alternatives.IsEmpty)
|
||
{
|
||
sb.AppendLine($" 备选:{string.Join(";", claim.Alternatives)}");
|
||
}
|
||
if (!claim.NeedsConfirmation.IsEmpty)
|
||
{
|
||
sb.AppendLine($" 待确认:{string.Join(";", claim.NeedsConfirmation)}");
|
||
}
|
||
}
|
||
foreach (var claim in claims.EventClaims)
|
||
{
|
||
sb.AppendLine($"- 事件:{claim.Claim}({LevelName(claim.EvidenceLevel)})");
|
||
}
|
||
foreach (var claim in claims.TimelineClaims)
|
||
{
|
||
sb.AppendLine($"- 时间线:{claim.Claim}({LevelName(claim.EvidenceLevel)})");
|
||
}
|
||
return sb.ToString().TrimEnd();
|
||
}
|
||
|
||
public static string? ExtractSummary(string response)
|
||
{
|
||
foreach (var rawLine in response.Replace("\r", "").Split('\n'))
|
||
{
|
||
var line = rawLine.Trim();
|
||
if (line.StartsWith("[小结]", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var text = line.Substring("[小结]".Length).Trim();
|
||
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static string LevelName(AIEvidenceLevel level) => level switch
|
||
{
|
||
AIEvidenceLevel.Confirmed => "确定",
|
||
AIEvidenceLevel.HighlyLikely => "高度可能",
|
||
AIEvidenceLevel.Possible => "可能",
|
||
AIEvidenceLevel.Uncertain => "不确定",
|
||
AIEvidenceLevel.RuledOut => "已排除",
|
||
_ => "未知",
|
||
};
|
||
}
|
||
|
||
/// <summary>为修订 pass 生成与受影响声明相关的事实摘要。</summary>
|
||
internal static class RelevantFactsFormatter
|
||
{
|
||
public static string Format(AIMachineReadableClaims claims, ReplayFactIndex factIndex)
|
||
{
|
||
var sb = new StringBuilder();
|
||
foreach (var claim in claims.UnitClaims)
|
||
{
|
||
if (!uint.TryParse(claim.UnitId, out var unitId)
|
||
|| !factIndex.UnitIdFirstObservedTime.TryGetValue(unitId, out var first))
|
||
{
|
||
continue;
|
||
}
|
||
sb.AppendLine($"- UnitId {claim.UnitId}:首次出现 {MatchDigestBuilder.FormatTime(first)};"
|
||
+ (factIndex.UnitIdSpecialPowers.TryGetValue(unitId, out var powers) && powers.Count > 0
|
||
? $"观察到的能力:{string.Join("、", powers.OrderBy(x => x))}"
|
||
: "未观察到特殊能力"));
|
||
if (factIndex.BuilderUnitIds.Contains(unitId))
|
||
{
|
||
sb.AppendLine(" 该 UnitId 曾作为建造者出现");
|
||
}
|
||
if (factIndex.ProducerUnitIds.Contains(unitId))
|
||
{
|
||
sb.AppendLine(" 该 UnitId 曾作为出兵建筑出现");
|
||
}
|
||
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds)
|
||
{
|
||
if (kv.Value.Contains(unitId))
|
||
{
|
||
sb.AppendLine($" 玩家 {kv.Key} 对它有强所有权证据");
|
||
}
|
||
}
|
||
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds)
|
||
{
|
||
if (kv.Value.Contains(unitId))
|
||
{
|
||
sb.AppendLine($" 玩家 {kv.Key} 对它有弱所有权证据(选中过)");
|
||
}
|
||
}
|
||
}
|
||
return sb.ToString().TrimEnd();
|
||
}
|
||
}
|
||
}
|