1115 lines
45 KiB
C#
1115 lines
45 KiB
C#
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<string> Evidence,
|
|
ImmutableArray<string> Alternatives,
|
|
ImmutableArray<string> NeedsConfirmation);
|
|
|
|
internal sealed record AIEventClaim(
|
|
string Claim,
|
|
AIEvidenceLevel EvidenceLevel,
|
|
ImmutableArray<string> Evidence);
|
|
|
|
internal sealed record AITimelineClaim(
|
|
string Claim,
|
|
AIEvidenceLevel EvidenceLevel,
|
|
ImmutableArray<string> Evidence);
|
|
|
|
internal sealed record AIMachineReadableClaims(
|
|
ImmutableArray<AIUnitClaim> UnitClaims,
|
|
ImmutableArray<AIEventClaim> EventClaims,
|
|
ImmutableArray<AITimelineClaim> TimelineClaims)
|
|
{
|
|
public static AIMachineReadableClaims Empty { get; } = new(
|
|
ImmutableArray<AIUnitClaim>.Empty,
|
|
ImmutableArray<AIEventClaim>.Empty,
|
|
ImmutableArray<AITimelineClaim>.Empty);
|
|
}
|
|
|
|
internal sealed record AIValidationIssue(
|
|
AIValidationSeverity Severity,
|
|
AIValidationIssueKind Kind,
|
|
string Message,
|
|
string? UnitId = null,
|
|
TimeSpan? Time = null);
|
|
|
|
internal sealed record AIValidationResult(
|
|
AIMachineReadableClaims Claims,
|
|
ImmutableArray<AIValidationIssue> Issues)
|
|
{
|
|
public static AIValidationResult Empty { get; } =
|
|
new(AIMachineReadableClaims.Empty, ImmutableArray<AIValidationIssue>.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<string, int>? aiNameToPlayerIndex = null,
|
|
StructuredKnowledge? structuredKnowledge = null)
|
|
{
|
|
var issues = ImmutableArray.CreateBuilder<AIValidationIssue>();
|
|
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<AIValidationIssue> 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<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)
|
|
{
|
|
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<string>.Empty;
|
|
}
|
|
|
|
private static AIMachineReadableClaims? ParseClaims(
|
|
ImmutableArray<string> jsonBlocks,
|
|
ImmutableArray<AIValidationIssue>.Builder issues)
|
|
{
|
|
var mergedUnits = new Dictionary<string, AIUnitClaim>(StringComparer.OrdinalIgnoreCase);
|
|
var mergedEvents = new List<AIEventClaim>();
|
|
var mergedTimelines = new List<AITimelineClaim>();
|
|
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<AIUnitClaim> ReadUnitClaims(JsonElement root, ImmutableArray<AIValidationIssue>.Builder issues)
|
|
{
|
|
if (!TryGetArray(root, "unitClaims", out var unitClaims))
|
|
{
|
|
return ImmutableArray<AIUnitClaim>.Empty;
|
|
}
|
|
|
|
var result = ImmutableArray.CreateBuilder<AIUnitClaim>();
|
|
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<string> Evidence);
|
|
|
|
private static ImmutableArray<SimpleClaim> ReadSimpleClaims(JsonElement root, string propertyName, ImmutableArray<AIValidationIssue>.Builder issues)
|
|
{
|
|
if (!TryGetArray(root, propertyName, out var claims))
|
|
{
|
|
return ImmutableArray<SimpleClaim>.Empty;
|
|
}
|
|
|
|
var result = ImmutableArray.CreateBuilder<SimpleClaim>();
|
|
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<AIValidationIssue>.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<string> 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<string>.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<StructuredEvidence> ParseAllEvidence(ImmutableArray<string> evidenceStrings)
|
|
{
|
|
return evidenceStrings
|
|
.Select(ParseStructuredEvidence)
|
|
.ToImmutableArray();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Validation rules
|
|
|
|
private static void ValidateUnpackAmbiguity(
|
|
AIMachineReadableClaims claims,
|
|
ImmutableArray<AIValidationIssue>.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<string, int>? aiNameToPlayerIndex,
|
|
StructuredKnowledge? structuredKnowledge,
|
|
ImmutableArray<AIValidationIssue>.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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>事件/时间线声明只有 claim + evidence,没有 player,因此只校验不依赖归属的事实。</summary>
|
|
private static void ValidateSimpleClaimEvidence(
|
|
AIMachineReadableClaims claims,
|
|
ReplayFactIndex factIndex,
|
|
ImmutableArray<AIValidationIssue>.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<string> evidenceStrings,
|
|
string kind,
|
|
ReplayFactIndex factIndex,
|
|
ImmutableArray<AIValidationIssue>.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<string>(
|
|
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<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)
|
|
{
|
|
if (root.TryGetProperty(propertyName, out array)
|
|
&& array.ValueKind == JsonValueKind.Array)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
array = default;
|
|
return false;
|
|
}
|
|
|
|
private static AIEvidenceLevel ReadEvidenceLevel(JsonElement item, ImmutableArray<AIValidationIssue>.Builder issues)
|
|
{
|
|
var value = ReadFlexibleString(item, "evidenceLevel");
|
|
return NormalizeEvidenceLevel(value, issues);
|
|
}
|
|
|
|
private static AIEvidenceLevel NormalizeEvidenceLevel(string value, ImmutableArray<AIValidationIssue>.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<string> ReadStringArray(JsonElement item, string propertyName)
|
|
{
|
|
if (!item.TryGetProperty(propertyName, out var value))
|
|
{
|
|
return ImmutableArray<string>.Empty;
|
|
}
|
|
|
|
if (value.ValueKind == JsonValueKind.String)
|
|
{
|
|
var text = value.GetString();
|
|
return string.IsNullOrWhiteSpace(text)
|
|
? ImmutableArray<string>.Empty
|
|
: ImmutableArray.Create(text!);
|
|
}
|
|
|
|
if (value.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return ImmutableArray<string>.Empty;
|
|
}
|
|
|
|
var result = new List<string>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
}
|