focus on intervals, and fix provider

This commit is contained in:
2026-09-08 14:44:34 +02:00
parent 579cd8e4b3
commit acad70aaac
11 changed files with 1163 additions and 286 deletions
+358 -47
View File
@@ -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}";
}