ai plan v2
This commit is contained in:
+335
-51
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 上下文 → 160K;200K~256K → 100K;更小 → 0(不支持长录像)。
|
||||
/// </summary>
|
||||
public int? ContextBudget { get; set; }
|
||||
|
||||
public Dictionary<string, object> ExtraParameters { get; set; } = [];
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user