using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace AnotherReplayReader.Utils { internal enum AIValidationSeverity { Info, WeakEvidence, Warning, Contradiction, Fatal } internal enum AIValidationIssueKind { InvalidMachineReadableClaims, MissingMachineReadableClaims, MissingAlternative, WeakEvidence, MissingEvidence, InvalidEvidenceLevel, UnitCapabilityContradiction, UnitTimelineContradiction, UnsupportedGameKnowledge, OwnershipMissingEvidence, OwnershipConflict, OwnershipWeakConflict } internal enum AIEvidenceLevel { Confirmed, HighlyLikely, Possible, Uncertain, RuledOut } internal sealed record AIUnitClaim( string UnitId, string Player, string Claim, AIEvidenceLevel EvidenceLevel, ImmutableArray Evidence, ImmutableArray Alternatives, ImmutableArray NeedsConfirmation); internal sealed record AIEventClaim( string Claim, AIEvidenceLevel EvidenceLevel, ImmutableArray Evidence); internal sealed record AITimelineClaim( string Claim, AIEvidenceLevel EvidenceLevel, ImmutableArray Evidence); internal sealed record AIMachineReadableClaims( ImmutableArray UnitClaims, ImmutableArray EventClaims, ImmutableArray TimelineClaims) { public static AIMachineReadableClaims Empty { get; } = new( ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty); } internal sealed record AIValidationIssue( AIValidationSeverity Severity, AIValidationIssueKind Kind, string Message, string? UnitId = null, TimeSpan? Time = null); internal sealed record AIValidationResult( AIMachineReadableClaims Claims, ImmutableArray Issues) { public static AIValidationResult Empty { get; } = new(AIMachineReadableClaims.Empty, ImmutableArray.Empty); // 除 Info 外,任何需要模型修正/降级的验证问题都触发一次隐藏修订; // 一次修订后仍遗留的问题只记录,不再循环请求。 public bool RequiresRevision => Issues.Any(i => i.Severity is AIValidationSeverity.Contradiction or AIValidationSeverity.Fatal or AIValidationSeverity.Warning or AIValidationSeverity.WeakEvidence); public bool HasIssues => !Issues.IsEmpty; } internal static class AIAnalysisValidation { private static readonly Regex _jsonFenceRegex = new( @"```(?:json)?\s*(\{[\s\S]*?\})\s*```", RegexOptions.IgnoreCase | RegexOptions.Compiled); public static AIValidationResult ValidateMachineReadableClaims( string response, ReplayFactIndex? factIndex = null, IReadOnlyDictionary? aiNameToPlayerIndex = null, StructuredKnowledge? structuredKnowledge = null) { var issues = ImmutableArray.CreateBuilder(); var jsonBlocks = ExtractAllJsonObjects(response); if (jsonBlocks.IsEmpty) { issues.Add(new AIValidationIssue( AIValidationSeverity.Warning, AIValidationIssueKind.MissingMachineReadableClaims, "AI 未输出机器可读声明,无法进行自动验证。")); return new AIValidationResult(AIMachineReadableClaims.Empty, issues.ToImmutable()); } var claims = ParseClaims(jsonBlocks, issues); if (claims is null) { issues.Add(new AIValidationIssue( AIValidationSeverity.Fatal, AIValidationIssueKind.InvalidMachineReadableClaims, "所有机器可读声明块都无法解析,当前输出不可用于自动验证。")); return new AIValidationResult(AIMachineReadableClaims.Empty, issues.ToImmutable()); } ValidateClaimSelfConsistency(claims, issues); ValidateUnpackAmbiguity(claims, issues); if (factIndex is not null) { ValidateTimelineConsistency(claims, factIndex, aiNameToPlayerIndex, structuredKnowledge, issues); ValidateSimpleClaimEvidence(claims, factIndex, issues); } return new AIValidationResult(claims, issues.ToImmutable()); } public static string FormatIssues(ImmutableArray issues) { if (issues.IsEmpty) { return "未发现机器可读声明问题。"; } var sb = new StringBuilder(); foreach (var issue in issues) { var unitText = string.IsNullOrWhiteSpace(issue.UnitId) ? string.Empty : $" UnitId={issue.UnitId}"; sb.AppendLine($"[{issue.Severity}/{issue.Kind}]{unitText} {issue.Message}"); } return sb.ToString().TrimEnd(); } private static ImmutableArray ExtractAllJsonObjects(string response) { var result = ImmutableArray.CreateBuilder(); var markerIndex = response.LastIndexOf("[机器可读声明]", StringComparison.OrdinalIgnoreCase); var searchText = markerIndex >= 0 ? response.Substring(markerIndex) : response; var matches = _jsonFenceRegex.Matches(searchText); if (matches.Count > 0) { 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) { result.Add(searchText.Substring(start, end - start + 1)); return result.ToImmutable(); } return ImmutableArray.Empty; } private static AIMachineReadableClaims? ParseClaims( ImmutableArray jsonBlocks, ImmutableArray.Builder issues) { var mergedUnits = new Dictionary(StringComparer.OrdinalIgnoreCase); var mergedEvents = new List(); var mergedTimelines = new List(); var parsedAny = false; 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 解析失败:{ex.Message}")); } } if (!parsedAny) { 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 ReadUnitClaims(JsonElement root, ImmutableArray.Builder issues) { if (!TryGetArray(root, "unitClaims", out var unitClaims)) { return ImmutableArray.Empty; } var result = ImmutableArray.CreateBuilder(); var totalCount = 0; foreach (var item in unitClaims.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) { continue; } totalCount++; if (result.Count >= MaxUnitClaims) { continue; } result.Add(new AIUnitClaim( ReadFlexibleString(item, "unitId"), ReadFlexibleString(item, "player"), ReadFlexibleString(item, "claim"), ReadEvidenceLevel(item, issues), ReadStringArray(item, "evidence"), ReadStringArray(item, "alternatives"), ReadStringArray(item, "needsConfirmation"))); } if (totalCount > MaxUnitClaims) { issues.Add(new AIValidationIssue( AIValidationSeverity.Info, AIValidationIssueKind.InvalidMachineReadableClaims, $"unitClaims 包含 {totalCount} 条声明,仅处理前 {MaxUnitClaims} 条,其余已忽略。")); } return result.ToImmutable(); } private sealed record SimpleClaim( string Claim, AIEvidenceLevel EvidenceLevel, ImmutableArray Evidence); private static ImmutableArray ReadSimpleClaims(JsonElement root, string propertyName, ImmutableArray.Builder issues) { if (!TryGetArray(root, propertyName, out var claims)) { return ImmutableArray.Empty; } var result = ImmutableArray.CreateBuilder(); var maxClaims = propertyName switch { "eventClaims" => MaxEventClaims, "timelineClaims" => MaxTimelineClaims, _ => 50, }; var totalCount = 0; foreach (var item in claims.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) { continue; } totalCount++; if (result.Count >= maxClaims) { continue; } var claim = ReadFlexibleString(item, "claim"); if (string.IsNullOrWhiteSpace(claim) && propertyName == "eventClaims") { claim = ReadFlexibleString(item, "event"); } result.Add(new SimpleClaim( claim, ReadEvidenceLevel(item, issues), ReadStringArray(item, "evidence"))); } if (totalCount > maxClaims) { issues.Add(new AIValidationIssue( AIValidationSeverity.Info, AIValidationIssueKind.InvalidMachineReadableClaims, $"{propertyName} 包含 {totalCount} 条声明,仅处理前 {maxClaims} 条,其余已忽略。")); } return result.ToImmutable(); } private static void ValidateClaimSelfConsistency( AIMachineReadableClaims claims, ImmutableArray.Builder issues) { foreach (var claim in claims.UnitClaims) { if (string.IsNullOrWhiteSpace(claim.UnitId)) { issues.Add(new AIValidationIssue( AIValidationSeverity.Warning, AIValidationIssueKind.InvalidMachineReadableClaims, "Unit claim 缺少 unitId。")); } if (string.IsNullOrWhiteSpace(claim.Claim)) { issues.Add(new AIValidationIssue( AIValidationSeverity.Warning, AIValidationIssueKind.InvalidMachineReadableClaims, "Unit claim 缺少 claim。", claim.UnitId)); } if (claim.EvidenceLevel is AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely && claim.Evidence.IsEmpty) { issues.Add(new AIValidationIssue( AIValidationSeverity.Warning, AIValidationIssueKind.MissingEvidence, "高置信 UnitId 推测缺少 evidence。", claim.UnitId)); } if (claim.EvidenceLevel is AIEvidenceLevel.Possible or AIEvidenceLevel.Uncertain && claim.Alternatives.IsEmpty && claim.NeedsConfirmation.IsEmpty) { issues.Add(new AIValidationIssue( AIValidationSeverity.WeakEvidence, AIValidationIssueKind.MissingAlternative, "低置信 UnitId 推测应提供 alternatives 或 needsConfirmation。", claim.UnitId)); } } } #region Structured evidence parsing internal enum AIEvidenceType { Build, Place, Produce, Sell, Select, Move, Power, Protocol, Unknown } internal sealed record StructuredEvidence( AIEvidenceType Type, string Time, ImmutableArray Parameters, string Raw) { 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], AIEvidenceType.Produce when Parameters.Length >= 2 => Parameters[1], AIEvidenceType.Power when Parameters.Length >= 2 => Parameters[1], AIEvidenceType.Sell when Parameters.Length >= 1 => Parameters[0], AIEvidenceType.Select when Parameters.Length >= 1 => Parameters[0], _ => null, }; public string? GetAssetName() => Type switch { AIEvidenceType.Build or AIEvidenceType.Place or AIEvidenceType.Produce when Parameters.Length >= 1 => Parameters[0], _ => null, }; } private static readonly Regex _structuredEvidenceRegex = new( @"^(build|place|produce|sell|select|move|power|protocol)\|([^|]+(?:\|(?!\|).*)?)$", RegexOptions.IgnoreCase | RegexOptions.Compiled); internal static StructuredEvidence ParseStructuredEvidence(string text) { var match = _structuredEvidenceRegex.Match(text.Trim()); if (!match.Success) { return new StructuredEvidence(AIEvidenceType.Unknown, string.Empty, ImmutableArray.Empty, text); } var type = match.Groups[1].Value.ToLowerInvariant() switch { "build" => AIEvidenceType.Build, "place" => AIEvidenceType.Place, "produce" => AIEvidenceType.Produce, "sell" => AIEvidenceType.Sell, "select" => AIEvidenceType.Select, "move" => AIEvidenceType.Move, "power" => AIEvidenceType.Power, "protocol" => AIEvidenceType.Protocol, _ => AIEvidenceType.Unknown, }; var rest = match.Groups[2].Value; var parts = rest.Split('|'); var time = parts.Length >= 1 ? parts[0].Trim() : string.Empty; var parameters = parts.Skip(1).Select(p => p.Trim()).ToImmutableArray(); return new StructuredEvidence(type, time, parameters, text); } internal static ImmutableArray ParseAllEvidence(ImmutableArray evidenceStrings) { return evidenceStrings .Select(ParseStructuredEvidence) .ToImmutableArray(); } #endregion #region Validation rules private static void ValidateUnpackAmbiguity( AIMachineReadableClaims claims, ImmutableArray.Builder issues) { foreach (var claim in claims.UnitClaims) { if (claim.EvidenceLevel is not (AIEvidenceLevel.Confirmed or AIEvidenceLevel.HighlyLikely)) { continue; } var evidence = ParseAllEvidence(claim.Evidence); var hasUnpack = evidence.Any(e => e.GetSpecialPowerName() is string p && p.IndexOf("UnpackReplaceSelf", StringComparison.OrdinalIgnoreCase) >= 0); if (!hasUnpack) { continue; } var hasPack = evidence.Any(e => e.GetSpecialPowerName() is string p && p.IndexOf("PackReplaceSelf", StringComparison.OrdinalIgnoreCase) >= 0); if (hasPack) { continue; } // 只有“基地车/主基地”这类同时具备 pack/unpack 的实体才存在 MCV vs 矿车的歧义; // 矿车本身只有 unpack,不应被这条规则误伤。 if (!LooksLikeUnpackAmbiguousUnit(claim.Claim)) { continue; } issues.Add(new AIValidationIssue( AIValidationSeverity.WeakEvidence, AIValidationIssueKind.MissingAlternative, $"UnitId {claim.UnitId} 使用了 UnpackReplaceSelf 但证据中无对应 PackReplaceSelf。UnpackReplaceSelf 可能对应基地车展开或矿车展开成指挥中心,建议降低置信度或添加 alternative。", claim.UnitId)); } } private static bool LooksLikeUnpackAmbiguousUnit(string claimText) { if (claimText.IndexOf("MCV", StringComparison.OrdinalIgnoreCase) >= 0 || claimText.IndexOf("基地车", StringComparison.OrdinalIgnoreCase) >= 0) { return true; } var structured = StructuredKnowledge.Instance; if (structured is not null) { foreach (var entity in structured.EntitiesWithTag(KnowledgeTag.Pack)) { if (claimText.IndexOf(entity.AssetName, StringComparison.OrdinalIgnoreCase) >= 0 || (!string.IsNullOrWhiteSpace(entity.DisplayName) && claimText.IndexOf(entity.DisplayName, StringComparison.OrdinalIgnoreCase) >= 0)) { return true; } } } return false; } private static void ValidateTimelineConsistency( AIMachineReadableClaims claims, ReplayFactIndex factIndex, IReadOnlyDictionary? aiNameToPlayerIndex, StructuredKnowledge? structuredKnowledge, ImmutableArray.Builder issues) { foreach (var claim in claims.UnitClaims) { if (string.IsNullOrWhiteSpace(claim.UnitId)) { continue; } if (!uint.TryParse(claim.UnitId, out var unitId)) { continue; } // Check 1: UnitId referenced in claim exists in the replay if (!factIndex.UnitIdFirstObservedTime.ContainsKey(unitId)) { issues.Add(new AIValidationIssue( AIValidationSeverity.Warning, AIValidationIssueKind.InvalidMachineReadableClaims, $"UnitId {claim.UnitId} 在回放数据中从未出现过,AI 可能编造了不存在的 UnitId。", claim.UnitId)); 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; } var evUnitIdStr = ev.GetUnitId(); if (evUnitIdStr is null || !uint.TryParse(evUnitIdStr, out var evUnitId)) { continue; } var powerName = ev.GetSpecialPowerName(); if (powerName is null) { continue; } // Does this UnitId exist in the fact index? if (!factIndex.UnitIdFirstObservedTime.ContainsKey(evUnitId)) { issues.Add(new AIValidationIssue( AIValidationSeverity.Info, AIValidationIssueKind.InvalidMachineReadableClaims, $"证据引用了回放中不存在的 UnitId {evUnitIdStr}。", claim.UnitId)); continue; } // Did this UnitId actually use this special power? 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.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, structuredKnowledge); if (claimLooksLikeBuilder && !isBuilderInReplay) { issues.Add(new AIValidationIssue( AIValidationSeverity.WeakEvidence, AIValidationIssueKind.UnitCapabilityContradiction, $"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); } } } /// 事件/时间线声明只有 claim + evidence,没有 player,因此只校验不依赖归属的事实。 private static void ValidateSimpleClaimEvidence( AIMachineReadableClaims claims, ReplayFactIndex factIndex, ImmutableArray.Builder issues) { foreach (var claim in claims.EventClaims) { ValidateSimpleClaim(claim.Claim, claim.Evidence, "事件声明", factIndex, issues); } foreach (var claim in claims.TimelineClaims) { ValidateSimpleClaim(claim.Claim, claim.Evidence, "时间线声明", factIndex, issues); } } private static void ValidateSimpleClaim( string claimText, ImmutableArray evidenceStrings, string kind, ReplayFactIndex factIndex, ImmutableArray.Builder issues) { foreach (var ev in ParseAllEvidence(evidenceStrings)) { if (ev.Type == AIEvidenceType.Protocol) { var techName = ev.GetTechName(); if (string.IsNullOrWhiteSpace(techName)) { continue; } var allTechChoices = new HashSet( factIndex.PlayerTechChoices.Values.SelectMany(set => set), StringComparer.OrdinalIgnoreCase); if (allTechChoices.Count > 0 && !allTechChoices.Contains(techName)) { issues.Add(new AIValidationIssue( AIValidationSeverity.Warning, AIValidationIssueKind.InvalidMachineReadableClaims, $"{kind}“{claimText}”引用了未在任何玩家选择中观察到的协议“{techName}”。")); } continue; } if (ev.Type == AIEvidenceType.Power) { var unitIdText = ev.GetUnitId(); if (unitIdText is null || !uint.TryParse(unitIdText, out var unitId)) { continue; } var powerName = ev.GetSpecialPowerName(); if (string.IsNullOrWhiteSpace(powerName)) { continue; } if (!factIndex.UnitIdFirstObservedTime.ContainsKey(unitId)) { issues.Add(new AIValidationIssue( AIValidationSeverity.Warning, AIValidationIssueKind.InvalidMachineReadableClaims, $"{kind}“{claimText}”的证据引用了回放中不存在的 UnitId {unitIdText}。", unitIdText)); continue; } if (factIndex.UnitIdSpecialPowers.TryGetValue(unitId, out var actualPowers)) { if (!actualPowers.Contains(powerName)) { issues.Add(new AIValidationIssue( AIValidationSeverity.Contradiction, AIValidationIssueKind.UnitCapabilityContradiction, $"{kind}“{claimText}”声称 UnitId {unitIdText} 使用了“{powerName}”,但该 UnitId 在回放中使用过:{string.Join("、", actualPowers.OrderBy(x => x))}。", unitIdText)); } } else { issues.Add(new AIValidationIssue( AIValidationSeverity.WeakEvidence, AIValidationIssueKind.WeakEvidence, $"{kind}“{claimText}”声称 UnitId {unitIdText} 使用了“{powerName}”,但该 UnitId 未观察到任何特殊能力。", unitIdText)); } continue; } if (ev.Type is AIEvidenceType.Build or AIEvidenceType.Place or AIEvidenceType.Produce or AIEvidenceType.Select or AIEvidenceType.Sell) { var unitIdText = ev.GetUnitId(); if (unitIdText is null || !uint.TryParse(unitIdText, out var unitId)) { continue; } if (!factIndex.UnitIdFirstObservedTime.ContainsKey(unitId)) { issues.Add(new AIValidationIssue( AIValidationSeverity.Warning, AIValidationIssueKind.InvalidMachineReadableClaims, $"{kind}“{claimText}”的证据引用了回放中不存在的 UnitId {unitIdText}。", unitIdText)); } } } } private static void ValidateOwnership( AIUnitClaim claim, uint unitId, int playerIndex, ReplayFactIndex factIndex, ImmutableArray.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.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 GetBomberAssetNames( string claimText, StructuredKnowledge? structuredKnowledge) { var result = new HashSet(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? 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) { if (root.TryGetProperty(propertyName, out array) && array.ValueKind == JsonValueKind.Array) { return true; } array = default; return false; } private static AIEvidenceLevel ReadEvidenceLevel(JsonElement item, ImmutableArray.Builder issues) { var value = ReadFlexibleString(item, "evidenceLevel"); return NormalizeEvidenceLevel(value, issues); } private static AIEvidenceLevel NormalizeEvidenceLevel(string value, ImmutableArray.Builder issues) { value = value.Trim().Replace("_", "").Replace("-", "").Replace(" ", ""); var result = value.ToLowerInvariant() switch { "confirmed" or "确定" => AIEvidenceLevel.Confirmed, "highlylikely" or "high" or "高度可能" => AIEvidenceLevel.HighlyLikely, "possible" or "可能" => AIEvidenceLevel.Possible, "ruledout" or "excluded" or "已排除" => AIEvidenceLevel.RuledOut, _ => (AIEvidenceLevel?)null, }; if (result is not null) { return result.Value; } issues.Add(new AIValidationIssue( AIValidationSeverity.Info, AIValidationIssueKind.InvalidEvidenceLevel, $"无法识别的证据等级 \"{value.Trim()}\",已降级为不确定。")); return AIEvidenceLevel.Uncertain; } private static string ReadFlexibleString(JsonElement item, string propertyName) { if (!item.TryGetProperty(propertyName, out var value)) { return string.Empty; } return value.ValueKind switch { JsonValueKind.String => value.GetString() ?? string.Empty, JsonValueKind.Number => value.GetRawText(), JsonValueKind.True => "true", JsonValueKind.False => "false", _ => string.Empty, }; } private static ImmutableArray ReadStringArray(JsonElement item, string propertyName) { if (!item.TryGetProperty(propertyName, out var value)) { return ImmutableArray.Empty; } if (value.ValueKind == JsonValueKind.String) { var text = value.GetString(); return string.IsNullOrWhiteSpace(text) ? ImmutableArray.Empty : ImmutableArray.Create(text!); } if (value.ValueKind != JsonValueKind.Array) { return ImmutableArray.Empty; } var result = new List(); foreach (var element in value.EnumerateArray()) { var text = element.ValueKind == JsonValueKind.String ? element.GetString() : element.GetRawText(); if (!string.IsNullOrWhiteSpace(text)) { result.Add(text!); } } return result.ToImmutableArray(); } /// /// Check if a claim text describes a builder unit (MCV, Nanocore, etc.). /// Uses structured knowledge (knowledge_units.json) when available, falls /// back to heuristic string matching for backward compatibility. /// private static bool ClaimLooksLikeBuilder( string claimText, StructuredKnowledge? structuredKnowledge) { // Primary: structured knowledge lookup var structured = structuredKnowledge ?? StructuredKnowledge.Instance; if (structured is not null) { foreach (var entity in structured.AllEntities) { if (entity.HasTag(KnowledgeTag.Builder) && claimText.IndexOf(entity.AssetName, StringComparison.OrdinalIgnoreCase) >= 0) { return true; } } } // Fallback: heuristic string matching var keywords = new[] { "MCV", "基地车", "Nanocore", "纳米核心", "builder", "建造者" }; return keywords.Any(kw => claimText.IndexOf(kw, StringComparison.OrdinalIgnoreCase) >= 0); } } }