ai plan v2

This commit is contained in:
2026-08-21 15:34:24 +02:00
parent 9555423f51
commit d06a93f759
20 changed files with 3093 additions and 523 deletions
+335 -51
View File
@@ -27,7 +27,10 @@ namespace AnotherReplayReader.Utils
InvalidEvidenceLevel,
UnitCapabilityContradiction,
UnitTimelineContradiction,
UnsupportedGameKnowledge
UnsupportedGameKnowledge,
OwnershipMissingEvidence,
OwnershipConflict,
OwnershipWeakConflict
}
internal enum AIEvidenceLevel
@@ -97,11 +100,13 @@ namespace AnotherReplayReader.Utils
public static AIValidationResult ValidateMachineReadableClaims(
string response,
ReplayFactIndex? factIndex = null)
ReplayFactIndex? factIndex = null,
IReadOnlyDictionary<string, int>? aiNameToPlayerIndex = null,
StructuredKnowledge? structuredKnowledge = null)
{
var issues = ImmutableArray.CreateBuilder<AIValidationIssue>();
var json = ExtractJsonObject(response);
if (json is null || string.IsNullOrWhiteSpace(json))
var jsonBlocks = ExtractAllJsonObjects(response);
if (jsonBlocks.IsEmpty)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
@@ -110,7 +115,7 @@ namespace AnotherReplayReader.Utils
return new AIValidationResult(AIMachineReadableClaims.Empty, issues.ToImmutable());
}
var claims = ParseClaims(json, issues);
var claims = ParseClaims(jsonBlocks, issues);
if (claims is null)
{
return new AIValidationResult(AIMachineReadableClaims.Empty, issues.ToImmutable());
@@ -120,7 +125,7 @@ namespace AnotherReplayReader.Utils
ValidateUnpackAmbiguity(claims, issues);
if (factIndex is not null)
{
ValidateTimelineConsistency(claims, factIndex, issues);
ValidateTimelineConsistency(claims, factIndex, aiNameToPlayerIndex, structuredKnowledge, issues);
}
return new AIValidationResult(claims, issues.ToImmutable());
}
@@ -143,69 +148,112 @@ namespace AnotherReplayReader.Utils
return sb.ToString().TrimEnd();
}
private static string? ExtractJsonObject(string response)
private static ImmutableArray<string> ExtractAllJsonObjects(string response)
{
var result = ImmutableArray.CreateBuilder<string>();
var markerIndex = response.LastIndexOf("[机器可读声明]", StringComparison.OrdinalIgnoreCase);
var searchText = markerIndex >= 0 ? response.Substring(markerIndex) : response;
var matches = _jsonFenceRegex.Matches(searchText);
if (matches.Count > 0)
{
return matches[matches.Count - 1].Groups[1].Value;
foreach (Match match in matches)
{
result.Add(match.Groups[1].Value);
}
return result.ToImmutable();
}
// 回退:整段响应中所有 fenced JSON
var allMatches = _jsonFenceRegex.Matches(response);
if (allMatches.Count > 0)
{
foreach (Match match in allMatches)
{
result.Add(match.Groups[1].Value);
}
return result.ToImmutable();
}
var start = searchText.LastIndexOf('{');
var end = searchText.LastIndexOf('}');
if (start >= 0 && end > start)
{
return searchText.Substring(start, end - start + 1);
result.Add(searchText.Substring(start, end - start + 1));
return result.ToImmutable();
}
return null;
return ImmutableArray<string>.Empty;
}
private static AIMachineReadableClaims? ParseClaims(
string json,
ImmutableArray<string> jsonBlocks,
ImmutableArray<AIValidationIssue>.Builder issues)
{
try
{
using var doc = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var mergedUnits = new Dictionary<string, AIUnitClaim>(StringComparer.OrdinalIgnoreCase);
var mergedEvents = new List<AIEventClaim>();
var mergedTimelines = new List<AITimelineClaim>();
var parsedAny = false;
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
foreach (var json in jsonBlocks)
{
try
{
using var doc = JsonDocument.Parse(json, new JsonDocumentOptions
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip
});
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
"机器可读声明不是 JSON object。"));
continue;
}
parsedAny = true;
foreach (var unitClaim in ReadUnitClaims(root, issues))
{
mergedUnits[unitClaim.UnitId] = unitClaim;
}
foreach (var claim in ReadSimpleClaims(root, "eventClaims", issues)
.Select(c => new AIEventClaim(c.Claim, c.EvidenceLevel, c.Evidence)))
{
mergedEvents.Add(claim);
}
foreach (var claim in ReadSimpleClaims(root, "timelineClaims", issues)
.Select(c => new AITimelineClaim(c.Claim, c.EvidenceLevel, c.Evidence)))
{
mergedTimelines.Add(claim);
}
}
catch (JsonException ex)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
"机器可读声明不是 JSON object。"));
return null;
$"机器可读声明 JSON 解析失败:{ex.Message}"));
}
return new AIMachineReadableClaims(
ReadUnitClaims(root, issues),
ReadSimpleClaims(root, "eventClaims", issues)
.Select(c => new AIEventClaim(c.Claim, c.EvidenceLevel, c.Evidence))
.ToImmutableArray(),
ReadSimpleClaims(root, "timelineClaims", issues)
.Select(c => new AITimelineClaim(c.Claim, c.EvidenceLevel, c.Evidence))
.ToImmutableArray());
}
catch (JsonException ex)
if (!parsedAny)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.InvalidMachineReadableClaims,
$"机器可读声明 JSON 解析失败:{ex.Message}"));
return null;
}
return new AIMachineReadableClaims(
mergedUnits.Values.Take(MaxUnitClaims).ToImmutableArray(),
mergedEvents.Take(MaxEventClaims).ToImmutableArray(),
mergedTimelines.Take(MaxTimelineClaims).ToImmutableArray());
}
private const int MaxUnitClaims = 10;
private const int MaxEventClaims = 5;
private const int MaxTimelineClaims = 3;
private static ImmutableArray<AIUnitClaim> ReadUnitClaims(JsonElement root, ImmutableArray<AIValidationIssue>.Builder issues)
{
@@ -263,12 +311,12 @@ namespace AnotherReplayReader.Utils
}
var result = ImmutableArray.CreateBuilder<SimpleClaim>();
var maxClaims = propertyName switch
{
"eventClaims" => 5,
"timelineClaims" => 3,
_ => 50,
};
var maxClaims = propertyName switch
{
"eventClaims" => MaxEventClaims,
"timelineClaims" => MaxTimelineClaims,
_ => 50,
};
var totalCount = 0;
foreach (var item in claims.EnumerateArray())
{
@@ -360,6 +408,7 @@ namespace AnotherReplayReader.Utils
Select,
Move,
Power,
Protocol,
Unknown
}
@@ -372,6 +421,9 @@ namespace AnotherReplayReader.Utils
public string? GetSpecialPowerName() =>
Type == AIEvidenceType.Power && Parameters.Length >= 1 ? Parameters[0] : null;
public string? GetTechName() =>
Type == AIEvidenceType.Protocol && Parameters.Length >= 1 ? Parameters[0] : null;
public string? GetUnitId() => Type switch
{
AIEvidenceType.Build or AIEvidenceType.Place when Parameters.Length >= 2 => Parameters[1],
@@ -391,7 +443,7 @@ namespace AnotherReplayReader.Utils
}
private static readonly Regex _structuredEvidenceRegex = new(
@"^(build|place|produce|sell|select|move|power)\|([^|]+(?:\|(?!\|).*)?)$",
@"^(build|place|produce|sell|select|move|power|protocol)\|([^|]+(?:\|(?!\|).*)?)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
internal static StructuredEvidence ParseStructuredEvidence(string text)
@@ -411,6 +463,7 @@ namespace AnotherReplayReader.Utils
"select" => AIEvidenceType.Select,
"move" => AIEvidenceType.Move,
"power" => AIEvidenceType.Power,
"protocol" => AIEvidenceType.Protocol,
_ => AIEvidenceType.Unknown,
};
@@ -472,6 +525,8 @@ namespace AnotherReplayReader.Utils
private static void ValidateTimelineConsistency(
AIMachineReadableClaims claims,
ReplayFactIndex factIndex,
IReadOnlyDictionary<string, int>? aiNameToPlayerIndex,
StructuredKnowledge? structuredKnowledge,
ImmutableArray<AIValidationIssue>.Builder issues)
{
foreach (var claim in claims.UnitClaims)
@@ -496,10 +551,37 @@ namespace AnotherReplayReader.Utils
continue;
}
var playerIndex = TryParsePlayerIndex(claim.Player, aiNameToPlayerIndex, out var parsed)
? parsed
: -1;
if (playerIndex < 0)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Info,
AIValidationIssueKind.InvalidMachineReadableClaims,
"Unit claim 缺少可识别的 player,无法校验所有权与生产时间线。",
claim.UnitId));
}
// Check 2: Verify special power evidence against fact index
var evidence = ParseAllEvidence(claim.Evidence);
foreach (var ev in evidence)
{
if (ev.Type == AIEvidenceType.Protocol && playerIndex >= 0)
{
var techName = ev.GetTechName();
if (techName is not null
&& factIndex.PlayerTechChoices.TryGetValue(playerIndex, out var chosen)
&& !chosen.Contains(techName))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitCapabilityContradiction,
$"玩家 {claim.Player} 在回放中选择过的协议为:{string.Join(", ", chosen.OrderBy(x => x))},但 AI 声称其选择了“{techName}”——该协议未观察到。",
claim.UnitId));
}
continue;
}
if (ev.Type != AIEvidenceType.Power)
{
continue;
@@ -527,20 +609,30 @@ namespace AnotherReplayReader.Utils
}
// Did this UnitId actually use this special power?
if (factIndex.UnitIdSpecialPowers.TryGetValue(evUnitId, out var actualPowers)
&& !actualPowers.Contains(powerName))
if (factIndex.UnitIdSpecialPowers.TryGetValue(evUnitId, out var actualPowers))
{
if (!actualPowers.Contains(powerName))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitCapabilityContradiction,
$"UnitId {evUnitIdStr} 在回放中使用过以下特殊能力:{string.Join(", ", actualPowers.OrderBy(x => x))},但 AI 声称其使用了“{powerName}”——此能力未在该 UnitId 上观察到。",
claim.UnitId));
}
}
else
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitCapabilityContradiction,
$"UnitId {evUnitIdStr} 在回放中使用过以下特殊能力:{string.Join(", ", actualPowers.OrderBy(x => x))},但 AI 声称其使用了“{powerName}”——此能力未在该 UnitId 上观察到。",
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.WeakEvidence,
$"UnitId {evUnitIdStr} 在回放中未观察到任何特殊能力,但 AI 声称其使用了“{powerName}”,证据不足。",
claim.UnitId));
}
}
// Check 3: UnitId used as builder vs claim
var isBuilderInReplay = factIndex.BuilderUnitIds.Contains(unitId);
var claimLooksLikeBuilder = ClaimLooksLikeBuilder(claim.Claim);
var claimLooksLikeBuilder = ClaimLooksLikeBuilder(claim.Claim, structuredKnowledge);
if (claimLooksLikeBuilder && !isBuilderInReplay)
{
issues.Add(new AIValidationIssue(
@@ -549,9 +641,199 @@ namespace AnotherReplayReader.Utils
$"AI 推测 UnitId {claim.UnitId} 是“{claim.Claim}”(推测是建造单位),但该 UnitId 在回放中从未作为建造者(建造建筑)出现。",
claim.UnitId));
}
// Check 4: 所有权证据(强/弱分层)
if (playerIndex >= 0)
{
ValidateOwnership(claim, unitId, playerIndex, factIndex, issues);
}
// Check 5: move 证据不能单独支撑高置信
if (claim.EvidenceLevel is AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely
&& !claim.Evidence.IsEmpty)
{
var parsedEvidence = ParseAllEvidence(claim.Evidence);
if (parsedEvidence.All(e => e.Type == AIEvidenceType.Move))
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.WeakEvidence,
$"UnitId {claim.UnitId} 的高置信推测只使用 move 证据(不携带 UnitId,无法验证),建议降级或补充其他证据。",
claim.UnitId));
}
}
// Check 6: 首次出兵时间线(轰炸机类)
if (playerIndex >= 0)
{
ValidateFirstProductionTimeline(claim, unitId, playerIndex, factIndex, structuredKnowledge, issues);
}
}
}
private static void ValidateOwnership(
AIUnitClaim claim,
uint unitId,
int playerIndex,
ReplayFactIndex factIndex,
ImmutableArray<AIValidationIssue>.Builder issues)
{
var myStrong = factIndex.PlayerStrongOwnershipUnitIds.TryGetValue(playerIndex, out var strong)
&& strong.Contains(unitId);
var myWeak = factIndex.PlayerWeakOwnershipUnitIds.TryGetValue(playerIndex, out var weak)
&& weak.Contains(unitId);
// 规则 1:高置信但无任何所有权证据
if (claim.EvidenceLevel is AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely
&& !myStrong && !myWeak)
{
issues.Add(new AIValidationIssue(
AIValidationSeverity.WeakEvidence,
AIValidationIssueKind.OwnershipMissingEvidence,
$"UnitId {claim.UnitId} 声称属于玩家 {claim.Player},但回放中该玩家对它的强/弱所有权证据(编队、建造者、维修、出售、施法者、选择)均未观察到,建议降级。",
claim.UnitId));
}
// 规则 2/4:其他玩家的强证据 → 冲突
foreach (var kv in factIndex.PlayerStrongOwnershipUnitIds)
{
if (kv.Key == playerIndex || !kv.Value.Contains(unitId))
{
continue;
}
var message = myStrong
? $"UnitId {claim.UnitId} 同时存在玩家 {claim.Player} 与玩家 {kv.Key} 的强所有权证据(异常/作弊操作),无法判定归属。"
: $"UnitId {claim.UnitId} 声称属于玩家 {claim.Player},但玩家 {kv.Key} 对它有强所有权证据(编队/建造者/维修/出售/施法者)。";
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.OwnershipConflict,
message,
claim.UnitId));
break;
}
// 规则 3:仅其他玩家的弱证据,且己方无证据 → 警告
if (!myStrong && !myWeak)
{
foreach (var kv in factIndex.PlayerWeakOwnershipUnitIds)
{
if (kv.Key == playerIndex || !kv.Value.Contains(unitId))
{
continue;
}
issues.Add(new AIValidationIssue(
AIValidationSeverity.Warning,
AIValidationIssueKind.OwnershipWeakConflict,
$"UnitId {claim.UnitId} 声称属于玩家 {claim.Player},但玩家 {kv.Key} 选中过它(可能只是点击敌方单位查看血量,不能完全排除),建议降置信度。",
claim.UnitId));
break;
}
}
}
private static void ValidateFirstProductionTimeline(
AIUnitClaim claim,
uint unitId,
int playerIndex,
ReplayFactIndex factIndex,
StructuredKnowledge? structuredKnowledge,
ImmutableArray<AIValidationIssue>.Builder issues)
{
if (claim.EvidenceLevel is not (AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely))
{
return;
}
if (!factIndex.UnitIdFirstObservedTime.TryGetValue(unitId, out var firstObserved))
{
return;
}
if (!factIndex.PlayerFirstProductionTime.TryGetValue(playerIndex, out var productions)
|| productions.Count == 0)
{
return;
}
var claimedBombers = GetBomberAssetNames(claim.Claim, structuredKnowledge);
if (claimedBombers.Count == 0)
{
return;
}
var wildcard = claimedBombers.Contains("*");
var relevant = productions
.Where(kv => claimedBombers.Contains(kv.Key)
|| (wildcard && IsBomberLikeName(kv.Key)))
.Select(kv => kv.Value)
.ToList();
if (relevant.Count == 0 || !relevant.All(t => t > firstObserved))
{
return;
}
var firstTime = $"{(int)firstObserved.TotalMinutes}:{firstObserved:ss\\.ff}";
issues.Add(new AIValidationIssue(
AIValidationSeverity.Contradiction,
AIValidationIssueKind.UnitTimelineContradiction,
$"UnitId {claim.UnitId} 首次出现于 {firstTime},早于玩家 {claim.Player} 首次生产轰炸机类单位的时间,声称其为“{claim.Claim}”与回放时间线矛盾。",
claim.UnitId));
}
private static HashSet<string> GetBomberAssetNames(
string claimText,
StructuredKnowledge? structuredKnowledge)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var structured = structuredKnowledge ?? StructuredKnowledge.Instance;
if (structured is not null)
{
foreach (var entity in structured.EntitiesWithTag("bomber"))
{
if (claimText.IndexOf(entity.AssetName, StringComparison.OrdinalIgnoreCase) >= 0)
{
result.Add(entity.AssetName);
}
}
}
if (result.Count == 0
&& (claimText.IndexOf("轰炸机", StringComparison.OrdinalIgnoreCase) >= 0
|| claimText.IndexOf("bomber", StringComparison.OrdinalIgnoreCase) >= 0))
{
result.Add("*");
}
return result;
}
private static bool IsBomberLikeName(string assetName) =>
assetName.IndexOf("Bomber", StringComparison.OrdinalIgnoreCase) >= 0
|| assetName.IndexOf("轰炸", StringComparison.OrdinalIgnoreCase) >= 0;
private static bool TryParsePlayerIndex(
string playerText,
IReadOnlyDictionary<string, int>? aiNameToPlayerIndex,
out int playerIndex)
{
playerIndex = -1;
if (string.IsNullOrWhiteSpace(playerText))
{
return false;
}
var trimmed = playerText.Trim();
if (aiNameToPlayerIndex is not null && aiNameToPlayerIndex.TryGetValue(trimmed, out playerIndex))
{
return true;
}
if (trimmed.StartsWith("Player", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(trimmed.Substring("Player".Length), out playerIndex))
{
return true;
}
if (trimmed.StartsWith("AI_", StringComparison.OrdinalIgnoreCase)
&& int.TryParse(trimmed.Substring("AI_".Length), out playerIndex))
{
return true;
}
return false;
}
#endregion
private static bool TryGetArray(JsonElement root, string propertyName, out JsonElement array)
@@ -652,10 +934,12 @@ namespace AnotherReplayReader.Utils
/// Uses structured knowledge (knowledge_units.json) when available, falls
/// back to heuristic string matching for backward compatibility.
/// </summary>
private static bool ClaimLooksLikeBuilder(string claimText)
private static bool ClaimLooksLikeBuilder(
string claimText,
StructuredKnowledge? structuredKnowledge)
{
// Primary: structured knowledge lookup
var structured = StructuredKnowledge.Instance;
var structured = structuredKnowledge ?? StructuredKnowledge.Instance;
if (structured is not null)
{
foreach (var entity in structured.AllEntities)
+110 -231
View File
@@ -17,21 +17,18 @@ namespace AnotherReplayReader.Utils
{
internal sealed class AIAnalyze
{
/// <summary>聊天消息。属性名保持小写以兼容 OpenAI 兼容端点。</summary>
public sealed record ChatMessage(
[property: System.Text.Json.Serialization.JsonPropertyName("role")] string Role,
[property: System.Text.Json.Serialization.JsonPropertyName("content")] string Content);
public static string GetSystemPrompt(
Replay replay,
ImmutableSortedDictionary<int, Player> players,
AiPromptSettings? promptSettings = null)
{
var defaultPrompt = BuildDefaultSystemPrompt(replay, players);
// Try loading from knowledge_{mod}.md file (generated by tools/expand_knowledge.py).
// If the file exists, use it instead of the built-in string.
var modName = replay.Mod.ModName?.ToLowerInvariant();
var knowledgeModName = modName switch
{
"corona" => "corona",
_ => "default",
};
string defaultPrompt;
var knowledgeModName = GetKnowledgeModName(replay);
var knowledgePath = Path.Combine(AppContext.BaseDirectory, $"knowledge_{knowledgeModName}.md");
if (File.Exists(knowledgePath))
{
@@ -43,10 +40,25 @@ namespace AnotherReplayReader.Utils
var knowledge = KnowledgeSet.ForMod(knowledgeModName, AppContext.BaseDirectory);
defaultPrompt = knowledge.RenderAsPrompt(factionNames, mapId);
}
else
{
// 旧路径:知识文件缺失时的回退(副作用 MessageBox 只在这里执行)
defaultPrompt = BuildDefaultSystemPrompt(replay, players);
}
return ComposeSystemPrompt(defaultPrompt, promptSettings);
}
/// <summary>根据回放 mod 名得到知识文件名(default/corona)。</summary>
public static string GetKnowledgeModName(Replay replay)
{
return replay.Mod.ModName?.ToLowerInvariant() switch
{
"corona" => "corona",
_ => "default",
};
}
public static string ComposeSystemPrompt(string defaultPrompt, AiPromptSettings? promptSettings)
{
var customSystemPrompt = promptSettings?.CustomSystemPrompt ?? string.Empty;
@@ -106,33 +118,27 @@ namespace AnotherReplayReader.Utils
- 玩家 ID 以及操作,例如:`PlayerC: 重新选择单位`
- 操作参数或操作对象,例如:`[UnitId]239`
典型的玩家操作流程
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。这些操作的对象是玩家自己的单位
1. 选择单位:可以选择单个或多个单位、选择编队、或者直接全选所有单位。选择的对象通常是玩家自己的单位,但也可能点击选中敌方单位(此时只能查看血量,无法下达命令);被加入编队的单位几乎可以确定是玩家自己的单位
2. 执行操作:让当前被选中的单位执行某个任务,例如攻击、释放技能。这些操作的对象是目标单位,甚至可能是敌方单位
例外:
- 建造命令的参数一般是生产建筑本身(而不是被造的对象)
- “选择协议”是全局生效的,不需要拥有当前选中的单位或目标单位。
# 输出要求
## 1. 初始阶段
触发条件:用户输入包含:""判断是否需要分段处理数据""
- 进行简单的推理,列举你的发现,目标:判断玩家操作记录是否应该分段分析
- 输出:对玩家操作记录的分段,各个分段的开始时间和结束时间,以及简短介绍。分段应按照时间排序,分段之间可以有一定的重叠
- 输出示例:
```
[分段列表]
#1 [0:00.0]~[0:55.4] 开局
#2 [0:50.1]~[3:02.1] 开局(第二部分)
#3 [3:00]~[5:16] 前中期
#4 [5:10]~[10:13.12] 中期
```
- 输出示例(假如判断不需要分段):
```
[分段列表]
#1 [0:00.0]~[17:23] 从开局到玩家操作记录结束
```
## 1. 总览阶段
触发条件:用户输入包含:""请先对整局进行总览""
- 输入中包含:对局摘要、机械分段的各段时间范围与事件数量、每段的关键事件采样
- 你的任务:
- 描述整局走势,允许跨越多个分段给出判断与线索,不要只逐段罗列
- 为每个分段给出简短标题与一句话概述,按 `#N 标题:概述` 的格式输出在 `[分段概述]` 块中(N 为分段编号)
- 指出值得跨段关联的事件(例如:第 1 段打包基地,第 3 段才重新展开)
- 如果某个分段在分析时可能需要核实远处原始记录,在对应行后另起一行写 `回查: mm:ss~mm:ss`
- 分段边界是程序预先切好的,不要自行划分或修改分段;不要输出 `[分段列表]`
## 2. 分段分析、推理阶段
触发条件:用户输入类似于:""请分析第N段([BEGIN]至[END]的数据""
触发条件:用户输入类似于:""请重点分析第N段([BEGIN]至[END]""
- 输入中包含:当前分段的原始操作记录切片、之前各段的已发现事实摘要,以及整局总览
- 如果某个远距离事件与当前分析相关,可以输出 `[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间,程序会把该区间的原始记录发给你
- 选取该阶段的主要事件,以及和它们的上下文
- 也可以选择数个其他有分析价值的事件
- 推理思考时:不要直接列出所有操作信息,可以先只列出一部分,然后按需向前以及向后“延申”
@@ -143,6 +149,7 @@ namespace AnotherReplayReader.Utils
- 按照**推理指南**进行详细的思考与推理,列举你的推理与发现
- 输出:该阶段的各个主要事件,以及你的推理和发现
- 假如推测 UnitId 对应的单位,请在正文中自然描述,并在末尾输出机器可读声明,方便程序验证
- 最后用一行 `[小结]` 输出 2~3 句该段最重要的结论,供后续分段参考
## 3. 最终总结阶段
触发条件:用户输入包含:""请对以上内容进行总结""
@@ -193,8 +200,9 @@ namespace AnotherReplayReader.Utils
- `produce|时间|单位名|出兵建筑UnitId` — 开始出兵,例如 `produce|0:14.66|AlliedScoutInfantry|291`
- `sell|时间|建筑UnitId` — 出售建筑,例如 `sell|2:21.93|255`
- `select|时间|单位UnitId` — 选择单位,例如 `select|1:24.13|587`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`
- `move|时间|x,y,z` — 移动,例如 `move|1:24.26|2026,2800,280`。注意:move 证据不携带 UnitId,无法被程序验证,不能单独作为高置信结论的证据
- `power|时间|技能名|单位UnitId` — 释放特殊能力,例如 `power|1:24.00|SpecialPower_PackReplaceSelf|246`
- `protocol|时间|科技名` — 选择协议(全局生效,无单位),例如 `protocol|0:02.33|PlayerTech_Allied_AirPower`
如果没有可验证推测,可以输出空数组。不要在 JSON 里写注释。
@@ -1010,71 +1018,56 @@ PlayerA: 开始出兵
return sb.ToString().Replace("\r", "");
}
public static string BuildUserPrompt(
Mod mod,
ImmutableSortedDictionary<int, Player> players,
string commandData,
out string prefix
)
public static string BuildOverviewUserPrompt(IReadOnlyList<ReplaySlice> slices)
{
var playerNamesForAI = PlayerNamesForAI(mod, players);
var playerData = players.Select(kv =>
{
var id = kv.Key;
var player = kv.Value;
var playerNameForAI = playerNamesForAI[id];
var prefix = player.IsComputer ? "电脑" : "玩家";
var name = player.IsComputer ? $"[{player.PlayerName}AI]" : player.PlayerName;
var factionName = ModData.GetFaction(mod, player.FactionId).Name;
if (player.Team >= 0)
{
return $"{prefix}#{id} {name} ({playerNameForAI}){factionName},队伍{player.Team}";
}
return $"{prefix}#{id} {name} ({playerNameForAI}){factionName}";
});
var sb = new StringBuilder();
sb.AppendLine("请你阅读并分析以下数据,首先进行初步分析,然后判断是否需要分段处理数据");
sb.AppendLine("玩家列表:");
sb.AppendLine(string.Join("\n", playerData));
sb.AppendLine("数据:");
prefix = sb.ToString();
sb.AppendLine(commandData);
sb.AppendLine("请先对整局进行总览。");
sb.AppendLine("下方是程序生成的分段元数据(分段边界由程序预先切好,不要修改)。对局摘要已在系统消息中提供。");
sb.AppendLine("请描述整局走势(允许跨分段),并为每个分段给出标题与一句话概述。");
sb.AppendLine();
sb.AppendLine("[分段元数据]");
foreach (var slice in slices)
{
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`。");
return sb.ToString().Replace("\r", "");
}
public static string BuildSegmentUserPrompt(IReadOnlyList<Segment> segments, int currentSegmentIndex, int eventCount)
public static string BuildSegmentUserPromptV2(
int segmentIndex,
int totalSegments,
ReplaySlice slice,
int eventCount,
string? title)
{
if (currentSegmentIndex < 0 || currentSegmentIndex >= segments.Count)
{
throw new InvalidOperationException("Current segment index is out of range.");
}
var segment = segments[currentSegmentIndex];
var beginText = currentSegmentIndex <= 0
? "游戏开始"
: segment.Start.ToString();
var endText = currentSegmentIndex >= segments.Count - 1
? "游戏结束"
: segment.End.ToString();
var beginText = segmentIndex <= 0 ? "游戏开始" : MatchDigestBuilder.FormatTime(slice.Start);
var endText = segmentIndex >= totalSegments - 1 ? "游戏结束" : MatchDigestBuilder.FormatTime(slice.End);
var titleLine = string.IsNullOrWhiteSpace(title) ? "" : $"\n段落标题:{title}";
var instruction = @$"
请分析第{currentSegmentIndex + 1}段({beginText}至{endText})的数据。
阶段一共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接把{eventCount}条操作信息都列出来。
也不要把每一条操作信息都视作一个单独事件
你需要列出{beginText}至{endText}的主要事件、以及其他有分析价值的事件
请按照按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
重点分析第{segmentIndex + 1}/{totalSegments}段({beginText}至{endText})的数据。
共有{eventCount}条操作信息,数量很大,因此**不要**在思考推理时直接列出所有操作信息,也不要把每一条操作信息都视作一个单独事件。{titleLine}
你可以参考输入中的对局摘要、整局总览与之前各段的已发现事实
如果某个远距离事件与当前分析相关,可以输出`[回查] mm:ss~mm:ss`(每段最多 3 次)请求对应原始区间
请按照[观察]、[分析]、[推理]、[进一步思考(可选)]的步骤,对各个事件进行分析和推理。
假如当前阶段存在一些较为重要的单位、而且能够推测出它们可能是什么单位,则可以列出单位的UnitId以及你对单位的推测。UnitId 推测最多 10 个,事件推测最多 5 个,时间线推测最多 3 个。
如果你列出了 UnitId 推测、关键事件推测或时间线推测,请在回答末尾附加`[机器可读声明]` JSON 代码块;如果没有相关推测,则输出空数组。
最后用一行 `[小结]` 输出 2~3 句该段最重要的结论。
";
return instruction.Trim().Replace("\r", "");
}
public static string BuildFinalUserPrompt(int totalEventCount)
public static string BuildSummaryUserPromptV2(int totalEventCount)
{
var instruction = $@"
请对以上内容进行总结。
基于:
- 你对于各阶段的推理分析
- 由我在对话刚开始时提供的原始数据(约{totalEventCount}个操作信息)。
- 整局总览与对局摘要
- 各分段的推理分析
- 各分段的已发现事实
(原始操作记录约{totalEventCount}条,未全部提供;如需要核实某个具体时间段,可以在总结中说明,由程序另行提供。)
请对各阶段的推理分析进行汇总。
判断是否需要对之前的分析进行补充,例如检查之前是否遗漏了某些重要的操作信息?
判断是否需要对之前的分析进行修正。
@@ -1084,6 +1077,36 @@ PlayerA: 开始出兵
return instruction.Trim().Replace("\r", "");
}
public static string BuildBackqueryUserPrompt(string sliceText)
{
return "以下是按你的 [回查] 请求提供的原始操作记录区间:\n\n" + sliceText;
}
public static string BuildRevisionUserPrompt(
string draft,
string validationIssues,
string relevantFacts)
{
var instruction = $@"
请重新检查你刚才的分析,并输出一份修正后的完整版本(正文 + 机器可读声明)。
机器校验发现以下问题:
{validationIssues}
相关事实:
{relevantFacts}
要求:
- 只输出修正后的分析本身,不要提及""修订""""抱歉""""之前回答有误""等字眼,不要复述本指令。
- 被机器校验否定的结论必须修正或降级;无法确定的结论标记为""不确定""或""可能""。
- 保留 [机器可读声明] JSON 代码块(与修正后的正文一致),并重新输出 [小结]。
- 原始草稿(供参考,不要原样照抄):
---
{draft}
---
";
return instruction.Trim().Replace("\r", "");
}
public static (int BytesCount, int EstimatedTokenCount) EstimateTokenCount(string text)
{
var bytesCount = Encoding.UTF8.GetByteCount(text);
@@ -1143,31 +1166,9 @@ PlayerA: 开始出兵
public string Text;
}
public record Segment(TimeSpan Start, TimeSpan End, string Description);
public record State(ImmutableList<object> Messages, ImmutableList<Segment> Segments, int CurrentSegment)
{
public static State Initial => new(ImmutableList<object>.Empty, ImmutableList<Segment>.Empty, -1);
public State AppendNewMessage(string role, string content)
{
var newMessages = Messages.Add(new
{
role,
content
});
return this with { Messages = newMessages };
}
public State AppendNewSegment(Segment segment)
{
var newSegments = Segments.Add(segment);
return this with { Segments = newSegments };
}
}
public struct Result
{
public string Response;
public State State;
public int? PromptTokens;
public int? CompletionTokens;
public int? TotalTokens;
@@ -1175,9 +1176,6 @@ PlayerA: 开始出兵
}
private readonly HttpClient _http;
private State _state = State.Initial;
public State LastSuccessfulState => _state;
public AIAnalyze()
{
@@ -1187,98 +1185,27 @@ PlayerA: 开始出兵
};
}
public void SetState(State state)
{
_state = state;
}
public async Task<Result> AnalyzeAsync(
string instruction,
string text,
/// <summary>对给定的消息列表发起一次完整的 chat completion 请求(流式),不修改内部状态。</summary>
public async Task<Result> CompleteAsync(
ImmutableList<ChatMessage> messages,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
var inputState = State.Initial
.AppendNewMessage("system", instruction)
.AppendNewMessage("user", text);
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
var resultState = result.State;
var splitted = result.Response.Split('\n').ToList();
var titleIndex = splitted.FindIndex(l => l.Contains("[分段列表]"));
if (titleIndex == -1)
{
throw new Exception("AI分析失败");
}
// regex match two timespan in "[0:00.0]~[0:55.4]"
var timeSpanRegex = new Regex(@"\[([^]]+)\]~\[([^]]+)\]");
for (var i = titleIndex + 1; i < splitted.Count; ++i)
{
var line = splitted[i];
var match = timeSpanRegex.Match(line);
if (match.Success)
{
var startTimeText = match.Groups[1].Value;
var endTimeText = match.Groups[2].Value;
var start = ParseAITimeSpan(startTimeText);
var end = ParseAITimeSpan(endTimeText);
var description = line.Substring(match.Index + match.Length).Trim();
resultState = resultState.AppendNewSegment(new(start, end, description));
}
}
result.State = resultState with { CurrentSegment = 0 };
_state = result.State;
return result;
}
public async Task<Result> ContinueAnalyzeAsync(
string instruction,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
if (_state.CurrentSegment < 0 || _state.CurrentSegment >= _state.Segments.Count)
{
throw new InvalidOperationException("Current segment index is out of range.");
}
var inputState = _state.AppendNewMessage("user", instruction);
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
result.State = result.State with { CurrentSegment = _state.CurrentSegment + 1 };
_state = result.State;
return result;
}
public async Task<Result> FinishAnalyzeAsync(
string instruction,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
if (_state.CurrentSegment != _state.Segments.Count)
{
throw new InvalidOperationException("Current segment index is out of range.");
}
var inputState = _state.AppendNewMessage("user", instruction);
var result = await Task.Run(() => DoRequest(_http, inputState, requestContext, onChunk, cancellationToken));
_state = result.State;
return result;
return await Task.Run(
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
cancellationToken);
}
private static async Task<Result> DoRequest(
HttpClient http,
State state,
ImmutableList<ChatMessage> messages,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
{
var provider = requestContext.Provider;
var requestParams = ProcessRequestParams(state, requestContext.BuildRequestParams());
var requestParams = ProcessRequestParams(messages, requestContext.BuildRequestParams());
var isStream = requestContext.Model.IsStream;
var inputJson = JsonSerializer.Serialize(requestParams);
@@ -1351,7 +1278,6 @@ PlayerA: 开始出兵
}
var resultText = fullBuilder.ToString();
result.State = state.AppendNewMessage("assistant", resultText);
result.Response = resultText;
return result;
}
@@ -1454,12 +1380,12 @@ PlayerA: 开始出兵
}
private static Dictionary<string, object> ProcessRequestParams(
State state,
ImmutableList<ChatMessage> messages,
Dictionary<string, object> inputRequestParams)
{
return new Dictionary<string, object>(inputRequestParams)
{
["messages"] = state.Messages.ToArray(),
["messages"] = messages.ToArray(),
["stream"] = true,
["stream_options"] = new
{
@@ -1468,52 +1394,5 @@ PlayerA: 开始出兵
};
}
private static TimeSpan ParseAITimeSpan(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
throw new FormatException("Empty input");
}
input = input.Trim();
var parts = input.Split(':');
if (parts.Length == 0)
{
throw new FormatException("Invalid format");
}
// -----------------------------
// 1. 解析最后一段:seconds + fraction
// -----------------------------
if (!float.TryParse(parts.Last(), out float floatSeconds))
{
throw new FormatException("Invalid seconds");
}
int seconds = (int)floatSeconds;
int milliseconds = (int)Math.Round((floatSeconds - seconds) * 1000);
// -----------------------------
// 2. 累加前面的部分(从右往左)
// -----------------------------
long totalSeconds = seconds;
long multiplier = 60; // 每层递进:秒->分->时->天...
for (int i = parts.Length - 2; i >= 0; i--)
{
if (!long.TryParse(parts[i], out long value))
throw new FormatException($"Invalid number: {parts[i]}");
totalSeconds += value * multiplier;
multiplier *= 60;
}
var result = TimeSpan.FromSeconds(totalSeconds)
+ TimeSpan.FromMilliseconds(milliseconds);
return result;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using System;
namespace AnotherReplayReader.Utils
{
/// <summary>
/// 上下文预算策略:每模型软上限、输出余量、估算安全系数与请求护栏。
/// </summary>
internal static class AiContextBudget
{
public const int Tier1MBudget = 160_000;
public const int Tier256KBudget = 100_000;
public const double EstimatorSafetyFactor = 1.2;
public const double HardUsageRatio = 0.9;
/// <summary>
/// 获取模型的一次请求总 token 软上限。0 表示该模型不支持长录像(只能短录像单 slice)。
/// </summary>
public static int GetContextBudget(AiModel model)
{
if (model.ContextBudget is { } explicitBudget && explicitBudget > 0)
{
return explicitBudget;
}
if (model.ContextLength >= 1_000_000)
{
return Tier1MBudget;
}
if (model.ContextLength >= 200_000)
{
return Tier256KBudget;
}
return 0;
}
/// <summary>为输出/推理 tokens 预留的余量。</summary>
public static int GetOutputHeadroom(AiProvider provider, AiModel model)
{
var maxTokens = provider.DefaultMaxTokens;
return Math.Max(2 * maxTokens, 32_000);
}
/// <summary>带安全系数的 token 估算(对中文偏乐观的 bytes/2.2 估算 × 1.2)。</summary>
public static int EstimateTokens(string text) =>
(int)Math.Ceiling(AIAnalyze.EstimateTokenCount(text).EstimatedTokenCount * EstimatorSafetyFactor);
/// <summary>请求护栏:超过硬上限返回 Block,超过软预算返回 Warn,否则 null。</summary>
public static ContextCheckResult CheckPromptUsage(
int estimatedPromptTokens,
AiProvider provider,
AiModel model)
{
if (model.ContextLength > 0)
{
var hardLimit = (int)(model.ContextLength * HardUsageRatio);
if (estimatedPromptTokens > hardLimit)
{
return new ContextCheckResult(
true,
$"估算输入 {estimatedPromptTokens:N0} token 超过模型上下文 {model.ContextLength:N0} 的 90%,已拒绝发起请求。请改用更长上下文的模型,或缩短操作记录。");
}
}
var budget = GetContextBudget(model);
if (budget > 0 && estimatedPromptTokens > budget)
{
return new ContextCheckResult(
false,
$"估算输入 {estimatedPromptTokens:N0} token 超过上下文预算 {budget:N0}(可在模型设置中调整),长录像将自动分段,超出部分会被压缩。");
}
return ContextCheckResult.Ok;
}
}
internal sealed record ContextCheckResult(bool Block, string Message)
{
public static ContextCheckResult Ok { get; } = new(false, string.Empty);
public bool IsOk => !Block && string.IsNullOrEmpty(Message);
}
}
+209 -61
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
@@ -34,6 +35,9 @@ namespace AnotherReplayReader.Utils
public const string Production = "production";
public const string Defense = "defense";
public const string Superweapon = "superweapon";
public const string Land = "land";
public const string Sea = "sea";
public const string Air = "air";
// ── Combat role tags (what a unit fights) ─────────────────────
public const string AntiInfantry = "antiInfantry";
@@ -41,6 +45,25 @@ namespace AnotherReplayReader.Utils
public const string AntiStructure = "antiStructure";
public const string AntiAir = "antiAir";
public const string AntiNaval = "antiNaval";
public const string AntiGround = "antiGround";
// ── 其余 JSON 实际使用的角色/定位标签 ──────────────────────────
public const string Miner = "miner";
public const string Scout = "scout";
public const string Support = "support";
public const string Siege = "siege";
public const string Bomber = "bomber";
public const string Engineer = "engineer";
public const string Fighter = "fighter";
/// <summary>完整标签集合:加载 JSON 时校验未知 tag 用。</summary>
public static readonly ImmutableArray<string> All = ImmutableArray.Create(
Builder, Pack, Unpack, Amphibious, Transport, ReturnToProducer, Cloak, ToggleWeapon,
Miner, Scout, Support, Siege, Bomber,
Infantry, Vehicle, Aircraft, Naval, Structure, Hero, Production, Defense, Superweapon,
Land, Sea, Air,
AntiInfantry, AntiVehicle, AntiStructure, AntiAir, AntiNaval, AntiGround,
Engineer, Fighter);
/// <summary>Create a special power reference tag.</summary>
public static string SpecialPower(string powerName) => $"specialPower:{powerName}";
@@ -118,15 +141,18 @@ namespace AnotherReplayReader.Utils
public ImmutableDictionary<string, EntityKnowledge> EntitiesByAssetName { get; }
public ImmutableArray<EntityKnowledge> AllEntities { get; }
public ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> EntitiesByFaction { get; }
public ImmutableArray<string> UnknownTags { get; }
private StructuredKnowledge(
ImmutableDictionary<string, EntityKnowledge> byAsset,
ImmutableArray<EntityKnowledge> allEntities,
ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> byFaction)
ImmutableDictionary<string, ImmutableArray<EntityKnowledge>> byFaction,
ImmutableArray<string> unknownTags)
{
EntitiesByAssetName = byAsset;
AllEntities = allEntities;
EntitiesByFaction = byFaction;
UnknownTags = unknownTags;
}
// ── Query helpers ────────────────────────────────────────────
@@ -149,92 +175,168 @@ namespace AnotherReplayReader.Utils
// ── Factory ──────────────────────────────────────────────────
private static readonly Lazy<StructuredKnowledge?> _lazyInstance = new(() => LoadFromFile());
private static readonly Lazy<StructuredKnowledge?> _lazyDefault = new(() => GetForMod("default"));
public static StructuredKnowledge? Instance => _lazyInstance.Value;
public static StructuredKnowledge? Instance => _lazyDefault.Value;
private static readonly ConcurrentDictionary<string, StructuredKnowledge?> _cache =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>按 mod 加载结构化知识;文件不存在返回 null(该 mod 无结构化数据)。</summary>
public static StructuredKnowledge? GetForMod(string? modName)
{
var key = modName ?? "default";
if (_cache.TryGetValue(key, out var cached))
{
return cached;
}
var loaded = LoadFromFile(key);
if (loaded is not null)
{
_cache.TryAdd(key, loaded);
}
return loaded;
}
/// <summary>Look up the display name for an asset name.</summary>
public string? GetDisplayName(string? assetName) =>
GetEntity(assetName)?.DisplayName;
private static StructuredKnowledge? LoadFromFile()
private static StructuredKnowledge? LoadFromFile(string modName)
{
var path = Path.Combine(AppContext.BaseDirectory, "knowledge_units.json");
var path = Path.Combine(AppContext.BaseDirectory, $"knowledge_units_{modName}.json");
if (!File.Exists(path)) return null;
try
{
var json = File.ReadAllText(path, Encoding.UTF8);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("factions", out var factions))
return null;
var allEntities = new List<EntityKnowledge>();
var byFaction = new Dictionary<string, List<EntityKnowledge>>(StringComparer.OrdinalIgnoreCase);
foreach (var faction in factions.EnumerateObject())
var builtin = ParseFactions(path, out var unknownTags);
if (builtin is null)
{
var factionName = faction.Name;
if (!byFaction.ContainsKey(factionName))
byFaction[factionName] = new List<EntityKnowledge>();
return null;
}
// Buildings
if (faction.Value.TryGetProperty("buildings", out var bldgs))
// 用户知识覆盖:AnotherReplayReader.user_knowledge.json,按 (阵营, assetName) 覆盖或新增
var userPath = Path.Combine(AppContext.BaseDirectory, "AnotherReplayReader.user_knowledge.json");
if (File.Exists(userPath))
{
var user = ParseFactions(userPath, out var userUnknownTags);
if (user is not null)
{
foreach (var b in bldgs.EnumerateArray())
foreach (var kv in user)
{
var ek = new EntityKnowledge(
GetString(b, "assetName") ?? "unknown",
GetString(b, "displayName") ?? "",
factionName,
Tier: null,
GetStringArray(b, "tags"),
ParseSpecialPowers(b),
ProducedBy: ImmutableArray<string>.Empty,
GetString(b, "text") ?? "");
allEntities.Add(ek);
byFaction[factionName].Add(ek);
}
}
// Units
if (faction.Value.TryGetProperty("units", out var units))
{
foreach (var u in units.EnumerateArray())
{
var assetName = GetString(u, "assetName") ?? "unknown";
var ek = new EntityKnowledge(
assetName,
GetString(u, "displayName") ?? "",
factionName,
GetString(u, "tier"),
GetStringArray(u, "tags"),
ParseSpecialPowers(u),
GetStringArray(u, "producedBy"),
GetString(u, "text") ?? "");
allEntities.Add(ek);
byFaction[factionName].Add(ek);
if (!builtin.TryGetValue(kv.Key, out var factionEntries))
{
factionEntries = new Dictionary<string, EntityKnowledge>(StringComparer.OrdinalIgnoreCase);
builtin[kv.Key] = factionEntries;
}
foreach (var entity in kv.Value)
{
factionEntries[entity.Key] = entity.Value;
}
unknownTags.UnionWith(userUnknownTags);
}
}
}
var allEntities = builtin.Values
.SelectMany(d => d.Values)
.OrderBy(e => e.Faction, StringComparer.OrdinalIgnoreCase)
.ThenBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase)
.ToImmutableArray();
var byFaction = builtin.ToImmutableDictionary(
kv => kv.Key,
kv => kv.Value.Values.OrderBy(e => e.AssetName, StringComparer.OrdinalIgnoreCase).ToImmutableArray(),
StringComparer.OrdinalIgnoreCase);
var unknownTagsArray = unknownTags.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToImmutableArray();
if (!unknownTagsArray.IsEmpty)
{
System.Diagnostics.Debug.WriteLine(
$"[AiKnowledge] 未知标签({modName}):{string.Join(", ", unknownTagsArray)}");
}
return new StructuredKnowledge(
allEntities.ToImmutableDictionary(e => e.AssetName, e => e, StringComparer.OrdinalIgnoreCase),
allEntities.ToImmutableArray(),
byFaction.ToImmutableDictionary(
kv => kv.Key,
kv => kv.Value.ToImmutableArray(),
StringComparer.OrdinalIgnoreCase));
allEntities,
byFaction,
unknownTagsArray);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[AiKnowledge] Failed to load knowledge_units.json: {ex.Message}");
System.Diagnostics.Debug.WriteLine($"[AiKnowledge] Failed to load knowledge_units_{modName}.json: {ex.Message}");
return null;
}
}
/// <summary>
/// 解析知识 JSON 的 factions 结构,返回 faction → (assetName → EntityKnowledge)
/// 同时收集未知标签。
/// </summary>
private static Dictionary<string, Dictionary<string, EntityKnowledge>>? ParseFactions(
string path,
out HashSet<string> unknownTags)
{
var unknownTagsLocal = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var json = File.ReadAllText(path, Encoding.UTF8);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("factions", out var factions))
{
unknownTags = unknownTagsLocal;
return null;
}
var result = new Dictionary<string, Dictionary<string, EntityKnowledge>>(StringComparer.OrdinalIgnoreCase);
foreach (var faction in factions.EnumerateObject())
{
var factionName = faction.Name;
if (!result.TryGetValue(factionName, out var entries))
{
entries = new Dictionary<string, EntityKnowledge>(StringComparer.OrdinalIgnoreCase);
result[factionName] = entries;
}
void AddEntity(JsonElement el, bool isBuilding)
{
var assetName = GetString(el, "assetName") ?? "unknown";
var tags = GetStringArray(el, "tags");
foreach (var tag in tags)
{
if (!KnowledgeTag.All.Contains(tag, StringComparer.OrdinalIgnoreCase))
{
unknownTagsLocal.Add(tag);
}
}
var ek = new EntityKnowledge(
assetName,
GetString(el, "displayName") ?? "",
factionName,
isBuilding ? null : GetString(el, "tier"),
tags,
ParseSpecialPowers(el),
isBuilding ? ImmutableArray<string>.Empty : GetStringArray(el, "producedBy"),
GetString(el, "text") ?? "");
entries[assetName] = ek;
}
if (faction.Value.TryGetProperty("buildings", out var bldgs))
{
foreach (var b in bldgs.EnumerateArray())
{
AddEntity(b, isBuilding: true);
}
}
if (faction.Value.TryGetProperty("units", out var units))
{
foreach (var u in units.EnumerateArray())
{
AddEntity(u, isBuilding: false);
}
}
}
unknownTags = unknownTagsLocal;
return result;
}
private static string? GetString(JsonElement el, string prop) =>
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
@@ -312,7 +414,10 @@ namespace AnotherReplayReader.Utils
foreach (var entry in ByScope(KnowledgeScopeKind.Global))
{
sb.AppendLine(entry.Text.Trim());
var text = entry.Id.StartsWith("knowledge-text-", StringComparison.Ordinal)
? FilterFlatTextByFactions(entry.Text, factionNames)
: entry.Text;
sb.AppendLine(text.Trim());
sb.AppendLine();
}
@@ -337,6 +442,49 @@ namespace AnotherReplayReader.Utils
return sb.ToString().Replace("\r", "");
}
private static readonly (string Faction, string StartMarker)[] FactionSectionMarkers =
{
("盟军", "盟军常用建筑与升级"),
("神州", "神州常用建筑"),
};
/// <summary>flat 文本按参战阵营过滤:只保留全局部分与参战阵营的章节,减少 token 浪费。</summary>
private static string FilterFlatTextByFactions(string text, IReadOnlyList<string> factionNames)
{
if (factionNames.Count == 0)
{
return text;
}
var participating = new HashSet<string>(factionNames, StringComparer.OrdinalIgnoreCase);
var lines = text.Replace("\r", "").Split('\n');
var sb = new StringBuilder();
string? currentFaction = null;
foreach (var raw in lines)
{
var line = raw.TrimStart();
var matched = FactionSectionMarkers.FirstOrDefault(
m => line.StartsWith(m.StartMarker, StringComparison.OrdinalIgnoreCase));
if (matched.Faction is not null)
{
currentFaction = matched.Faction;
if (participating.Contains(currentFaction))
{
sb.AppendLine(raw);
}
continue;
}
if (line.StartsWith("# 地图参数", StringComparison.OrdinalIgnoreCase))
{
currentFaction = null;
}
if (currentFaction is null || participating.Contains(currentFaction))
{
sb.AppendLine(raw);
}
}
return sb.ToString().TrimEnd();
}
// ── Built-in factory ─────────────────────────────────────────
/// <summary>
@@ -349,7 +497,7 @@ namespace AnotherReplayReader.Utils
{
var searchDir = baseDirectory ?? AppContext.BaseDirectory;
var entries = new List<(KnowledgeScope Scope, KnowledgeEntry Entry)>();
var structured = StructuredKnowledge.Instance;
var structured = StructuredKnowledge.GetForMod(modName);
// 1. Load flat text from knowledge_{modName}.md
var flatPath = Path.Combine(searchDir, $"knowledge_{modName}.md");
+5
View File
@@ -42,6 +42,11 @@ namespace AnotherReplayReader
public string? DisplayName { get; set; }
public bool IsStream { get; set; }
public int ContextLength { get; set; } // 0 表示未知
/// <summary>
/// 一次请求的总 token 软上限(prompt + 输出/推理余量)。
/// null 或 0 表示使用档位默认值:≥1M 上下文 → 160K200K~256K → 100K;更小 → 0(不支持长录像)。
/// </summary>
public int? ContextBudget { get; set; }
public Dictionary<string, object> ExtraParameters { get; set; } = [];
+540
View File
@@ -0,0 +1,540 @@
using AnotherReplayReader.ReplayFile;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
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 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 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>
/// 确定性对局摘要:由 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 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}");
}
sb.AppendLine();
sb.AppendLine("# 首次出兵时间表");
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("# 打包/展开");
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)
{
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();
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 currentTime = "";
foreach (var rawLine in text.Replace("\r", "").Split('\n'))
{
var line = rawLine.Trim();
if (line.StartsWith("[") && line.Contains("]"))
{
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;
}
}
continue;
}
if (IsKeyEventLine(line))
{
result.Add($"[{currentTime}] {line}");
if (result.Count >= maxEvents)
{
break;
}
}
}
return result.ToImmutableArray();
}
private static bool IsKeyEventLine(string line) =>
line.Contains("开始建造") || line.Contains("摆放建筑") || line.Contains("出售建筑") ||
line.Contains("释放特殊能力") || line.Contains("选择协议") || line.Contains("开始出兵") ||
line.Contains("开始升级");
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], 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();
}
}
}
+316 -42
View File
@@ -31,13 +31,25 @@ namespace AnotherReplayReader.Utils
/// <summary>Per player, which UnitIds they have selected.</summary>
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerSelectedUnitIds { get; }
/// <summary>Per player, UnitIds with strong ownership evidence (control group, builder/producer, repair, sell, power caster).</summary>
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerStrongOwnershipUnitIds { get; }
/// <summary>Per player, UnitIds with weak ownership evidence (plain selection only).</summary>
public ImmutableDictionary<int, ImmutableHashSet<uint>> PlayerWeakOwnershipUnitIds { get; }
/// <summary>Per player, tech/protocol choices (0x24E).</summary>
public ImmutableDictionary<int, ImmutableHashSet<string>> PlayerTechChoices { get; }
public ReplayFactIndex(
ImmutableDictionary<uint, TimeSpan> unitIdFirstObservedTime,
ImmutableDictionary<uint, ImmutableHashSet<string>> unitIdSpecialPowers,
ImmutableHashSet<uint> builderUnitIds,
ImmutableHashSet<uint> producerUnitIds,
ImmutableDictionary<int, ImmutableDictionary<string, TimeSpan>> playerFirstProductionTime,
ImmutableDictionary<int, ImmutableHashSet<uint>> playerSelectedUnitIds)
ImmutableDictionary<int, ImmutableHashSet<uint>> playerSelectedUnitIds,
ImmutableDictionary<int, ImmutableHashSet<uint>> playerStrongOwnershipUnitIds,
ImmutableDictionary<int, ImmutableHashSet<uint>> playerWeakOwnershipUnitIds,
ImmutableDictionary<int, ImmutableHashSet<string>> playerTechChoices)
{
UnitIdFirstObservedTime = unitIdFirstObservedTime;
UnitIdSpecialPowers = unitIdSpecialPowers;
@@ -45,6 +57,9 @@ namespace AnotherReplayReader.Utils
ProducerUnitIds = producerUnitIds;
PlayerFirstProductionTime = playerFirstProductionTime;
PlayerSelectedUnitIds = playerSelectedUnitIds;
PlayerStrongOwnershipUnitIds = playerStrongOwnershipUnitIds;
PlayerWeakOwnershipUnitIds = playerWeakOwnershipUnitIds;
PlayerTechChoices = playerTechChoices;
}
public static ReplayFactIndex Build(
@@ -57,6 +72,10 @@ namespace AnotherReplayReader.Utils
var producerUnits = new HashSet<uint>();
var playerFirstProduction = new Dictionary<int, Dictionary<string, TimeSpan>>();
var playerSelected = new Dictionary<int, HashSet<uint>>();
var playerStrongOwnership = new Dictionary<int, HashSet<uint>>();
var playerWeakOwnership = new Dictionary<int, HashSet<uint>>();
var playerTechChoices = new Dictionary<int, HashSet<string>>();
var controlGroups = new Dictionary<int, HashSet<uint>>();
foreach (var (time, commands) in timeline)
{
@@ -65,7 +84,9 @@ namespace AnotherReplayReader.Utils
ProcessCommand(time, command, stringHashTable,
unitFirstObserved, unitSpecialPowers,
builderUnits, producerUnits,
playerFirstProduction, playerSelected);
playerFirstProduction, playerSelected,
playerStrongOwnership, playerWeakOwnership,
playerTechChoices, controlGroups);
}
}
@@ -78,6 +99,12 @@ namespace AnotherReplayReader.Utils
playerFirstProduction.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableDictionary()),
playerSelected.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
playerStrongOwnership.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
playerWeakOwnership.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()),
playerTechChoices.ToImmutableDictionary(
kv => kv.Key, kv => kv.Value.ToImmutableHashSet()));
}
@@ -90,7 +117,11 @@ namespace AnotherReplayReader.Utils
HashSet<uint> builderUnits,
HashSet<uint> producerUnits,
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
Dictionary<int, HashSet<uint>> playerSelected)
Dictionary<int, HashSet<uint>> playerSelected,
Dictionary<int, HashSet<uint>> playerStrongOwnership,
Dictionary<int, HashSet<uint>> playerWeakOwnership,
Dictionary<int, HashSet<string>> playerTechChoices,
Dictionary<int, HashSet<uint>> controlGroups)
{
var player = command.PlayerIndex;
var cmdId = command.CommandId;
@@ -99,52 +130,80 @@ namespace AnotherReplayReader.Utils
{
// select unit(s): 0x1F5
case 0x1F5:
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected);
RecordSelectUnit(time, command, player, unitFirstObserved, playerSelected, playerWeakOwnership);
break;
// special power (no target): 0x1FE
// 从选择中移除单位:与选择类似,仅弱所有权
case 0x1F9:
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerWeakOwnership);
break;
// special power (no target): 0x1FE —— 布局确凿,ObjectId 是施法者
case 0x1FE:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (target position): 0x1FF
case 0x1FF:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (target position and angle): 0x200
// special power (target position and angle): 0x200 —— 布局确凿,ObjectId 是施法者
case 0x200:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
RecordSpecialPower(time, command, player, stringHashTable,
unitFirstObserved, unitSpecialPowers, playerStrongOwnership);
break;
// special power (target unit): 0x201
// special power (target position): 0x1FF —— ObjectId 语义待核实,只记录"出现过"
case 0x1FF:
// special power (target unit): 0x201 —— ObjectId 可能是目标
case 0x201:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
break;
// special power (one or more targets): 0x232
// special power (one or more targets): 0x232 —— ObjectId 可能是目标
case 0x232:
RecordSpecialPower(time, command, stringHashTable, unitFirstObserved, unitSpecialPowers);
RecordObjectReference(time, command, unitFirstObserved);
break;
// start production: 0x205
case 0x205:
RecordProduction(time, command, player, unitFirstObserved, producerUnits, playerFirstProduction);
RecordProduction(time, command, player, unitFirstObserved,
producerUnits, playerFirstProduction, playerStrongOwnership);
break;
// start construction: 0x207
case 0x207:
RecordConstruction(time, command, unitFirstObserved, builderUnits);
RecordConstruction(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
break;
// place building: 0x209
case 0x209:
RecordPlaceBuilding(time, command, unitFirstObserved, builderUnits);
RecordPlaceBuilding(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
break;
// sell building: 0x20A
case 0x20A:
RecordObjectReference(time, command, unitFirstObserved);
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerStrongOwnership);
break;
// 开始/停止维修建筑:只能维修己方建筑 → 强所有权
case 0x228:
case 0x229:
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerStrongOwnership);
break;
// 命令矿车交矿 / 让矿车去采矿:只能命令己方矿车 → 强所有权
case 0x212:
case 0x248:
RecordObjectReferenceWithOwnership(time, command, player, unitFirstObserved, playerStrongOwnership);
break;
// 创建编队:编队成员几乎确定是己方单位 → 强所有权
case 0x1FA:
RecordControlGroupCreate(time, command, player, unitFirstObserved,
playerStrongOwnership, controlGroups);
break;
// 选择编队 / 将编队加入选择:通过编队状态解析成员 → 强所有权
case 0x1FB:
case 0x1FC:
RecordControlGroupSelect(time, command, player, unitFirstObserved,
playerStrongOwnership, controlGroups);
break;
// 选择协议:全局生效,无 UnitId
case 0x24E:
RecordTechChoice(time, command, player, playerTechChoices);
break;
// move: 0x214
@@ -162,7 +221,8 @@ namespace AnotherReplayReader.Utils
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerSelected)
Dictionary<int, HashSet<uint>> playerSelected,
Dictionary<int, HashSet<uint>> playerWeakOwnership)
{
// Data layout for 0x1F5:
// Data[0]: Bool (isReplace), if count > 0 the rest are ObjectIds
@@ -176,6 +236,7 @@ namespace AnotherReplayReader.Utils
var unitId = (uint)entry.Value;
TryRecordFirstObserved(unitId, time, unitFirstObserved);
RecordPlayerSelection(player, unitId, playerSelected);
RecordPlayerOwnership(player, unitId, playerWeakOwnership);
}
else
{
@@ -183,6 +244,7 @@ namespace AnotherReplayReader.Utils
{
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerSelection(player, id, playerSelected);
RecordPlayerOwnership(player, id, playerWeakOwnership);
}
}
}
@@ -192,9 +254,11 @@ namespace AnotherReplayReader.Utils
private static void RecordSpecialPower(
TimeSpan time,
CommandChunk command,
int player,
IReadOnlyDictionary<uint, string> stringHashTable,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<uint, HashSet<string>> unitSpecialPowers)
Dictionary<uint, HashSet<string>> unitSpecialPowers,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
string? powerName = null;
var unitIds = new List<uint>();
@@ -205,11 +269,19 @@ namespace AnotherReplayReader.Utils
{
case CommandArgumentType.Int32 when powerName is null:
{
// First Int32 is the special power hash ID
var hash = unchecked((uint)(int)entry.Value);
powerName = stringHashTable.TryGetValue(hash, out var name)
? name
: $"Hash_{hash:X8}";
// First Int32 is the special power hash ID
// 同类型参数可能被打包成数组(首个元素是 hash)
var hash = entry.Count == 1 && entry.Value is int singleInt
? unchecked((uint)singleInt)
: entry.Value is int[] ints && ints.Length > 0
? unchecked((uint)ints[0])
: (uint?)null;
if (hash is { } hashValue)
{
powerName = stringHashTable.TryGetValue(hashValue, out var name)
? name
: $"Hash_{hashValue:X8}";
}
break;
}
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2:
@@ -245,6 +317,7 @@ namespace AnotherReplayReader.Utils
unitSpecialPowers[unitId] = powers;
}
powers.Add(powerName);
RecordPlayerOwnership(player, unitId, playerStrongOwnership);
}
}
@@ -254,7 +327,8 @@ namespace AnotherReplayReader.Utils
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> producerUnits,
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction)
Dictionary<int, Dictionary<string, TimeSpan>> playerFirstProduction,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
uint? producerId = null;
string? unitName = null;
@@ -264,8 +338,23 @@ namespace AnotherReplayReader.Utils
switch (entry.Type)
{
case CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
when entry.Count == 1 && producerId is null:
producerId = (uint)entry.Value;
when producerId is null:
if (entry.Count == 1)
{
var id = (uint)entry.Value;
if (id != 0) producerId = id;
}
else if (entry.Value is uint[] ids)
{
foreach (var id in ids)
{
if (id != 0)
{
producerId = id;
break;
}
}
}
break;
case CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString
when unitName is null:
@@ -278,6 +367,7 @@ namespace AnotherReplayReader.Utils
{
TryRecordFirstObserved(producerId.Value, time, unitFirstObserved);
producerUnits.Add(producerId.Value);
RecordPlayerOwnership(player, producerId.Value, playerStrongOwnership);
}
if (!string.IsNullOrWhiteSpace(unitName))
@@ -297,47 +387,218 @@ namespace AnotherReplayReader.Utils
private static void RecordConstruction(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> builderUnits)
HashSet<uint> builderUnits,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
// Data[0]: ObjectId (builder)
// Data[1]: AsciiString (building name)
RecordBuilder(time, command, unitFirstObserved, builderUnits);
RecordBuilder(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
}
private static void RecordPlaceBuilding(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> builderUnits)
HashSet<uint> builderUnits,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
// Data[0]: ObjectId (builder)
// Data[1]: AsciiString (building name)
// Data[2]: Int32 (count)
// Data[3]: Vector3 (position)
// Data[4]: Float32 (angle)
RecordBuilder(time, command, unitFirstObserved, builderUnits);
RecordBuilder(time, command, player, unitFirstObserved, builderUnits, playerStrongOwnership);
}
private static void RecordBuilder(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
HashSet<uint> builderUnits)
HashSet<uint> builderUnits,
Dictionary<int, HashSet<uint>> playerStrongOwnership)
{
foreach (var entry in command.Data)
{
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2
&& entry.Count == 1)
if (entry.Type is not (CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2))
{
continue;
}
if (entry.Count == 1)
{
var id = (uint)entry.Value;
if (id != 0)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
builderUnits.Add(id);
RecordPlayerOwnership(player, id, playerStrongOwnership);
}
return; // only first ObjectId is the builder
}
if (entry.Value is uint[] ids)
{
foreach (var id in ids)
{
if (id != 0)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
builderUnits.Add(id);
RecordPlayerOwnership(player, id, playerStrongOwnership);
return;
}
}
}
}
}
private static void RecordControlGroupCreate(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerStrongOwnership,
Dictionary<int, HashSet<uint>> controlGroups)
{
// Data[0]: Int32 编队号;Data[1..]: 成员 ObjectId
int? groupNumber = null;
var members = new List<uint>();
foreach (var entry in command.Data)
{
if (groupNumber is null && entry.Type == CommandArgumentType.Int32)
{
groupNumber = entry.Count == 1 && entry.Value is int singleInt
? singleInt
: entry.Value is int[] ints && ints.Length > 0
? ints[0]
: (int?)null;
continue;
}
if (entry.Type is CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2)
{
if (entry.Count == 1)
{
var id = (uint)entry.Value;
if (id != 0) members.Add(id);
}
else
{
foreach (var id in (uint[])entry.Value)
{
if (id != 0) members.Add(id);
}
}
}
}
if (groupNumber is null || members.Count == 0)
{
return;
}
var group = new HashSet<uint>(members);
controlGroups[groupNumber.Value] = group;
foreach (var id in members)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerOwnership(player, id, playerStrongOwnership);
}
}
private static void RecordControlGroupSelect(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerStrongOwnership,
Dictionary<int, HashSet<uint>> controlGroups)
{
foreach (var entry in command.Data)
{
if (entry.Type != CommandArgumentType.Int32)
{
continue;
}
var groupNumber = entry.Count == 1 && entry.Value is int singleInt
? singleInt
: entry.Value is int[] ints && ints.Length > 0
? ints[0]
: (int?)null;
if (groupNumber is null)
{
continue;
}
if (controlGroups.TryGetValue(groupNumber.Value, out var members))
{
foreach (var id in members)
{
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerOwnership(player, id, playerStrongOwnership);
}
}
}
}
private static void RecordTechChoice(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<int, HashSet<string>> playerTechChoices)
{
foreach (var entry in command.Data)
{
if (entry.Type is CommandArgumentType.AsciiString or CommandArgumentType.UnicodeString)
{
var tech = entry.Count == 1
? entry.Value.ToString()
: entry.Value is string[] strings
? strings.FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))
: null;
if (string.IsNullOrWhiteSpace(tech))
{
continue;
}
if (!playerTechChoices.TryGetValue(player, out var set))
{
set = new HashSet<string>();
playerTechChoices[player] = set;
}
set.Add(tech);
return;
}
}
}
private static void RecordObjectReferenceWithOwnership(
TimeSpan time,
CommandChunk command,
int player,
Dictionary<uint, TimeSpan> unitFirstObserved,
Dictionary<int, HashSet<uint>> playerOwnership)
{
foreach (var entry in command.Data)
{
if (entry.Type is not (CommandArgumentType.ObjectId or CommandArgumentType.ObjectId_2))
{
continue;
}
if (entry.Count == 1)
{
var id = (uint)entry.Value;
if (id == 0) continue;
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerOwnership(player, id, playerOwnership);
}
else
{
foreach (var id in (uint[])entry.Value)
{
if (id == 0) continue;
TryRecordFirstObserved(id, time, unitFirstObserved);
RecordPlayerOwnership(player, id, playerOwnership);
}
}
}
}
@@ -375,6 +636,19 @@ namespace AnotherReplayReader.Utils
set.Add(unitId);
}
private static void RecordPlayerOwnership(
int player,
uint unitId,
Dictionary<int, HashSet<uint>> playerOwnership)
{
if (!playerOwnership.TryGetValue(player, out var set))
{
set = new HashSet<uint>();
playerOwnership[player] = set;
}
set.Add(unitId);
}
private static void TryRecordFirstObserved(uint unitId, TimeSpan time, Dictionary<uint, TimeSpan> unitFirstObserved)
{
if (unitId == 0) return;